Compare commits

30 Commits

Author SHA1 Message Date
Yaojia Wang
242a4e0dc1 docs: log Orbit icon rollout across iOS/desktop/tray/web + desktop repackage
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-06 11:54:48 +02:00
Yaojia Wang
86d100a9ad feat(icons): Orbit app icon + template tray icon across all clients
New brand icon (Orbit — a node circling a block-cursor core, amber-gold on
warm-dark, matching the desktop theme):
- iOS: Assets.xcassets/AppIcon.appiconset (opaque 1024, was no icon)
- desktop: resources/icon.png (rounded 1024 → .icns at package time)
- desktop tray: trayTemplate.png/@2x — a proper macOS TEMPLATE (black shapes on
  transparent, system-tinted) replacing the old white blob
- web: public/icon.svg (vector favicon/PWA)
Master SVGs rendered with rsvg-convert.
2026-07-06 11:49:06 +02:00
Yaojia Wang
3492f0cf1f fix(projects): drop parent folders; attribute sessions to the deepest project
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
The projects panel listed bare parent folders (~, ~/Documents) as projects and
a single session lit up every ancestor project's 'Active now'. dropParentFolders
filters non-git entries that merely contain other listed projects; assignSessions
attributes each live session to the DEEPEST containing project (detail page keeps
prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
2026-07-06 11:00:45 +02:00
Yaojia Wang
d36deb6922 fix(ios): dark-appearance-first + readable dirty badge (gold-on-light contrast)
Amber-gold accent is designed for the desktop's warm-dark background; on light
mode gold text/badges wash out. Match the desktop (dark theme by default):
.preferredColorScheme(.dark) at the root. DirtyBadge becomes a soft filled
capsule (amber on amber-18%) instead of amber-on-quaternary, readable in any mode.
2026-07-06 08:10:34 +02:00
Yaojia Wang
a887638557 docs: document native clients — full iOS/iPad feature README + root Clients section
ios/README: add a comprehensive Features section (P0 pocket cockpit, P1 walk-away
complete, iPad adaptive split, design system, verification status); note min iOS
17, both-idiom test commands, P1 server touch-points.
root README: add a Clients section covering the iOS/iPad app, desktop Electron
shell, and the pre-production relay stack (with Tailscale as the remote-access
path today).
2026-07-06 08:10:33 +02:00
Yaojia Wang
d01a69eb72 feat(ios): switch accent to desktop amber-gold theme (was indigo)
Match the desktop/web theme (public/style.css): accent #E3A64A gold (dark) /
#C9892F (light) replacing the indigo, +onAccent #1A1305 dark ink for gold-fill
buttons, +accentSoft. Status colors realigned to the web warm palette
(#46D07F/#F5B14C/#FF6B6B). Terminal canvas fixed to the desktop warm-dark
(#100F0D bg / #ECE9E3 fg) with gold caret/selection. DSButtonStyle primary now
uses onAccent (dark) instead of white for contrast on gold. All via the single
accent token + DS palette — no scattered edits.

Verified: iPhone 16 290 + iPad Pro 11 290 tests green (DesignSystemTests assert
the exact gold + web-status RGB in both schemes).
2026-07-06 07:25:32 +02:00
Yaojia Wang
c44725618c docs(ios): log comprehensive UX/UI polish (design system, 8.5/10, 290 tests both sims) 2026-07-05 22:01:27 +02:00
Yaojia Wang
660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.

Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).

Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
2026-07-05 22:00:31 +02:00
Yaojia Wang
823432b1c8 feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
2026-07-05 19:58:30 +02:00
Yaojia Wang
77502ec4fe feat(ipad): T-iPad-1 — device family [1,2] + iPad orientations + CI leg
Opens WebTerm to iPhone + iPad: TARGETED_DEVICE_FAMILY 1→1,2 (project + all
targets), iPad gets all four orientations (~ipad key), ios.yml gains an
ipad-tests leg (iPad Pro 11 sim). Built plist verified UIDeviceFamily [1,2];
app launches natively full-screen on iPad Pro 11 sim (no letterbox). Adaptive
layout (NavigationSplitView) lands in T-iPad-2. Plan: docs/PLAN_IOS_IPAD.md.
2026-07-05 18:29:30 +02:00
Yaojia Wang
9c0097a305 docs(ios): P1 complete — T-iOS-30 acceptance + security re-audit both PASS (zero findings)
Part A: 261 pkg + 247 app + 10 integration re-run green; boot-crash fix re-verified (3 launches,
0 crashes, permission prompt reached); simctl push renders minimized WEBTERM_GATE banner; F-iOS-15
server-slice (distinct lastOutputAt) executed; old-server compat degradation verified against the
user's live 127.0.0.1:3000 (no unread dot, stuck red dot, correct meta)
Part B: APNs payload minimization holds; capability token clean end-to-end (validate→use→never
persisted→single-use→zero in logs); deep-link 50+ fuzz all safe; lock-screen .authenticationRequired
green; prefs empty-base guard intact; P1-B attack surface swept clean
P1 total: 518 automated checks + server 1470 green; server changed only at the 2 declared §0.3
touch-points. Real-device/APNs items DEFERRED (need device + paid account)
2026-07-05 16:42:50 +02:00
Yaojia Wang
f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00
Yaojia Wang
4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00
Yaojia Wang
aa956fcbb4 feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening
T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both
PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists.
Fixes (1 MED + 3 LOW, all closed):
- WebTermUITests: the single scripted happy path (manual-entry pair → list → attach →
  KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side
  behavior==allow assertion). Green twice locally (fresh + already-paired branches).
- ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg
  (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO
  from app-tests leg (unsigned hosts get -34018 from the real keychain — measured)
- URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes)
- TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only)
Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/
untouched; P0 complete (19/19 tasks)
2026-07-05 02:16:52 +02:00
Yaojia Wang
cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00
Yaojia Wang
5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
2026-07-05 00:13:14 +02:00
Yaojia Wang
5c8c87fb7a chore(ios): add WebTermTests unit-test bundle target (W3 coordination point)
project.yml gains a bundle.unit-test target hosted by the app, wired to all
5 local packages (TestSupport test-doubles included) so T-iOS-11..14 VM tests
run via xcodebuild test. Coverage gate still counts only the 4 packages (§9).
2026-07-04 23:00:11 +02:00
Yaojia Wang
a2b14ab6e7 feat(ios): W2 connection core — URLSessionTermTransport + SessionEngine actor
T-iOS-9: TermTransport impl (Origin single-source, 16MiB from Tunables, re-arm loop,
EMSGSIZE(40)/ENOBUFS(55)→typed replayTooLarge, delegate-driven state, RFC6455 scripted test server), 11 tests
T-iOS-10: SessionEngine actor (attach-first ordering, generation guard, terminal states never backoff,
exactly-once digest, gate canDecide second line, defensive input validation), 20 tests
Verified: 209 tests green across 5 packages; SessionCore own-src coverage 96.68%; 4/4 semantic invariants
audited in source; frozen contracts untouched
2026-07-04 22:55:56 +02:00
Yaojia Wang
95438cdc12 feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
2026-07-04 21:53:41 +02:00
Yaojia Wang
2ab93c9682 chore(ios): wire TestSupport test-dependency into SessionCore/APIClient manifests (W1 coordination point) 2026-07-04 21:21:09 +02:00
Yaojia Wang
cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00
Yaojia Wang
9b41ffa574 docs(ios): iOS client implementation plan (PLAN_IOS_CLIENT.md) + progress log entry 2026-07-04 20:23:25 +02:00
Yaojia Wang
ba85871227 feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test
New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
2026-07-04 14:13:04 +02:00
Yaojia Wang
3d17bed322 docs(relay): Phase 0 runnable-relay build spec (PLAN_RELAY_RUN_PHASE0.md)
File-level plan for a single-host `npm start` relay: new relay-run/ package wiring
the real P4/P5 + in-memory control-plane + self-signed TLS + agent + relay-web.
Seam files to read, 9-step build order, verify steps. Executable spec for a fresh
build session; reuses the e2e/harness wiring.
2026-07-04 13:49:09 +02:00
Yaojia Wang
340ea48478 docs(relay): deployment build & ops plan (DEPLOY_RELAY.md)
Grounded assessment that the relay service is pre-production (every cross-package
seam is a fail-closed stub, no server/TLS/DB/orchestration). Documents the target
topology + trust boundaries, the 7 wiring gaps to close (P1 socket server, P5
injection into P1/P3, pg/redis stores, real KMS CA custody, agent/relay-web build,
F2 principal + F6 replay-epoch residuals), a phased build order (dev-run → staging
→ prod), an accurate per-service env reference, infra checklist, and an ops runbook.
2026-07-03 14:16:30 +02:00
Yaojia Wang
c0279fd6e2 Merge security/relay-auth-audit-fixes: relay-auth/relay-e2e security audit
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Two-round multi-agent security audit of the rendezvous-relay E2E/auth core.
Fixes 6 confirmed findings (F1–F6) with regression tests, adds a cross-package
e2e adversarial security harness (dynamic re-validation), and the audit report.
534 tests green across the affected packages; tsc clean. See docs/REVIEW_RELAY_SECURITY.md.
2026-07-02 16:41:38 +02:00
Yaojia Wang
0ad7c31549 docs(relay): security audit report + progress log for the relay findings
REVIEW_RELAY_SECURITY.md: two-round multi-agent audit — F1–F6 with fix plan/
resolution, refuted-but-recorded items, and the e2e harness coverage table.
PROGRESS_LOG.md: cross-session record of the audit + fixes + harness.
2026-07-02 16:41:19 +02:00
Yaojia Wang
ad5cf06207 test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through
in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically
re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/
single-use — every assertion runs the production code path. 21 tests green, tsc
clean. node_modules via symlinks (gitignored); no npm install needed.
2026-07-02 16:41:19 +02:00
Yaojia Wang
3020184054 fix(relay): close F6 replay K_content nonce reuse via per-generation epoch-in-key
The recoverable replay key was stable per (hostContentSecret, sessionId) while the
agent-side sealer resets its deterministic-nonce seq to 0 on every restart/re-attach
→ two sealer generations sealed distinct plaintext under the SAME (key, nonce).

Add a required 'epoch' to ReplayKeyParams, fold it into the K_content HKDF salt
(sessionId U+001F epoch), mint a fresh epoch per createReplaySealer generation and
expose it, and thread it through ReplaySource so the browser re-derives the matching
key. Fresh epoch per generation ⇒ fresh key ⇒ seq=0 can never collide; recoverability
within a generation is preserved.

Touches relay-contracts/relay-e2e/agent/relay-web. Green: contracts 81, e2e 78,
agent 133, web 99; tsc clean. Regression proves same seq-0 nonce, different key.
2026-07-02 16:41:19 +02:00
Yaojia Wang
a09c131539 fix(relay-auth): close 5 pre-production security findings (F1–F5)
F1 (HIGH): bind step-up freshness to the required method (stepUpMethod) so a
  login-time or weaker (TOTP) factor can no longer satisfy a passkey requirement.
F2 (HIGH): make the step-up gate host-driven and fail-closed on connect+reattach
  (deny when policy.required and the principal is missing/stale/wrong-method).
F3 (MED): bind the WebAuthn assertion to the stored credential (credentialId
  cross-check + forward publicKey/credentialId to the verifier).
F4 (LOW): make verifyDpopProof totally fail-safe (no unhandled throw), wrap the
  authz boundary into a clean audited 401, and validate cnfJkt format at issue.
F5 (LOW): return newSignCount from finishAuthentication so the caller can persist
  the advanced counter (WebAuthn clone detection).

All fixes ship regression tests. relay-auth: 122 tests green, tsc clean.
2026-07-02 16:41:18 +02:00
260 changed files with 36054 additions and 109 deletions

220
.github/workflows/ios.yml vendored Normal file
View File

@@ -0,0 +1,220 @@
# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
#
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
# iPhone 16 simulator): ViewModels/components of the app glue layer.
# 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
# is the standing anti-drift gate between the Swift-replicated protocol
# and the server implementation — hence the src/** path triggers below.
name: ios
on:
push:
paths:
- "ios/**"
- ".github/workflows/ios.yml"
# integration layer guards client<->server protocol drift:
- "src/**"
- "package.json"
- "package-lock.json"
pull_request:
paths:
- "ios/**"
- ".github/workflows/ios.yml"
- "src/**"
- "package.json"
- "package-lock.json"
jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
# tests below without a gate (test doubles are excluded from the gate).
package-tests:
runs-on: macos-15
strategy:
fail-fast: false
matrix:
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test + own-sources coverage gate (>= 80%)
run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }}
testsupport-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
run: swift test --package-path ios/Packages/TestSupport
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
app-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
# real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \
test
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
# runner); the Swift ServerHarness then boots the server itself.
integration-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
with:
node-version: 22
- name: npm ci (builds node-pty native module)
run: npm ci
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
run: swift test --package-path ios/IntegrationTests
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
ui-test:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
with:
node-version: 22
- name: npm ci (builds node-pty native module)
run: npm ci
- name: Start web-terminal server on loopback
run: |
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
echo $! > uitest-server.pid
ready=0
for _ in $(seq 1 30); do
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "server did not come up on 127.0.0.1:3217" >&2
cat uitest-server.log >&2
exit 1
fi
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
# The 输入 step taps the KeyBar, which is the terminal's
# inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> into the test-runner process);
# passing it as a KEY=VALUE argument makes it a build setting, which
# never reaches the runner (verified empirically, 2026-07-05).
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
# the data-protection keychain rejects every SecItem call (-34018) and
# pairing dies at "保存到本机失败" (verified empirically, run 6).
# Simulator builds sign locally without any certificate.
env:
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' test
- name: Server log + shutdown
if: always()
run: |
cat uitest-server.log || true
kill "$(cat uitest-server.pid)" 2>/dev/null || true
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
# Xcode 16.x can build against it. Local machines are NOT expected to
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
# fail the job (no silent pass).
ios17-floor-tests:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain)
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
id: sim17
run: |
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
if [ -z "$runtime" ]; then
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
echo "runtime=" >> "$GITHUB_OUTPUT"
exit 0
fi
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
if: steps.sim17.outputs.runtime != ''
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test

View File

@@ -49,6 +49,36 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
---
## Clients
The browser is the reference client; two native clients and a remote-access
service are also in the repo (all consume the same wire protocol — the server
stays a byte-shuttle).
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
native terminal with scrollback replay, QR/manual pairing (Keychain), the
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
changes; P1 adds two declared additive touch-points. See
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
embeds this Node server + node-pty** (all-in-one), for native notifications,
tray, deep links and launch-at-login. Design in
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
verified on Apple Silicon.)*
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
**Tailscale** (§ Security & deployment below).
---
## Quick start
### Prerequisites

View File

@@ -13,7 +13,19 @@
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
* NEVER logged, NEVER sent to the relay (INV2/INV9).
*
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
* key) is preserved.
*/
import { randomUUID } from 'node:crypto'
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
@@ -23,13 +35,20 @@ export interface ReplayCrypto {
}
export interface ReplaySealer {
/**
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
* with the ring buffer / replay stream so the browser re-derives the matching key.
*/
readonly epoch: string
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
seal(plaintext: Uint8Array): E2EEnvelope
}
/**
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
* Build a per-(host, session) replay sealer for ONE generation. A FRESH `epoch` is minted per call
* and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
*/
export function createReplaySealer(
hostContentSecret: Uint8Array,
@@ -37,9 +56,11 @@ export function createReplaySealer(
alg: AeadAlg,
crypto: ReplayCrypto,
): ReplaySealer {
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
const epoch = randomUUID()
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
let seq = 0n
return {
epoch,
seal(plaintext: Uint8Array): E2EEnvelope {
const env = crypto.sealReplayFrame(key, seq, plaintext)
seq += 1n

View File

@@ -107,6 +107,7 @@ describe('createE2ETransform (T15)', () => {
function fakeReplay(): ReplaySealer & { calls: number } {
const r = {
calls: 0,
epoch: 'test-epoch',
seal(_pt: Uint8Array) {
r.calls += 1
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }

View File

@@ -2,14 +2,24 @@ import { describe, expect, it, vi } from 'vitest'
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
/**
* Fake AEAD. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
* (a UUID's first char is a hex digit 0x300x66 → always nonzero, so the marker is always hidden).
*/
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
const derivations: ReplayKeyParams[] = []
const keys: Uint8Array[] = []
return {
derivations,
keys,
deriveContentKey(params: ReplayKeyParams): AeadKey {
derivations.push(params)
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`)
const tag = new TextEncoder().encode(
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
)
keys.push(tag)
return tag as unknown as AeadKey
},
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
@@ -23,12 +33,26 @@ function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
const SECRET = new Uint8Array([1, 2, 3, 4])
describe('createReplaySealer (T19, FIX 3)', () => {
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
const c1 = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
const c2 = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
expect(c1.derivations[0]).toEqual(c2.derivations[0])
it('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
expect(c.derivations).toHaveLength(1)
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
})
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
const browser = fakeCrypto()
const browserKey = browser.deriveContentKey({
hostContentSecret: SECRET,
sessionId: 'sess-1',
alg: 'aes-256-gcm',
epoch: sealer.epoch,
}) as unknown as Uint8Array
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
})
it('a different sessionId → a different key (per-session separation)', () => {
@@ -38,6 +62,23 @@ describe('createReplaySealer (T19, FIX 3)', () => {
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
})
it('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
const c = fakeCrypto()
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// Fresh epoch per generation…
expect(gen2.epoch).not.toBe(gen1.epoch)
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
expect(s1.seq).toBe(0n)
expect(s2.seq).toBe(0n)
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
})
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
const marker = new TextEncoder().encode('SECRET-MARKER')
@@ -50,7 +91,7 @@ describe('createReplaySealer (T19, FIX 3)', () => {
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
// model a live seal with a different key byte
// model a live seal with a different key byte (0x11 is below the 0x300x66 hex-digit epoch prefix)
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
const rep = replay.seal(new Uint8Array([0x41, 0x42]))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 861 KiB

After

Width:  |  Height:  |  Size: 162 KiB

168
docs/DEPLOY_RELAY.md Normal file
View File

@@ -0,0 +1,168 @@
# Deploying the rendezvous-relay service — build & ops plan
> **Status: PRE-PRODUCTION — not runnable as-is.** The 7 relay packages (P1P6 + contracts) are
> libraries wired through **injection seams that currently hold fail-closed stubs**. There is no
> server process, no TLS/subdomain ingress, no Postgres/Redis/KMS wiring, and no orchestration.
> "Deploying" the relay means **building the integration + infra layer** the packages were designed
> to receive. This doc is the buildable spec: architecture, the seam-by-seam gaps, a phased order,
> the env/config reference, and the ops runbook. Security posture of the packages themselves is
> covered in [REVIEW_RELAY_SECURITY.md](./REVIEW_RELAY_SECURITY.md) (findings F1F6 fixed).
---
## 1. Target architecture
A **rendezvous relay**: the agent runs on the user's own host (no inbound ports there) and **dials
out** to the relay; the browser connects to a per-tenant subdomain; the relay muxes the two and does
an **opaque byte splice** — it only ever sees ciphertext (INV2). End-to-end crypto (P4) is between
browser and agent; auth/tenant-isolation (P5) gates every upgrade; the control-plane (P3) is the
registry + CA + revocation publisher.
```
Browser (P6, xterm) User's host machine
│ WSS <sub>.term.<domain> │
│ capability token (subprotocol/cookie) + DPoP │ shell / node-pty
▼ ▲ loopback (opaque, INV11)
┌──────────────────────────┐ agent mux (mTLS, ┌────┴───────────────┐
│ RELAY DATA PLANE (P1) │◀──SPIFFE cert)─────────│ AGENT (P2) │
│ term-relay/data-plane │ dial-OUT │ dist/cli.js, ws │
│ • TLS + WSS ingress │ │ E2E host endpoint│
│ • onUpgrade → P5 authz │ │ replay sealer │
│ • subdomain → hostId │ └────────────────────┘
│ • OPAQUE splice (INV2) │ ▲ enroll/pair
│ • Redis sub relay:revoc. │ │
└───────┬──────────────────┬┘ │
│ P5 authz (lib) │ Redis pub/sub │
▼ ▼ │
┌──────────────────┐ ┌─────────┐ ┌──────────────────────────┴─┐
│ relay-auth (P5) │ │ Redis │◀──│ CONTROL PLANE (P3) │
│ tokens+DPoP, │ │ relay: │ │ control-plane, Fastify │
│ step-up, mTLS/ │ │ revoc. │ │ • accounts/hosts/sessions │
│ SPIFFE, revoke │ └─────────┘ │ • pairing + /enroll (CA) │──▶ Postgres
└──────────────────┘ │ • issues agent SPIFFE certs│──▶ KMS (CA key)
│ • publishes KillSignals │
└─────────────────────────────┘
```
**Trust boundaries:** the relay is UNTRUSTED for payload confidentiality (E2E, INV2); it IS trusted
for availability + authz enforcement. The control-plane holds the CA (must be KMS-custodied). Agents
authenticate to the relay with SPIFFE mTLS; browsers authenticate with short-lived capability tokens
(minted by P5 after human auth) bound to a DPoP key. Never expose any port without TLS.
---
## 2. Components & runtime
| Pkg | Role | Runtime today | External deps |
|---|---|---|---|
| **relay-contracts** | frozen schemas/codecs | library | — |
| **relay-e2e (P4)** | browser↔agent E2E crypto | library (isomorphic) | @noble/* |
| **relay-auth (P5)** | authz, tokens+DPoP, step-up, mTLS, revoke | library ✅ audited | — |
| **term-relay (P1)** | stateless relay data-plane + mux | **library — no server** | (needs `ws`, `tls`) |
| **control-plane (P3)** | accounts/hosts/pairing/CA/revocation | Fastify app, **in-memory default** | fastify, **pg**, **ioredis**, KMS |
| **agent (P2)** | host agent: dial, pair, mTLS, E2E, replay | CLI `web-terminal-agent``dist/cli.js` | ws |
| **relay-web (P6)** | browser bundle (xterm, preview) | needs `build` + static serve | @xterm/* |
---
## 3. The wiring gaps (what must be BUILT to deploy)
Each is an injected seam currently holding a safe stub. In rough dependency order:
1. **P1 socket server (biggest gap).** `term-relay/data-plane/relay-node.ts` takes `WebSocketLike`
+ `AgentListener` as **injected seams** — there is no actual listener. Build a `relay-run/` entry
that: (a) terminates browser **WSS** on `BIND_PORT` and adapts each socket to `WebSocketLike`;
(b) accepts agent **mTLS** connections on `AGENT_BIND_PORT` (verify SPIFFE client certs against
`AGENT_CA_CHAIN_PATH`) and adapts them to `AgentListener`/mux; (c) calls `authorizeUpgrade`
(→ P5 `onUpgrade`) and splices. Wire the Redis `relay:revocations` subscriber to `closeStream`.
2. **Inject P5 into P1 + P3.** P1's `Authorizer` must call relay-auth `onUpgrade`/`onReattach` with a
real `EnforceDeps` (registries backed by P3, revocation store, buckets, audit). P3's
`refuseAllVerifier` must be replaced with relay-auth `verifyCapabilityToken` + the real
`CAPABILITY_SIGN_PUBKEY_B64`. **Close F2 here:** the P1 upgrade adapter currently hardcodes
`principal: null` — for any host with a step-up policy it must resolve + pass the authenticated
principal, else those hosts (correctly) 403. See [[relay-stepup-needs-principal-wiring]].
3. **P3 durable stores.** Swap `createMemoryStores` for the Postgres adapter (accounts/hosts/sessions
registries) + run migrations; point the revocation bus at real Redis (`ioredis`).
4. **Real KMS for the CA key.** `boot/ca-wiring.ts` uses a dev in-process Ed25519 signer ("NOT a real
KMS"). Provide a `KmsResolver` backed by cloud KMS/HSM; the intermediate key must be
non-exportable (§3.1). `CA_INTERMEDIATE_KMS_KEY_REF` is the ref.
5. **Agent build + config.** Build `dist/cli.js`; configure `RELAY_URL`/`ENROLL_URL`/`SUBDOMAIN`/
`HOST_ID`/`LOCAL_TARGET_URL`/`STATE_DIR`; enroll (redeem pairing code → SPIFFE cert +
`hostContentSecret`).
6. **relay-web build + serve.** `npm --prefix relay-web run build`; serve the bundle from each tenant
subdomain (same origin as the WSS endpoint so Origin/CSWSH checks pass).
7. **F6 replay transport (residual).** The recoverable-replay ring buffer must **persist each
generation's `epoch` and serve the epoch matching those exact frames** (`relay-web/.../manage-page.ts`
`loadReplay` is still a throwing stub). Until wired, the preview/replay feature is inert (fail-closed).
---
## 4. Phased build order
- **Phase 0 — single-host dev run (one process, self-signed).** Reuse the `e2e/harness` wiring:
a `ws` server hosting the relay-node, control-plane with in-memory stores, P4/P5 wired for real,
self-signed TLS, agent dialing `localhost`, relay-web served locally. Goal: an actually-running
end-to-end relay to click through. No pg/redis/KMS.
- **Phase 1 — single-tenant staging.** Real Postgres + Redis; real (staging) TLS cert for one
subdomain; close F2 principal wiring; agent on a second machine dialing in; behind Tailscale.
- **Phase 2 — multi-tenant production.** Wildcard TLS `*.term.<domain>` + subdomain routing; real
KMS CA custody; horizontal relay nodes (stateless, share Redis); migrations/rollback; metering;
F6 replay transport; monitoring/alerting on the audit sink + revocation latency budget.
---
## 5. Environment / config reference (accurate to the code)
**control-plane (P3)**`control-plane/src/env.ts`, all required unless defaulted:
`PG_URL`, `REDIS_URL`, `CAPABILITY_SIGN_PUBKEY_B64` (P5 verify pubkey), `CA_INTERMEDIATE_KMS_KEY_REF`,
`CA_INTERMEDIATE_CERT_PATH`, `NODE_MTLS_TRUST_BUNDLE_PATH`, `BASE_DOMAIN`, `HEARTBEAT_TTL_SEC`=15,
`PAIRING_TTL_SEC`=600, `PAIRING_MAX_REDEEM_ATTEMPTS`=5.
Routes: `POST /accounts`, `POST /accounts/:id/pairing-codes`, `POST /accounts/:id/status`,
`GET /accounts/:id/hosts`, `DELETE /hosts/:hostId`, `POST /enroll`. (No `start` script / listen entry
exists yet — add one.)
**term-relay data-plane (P1)**`term-relay/data-plane/config.ts`:
`BASE_DOMAIN`, `BIND_HOST`, `BIND_PORT` (browser WSS), `TLS_CERT_PATH`, `TLS_KEY_PATH`,
`AGENT_BIND_PORT`, `AGENT_CA_CERT_PATH`, `AGENT_CA_CHAIN_PATH` (verify agent mTLS), `RELAY_NODE_ID`,
`HEARTBEAT_INTERVAL_MS`, `INITIAL_WINDOW_BYTES`, `MAX_FRAME_BYTES`, `ROUTE_TTL_MS`. (Library — the
process that reads this config and listens must be built, gap #1.)
**relay-auth (P5)**`RELAY_AUTH_VERIFY_PUBKEY` (base64url raw Ed25519) via `loadVerifyKeyFromEnv`;
`RELAY_TRUST_DOMAIN` for SPIFFE.
**agent (P2)**`RELAY_URL`, `ENROLL_URL`, `HOST_ID`, `SUBDOMAIN`, `LOCAL_TARGET_URL`, `STATE_DIR`.
---
## 6. Infra checklist
- [ ] **TLS**: browser-facing wildcard cert `*.term.<domain>` (Phase 2) or per-subdomain (Phase 1);
separate CA + trust bundle for **agent mTLS** (`AGENT_CA_*` / `NODE_MTLS_TRUST_BUNDLE_PATH`).
- [ ] **DNS**: wildcard `*.term.<domain>` → relay node(s); `<control-plane host>`.
- [ ] **Postgres**: accounts/hosts/sessions schema + migrations (adapter unbuilt).
- [ ] **Redis**: `relay:revocations` pub/sub (P3 publishes, P1 subscribes) — the INV12 teardown path.
- [ ] **KMS/HSM**: non-exportable CA intermediate signing key (`CA_INTERMEDIATE_KMS_KEY_REF`).
- [ ] **Ingress for agents**: agents dial OUT (mTLS) — expose only `AGENT_BIND_PORT` + browser `BIND_PORT`.
- [ ] **Orchestration**: Dockerfiles + compose/k8s; relay nodes are stateless (crash-safe, INV7).
## 7. Security/ops runbook (deploy invariants)
- **Origin/CSWSH**: the browser WSS handshake MUST validate Origin against the tenant subdomain
(relay-auth `onUpgrade` step 1 enforces it — pass the correct `allowedOrigins`). Never disable.
- **Never expose without TLS**; the relay hands a shell to whoever authenticates. Prefer Tailscale
for the agent path.
- **Key custody (INV9)**: P5 signing key and the CA key never in process memory / logs; KMS refs only.
- **Revocation latency (INV12)**: monitor time from `revoke()` → stream teardown; alert on budget miss.
- **mTLS trust anchors**: agent certs verified against a **pinned** CA bundle — never a peer-supplied
chain (relay-auth `verifyAgentCert` gates on the registry too).
- **Audit sink (INV10)**: zero-payload; ship to append-only storage; alert on `deny` spikes /
`cross-tenant-attempt`.
- **Close before prod**: F2 principal wiring (gap #2) and F6 replay-epoch transport (gap #7).
---
## 8. TL;DR
There is no one-command deploy today. Minimum to get a *running* relay = **Phase 0** (gaps #1, #2, #5,
#6 with in-memory stores + self-signed TLS). Production = **all gaps** + Postgres/Redis/KMS/wildcard-TLS
+ orchestration. The security-critical *logic* is done and audited; what remains is integration + infra.

948
docs/PLAN_IOS_CLIENT.md Normal file
View File

@@ -0,0 +1,948 @@
# PLAN_IOS_CLIENT.md — iOS 原生客户端SwiftUI + SwiftTerm"口袋驾驶舱"
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS其余 HTTP 轮询服务器零改动P0 零触点P1 仅声明的附加触点,见 §0.3**。
> 状态:**规划中2026-07-04未开工**。
> 本文是「怎么做」的蓝图,配合 [TECH_DOC.md](./TECH_DOC.md)why+ [ARCHITECTURE.md](./ARCHITECTURE.md)how桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md);远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG
> **G1 日志铁律**`PROGRESS_LOG.md` 由 **orchestrator 独写**,不在任何任务的 `Owns:` 里;被派的 subagent **不写 LOG**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
---
## 0. 目标与范围
### 做什么
一个 iPhone 原生 AppiOS 17+Swift 6 language mode面向核心场景**给 Claude Code 发个任务走开,手机把你叫回来,两次手势处理完**。
- **完整可交互终端**SwiftTerm 渲染attach 即回放 ring-buffer 全量 scrollback服务器仍是"字节搬运工"App 端 `feed()` 原样喂字节。
- **会话跨断线存活**:杀 App / 切后台 / 断网 → 重新 attach 即恢复;`sessionId` 按 host 持久化。**前台会话一条活 WS其余会话靠 HTTP 轮询**。
- **一眼看全glance list**:会话列表 = chooser + dashboard 合并——状态点working/waiting/idle/stuck、telemetry 芯片cost/context/PR带 staleness TTL、swipe-to-kill、下拉刷新。
- **远程审批THE steering primitive**tool gate 的 Approve/Reject 横幅 + plan gate 三选一Approve+Auto / Approve+Review / Keep Planninggate 到达触发 haptics。
- **"离开期间发生了什么"digest**:重连后终端顶部渲染 away-digest来源 `GET /live-sessions/:id/events`)。
- **主机找得到手机**P0 复用**既有** ntfy 桥(`npm run setup-hooks` 已内置,`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装零新代码NEEDS-INPUT/DONEP1 换 APNs + 锁屏 Allow/Denynotification action → **Face ID/通行码确认(`.authenticationRequired`**`POST /hook/decision`,仍不启动 App UI
- **移动键位补全**native `inputAccessoryView` key-barEsc / Shift+Tab / 方向 / Ctrl-C…字节表逐字节复刻 `public/keybar.ts`+ 硬件键盘 `UIKeyCommand`
- **配对即用**:扫 web UI 的 QR`qr.ts` origin URL——**扫码结果是不可信外部输入,先显式确认解析出的 host 再发起任何网络请求**,见 T-iOS-12或手输 URL配对探针 + 可操作的错误话术("Local Network 权限被拒""Origin 被拒——在主机加 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`,与 App 连接的 URL 一致")。
### 不做v1 范围外)
- **鉴权/登录、多用户隔离**沿用现有威胁模型TECH_DOC §7——LAN-only + 推荐 Tailscale严禁公网暴露。
- **relay / E2E 加密通道**relay 栈仍 pre-production无可跑服务端见 [DEPLOY_RELAY.md](./DEPLOY_RELAY.md)),且**已审计的 TS 加密实现绝不能随手用 Swift 重写**F6 这类 deterministic-nonce 交互连 99-agent 审计第一轮都漏了)。详见 §5.5。
- **WKWebView 套 xterm.js**两位评审一致否决——WKWebView 卡顿与 content-process 被杀正是本 App 要逃离的痛。
- **iPad 优化布局、Live Activities、Widget、Mac Catalyst**后续再议Live Activities 依赖 APNs `liveactivity` pushP1 之后才有条件)。
- **后台常驻 WS**iOS 切后台数秒即 suspend做不到也不装能做到设计基石就是 foreground-reattach。
- **前端/服务器重构**`public/``src/` 一行不改(触点见 §0.3)。
### 已被验证掉的大风险(关键复用点,引现有代码为证)
服务器**早就为"多客户端、断线重连"设计好了**iOS 客户端只是又一个说同一协议的端:
```ts
// 会话/连接解耦:WS 断开只 detach,PTY 继续跑;最后一个客户端离开才开始计 idle
// src/server.ts:791-800
// attach(sessionId) → 全量回放 ring-buffer snapshot() 再续实时流
// src/session/session.ts:158-170;回放前缀 soft-reset \x1b[0m(src/types.ts:167-170)
// JOIN(mirror)语义:新 attach 加入镜像,绝不踢掉其他客户端
// src/session/manager.ts:108-174;PTY 尺寸 latest-writer-wins(src/session/session.ts:200-211)
// 协议解析永不抛异常,非法帧静默丢弃 —— 客户端照抄这个韧性
// src/protocol.ts:45-86;src/server.ts:698-701
```
已核实的平台事实详见任务内引用SwiftTerm v1.13+MIT、SPM、商业 App 验证过)`TerminalViewDelegate.send/sizeChanged` 与本项目字节协议 1:1 对应;`URLSessionWebSocketTask` 可自定义 `Origin` header不在 reserved-header 列表)——但此为 MED 置信度,**Day-1 spike 对真服务器实测**T-iOS-2
### 服务器触点server touch-points学 relay 计划的惯例:声明而非隐藏)
本计划对本仓库 `src/``public/` 的改动**只有以下 P1 两处均为增量P0 零触点**
| 阶段 | 触点 | 性质 |
|---|---|---|
| P0 | **零新代码** —— 复用**已随 `npm run setup-hooks` 发布**的 ntfy 桥(安装逻辑 scripts/setup-hooks.mjs:227-238`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装NEEDS-INPUT=high、DONE=lowenv 已被 src/session/session.ts:99-100 转发进每个会话。STUCK 不在 P0 信号列表stuck 是服务器 `manager.sweepStuck` 派生态,无对应 Claude hook 事件hook 桥发不出——推迟到 P1 APNs走既有事件总线/NotifyService | T-iOS-17 只**验证 + 写文档**,不建新文件 |
| P1 | `src/push/` 旁新增 **APNs sender**~150 行,`.p8` + HTTP/2复用现有 hook 事件总线;外加一个**增量** APNs device-token 注册端点(形状在 T-iOS-20 内定稿,`G` 守卫 + 限频,对齐 `POST /push/subscribe` 的既有约定 src/server.ts:461-480 | 增量文件 + 增量 route`/hook/decision` 原样复用src/server.ts:503-525 |
| P1 | `LiveSessionInfo` 增加可选 `lastOutputAt` 字段:`src/types.ts:246-256` 加一字段 + `src/session/manager.ts` `list()` 加一行映射(服务器本就逐 `pty.onData` 维护 `lastOutputAt`src/types.ts:211/M3供 T-iOS-23 unread 水位 | 增量字段 + 测试TypeScript 任务 **T-iOS-37**(遵循根仓库 PLAN 工作流) |
除此之外**服务器 byte-for-byte 零改动**。若实施中发现 server 侧缺陷修复归属对应模块route/session 文件的 owner按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 server。
---
## 1. 整体架构 / 进程模型
```
┌───────────────────────────── iPhone AppSwiftUI─────────────────────────────┐
│ │
│ App/WebTerm(胶水层,排除在覆盖率门之外) │
│ PairingScreen → SessionListScreen(合并 chooser+dashboard) → TerminalScreen │
│ │ │ │ +KeyBar +GateBanner │
│ │ │ │ +AwayDigestView │
│ │ @MainActor @Observable ViewModel(消费 AsyncStream<SessionEvent>) │
│ ▼ ▼ ▼ │
│ Packages(纯逻辑,Sendable 不可变,80% 覆盖率门): │
│ ┌────────────┐ ┌───────────┐ ┌──────────────────────────┐ ┌───────────────┐ │
│ │HostRegistry│ │ APIClient │ │ SessionCore │ │ WireProtocol │ │
│ │ Keychain + │ │ URLRequest│ │ SessionEngine(actor) │→│ 冻结契约: │ │
│ │ UserDefaults│ │ builders │ │ ReconnectMachine(纯SM) │ │ Client/Server │ │
│ │ last- │ │ +Pairing- │ │ PingScheduler·GateState │ │ Message enums │ │
│ │ sessionId │ │ Error 分类│ │ AwayDigest reducer │ │ MessageCodec │ │
│ └────────────┘ └───────────┘ │ URLSessionTermTransport │ │ Validation │ │
│ └──────────────────────────┘ └───────────────┘ │
└──────────┬──────────────────┬──────────────────────┬───────────────────────────┘
HTTP GET 轮询(RO,无 Origin) HTTP 变更(G,带 Origin) 单条活 WS(仅前台会话:
/live-sessions /events … DELETE /live-sessions… Origin header + 16 MiB
▼ ▼ maximumMessageSize+25s ping)
┌────────────────────── Mac 上的 web-terminal 服务器(零改动) ──────────────────────┐
│ isOriginAllowed(默认拒空 Origin) · /term WS · RingBuffer 回放 · JOIN mirror │
└────────────────────────────────────────────────────────────────────────────────┘
```
**为什么"单条活 WS + HTTP 轮询"而不是每会话一个 connection actor**iOS 切后台数秒内 suspend、socket 必死Apple forums 716118per-session 常驻连接是在跟平台打一场必输的仗,还会制造大量"看着连着其实死了"的僵尸状态。前台会话独占唯一活 WS切会话 = detach + attach服务器回放 ring-buffer切换成本 ≈ 0后台会话的"有没有新动静"靠 `/live-sessions` 轮询P0→ APNsP1。这与服务器的 session/connection 解耦设计**严丝合缝**。接受的代价APNs 落地前,后台会话的 unread 信号有轮询延迟(评审确认可辩护)。**升级路**:若未来要多会话并行盯屏,加第二条"观察者 WS"即可SessionEngine 不用改。
**为什么 SwiftTerm 而不是 WKWebView + xterm.js**:原生渲染、原生手势与选择、软键盘/IME 全走系统栈;`TerminalViewDelegate.send(source:data:)` / `sizeChanged` / `feed(byteArray:)``input`/`resize`/`output` 帧 1:1。WKWebView 方案两位评审均否决(见 §0 不做。SwiftTerm 历史上最麻烦的是自绘文本选择与 first-responder——Day-1 spike + 验收里专门压这块。
**为什么 URLSessionWebSocketTask 而不是 Starscream**系统框架、iOS 13+ 内建Starscream 最后一版 4.0.8 已 ~2 年未动,仅作 spike 失败时的备胎。三个已知坑在 P0 直接立规矩:**`maximumMessageSize` 默认 1 MiB而 ring-buffer 回放是单帧全量(`buffer.snapshot()`src/session/session.ts:165-171JSON 会把控制字节 `\uXXXX` 转义膨胀 16×src/protocol.ts:186最坏 ≈ 6 × SCROLLBACK_BYTES默认 2 MiB——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"T-iOS-2 spike 实测勘误——原文误标 ENOBUFSDarwin ENOBUFS=55归类以 40 为主、55 兜底)失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping25 s 定时 `sendPing`**`PingScheduler`+ 显式 "reconnecting…" 横幅,终端绝不"看着连着其实死了"。
**为什么 4 个纯 SwiftPM 包 + 薄 App 胶水**:逻辑全部下沉到无 UIKit 依赖的包里(`WireProtocol`/`SessionCore`/`HostRegistry`/`APIClient``swift test` 秒级跑、80% 覆盖率门只量"值得量的逻辑"App target 只剩 UIViewRepresentable、导航和 ViewModel 粘合。依赖方向**严格单向向下**`WireProtocol` 是唯一冻结契约(对应 `src/types.ts` 的地位)——**共享 I/O 边界类型也在这里**`HostEndpoint`/`TermTransport`/`TransportConnection`/`HTTPTransport`/`TimelineEvent`/`Tunables`,见 §3.1SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol叶子包之间零耦合W0 的 TestSupport 就能编译。
**为什么 v1 只做 LAN + Tailscale、不碰 relay**Tailscale iOS App 是系统级 VPNNetwork Extension对本 App 完全透明——tailnet IP/MagicDNS 直接可达,**无 SDK、无一行网络代码**服务器零改动。relay 栈尚无可部署的服务端,且把刚审计完的 TS 加密X25519/HKDF/deterministic-nonce AEAD/epoch-in-key移植成 Swift 等于重开所有 findings§5.5)。**升级路**relay 上线后优先评估 WKWebView 嵌 relay-web复用已审计密码学字节而非 Swift 重写。
**为什么合并 chooser + dashboard 成一张列表**`GET /live-sessions` 一个响应里已经带 `status + telemetry`src/types.ts:246-256, src/session/manager.ts:181-194拆两个界面是自造 DRY 违约;一张列表 5 秒回答"要不要介入"。
---
## 2. 目录结构
新增独立 `ios/`(与 `desktop/` 同级同法),与 `src/``public/` **并列且解耦**——不污染根 `package.json`不引入任何第三方运行时依赖SwiftTerm 是唯一 SPM 依赖,且只挂在 App target
```
web-terminal/
├── src/ public/ desktop/ # 全部不动
└── ios/ # ★ 新增
├── project.yml # XcodeGen 声明式工程;.xcodeproj 不入库
├── .gitignore # DerivedData / *.xcodeproj / xcuserdata
├── Packages/
│ ├── WireProtocol/ # 冻结契约(对应 src/types.ts + src/protocol.ts)+共享 I/O 边界类型
│ │ ├── Package.swift
│ │ ├── Sources/WireProtocol/
│ │ │ ├── ClientMessage.swift ├── ServerMessage.swift
│ │ │ ├── MessageCodec.swift ├── Validation.swift
│ │ │ ├── WireConstants.swift ├── Tunables.swift
│ │ │ ├── HostEndpoint.swift ├── TermTransport.swift # 含 TransportConnection
│ │ │ ├── HTTPTransport.swift └── TimelineEvent.swift
│ │ └── Tests/WireProtocolTests/
│ │ ├── CodecRoundtripTests.swift
│ │ ├── HostEndpointTests.swift # originHeader/wsURL 派生向量
│ │ └── ServerVectorTests.swift # 从 test/protocol.test.ts 移植的跨实现向量
│ ├── SessionCore/
│ │ ├── Sources/SessionCore/
│ │ │ ├── URLSessionTermTransport.swift
│ │ │ ├── SessionEngine.swift ├── SessionEvent.swift
│ │ │ ├── ReconnectMachine.swift ├── PingScheduler.swift
│ │ │ ├── GateState.swift ├── AwayDigest.swift
│ │ │ └── KeyByteMap.swift # 键位字节表(纯数据,T-iOS-11 所有)
│ │ └── Tests/SessionCoreTests/…
│ ├── HostRegistry/
│ │ ├── Sources/HostRegistry/{Host,HostStore,KeychainHostStore,SecItemShim,InMemoryHostStore,LastSessionStore}.swift
│ │ └── Tests/HostRegistryTests/…
│ ├── APIClient/
│ │ ├── Sources/APIClient/{APIClient,Endpoints,Models,PairingProbe,PairingError}.swift
│ │ └── Tests/APIClientTests/…
│ └── TestSupport/ # 测试替身(仅被各包 test target 依赖;仅依赖 WireProtocol)
│ └── Sources/TestSupport/{FakeTransport,FakeClock,FakeHTTPTransport}.swift
├── App/WebTerm/ # 胶水层,排除在覆盖率门之外
│ ├── WebTermApp.swift DeepLinkRouter.swift(P1)
│ ├── Screens/{PairingScreen,SessionListScreen,TerminalScreen}.swift
│ ├── Components/{KeyBar,GateBanner,PlanGateSheet,AwayDigestView,ReconnectBanner,TelemetryChips}.swift
│ ├── ViewModels/{TerminalViewModel,SessionListViewModel,PairingViewModel}.swift
│ └── Resources/ # Assets;Info.plist 键在 project.yml 里声明
└── IntegrationTests/ # CI 专用:对真 Node 服务器的 Swift Testing
└── LiveServerTests.swift
```
**构建管线**(本地与 CI 同路径):
1. `brew install xcodegen && cd ios && xcodegen generate``WebTerm.xcodeproj`(不入库)。
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
3. App 构建:`xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm -destination 'platform=iOS Simulator,name=iPhone 16' build test`
4. 集成CImacOS runner根目录 `npm ci && PORT=0 … npm start` 起真服务器(临时端口 + `ALLOWED_ORIGINS`)→ `swift test --package-path ios/IntegrationTests`(详见 T-iOS-16 与 §7
---
## 3. 协议与连接核心契约(函数签名级,风格对齐 ARCHITECTURE §3
> 以下签名是 W0 冻结的接口;实现细节归各任务。**不可变 + Sendable** 是硬约束:状态一律 struct 快照变更返回新值可变运行时句柄socket task、URLSession只活在 actor 内部。
### 3.1 `WireProtocol` — 冻结契约(对应 `src/types.ts:87-120` + `src/protocol.ts`
```swift
// 伪代码 —— 不可变、显式错误处理(decode 失败 → nil,镜像服务器"非法帧静默丢弃"语义)
public enum ClientMessage: Sendable, Equatable {
case attach(sessionId: UUID?, cwd: String?) // 首帧必须是它(src/server.ts:707-711)
case input(data: String) // 原始键盘字节,逐字节透传,不过滤
case resize(cols: Int, rows: Int) // 均为 1...1000 整数(src/protocol.ts:113-115)
case approve(mode: ApproveMode?) // mode 仅对 plan gate 有意义
case reject
}
public enum ApproveMode: String, Sendable, CaseIterable { case `default`, acceptEdits, plan, auto }
// 注:plan gate 三选一只发 acceptEdits/default(镜像 public/tabs.ts:345-347 与 src/types.ts:84-86);
// raw `auto` 是被 ALLOW_AUTO_MODE 门控的高危模式(默认 false,src/config.ts:385,服务器会降级 auto→default,
// src/server.ts:765-766)——仅保留给未来的"权限模式切换器"(那里才按 uiConfig.allowAutoMode 过滤),本计划无任务消费它。
public enum ServerMessage: Sendable, Equatable {
case attached(sessionId: UUID) // 永远采用服务器回发的 id
case output(data: String) // 不透明 ANSI/UTF-8,回放与实时同型
case exit(code: Int, reason: String?) // code == -1 → spawn 失败,reason 必有
case status(ClaudeStatus, detail: String?, pending: Bool, gate: GateKind?)
case telemetry(StatusTelemetry)
}
public enum ClaudeStatus: String, Sendable { case working, waiting, idle, unknown, stuck }
public enum GateKind: String, Sendable { case tool, plan }
public struct StatusTelemetry: Sendable, Equatable, Decodable {
// 全可选字段 + 必有 at(服务器 ms 时间戳)—— 镜像 src/types.ts:406-416
public let contextUsedPct: Double?; public let costUsd: Double?
public let linesAdded: Int?; public let linesRemoved: Int?
public let model: String?; public let effort: String?
public let pr: PrInfo?; public let rate: RateInfo?; public let at: Int
}
public enum MessageCodec { // 纯静态,永不 throw
public static func encode(_ msg: ClientMessage) -> String // → JSON 文本帧
public static func decodeServer(_ text: String) -> ServerMessage? // 非法 → nil
}
public enum Validation { // 与服务器同规则(src/protocol.ts:22-23,113-115,142-149)
public static func isValidSessionId(_ s: String) -> Bool // UUID v4 正则,同 SESSION_ID_RE
public static func isValidResize(cols: Int, rows: Int) -> Bool
public static func isAbsoluteCwd(_ s: String) -> Bool // 必须以 "/" 开头
}
public enum WireConstants {
public static let wsPath = "/term" // src/config.ts:41
public static let replaySoftResetPrefix = "\u{1B}[0m" // src/types.ts:167-170
public static let spawnFailedExitCode = -1
public static let resizeRange: ClosedRange<Int> = 1...1000
}
// —— 共享 I/O 边界类型(同包冻结;SessionCore/HostRegistry/APIClient/TestSupport 只 import,不得另立) ——
public struct HostEndpoint: Sendable, Equatable, Codable {
public let baseURL: URL // http(s)://<dialed-host>:<port>,Origin 由它单点派生
public var wsURL: URL { get } // ws(s) 同 host 同 port + WireConstants.wsPath
public var originHeader: String { get }
// "<scheme>://<host>[:<port>]"——端口为 scheme 默认值(http/80、https/443)时省略,
// 其余部分与 baseURL 逐字符一致(与浏览器 Origin 序列化一致)
}
public protocol TermTransport: Sendable {
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection
}
public struct TransportConnection: Sendable {
public let frames: AsyncThrowingStream<String, Error> // 服务器 JSON 文本帧;结束/出错 → 断线
public let send: @Sendable (String) async throws -> Void
public let close: @Sendable () async -> Void
}
public protocol HTTPTransport: Sendable { // URLSession 薄封装;FakeHTTPTransport(TestSupport)同实现
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
public struct TimelineEvent: Sendable, Equatable, Decodable {
// 镜像 src/types.ts:428-433(GET /live-sessions/:id/events 条目);未知 class → 该条丢弃不 crash
public let at: Int; public let `class`: String
public let toolName: String?; public let label: String
}
public enum Tunables { /* 全部具名常量;值与出处见 §3.2.1 表。需新增常量 → 回 T-iOS-3 改契约 */ }
```
### 3.2 `SessionCore` — 连接核心
```swift
// 伪代码 —— HostEndpoint/TermTransport/TransportConnection/HTTPTransport/TimelineEvent/Tunables
// 定义在 WireProtocol(§3.1),本包只 import。TermTransport 是唯一 WS I/O 边界,FakeTransport(TestSupport)
// 与 URLSessionTermTransport 同实现此协议;SessionEngine 对二者不可区分。
public actor SessionEngine { // 每个"打开中的会话"一个 engine;前台仅一个持活
public init(transport: any TermTransport, clock: any Clock<Duration>,
endpoint: HostEndpoint, reconnect: ReconnectMachine = .initial,
eventsSource: @Sendable (UUID) async throws -> [TimelineEvent])
// eventsSource = 重连后 digest 的注入点(生产实现由 App 层用 APIClient.events 包一层;
// 测试注入 fake)——engine 不直接持有 HTTP 客户端,保持 TermTransport 为唯一 WS 边界
public nonisolated let events: AsyncStream<SessionEvent> // UI 消费的唯一出口
public func open(sessionId: UUID?, cwd: String?) async // 连接→首帧 attach→回放→实时
public func send(_ msg: ClientMessage) async // input/resize/approve/reject
public func notifyForegrounded(dims: (cols: Int, rows: Int)) async // 重连+attach+补发 resize
public func close() async // 显式 detach(PTY 继续跑)
}
public enum SessionEvent: Sendable, Equatable {
case connection(ConnectionState) // .connecting/.connected/.reconnecting(attempt:next:)/.closed
// /.failed(FailureReason) —— 不可重试终态(如 .replayTooLarge:
// receive() EMSGSIZE(40)/message-too-big,绝不进 backoff 重连,UI 给可操作话术)
case adopted(sessionId: UUID) // attached 帧;未知 UUID 会拿到新 id —— 永远采用它
case output(String) // 直接 feed 给 SwiftTerm(@MainActor hop 由 VM 负责)
case exited(code: Int, reason: String?)
case gate(GateState?) // nil = gate 已解除
case telemetry(StatusTelemetry)
case digest(AwayDigest) // 重连完成后由 engine 拉 /events 归纳一次
}
public struct ReconnectMachine: Sendable, Equatable { // 纯状态机,注入 Clock,零真实计时
public static let initial: ReconnectMachine
public enum Input { case connected, disconnected, retryTimerFired, foregrounded, userRetry }
public enum Effect: Equatable { case connectNow, scheduleRetry(after: Duration), none }
public func reduce(_ input: Input) -> (ReconnectMachine, Effect)
// backoff 1s→2s→4s…封顶 30s(镜像 public/terminal-session.ts);connected 归零
}
public struct PingScheduler: Sendable { // 25s sendPing;连续 2 次无 pong → 视为断线
public init(interval: Duration = Tunables.pingInterval)
}
public struct GateState: Sendable, Equatable {
public let kind: GateKind; public let detail: String?
public let epoch: Int // pending 上升沿 +1;approve/reject 带 epoch,陈旧操作丢弃(防误批新 gate)
}
public struct AwayDigest: Sendable, Equatable {
public let toolRuns: Int; public let waitingCount: Int
public let sawDone: Bool; public let sawStuck: Bool; public let recent: [TimelineEvent]
public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest
}
```
#### 3.2.1 `Tunables` 取值表唯一取值出处Tunables.swift 在 WireProtocol归 T-iOS-3
| 常量 | 值 | 出处 / 说明 |
|---|---|---|
| `pingInterval` | 25 s | §1URLSessionWebSocketTask 无自动 ping |
| `pongMissLimit` | 2 | 连续 2 次无 pong → 视为断线T-iOS-5 |
| `listPollInterval` | 5 s | 镜像 web launcher 轮询节奏public/launcher.ts:30 `REFRESH_MS = 5000` |
| `telemetryStaleTtlMs` | 30_000 | 镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`= 服务器默认 src/config.ts:63服务器侧 env 可覆盖——iOS 固化默认值,主机改配置时会漂移,已接受) |
| `digestFadeDelay` | 8 s | T-iOS-14 digest 自动淡出 |
| `titleMaxLength` | 256 | T-iOS-23 OSC 标题净化上限 |
| `pairingProbeTimeout` | 10 s | 配对探针整体 deadline§3.4 契约裁定新增;两步探针任一步挂起超此时限 → `.timeout` |
| `maxWSMessageBytes` | 16 MiB`16 * 1024 * 1024` | ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。**耦合警示(写进 doc comment**:回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数16×控制字节→`\uXXXX`src/protocol.ts:186SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`§3.2 / T-iOS-9/10 |
### 3.3 `HostRegistry`
```swift
public struct Host: Sendable, Equatable, Codable, Identifiable {
public let id: UUID; public let name: String; public let endpoint: HostEndpoint
}
public protocol HostStore: Sendable { // Keychain 实现 + InMemory 替身(都在本包)
func loadAll() async throws -> [Host]
func upsert(_ host: Host) async throws -> [Host] // 返回新集合(不可变风格)
func remove(id: UUID) async throws -> [Host]
}
// KeychainHostStore 经 SecItemShim 协议封装 SecItem* 调用(可测性缝):unsigned `swift test` 二进制
// 用不了 data-protection keychain(SecItemAdd → -34018 errSecMissingEntitlement),故 swift test 层
// 测 store 逻辑对 fake shim;真 Keychain 路径 + kSecAttrAccessible 属性断言放 xcodebuild 模拟器测试(签名宿主)。
public protocol LastSessionStore: Sendable { // UserDefaults 实现(非机密)
func lastSessionId(host: UUID) -> UUID?
func setLastSessionId(_ id: UUID?, host: UUID)
}
```
### 3.4 `APIClient`
```swift
public struct APIClient: Sendable {
public init(endpoint: HostEndpoint, http: any HTTPTransport) // HTTPTransport 定义在 WireProtocol,可注入替身
// RO(无 Origin):
public func liveSessions() async throws -> [LiveSessionInfo] // GET /live-sessions
public func preview(id: UUID) async throws -> SessionPreview // GET /live-sessions/:id/preview
public func events(id: UUID) async throws -> [TimelineEvent] // GET /live-sessions/:id/events
public func uiConfig() async throws -> UiConfig // GET /config/ui {allowAutoMode}
// 预留:未来权限模式切换器用(那里才按
// allowAutoMode 过滤 raw auto);plan gate 不消费
// G(必带 Origin,src/server.ts:332-339):
public func killSession(id: UUID) async throws // DELETE /live-sessions/:id
public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws
}
// Origin 铁律:仅 G 端点 stamp `Origin: endpoint.originHeader`;RO GET 一律不带 ——
// 这样一旦服务器把某 RO 端点改为 G,集成测试立刻红,而不是靠巧合通过。
public enum PairingError: Error, Equatable { // [S:hybrid] 错误分类学,逐项映射探针失败模式
case localNetworkDenied // POSIX "Network is down" + LAN IP → 引导去设置开权限
case hostUnreachable(underlying: String)
case httpOkButNotWebTerminal // GET /live-sessions 非预期形状 → "端口对吗?"
case originRejected(hint: String) // WS 401 → "在主机加 ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port],与 App 连接的 URL 一致"
case atsBlocked(host: String) // NSURLErrorDomain -1022(ATS 拦明文) → "该 IP 段不在 App 例外列表,
// 用 https/tailscale serve,或反馈该网段"(§5.2 例外列表之外的明文目标)
case tlsFailure, timeout
}
public func runPairingProbe(endpoint: HostEndpoint, http: any HTTPTransport,
ws: any TermTransport) async -> Result<HostEndpoint, PairingError>
// 【契约裁定 2026-07-04T-iOS-8 BLOCKED 上报】原冻结签名返回 Result<Host,…>,但 Host 是 §3.3
// HostRegistry 类型,APIClient 依赖边只有 WireProtocol(§1 叶子包零耦合)——签名自相矛盾。
// 裁定:探针职责=验证 endpoint,返回 HostEndpoint;Host{id,name} 由 T-iOS-12 PairingViewModel
// 构造后入 store(id/name 本就非探针所知)。超时经 Tunables.pairingProbeTimeout(§3.2.1 新增行)。
// 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返
//(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。
// 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。
```
### 3.5 App 胶水(不冻结,示意)
`TerminalViewModel``@MainActor @Observable`):持有 `SessionEngine``for await event in engine.events``output` `feed()` 给 SwiftTerm、把 `connection/exited` 转成 UI 状态;实现 `TerminalViewDelegate.send``engine.send(.input(…))``sizeChanged``engine.send(.resize(…))``GateViewModel`(独立 VMT-iOS-14消费同一 events 流的 `.gate/.digest`T-iOS-15 接线。Swift 6 strict concurrency 编译期强制 socket→main 的 hop。`scenePhase == .active``engine.notifyForegrounded(dims:)`(重连 + 补发 resizelatest-writer-wins 夺回全屏);`scenePhase != .active` → 隐私遮罩T-iOS-15
---
## 4. 工程标准(每个任务的硬性门槛)
- **TDD 强制**:每个任务 RED先写失败测试来自 Steps(测试))→ GREEN最小实现→ REFACTOR。测试与实现同属一个任务、一个 agent。
- **覆盖率 ≥ 80%**unit + integration + E2E 三类都要;门只对 4 个 Packages 计App 胶水排除)。
- **不可变**:绝不原地 mutate状态为不可变快照变更返回新副本可变运行时句柄socket/URLSession/TerminalView单独持有在 actor / @MainActor 类内。
- **文件 200400 行典型,硬上限 800**;多小文件、按 feature 组织。
- **函数 < 50 **单一职责
- **早返回**优先嵌套 > 4 层禁止。
- **无魔法数字**:阈值/延迟/上限一律具名常量(`Tunables.pingInterval``Tunables.maxWSMessageBytes = 8 * 1024 * 1024`…)。
- **无硬编码配置/密钥**:用户可见配置进 Settings必需配置启动即校验、fail fast。
- **系统边界全验证****把服务器当不可信输入源**——每一帧过 `MessageCodec`/`Validation` 白名单,非法 → 丢弃(不 crash、不猜用户输入URL、扫码结果同样先验证。
- **错误处理全面显式**UI 给用户友好话术PairingError 分类学、ReconnectBanner内部日志留细节绝不静默吞错。
- **Conventional commits**`feat:`/`fix:`/`test:`…),提交边界对齐任务。
- **每任务完成即 code review**CRITICAL/HIGH 必须修完才算完);网络/输入/Keychain 代码追加 security review。
---
## 5. 安全考量(沿用现有威胁模型)
### 5.1 Origin header原生客户端的第一道坎
`isOriginAllowed` **对 `undefined`/空 Origin 默认拒绝**src/http/origin.ts:26-29——非浏览器客户端天然被拒且要求 scheme+hostname+port **逐项精确匹配**白名单src/http/origin.ts:47-51。白名单由**主机网卡 IPv4 + 端口**派生(不是 bindHost`http(s)://localhost:<port>``127.0.0.1`、各非内网卡 IP再并入 `ALLOWED_ORIGINS` envsrc/config.ts:187-226。因此
- App 必须在 **(a) WS 升级**(否则 401src/server.ts:646-651**(b) 所有 `G` 类变更 HTTP**(否则 403src/server.ts:332-339上显式携带 `Origin: <scheme>://<host>[:<port>]`——端口为 scheme 默认值http/80、https/443时省略其余部分与实际连接 URL 逐字符一致(与浏览器 Origin 序列化一致),由 `HostEndpoint.originHeader` 单点派生,禁止手拼。
- RO GET **一律不带** Origin见 §3.4 铁律)。
- Tailscale/TLS 场景:`ALLOWED_ORIGINS=<scheme>://<App 拨号的 host>[:port]`——填 App 实际连接的 URL 即可。`isOriginAllowed` 对**两侧**都做 `new URL()` 规范化后比对 protocol/hostname/portsrc/http/origin.ts:31-51https 默认端口写不写 `:443` 均匹配WHATWG URL 把默认端口规范化为空串双向对称。PairingError 话术只引导"加与拨号 URL 一致的 `ALLOWED_ORIGINS`",不引入端口迷信。
### 5.2 ATS 与 Local NetworkInfo.plist 精确键,只开最小口子)
```yaml
# project.yml 内声明(release 构建;debug 可临时 NSAllowsArbitraryLoads,严禁进 release ——
# 注意:debug 全放开会掩盖缺段问题,五段 CIDR 是否齐全必须在 release ipa 层核对,见 T-iOS-19)
NSAppTransportSecurity:
NSAllowsLocalNetworking: true # 只覆盖 .local / 无点主机名,不覆盖裸 IP
NSExceptionDomains: # 裸 IP 用 CIDR 例外(iOS 17+ 语义)
"192.168.0.0/16": { NSExceptionAllowsInsecureHTTPLoads: true }
"10.0.0.0/8": { NSExceptionAllowsInsecureHTTPLoads: true }
"172.16.0.0/12": { NSExceptionAllowsInsecureHTTPLoads: true } # RFC1918 第三段(iPhone 热点 172.20.10.x/企业内网)
"100.64.0.0/10": { NSExceptionAllowsInsecureHTTPLoads: true } # Tailscale CGNAT
"127.0.0.0/8": { NSExceptionAllowsInsecureHTTPLoads: true } # 模拟器 dev-loop(用 CIDR 而非单 IP 键——
# loopback 单 IP 键有不匹配史,DevForums 6205)
NSLocalNetworkUsageDescription: "连接你自己电脑上的 web-terminal 服务器" # iOS 18+ 缺失则提示异常
NSCameraUsageDescription: "扫描 web 终端的配对二维码" # P0 必需:T-iOS-12 用 DataScannerViewController,
# 缺失 = 打开扫码即 TCC crash
```
> P2 前置T-iOS-31语音 PTT开工前需另加 `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(属于该任务的前置,不属于 P0
- MagicDNS 名(`*.ts.net`)是 FQDN → ATS 全额适用 → 用 100.x IP、加例外域`tailscale serve`https/wss最优
- Local Network 弹窗被拒 → 连接报 POSIX "Network is down";映射到 `PairingError.localNetworkDenied` 并引导去 设置→隐私→本地网络iOS 18 有需重启的已知 bug话术里写明
### 5.3 凭据与本地存储
- **Keychain**host 列表(含未来 authMaterial 占位)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,不进 iCloud 同步。
- **UserDefaults**仅非机密per-host lastSessionId、UI prefs
- `/hook/decision` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期src/server.ts:503-525限频 10/min/IPApp 端绝不落盘。
- App 内无任何硬编码 host/密钥;首个 host 必经配对流程。
### 5.4 继承的威胁模型TECH_DOC §7
无鉴权 = **谁能连上端口谁就有 shell**。App 不改变这一点,只继承部署纪律:**绝不要**把服务器端口 port-forward/隧道到公网——后果是任何人拿到你的 shell推荐 Tailscale设备注册 + WireGuard E2E + 端口对公网不可见)。**标准部署话术推荐 `tailscale serve`wss**——绕开全部 ATS 例外与明文嗅探面(开放项 5 已定)。
配对/确认界面的提示**按 scheme + 地址类别分层**TECH_DOC §7 明文嗅探/MITM 行的完整继承——`ws://` 在任何不可信 LAN 上暴露键击(密码/API key/Claude token与全部输出给同网段被动嗅探与 ARP-spoof MITMOrigin 校验对此零防护):
| 目标 | 提示 |
|---|---|
| https/wss | 无 |
| ws:// → 100.64.0.0/10 或 *.ts.netMagicDNS | 无明文警告WireGuard 网络层已加密);可选正向"经 Tailscale 加密"徽标 |
| ws:// → loopback | 无 |
| ws:// → RFC1918/link-local | **非阻断明文提示**:流量未加密、键击可被同网嗅探,仅限可信 LAN优先 `tailscale serve`wss |
| http/ws → 公网(非 RFC1918https 也含在"公网 host"确认警告内,见 T-iOS-12 | **最强阻断式警告**(醒目、需显式确认) |
### 5.5 relay / E2E显式推迟为什么不现在做
relay 栈term-relay/agent/relay-e2e/relay-auth…是 pre-production 库集合无可跑服务端进程、F6 replay 路径未端到端接线、Phase 0 集成未开工([DEPLOY_RELAY.md](./DEPLOY_RELAY.md)、[PLAN_RELAY_RUN_PHASE0.md](./PLAN_RELAY_RUN_PHASE0.md)。更关键的relay-e2e 的密码学X25519 握手、HKDF 方向分key、`nonce=f(seq)` 确定性 AEAD、epoch-in-key 回放)刚经 99-agent 对抗审计修完 F1F6——**用 Swift 重写 = 重开每一个已关闭的 finding且一条既有回归测试都带不走**F6 恰是第一轮审计漏掉的那类交互)。故 v1 远程 = LAN + Tailscalerelay 上线后先评估 WKWebView 嵌 relay-web字节级复用已审计 TSSwift 移植仅在有跨语言 KAT + 独立审计预算时考虑。
---
## 6. 多 Agent 并行规则与波次
并行三条铁律(同 [PLAN.md](./PLAN.md) §0违反会互相踩踏
1. **文件所有权独占**:每任务 `Owns:` 独占创建/修改权。`ios/Packages/WireProtocol/**`**T-iOS-3 独占并冻结**,其余任务只读 import要加共享类型 → 回 T-iOS-3 改契约,不得各自另立。
2. **只依赖接口不依赖实现**`Depends:` 指"需要对方接口/产物"§3 的签名在 W0 冻结,故多数任务可与依赖方并行编码,集成时汇合。
3. **G1**LOG 由 orchestrator 独写subagent 返回可粘贴条目(见页首)。
subagent **不能问用户、不能互相通信**:遇到本文+ARCHITECTURE+TECH_DOC 未定义的歧义 → 停下返回 `[!] BLOCKED`**绝不猜**。单批并行控制在 **~35 个 agent**。验收任务 report-onlyG4findings 标 severity + owning task修复派回该任务的 builder。
```
W0 基础(串行;T-iOS-2 与 T-iOS-3 可在 T-iOS-1 后并行)
T-iOS-1 脚手架 → T-iOS-2 Day-1 双 spike ∥ T-iOS-3 WireProtocol 契约(冻结,含共享 I/O 边界类型+Tunables) → T-iOS-4 测试替身
W1 叶子包(全部并行,互不依赖)
T-iOS-5 Reconnect+Ping · T-iOS-6 Gate+Digest · T-iOS-7 HostRegistry · T-iOS-8 APIClient+探针
W2 连接核心
T-iOS-9 URLSessionTermTransport → T-iOS-10 SessionEngine(可先按 §3 接口并行编码,集成汇合)
W3 UI 胶水(全部并行;只依赖 §3 接口 + FakeTransport)
T-iOS-11 Terminal+KeyBar · T-iOS-12 Pairing · T-iOS-13 SessionList · T-iOS-14 Gate/Digest UI
W4 集成(汇合点;T-iOS-16 仅依赖 W2、T-iOS-17 零依赖——二者可提前并入第 6 批,见 §8)
T-iOS-15 App 接线+生命周期 · T-iOS-16 集成 CI(真 Node 服务器) · T-iOS-17 ntfy 桥验证
W5 验收(report-only, 并行)
T-iOS-18 F 走查(真机) · T-iOS-19 安全核对(对照 TECH_DOC §7 + 本文 §5)
```
---
## 7. 任务清单
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
> 每个任务自带 TDD测试与实现同 agent 同文件组。测试框架Swift Testing`@Test`/`#expect`XCTest 仅 XCUITest。
> 覆盖率验证命令(各包):`swift test --package-path ios/Packages/<Pkg> --enable-code-coverage` + `xcrun llvm-cov report`(阈值 80%,见 §9
### P0 — 每日可用("口袋里能开终端、能批准")· 合计 ~13 人天
#### W0 · 基础(串行)
#### T-iOS-1 · `ios/` 脚手架 + XcodeGen 工程 `[ ]` · ~0.5 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/project.yml``ios/.gitignore`、5 个 `Package.swift` 空壳4 个 gated 包 + `ios/IntegrationTests`TestSupport 的 manifest 归 T-iOS-4 的 `**` glob`ios/App/WebTerm/WebTermApp.swift`空窗、CI workflow 骨架(`.github/workflows/ios.yml`
- **Depends**: 无 · **Parallel-safe**: 无(必须最先)
- **Steps**:
- [ ] `project.yml`App targetiOS 17 floor、Swift 6 language mode、strict concurrency、default MainActor isolation+ 4 个本地 SPM 包引用 + SwiftTerm v1.13+SPM仅 App target
- [ ] Info.plist 键全部经 `project.yml` 声明:**§5.2 的全部键**ATS/五段 CIDR/NSLocalNetworkUsageDescription/NSCameraUsageDescription——枚举以 §5.2 为准不得漏项release 无 `NSAllowsArbitraryLoads`
- [ ] `xcodegen generate` 产物 gitignoreCI 骨架跑 `swift test`0 测试也过)
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
#### T-iOS-2 · Day-1 双 spike评审强制`[ ]` · ~1 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/IntegrationTests/OriginSpikeTests.swift``ios/App/WebTerm/Screens/SpikeTerminalScreen.swift`临时文件W4 删)
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-3
- **Steps(测试先行——spike 本身就是测试)**:
- [ ] `OriginSpikeTests`(对本仓库真服务器 `npm start`):① 无 Origin 的 WS 升级收 **401**src/server.ts:646-651`Origin: http://127.0.0.1:<port>` 精确匹配 → 升级成功、`attach(null)` 收到 `attached``maximumMessageSize` 默认 1 MiB 时灌 >1 MiB 输出再 reattach → **复现失败**;设 `Tunables.maxWSMessageBytes`16 MiB→ 回放成功;**追加对抗用例**:预灌 ESC/C0 控制字节密集输出JSON `\uXXXX` 转义膨胀 ~6×再 reattach → 仍成功 ④ 端口不匹配的 Origin`Origin: http://127.0.0.1:9999`)→ **401**src/http/origin.ts:47-51 端口精确比对;注意默认端口如 `:443` 会被 `new URL()` 双向规范化、**不是**失配案例)
- [ ] 真机 smoke人工清单结果记录进返回条目SwiftTerm 键盘弹出/first-responder、中文 IME 组合输入、`inputAccessoryView` 原型上 Esc/Ctrl-C 可发、文本选择不崩
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[ ]` · ~1.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/WireProtocol/**`Sources + Tests 全部,含 `HostEndpoint/TermTransport/HTTPTransport/TimelineEvent/Tunables`
- **Depends**: T-iOS-1 · **Parallel-safe**: T-iOS-2
- **Steps(测试先行, RED)** — `Tests/WireProtocolTests/CodecRoundtripTests.swift``HostEndpointTests.swift``ServerVectorTests.swift`:
- [ ] 5 种 `ClientMessage` encode 后的 JSON 与服务器 `parseClientMessage` 接受的形状逐键一致(`attach` 必含 `sessionId`null 也要显式src/protocol.ts:132-134
- [ ] `decodeServer` 5 种合法帧解出对应 case`attached` 的 sessionId 非 UUID → nil
- [ ] 非法帧全表 → nil 且不 throw坏 JSON、未知 type、`resize` cols=0/1001/非整数、`input.data` 非 string、缺字段
- [ ] `Validation.isValidSessionId`:合法 UUID v4 过;`abc123`、UUID v1、大写混合按服务器正则同判向量从 `test/protocol.test.ts` 移植)
- [ ] `isAbsoluteCwd("/a")==true``("a")==false``("")==false`
- [ ] `telemetry` 帧全可选字段缺省可解、`at` 缺失 → nil
- [ ] roundtrip property任意合法 ClientMessage `encode→(服务器视角)decode` 不变形——**`approve.mode` 除外**:服务器 `parseClientMessage` 刻意丢 mode、对一切 approve 帧返回裸 `{type:'approve'}`src/protocol.ts:77-79mode 由 WS 接线层对**原始帧**二次解析顶层 `mode` 键恢复src/server.ts:91-102。approve 只断言"形状被接受"
- [ ] 显式向量:`encode(.approve(mode:))` 必须把 `mode` 放**顶层键**、值 ∈ {default,acceptEdits,plan,auto}(因为 src/server.ts:94-102 读的是原始帧的 `obj['mode']`,不是解析后消息)
- [ ] `HostEndpoint` 派生向量(原 T-iOS-7 用例移入本包):`http://192.168.1.5:3000` → originHeader 同串https+非标端口保留端口;**https+443 → 无端口后缀****http+80 → 无端口后缀**wsURL 派生 scheme http→ws / https→wss + `/term`
- [ ] `TimelineEvent` 解码:合法条目 + 未知 `class` → nil消费方丢弃该条
- **Steps(实现, GREEN)**: [ ] §3.1 全部类型与函数(含 `Tunables` 常量表 §3.2.1 落地)[ ] 完成后**冻结**:新增类型/常量必须回本任务改
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash用随机字节 fuzz 一轮)。
#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~0.25 pd
- **Wave/阶段**: W0 / P0 · **Owns**: `ios/Packages/TestSupport/**`(含本包 `Package.swift`,仅声明对 WireProtocol 的依赖——故 W0 即可编译)
- **Depends**: T-iOS-3接口 · **Parallel-safe**: W1 全部
- **Steps**: [ ] `FakeTransport`(实现 WireProtocol 的 `TermTransport`;可手动灌帧 `emit(frame:)`/`emitError`、记录 send/close 调用)[ ] `FakeClock`(手动推进)[ ] `FakeHTTPTransport`(实现 WireProtocol 的 `HTTPTransport`,按 URL 排队响应)[ ] 各带 1 条 smoke 测试(`InMemoryHostStore` 归 T-iOS-7 的 HostRegistry 包——HostStore 协议在 W1 才存在)
- **Accept**: `swift test --package-path ios/Packages/TestSupport` 全绿
#### W1 · 叶子包(全部并行)
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{ReconnectMachine,PingScheduler}.swift``Tests/…/{ReconnectMachineTests,PingSchedulerTests}.swift`
- **Depends**: T-iOS-3、T-iOS-4FakeClock · **Parallel-safe**: T-iOS-6/7/8
- **Steps(测试先行, RED)**:
- [ ] 断线序列 → 重试延迟 1s,2s,4s,8s,16s,30s,30s封顶
- [ ] `connected` 输入 → backoff 归零,下次断线从 1s 重来
- [ ] `foregrounded`/`userRetry` → 立即 `connectNow`,不等定时器
- [ ] reduce 是纯函数同输入同输出原值不被改Equatable 断言旧快照)
- [ ] PingScheduler25s 触发 ping1 次 pong 丢失容忍、连续 2 次 → 发出 disconnected 信号FakeClock 推进驱动,测试 0 真实等待
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`WireProtocolT-iOS-3 所有;值见 §3.2.1——需新增常量 → 回 T-iOS-3 改契约,无魔法数字)
- **Accept**: `swift test --package-path ios/Packages/SessionCore --filter Reconnect` 等全绿
#### T-iOS-6 · `GateState` + `AwayDigest` reducer `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `SessionCore/Sources/…/{GateState,AwayDigest}.swift`、对应 Tests
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/7/8
- **Steps(测试先行, RED)**:
- [ ] status 帧 `pending:false→true` 上升沿 epoch +1同一 pending 持续不再 +1
- [ ] 携带过期 epoch 的 approve → 判定丢弃(防止批到"下一个 gate"
- [ ] `gate:'plan'``'tool'` 分别映射三选一/两选一 affordance 数据
- [ ] digest reduceevents 里 3 tool + 1 waiting + done → `{toolRuns:3, waitingCount:1, sawDone:true}`
- [ ] `since` 过滤:早于离开时刻的事件不计入
- [ ] `limit` 截断 recent空 events → 全零 digestUI 可据此不渲染)
- **Accept**: 对应 filter 全绿reducer 纯函数、不可变
#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~0.5 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/HostRegistry/**`(含 `SecItemShim.swift` 与测试替身 `InMemoryHostStore.swift`——放 Sources供本包与 App 层 VM 测试 import
- **Depends**: T-iOS-3 · **Parallel-safe**: T-iOS-5/6/8
- **Steps(测试先行, RED)** — 用 InMemory 替身测协议契约Keychain 实现经 `SecItemShim` 缝测:
- [ ] upsert 新 host → 集合含它;同 id 再 upsert → 替换不重复
- [ ] remove 不存在的 id → 集合不变、不 throw 隐患(显式结果)
- [ ] `KeychainHostStore` 逻辑对 fake `SecItemShim`add/update/delete 走对分支、错误码显式映射(**不可**在 `swift test` 直连真 Keychain——unsigned 测试二进制对 data-protection keychain 必得 `-34018 errSecMissingEntitlement`;省掉 `kSecUseDataProtectionKeychain` 又会落到 legacy 文件 keychain、验证不了 §5.3 语义)
- [ ] lastSessionId 存取/清除
-`HostEndpoint` originHeader/wsURL 派生向量已移入 T-iOS-3 的 WireProtocol 测试——本包不再定义该类型)
- **Steps(实现, GREEN)**: [ ] `SecItemShim` 协议 + 真实现(`kSecUseDataProtectionKeychain` + AfterFirstUnlockThisDeviceOnly[ ] UserDefaults LastSessionStore [ ] 真 Keychain 路径 + `kSecAttrAccessible` 属性断言放 **xcodebuild 模拟器测试**(签名宿主 AppT-iOS-15/16 管线)
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9KeychainHostStore 以 shim 注入计入)
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走review 时对照 §5.3。
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
- **Wave/阶段**: W1 / P0 · **Owns**: `ios/Packages/APIClient/**`
- **Depends**: T-iOS-3、T-iOS-4 · **Parallel-safe**: T-iOS-5/6/7
- **Steps(测试先行, RED)** — `Tests/APIClientTests/{RequestBuilderTests,PairingProbeTests,ModelDecodingTests}.swift`:
- [ ] **Origin 出现当且仅当 G 端点**`liveSessions/preview/events/uiConfig` 请求无 Origin header`killSession/hookDecision` 有且逐字符等于 `endpoint.originHeader`
- [ ] `LiveSessionInfo` 解码:全字段样本 + `telemetry` 缺失样本src/types.ts:246-256 形状)
- [ ] `TimelineEvent` 解码 + 未知 `class` 值 → 该条丢弃不 crash服务器视为不可信
- [ ] `hookDecision` body 形状 `{sessionId,decision,token}`403 → 显式错误token 过期话术)
- [ ] 探针①失败分支:连接拒绝 → `hostUnreachable`;返回 HTML → `httpOkButNotWebTerminal`
- [ ] 探针②失败分支WS 401 → `originRejected(hint:…)`hint 含 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`,与 App 连接的 URL 一致——不含任何 ":443 迷信"
- [ ] 探针全通 → `Result.success(HostEndpoint)`契约裁定Host 由 T-iOS-12 VM 构造,见 §3.4 注);探针成功路径里 attach(null) 后必发 kill不留孤儿会话
- [ ] 超时FakeClock`.timeout`
- **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment按端点精确`hookDecision` body ≤ 4 KBsrc/server.ts:503、≤10 次/分/IPsrc/server.ts:72,504-508——P1 增量端点的限额归 T-iOS-38
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
- **安全注**: G/RO 分界是安全语义§5.1),测试名里写明"iff"。
#### W2 · 连接核心
#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~1 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/URLSessionTermTransport.swift``Tests/…/URLSessionTermTransportTests.swift`
- **Depends**: T-iOS-2spike 结论、T-iOS-3 · **Parallel-safe**: T-iOS-10接口并行
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo测试内起 `NWListener` 或复用 IntegrationTests 服务器):
- [ ] 升级请求含 `Origin` header 且等于 endpoint.originHeader
- [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`16 MiB——直接断言 task 配置
- [ ] receive 循环持续 re-arm连发 100 帧全部到达、顺序不乱
- [ ] 服务器关闭close frame→ frames stream finish错误 → stream throw两种可区分
- [ ] receive 失败 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"——T-iOS-2 spike 实测;原文 ENOBUFS 为笔误55(ENOBUFS) 作兜底同判——超 `maximumMessageSize` 在 iOS 上**不是** 1009 干净关闭)→ stream throw **类型化 `.replayTooLarge` 错误**(供 engine 识别为不可重试)
- [ ] `close()` 后 send → 显式错误,不 crash
- [ ] 非文本binary帧 → 丢弃并继续收(服务器只发文本帧,但不信任它)
- **Steps(实现, GREEN)**: [ ] `URLSessionWebSocketDelegate`didOpen/didClose 驱动状态,不靠 receive error 猜)[ ] ping 由 PingScheduler 注入驱动
- **Accept**: filter 全绿T-iOS-16 的真服务器测试是它的最终验收
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~1.5 pd
- **Wave/阶段**: W2 / P0 · **Owns**: `SessionCore/Sources/…/{SessionEngine,SessionEvent}.swift``Tests/…/SessionEngineTests.swift`
- **Depends**: T-iOS-3/4/5/6接口、T-iOS-9集成汇合 · **Parallel-safe**: T-iOS-9
- **Steps(测试先行, RED)** — 全部对 FakeTransport
- [ ] `open()` 后**首帧必为 attach**,之前的 send 排队不越序src/server.ts:707-711 语义)
- [ ] `attached` 帧 → `adopted(sessionId:)` 事件;**未知 UUID 拿到新 id 时采用新 id**src/session/manager.ts:166-173
- [ ] 回放→实时顺序attach 后灌 3 帧 output → events 按序交付、无丢帧
- [ ] `exit(code:-1, reason:)``exited` 事件且 engine 停止重连spawn 失败不重试)
- [ ] 断线 → `connection(.reconnecting(attempt:next:))` 事件流 + FakeClock 推进后自动重连、重新 attach **同一 sessionId**
- [ ] `notifyForegrounded(dims:)` → 重连后补发 `resize`latest-writer-wins已连接时只发 resize 不重连
- [ ] gate 时序status(pending:true) → `gate(GateState(epoch:1))`approve 后 pending:false → `gate(nil)`epoch 过期的 approve 不发送
- [ ] 重连成功后拉 events 归纳 → 恰好一次 `digest` 事件(经 init 的 `eventsSource` 参数注入 fake events 源§3.2
- [ ] transport 抛 `.replayTooLarge``connection(.failed(.replayTooLarge))` 事件、**停止重连**(不可进 backoff 循环——否则确定性无限重试)
- [ ] `close()` → transport.close 被调、events stream finish、无泄漏 task用 confirmation 断言)
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] attach 后立即补发一次 resize服务器 80×24 spawnsrc/server.ts:714-717
- **Accept**: `swift test --package-path ios/Packages/SessionCore` 全绿SessionCore 包覆盖率 ≥ 80%
- **安全注**: 每帧过 `MessageCodec.decodeServer`nil → 丢弃计数(日志),绝不 crash。
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/TerminalScreen.swift``Components/{KeyBar,ReconnectBanner}.swift``ViewModels/TerminalViewModel.swift``SessionCore/Sources/SessionCore/KeyByteMap.swift` + `SessionCore/Tests/…/KeyByteMapTests.swift`(字节表为纯数据,源与测试都在包内——本任务是 W3 唯一持有 SessionCore 文件者T-iOS-12/13/14 不碰 SessionCore、W1/W2 的 SessionCore owner 已完工,并行安全;纯数据+测试计入 SessionCore 覆盖率门,只帮不损)
- **Depends**: T-iOS-10接口 · **Parallel-safe**: T-iOS-12/13/14
- **Steps(测试先行, RED)**:
- [ ] 字节表逐键对照 `public/keybar.ts`Esc=`\u{1B}`、Esc·Esc、Shift+Tab=`\u{1B}[Z`、↑↓←→=`\u{1B}[A/B/D/C`、Enter=`\r`**不是 `\n`**、Ctrl-C=`\u{03}`、Ctrl-R/O/L/T/B/D、Tab=`\t``/`
- [ ] VMengine 发 `output``feed` 调用在 MainActor编译期由 Swift 6 保证,测试断言转发次序)
- [ ] VM`connection(.reconnecting)` → banner 状态量;`.connected` → 隐藏
- [ ] VM`connection(.failed(.replayTooLarge))` → 不再重试的错误态 + 可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
- [ ] VM`exited` → 终端只读 + exit 提示状态
- **Steps(实现, GREEN)**: [ ] `UIViewRepresentable``SwiftTerm.TerminalView`delegate `send``engine.send(.input)``sizeChanged``.resize` [ ] KeyBar 为 `inputAccessoryView`,直发 engine绕开 TerminalView 避免软键盘弹出逻辑干扰)[ ] 硬件键盘 `UIKeyCommand` 同映射 [ ] KeyBar 按钮与 `UIKeyCommand` 的标签→字节解析**一律经 `KeyByteMap` 常量**(单一事实源,镜像 `public/keybar.ts`[ ] IME不加自己的 keydown 拦截SwiftTerm 自管 composition
- **Accept**: 字节表测试全绿;模拟器人工冒烟(真机压在 T-iOS-18
#### T-iOS-12 · `PairingScreen`QR + 手输 + 探针 UI`[ ]` · ~0.5 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/PairingScreen.swift``ViewModels/PairingViewModel.swift`
- **Depends**: T-iOS-7/8接口 · **Parallel-safe**: T-iOS-11/13/14
- **Steps(测试先行, RED)** — VM 层(探针逻辑已在 T-iOS-8 测过,这里测状态映射):
- [ ] 每种 `PairingError` → 对应内联话术与"去设置/重试"动作(`localNetworkDenied` → 打开设置深链;`atsBlocked` → "明文 HTTP 被 ATS 拦截——该 IP 段不在 App 例外列表内,请用 https/`tailscale serve` 或反馈该网段"
- [ ] **扫码 → `confirmingHost` 确认态**:展示解析出的 `scheme://host:port`(经 `HostEndpoint` 单点解析,禁止手拼),**用户未点"连接"前对该 host 零网络请求**FakeHTTPTransport/FakeTransport 断言零调用——探针①就会联网、②会在目标机 spawn 会话,扫码内容是不可信外部输入)
- [ ] 确认后才:跑两步探针 → 成功 → host 入 store + 跳转列表
- [ ] 扫码结果非 http(s) URL → 拒绝并提示(输入边界验证)
- [ ] 警告分层§5.4**在确认页展示**):公网 host**http/https 均**——RFC1918、100.64/10、loopback、`.local``*.ts.net` 视为私网级,其余一律公网警告)→ 醒目警告;`ws://`+RFC1918/link-local → 非阻断明文嗅探提示100.64/10 或 `*.ts.net` → 无明文警告Tailscale 豁免loopback → 无
- **Steps(实现, GREEN)**: [ ] `DataScannerViewController`(真机 only模拟器隐藏入口依赖 §5.2 `NSCameraUsageDescription`)读 web UI `qr.ts` 的 origin URL [ ] 手输表单 fallback用户自己输入的 URL 身份已知,可直连探针;复用确认态亦可)[ ] 多 host 切换入口(列表页 header 用)
- **Accept**: VM 测试全绿;模拟器手输路径可配对本机服务器
#### T-iOS-13 · `SessionListScreen`(合并 chooser + dashboard`[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Screens/SessionListScreen.swift``Components/TelemetryChips.swift``ViewModels/SessionListViewModel.swift`
- **Depends**: T-iOS-8接口 · **Parallel-safe**: T-iOS-11/12/14
- **Steps(测试先行, RED)** — VM 对 FakeHTTPTransport:
- [ ] 轮询节奏:前台每 `Tunables.listPollInterval`5 s§3.2.1)拉一次 `/live-sessions`;离开页面停止(无泄漏 timer
- [ ] 状态点映射 5 态 + `pending:true` → ⚠ 徽标优先
- [ ] telemetry staleness`at` 距今 > `Tunables.telemetryStaleTtlMs`30 s镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`)→ 芯片置灰
- [ ] swipe-to-kill → `DELETE /live-sessions/:id`(带 Origin→ 乐观移除 + 失败回滚
- [ ] 列表排序 newest-first 保持服务器顺序;`exited:true` 会话分组置底
- [ ] "+ New session" → 进入 TerminalScreen 且 `open(sessionId:nil)`
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
- **Accept**: VM 测试全绿
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~1 pd
- **Wave/阶段**: W3 / P0 · **Owns**: `App/WebTerm/Components/{GateBanner,PlanGateSheet,AwayDigestView}.swift``ViewModels/GateViewModel.swift`**独立 VM**`@MainActor @Observable`,消费 SessionEvent 的 `.gate/.digest`——不是 TerminalViewModel 的扩展,与 T-iOS-11 真并行;接入 TerminalScreen 的 wiring 归 T-iOS-15+ 对应 Tests
- **Depends**: T-iOS-6/10接口 · **Parallel-safe**: T-iOS-11/12/13
- **Steps(测试先行, RED)**:
- [ ] `gate(kind:.tool)` → 两键横幅Approve/Reject`.plan` → 三选一 sheetApprove+Auto / Approve+Review / Keep Planning
- [ ] 三选一映射(**镜像 public/tabs.ts:345-347 与 src/types.ts:84-86**Approve+Auto→`approve(mode:.acceptEdits)`、Approve+Review→`approve(mode:.default)`、Keep Planning→`reject`。**无 allowAutoMode 门**——web 端从不在 plan gate 上做此门控;`acceptEdits` 不受 SEC-M5 auto 降级影响(降级只打 raw `auto`src/server.ts:765-766raw `auto` 仅保留给未来权限模式切换器§3.1 注)
- [ ] gate 到达 → haptic 触发一次(同 gate 不重复震)
- [ ] digest 事件非全零 → 顶部渲染摘要行;全零 → 不渲染;点击展开 recent 明细
- **Steps(实现, GREEN)**: [ ] `UINotificationFeedbackGenerator` [ ] digest 自动淡出(`Tunables.digestFadeDelay` = 8 s、可手动展开
- **Accept**: VM/映射测试全绿
#### W4 · 集成(汇合点)
#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~0.5 pd
- **Wave/阶段**: W4 / P0 · **Owns**: `App/WebTerm/WebTermApp.swift`(改)、导航组装、删除 T-iOS-2 的 SpikeTerminalScreen
- **Depends**: T-iOS-1114 全部 · **Parallel-safe**: T-iOS-16/17
- **Steps**: [ ] Pairing→List→Terminal 导航 + 依赖注入(真实现 wiring`GateViewModel`T-iOS-14接入 TerminalScreen[ ] `scenePhase == .active``engine.notifyForegrounded(dims:)`(重连 + 补发 resize**这是"换设备夺回全屏"的关键**[ ] `.background` → 主动 `close()`(干净 detach不留半死 socket[ ] **隐私遮罩**`scenePhase != .active` 时终端覆盖不透明遮罩、`.active` 恢复(**必须用 `!= .active`,不能只判 `.inactive`**——覆盖切换器进入的 .inactive 与快照发生的 .background 两态iOS 后台快照会把终端内容API key/token/源码)写盘并展示在多任务切换器)[ ] 冷启动:有 lastSessionId 的 host → 列表页高亮"继续上次"
- **Accept**: 模拟器全流程手工走查配对→列表→attach→后台→前台重连回放**切后台开切换器 → 卡片显示遮罩而非终端内容**
- **安全注**: 组装点核对一次:所有 G 调用来自 APIClient无绕过、debug ATS 设置未漏进 release scheme。录屏暴露面iOS 无公开 API 把窗口排除出截屏/录屏——只可选做 `UIScreen.isCaptured` 检测(录屏时可选拉黑终端);除此之外记为已接受残余风险(本地信任模型),**不得**计划 isSecureTextEntry 层这类非受支持 hack。
#### T-iOS-16 · 集成 CI对真 Node 服务器)`[ ]` · ~0.5 pd
- **Wave/阶段**: W4 / P0仅依赖 W2——可提前并入第 6 批,见 §8 · **Owns**: `ios/IntegrationTests/**`(含吸收 T-iOS-2 的 OriginSpikeTests`.github/workflows/ios.yml`(改)
- **Depends**: T-iOS-9/10 · **Parallel-safe**: T-iOS-1115/17
- **Steps(测试清单)** — macOS runner`npm ci` → 临时端口 `npm start``ALLOWED_ORIGINS` 注入)→ Swift Testing
- [ ] attach(null) → attached → input `echo hi\r` → output 含 `hi`
- [ ] resize(120,40) 后 `stty size` 输出 `40 120`(整数边界 1/1000 各一发)
- [ ] 灌 >1 MiB 输出 → 断开 → 重 attach → 回放完整16 MiB 上限的端到端回归);**追加对抗用例**ESC/C0 控制字节密集回放JSON `\uXXXX` 转义膨胀 ~6×→ 仍完整
- [ ] 双客户端 JOIN mirrorA、B 同 attachA inputB 收 output
- [ ] 无 Origin → 401错 Origin → 401DELETE 无 Origin → 403G 守卫回归)
- [ ] `DELETE /live-sessions/:id`kill→ 镜像客户端观察到 **WS close****不是** `exit` 帧——`manager.killById``ws.close()` 全部客户端再 killsrc/session/manager.ts:202-212`sendIfOpen` 只投给 OPEN socket且该 id 从 `GET /live-sessions` 消失
- [ ] 自然退出广播路径:客户端 A input `exit\r` → 镜像客户端 B 收到 `{type:'exit'}`socket 仍开时 pty onExit 广播src/session/session.ts:146-151
- [ ] **覆盖率门接线**`ios.yml` 对 4 个包跑 §9 覆盖率循环,任一包 line coverage < 80% job
- **Accept**: CI job 绿覆盖率门**演示过一次红**故意把某包压到 80% 下或抬高阈值再回绿这是"客户端复刻的协议契约"的持续防漂移闸门
- **安全注**: 本任务是 §5.1 的自动化化身任何"为了过 CI 放宽 Origin 断言"= CRITICAL
#### T-iOS-17 · ntfy 桥验证 + 文档P0 临时通知,**零新代码**`[ ]` · ~0.1 pd
- **Wave/阶段**: W4 / P0 · **Owns**: iOS README ntfy 章节文档**不建任何脚本文件**——桥已随 `npm run setup-hooks` 出货安装逻辑 scripts/setup-hooks.mjs:227-238env :262-264重装时 marker 自清理 §0.3
- **Depends**: App 解耦 · **Parallel-safe**: T-iOS-15/16
- **Steps**: [ ] 验证既有桥 `WEBTERM_NTFY_URL` + `WEBTERM_NTFY_TOPIC`可选 `WEBTERM_NTFY_TOKEN`)→ `npm run setup-hooks` 确认日志 "Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)" [ ] README 写明env 未设即完全无副作用默认关闭已是出货行为topic 生成建议随机串topic 即密码[ ] 记录**STUCK 不在 P0 信号内**服务器 sweepStuck 派生态 hook 事件桥发不出——P1 APNs 经事件总线补上
- **Accept**: 手机装 ntfy App 订阅 topic 触发 held gate 手机收到通知P1 APNs 落地后 README 标注可停用
- **安全注**: 复核既有 payload 最小化sessionId 短前缀 + 状态词不含 cwd/命令内容token 绝不进命令字面量——SEC-C6 已保证)。
#### W5 · 验收report-onlyG4只报告不改码findings 标 owning task 派回)
#### T-iOS-18 · 验收走查 F-iOS-1…13真机`[ ]` · ~0.25 pd
- **Depends**: T-iOS-15/16/17 · **Owns**: 无源码report-only
- **Steps**: §9 验收脚本逐条执行记录结论与录屏
#### T-iOS-19 · 安全核对 `[ ]` · ~0.25 pd
- **Depends**: T-iOS-15 · **Owns**: 无源码(report-only)
- **Steps**: [ ] 对照 TECH_DOC §7 + 本文 §5 逐条核Origin 单点派生G/RO 分界ATS release 实况 ipa Info.plist——**五段 CIDR 逐段核对**debug `NSAllowsArbitraryLoads` 会掩盖缺段)、隐私 usage description 与实际使用的 capability **一一对应**相机/本地网络P2 加麦克风/语音识别—— ipa 核对)、Keychain 属性模拟器测试断言 `kSecAttrAccessible`)、ntfy payload 最小化无硬编码 host/密钥警告分层文案在位公网阻断 + RFC1918 明文提示 + Tailscale 豁免)、**真机核切后台开切换器 快照卡片是遮罩非终端内容**。
**P0 合计 ≈ 13 人天**0.5+1+1.25+0.25 / 0.5+0.5+0.5+1 / 1+1.5 / 1+0.5+1+1 / 0.5+0.5+0.1 / 0.25+0.25)。
---
### P1 — walk-away 完整("锁屏上两次手势搞定")· 合计 ~17 人天
> 波次:**W6服务器触点T-iOS-20 ∥ T-iOS-37串行入库各自 repo 流程)与 W7 并行开跑**——W7 里只有 T-iOS-21 依赖 T-iOS-20payload 形状、T-iOS-23 依赖 T-iOS-37lastOutputAt 字段),其余 W7 任务T-iOS-22/24/25/27/28/29不等 W6T-iOS-38APIClient P1 契约增量)在 W7 首发T-iOS-21/26 依赖它 → W8验收。任务粒度与 P0 同规格;此处 Steps(测试) 列关键用例,细化在开工时由任务 owner 补足(不改接口)。
#### T-iOS-20 · server: APNs sender + token 注册端点 `[ ]` · ~2 pd
- **Wave**: W6 · **Owns**: `src/push/apns.ts`)、`src/server.ts` 增量 route`test/push-apns.test.ts`**服务器触点TypeScript 任务**遵循根仓库 PLAN 工作流
- **Depends**: · **Parallel-safe**: T-iOS-37/38 W7 T-iOS-21 外全部接口先行
- **Steps(测试先行)**: [ ] `.p8` 缺失 功能整体 disabled启动不 crash [ ] hook 事件 APNs payload 形状 `/hook/decision` 用的 capability token + category[ ] token 注册端点G 守卫 403限频 429幂等注册/注销 [ ] 与既有 web-push 并行互不干扰
- **Steps(实现)**: [ ] HTTP/2 `api.push.apple.com``.p8` env 路径无硬编码密钥[ ] 复用 `src/push/` 的事件订阅点
- **安全注**: capability token 语义不变单次过期10/min 限频src/server.ts:503-525APNs payload 不含命令内容
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[ ]` · ~0.25 pd
- **Wave**: W6 · **Owns**: `src/types.ts` `LiveSessionInfo` 增量字段`src/session/manager.ts` `list()` 一行映射并更新其 "omitted by design" 注释)、对应测试增量**声明的服务器触点TypeScript 任务**遵循根仓库 PLAN 工作流 §0.3
- **Depends**: · **Parallel-safe**: T-iOS-20W7 全部
- **Steps(测试先行)**: [ ] `GET /live-sessions` 响应含 `lastOutputAt`服务器已逐 `pty.onData` 维护src/types.ts:211/M3——只是序列化出来[ ] 旧客户端兼容字段为**新增可选**web 前端不受影响
- **Accept**: 根仓库 `npm test` 全绿T-iOS-23 unread 水位有数据源
#### T-iOS-38 · `APIClient` P1 契约增量W7 首发,其余 W7 任务的 APIClient 单一 owner`[ ]` · ~0.5 pd
- **Wave**: W7首发 · **Owns**: `ios/Packages/APIClient/**` **全部 P1 增量**APNs token 注册 builder`/projects``/projects/detail?path=``GET/PUT /prefs` builders 与解码 + Tests)——W7 期间 APIClient 文件**只有本任务可改**对齐 T-iOS-3 冻结契约模式避免 T-iOS-21/26 同波踩踏
- **Depends**: T-iOS-8T-iOS-20 token 注册 builder 的端点形状——可接口先行并行编码形状定稿时汇合 · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29均不碰 APIClient 文件
- **Steps(测试先行)**: [ ] token 注册 builderG Origin[ ] projects/detail/prefs buildersPUT Origin与解码 [ ] doc comment 端点约束push subscribe body 8 KB、≤5 //IPsrc/server.ts:73,461-466`PUT /prefs` 64 KBsrc/server.ts:278
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿覆盖率 80%
#### T-iOS-21 · PushRegistrar + 锁屏 Allow/Deny `[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Push/{PushRegistrar,NotificationActionHandler}.swift` + 对应 TestsAPIClient token 注册 builder T-iOS-38
- **Depends**: T-iOS-20payload 形状)、T-iOS-38token 注册 builder · **Parallel-safe**: T-iOS-2229
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/Deny 注册形状——**Allow 动作必须带 `UNNotificationActionOptions.authenticationRequired`**锁屏批准 = 授权主机执行命令;旁观者拿到锁屏手机只能 Deny——fail-safe断言注册 category Allow 选项含 `.authenticationRequired`且两动作均**不含** `.foreground`[ ] action handler payload `{sessionId, token}` `POST /hook/decision` Origin)→ **不启动 App UI**Face ID 设备上"两次手势 + 一瞥"闭环[ ] token 用后即弃不落盘 [ ] 决策失败token 过期 403)→ 补一条本地通知提示进 App 处理
- **安全注**: Allow/Deny 由系统**后台拉起主 App**、送达 `UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)`——** extension 参与**本工程没有 notification extension targetService Extension 只能改写来押通知收不到 action tap)。handler `beginBackgroundTask/endBackgroundTask` 包住 POST请求完成/失败后才调 completion handler失败必须可见本地通知兜底绝不静默吞
#### T-iOS-22 · DeepLinkRouter `[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/DeepLinkRouter.swift` + Tests
- **Steps(测试先行)**: [ ] `webterminal://open?host=<id>&join=<uuid>`UUID v4 校验复用 `Validation`非法 忽略并留日志 [ ] 未知 host id 落到配对页并提示 [ ] /热启动两路径都直达 gated 会话 [ ] push tap 同一路由
- **安全注**: deep link 是外部输入——全字段白名单校验绝不据此直接拼 URL 请求
#### T-iOS-23 · 多会话切换器unread dots + OSC 标题)`[ ]` · ~2 pd
- **Wave**: W7 · **Owns**: `SessionListScreen` 增强**W7 内该文件唯一 owner** T-iOS-29 移交的列表侧入口)、`SessionCore` unread 记账`UnreadLedger.swift`)、标题净化器`TitleSanitizer.swift`+ Tests
- **Depends**: T-iOS-37`lastOutputAt` 字段 · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] 单活 WS 不变切会话 = close→open回放恢复 [ ] unread 判定`/live-sessions` 快照的 `lastOutputAt`T-iOS-37 新增字段> 本地 last-seen 水位 → unread 点 [ ] OSC 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符U+200B200F、U+202A202E、U+20662069——OSC 字符串解析已排除 C0真正的仿冒向量是 bidi/零宽);渲染用 `Text(verbatim:)`(绝不走 LocalizedStringKey/Markdown+ `.lineLimit(1)` 截断 [ ] 敌意标题解码测试超长、U+202E payload、emoji 洪泛 → 断言净化输出 [ ] 切换 <1s 观感回放解析在后台feed main
#### T-iOS-24 · Timeline sheet完整时间线钻取`[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/TimelineSheet.swift` + VM 测试
- **Steps(测试先行)**: [ ] `/live-sessions/:id/events` 全量渲染class 图标/颜色映射[ ] timeline disabled空数组)→ 空态而非错误 [ ] digest "展开"入口进入
#### T-iOS-25 · Quick-reply chips + 常用语面板 `[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/QuickReply.swift`本地存储UserDefaults+ Tests
- **Steps(测试先行)**: [ ] chip 点击 `input` 文本 + `\r`[ ] 自定义面板增删改序 [ ] waiting 状态才浮出对齐 web `quick-reply.ts` 行为
#### T-iOS-26 · Projects列表 + 详情 + 在仓库起 Claude `[ ]` · ~2 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Testsprojects/detail/prefs APIClient builders T-iOS-38本任务只消费
- **Depends**: T-iOS-38builders · **Parallel-safe**: T-iOS-21/22/23/24/25/27/28/29
- **Steps(测试先行)**: [ ] VM 消费 `/projects``/projects/detail?path=``GET/PUT /prefs`builder 与解码测试在 T-iOS-38[ ] favourites 同步 [ ] "在此仓库开新会话" = `attach(null, cwd)` + 注入 `claude\r` [ ] detail 400/404/500 `{error}` 显式路径
#### T-iOS-27 · Diff 查看器(只读)`[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/DiffScreen.swift` + VM 测试
- **Steps(测试先行)**: [ ] `DiffResult{files,staged,truncated}` 渲染truncated 提示 [ ] staged/unstaged 切换 [ ] path 非法 404 友好错误
#### T-iOS-28 · 会话缩略图offscreen SwiftTerm`[ ]` · ~1.5 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Components/SessionThumbnail.swift` + 快照测试
- **Steps(测试先行)**: [ ] `GET /live-sessions/:id/preview``{id,cols,rows,data}`24KB tail)→ 离屏 TerminalView feed 快照图 [ ] 列表滚动不掉帧离屏渲染限并发[ ] 404 占位图
#### T-iOS-29 · 杂项闭环new-in-cwd + 退出会话清理 `[ ]` · ~1 pd
- **Wave**: W7 · **Owns**: `TerminalScreen` 的小增量 + Tests**不碰 `SessionListScreen`**——列表侧入口/行项变更移交 T-iOS-23该文件 W7 内单一 owner
- **Depends**: T-iOS-23列表侧入口 · **Parallel-safe**: T-iOS-21/22/24/25/26/27/28
- **Steps(测试先行)**: [ ] "在当前会话 cwd 开新会话"`attach(null, cwd)`[ ] exited 会话点开 回放 + exit 横幅 + "开新会话"动作src/session/manager.ts:145-153 语义
#### T-iOS-30 · P1 验收 + 安全复核 `[ ]` · ~1 pdreport-only
- **Steps**: [ ] F-iOS-14/15(§9真机走查 [ ] 安全APNs payload 审计token 生命周期deep link fuzz通知在锁屏的预览泄露面默认隐藏内容验证)、**锁屏 Allow 动作必须 `.authenticationRequired`真机验锁屏 Allow Face ID/通行码Deny 无需解锁**。
**P1 合计 ≈ 17.5 人天**含新增 T-iOS-37 0.25 + T-iOS-38 0.5T-iOS-21/26 相应减负)。
---
### P2 — 打磨 · 合计 ~8 人天
> P2 任务此处只给**分派必需元数据**Wave/Owns/Depends/Accept完整 RED 测试清单在开工时由任务 owner 按 P0 规格扩写(不改 §3 接口)。**未扩写前不得按下述描述直接分派**§4 TDD 强制与 §6 Owns 铁律同样适用)。
- **T-iOS-31** · 语音 PTT + 确认端口匹配器 / 1.5s 撤销 / epoch 防误发`[ ]` ~2.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/VoicePTT.swift` + VM Testsepoch 防误发若需 SessionCore 新接口 T-iOS-6 owner SessionCore 不直改)· **Depends**: T-iOS-6/11**前置**Info.plist `NSMicrophoneUsageDescription` + `NSSpeechRecognitionUsageDescription`(§5.2 )· **Accept**: VM 测试 + 真机口述确认注入 input
- **T-iOS-32** · Worktree 创建`POST /projects/worktree`G+ `claude --resume <id>` 历史`GET /sessions``[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Screens/WorktreeSheet.swift` + TestsAPIClient builders T-iOS-38 owner 模式回 APIClient )· **Depends**: T-iOS-26/38 · **Accept**: builder 测试 + 端到端一次
- **T-iOS-33** · 终端内搜索 `[ ]` ~1 pd。**Wave**: W9 · **Owns**: `App/WebTerm/Components/TerminalSearchBar.swift` + Tests · **Depends**: T-iOS-11 · **Accept**: SwiftTerm search API 命中高亮
- **T-iOS-34** · 主题 + Dynamic Type `[ ]` ~1.5 pd。**Wave**: W9 · **Owns**: 主题/字号小增量与同波任务文件不相交开工时列明文件清单)· **Depends**: T-iOS-11/13 · **Accept**: 亮暗主题 + 最大字号不破版
- **T-iOS-35** · web `?join=` 互通分享 QR 双向`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话
- **T-iOS-36** · P2 验收 `[ ]` ~1 pdreport-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-3135
**总计P0 13 + P1 17.5 + P2 8 ≈ 38.5 人天。**
---
## 8. 分派批次与模型/隔离分配(多 agent 用)
| 批次 | 可同时开工 | 说明 |
|------|-----------|------|
| 1 | T-iOS-1 | 串行工程骨架 |
| 2 | T-iOS-2 T-iOS-3 | spike 与契约并行都只依赖骨架 |
| 3 | T-iOS-4 | 替身可并入第 4 批首个 agent |
| 4 | **T-iOS-5,6,7,8** | W1 四叶子全并行 |
| 5 | T-iOS-9 T-iOS-10 | 接口已冻结可并行编码集成时汇合 |
| 6 | **T-iOS-11,12,13,14** T-iOS-16 T-iOS-17 | W3 UI 四路并行各对替身开发T-iOS-16 只依赖 W2提前把协议防漂移闸门变绿)、T-iOS-17 零依赖 |
| 7 | T-iOS-15 | 汇合接线依赖 T-iOS-1114 |
| 8 | T-iOS-18 T-iOS-19 | 验收 report-only |
| P1 | T-iOS-20 T-iOS-37 T-iOS-38 其余 W7 并行 21 20/3823 3726 38 | W6 W7 不整体串行 P1 波次注 |
> 单批 ~35 agent 甜区worktree 前提:**W0T-iOS-1…4先 commit**,再派 worktree 隔离的并行 builder。
| 任务 | 类型 | 建议模型 | 隔离 | 理由 |
|------|------|----------|------|------|
| T-iOS-1 脚手架 | builder | sonnet | | XcodeGen/Swift6 配置排错串行 |
| T-iOS-2 spike | builder | sonnet | | 平台事实一锤定音需真机人工配合 |
| T-iOS-3 WireProtocol | builder | sonnet | | 契约精度要紧全员依赖根 |
| T-iOS-4 替身 | builder | haiku | | 小而机械 |
| T-iOS-5 Reconnect+Ping | builder | sonnet | worktree | 纯状态机时序推理 |
| T-iOS-6 Gate+Digest | builder | sonnet | worktree | epoch 语义是防误批关键 |
| T-iOS-7 HostRegistry | builder | haiku | worktree | 薄封装 + 明确规格 |
| T-iOS-8 APIClient | builder | sonnet | worktree | Origin iff-G 是安全语义 |
| T-iOS-9 WSTransport | builder | sonnet | worktree | delegate/re-arm 细节多 |
| T-iOS-10 SessionEngine | builder | **opus** | worktree | 并发/生命周期/重连时序最硬的模块 |
| T-iOS-11 Terminal+KeyBar | builder | sonnet | worktree | UIKit 桥接 + 字节表 |
| T-iOS-12 Pairing | builder | haiku | worktree | VM 映射为主 |
| T-iOS-13 SessionList | builder | sonnet | worktree | 轮询/TTL/乐观更新 |
| T-iOS-14 Gate UI | builder | sonnet | worktree | 三选一acceptEdits/default/reject allowAutoMode |
| T-iOS-15 接线 | builder | sonnet | | 汇合点串行 |
| T-iOS-16 集成 CI | builder | sonnet | | 真服务器时序断言 + 覆盖率门接线 |
| T-iOS-17 ntfy 验证 | builder | haiku | | 验证既有桥 + 文档零新代码 |
| T-iOS-18/19 验收 | **reviewer** | sonnet | | 只读报告修复回流 owner |
| T-iOS-20 APNs(server) | builder | **opus** | | server 触点 + 密钥/token 语义 |
| T-iOS-37 lastOutputAt(server) | builder | haiku | | server 触点一字段 + 一行映射 + 测试TS 任务 |
| T-iOS-38 APIClient P1 契约 | builder | sonnet | | W7 首发APIClient 单一 owner其余任务依赖它 |
| T-iOS-2129 | builder | sonnet(28 haiku) | worktree | 常规并行 |
| T-iOS-30/36 验收 | reviewer | sonnet | | report-only |
| T-iOS-3135P2 | builder | sonnet(33/34/35 haiku) | worktree | P2 节元数据分派未扩写 RED 清单前不派 |
---
## 9. 测试与验收
### TDD 工作流
每任务**RED** Steps(测试) 先写失败测试)→ **GREEN**最小实现过测)→ **REFACTOR**对照 §4 清单)。测试命名讲行为`test("未知 UUID attach 后采用服务器新发的 id")`AAA 结构
### 覆盖率门(≥ 80%,只量 4 个包)
```bash
for p in WireProtocol SessionCore HostRegistry APIClient; do
swift test --package-path ios/Packages/$p --enable-code-coverage
BIN="$(swift build --package-path ios/Packages/$p --show-bin-path)/${p}PackageTests.xctest/Contents/MacOS/${p}PackageTests"
PROF="$(swift test --package-path ios/Packages/$p --show-codecov-path | xargs dirname)/default.profdata"
# 只量生产代码:排除测试目标自身、TestSupport 替身、SwiftPM 生成的 runner shim
# (不排除的话 Tests/** 近 100% 覆盖会虚抬 TOTAL);jq -e 低于阈值退出非零 → CI 红
xcrun llvm-cov export -summary-only "$BIN" -instr-profile "$PROF" \
-ignore-filename-regex '(Tests|TestSupport|\.build)/' \
| jq -e '.data[0].totals.lines.percent >= 80'
done # 每包 line coverage ≥ 80%,CI 强制(接线与红/绿演示归 T-iOS-16)
```
> **KeychainHostStore 计法**`swift test` 覆盖的是经 `SecItemShim` 注入 fake 的 store 逻辑unsigned 测试二进制拿不到 data-protection keychain-34018真 Keychain 路径 + `kSecAttrAccessible` 断言在 xcodebuild 模拟器测试(签名宿主)里跑,不计入本门。
### 集成测试(真 Node 服务器)
CI macOS runner`npm ci` `PORT=<ephemeral> ALLOWED_ORIGINS=… npm start` `swift test --package-path ios/IntegrationTests`用例见 T-iOS-16)。这层持续看护"客户端复刻协议"与服务器实现之间的契约漂移也是 Origin spike 的常驻化
### 设备矩阵
| | 环境 |
|---|---|
| 单元/ | macOSswift test无模拟器 |
| App/XCUITest | iPhone 16 模拟器iOS 26 SDK+ iOS 17 最低目标模拟器各一轮 |
| 真机必测项 | 键盘/IME/key-barQR 扫码hapticsLocal Network 弹窗ntfy/APNs 通知锁屏动作1 台实机iOS 18+ |
XCUITest 只保一条 happy path配对attach输入gate approve脆弱面最小化)。
### 验收演示脚本F-style对齐 ARCHITECTURE §8
- **F-iOS-1** iPhone Mac web UI QR 配对探针通过 host 入列
- **F-iOS-2** 会话列表显示 Mac 上运行中的会话状态点telemetry 芯片cwd
- **F-iOS-3** 点开会话 全量 scrollback 回放预灌 >1 MiB 输出 + ESC/C0 密集输出,验证 16 MiB 帧上限)→ 实时流续上。
- **F-iOS-4** 打字、跑 `vim`/`top`、旋转屏幕 → resize 生效、TUI 不错位。
- **F-iOS-5** key-barEsc/Shift+Tab/方向/Ctrl-C 生效;中文 IME 输入不乱码不重复。
- **F-iOS-6** Claude 触发 tool gate → 手机横幅 + 震动 → Approve/Reject 生效plan gate → 三选一 sheet**Approve+Auto 发送 `mode=acceptEdits`(与 web 端一致public/tabs.ts:345-347**。
- **F-iOS-7** 杀掉 App → 重开 → 自动回到上次会话,回放无缺。
- **F-iOS-8** 切后台 5 分钟 → 回前台 → "reconnecting…" → 自动重连 + 全屏尺寸夺回latest-writer-wins
- **F-iOS-9** 手机与浏览器同 attach 一个会话mirror双方都能打字、都见输出互不踢。
- **F-iOS-10** 离开期间 Claude 干了活 → 重连后顶部 away-digest 摘要正确。
- **F-iOS-11** held gate 时手机收到 ntfy 通知P0P1 换 APNs 后:锁屏长按 → Allow → **Face ID/通行码确认(`.authenticationRequired`** → 不开 AppMac 侧放行Face ID 设备上仍是两次手势 + 一瞥Deny 无需解锁)。
- **F-iOS-12** 负路径:服务器未白名单该 Origin → 配对页出现可操作话术(含 ALLOWED_ORIGINS 提示Local Network 被拒 → 引导话术。
- **F-iOS-13** 列表 swipe-to-kill → 会话消失Mac 侧 PTY 确认被杀exited 会话点开见末屏 + exit 横幅。
- **F-iOS-14**P1push tap 深链直达 gated 会话。
- **F-iOS-15**P1多会话切换unread 点、OSC 标题、切换回放 <1s 观感
---
## 10. 风险与开放问题
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| URLSessionWebSocketTask 自定义 Origin 实为 MED 置信度 | | T-iOS-2 Day-1 一锤定音失败 Starscream 备胎仅此一处允许第三方依赖 orchestrator 拍板 |
| SwiftTerm 键盘/IME/选择是历史雷区 | | Day-1 真机 smoke + T-iOS-11 不自加 keydown 拦截 + T-iOS-18 真机验收专项 |
| 回放单帧撞 `maximumMessageSize` | (已缓解——默认配置) | 16 MiB 常量(≥6×默认 SCROLLBACK_BYTES,JSON 控制字节转义最坏 6×) + spike 对抗复现 + T-iOS-16 常驻回归 + 超限不可重试 `.replayTooLarge` 显式错误态(绝不无限重连)。**残余**:主机把 SCROLLBACK_BYTES 调到 >~2.7 MB 或极端转义密集回放仍可超限(客户端运行时无法得知服务器配置)。**彻底消除**需服务器把回放 snapshot 分块成有界 `output` 帧(≤256 KiB,按 ring 既有 append-chunk 边界切,不割 ANSI/UTF-8,M2 语义)——另立 server 任务,落地后本行方可标"已消" |
| iOS 后台 socket 必死 → 后台会话信号有轮询延迟 | 中 | 设计即前台单 WSP1 APNs 补上"主机找手机";评审已接受 |
| ATS 对裸 IP/CIDR 的语义在 iOS 17+ 有过变更 | 中 | §5.2 三段 CIDR + `NSAllowsLocalNetworking`;真机矩阵含 iOS 18 弹窗 bug 项 |
| MagicDNS FQDN 触发完整 ATS | 中 | 话术引导用 100.x IP 或 `tailscale serve`(wss) |
| 协议在 TS 与 Swift 双实现,会漂移 | 中 | T-iOS-3 移植服务器测试向量 + T-iOS-16 真服务器 CI 常驻 |
| APNs 需付费开发者账号 + `.p8` 运维 | 中 | P0 用 ntfy 桥过渡P1 前用户拍板账号 |
| ntfy topic 泄露 = 通知可被旁观 | 低 | 随机 topic + payload 最小化T-iOS-17 安全注)|
| epoch 防误批依赖客户端自律(服务器无 epoch 概念) | 中 | GateState epoch 单测 + 验收 F-iOS-6;长期可提案服务器带 gate id另立 server 任务,不在本计划)|
| 单 WS 设计与多会话切换器P1的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
**待你拍板的开放项**
1. Bundle id / 产品名 / 图标App 叫什么?`webterminal://` scheme 是否可用/要改名?)
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点P0 期间接受自签 sideload
3. 最低系统版本iOS 17可分发下限还是 18/26个人工具availability 噪音最小)?
4. ~~P0 的 ntfy 桥要不要做成默认关闭~~ **已解决**:桥已随 `npm run setup-hooks` 出货且默认关闭(`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC` 未设即完全无副作用——无需新做T-iOS-17 只验证/写文档。
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**:推荐 `tailscale serve`wss为标准部署话术配对 UI/README 采用;绕开全部 ATS 例外与明文嗅探面,见 §5.4)。
---
## 11. 与现有文档的关系
- 本文只新增 `ios/`,加 §0.3 声明的服务器触点(**P0 零触点**——ntfy 桥复用既有 setup-hooks 实现P1 两处APNs sender + token 端点T-iOS-20`LiveSessionInfo.lastOutputAt` 字段T-iOS-37。**除触点外不改 `src/``public/`**。
- 实施中发现的 server 侧缺陷修复归属对应模块route/session/protocol 文件的 owner 任务),按 CLAUDE.md 记 `PROGRESS_LOG.md`——iOS 任务不越界改 serverT-iOS-20 作为 server 任务遵循根仓库工作流。
- 冲突裁决:**ARCHITECTURE 管 how、TECH_DOC 管 why/scope**;本文是它们之上的"原生客户端"新层,不改协议/会话模型只消费之。§0/§3 引用的 `file:line` 线协议事实以**代码现状**为准——若代码演进导致引用失效,更新本文并记 LOG。
- 与 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md) 平行desktop 是"内嵌服务器的壳"iOS 是"纯远端客户端"——二者都不改动核心,互不依赖。
- 与 relay 计划族([PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)的边界v1 明确不接 relay§5.5relay 可部署后,接入方案另立计划文档,不回改本文任务。

231
docs/PLAN_IOS_IPAD.md Normal file
View File

@@ -0,0 +1,231 @@
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1 已交付,分支 `feat/ios-client`**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
> 状态:**规划中2026-07-05未开工**。
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
> **G1 日志铁律**`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG在返回消息末尾附可粘贴条目。
---
## 0. 目标与范围
### 做什么
在 iPadiPadOS 17+)上把「口袋驾驶舱」升级为「桌面级驾驶舱」,利用大屏做手机做不到的事:
- **分栏常驻**:左 sidebar = 会话列表(+ Projects 入口),右 detail = 终端 + gate/digest 叠层——不用来回 push/pop一眼看全 + 直接介入。
- **终端更宽**iPad 全屏能放下接近桌面的列数,直接缓解「宽桌面 + 窄手机同看一个全屏 TUI 折行成竖条」的多设备张力(见 [PROGRESS_LOG](./PROGRESS_LOG.md) 该条——iPad 自己就是宽屏 writer。
- **双向布局**横竖屏、Split View、Slide Over、Stage Manager 尺寸变化全程 size-class 自适应,绝不写死方向。
- **硬件键盘为一等公民**iPad 常接键盘——现有 `UIKeyCommand` 全键位复用;软键盘 KeyBar`inputAccessoryView`)在有硬件键盘时可隐、无则保留。
- **指针/悬停**iPadOS 指针 hover 高亮、右键次要点击上下文菜单kill/新建/在 cwd 开)。
### 不做(本期范围外)
- **多窗口 / 多场景(`UISceneConfiguration` 多实例、拖拽会话到新窗口)**iPad 能开两个终端并排是诱人的,但涉及场景生命周期重构 + 每场景独立 SessionEngine单列一期见 §7 后续)。本期 = 单场景自适应分栏。
- **Stage Manager 外接显示器专属布局**:本期只保证 Stage Manager 下尺寸变化不崩、布局自适应;不做外接屏专属多窗排布。
- **Apple Pencil、拖放文件进终端、Mac Catalyst**:非目标(同 iPhone 计划 §0
- **纯逻辑包改动**`WireProtocol/SessionCore/HostRegistry/APIClient` 与设备无关,**一行不改**(若发现某常量隐含 iPhone 假设 → 回对应包 owner不在本计划直改
- **服务器 / `public/` / `src/`**零触点iPad 只是又一个说同一协议的客户端)。
### 关键约束iPhone 零回归(硬性)
**任何自适应改动必须先满足 compact 宽度 == 现有 iPhone 行为**。每个任务的验收都含一条「iPhone 模拟器全套件仍绿 + 目视无变化」。self-checkcompact size class 下走的代码路径应与改动前逐帧一致(分栏只在 regular 宽度激活)。
---
## 1. 整体架构 / 适配策略
```
size class 驱动的单一根视图AdaptiveRootView
┌─────────────────────────────┴─────────────────────────────┐
horizontalSizeClass == .compact horizontalSizeClass == .regular
iPhone / iPad Slide Over / 小分屏) iPad 全屏 / 大分屏 / Stage Manager 大窗)
│ │
现有 NavigationStack NavigationSplitView
列表 → push 终端(字节级不变) ├ sidebar: 会话列表 + Projects section
└ detail : TerminalContainerViewgate/digest 叠层)
└───────────────── 共享同一 AppCoordinator / 同一 SessionEngine ─────────────────┘
隐私遮罩ZStack 顶层,!= .active· scenePhase · deep link 全部设备无关,原样复用
```
**为什么自适应而非 iPad 分叉**`horizontalSizeClass` 是同一 App 内运行时可变量iPad 拉出 Slide Over 立刻从 regular 变 compact——分叉两套 UI 无法应对同一次运行内的尺寸切换,且双倍维护。自适应 = 一套代码、一处 size-class 分支、compact 分支就是现有已测代码。
**为什么 detail 复用 TerminalContainerView 不动**:终端 + gate + digest 的组装T-iOS-15/24 的 `TerminalContainerView`)与它挂在 push destination 还是 split detail 无关——只换外层容器内容零改。SessionEngine 单活 WS、latest-writer-wins、隐私遮罩、生命周期全部设备无关直接复用。
**列宽收益(自动,非新逻辑)**:终端列数由 SwiftTerm 的 `sizeChanged` 从其视图宽度 + 字号算出T-iOS-11 已实现detail 面板越宽 → cols 越多 → 越接近桌面宽度。**无新代码**,是分栏的自然结果。
**依赖方向**iPad 适配全部落在 **App 胶水层**`ios/App/WebTerm/**`);纯包不动;自适应「决策」抽成纯函数(`LayoutMode` 之于 size class仿 `PrivacyShadePolicy` 先例以便单测SwiftUI 布局本身靠模拟器目视 + XCUITest。
---
## 2. 目录结构(新增/改动)
```
ios/App/WebTerm/
├── Wiring/
│ ├── RootView.swift # 改:抽 compact 分支为子视图,加 regular 分支入口T-iPad-2
│ ├── AdaptiveRootView.swift # ★新size-class 分支 + LayoutMode 决策消费T-iPad-2
│ ├── SplitRootView.swift # ★新NavigationSplitViewsidebar+detailT-iPad-2
│ ├── LayoutMode.swift # ★新:纯函数 size class → LayoutMode.stack/.splitT-iPad-2
│ └── AppCoordinator.swift # 改sidebar 选中态 ↔ 现有 terminal/projects 路由桥(最小增量)
├── Screens/
│ ├── SessionListScreen.swift # 改sidebar 语境下的选中高亮 + Projects section自适应compact 不变)
│ └── ProjectsScreen.swift # 改regular 宽度下作为 sidebar section / 多列网格compact 仍是 sheet
├── Components/
│ ├── KeyBar.swift # 改硬件键盘在场时可隐T-iPad-3
│ └── TerminalContextMenu.swift # ★新:指针右键/长按上下文菜单kill/新建/cwd 开T-iPad-3
├── project.yml # 改TARGETED_DEVICE_FAMILY "1,2" + iPad 方向/plistT-iPad-1
└── WebTermTests/ , WebTermUITests/ # 各任务的测试
```
`ios/Packages/**` 零改动。
---
## 3. 契约(自适应决策的可测核)
```swift
// LayoutMode.swift — 纯函数,唯一 size-class 决策点(仿 PrivacyShadePolicy
public enum LayoutMode: Equatable { case stack, split }
public enum LayoutPolicy {
/// regular 宽度 → splitsidebar+detailcompact → stack现有 iPhone 路径)。
/// 唯一判据,禁止在视图里散落 sizeClass 分支。
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode
}
// SidebarSelection — split 模式下 sidebar 选中态(与现有 AppCoordinator 路由等价映射)
enum SidebarItem: Hashable { case session(UUID), newSession, projects }
```
隐私遮罩、scenePhase、deep link、SessionEngine 契约**沿用 iPhone 计划 §3不重定义**。
---
## 4. 工程标准
沿用 [PLAN_IOS_CLIENT.md §4](./PLAN_IOS_CLIENT.md) 全部TDD 强制、不可变、文件 ≤400 行、函数 <50 早返回无魔法数字系统边界验证显式错误conventional commits覆盖率仅计 4 个纯包)。追加两条 iPad 专属
- **size-class 分支只出现在一处**`LayoutPolicy`视图内严禁散落 `if sizeClass == …`
- **iPhone 零回归是每个任务的验收前置**compact 路径 == 改动前)。
---
## 5. 任务清单
> 状态图例 `[ ]`/`[~]`/`[x]`/`[!]` 同 iPhone 计划。ID 稳定:`T-iPad-N`。测试框架 Swift TestingXCUITest 仅 UI happy path。
### W0 · 可安装性(串行,先行)
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
- **Owns**: `ios/project.yml`device familyiPad 方向必要 plist)、`.github/workflows/ios.yml` iPad 模拟器测试腿
- **Depends**:
- **Steps(测试先行)**:
- [ ] `TARGETED_DEVICE_FAMILY` `"1"` `"1,2"`project + target level——注意 XcodeGen target 默认覆盖 iPhone 计划 W5 finding target 显式设
- [ ] iPad 方向键全开`UISupportedInterfaceOrientations~ipad` Portrait/Landscape/UpsideDowniPhone 方向键**不动**
- [ ] ATS 五段 CIDR / usage description 原样设备无关复核仍在
- [ ] `xcodegen generate` 拆构建产物 plist 断言 `UIDeviceFamily [1,2]`
- [ ] ios.yml 加一条 `xcodebuild test` 腿跑 iPad 模拟器iPad Pro 11" iPad (A16)
- **Accept**: iPad 模拟器 `xcodebuild build` 通过空跑现有全套件绿此刻仍是 iPhone 布局放大不崩产物 plist `[1,2]`
- **安全注**: device family 放开后 T-iPad-5 ipa 核对须覆盖 iPad 产物capabilityusage 一一对应不变
### W1 · 自适应导航壳(核心)
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[ ]` · ~2 pd
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`)、`Wiring/RootView.swift`现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`sidebar 选中 路由最小桥)、对应测试
- **Depends**: T-iPad-1
- **Steps(测试先行, RED)** `LayoutPolicyTests` + `SidebarSelectionTests`:
- [ ] `LayoutPolicy.mode(.compact) == .stack``.mode(.regular) == .split``.mode(nil) == .stack`未知按最保守 = 现有路径)
- [ ] SidebarItem AppCoordinator 路由等价`.session(id)` == 现有 open(id)`.newSession` == open(nil)`.projects` == 现有 projects 呈现——**同一 coordinator APIsplit 只是另一个触发面**断言不新增会话生命周期路径
- [ ] compact 分支渲染的视图树 == 改动前 `RootView`快照/结构断言隐私遮罩仍 ZStack 顶层scenePhase/deepLink/sheet 全在
- [ ] size class regularcompact 切换iPad Slide Over时选中态保持终端不重建复用 T-iOS-29 `.id(controller.id)` 稳定性断言 controller 不换
- **Steps(实现, GREEN)**:
- [ ] `AdaptiveRootView` `@Environment(\.horizontalSizeClass)` `LayoutPolicy.mode` `StackRootView`现有`SplitRootView`
- [ ] `SplitRootView` = `NavigationSplitView { sidebar } detail { TerminalContainerView 或空态 }`sidebar = SessionListScreen选中绑定+ Projects section 入口detail = 「选择或新建会话」占位
- [ ] 隐私遮罩/scenePhase/deepLink/task(bootstrap) 上提到 `AdaptiveRootView`两分支共享绝不重复布线
- **Accept**: iPad 模拟器横竖屏 + Split View 拉入拉出全程分栏/退栈自适应无崩**iPhone 模拟器全套件绿 + 目视逐屏无变化**`LayoutPolicy` 覆盖率 100%
- **安全注**: 隐私遮罩必须仍是**两分支共同的** ZStack 顶层——split detail 的终端字节同样要被 `!= .active` 遮住新增一条 iPad 遮罩目视验收Stage Manager 切走 detail 终端被遮
### W2 · 逐面适配(并行,文件互斥)
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift``Screens/TerminalScreen.swift`增量)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
- **Steps(测试先行)**:
- [ ] KeyBar 可见性纯谓词`GCKeyboard.coalesced != nil`硬件键盘在场)→ 默认隐 用户可手动切谓词单测硬件态注入
- [ ] 上下文菜单右键/长按= {在 cwd 开新会话kill复制选区}——动作复用现有 OpenRequest/killSession 通道断言不新增网络路径kill 仍带 Origin
- [ ] 指针 hover 高亮不改字节流 UI
- **Accept**: iPad 接键盘时 KeyBar 自动隐去键盘复现右键菜单在 iPad 生效iPhone 无硬件键盘时 KeyBar 行为不变
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标复用 APIClient无绕过
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[ ]` · ~1 pd
- **Owns**: `Screens/ProjectsScreen.swift`增量)、Projects/Timeline 呈现方式regular `.sheet` /`.presentationDetents` sidebar section)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
- **Steps(测试先行)**:
- [ ] Projects 网格列数随宽度自适应纯函数compact=1 列现状regular=多列)——列数决策单测
- [ ] regular 宽度下 Projects 作为 sidebar section detail compact 仍走现有 sheet分组/收藏/prefs 往返逻辑零改——只换容器
- [ ] Timeline/其它 sheet iPad 用合适 detent/尺寸不占满全屏
- **Accept**: iPad Projects 多列Timeline 合理尺寸iPhone 全部走原有 sheet 路径不变prefs 未知键往返T-iOS-38 不变量回归绿
### W3 · 验收report-only, G4
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码report-onlyfindings 派回 owner
- **Steps**:
- [ ] **F-iPad 走查**(§6 清单逐条分栏横竖屏Split View/Slide Over/Stage Manager 尺寸切换硬件键盘/指针终端更宽列数遮罩覆盖 detail
- [ ] **iPhone 零回归**iPhone 16 全套件绿 + 逐屏目视对比无变化compact 路径未动
- [ ] **安全** iPad 产物 ipa 核对UIDeviceFamily [1,2]、五段 CIDRusage 一一对应分栏后隐私遮罩仍遮 detail 终端上下文菜单/键盘触发的 G 调用仍走 APIClient Origin
- [ ] 真机项 iPad 手势/键盘/指针目视Stage Manager 实机)→ DEFERRED附手工清单
---
## 6. 测试与验收
### 设备矩阵
| | 环境 |
|---|---|
| 纯逻辑LayoutPolicy/谓词 | macOS `swift test` / 宿主 WebTermTests |
| App 自适应 | iPhone 16compact 回归+ iPad Pro 11"regular+ **iPad Split View同一次运行内 regular↔compact 切换)** |
| XCUITest | iPad happy path 1 sidebar 选会话 detail attach 输入 gate approve复用 iPhone happy path 骨架换分栏断言 |
| 真机必测 | iPad 分栏手势硬件键盘全键位指针 hover/右键Stage Manager1 DEFERRED 至有设备 |
### 验收演示脚本F-style
- **F-iPad-1** iPad 全屏 sidebar 会话列表 + detail 终端同屏选另一会话 detail 即时切换回放恢复)。
- **F-iPad-2** 横竖屏旋转 布局自适应终端 resize 重绘无错位
- **F-iPad-3** 拉出 Slide Overcompact)→ 自动退化为 stack== iPhone 布局推回全屏 恢复分栏全程会话不断终端不重建
- **F-iPad-4** iPad 接硬件键盘 KeyBar 自动隐`UIKeyCommand` 全键位可用拔键盘 KeyBar 回来
- **F-iPad-5** 指针右键会话行/终端 上下文菜单kill/新建/cwd 生效
- **F-iPad-6** iPad 全屏终端列数明显多于 iPhone接近桌面 TUI 折行大幅缓解
- **F-iPad-7** Stage Manager/切后台 detail 终端被隐私遮罩覆盖快照不泄露)。
- **F-iPad-8**回归iPhone 16 逐屏与适配前目视一致全套件绿
---
## 7. 风险与开放问题
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| `NavigationSplitView` size-class 频繁切换Slide Over 反复拉时状态/选中丢失 | | 选中态提升到 AppCoordinator设备无关单一真值T-iPad-2 专项测切换保持终端 `.id(controller.id)` 稳定性已在 P1 验证 |
| 隐私遮罩在 split detail 漏遮 | 安全 | 遮罩上提到 AdaptiveRootView 两分支共享 ZStack 顶层T-iPad-2/5 双重目视 |
| iPhone 回归compact 分支被无意改动 | | 每任务验收前置 iPhone 全绿 + 目视compact 分支复用现有 StackRootView 原样 |
| SwiftTerm 在超宽 detail 的性能/选择手感 | | 复用 iPhone 已测路径真机目视 |
| 多窗口诱惑导致 scope 膨胀 | | 本期明确单场景(§0 非目标多窗口另立计划 |
**待你拍板**
1. iPad 最低系统版本iPadOS 17 iPhone 一致还是抬到 18/26
2. 本期是否要指针右键上下文菜单T-iPad-3 后半)——纯锦上添花可砍到后续
3. 多窗口拖会话开新窗并排两终端确认放到下一期
**工作量合计**W0 0.5 + W1 2 + W2342 + W3 0.5 **5 人日**并行后墙钟更短)。
---
## 8. 与现有文档的关系
- 本文只改 `ios/App/WebTerm/**` + `project.yml`/`ios.yml``ios/Packages/**``src/``public/` 零改动
- [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) **布局适配层**复用其 §3 契约4 标准5 安全6 并行规则把该计划 §0 非目标里的iPad 优化布局提取为本期
- 多设备共享 PTY 的尺寸张力[PROGRESS_LOG](./PROGRESS_LOG.md) 记录的宽桌面+窄手机折行」):iPad 因自身是宽屏 writer **天然缓解**非本计划新机制
- 冲突裁决iPhone 计划管协议/会话/安全模型本文只加自适应布局不改之

View File

@@ -0,0 +1,79 @@
# Phase 0 — single-host runnable relay (build spec)
> Goal: a single `npm start` that boots an **end-to-end relay you can click through locally** —
> real P4(crypto)+P5(auth) wiring, in-memory stores, self-signed TLS, agent dialing `localhost`,
> `relay-web` served. No Postgres/Redis/KMS. This is the executable spec for the Phase 0 milestone
> in [DEPLOY_RELAY.md](./DEPLOY_RELAY.md) §4. Reuses the wiring proven in `e2e/harness/`.
>
> **Run this build in a FRESH session** (recommend `/compact` first). Read the seam files named below
> before writing — the exact interface shapes must be confirmed against the code, not assumed.
## Deliverable
A new package `relay-run/` (top-level, symlink node_modules like `e2e/`) with one entry
`relay-run/src/main.ts` that `npm start` runs, plus small wiring modules. Result: browser at
`https://localhost:8443/` → authenticates → live terminal to a shell on the same machine, spliced
through the real relay-node, E2E-encrypted (relay sees only ciphertext).
## Seam files to read FIRST (confirm exact shapes)
- `term-relay/data-plane/ws-like.ts` — the `WebSocketLike` interface the relay-node consumes.
- `term-relay/data-plane/agent-listener.ts``AgentListener`/`AgentTunnel` shapes (how an agent mux attaches).
- `term-relay/data-plane/relay-node.ts` + `upgrade.ts` + `authz-port.ts``createRelayNode`, `authorizeUpgrade`, the `Authorizer` port (`onUpgrade`/`onReattach`).
- `term-relay/mux/mux-session.ts``createMuxSession`, `MuxStreamHandle` (frame the agent side).
- `control-plane/src/main.ts``buildControlPlane`/overrides (`stores`, `verifier`, `bus`, `kmsResolver`), `createMemoryStores`, `inProcessCaSigner`.
- `agent/src/cli.ts` + `agent/src/e2e/hostEndpoint.ts` + `transport/dial.ts` — how the agent dials + runs the E2E host endpoint + loopback splice.
- `e2e/harness/world.ts` — the REFERENCE wiring: it already composes real P5 `onUpgrade` + P4 handshake + fakes. Lift the `EnforceDeps`/registry/bucket/audit construction from here (and `e2e/harness/fakes.ts`).
## Build steps (order)
1. **Scaffold `relay-run/`**: `package.json` (type module, `"start":"tsx src/main.ts"`, dep on `tsx`, `ws`), `tsconfig.json`, and `node_modules` symlinks to all relay packages + `ws`/`tsx` (mirror `e2e/`s manual-symlink approach — avoids `npm install`). Add `relay-run/node_modules` to `.gitignore` coverage (root already ignores `node_modules/`).
2. **Self-signed TLS**: generate a localhost cert at boot (node `selfsigned` or a checked-in dev cert) for the browser WSS listener; a throwaway CA for agent mTLS.
3. **Control-plane (in-memory)**: boot `buildControlPlane` with `overrides = { stores: createMemoryStores(), verifier: <real P5 verifyCapabilityToken bound to the dev P5 pubkey>, bus: in-memory, kmsResolver: inProcessCaSigner }`. Listen on `:8080`. Seed one account + issue a pairing code programmatically at boot.
4. **P5 EnforceDeps**: build the real `EnforceDeps` (hosts/sessions/revocation/buckets/audit + `stepUpPolicyFor: () => NO_STEPUP_POLICY`) backed by the in-memory control-plane registries — copy the construction from `e2e/harness`. Configure the P5 verify key (`configureVerifyKey`).
5. **`WebSocketLike` adapter**: wrap `ws` `WebSocket` → the `WebSocketLike` interface (send/close/onmessage/onclose per ws-like.ts). Browser WSS server on `:8443`; on upgrade, build the `UpgradeRequest` (extract token from subprotocol/cookie, Origin, DPoP header) and call `authorizeUpgrade` (→ real `onUpgrade`). **Close F2 here**: pass a resolved principal when the host policy requires step-up (Phase 0 default policy = not-required, so `null` is fine).
6. **`AgentListener` adapter**: mTLS `tls.Server` on `:8444`; verify the agent client cert against the dev CA (`verifyAgentCert`), attach each as an `AgentTunnel` + `createMuxSession`. Wire `createRelayNode({ config, agentListener, authorizer })` and the opaque splice.
7. **Agent**: build the CLI (`dist/cli.js`) or run `agent` in-process; enroll against control-plane `POST /enroll` with the seeded pairing code (→ SPIFFE cert + `hostContentSecret` + subdomain); dial `wss://localhost:8444` (mTLS) and run the E2E host endpoint splicing to a local shell (`LOCAL_TARGET_URL` / node-pty).
8. **relay-web**: `npm --prefix relay-web run build`; serve the bundle from the browser listener (same origin as WSS so Origin/CSWSH passes). Wire the login → P5 capability-token mint (Phase 0: a dev endpoint that mints a token after a stub human-auth, or reuse `issueCapabilityToken` directly).
9. **`main.ts`**: bring up 3→5→6→7→8 in order; print the URL + a ready-to-use token/subdomain.
## Verify
- `npm start` in `relay-run/` boots without error; console prints the local URL + subdomain.
- Browser: open `https://localhost:8443/?...`, authenticate, see a live shell; type `whoami` → output.
- Kill/restart the agent → session survives per design (reconnect); `revoke()` → stream tears down.
- Add a smoke test (Vitest) that boots the world in-process and round-trips one command E2E through
the real relay-node (extend the `e2e/harness` pattern with the real `WebSocketLike` adapter).
## Explicitly NOT in Phase 0
Postgres/Redis (in-memory only), real KMS (dev in-process signer), wildcard TLS/multi-tenant subdomain
routing, horizontal scaling, F6 replay-epoch transport (preview stays inert), metering. Those are
Phase 1/2 in DEPLOY_RELAY.md.
## Build outcome (2026-07-04) + seam drifts found
Built as `relay-run/`. **P1 achieved + verified** (4 tests green, tsc clean): an in-process
integration test round-trips a sealed payload both directions through the REAL `createRelayNode`
splice + REAL `createMuxSession`, authorized by the REAL relay-auth `onUpgrade` (Origin/CSWSH +
capability verify + DPoP PoP + single-use jti) with REAL P4 E2E; **INV2 asserted** (relay sees only
ciphertext); negative controls (foreign Origin → 401, missing DPoP → 401). **P2 achieved** (`npm start`
boots self-signed TLS + browser WSS `:8443` + agent mTLS `:8444` + control-plane `:8080`, prints a
minted capability token). **P3 not landed** (no agent dial / node-pty / relay-web serve). No audited
package modified.
**Two real integration blockers surfaced by actually wiring it (reported, not hacked):**
1. **DpopContext shape drift** — term-relay's `authz-port` `DpopContext` is `{proof, publicKeyThumbprint}`
but relay-auth needs `{proofJws, htu, htm}`. Reconciled in `relay-run/src/wiring/authorizer.ts` by
deriving `htu`/`htm` from the relay's own authority (`expectedAud`) — spec-faithful (RFC 9449), the
DPoP binding stays fully enforced. Consider aligning the port type upstream.
2. **control-plane `CapabilityVerifier.verify` is SYNC, relay-auth `verifyCapabilityToken` is ASYNC**
(WebCrypto). The real P5 verifier therefore cannot be injected into the control-plane admin routes
**programmatic account+pairing seeding is blocked in Phase 0** (control-plane boots on its default
fail-closed verifier). Fix: widen `CapabilityVerifier.verify` to return `Promise<CapabilityToken>`
(1-line additive in `control-plane/src/api/authz.ts`), then inject the real async verifier.
**Next steps to a full browser click-through:** (a) the async-verify widen above; (b) a browser-usable
DPoP carrier on the WS upgrade (subprotocol/cookie/preflight — browsers can't set a `dpop` header);
(c) run the agent (enroll → mTLS dial → E2E host endpoint → node-pty); (d) swap the Phase-0
`mtls.verifyPeer` stub for the registry-gated `verifyAgentCert` and serve the built `relay-web` bundle.
## Security notes (keep even in dev)
- Real Origin/CSWSH check on the WSS upgrade (dont stub it off for localhost — set allowedOrigins to
the dev origin). Real capability-token verify + DPoP (no bypass). Self-signed certs are dev-only;
never reuse the dev CA anywhere real. hostContentSecret 0600, never logged.

View File

@@ -24,6 +24,115 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~``~/Documents`)原样列为项目。
- **修复(TDD,先红后绿)**: ① 新增 `assignSessions` —— 每个 live 会话只归属**最深**的包含项目(`buildProjects` 改用;`buildProjectDetail` 保留前缀匹配,单项目详情页语义不变);② 新增 `dropParentFolders` —— 丢弃"非 git 且包含其他已列项目"的父文件夹条目(discovery 阶段过滤,进缓存)。
- **验证**: 新增 3 测(最深归属/父文件夹剔除/独立非 git 历史项目保留);`test/projects.test.ts` 29/29 绿;全量 `npm test` 53 files / **1473** 全绿;`tsc --noEmit` 干净。前端零改动("Active now" 分组消费 `sessions[]`,服务器修正后自然收敛)。
### ✅ 全端应用图标 "Orbit" + 托盘模板图(完成 — 2026-07-06,main,commit 86d100a)
用户选定 "Orbit"(节点绕方块光标核环绕=agent 自主替你运行),品牌沿用桌面 `--accent #E3A64A` 琥珀金 + 暖近黑 `#100F0D`。矢量母版三份(App 圆角暖底 / iOS 满幅不透明 / 托盘纯黑透明加粗),`rsvg-convert`(`brew install librsvg`)栅格化铺各端:
- **iOS/iPad**: `Assets.xcassets/AppIcon.appiconset`(单尺寸 1024 不透明,Xcode 自动生成全套;此前**无图标**)。构建验证 `AppIcon60x60@2x`/`76x76@2x~ipad` 入 Assets.car;模拟器主屏实拍确认。
- **桌面 App**: `desktop/resources/icon.png`(圆角 1024)→ `npm run dist:mac` 重打包出新 icns + 156MB DMG(`dist-app/Web Terminal-0.1.0-arm64.dmg`),顺带带上 projects 修复。装机需 `xattr -cr` 清 quarantine(ad-hoc 签名 Gatekeeper)。
- **桌面托盘**: `trayTemplate.png`/`@2x` 换成正确的 macOS **模板图**(黑形状+透明底,系统按亮/暗菜单栏自动染色)——根治"一坨白"。
- **web/PWA**: `public/icon.svg` 矢量。**待办**: 分支/main 未推送;图标对比页(5 创新+5 经典)在 job scratchpad 未入库。
### ✅ iOS 客户端全面 UX/UI 打磨(完成 — 2026-07-05,分支 `feat/ios-client`,commit 660a404;精致原生方向)
用户定方向「精致原生 + 可重构」(靛紫 #7C8CFF 强调、语义状态色、SF Mono 数字、系统材质)。编排 = 一个 ultracode `Workflow`(9 agents,4 阶段):3 路审计(易用性/视觉/无障碍,各 boot 两 sim 取证)→ **冻结 DesignSystem**(协调点,防各 agent 各挑配色)→ 4 组文件互斥并行套用 → 验收。
- **审计**: 3 视角共产出 ~40 findings,共识根因=「无设计系统」——全 App stock 系统蓝、状态仅靠颜色(违反色+形,idle 蓝撞 unread 蓝)、gate 决策按钮 <44pt间距/圆角魔数无刻度数字非 tabular缩略图纯黑块gate 中英混排reduceMotion 未处理
- **冻结 DesignSystem**(`ios/App/WebTerm/DesignSystem/**` 4 文件): Tokens(自适应 accent 靛紫语义 status 间距 2-24 刻度圆角 sm8/md12/lg16Motion gated by reduceMotionHaptics)、Typography(SF ramp + mono tabular)、StatusStyle(status+SF Symbol+中文,单一真源,+)、Primitives(StatusBadge/TelemetryChip/Card/SectionHeader/DSButtonStyle 可复用)。 `.tint(DS.Palette.accent)` 单点注入
- **4 组套用( DONE,纯视觉零逻辑改动)**: G1 列表行重构+状态徽标+telemetry 芯片+缩略图占位;G2 终端 gate (≥44pt approve/reject)+SwiftTerm accent 主题+KeyBar/reconnect/quick-reply/digest;G3 配对 hero+警告分层视觉化+Projects 卡片+Timeline/Diff;G4 导航 chrome+iPad 分栏占位(accent 图标)+隐私遮罩+动效+`ContinueLastBanner`/`ProjectsToolbarItem` 抽共享组件gate 文案落中文
- **验收 PASS_WITH_FINDINGS,设计评分 8.5/10,1 finding**: TimelineSheet class色仍裸色(全改动里唯一越界)→ orchestrator `DS.Palette.timelineTool/timelineUser` token + TimelineSheet 全走 DS + 更新钉旧裸色的测试
- **验证(亲验)**: iPhone 16 **290** + iPad Pro 11 **290** 全绿( finding );+集成绿;iPad 分栏占位截图确认靛紫强调色生效。**未推送**。**待用户目视**: 会话列表行/gate /配对页真机观感(截图受通知授权弹窗遮挡,建议真机或点过授权后看)。iPad 侧栏 List(selection:) 高亮审计标记为列表组 Owns 后续 pass」——当前 onOpen 已驱动切换,视觉选中高亮可再补
### ✅ iOS 客户端 iPad 适配(完成 — 2026-07-05,分支 `feat/ios-client`;自适应布局,非分叉)
`docs/PLAN_IOS_IPAD.md`(5 人日)编排:W0 orchestrator 亲做 一个 ultracode `Workflow`(4 agents: T-iPad-2 核心串行 T-iPad-3 T-iPad-4 T-iPad-5 验收)。
- **[x] T-iPad-1 device family(orchestrator 亲做,commit 77502ec)**: `TARGETED_DEVICE_FAMILY` target `1``1,2`;iPad `~ipad` 四方向(iPhone 不动);ios.yml iPad Pro 11 测试腿实测:产物 `UIDeviceFamily [1,2]`iPad 原生满屏(不再 iPhone 黑边)。
- **[x] T-iPad-2 AdaptiveRootView + NavigationSplitView + LayoutPolicy(核心)**: `LayoutPolicy.mode(hSizeClass)` 唯一 size-class 决策点(regularsplit/compactstack,仿 PrivacyShadePolicy);`AdaptiveRootView` 把横切布线(**隐私遮罩 ZStack 顶层两分支共享**/scenePhase/deepLink/sheets)上提唯一一处;`StackRootView`= RootView body **原样搬迁**(iPhone 字节级零回归);`SplitRootView`= SessionListScreen(复用不改)+ TerminalContainerView(复用不改, `.id`);AppCoordinator SidebarItem 路由桥(非平行生命周期)。
- **[x] T-iPad-3 KeyBar 自适应 + 指针右键菜单**: `KeyBarVisibility` 纯谓词(硬件键盘在场默认隐);`TerminalContextMenu`(iPad idiom,复制选区/开新会话/结束会话,一一路由既有通道);kill APIClient Origin 单测
- **[x] T-iPad-4 Projects 大屏化**: `ProjectsGridLayout.columnCount`(iPhone 1/iPad 按宽 1-3);iPad 多列网格复用同款 row/分组/prefs 逻辑零改;Timeline 已有 detent 两端合理。**决策**: Projects iPad form sheet 呈现,其内 hSizeClass compact 改用 `userInterfaceIdiom` 判据( size class 正交)。
- **[x] T-iPad-5 验收 PASS_WITH_FINDINGS orchestrator 修复 4/4**: **iPhone 零回归硬门守住**(277 App+261 +10 集成);iPad 分栏工作; CRITICAL/HIGH/安全回归修复:MED kill 菜单项无法渲染 `onKillSession` 贯穿 TerminalContainerView两根视图 + `AppCoordinator.killCurrentSession()`(复用 kill 通道);②MED split 未消费 route `AdaptiveRootView` `.loading/.pairing` 强制走 StackRootView(iPad 首启配对不被困在空态);③LOW 继续上次横幅补进 split sidebar(截图确认);④LOW iPad happy-path XCUITest **接受推迟**(split 选中逻辑已由 SidebarSelectionTests 9 测真 engine 覆盖,stack happy-path 未变,XCUITest 单跑 7-11min 且脆,成本不划算)。**验证(亲验)**: iPhone 16 **277** + iPad Pro 11 **278**(+kill 桥测试)全绿;iPad 分栏 sidebar+detail 截图确认
- **合计**: iPad 适配零改 `ios/Packages/**`/`src/`/`public/`;纯逻辑包设备无关直接复用。**待用户**: 真机 iPad(分栏手势/硬件键盘/指针右键/Stage Manager)DEFERRED;多窗口(拖会话开新窗并排两终端)另立下一期(本期单场景)。
### ✅ iOS 客户端 P1 实施(完成 — 2026-07-05,分支 `feat/ios-client`,P0+P1 共 14 commits,未推送/未合并)
- **[x] P1-A(W6+W7 首发: T-iOS-37 T-iOS-20 T-iOS-38,3 builders + verify 6/6 PASS;`/push/apns-token` 线形状由 orchestrator 预冻结,两侧并行编码零失配——verify 逐项对照 method/path/body key/hex 规则/状态码/守卫/限额全 match)**:
- **T-iOS-37 server lastOutputAt**: `LiveSessionInfo.lastOutputAt?: number` 可选增量 + `manager.list()` 一行映射 + 4 ;web grep 零消费者,tsc+全量双证兼容
- **T-iOS-20 server APNs**: `src/push/apns.ts`(576 )——env 三件套 all-or-disabled(缺失路由不挂载,启动不 crash,密钥材料零日志)、token store 照抄 subscription-store 约定(幂等/上限/0600)、**手写 ES256 JWT(node:crypto ieee-p1363,零新依赖)**、NEEDS-INPUT(priority 10+WEBTERM_GATE+capability token)/DONE(priority 5)、payload 最小化结构性保证(构造函数只读 meta.id,cwd/命令内容不可能出现)、410/BadDeviceToken 逐出`combineNotifyServices` web-push 并联互不阻塞;`hasPushTarget` 扩展( APNs token 也能 hold gate)。**65 **含双 e2e(本地 h2c APNs:held gate双通道同发同 token→/hook/decision 放行)。新增 `APNS_HOST` env(sandbox 切换+测试零网络)。**npm test 53 files/1470 全绿,tsc 干净**。
- **T-iOS-38 APIClient P1 契约**: APNs token builders(hex 校验镜像服务器联网前拒绝显式 ASCII 字符集防全角)、projects/detail(**证实线上无 namespace 字段**—— web 端分组概念;lossy 解码;percent-encode 单点+ + 规避)、prefs(**未知顶层键往返逐字节存活**—— iOS 覆写 web 偏好,关键陷阱已测;JSONValue 独立 integer case 4242.0)、公开四层 HostNetworkTier(W3 dedup 欠账,PairingVM 后续 pass 可切换)。76/76 绿,覆盖 95.26%。
- 遗留(LOW/cosmetic): Swift doc comment server 行号引用漂移;PairingVM 分类去重待后续 pass
- **[x] P1-B UI 九连链(222425272623292821 DONE,10 agents;verify BLOCKEDorchestrator 修复后放行)**:
- **T-iOS-22 DeepLinkRouter**: 全字段白名单(scheme/action/path /query 键精确/重复键拒)、UUID 复用冻结 Validation(v4);冷启动单槽 stash;`route(from:)` push-tap 复用;非法链接计数不回显内容(防日志注入)。21
- **T-iOS-24 Timeline sheet**: 呈现顺序逐行镜像 web render()(slice 50reverse);`[]`=disabled空态非错误;复用 AwayDigestView 既有 onExpand(T-iOS-14 零改动);label Text(verbatim:)。14
- **T-iOS-25 Quick-reply**: 内置 6 chips 逐项镜像 quick-reply.ts(Esc/Enter KeyByteMap 零手写转义);显示条件=持牌 gate(流上唯一 waiting 信号)&&!readOnly;UserDefaults lossy 载入16
- **T-iOS-27 Diff 查看器**: App DiffFetcher(RO Origin;并注记归 T-iOS-38 后续收编);hunks 着色/staged 切换/truncated 横幅/lazy list;diff 字节 verbatim 渲染
- **T-iOS-26 Projects**: 分组逐条镜像 projects.ts ** key web 逐字节一致**(collapsed /prefs 跨端共享,key 漂移=两端互丢折叠);**prefs clobber 防线**——GET 失败toggle 仅本地绝不 PUT(空底盘 PUT=清库)、PUT 采纳服务器 echo;`claude\r` bootstrap 镜像 tabs.ts:679
- **T-iOS-23 切换器**: UnreadLedger(单调水位,严格 >)+TitleSanitizer(C0/C1/DEL+bidi/零宽全表,字素簇截断 256)入 SessionCore(+15 包测);LiveSessionInfo.lastOutputAt 容错解码(授权增量);列表边界二次净化。
- **T-iOS-29 杂项**: new-in-cwd(cwd 不可信,非绝对路径按未知;**bootstrap 绝不复注入**——开新 shell≠起 claude,专项断言);exit 横幅"开新会话";**加建修复潜在 bug**: RootView 给容器补 `.id(controller.id)`(呈现中换 controller 不换 SwiftTerm 视图→终端假死)。
- **T-iOS-28 缩略图**: 离屏 SwiftTerm 渲染管线(LRU 32/并发闸 2/预览 256KiB 上限/网格钳制),缓存键 (sessionId,lastOutputAt),CADisplayLink 注销不泄漏。
- **T-iOS-21 PushRegistrar+锁屏**: WEBTERM_GATE category(Allow=.authenticationRequired、Deny 免认证 fail-safe、均无 .foreground,形状测试钉死);device token 对每 host 注册+记账重试;NotificationActionHandler(sessionId 复用 DeepLinkRouter 白名单、token 校验后仅入参绝不落盘、beginBackgroundTask 包 POST、403→本地通知兜底)。锁屏真机行为 DEFERRED(需付费账号)。
- **verify(独立 agent)**: 包 261(SessionCore 92/APIClient 77/…)+App 247+集成 10 全绿;7 项语义抽查全 PASS(含恶意标题/putPrefs 唯一调用点走保留路径/token 零持久化 grep);Owns 审计干净;**但抓到 CRITICAL——生产模式启动即崩**: @MainActor UNNotificationCenterAdapter 的 completion 闭包继承主 actor 隔离,UN center 在后台队列回调→Swift 6 executor 断言 trap(测试全绿盖不住:push 接线在测试宿主下被跳过——**测试盲区实证**)。**orchestrator 修复**: 三处闭包加 `@Sendable` 字面量标注(不继承隔离);重建+实机启动验证:进程存活、崩溃报告零新增、通知授权弹窗弹出(=走过原 trap 点)、弹窗期间隐私遮罩正确覆盖(scenePhase .inactive)。
- 遗留(LOW,归 P1-C 复核): deep link 未断言 userinfo/port(`webterminal://user:pass@open` 仍路由;id 均 v4 校验+host 只经 store 解析,影响可忽略);Diff fetcher 收编 APIClient 待后续 pass。
- **[x] P1-C 验收+安全复核(T-iOS-30,2 reviewer ∥,report-only)—— 双双 PASS,零 findings**:
- **Part A 功能验收**: 亲跑 261 包测+247 App 测+10 集成全绿;**启动崩溃修复复核 PASS**(3 次实机启动零崩溃,通知授权弹窗弹出=走过原 trap 点,崩溃报告计数停在修复前的 2);**`simctl push` 实测**注入 WEBTERM_GATE payload → 通知横幅真实渲染("Needs input / Session 3d34818d",目视确认最小化:仅状态词+8 位前缀,无 cwd/命令/token);F-iOS-15 服务端切片实测(双会话 `lastOutputAt` 差分正确);**意外收获**——对**你的真实服务器**(127.0.0.1:3000,pre-T-iOS-37 旧构建)实测旧版兼容降级:App 直接渲染真实会话列表(stuck 红点/`未知目录` 标题回退/`0 台设备在看 · 51×50` 与服务器精确一致/无 unread 点=正确降级)。真机项(锁屏 tap/Face ID、unread 点亮目视、OSC 标题目视)DEFERRED 附手工步骤。
- **Part B 安全复核(对抗式)**: APNs payload 最小化 post-P1-B 仍成立(OSC 标题不进 alert,grep 证实);**capability token 全链路 CLEAN**——issue(server randomUUID+TTL)→payload(仅 gate)→handler 校验后仅入参 hookDecision→**零落盘**(grep Push/ 及全 App 无 UserDefaults/Keychain/file 写;唯一持久 token 是内存态 device token)→单次消费(server resolvePending 一次性)→**两侧日志零 token**;deep-link 50+ fuzz 全部安全(userinfo/port LOW 判定 inert——从不用于构造请求、id 仍 v4 校验,继续接受);锁屏 Allow `.authenticationRequired` 形状测试绿;prefs 唯一写入点仍守空底盘;P1-B 新攻击面(缩略图字节只进 SwiftTerm、diff verbatim、标题净化覆盖全渲染点)扫盘无泄漏;残余风险台账完整。
- **P1 收官统计**: 累计 **518 项自动化**(261 包测 + 247 App 测 + 10 集成)+ 服务器 1470 测全绿;新增能力=远程 APNs 推送(锁屏 Allow/Deny+Face ID)、deep link、Projects 项目列表(分组/收藏/在仓库起 Claude)、多会话切换器(unread+OSC 标题净化)、Timeline/Diff/Quick-reply/缩略图;服务器仅 §0.3 声明的两处增量触点(lastOutputAt + APNs)。**待用户**: ①真机走查(T-iOS-30 报告手工清单);②Apple 付费账号(APNs 真机端到端+TestFlight);③推送分支跑首轮 GH Actions(ui-test 腿/ios17 runtime);④P2(语音 PTT/worktree/终端内搜索/主题/web ?join= 互通)是否开工——P2 任务需先按 P0 规格扩写 RED 清单再分派(§7 P2 注)。
### ✅ iOS 客户端 P0 实施(完成 — 2026-07-05,分支 `feat/ios-client`,7 commits,未推送/未合并)
按 PLAN_IOS_CLIENT §8 批次推进;编排 = 每波一个 ultracode `Workflow`(builder 按任务派发 + 独立 verify agent 复跑验收)。
- **[x] W0 基础(T-iOS-1/2/3/4,5 agents,verify 6/6 PASS)**:
- **T-iOS-1 脚手架**: `ios/` 全结构(project.yml/XcodeGen、5 包空壳、WebTermApp 空窗、`.github/workflows/ios.yml`);Info.plist 经 plutil 核对 = §5.2 逐字(五段 CIDR,**全程零 NSAllowsArbitraryLoads**);bundle id `com.yaojia.webterm`。**偏差**: "default MainActor isolation" 属 Swift 6.2+,本机 6.1 → Swift 6 语言模式 + strict concurrency + 显式 @MainActor 替代(已注 project.yml)。**环境事实**: 本机 Xcode 16.3 原本无 iOS 模拟器 runtime → `xcodebuild -downloadPlatform iOS` 装 iOS 18.4(22E238)后模拟器构建绿。
- **T-iOS-2 Day-1 双 spike**: **平台事实定音——URLSessionWebSocketTask 可发自定义 Origin(①无 Origin→401 ②精确匹配→101+attached ④端口失配→401),不切 Starscream**;③ 1.5MB 回放在默认 1MiB 上限失败(withKnownIssue 记录)、16MiB 成功,ESC/C0 对抗(转义≈3.6MB)成功。**勘误(已写回 PLAN §1/§3.2/T-iOS-9)**: 超限 errno 实测 NSPOSIXErrorDomain **40=EMSGSIZE**(计划原文误标 ENOBUFS=55),T-iOS-9 归类以 40 为主、55 兜底。ServerHarness 自举真服务器(tsx spawn+death-pipe watchdog,无孤儿进程)。真机 smoke 4 项 DEFERRED(无真机),SpikeTerminalScreen 已编入 App target 待 T-iOS-18。
- **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。
- **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。
- **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。
- **[x] W1 叶子包(T-iOS-5/6/7/8,4 builders;编排 [5→6 串行] ∥ 7 ∥ 8——5/6 同在 SessionCore 包,主树并行会互坏编译)**:
- **T-iOS-5 ReconnectMachine+PingScheduler**: 纯 reducer,§3.2 签名逐字;backoff 1s→…→30s 封顶镜像 terminal-session.ts(连上才归零,retry/foregrounded 不重置阶梯——防狂按打穿);Ping 25s/连续 2 miss→connectionLost/取消与断线显式区分。16 测,0 真实等待,文件级覆盖 100%/95%。
- **T-iOS-6 GateState+AwayDigest**: epoch 语义逐行镜像 terminal-session.ts:306-311(上升沿 +1、持续帧刷新 detail、下降沿不重置计数器);`canDecide(epoch:)` 防误批;affordance 映射逐字对齐 tabs.ts:345-347(绝不发 raw auto);digest 词表与 `TimelineEvent.knownClasses` 有相等性绊线测试;防御:乱序容忍、Date→ms clamp 不 trap。25 测,两文件 100%。
- **T-iOS-7 HostRegistry**: §3.3 契约;KeychainHostStore 经 SecItemShim 缝(swift test 打 fake;真 shim kSecUseDataProtectionKeychain+AfterFirstUnlockThisDeviceOnly,属性字典单点构造+逐字单测);InMemoryHostStore 入 Sources 供 App-VM 测试。30 测,own-Sources 覆盖 88.1%。
- **T-iOS-8 APIClient+探针**: 44 测先全绿后 **`[!] BLOCKED` 正确上报冻结契约自相矛盾**——§3.4 `runPairingProbe → Result<Host,…>` 中 Host 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result<HostEndpoint,…>`,Host{id,name} 由 T-iOS-12 VM 构造(id/name 本非探针所知);同时按法定路径加 `Tunables.pairingProbeTimeout`(10s,§3.2.1 新增行+绊线断言)。裁定已写回 PLAN §3.4/§3.2.1/T-iOS-8,公开包装+冒烟测试由 orchestrator 落地。Origin **iff**-G 入测试名;探针②成功后必带 Origin kill 不留孤儿;403 kill→originRejected(HTTP 侧 G 守卫也是配对必验项)。45 测,own-Sources 覆盖 98.1%。
- **W1 验证(orchestrator 亲验)**: 5 包 178 tests 全绿(59+3+41+30+45);覆盖率(own-Sources 口径)SessionCore 99.1%/HostRegistry 88.1%/APIClient 98.1%,全过 80% 门;`git status` 全部在 Owns 内零越界。**发现归 T-iOS-16**: §9 覆盖率命令的 ignore regex 不排静态链入的依赖包源码,CI 接线时须按包过滤(本次已按正确口径亲算)。
- **[x] W2 连接核心(T-iOS-9→10 串行;首次派发 T-iOS-9 agent 死于 API 连接中断——现场零文件、直接 resume 重跑,非任务失败)**:
- **T-iOS-9 URLSessionTermTransport**: 冻结 TransportConnection 不动,ping 经 SessionCore 内部(非冻结)`PingableTermTransport/ConnectionPinger` 扩展点接入;pong 时限复用 `Tunables.pingInterval`(无新常量);EMSGSIZE 归类 40 主/55 兜底→类型化 `.replayTooLarge`;connect 失败原样重抛底层错误(保住 PairingError.classify);状态 delegate 驱动,close 三态可区分;测试服务器 = 裸 TCP 手写 RFC6455 framing(可逐字验 Origin/注入 binary/超帧)。11 测,文件覆盖 97.83%,iOS triple 编译过。
- **T-iOS-10 SessionEngine actor**: 单 Task 生命周期循环 + generation 计数防陈旧任务复活;首帧铁律 attach→lastKnownDims resize→排队帧按序 flush;终态(exit / replayTooLarge)绝不进 backoff;digest 断点原子消费恰好一次;gate 决策 canDecide 兜底、断线期决策丢弃不入队(重连后 gate 可能换代);防御:非 v4 UUID/相对 cwd 剥离(服务器会静默丢帧致挂死)。**决策**: 初始 resize 依赖首次 sizeChanged/notifyForegrounded(冻结 open 无 dims 参数,SwiftTerm 首次布局即触发,已注 doc);digest limit 传 Int.max(截断属 UI,T-iOS-14)。20 测,SessionEngine.swift 94.98%(480 行,<800 硬上限,内聚裁量已记)。
- **W2 验证(独立 agent,5/5 PASS)**: SessionCore 72 (41 W1 完好+31 );四包回归全绿(**累计 209 tests**);own-Sources 覆盖 96.68%;冻结契约零改动;4 项语义抽查(Origin 单点 :169/16MiB 来自 Tunables/replayTooLarge 断重连路径逐行核/decodeServer 唯一入口 nil-drop)全数在源码落实
- **[x] W3 UI+提前项(7 agents: [T-iOS-11121314] 串行链 T-iOS-16 T-iOS-17;协调点 = orchestrator 预建 WebTermTests 单测 bundle(project.yml+5 包依赖,模拟器实测过)并单独提交)**:
- **T-iOS-11 Terminal+KeyBar**: KeyByteMap(SessionCore 纯数据)逐字节复刻 keybar.ts 17 (⏎=\r \n 专项断言);TerminalViewModel 消费 engine.events,replayTooLarge不可重试态+话术逐字断言;**决策**: 硬件 UIKeyCommand 仅注册 Esc/⇧Tab/Ctrl 和弦,箭头不注册(固定 CSI 会破 DECCKM 应用光标模式,vim/htop 错乱)——有排除测试;OSC52 剪贴板拒绝链接仅 http(s)。
- **T-iOS-12 Pairing**: Phase 状态机;扫码confirmingHost,**确认前零网络**(recordedRequests/connectAttempts 空断言);公网需显式勾选确认5.4 四层警告 13 组参数化3.4 裁定落地(VM 构造 Host store);host 分级 VM 内重实现(APIClient internal 粒度不足,重复已标注归 T-iOS-38 去重);真机 DataScanner 编译隔离,generic/platform=iOS 构建过
- **T-iOS-13 SessionList**: 轮询 Tunables.listPollInterval,离开断言 0 sleeper+10 周期零请求(无泄漏);**发现**: LiveSessionInfo pending 字段(已核 manager.list())→ 徽标经 setPendingApproval overlay T-iOS-15 gate 事件接入;kill 乐观移除+404=成功/403 回滚;staleness 严格 > 镜像 web;PR 链接 https-only(SEC-L5 镜像)。
- **T-iOS-14 Gate/Digest UI**: GateViewModel 独立 VM(events 与 engine 分离注入,T-iOS-15 fan-out 零改动);三选一映射唯一事实源=SessionCore Affordance.clientMessage(acceptEdits/default/reject,App 层零 allowAutoMode 逻辑);tap-epoch+affordance 双守卫;haptic 每 epoch 恰一次(HapticSignaling 注入);digest 自动淡出 FakeClock 驱动;文案逐字镜像 tabs.ts:326-350。
- **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。
- **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。
- **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。
- **[x] W4 汇合(T-iOS-15,1 builder + verify 8/8 PASS)**:
- **T-iOS-15 App 接线**: `Wiring/` 9 文件——AppEnvironment 生产 DI(真 Keychain/真探针/URLSessionHTTPTransport 零逻辑透传,Origin 规则全留 APIClient)、EventFanOut(单消费者 engine.events → TerminalVM+GateVM+SessionActivityBridge 三分支保序广播)、bridge(.adopted→存 lastSessionId/.gate→列表 ⚠ overlay/.exited→清两者,防「继续上次」指向死会话)、TerminalSessionController(.background→close 干净 detach;suspend 后 .active→重建栈+open,generation 换 SwiftUI identity 触发首次 sizeChanged 夺回全屏)、**PrivacyShade 严格 `scenePhase != .active`**(三相位测试)、冷启动路由纯函数;WebTermApp 薄 @main;**删除 SpikeTerminalScreen**(grep 零残留)。
- **两项自动化走查代理**: ①LiveServerSmokeTests——签名宿主内真生产 DI 图端到端(探针→host 入库→liveSessions→attach→echo 回显→close→kill 清理);②KeychainHostStoreLiveTests——真 Keychain 往返+`SecItemCopyMatching` 断言 `kSecAttrAccessible==AfterFirstUnlockThisDeviceOnly`+非 synchronizable(T-iOS-7 递延项闭合)。模拟器 boot 截屏:配对页正常渲染。
- **环境硬墙(新发现,已记 harness)**: repo 在 `~/Documents`(TCC 保护)时,模拟器 App 语境 posix_spawn 的 node 读 repo 文件被 tccd 无限阻塞(sample 实测卡 `uv_cwd→__open_nocancel`;平台二进制豁免)→ SimServerHarness 双模式:A 自举(CI/非 TCC 路径)/B `WEBTERM_SERVER_URL` 外部注入;TCC 下 0.001s 快速失败+指引。**本机跑法**: 起服务器后 `xcrun simctl spawn booted launchctl setenv WEBTERM_SERVER_URL …` 再 xcodebuild test(命令行 TEST_RUNNER_ 前缀实测传不进宿主 App 测试进程);裸跑 smoke 红=有意显式失败非回归。
- **偏差闭合(orchestrator 代 T-iOS-11 owner 修)**: W4 曾记「alive-engine `.active` 未调 notifyForegrounded(dims:)——W3 无 dims 钩子」→ TerminalViewModel 增 `lastSentDims`(仅记有效 resize,invalid 不覆盖,有测试)+ controller `.active` 分支接 `notifyForegrounded(dims:)`(无有效 dims 时跳过,SwiftTerm 首次布局兜底)。**76 App 测全绿**(75+1)。
- **verify(独立 agent,8/8)**: 214 包测+76 App 测+10 集成;spike 删净;遮罩规则/生命周期双向 close 路径/组装安全(G 全走 APIClient、零 ad-hoc URLSession、零 ArbitraryLoads、Origin 零手拼)逐项在源码核实;Owns 零越界。
- **[x] W5 验收 + findings 修复(2 reviewer ∥ + 1 fix builder;均 PASS_WITH_FINDINGS→已闭合)**:
- **T-iOS-18 F 走查**: 全部机器可执行项本轮亲自重跑(集成 10/App 76/包 214 + 模拟器冷启动·后台·重启截屏);F-iOS-1..13 逐条 EXECUTED/EVIDENCED(指认测试名)/DEFERRED(真机)(QR 扫码/IME/震动/切换器遮罩目检/ntfy 端到端,附手工步骤清单)。
- **T-iOS-19 安全核对**: 对产物审——Origin 写入点全树恰两处且单点派生;G/RO 分界;**拆构建产物**验五段 CIDR+零 ArbitraryLoads+双 usage description;Keychain 属性;P0 不该在的面(deep link/麦克风)确认缺席;残余风险(isCaptured 跳过/ws 明文模型/epoch 仅客户端)记录完整。
- **findings(0 CRITICAL/HIGH; 1 MED+3 LOW,全部闭合)**: ①MED §9 XCUITest happy path 缺失 → orchestrator 建 WebTermUITests target/scheme,fix agent 落地唯一用例:**配对(手输)→列表→attach→KeyBar ^L→注入 held gate(POST /hook/permission)→点 Approve→断言服务器真放行 behavior==allow**,服务器为 oracle(XCUITest 读不了 SwiftTerm 字形);幂等三分支(全新/已配对/空态)全被实走;**两次真实跑通**(439s/677s,EXIT=0)。②LOW CI 缺 iOS17 下限腿 → ios.yml 增 ios17-floor job(runtime 缺失大声跳过/存在必判,CI-only 本机未验)。③LOW URLSession.shared 磁盘缓存可落盘 preview 终端字节 → 生产 HTTPTransport 默认 .ephemeral。④LOW XcodeGen target 级默认覆盖 project 级 TARGETED_DEVICE_FAMILY(实际打包 [1,2]) → 移 target 级,产物已验 [1]。
- **XCUITest 实证发现(已记 ios.yml/用例注释)**: `TEST_RUNNER_` env 只在 xcodebuild **进程环境**里才透传 runner(命令行 KEY=VALUE 是 build setting 到不了);**`CODE_SIGNING_ALLOWED=NO` 打断真 Keychain(-34018)**→ 顺手修掉既有 app-tests 腿同 flag(否则 KeychainHostStoreLiveTests 在 GH runner 必红);XCUITest typeText 够不着 SwiftTerm(无 AX 键盘焦点)→ 输入断言走 KeyBar ^L+清屏应答;SwiftTerm 光标闪烁烧 XCUITest 60s idle-wait/事件(单跑 7-11min,已压到 3 次终端交互)。
- **遗留观察(report-only,非阻塞)**: 配对探针偶发残留 clientCount=0 会话(killProbeSession 竞态,run 8 一例)——归 T-iOS-8 owner 复核;真 GH Actions 首跑待推送后确认(ui-test 腿/ios17 runtime 镜像现状)。
- **P0 收官统计**: 306 项自动化(214 包测 + 76 App 测 + 10 集成 + 1 XCUITest[计 1,参数化更多]) 全绿;覆盖率 own-Sources SessionCore 96.7%/HostRegistry 88.1%/APIClient 98.1%/WireProtocol 100%;19/19 任务完成(全部偏差有日志);服务器 src/ public/ **零改动**。**待用户**: ①真机走查清单(T-iOS-18 报告内);②推送分支跑首轮 GH Actions;③P1 开工前拍板:Apple 付费账号(APNs/TestFlight)与 bundle id/App 名(现 com.yaojia.webterm/WebTerm)。P1 首批 = T-iOS-20(APNs server) ∥ T-iOS-37(lastOutputAt) ∥ T-iOS-38(APIClient P1 契约)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
**多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。
- **方案**: Phased-Native「口袋驾驶舱」—— SwiftUI + SwiftTerm(MIT/SPM),4 个纯 SwiftPM 包(WireProtocol/SessionCore/HostRegistry/APIClient)+ 薄 App 胶水,`ios/` 顶层新包;前台会话单条活 WS + 其余 HTTP 轮询;iOS 17+/Swift 6/XcodeGen;**P0 服务器零触点**(通知复用既有 ntfy 桥 scripts/setup-hooks.mjs:227-238),P1 仅两处声明触点(APNs sender + `LiveSessionInfo.lastOutputAt`,后者为 TS 任务 T-iOS-37)。relay/E2E 明确推迟(已审计 TS 加密不得随手 Swift 重写)。
- **任务机**: 38 个任务(T-iOS-1…38),P0=19 任务 W0W5 ≈13 人日、P1 W6W8 ≈17.5、P2 W9W10 ≈8,总 ≈38.5 人日;每任务 Owns/Depends/测试先行 Steps/Accept/估算/分派模型。关键平台事实:原生客户端 **WS 握手必须显式带白名单内 `Origin` 头**(src/http/origin.ts:27 对 undefined 默认拒);`URLSessionWebSocketTask.maximumMessageSize` 需调大到 16 MiB(回放单帧 JSON \u 转义最坏 6×2MB)。
- **Review 结果**: 35 findings → **32 confirmed 全部修复**、3 rejected(含「ATS 不收 CIDR」被 verify 驳回 —— iOS 17+ 确实支持 CIDR exception domains)。修复亮点:plan-gate 三选一映射纠正为 `acceptEdits/default/reject`(对照 public/tabs.ts:345-347);「:443 破坏 Origin 匹配」为**臆造 gotcha,反向纠正**(new URL() 双侧归一化);ntfy 桥从「新建」改「复用既有」;CRITICAL 共享类型无主 → 全部收编进 WireProtocol(T-iOS-3);W7 Owns 重叠拆出 T-iOS-38;锁屏 Allow 加 `.authenticationRequired`(Face ID 确认才批 shell 命令);QR 扫码改「先确认再发包」防恶意配对。
- **验证(orchestrator 亲验)**: 文档 943 行落盘、11 节结构完整、fixer 27 组修复日志逐条对账;`git status` 仅新增该 .md,src/ 零改动。**未提交**。下一步:用户批准后按 §8 分派批次开工(W0 首日 spike 实测 Origin header)。
### ✅ 安全审计 relay-e2e + relay-auth — 上线前深度审计 + 修复(完成 — 2026-07-02,分支 `security/relay-auth-audit-fixes`,未提交)
上线门:自动生成的 E2E/鉴权核心须过安全专家审计(前次复审判 approve-with-changes)。**多 agent 编排 = 两个 ultracode `Workflow`**:①**审计**(99 agents)—— 11 维 finder 并行(两包)→ 每个 finding 3 lens 对抗式 verify(exploitability/crypto-spec/refuter 多数票)→ 第二轮「重复 review」深挖 → 每包 completeness critic → dedup+定级;②**修复循环**(10 agents)—— foundation(types)→ 3 组文件互斥并行修复(module fixers)→ **verify 循环至 tsc 干净 + 全绿** → 5 路对抗式 re-audit 确认每个 exploit 已闭合。
- **审计结论**: **relay-e2e = PASS 零改动**(所有候选在 verify 阶段被驳回:确定性 nonce 安全 —— 方向分离 + 真实 agent 侧 `createReplaySealer` 各自持独立单调 seq;strict-successor recv guard 无法导致 replay-accept)。**relay-auth = approve-with-changes,5 条确认**(2 HIGH / 1 MED / 2 LOW),全部在 human-auth/enforcement 层,全部包内可修。驳回但存档(不再复议):E2E replay nonce reuse、mTLS EKU、SPIFFE 域未 pin、reattach host-scope、TOTP 时序。完整报告见 `docs/REVIEW_RELAY_SECURITY.md`
- **修复**(均含回归测试): **F1 HIGH** step-up 因子降级 —— `needsStepUp` 现把新鲜度绑定到**精确方法**(`stepUpMethod`),钓来的 TOTP 不再满足 passkey;**F2 HIGH** step-up 在新建会话路径被完全跳过(生产 caller `term-relay/data-plane/upgrade.ts:105` 硬编码 `principal:null` → 生产环境 step-up 形同虚设)—— 改为**主机驱动 + fail-closed**(`policy.required` 时 principal 缺失/过期/错方法一律 403);**F3 MED** WebAuthn 断言未绑定已存凭据 —— 现校验 credentialId 且转发 pubkey 给 verifier;**F4 LOW** DPoP verify 未捕获抛出 → INV15 门未审计地崩(审计逃逸+DoS)—— `verifyDpopProof` 全体 try/catch→false + `coreAuthorize` 边界兜成干净审计 401 + issue 校验 cnfJkt 格式;**F5 LOW** 验证过的 signCount 被丢弃 → 克隆检测失效 —— `finishAuthentication` 现返回 `{principal,newSignCount}` 供持久化。
- **验证(orchestrator 亲验)**: relay-auth `tsc --noEmit` 干净 + **122 测试全绿**(基线 104,+18 回归);relay-e2e 未动、tsc 干净、76 全绿。re-audit **5/5 闭合**(置信 0.950.97,均有回归测试)。
- **遗留/behavioral note(非阻塞)**: F2 改为 fail-closed 后,**任何 `stepUpPolicyFor(host).required===true` 的主机,P1(term-relay)必须在 upgrade ctx 传入已认证 principal**,否则正确地被拒;当前 v0.9 默认 `NO_STEPUP_POLICY.required===false`,现网非 step-up 主机不受影响。P1 集成时须补此接线(见 [[relay-stepup-needs-principal-wiring]])。
- **第二轮独立 review(Fable 5,换模型增强多样性)+ F6 修复**: 第二个 `Workflow`(fix-regression 猎取 + 新角度 sweep + 对抗式 re-attack 被驳回项)判:**5 条修复 0 regression**、被驳回/D-i-D 项 re-attack **0 存活**(确认真不可利用),但**新增 1 条 HIGH——F6**:relay-e2e `replay-key.ts` 的可恢复 `K_content` 仅由 `(hostContentSecret, sessionId)` 决定(稳定),而真实消费者 `agent/src/e2e/replaySeal.ts``let seq=0n` 在每次重建(重启/重连)时归零 → 两代 sealer 在**相同 (key, nonce)** 下封不同明文 = 灾难性 AEAD 复用。**第一轮曾误驳此项**(verifier 看到"独立单调计数器"就类比 live 路径判安全,漏了 K_content 跨重启稳定);第二轮定向 re-attack + orchestrator 亲读 `replaySeal.ts` 证实为真。**修法(用户选 epoch-in-key,4 包)**:`ReplayKeyParams``epoch`;`deriveContentKey` 把 epoch 以 `sessionId‖0x1f‖epoch` 混入 HKDF salt;`createReplaySealer` 每代 `randomUUID()` 新 epoch 并暴露;`ReplaySource` 带 epoch、浏览器按之重推。**验证(亲验)**:4 包 tsc 干净 + 全绿(relay-contracts 81 / relay-e2e 78[+2 F6 回归] / agent 133 / relay-web 99);re-audit 判闭合(回归证:两代同 seq=0 nonce 但不同 key)。**残留(fail-closed 非复用)**:ring-buffer 传输(P1/P2)须持久化并按代下发 epoch —— 已记 TODO,当前 replay 路径尚未端到端接线(`manage-page.loadReplay` 仍是 throw stub),故 F6 此刻是潜伏漏洞。完整报告 `docs/REVIEW_RELAY_SECURITY.md`(含 F6 + RESOLUTION)。
- **动态 e2e 安全测试台(新增 `e2e/` 包)**: 跨包 harness `buildRelayWorld()` 把**真实** P5(auth)+P4(crypto)+P2(agent replay) 通过内存 seam + 不可信 relay「RelaySpy」攻击者视角接起来(仅 I/O 边界 fake,所有安全检查走生产代码路径)。全流程 issue cap→upgrade/authz→handshake→sealed session 过 relay→reattach→revoke。**21 测试全绿、tsc 干净(亲验)**。把 F1/F2/F3/F4/F6 从「静态+单测」升级为「动态攻击者实测」,另加 MITM/reflection/replay/reorder/INV2(live+replay)/cross-tenant/single-use。F5(signCount)为返回形状改动、由 relay-auth 单测覆盖;RelaySpy 代替 term-relay mux(真多进程/浏览器 e2e 需尚未建的 run tooling)。用符号链接免 `npm install`(`e2e/node_modules/*` → 各包源码)。覆盖表见 `docs/REVIEW_RELAY_SECURITY.md`。**未提交**,留待用户决定。
### ✅ 桌面客户端 v0.1 — Electron 一体化壳(代码完成 — 2026-07-01,分支 `feat/desktop-electron`,未提交)
Mac/Windows 桌面 App,**内嵌现有 Node 服务器 + node-pty**(all-in-one):主进程 `startEmbeddedServer` 复用 `src/server.ts``startServer(cfg)`/`loadConfig`(**服务器零改动** —— 它本就导出可编程启动且 import 无副作用),窗口 `loadURL('http://127.0.0.1:<port>/')` 复用**前端零改动**(前端严格同源,`location.host` 自动指向内嵌服务器;Origin 白名单默认含 localhost)。核心价值 = **原生通知**(轮询 `/live-sessions``computeNotifications` → 系统通知)+ 托盘常驻 + 深链 `terminalapp://` + 开机自启。
- **编排**: ultracode `Workflow` —— 脚手架(orchestrator 冻结 `desktop/src/types.ts` 契约 + package/tsconfig/esbuild/electron-builder)→ Build(4 builder 并行、文件互斥、对齐冻结签名)→ Review(correctness/security/typescript 三 lens 并行、schema 结构化 findings)。纯逻辑尽量抽出可单测;Electron glue(main/window/tray/menu/preload/embedded-server/logger)作为不可单测 wiring 排除出覆盖率(沿用既有 vitest.config 先例)。**无用 zod、无用 electron-store**(遵循项目手写校验 + 手写 JSON 持久化的最小依赖惯例)。

View File

@@ -0,0 +1,169 @@
# Security Audit — relay-e2e + relay-auth (pre-production go-live gate)
**Date:** 2026-07-02 · **Branch:** `security/relay-auth-audit-fixes` · **Prior verdict:** approve-with-changes
**Method:** multi-agent deep audit — 11 security dimensions fanned out across both packages, every
candidate finding adversarially verified by ≥3 independent skeptic lenses (exploitability /
crypto-spec / refuter, majority vote), a second "repeat-review" deep-dive pass, a per-package
completeness critic, then dedup + severity re-rank. 99 agents, ~6.9M tokens. The orchestrator
independently read 100% of both `src/` trees to adjudicate.
## Verdict
**relay-e2e (crypto core): PASS — no changes required.** Every candidate against the E2E core
(replay-key nonce reuse, sequence/handshake ordering, key-hygiene, X25519 low-order) was **refuted**
on verification: the deterministic-nonce discipline is safe because the direction split + the real
agent-side `createReplaySealer` (in `term-relay/agent`) each own an independent monotonic counter;
recv `accept()` is a strict-successor guard whose pre-auth commit cannot cause a replay-accept.
**relay-auth: APPROVE-WITH-CHANGES — 5 confirmed findings (2 HIGH, 1 MED, 2 LOW), all in the
human-auth / enforcement layer.** All fixes are contained in relay-auth; the P5-local types
(`AuthenticatedPrincipal`, `StepUpPolicy`) are extendable without touching any frozen contract.
### Refuted (verified NOT exploitable — recorded so they are not re-litigated)
- E2E replay `K_content` nonce reuse — real consumer keeps an independent monotonic seq (no reset-to-0).
- mTLS `verifyChain` missing EKU/keyUsage — every abuse cert fails the later mandatory SPIFFE-SAN →
enrolled-&-non-revoked-host registry gate.
- SPIFFE trust-domain not pinned on verify — gated by chain-valid + registry; defense-in-depth only.
- Reattach host-scope "bypass" — `token.host === requestedHostId` bind at `decide.ts:62` blocks it;
session↔host binding is enforced at connect by P1/P3. (Kept as a cheap D-i-D hardening note.)
- TOTP non-constant-time compare / device-proof `acct` canonicalization / DPoP ±future-iat — LOW, refuted.
---
## Confirmed findings + fix plan
### F1 · HIGH — Step-up factor downgrade (`human/stepup/stepup.ts:33`)
`needsStepUp()` tests `policy.requiredMethod ∈ principal.amr` and freshness via a single
method-agnostic `stepUpAt`. `amr` is cumulative across login + every step-up, so a **passkey**
requirement is satisfied by a passkey used at *login*, while the freshness timestamp can be produced
by a *weaker* step-up (e.g. phished TOTP). The phishing-resistant control silently downgrades.
**Fix:** bind freshness to the method. Add `stepUpMethod` to the principal; `needsStepUp` requires
`stepUpMethod === requiredMethod` AND fresh; `recordStepUp` stamps the method. (Group A)
### F2 · HIGH — Mandatory step-up skipped on the new-session path (`enforce/onUpgrade.ts:128`)
The gate is wrapped in `if (ctx.principal !== null)`, but the production caller
(`term-relay/data-plane/upgrade.ts:105`) hardcodes `principal: null` on **both** connect and reattach
(session principal exists only *after* authz). Result: **step-up never runs in production** — a valid
DPoP-bound token opens a root shell with the mandatory passkey ceremony never performed.
Deny-by-default is violated (absence of proof → allow).
**Fix:** make step-up **host-driven and fail-closed**: when `stepUpPolicyFor(host).required`, DENY
(403 `step_up_required`) unless an authenticated principal proves fresh step-up. Applies to connect
and reattach. Default v0.9 policy is `required:false` → existing non-step-up hosts unaffected.
**Behavioral note:** once a host sets `required:true`, the P1 caller must supply a step-up principal;
until then such hosts correctly deny. (Group A)
### F3 · MEDIUM — WebAuthn assertion not bound to the stored credential (`human/webauthn/authenticate.ts:40`)
`finishAuthentication` forwards only `signCount` to the verifier — never `cred.publicKey` /
`cred.credentialId`, and there is no `credentialId` cross-check. Any reasonable verifier can only
check challenge/origin/rpId, so an assertion from a *different* registered credential authenticates.
**Fix:** add `credentialPublicKey` + `expectedCredentialId` to the verifier interface & forward them;
assert `resp.credentialId === cred.credentialId` before trusting `verified`. (Group B)
### F4 · LOW — DPoP verify throws (unhandled) → INV15 gate fails un-audited (`capability/verify.ts:142`)
`importEd25519PublicRaw(rawPub)` (and `readCnfJkt`) run outside try/catch; a thumbprint-matching but
malformed key makes `verifyDpopProof` **reject** instead of returning false. Callers (`decide.ts:57`,
`onUpgrade.ts:112`) don't catch → the sole authz gate throws an unhandled exception and emits **no**
`deny` AuditEvent (audit-evasion + repeatable unhandled-rejection DoS). Fail-closed but off-contract.
**Fix (defense-in-depth):** (a) wrap the risky calls in `verifyDpopProof` → return false; (b) wrap the
`verifyDpopProof` call in `coreAuthorize` → clean audited `deny(401)`; (c) validate `cnfJkt` format at
issue. (Group C)
### F5 · LOW — WebAuthn verified `signCount` discarded → clone detection inert (`human/webauthn/authenticate.ts:52`)
The advanced `newSignCount` is used only for the regression check and never returned/persisted, so the
guard forever compares against the registration-time count — a cloned authenticator is never detected.
**Fix:** return `newSignCount` (updated credential) so the caller can persist it. (Group B)
---
## Execution plan (multi-agent, loop mode)
- **Foundation (barrier):** `types.ts` — add `StepUpPolicy.required: boolean` + `AuthenticatedPrincipal.stepUpMethod?: AuthMethod|null` (+ Zod). Optional `stepUpMethod` ⇒ no ripple to other principal constructors; absent ⇒ treated as null ⇒ needs-step-up (fail-safe).
- **Fix groups (parallel, file-disjoint):**
- **Group A (F1,F2):** `stepup.ts`, `enforce/onUpgrade.ts`, `test/{stepup,enforce}.test.ts`, `test/tripwire/cross-tenant.test.ts`.
- **Group B (F3,F5):** `human/webauthn/{verifier,authenticate}.ts`, `test/webauthn.test.ts`.
- **Group C (F4):** `capability/{verify,issue}.ts`, `authz/decide.ts`, `test/capability.test.ts`.
- **Verify (loop until green):** `tsc --noEmit` + full `vitest run` in relay-auth; repair any breakage; repeat. Baseline to preserve/extend: relay-e2e 76, relay-auth 104.
- **Re-audit (parallel adversarial):** one verifier per finding re-reads the fixed code and confirms the specific exploit is closed.
Each fix ships with a **regression test** encoding the exploit (F2: STRICT+null→403; F1: fresh TOTP ≠ passkey; F3: credentialId mismatch→reject; F4: malformed-jwk DPoP→false+audited deny; F5: newSignCount surfaced).
---
## Second independent review (2026-07-02, Fable 5) — RECORD CORRECTION
A second review (fix-regression hunt + fresh-angle sweep + adversarial re-attack of refuted items),
run on a **different model** for reviewer diversity, returned:
- **Fix-regression: 0** — the 5 fixes (F1F5) survive independent scrutiny, no regressions.
- **Re-attack of refuted/D-i-D items: 0 survived** — mTLS EKU, SPIFFE domain, reattach host-scope, TOTP
replay, device-proof canonicalization are re-confirmed **non-exploitable**.
- **1 new HIGH** — which the first audit had **REFUTED in error** (a false-negative). Corrected below.
### F6 · HIGH — Recoverable `K_content` replay key reuses `(key, nonce)` across sealer generations
**⚠️ This corrects the earlier "relay-e2e PASS" verdict — relay-e2e's replay surface has a real nonce-reuse hazard.**
`relay-e2e/src/replay-key.ts` `deriveContentKey` keys `K_content = HKDF(hostContentSecret, salt=sessionId,
info=const)`**deterministic**, and *intended* to be recoverable/stable per `(host, sessionId)`. The
nonce is deterministic `f(seq)` and `sealReplayFrame` is stateless (no SequenceGuard). The sole real
consumer, `agent/src/e2e/replaySeal.ts:41` `createReplaySealer`, holds `let seq = 0n` **in memory** and
**resets to 0 on every reconstruction** (agent restart/redeploy, or a new attach), while `hostContentSecret`
is persisted 0600 (Keystore) and `sessionId` is stable (localStorage). Unlike the LIVE h2c path — safe
because each reconnect runs a fresh ECDH → brand-new keys — the replay path reuses the **same K_content**
with `seq` restarting at 0 ⇒ two generations of distinct plaintext sealed under identical `(key, nonce)`.
An untrusted relay (INV2 sees ciphertext) that observes both generations recovers plaintext-XOR (terminal
output: commands/tokens/code) and, for AES-256-GCM, recovers the GHASH auth-subkey → forgery, which
`openReplayCiphertext` (no cross-frame replay check) will accept.
**Why round 1 missed it:** the round-1 verifier located `let seq = 0n`, correctly noted it's an independent
counter, but wrongly concluded "safe" by analogy to the live path — missing that `K_content` (unlike the
live ephemeral keys) is STABLE across restarts, so the seq reset *does* collide. Round 2's dedicated
re-attack + orchestrator's direct read of `replaySeal.ts`/`hostEndpoint.ts` confirm the exploit.
**Fix options (design decision — touches a frozen contract and/or the agent package, both outside the two
originally-scoped packages):**
- **(A) Agent-side durable seq** — persist the replay `SequenceGuard` with the ring buffer / Keystore so
`seq` never resets for a given `(sessionId, K_content)`. Keeps `K_content` recoverable; blast radius =
`agent/` only. Requires durable monotonic-seq guarantee across restart.
- **(B) Per-generation epoch in `K_content`** — add an epoch/generation to `ReplayKeyParams` (frozen in
`relay-contracts/src/e2e/types.ts`), mixed into the HKDF salt, and carry it with each frame so the browser
re-derives the right key. Cryptographically robust (fresh key per generation) but changes a **frozen
contract** + `relay-e2e` + `relay-web` (browser re-derivation) + `agent`.
- Regression test (either path): two sealer generations for the same `(secret, sessionId)` must never emit
two frames sharing `(key, nonce)`.
**RESOLUTION — FIXED (option B, epoch-in-key), 2026-07-02.** Implemented across all 4 packages:
`ReplayKeyParams` (relay-contracts) gains a required `epoch: string`; `deriveContentKey` (relay-e2e) folds it
into the HKDF salt as `utf8(sessionId) ‖ 0x1F ‖ utf8(epoch)` (unit-separator, no concat aliasing);
`createReplaySealer` (agent) mints a fresh `randomUUID()` epoch per generation and exposes it; `ReplaySource`
(relay-web) carries `epoch` and the browser re-derives with it. A fresh epoch per generation ⇒ a fresh key,
so a `seq=0` reset after restart/re-attach can never collide with a prior generation's `(key, nonce)`;
recoverability within a generation (same epoch ⇒ same key) is preserved. **Verified:** all 4 packages
tsc-clean and green (relay-contracts 81, relay-e2e 78 [+2 F6 regressions], agent 133, relay-web 99); re-audit
confirms closure (regression proves two generations share the seq=0 nonce yet derive different keys).
**Residual (fail-closed, not reuse):** the ring-buffer transport (P1/P2) must persist each generation's epoch
and serve the epoch matching those exact frames; documented as a TODO at `relay-web/.../manage-page.ts`
(`loadReplay` is still a throwing stub — the replay path is not wired end-to-end yet, so F6 was latent).
---
## Dynamic end-to-end security harness (`e2e/`, 2026-07-02)
A cross-package harness (`e2e/`) now wires the **real** P5(auth) + P4(crypto) + P2(agent replay) exports
through in-memory seams and an untrusted-relay "RelaySpy" attacker vantage — every security check runs the
production code path; only true I/O boundaries (registries/buckets/revocation/audit/sockets) are faked.
`buildRelayWorld()` exposes the full flow (issue cap → upgrade/authz → handshake → sealed session via relay
→ reattach → revoke). **21 tests pass, tsc clean.** This upgrades the findings from *static + unit* validation
to *dynamic* validation — the attacker actually attempts each exploit:
| Attack (dynamic) | Asserted defense |
|---|---|
| **F1** step-up factor downgrade | fresh TOTP step-up → `needsStepUp` true + upgrade 403; passkey → allowed |
| **F2** step-up skipped (fail-open) | STRICT + `principal:null` → 403 `step_up_required` + audit; `required:false`+null → allowed |
| **F3** WebAuthn credential binding | `credentialId` mismatch → `WebAuthnError` even with a verifier that returns `verified:true` |
| **F4** DPoP unhandled throw | malformed thumbprint-matching jwk → `verifyDpopProof` resolves `false` (no throw) → clean audited `dpop_proof_failed` deny |
| **F6** replay `(key,nonce)` reuse | two sealer generations → same seq-0 nonce but different key ⇒ ciphertext+tag differ; cross-gen open throws; within-gen recovers |
| MITM | wrong `agentPubkey` / tampered `hostEphPub``FingerprintMismatchError`, no keys derived |
| reflection / replay / reorder | c2h→client.open rejects; dup/out-of-order `open` throws (SequenceGuard) |
| INV2 | RelaySpy ciphertext (live + replay path) never contains the plaintext marker |
| cross-tenant (INV1) | acct-A token at acct-B host → 403 `cross_tenant` + `cross-tenant-attempt` audit |
| capability single-use | same jti twice (fresh DPoP) → 2nd 403 `token_replayed` |
**Not dynamically covered:** F5 (signCount persistence) is a return-shape change, asserted by its relay-auth
unit regression. The harness fakes the P1 relay/transport (RelaySpy) rather than running term-relay's mux
and the control-plane HTTP — a true multi-process/browser e2e would need the (not-yet-built) run tooling.

141
e2e/harness/dpop.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL
* relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`,
* `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal,
* so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath
* imports resolve through the e2e node_modules symlink).
*/
import { randomUUID } from 'node:crypto'
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import { encodeBase64UrlBytes } from 'relay-contracts'
import {
configureVerifyKey,
resetVerifyKeyForTest,
} from 'relay-auth/src/config/keys.js'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
} from 'relay-auth/src/crypto/ed25519.js'
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
import { principal } from './fakes.js'
export { resetDpopCacheForTest, jwkThumbprint }
export interface P5Key {
readonly signingKey: CryptoKey
readonly publicRaw: Uint8Array
}
/** Configure the P5 verifying key globally and return the matching private signing key. */
export async function setupP5SigningKey(): Promise<P5Key> {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
configureVerifyKey(pair.publicKey)
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
return { signingKey: pair.privateKey, publicRaw }
}
export interface Ephemeral {
readonly privateKey: CryptoKey
readonly publicRaw: Uint8Array
}
export async function makeEphemeral(): Promise<Ephemeral> {
const pair = await generateEd25519KeyPair()
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
}
export interface IssueOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly rights?: readonly CapabilityRight[]
readonly ttl?: number
readonly now: number
readonly htu?: string
readonly htm?: string
}
/**
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
* DPoP (single-use jti / revocation tests) without tripping the DPoP replay cache.
*/
export interface CapBundle {
readonly raw: string
readonly dpop: DpopContext
readonly newDpop: (at?: number) => Promise<DpopContext>
}
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
const eph = await makeEphemeral()
const cnfJkt = await jwkThumbprint(eph.publicRaw)
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: o.rights ?? ['attach'],
ttlSeconds: o.ttl ?? 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = o.htu ?? `https://${o.aud}/ws`
const htm = o.htm ?? 'GET'
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
htu,
htm,
jti: randomUUID(),
iat: at,
}),
htu,
htm,
})
return { raw, dpop: await newDpop(o.now), newDpop }
}
/**
* F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP
* proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and
* execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the
* fail-safe (must resolve to false, never reject).
*/
export interface MalformedOpts {
readonly accountId: string
readonly host: string
readonly aud: string
readonly now: number
readonly blobLen?: number
}
export async function craftMalformedDpopBundle(
signingKey: CryptoKey,
o: MalformedOpts,
): Promise<{ raw: string; dpop: DpopContext }> {
const blob = new Uint8Array(o.blobLen ?? 16).fill(7)
const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length
const raw = await issueCapabilityToken(
{
principal: principal(o.accountId),
aud: o.aud,
host: o.host,
rights: ['attach'],
ttlSeconds: 45,
cnfJkt,
},
signingKey,
o.now,
)
const htu = `https://${o.aud}/ws`
const enc = (x: unknown): string =>
encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x)))
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } })
const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now })
const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes
return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } }
}

132
e2e/harness/fakes.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* In-memory port fakes — the ONLY seams that stand in for the true I/O boundaries P1/P3 own
* (host registry / session registry / revocation store / token buckets / audit sink / revocation
* bus). Every security decision still runs through the REAL relay-auth exports; these fakes only
* supply storage. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths).
*/
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
import type {
AuthenticatedPrincipal,
AuditEvent,
AuditSink,
HostRegistryPort,
SessionRegistryPort,
RevocationStore,
TokenBucketStore,
} from 'relay-auth'
/** An authenticated principal (the ONLY source of accountId, INV3). */
export function principal(
accountId: string,
overrides: Partial<AuthenticatedPrincipal> = {},
): AuthenticatedPrincipal {
return {
kind: 'human',
accountId,
principalId: 'cred-' + accountId,
amr: ['passkey'],
authAt: 1000,
stepUpAt: null,
...overrides,
}
}
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
export function makeHostRecord(
accountId: string,
hostId: string,
status: HostRecord['status'] = 'online',
): HostRecord {
return {
hostId,
accountId,
subdomain: 'sub-' + hostId,
agentPubkey: new Uint8Array(32),
enrollFpr: 'fpr-' + hostId,
status,
lastSeen: '2026-01-01T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
}
}
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
const map = new Map(hosts.map((h) => [h.hostId, h]))
return { getById: async (id) => map.get(id) ?? null }
}
export interface MutableSessionRegistry extends SessionRegistryPort {
add(entry: { sessionId: string; hostId: string; accountId: string }): void
}
export function fakeSessionRegistry(
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
): MutableSessionRegistry {
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
return {
getById: async (id) => map.get(id) ?? null,
add: (e) => {
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
},
}
}
export interface RevocationFake extends RevocationStore {
readonly revoked: Set<string>
readonly consumed: Set<string>
}
export function fakeRevocationStore(): RevocationFake {
const revoked = new Set<string>()
const consumed = new Set<string>()
return {
revoked,
consumed,
isRevoked: async (jti) => revoked.has(jti),
revokeJti: async (jti) => {
revoked.add(jti)
},
consumeOnce: async (jti) => {
if (consumed.has(jti)) return false
consumed.add(jti)
return true
},
}
}
export interface TokenBucketFake extends TokenBucketStore {
readonly blocked: Set<string>
readonly calls: string[]
}
/** Token bucket that always allows unless a key is added to `blocked`. */
export function fakeTokenBucket(): TokenBucketFake {
const blocked = new Set<string>()
const calls: string[] = []
return {
blocked,
calls,
take: async (key) => {
calls.push(key)
return !blocked.has(key)
},
}
}
export interface AuditFake extends AuditSink {
readonly events: AuditEvent[]
}
export function fakeAuditSink(): AuditFake {
const events: AuditEvent[] = []
return { events, append: async (e) => void events.push(e) }
}
export interface RevocationBusFake extends RevocationBus {
readonly published: KillSignal[]
}
export function fakeRevocationBus(): RevocationBusFake {
const published: KillSignal[] = []
return { published, publish: async (s) => void published.push(s) }
}

34
e2e/harness/spy.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext
* byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must
* NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy.
*/
import { MAX_FRAME_BYTES } from 'relay-e2e'
export class RelaySpy {
readonly captured: Uint8Array[] = []
/** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */
forward(payload: Uint8Array): Uint8Array {
if (payload.length > MAX_FRAME_BYTES) {
throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`)
}
this.captured.push(payload.slice())
return payload
}
/** Latin1 concatenation of everything the relay saw — for substring canary scans. */
snapshot(): string {
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ')
}
/** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */
contains(marker: string): boolean {
if (this.snapshot().includes(marker)) return true
return this.captured.some(
(b) =>
Buffer.from(b).toString('utf8').includes(marker) ||
Buffer.from(b).toString('latin1').includes(marker),
)
}
}

332
e2e/harness/world.ts Normal file
View File

@@ -0,0 +1,332 @@
/**
* buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay)
* exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted
* "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the
* production code path. Pass an explicit `now` everywhere (no Date.now in assertions).
*
* The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519
* key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and
* no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths).
*/
import { randomUUID, randomBytes } from 'node:crypto'
import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts'
import {
createClientHandshake,
createHostHandshake,
createE2ESession,
MemoryDevicePinStore,
deriveContentKey,
sealReplayFrame,
openReplayCiphertext,
encodeEnvelope,
} from 'relay-e2e'
import {
onUpgrade,
onReattach,
revoke,
revokeToken,
signDeviceAuthProof,
verifyDeviceProof,
type AuthzOutcome,
type EnforceDeps,
type UpgradeContext,
type StepUpPolicy,
type RevocationScope,
} from 'relay-auth'
import {
generateEd25519KeyPair,
exportEd25519PublicRaw,
importEd25519PublicRaw,
signEd25519,
verifyEd25519,
} from 'relay-auth/src/crypto/ed25519.js'
import { createReplaySealer, type ReplaySealer } from 'agent'
import {
fakeAuditSink,
fakeHostRegistry,
fakeRevocationBus,
fakeRevocationStore,
fakeSessionRegistry,
fakeTokenBucket,
makeHostRecord,
principal,
type AuditFake,
type MutableSessionRegistry,
type RevocationBusFake,
type RevocationFake,
type TokenBucketFake,
} from './fakes.js'
import {
craftMalformedDpopBundle,
issueCapBundle,
resetDpopCacheForTest,
setupP5SigningKey,
type CapBundle,
} from './dpop.js'
import { RelaySpy } from './spy.js'
export const DEFAULT_NOW = 1_700_000_000
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
export const NO_STEP_UP: StepUpPolicy = {
required: false,
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
requiredMethod: 'passkey',
}
export const STRICT_PASSKEY: StepUpPolicy = {
required: true,
maxAgeSeconds: 300,
requiredMethod: 'passkey',
}
/** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */
export interface HostFixture {
readonly label: string
readonly hostId: string
readonly accountId: string
readonly aud: string
readonly origin: string
readonly agentPubkey: Uint8Array
readonly hostContentSecret: Uint8Array
readonly replayAlg: AeadAlg
/** WebCrypto private key backing the handshake transcript signer. */
readonly signingPriv: CryptoKey
}
async function makeHostFixture(label: string, accountId: string): Promise<HostFixture> {
const kp = await generateEd25519KeyPair()
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
const sub = `${label}.term.example.com`
return {
label,
hostId: randomUUID(),
accountId,
aud: sub,
origin: `https://${sub}`,
agentPubkey,
hostContentSecret: new Uint8Array(randomBytes(32)),
replayAlg: REPLAY_ALG,
signingPriv: kp.privateKey,
}
}
export interface UpgradeArgs {
readonly raw: string
readonly dpop: UpgradeContext['dpop']
readonly host: string
readonly aud: string
readonly origin: string
readonly now: number
readonly principal?: UpgradeContext['principal']
readonly requiredRight?: UpgradeContext['requiredRight']
readonly activeSessionCount?: number
readonly remoteAddrHash?: string
}
export interface ReattachArgs extends UpgradeArgs {
readonly sessionId: string
}
export interface HandshakeOpts {
readonly host?: HostFixture
readonly now?: number
/** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */
readonly verifyAgainstPubkey?: Uint8Array
/** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */
readonly tamperHostHello?: (h: HostHello) => HostHello
}
export interface EstablishedSessions {
readonly client: E2ESession
readonly host: E2ESession
readonly spy: RelaySpy
readonly agentPubkey: Uint8Array
}
export interface RelayWorld {
readonly now: number
readonly signingKey: CryptoKey
readonly publicRaw: Uint8Array
readonly hostA: HostFixture
readonly hostB: HostFixture
readonly allowedOrigins: readonly string[]
readonly deps: EnforceDeps
readonly audit: AuditFake
readonly revocation: RevocationFake
readonly buckets: TokenBucketFake
readonly bus: RevocationBusFake
readonly sessions: MutableSessionRegistry
principal(accountId: string, over?: Parameters<typeof principal>[1]): ReturnType<typeof principal>
setStepUpPolicy(policy: StepUpPolicy): void
issueCap(o: {
accountId: string
host: string
aud: string
rights?: readonly UpgradeContext['requiredRight'][]
ttl?: number
now?: number
}): Promise<CapBundle>
craftMalformedDpop(o: {
accountId: string
host: string
aud: string
now?: number
blobLen?: number
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
reattach(a: ReattachArgs): Promise<AuthzOutcome>
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer
/** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */
openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array
revokeToken(jti: string, exp?: number): Promise<void>
revoke(scope: RevocationScope): Promise<void>
}
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
const { signingKey, publicRaw } = await setupP5SigningKey()
resetDpopCacheForTest()
const hostA = await makeHostFixture('alice', 'acct-A')
const hostB = await makeHostFixture('bob', 'acct-B')
const hostRecords: HostRecord[] = [
makeHostRecord(hostA.accountId, hostA.hostId),
makeHostRecord(hostB.accountId, hostB.hostId),
]
const hosts = fakeHostRegistry(hostRecords)
const sessions = fakeSessionRegistry([])
const revocation = fakeRevocationStore()
const buckets = fakeTokenBucket()
const audit = fakeAuditSink()
const bus = fakeRevocationBus()
const allowedOrigins = [hostA.origin, hostB.origin]
let stepUpPolicy: StepUpPolicy = NO_STEP_UP
const deps: EnforceDeps = {
hosts,
sessions,
revocation,
buckets,
audit,
stepUpPolicyFor: () => stepUpPolicy,
}
function ctxFor(a: UpgradeArgs): UpgradeContext {
return {
capabilityRaw: a.raw,
originHeader: a.origin,
expectedAud: a.aud,
requestedHostId: a.host,
requiredRight: a.requiredRight ?? 'attach',
remoteAddrHash: a.remoteAddrHash ?? 'ip-hash',
activeSessionCount: a.activeSessionCount ?? 0,
dpop: a.dpop,
principal: a.principal ?? null,
}
}
const replayCrypto = { deriveContentKey, sealReplayFrame }
return {
now,
signingKey,
publicRaw,
hostA,
hostB,
allowedOrigins,
deps,
audit,
revocation,
buckets,
bus,
sessions,
principal,
setStepUpPolicy(policy) {
stepUpPolicy = policy
},
issueCap(o) {
return issueCapBundle(signingKey, {
accountId: o.accountId,
host: o.host,
aud: o.aud,
rights: o.rights,
ttl: o.ttl,
now: o.now ?? now,
})
},
craftMalformedDpop(o) {
return craftMalformedDpopBundle(signingKey, {
accountId: o.accountId,
host: o.host,
aud: o.aud,
now: o.now ?? now,
blobLen: o.blobLen,
})
},
upgrade(a) {
return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now)
},
reattach(a) {
return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now)
},
addSession(e) {
sessions.add(e)
},
async establishSession(opts: HandshakeOpts = {}): Promise<EstablishedSessions> {
const h = opts.host ?? hostA
const hsNow = opts.now ?? now
const client = createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: {
proofFor: (_hostId, binding) =>
signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow),
},
verifier: {
verify: async (pub, transcript, sig) =>
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
},
pinStore: new MemoryDevicePinStore(),
hostId: h.hostId,
})
const host = createHostHandshake({
signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) },
agentPubkey: h.agentPubkey,
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
})
const clientHello = await client.start()
const rawHostHello = await host.onClientHello(clientHello)
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
const clientResult = await client.onHostHello(
hostHello,
opts.verifyAgainstPubkey ?? h.agentPubkey,
)
return {
client: createE2ESession('client', clientResult),
host: createE2ESession('host', host.result!),
spy: new RelaySpy(),
agentPubkey: h.agentPubkey,
}
},
newReplaySealer(sessionId, host = hostA) {
return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto)
},
openReplay(sessionId, epoch, env, host = hostA) {
const key = deriveContentKey({
hostContentSecret: host.hostContentSecret,
sessionId,
alg: host.replayAlg,
epoch,
})
return openReplayCiphertext(key, encodeEnvelope(env))
},
async revokeToken(jti, exp = now + 60) {
await revokeToken(jti, exp, revocation)
},
async revoke(scope) {
await revoke(scope, revocation, hosts, bus, now)
},
}
}

12
e2e/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "relay-e2e-suite",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Cross-package end-to-end + adversarial security test harness for the rendezvous-relay service. Wires the REAL P1/P2/P4/P5/P6 exports through in-memory seams and an untrusted-relay attacker vantage; dynamically re-validates the audit findings F1-F6 plus MITM/replay/reflect/INV2/cross-tenant.",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
}
}

View File

@@ -0,0 +1,226 @@
/**
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1F4 plus cross-tenant
* isolation and capability single-use. Each case ASSERTS the defense holds through the REAL
* relay-auth enforcement path. Pass an explicit `now` everywhere.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import {
needsStepUp,
recordStepUp,
verifyCapabilityToken,
verifyDpopProof,
finishAuthentication,
WebAuthnError,
type WebAuthnVerifier,
type AuthenticationResponse,
type WebAuthnCredential,
} from 'relay-auth'
import {
buildRelayWorld,
DEFAULT_NOW,
STRICT_PASSKEY,
NO_STEP_UP,
type RelayWorld,
} from '../harness/world.js'
const NOW = DEFAULT_NOW
describe('adversarial — auth (P5)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
// ── F2 · mandatory step-up is fail-closed on the new-session path ──────────────────────────────
describe('F2 (dynamic): host-driven step-up gate is fail-closed', () => {
it('STRICT policy + principal:null → DENIED 403 step_up_required', async () => {
const { hostA } = world
world.setStepUpPolicy(STRICT_PASSKEY)
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: null,
now: NOW,
})
expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
expect(world.audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true)
})
it('required:false policy + principal:null → allowed (non-step-up host preserved)', async () => {
const { hostA } = world
world.setStepUpPolicy(NO_STEP_UP)
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: null,
now: NOW,
})
expect(out.ok).toBe(true)
})
})
// ── F1 · step-up factor must be method-bound (a passkey requirement ≠ a TOTP step-up) ───────────
describe('F1 (dynamic): step-up freshness is bound to the required method', () => {
it('a fresh TOTP step-up does NOT satisfy a STRICT passkey policy; a passkey step-up DOES', async () => {
const { hostA } = world
world.setStepUpPolicy(STRICT_PASSKEY)
const totp = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'totp', NOW)
expect(needsStepUp(totp, STRICT_PASSKEY, NOW)).toBe(true)
const capT = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const deniedTotp = await world.upgrade({
raw: capT.raw,
dpop: capT.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: totp,
now: NOW,
})
expect(deniedTotp).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
const passkey = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'passkey', NOW)
expect(needsStepUp(passkey, STRICT_PASSKEY, NOW)).toBe(false)
const capP = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const allowedPasskey = await world.upgrade({
raw: capP.raw,
dpop: capP.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
principal: passkey,
now: NOW,
})
expect(allowedPasskey.ok).toBe(true)
})
})
// ── F4 · a thumbprint-matching but malformed DPoP key must fail-safe (resolve false, never throw) ─
describe('F4 (dynamic): verifyDpopProof is totally fail-safe', () => {
it('a proof whose jwk.x is a non-32-byte blob RESOLVES false and yields a clean denied outcome', async () => {
const { hostA } = world
const craft = await world.craftMalformedDpop({
accountId: hostA.accountId,
host: hostA.hostId,
aud: hostA.aud,
})
// Direct: the verifier resolves to false — it never throws / rejects (audit-evasion + DoS fix).
const tok = await verifyCapabilityToken(craft.raw, hostA.aud, NOW)
await expect(verifyDpopProof(tok, craft.dpop, NOW)).resolves.toBe(false)
// Via the full enforcement path: a clean denied AuthzOutcome, not an exception.
const out = await world.upgrade({
raw: craft.raw,
dpop: craft.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(out).toMatchObject({ ok: false, reason: 'dpop_proof_failed' })
// Exactly one audited deny was emitted (no unhandled rejection, no audit evasion).
expect(world.audit.events.some((e) => e.outcome === 'deny')).toBe(true)
})
})
// ── F3 · WebAuthn assertion must be bound to the stored credential id ───────────────────────────
describe('F3: an assertion whose credentialId ≠ the stored credential is rejected', () => {
const RP_ID = 'alice.term.example.com'
const ORIGIN = 'https://alice.term.example.com'
const cred: WebAuthnCredential = {
credentialId: 'cred-1',
accountId: 'acct-A',
publicKey: new Uint8Array([1, 2, 3]),
signCount: 5,
transports: ['internal'],
createdAt: '2026-01-01T00:00:00.000Z',
}
// A permissive verifier that would "verify" ANY assertion — the credentialId cross-check must
// still reject before the verifier's verdict is trusted.
const forgivingVerifier: WebAuthnVerifier = {
verifyRegistration: async () => ({
verified: true,
credentialId: 'cred-1',
publicKey: new Uint8Array([1, 2, 3]),
signCount: 0,
transports: ['internal'],
}),
verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({
verified: true,
newSignCount: stored + 1,
}),
}
it('rejects with WebAuthnError even though the mock verifier returns verified:true', async () => {
const resp: AuthenticationResponse = {
clientChallenge: 'chal',
origin: ORIGIN,
rpId: RP_ID,
credentialId: 'other-cred', // ≠ cred.credentialId
}
await expect(
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
).rejects.toBeInstanceOf(WebAuthnError)
await expect(
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
).rejects.toThrow('credential id mismatch')
})
})
// ── Cross-tenant · a token for account A can never reach a host owned by account B ──────────────
describe('cross-tenant isolation (INV1)', () => {
it('an A-account token presented at host B (owned by B) is DENIED 403', async () => {
const { hostA, hostB } = world
// token.host === requestedHostId (hostB) so host-scope passes; the registry then shows hostB
// belongs to acct-B ≠ token.sub (acct-A) → the cross_tenant gate fires.
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostB.hostId, aud: hostB.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostB.hostId,
aud: hostB.aud,
origin: hostB.origin,
now: NOW,
})
expect(out).toMatchObject({ ok: false, status: 403 })
if (!out.ok) expect(['cross_tenant', 'host_scope_mismatch']).toContain(out.reason)
expect(world.audit.events.some((e) => e.action === 'cross-tenant-attempt' && e.outcome === 'deny')).toBe(true)
})
})
// ── Capability single-use · a token+jti may be upgraded exactly once ────────────────────────────
describe('capability single-use (consumeOnce)', () => {
it('the same token+jti upgraded twice → the second is denied token_replayed', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const first = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(first.ok).toBe(true)
const second = await world.upgrade({
raw: cap.raw,
dpop: await cap.newDpop(NOW), // fresh DPoP so the jti burn (not the DPoP cache) is what denies
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_replayed' })
})
})
})

View File

@@ -0,0 +1,115 @@
/**
* Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy).
* Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay
* sealer through the harness. Pass an explicit `now` everywhere.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { FingerprintMismatchError } from 'relay-e2e'
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
const NOW = DEFAULT_NOW
function flipByte(b: Uint8Array, i = 0): Uint8Array {
const c = b.slice()
c[i] = (c[i]! ^ 0xff) & 0xff
return c
}
describe('adversarial — transport (P4)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
describe('MITM: host_hello must verify against the registry key', () => {
it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => {
// The malicious relay swaps in a key it controls; the client checks against hostB's real key.
await expect(
world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => {
await expect(
world.establishSession({
tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }),
}),
).rejects.toBeInstanceOf(FingerprintMismatchError)
})
})
it('reflection: a c2h frame fed back into the clients own open is rejected (direction split)', async () => {
const { client } = await world.establishSession()
const c2h = client.seal(utf8('sudo rm -rf /'))
// Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c
// read key + h2c aad label, so the tag over the c2h direction never verifies.
expect(() => client.open(c2h)).toThrow()
})
it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => {
const { client, host, spy } = await world.establishSession()
const f0 = spy.forward(client.seal(utf8('whoami')))
expect(fromUtf8(host.open(f0))).toBe('whoami')
expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard
})
it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => {
const { client, host } = await world.establishSession()
const f0 = client.seal(utf8('cmd-0'))
const f1 = client.seal(utf8('cmd-1'))
expect(() => host.open(f1)).toThrow() // expected seq 0, got 1
// and the in-order pair still works on a fresh pair (sanity)
expect(fromUtf8(host.open(f0))).toBe('cmd-0')
expect(fromUtf8(host.open(f1))).toBe('cmd-1')
})
it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `TOP_SECRET_${crypto.randomUUID()}`
for (let i = 0; i < 3; i++) {
const wire = spy.forward(client.seal(utf8(`${marker}#${i}`)))
expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`)
}
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(3)
})
describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => {
it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => {
const sessionId = 'sess-restart-1'
const gen1 = world.newReplaySealer(sessionId)
const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach
expect(gen2.epoch).not.toBe(gen1.epoch)
const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0')
const e1 = gen1.seal(pt)
const e2 = gen2.seal(pt)
// Same deterministic nonce (seq 0) …
expect(e1.seq).toBe(0n)
expect(e2.seq).toBe(0n)
expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true)
// … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse).
expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false)
expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false)
// Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame.
expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow()
// Recoverability WITHIN a generation is preserved (same epoch ⇒ same key).
expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
})
it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => {
const sealer = world.newReplaySealer('sess-inv2')
const marker = 'REPLAY_MARKER_ABC'
const env = sealer.seal(utf8(marker))
expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker)
expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker)
})
})
})

99
e2e/tests/flow.test.ts Normal file
View File

@@ -0,0 +1,99 @@
/**
* Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each
* stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
const NOW = DEFAULT_NOW
describe('relay flow (happy path)', () => {
let world: RelayWorld
beforeEach(async () => {
world = await buildRelayWorld(NOW)
})
it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' })
})
it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => {
const { client, host } = await world.establishSession()
// Matching keys ⇒ bidirectional plaintext round-trips through the direction split.
const c2h = host.open(client.seal(utf8('ls -la')))
expect(fromUtf8(c2h)).toBe('ls -la')
const h2c = client.open(host.seal(utf8('total 0')))
expect(fromUtf8(h2c)).toBe('total 0')
})
it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => {
const { client, host, spy } = await world.establishSession()
const marker = `PLAINTEXT_${crypto.randomUUID()}`
const up = spy.forward(client.seal(utf8(marker)))
expect(fromUtf8(host.open(up))).toBe(marker)
const down = spy.forward(host.seal(utf8(`reply_${marker}`)))
expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`)
expect(spy.contains(marker)).toBe(false)
expect(spy.captured.length).toBe(2)
})
it('reattach to an own-account session is allowed', async () => {
const { hostA } = world
const sessionId = crypto.randomUUID()
world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId })
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const out = await world.reattach({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
sessionId,
now: NOW,
})
expect(out.ok).toBe(true)
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' })
})
it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', async () => {
const { hostA } = world
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
const first = await world.upgrade({
raw: cap.raw,
dpop: cap.dpop,
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(first.ok).toBe(true)
if (!first.ok) return
await world.revokeToken(first.jti)
const second = await world.upgrade({
raw: cap.raw,
dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache
host: hostA.hostId,
aud: hostA.aud,
origin: hostA.origin,
now: NOW,
})
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' })
})
})

21
e2e/tests/smoke.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
// Cross-package resolution smoke test: prove the harness can bare-import the REAL exports of
// every relay package (each resolves via its own node_modules/relay-contracts symlink transitively).
import { issueCapabilityToken, onUpgrade, needsStepUp } from 'relay-auth'
import { createClientHandshake, createHostHandshake, createE2ESession, deriveContentKey } from 'relay-e2e'
import { createReplaySealer } from 'agent'
import { CapabilityTokenSchema } from 'relay-contracts'
describe('cross-package import smoke', () => {
it('resolves the real exports of relay-auth / relay-e2e / agent / relay-contracts', () => {
expect(typeof issueCapabilityToken).toBe('function')
expect(typeof onUpgrade).toBe('function')
expect(typeof needsStepUp).toBe('function')
expect(typeof createClientHandshake).toBe('function')
expect(typeof createHostHandshake).toBe('function')
expect(typeof createE2ESession).toBe('function')
expect(typeof deriveContentKey).toBe('function')
expect(typeof createReplaySealer).toBe('function')
expect(CapabilityTokenSchema).toBeDefined()
})
})

15
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["harness/**/*.ts", "tests/**/*.ts"]
}

8
e2e/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
})

10
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Generated by xcodegen — regenerate with: cd ios && xcodegen generate
*.xcodeproj
# XcodeGen-generated Info.plist (keys are declared in project.yml)
App/WebTerm/Resources/Info.plist
# Xcode / SwiftPM build state
DerivedData/
xcuserdata/
.build/
.swiftpm/

View File

@@ -0,0 +1,113 @@
import SessionCore
import SwiftUI
import WireProtocol
/// T-iOS-14 · "What happened while I was away" banner rendered above the
/// terminal after a reconnect. Render-only: `GateViewModel` owns the state
/// an ALL-ZERO digest never reaches this view (the VM keeps `digest` nil),
/// the collapsed row auto-fades after `Tunables.digestFadeDelay`, and manual
/// expansion (which cancels the fade) reveals the recent entries.
struct AwayDigestView: View {
let digest: AwayDigest
let isExpanded: Bool
let onExpand: () -> Void
let onDismiss: () -> Void
private enum Metrics {
/// Recent entries shown when expanded (newest kept the engine's
/// digest is uncapped, the banner must not be).
static let maxRecentRows = 12
}
private enum Copy {
static let prefix = "离开期间:"
static let separator = " · "
static let expandTitle = "展开明细"
static let dismissTitle = "关闭摘要"
}
/// Summary line: non-zero parts only, joined web-statusline style. A
/// non-empty digest whose counted signals are all zero (e.g. only `user`
/// events) falls back to a plain activity count so the row is never blank.
static func summary(for digest: AwayDigest) -> String {
let parts = [
digest.toolRuns > 0 ? "工具调用 \(digest.toolRuns)" : nil,
digest.waitingCount > 0 ? "等待审批 \(digest.waitingCount)" : nil,
digest.sawDone ? "已完成" : nil,
digest.sawStuck ? "曾卡住" : nil,
].compactMap { $0 }
guard !parts.isEmpty else {
return "\(Copy.prefix)活动 \(digest.recent.count)"
}
return Copy.prefix + parts.joined(separator: Copy.separator)
}
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
summaryRow
if isExpanded {
recentRows
}
}
.padding(.horizontal, DS.Space.md12)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityElement(children: .contain)
}
private var summaryRow: some View {
HStack(spacing: DS.Space.sm8) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(DS.Palette.accent)
Text(Self.summary(for: digest))
.font(DS.Typography.callout.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: DS.Space.sm8)
if !isExpanded {
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
.labelStyle(.iconOnly)
}
Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.tint(DS.Palette.accent)
.font(DS.Typography.callout)
}
/// Newest `maxRecentRows` entries, oldestnewest. Server text (`label`) is
/// untrusted display input rendered as plain Text only.
private var recentRows: some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
ForEach(
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
id: \.offset
) { _, event in
recentRow(event)
}
}
}
private func recentRow(_ event: TimelineEvent) -> some View {
HStack(spacing: DS.Space.sm8) {
Text(Self.time(of: event))
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
Text(event.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
}
}
private static func time(of event: TimelineEvent) -> String {
let millisecondsPerSecond = 1_000.0
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
return date.formatted(date: .omitted, time: .shortened)
}
}

View File

@@ -0,0 +1,108 @@
import SessionCore
import SwiftUI
/// Copy + wire pairing for one gate button the SINGLE label source for both
/// gate surfaces (`GateBanner`, `PlanGateSheet`). Labels mirror
/// public/tabs.ts:334-350 byte-for-byte; the affordanceClientMessage mapping
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
struct GateChoiceSpec: Equatable {
let label: String
let affordance: GateState.Affordance
/// Ordered button set for `gate` (tool two, plan three), driven by the
/// frozen `GateState.affordances` list.
static func specs(for gate: GateState) -> [GateChoiceSpec] {
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
}
static func label(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "✓ Approve + Auto"
case .approveReview: return "✓ Approve + Review"
case .keepPlanning: return "✎ Keep Planning"
case .approve: return "✓ Approve"
case .reject: return "✗ Reject"
}
}
}
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
/// rendered the VM drops stale taps (first-line guard); wiring into
/// TerminalScreen is T-iOS-15.
struct GateBanner: View {
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Copy {
/// Chinese lead-in above the (web-mirrored) English tool line makes the
/// "this needs me" intent unmissable at a glance.
static let heading = "需要你的确认"
}
private enum Metrics {
static let messageLineLimit = 2
}
/// Banner headline, mirror of public/tabs.ts:329:
/// `Claude wants to use ${pendingTool ?? 'a tool'}`. `detail` is
/// server-supplied display text (untrusted) rendered as plain Text only.
static func message(for gate: GateState) -> String {
"Claude wants to use \(gate.detail ?? "a tool")"
}
/// A prominent Card (the gate is THE core action approve/reject a shell
/// command). An amber "needs me" badge heads it, the tool line reads big and
/// clear, and full-width DS buttons give unmissable tap targets.
var body: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
header
Text(Self.message(for: gate))
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.messageLineLimit)
.fixedSize(horizontal: false, vertical: true)
buttonRow
}
}
.accessibilityElement(children: .contain)
}
private var header: some View {
HStack(spacing: DS.Space.sm8) {
StatusBadge(status: .pendingApproval)
Text(Copy.heading)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
private var buttonRow: some View {
HStack(spacing: DS.Space.sm8) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
decisionButton(spec)
}
}
}
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
Button(spec.label) {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
}
.buttonStyle(DSButtonStyle(kind: kind(for: spec.affordance)))
.accessibilityIdentifier("gate.decision.\(spec.affordance)")
}
/// Approve reads as the accent-filled primary, Reject as the destructive
/// red; Keep Planning (never on a tool gate, but mapped for completeness)
/// is the tinted secondary.
private func kind(for affordance: GateState.Affordance) -> DSButtonStyle.Kind {
switch affordance {
case .approve, .approveAuto, .approveReview: return .primary
case .reject: return .destructive
case .keepPlanning: return .secondary
}
}
}

View File

@@ -0,0 +1,267 @@
import SessionCore
import UIKit
/// T-iOS-11 · Mobile key bar + hardware key mapping, mirroring
/// `public/keybar.ts` the Claude Code keys a phone keyboard can't produce,
/// most-used first. EVERY labelbytes lookup resolves through
/// `KeyByteMap` (SessionCore) no byte literal lives in this file.
///
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
/// never pops or fights the soft keyboard same reason the web bar bypasses
/// xterm and calls `ws.send`).
// MARK: - Visibility policy (T-iPad-3)
/// Pure predicate deciding whether the soft-keyboard KeyBar
/// (`inputAccessoryView`) should show THE single decision point (mirrors
/// `PrivacyShadePolicy` / `LayoutPolicy`, no scattered checks in views):
/// - a hardware keyboard makes the on-screen key bar redundant default hidden;
/// - no hardware keyboard shown (unchanged iPhone behavior zero regression);
/// - an explicit user toggle (`userOverride`) always wins over the auto default.
///
/// Hardware presence is injected (`GCKeyboard.coalesced != nil` at the call
/// site) so this stays a fast, device-agnostic unit (`KeyBarVisibilityTests`).
enum KeyBarVisibility {
/// - Parameters:
/// - hardwareKeyboardPresent: injected `GCKeyboard.coalesced != nil`.
/// - userOverride: nil = follow the auto default; true/false = the user
/// explicitly forced show/hide (wins over the hardware-driven default).
static func isVisible(hardwareKeyboardPresent: Bool, userOverride: Bool?) -> Bool {
if let userOverride { return userOverride }
return !hardwareKeyboardPresent
}
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
/// One key-bar button: glyph, short function caption (shown under the glyph,
/// self-documenting on touch) and the full title for accessibility.
struct KeyBarButtonSpec: Equatable, Sendable {
let key: KeyByteMap.Key
let label: String
let caption: String
let title: String
let isPrimary: Bool
init(key: KeyByteMap.Key, label: String, caption: String, title: String,
isPrimary: Bool = false) {
self.key = key
self.label = label
self.caption = caption
self.title = title
self.isPrimary = isPrimary
}
}
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
enum KeyBarLayout {
static let buttons: [KeyBarButtonSpec] = [
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯",
title: "Esc Esc — rewind / clear draft"),
KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式",
title: "Shift+Tab — cycle plan / auto-accept mode"),
KeyBarButtonSpec(key: .arrowUp, label: "", caption: "上一个",
title: "Up — previous option / history"),
KeyBarButtonSpec(key: .arrowDown, label: "", caption: "下一个",
title: "Down — next option / history"),
KeyBarButtonSpec(key: .enter, label: "", caption: "确认",
title: "Enter — confirm"),
KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消",
title: "Ctrl+C — cancel / quit"),
KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史",
title: "Ctrl+R — reverse-search command history"),
KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情",
title: "Ctrl+O — expand transcript / tool detail"),
KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘",
title: "Ctrl+L — redraw screen"),
KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务",
title: "Ctrl+T — toggle task list"),
KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台",
title: "Ctrl+B — background running task"),
KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出",
title: "Ctrl+D — exit session (EOF)"),
KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全",
title: "Tab — complete / toggle"),
KeyBarButtonSpec(key: .arrowLeft, label: "", caption: "左移",
title: "Left — move cursor left"),
KeyBarButtonSpec(key: .arrowRight, label: "", caption: "右移",
title: "Right — move cursor right"),
KeyBarButtonSpec(key: .slash, label: "/", caption: "命令",
title: "Slash — command launcher"),
]
}
/// Layout constants all composed from the frozen `DS` scale (no literals).
/// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
/// stays on-grid with the rest of the app.
private enum KeyBarMetrics {
/// A hit-target-tall row plus a little breathing room (44 + 8).
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
static let buttonSpacing: CGFloat = DS.Space.xs4
static let contentInset: CGFloat = DS.Space.sm8
static let buttonInsets = NSDirectionalEdgeInsets(
top: DS.Space.xs4, leading: DS.Space.md12,
bottom: DS.Space.xs4, trailing: DS.Space.md12
)
}
// MARK: - KeyBarView (inputAccessoryView)
/// Horizontally scrolling key bar, installed as the terminal's
/// `inputAccessoryView`. Taps call `onKey` the screen routes them to
/// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`.
final class KeyBarView: UIInputView {
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
/// Built buttons in layout order (test-visible).
private(set) var keyButtons: [UIButton] = []
init() {
super.init(
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
inputViewStyle: .keyboard
)
allowsSelfSizing = true
buildBar()
}
@available(*, unavailable, message: "KeyBarView is code-built only")
required init?(coder: NSCoder) {
return nil
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
}
private func buildBar() {
let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = KeyBarMetrics.buttonSpacing
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
for spec in KeyBarLayout.buttons {
let button = makeButton(for: spec)
keyButtons = keyButtons + [button]
stack.addArrangedSubview(button)
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.addSubview(stack)
addSubview(scroll)
NSLayoutConstraint.activate([
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
scroll.topAnchor.constraint(equalTo: topAnchor),
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
stack.leadingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.leadingAnchor,
constant: KeyBarMetrics.contentInset
),
stack.trailingAnchor.constraint(
equalTo: scroll.contentLayoutGuide.trailingAnchor,
constant: -KeyBarMetrics.contentInset
),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
])
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
// UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
// vends the accent, so native system labels stand in at this boundary.)
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var label = AttributedString(spec.label)
label.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .semibold
)
config.attributedTitle = label
var caption = AttributedString(spec.caption)
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
caption.foregroundColor = .secondaryLabel
config.attributedSubtitle = caption
config.titleAlignment = .center
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
if spec.isPrimary {
button.tintColor = DS.Palette.accentUIColor()
}
button.accessibilityLabel = spec.title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
button.addAction(
UIAction { [weak self] _ in self?.onKey?(spec.key) },
for: .touchUpInside
)
return button
}
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
/// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping
/// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc,
/// Shift+Tab and the Ctrl chords are registered
/// - arrows stay SwiftTerm-native: a fixed CSI override would break
/// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`);
/// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers;
/// - Esc·Esc is simply two Esc presses.
/// UIKeyCommand interception replaces (never duplicates) the responder's own
/// press handling, so each chord is sent exactly once.
enum HardwareKeyCommands {
struct Chord: Equatable {
let key: KeyByteMap.Key
let input: String
let modifiers: UIKeyModifierFlags
}
static let chords: [Chord] = [
Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []),
Chord(key: .shiftTab, input: "\t", modifiers: .shift),
Chord(key: .ctrlC, input: "c", modifiers: .control),
Chord(key: .ctrlR, input: "r", modifiers: .control),
Chord(key: .ctrlO, input: "o", modifiers: .control),
Chord(key: .ctrlL, input: "l", modifiers: .control),
Chord(key: .ctrlT, input: "t", modifiers: .control),
Chord(key: .ctrlB, input: "b", modifiers: .control),
Chord(key: .ctrlD, input: "d", modifiers: .control),
]
/// One prioritized `UIKeyCommand` per chord, all firing `action`.
/// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.)
@MainActor
static func build(action: Selector) -> [UIKeyCommand] {
chords.map { chord in
let command = UIKeyCommand(
action: action, input: chord.input, modifierFlags: chord.modifiers
)
command.wantsPriorityOverSystemBehavior = true
return command
}
}
/// Reverse lookup for the command handler: which chord fired.
@MainActor
static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? {
chords.first {
$0.input == command.input && $0.modifiers == command.modifierFlags
}?.key
}
}

View File

@@ -0,0 +1,118 @@
import SessionCore
import SwiftUI
/// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto /
/// Approve+Review / Keep Planning mapping mirrors public/tabs.ts:345-347
/// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default /
/// reject; never raw `auto`, and NO allowAutoMode gating plan §7 T-iOS-14).
///
/// Render-only: state lives in `GateViewModel`; every tap reports the
/// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap
/// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15.
struct PlanGateSheet: View {
/// Mirror of public/tabs.ts:326 plan-gate headline.
static let title = "Claude finished planning — how should it proceed?"
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Copy {
/// Chinese lead-in above the (web-mirrored) English question.
static let heading = "计划已就绪"
}
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.lg16) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(Copy.heading)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Text(Self.title)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView {
VStack(spacing: DS.Space.sm8) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
choiceButton(spec)
}
}
}
.scrollBounceBehavior(.basedOnSize)
}
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, alignment: .leading)
.presentationDetents([.medium])
.accessibilityElement(children: .contain)
}
/// One full-width choice Card: semantic icon + label + the plain-language
/// caption of what the wire mode actually does + a disclosure chevron. The
/// whole card is the (large) tap target.
private func choiceButton(_ spec: GateChoiceSpec) -> some View {
Button {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
} label: {
Card(padding: DS.Space.md12) {
HStack(spacing: DS.Space.md12) {
Image(systemName: symbol(for: spec.affordance))
.font(DS.Typography.title)
.foregroundStyle(iconColor(for: spec.affordance))
.frame(width: DS.Space.xxl24)
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(spec.label)
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
Text(Self.caption(for: spec.affordance))
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: DS.Space.sm8)
Image(systemName: "chevron.right")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("plan.decision.\(spec.affordance)")
}
/// Secondary line under each choice: what the wire mode actually does.
static func caption(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "执行计划编辑自动接受acceptEdits"
case .approveReview: return "执行计划每次编辑需确认default"
case .keepPlanning: return "继续规划,不执行"
case .approve: return "允许本次工具调用"
case .reject: return "拒绝本次工具调用"
}
}
/// Distinct SF Symbol per choice (shape carries meaning, not just color):
/// Auto = a bolt (fast, hands-off), Review = a checkmark (approve, confirm
/// each edit), Keep Planning = a pencil (stay drafting).
private func symbol(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "bolt.fill"
case .approveReview: return "checkmark.circle"
case .keepPlanning: return "pencil.and.outline"
case .approve: return "checkmark.circle"
case .reject: return "xmark.circle"
}
}
/// Both approve paths wear the accent (this is the encouraged action); Keep
/// Planning is a quiet secondary; a bare reject (tool-shaped, unused here)
/// stays the stuck red.
private func iconColor(for affordance: GateState.Affordance) -> Color {
switch affordance {
case .approveAuto, .approveReview, .approve: return DS.Palette.accent
case .keepPlanning: return DS.Palette.textSecondary
case .reject: return DS.Palette.statusStuck
}
}
}

View File

@@ -0,0 +1,255 @@
import SessionCore
import SwiftUI
/// T-iOS-25 · Quick-reply chip row + (plan §7; behavior mirror of
/// `public/quick-reply.ts`, data layer in `QuickReplyStore.swift`).
///
/// Visibility decision (documented): the plan step says "waiting ".
/// On the iOS `SessionEvent` stream the raw `ClaudeStatus` is folded away by
/// the engine (`SessionEngine.applyGateFrame` keeps only pending/gate), so the
/// stream's ONLY waiting projection is the HELD GATE a `status:'waiting',
/// pending:true` frame surfaces as `.gate(GateState)` and the lift as
/// `.gate(nil)`. Chips therefore float while a gate is held AND the terminal
/// is live (not exited/failed). Waiting-without-pending (a Notification
/// permission_prompt with no held relay) never reaches the stream accepted
/// limitation; the observation point stays the SAME fan-out branches the
/// Gate/Terminal VMs already consume (no new SessionCore surface, no extra
/// fan-out branch needed).
///
/// Send path: a chip tap goes through `TerminalViewModel.sendInput` the one
/// ordered send pump so a rapid double-tap yields two frames in tap order,
/// never interleaved, and the read-only guard drops taps on a dead terminal.
struct QuickReplyBar: View {
enum Copy {
static let managePhrases = "管理常用语"
}
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let store: QuickReplyStore
@State private var isPanelPresented = false
/// Pure visibility rule (see type doc): held gate = the stream's waiting
/// signal; read-only (exited/failed) always hides.
static func isVisible(gate: GateState?, isReadOnly: Bool) -> Bool {
gate != nil && !isReadOnly
}
/// The production tap path (also exercised directly by tests): compose the
/// payload via `QuickReplyPalette` and hand it to the VM's ordered pump.
static func send(_ chip: QuickReplyChip, through viewModel: TerminalViewModel) {
viewModel.sendInput(QuickReplyPalette.payload(for: chip))
}
var body: some View {
if Self.isVisible(
gate: gateViewModel.currentGate,
isReadOnly: terminalViewModel.isReadOnly
) {
chipRow
.sheet(isPresented: $isPanelPresented) {
QuickReplyPanel(store: store)
}
}
}
private var chipRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DS.Space.sm8) {
ForEach(store.allChips) { chip in
chipButton(chip)
}
managePhrasesButton
}
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs4)
}
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
private func chipButton(_ chip: QuickReplyChip) -> some View {
Button {
Self.send(chip, through: terminalViewModel)
} label: {
// Text(verbatim:) user/label strings render as inert text, never
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
Text(verbatim: chip.label)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.accent)
.lineLimit(1)
.padding(.horizontal, DS.Space.md12)
.frame(minHeight: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
}
private var managePhrasesButton: some View {
Button {
isPanelPresented = true
} label: {
Image(systemName: "plus")
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(width: DS.Layout.minHitTarget, height: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel(Copy.managePhrases)
}
}
/// : user-phrase CRUD + drag reorder (), presented from the
/// chip row's `+`. The add/edit form mirrors the web inline editor: text,
/// optional label (defaults to text) and an append-Enter toggle that starts
/// on. Tapping an existing row loads it into the form for editing ().
struct QuickReplyPanel: View {
enum Copy {
static let title = "常用语"
static let addSection = "添加新常用语"
static let customSection = "自定义常用语"
static let textPlaceholder = "要发送的文本"
static let labelPlaceholder = "标签(可选,默认同文本)"
static let appendEnterToggle = "发送后自动回车"
static let addButton = "添加"
static let saveButton = "保存修改"
static let cancelEditButton = "取消编辑"
static let doneButton = "完成"
static let emptyState = "还没有自定义常用语"
/// Payload preview suffix for append-Enter chips (web: `${text}`).
static let enterSuffixSymbol = ""
}
let store: QuickReplyStore
@Environment(\.dismiss) private var dismiss
@State private var draftText = ""
@State private var draftLabel = ""
/// Mirrors the web editor default: `enterCheck.checked = true`.
@State private var draftAppendEnter = true
/// Non-nil while the form edits an existing chip instead of adding.
@State private var editingChipId: String?
var body: some View {
NavigationStack {
List {
customSection
editorSection
}
.navigationTitle(Copy.title)
.toolbar {
ToolbarItem(placement: .topBarLeading) { EditButton() }
ToolbarItem(placement: .topBarTrailing) {
Button(Copy.doneButton) { dismiss() }
}
}
}
}
// MARK: - Custom phrases (delete / reorder / tap-to-edit)
@ViewBuilder private var customSection: some View {
Section(Copy.customSection) {
if store.userChips.isEmpty {
Text(Copy.emptyState)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(store.userChips) { chip in
Button {
beginEditing(chip)
} label: {
chipRow(chip)
}
.tint(DS.Palette.textPrimary)
}
.onDelete { offsets in
// Resolve ids FIRST removing while iterating offsets
// would shift indices under us.
let ids = offsets.map { store.userChips[$0].id }
for id in ids {
store.removeChip(id: id)
}
}
.onMove { fromOffsets, toOffset in
store.moveChips(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
}
}
private func chipRow(_ chip: QuickReplyChip) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// Text(verbatim:) stored strings are boundary data (SEC-L3 mirror).
Text(verbatim: chip.label)
.font(DS.Typography.body)
.lineLimit(1)
Text(verbatim: chip.appendEnter
? chip.text + Copy.enterSuffixSymbol
: chip.text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
}
// MARK: - Add / edit form
@ViewBuilder private var editorSection: some View {
Section(Copy.addSection) {
TextField(Copy.textPlaceholder, text: $draftText)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField(Copy.labelPlaceholder, text: $draftLabel)
Toggle(Copy.appendEnterToggle, isOn: $draftAppendEnter)
Button(editingChipId == nil ? Copy.addButton : Copy.saveButton) {
commitDraft()
}
.disabled(trimmedDraftText.isEmpty)
if editingChipId != nil {
Button(Copy.cancelEditButton, role: .cancel) {
clearDraft()
}
}
}
}
private var trimmedDraftText: String {
draftText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func beginEditing(_ chip: QuickReplyChip) {
editingChipId = chip.id
draftText = chip.text
draftLabel = chip.label
draftAppendEnter = chip.appendEnter
}
/// Web `onSaveClick` semantics: empty text is a no-op (button is disabled
/// anyway belt and braces), empty label defaults to the text.
private func commitDraft() {
let text = trimmedDraftText
guard !text.isEmpty else { return }
let trimmedLabel = draftLabel.trimmingCharacters(in: .whitespacesAndNewlines)
let label = trimmedLabel.isEmpty ? text : trimmedLabel
if let id = editingChipId {
store.updateChip(id: id, text: text, label: label,
appendEnter: draftAppendEnter)
} else {
store.addChip(text: text, label: label, appendEnter: draftAppendEnter)
}
clearDraft()
}
private func clearDraft() {
editingChipId = nil
draftText = ""
draftLabel = ""
draftAppendEnter = true
}
}

View File

@@ -0,0 +1,200 @@
import Foundation
import Observation
import SessionCore
/// T-iOS-25 · Quick-reply data layer: chip model, pure immutable CRUD and the
/// UserDefaults-backed palette store a behavior mirror of
/// `public/quick-reply.ts` (Chip / addChip / removeChip / reorderChip /
/// updateChip / loadPalette / savePalette).
///
/// Storage decision (plan §5.3 split): the palette is NON-SECRET UI prefs
/// UserDefaults with an injectable suite (same pattern as
/// `UserDefaultsLastSessionStore`), never Keychain.
/// One sendable snippet (mirror of the web `Chip` interface).
struct QuickReplyChip: Sendable, Equatable, Codable, Identifiable {
/// Stable identifier (built-ins use the `__` prefix, user chips `user_`).
let id: String
/// Raw bytes to send (without the Enter suffix).
let text: String
/// Display label on the chip button. Rendered via `Text(verbatim:)` only
/// the SwiftUI analogue of the web's textContent-never-innerHTML (SEC-L3).
let label: String
/// If true, Enter (`\r`) is appended to `text` when sending.
let appendEnter: Bool
}
/// Pure palette operations every function returns a NEW array and never
/// mutates its input (web quick-reply.ts CRUD, verbatim semantics).
enum QuickReplyPalette {
/// The six built-in chips for Claude Code interaction, order and values
/// mirroring web `BUILT_IN_CHIPS`. Esc bytes resolve through `KeyByteMap`
/// hand-writing an escape sequence in the App layer is a review finding.
static let builtInChips: [QuickReplyChip] = [
QuickReplyChip(id: "__yes", text: "yes", label: "yes", appendEnter: true),
QuickReplyChip(id: "__continue", text: "continue", label: "continue",
appendEnter: true),
QuickReplyChip(id: "__1", text: "1", label: "1", appendEnter: true),
QuickReplyChip(id: "__2", text: "2", label: "2", appendEnter: true),
QuickReplyChip(id: "__3", text: "3", label: "3", appendEnter: true),
QuickReplyChip(id: "__esc", text: KeyByteMap.bytes(for: .esc), label: "Esc",
appendEnter: false),
]
/// Prefix for user-created chip ids (web: `user_<ts>_<rand>`; iOS: UUID).
static let userChipIdPrefix = "user_"
/// The full byte string a chip tap sends: text plus when `appendEnter`
/// the Enter byte from `KeyByteMap` (`\r`, 0x0D, NOT `\n`; CLAUDE.md gotcha).
static func payload(for chip: QuickReplyChip) -> String {
chip.text + (chip.appendEnter ? KeyByteMap.bytes(for: .enter) : "")
}
/// New array with `chip` appended (web `addChip`).
static func adding(_ chips: [QuickReplyChip], _ chip: QuickReplyChip) -> [QuickReplyChip] {
chips + [chip]
}
/// New array with the chip matching `id` removed (web `removeChip`).
static func removing(_ chips: [QuickReplyChip], id: String) -> [QuickReplyChip] {
chips.filter { $0.id != id }
}
/// New array with the chip at `fromIndex` moved to `toIndex` (web
/// `reorderChip` splice semantics). Either index out of bounds the input
/// returned unchanged. Operates on a local copy value semantics
/// guarantee the caller's array is never touched.
static func reordering(
_ chips: [QuickReplyChip], fromIndex: Int, toIndex: Int
) -> [QuickReplyChip] {
guard chips.indices.contains(fromIndex), chips.indices.contains(toIndex) else {
return chips
}
var result = chips
let moved = result.remove(at: fromIndex)
result.insert(moved, at: toIndex)
return result
}
/// New array where the chip with `id` has non-nil fields patched (web
/// `updateChip`: shallow merge, id immutable). Unknown id unchanged.
static func updating(
_ chips: [QuickReplyChip], id: String,
text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) -> [QuickReplyChip] {
chips.map { chip in
guard chip.id == id else { return chip }
return QuickReplyChip(
id: chip.id,
text: text ?? chip.text,
label: label ?? chip.label,
appendEnter: appendEnter ?? chip.appendEnter
)
}
}
}
/// Per-item lossy decode wrapper (mirror of the web `filter(isValidChip)`):
/// one malformed entry is dropped, the well-formed rest survive stored data
/// crosses a storage boundary and is never trusted (LastSessionStore pattern).
private struct LossyQuickReplyChip: Decodable {
let chip: QuickReplyChip?
init(from decoder: any Decoder) throws {
chip = try? QuickReplyChip(from: decoder)
}
}
/// Observable palette store: holds the user chips, persists every change to
/// the injected `UserDefaults` and exposes the render list (built-ins first,
/// then user chips web render order).
@MainActor
@Observable
final class QuickReplyStore {
/// Single UserDefaults key for the encoded user palette
/// (web: localStorage `web-terminal:quick-reply-palette`).
static let paletteDefaultsKey = "quickReplyPalette"
private(set) var userChips: [QuickReplyChip]
@ObservationIgnored private let defaults: UserDefaults
/// Built-in chips first, then the user palette (web `render()` order).
var allChips: [QuickReplyChip] {
QuickReplyPalette.builtInChips + userChips
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
userChips = Self.loadPalette(from: defaults)
}
/// Add a new phrase (web editor `onSaveClick` semantics): text/label are
/// trimmed, empty text is a no-op (returns false), empty label defaults to
/// the text, the id is minted with the `user_` prefix.
@discardableResult
func addChip(text: String, label: String, appendEnter: Bool) -> Bool {
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty else { return false }
let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines)
let chip = QuickReplyChip(
id: QuickReplyPalette.userChipIdPrefix + UUID().uuidString,
text: trimmedText,
label: trimmedLabel.isEmpty ? trimmedText : trimmedLabel,
appendEnter: appendEnter
)
setUserChips(QuickReplyPalette.adding(userChips, chip))
return true
}
func removeChip(id: String) {
setUserChips(QuickReplyPalette.removing(userChips, id: id))
}
func moveChip(fromIndex: Int, toIndex: Int) {
setUserChips(QuickReplyPalette.reordering(
userChips, fromIndex: fromIndex, toIndex: toIndex
))
}
/// SwiftUI `List.onMove` adapter (IndexSet + insert-before destination on
/// the ORIGINAL array `Array.move` implements that convention).
func moveChips(fromOffsets: IndexSet, toOffset: Int) {
var next = userChips
next.move(fromOffsets: fromOffsets, toOffset: toOffset)
setUserChips(next)
}
func updateChip(
id: String, text: String? = nil, label: String? = nil, appendEnter: Bool? = nil
) {
setUserChips(QuickReplyPalette.updating(
userChips, id: id, text: text, label: label, appendEnter: appendEnter
))
}
/// Load the persisted palette. Missing key, non-JSON data or a non-array
/// root `[]`; malformed items are dropped per-entry. Never throws
/// (web `loadPalette` contract).
static func loadPalette(from defaults: UserDefaults) -> [QuickReplyChip] {
guard let data = defaults.data(forKey: paletteDefaultsKey) else { return [] }
guard let lossy = try? JSONDecoder().decode([LossyQuickReplyChip].self, from: data)
else { return [] }
return lossy.compactMap(\.chip)
}
// MARK: - Private
private func setUserChips(_ chips: [QuickReplyChip]) {
userChips = chips
persist(chips)
}
/// Best-effort save (web `savePalette` contract: storage failure never
/// throws or crashes the in-memory palette stays authoritative for the
/// session). Encoding a `[QuickReplyChip]` cannot practically fail.
private func persist(_ chips: [QuickReplyChip]) {
guard let data = try? JSONEncoder().encode(chips) else { return }
defaults.set(data, forKey: Self.paletteDefaultsKey)
}
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
import WireProtocol
/// T-iOS-11 · Connection-state banner over the terminal (plan §1: the terminal
/// must NEVER "look connected but dead" every non-live state is explicit).
///
/// Render input is the `Model` enum only; the mapping from engine events to a
/// `Model` lives in `TerminalViewModel.bannerModel` (tested there).
struct ReconnectBanner: View {
/// What the banner says. Terminal phases (`failed`/`exited`) win over
/// transient connection states (mapping in `TerminalViewModel.bannerModel`).
enum Model: Equatable {
case connecting
case reconnecting(attempt: Int, retryIn: Duration)
/// Non-retryable failure: actionable copy, NO spinner, NO retry loop.
case failed(message: String)
/// Session over terminal is read-only from here on.
case exited(code: Int, reason: String?)
}
let model: Model
/// T-iOS-29 · "" on the EXITED banner only (session over the
/// natural next step; manager.ts:145-153 the old session is done for
/// good). nil = affordance hidden. Not offered on `.failed`: that copy
/// asks the user to change a knob first, not to spawn again.
var onNewSession: (@MainActor () -> Void)? = nil
private enum Copy {
static let newSession = "开新会话"
}
/// + +
/// GROUP BRIEF
var body: some View {
HStack(spacing: DS.Space.sm8) {
leadingIndicator
Text(text)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.multilineTextAlignment(.leading)
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
Button(Copy.newSession) { onNewSession() }
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.buttonStyle(.plain)
.accessibilityIdentifier("terminal.exitNewSessionButton")
}
}
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityElement(children: .combine)
}
/// Spinner while a connection is in flight, otherwise the state's icon
/// both painted in the semantic indicator color (shape + color, never color
/// alone).
@ViewBuilder private var leadingIndicator: some View {
if isSpinning {
ProgressView()
.controlSize(.small)
.tint(indicatorColor)
} else {
Image(systemName: iconName)
.foregroundStyle(indicatorColor)
}
}
/// The new-session affordance appears on the `.exited` banner only
/// pure predicate so the T-iOS-29 tests pin the rule.
static func isNewSessionActionAvailable(for model: Model) -> Bool {
if case .exited = model { return true }
return false
}
private var isSpinning: Bool {
switch model {
case .connecting, .reconnecting: return true
case .failed, .exited: return false
}
}
private var iconName: String {
switch model {
case .failed: return "exclamationmark.octagon.fill"
case .exited: return "flag.checkered"
case .connecting, .reconnecting: return "wifi"
}
}
/// Semantic indicator color (DS only): connecting is quiet gray, reconnecting
/// is the calm accent (not an orange alarm), failed is the stuck red, exited
/// is dimmed secondary. Paired with a distinct icon so it is never color-only.
private var indicatorColor: Color {
switch model {
case .connecting: return DS.Palette.textSecondary
case .reconnecting: return DS.Palette.accent
case .failed: return DS.Palette.statusStuck
case .exited: return DS.Palette.statusExited
}
}
private var text: String {
switch model {
case .connecting:
return "连接中…"
case .reconnecting(let attempt, let retryIn):
return "连接已断开 · 第 \(attempt) 次重连将在 \(retryIn.components.seconds)s 后…"
case .failed(let message):
return message
case .exited(let code, let reason):
return Self.exitText(code: code, reason: reason)
}
}
/// Exit copy: spawn failure (-1, M4) reads as an error; a normal exit reads
/// as a conclusion. `reason` is server-supplied display text (untrusted
/// rendered as plain text only).
private static func exitText(code: Int, reason: String?) -> String {
if code == WireConstants.spawnFailedExitCode {
return "启动 shell 失败:\(reason ?? "未知原因")"
}
let suffix = reason.map { "\($0)" } ?? ""
return "会话已退出 exit \(code)\(suffix) · 终端已只读"
}
}

View File

@@ -0,0 +1,374 @@
import APIClient
import Foundation
import os
import SwiftUI
import UIKit
import WireProtocol
/// T-iOS-28 · 线`GET /live-sessions/:id/preview`
/// SwiftTerm UIImage /LRU
/// 线 SwiftUI
/// `SessionThumbnailRenderer.swift` import SwiftTerm
///
/// Steps
/// - ** = (sessionId, lastOutputAt)**`lastOutputAt`T-iOS-37 =
/// = nilP1
/// 5s
///
/// - **** spawn SwiftTerm
/// `SessionThumbnailRenderGate`FIFOpermit +
/// 线 `maxConcurrentRenders`
/// - ****404// `.placeholder`
/// lastOutputAt
/// - ****preview `data` SwiftTerm ANSI
/// id//
struct SessionThumbnailRequest: Equatable, Sendable {
let endpoint: HostEndpoint
let sessionId: UUID
/// `LiveSessionInfo.lastOutputAt`ms since epoch
let lastOutputAt: Int?
var key: SessionThumbnailKey {
SessionThumbnailKey(sessionId: sessionId, lastOutputAt: lastOutputAt)
}
}
/// =
struct SessionThumbnailKey: Hashable, Sendable {
let sessionId: UUID
let lastOutputAt: Int?
}
/// 线`Equatable` `.rendered` ===
enum SessionThumbnailImage: Sendable {
case rendered(UIImage)
case placeholder
var isPlaceholder: Bool {
if case .placeholder = self { return true }
return false
}
var uiImage: UIImage? {
if case .rendered(let image) = self { return image }
return nil
}
}
extension SessionThumbnailImage: Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.placeholder, .placeholder): return true
case (.rendered(let a), .rendered(let b)): return a === b
default: return false
}
}
}
/// feature WireProtocol `Tunables`
enum SessionThumbnailTunable {
/// + 线 spawn
static let maxConcurrentRenders = 2
/// LRU 2x
static let maxCacheEntries = 32
/// tail 24 KiBsrc/config.ts:46
/// DEFAULT_PREVIEW_BYTESenv `PREVIEW_BYTES` ~10×
///
static let maxPreviewDataBytes = 256 * 1024
/// /
/// 300
static let maxRenderCols = 300
static let maxRenderRows = 120
/// web `Math.max(2, )`public/preview-grid.ts:112-117
static let minRenderGrid = 2
}
// MARK: - LRU
/// LRU`order` 使
struct SessionThumbnailCache {
let maxEntries: Int
private let entries: [SessionThumbnailKey: SessionThumbnailImage]
private let order: [SessionThumbnailKey]
init(maxEntries: Int) {
self.init(maxEntries: maxEntries, entries: [:], order: [])
}
private init(
maxEntries: Int,
entries: [SessionThumbnailKey: SessionThumbnailImage],
order: [SessionThumbnailKey]
) {
self.maxEntries = max(1, maxEntries)
self.entries = entries
self.order = order
}
func value(for key: SessionThumbnailKey) -> SessionThumbnailImage? {
entries[key]
}
/// most-recently-used
func bumping(_ key: SessionThumbnailKey) -> SessionThumbnailCache {
guard entries[key] != nil else { return self }
return SessionThumbnailCache(
maxEntries: maxEntries, entries: entries,
order: order.filter { $0 != key } + [key]
)
}
/// least-recently-used
func inserting(
_ image: SessionThumbnailImage, for key: SessionThumbnailKey
) -> SessionThumbnailCache {
var newEntries = entries.merging([key: image]) { _, new in new }
var newOrder = order.filter { $0 != key } + [key]
while newOrder.count > maxEntries, let oldest = newOrder.first {
newOrder = Array(newOrder.dropFirst())
newEntries = newEntries.filter { $0.key != oldest }
}
return SessionThumbnailCache(
maxEntries: maxEntries, entries: newEntries, order: newOrder
)
}
}
// MARK: - FIFOpermit
/// 线`acquire` FIFO `release`
/// permit activeCount `waitUntilWaiting`
/// FakeClock.waitForSleepers continuation
@MainActor
final class SessionThumbnailRenderGate {
let limit: Int
private(set) var activeCount = 0
private(set) var peakActiveCount = 0
private var waiters: [CheckedContinuation<Void, Never>] = []
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
var waitingCount: Int { waiters.count }
init(limit: Int) {
self.limit = max(1, limit)
}
func acquire() async {
if activeCount < limit {
activeCount += 1
peakActiveCount = max(peakActiveCount, activeCount)
return
}
await withCheckedContinuation { cont in
waiters = waiters + [cont]
notifyBarriers()
}
// release() = permit activeCount
peakActiveCount = max(peakActiveCount, activeCount)
}
func release() {
guard waiters.isEmpty else {
let next = waiters[0]
waiters = Array(waiters.dropFirst())
next.resume() // permit activeCount
return
}
activeCount = max(0, activeCount - 1)
}
/// `count` acquire
func waitUntilWaiting(count: Int) async {
if waiters.count >= count { return }
await withCheckedContinuation { cont in
barriers = barriers + [(count, cont)]
}
}
private func notifyBarriers() {
let met = barriers.filter { $0.target <= waiters.count }
barriers = barriers.filter { $0.target > waiters.count }
for barrier in met { barrier.cont.resume() }
}
}
// MARK: - 线
@MainActor
final class SessionThumbnailPipeline {
/// = `APIClient.preview(id:)`RO GET Origin
typealias PreviewLoader = @MainActor (SessionThumbnailRequest) async throws -> SessionPreview
/// = `SessionThumbnailRenderer.render` SwiftTerm
/// `clampedGeometry`
typealias PreviewRenderer = @MainActor (_ data: String, _ cols: Int, _ rows: Int) -> UIImage?
private let loader: PreviewLoader
private let renderer: PreviewRenderer
private let gate: SessionThumbnailRenderGate
private var cache: SessionThumbnailCache
/// await
private var inFlight: [SessionThumbnailKey: Task<SessionThumbnailImage, Never>] = [:]
private let logger = Logger(
subsystem: SessionThumbnailLog.subsystem, category: SessionThumbnailLog.category
)
init(
loader: @escaping PreviewLoader,
renderer: @escaping PreviewRenderer,
gate: SessionThumbnailRenderGate? = nil,
maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries
) {
self.loader = loader
self.renderer = renderer
self.gate = gate
?? SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders)
cache = SessionThumbnailCache(maxEntries: maxCacheEntries)
}
/// ephemeral URLSessionpreview
/// only T-iOS-19 RO GET
static func live() -> SessionThumbnailPipeline {
SessionThumbnailPipeline(
loader: { request in
try await APIClient(endpoint: request.endpoint, http: liveTransport)
.preview(id: request.sessionId)
},
renderer: { data, cols, rows in
SessionThumbnailRenderer.render(data: data, cols: cols, rows: rows)
}
)
}
private static let liveTransport = URLSessionHTTPTransport()
/// `.placeholder`
/// UI
func thumbnail(for request: SessionThumbnailRequest) async -> SessionThumbnailImage {
let key = request.key
if let hit = cache.value(for: key) {
cache = cache.bumping(key)
return hit
}
if let running = inFlight[key] {
return await running.value
}
let task = Task { await produce(request) }
inFlight = inFlight.merging([key: task]) { _, new in new }
let result = await task.value
inFlight = inFlight.filter { $0.key != key }
cache = cache.inserting(result, for: key)
return result
}
private func produce(_ request: SessionThumbnailRequest) async -> SessionThumbnailImage {
await gate.acquire()
defer { gate.release() }
let preview: SessionPreview
do {
preview = try await loader(request)
} catch {
logger.debug("preview fetch failed: \(String(describing: error), privacy: .public)")
return .placeholder
}
guard Self.isAcceptable(preview, for: request),
let grid = Self.clampedGeometry(cols: preview.cols, rows: preview.rows) else {
logger.debug("preview rejected at boundary (id/bytes/geometry)")
return .placeholder
}
guard let image = renderer(preview.data, grid.cols, grid.rows) else {
logger.debug("offscreen snapshot returned nil")
return .placeholder
}
return .rendered(image)
}
// MARK: - data SwiftTerm
/// id
static func isAcceptable(
_ preview: SessionPreview, for request: SessionThumbnailRequest
) -> Bool {
preview.id == request.sessionId
&& preview.data.utf8.count <= SessionThumbnailTunable.maxPreviewDataBytes
}
/// + resize `WireConstants.resizeRange`
/// 1...1000 nilmin web
/// `max(2,·)`max /
static func clampedGeometry(cols: Int, rows: Int) -> (cols: Int, rows: Int)? {
guard Validation.isValidResize(cols: cols, rows: rows) else { return nil }
let clampedCols = min(
max(cols, SessionThumbnailTunable.minRenderGrid),
SessionThumbnailTunable.maxRenderCols
)
let clampedRows = min(
max(rows, SessionThumbnailTunable.minRenderGrid),
SessionThumbnailTunable.maxRenderRows
)
return (clampedCols, clampedRows)
}
}
// MARK: - SwiftUI
/// `.task(id: key)` lastOutputAt
/// / /
/// UI
struct SessionThumbnailView: View {
let request: SessionThumbnailRequest
let pipeline: SessionThumbnailPipeline
@State private var image: SessionThumbnailImage?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// `SessionThumbnailRenderer.snapshotTargetWidth`=2×88
/// 2x token
private enum Metrics {
static let width: CGFloat = 88
static let height: CGFloat = 56
}
var body: some View {
ZStack {
placeholder
if let snapshot = image?.uiImage {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity)
}
}
.frame(width: Metrics.width, height: Metrics.height)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm8))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.sm8)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
// Reduce Motion
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: image)
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
.task(id: request.key) {
image = await pipeline.thumbnail(for: request)
}
}
/// / / + overlay +
/// DS direction: "not a raw black rect"
private var placeholder: some View {
ZStack {
DS.Palette.card
Image(systemName: "terminal")
.imageScale(.large)
.foregroundStyle(DS.Palette.textTertiary)
}
}
}
///
enum SessionThumbnailCopy {
static let thumbnailLabel = "会话画面缩略图"
}
private enum SessionThumbnailLog {
static let subsystem = "com.yaojia.webterm"
static let category = "session-thumbnail"
}

View File

@@ -0,0 +1,85 @@
import SwiftTerm
import UIKit
/// T-iOS-28 · SwiftTerm preview tail ANSI
/// ****
/// `TerminalView` `UIGraphicsImageRenderer` view
///
/// web public/preview-grid.ts makePreviewCard/renderPreview
/// - = cols×rows
/// - `scrollback: 0` `changeScrollback(nil)`buffer
/// `contentOffset` 0 draw
/// - + web cursor==background DECTCEM
/// iOS caret
/// - `snapshotTargetWidth`
///
/// `TerminalView` runloop CADisplayLink
/// `updateUiClosed()` runloop
@MainActor
enum SessionThumbnailRenderer {
/// pt 88pt 2 `snapshotScale`=2
/// KB
static let snapshotTargetWidth: CGFloat = 176
/// 2x 3x × 2.25
static let snapshotScale: CGFloat = 2
/// web public/preview-grid.ts fontSize: 12
static let fontSize: CGFloat = 12
/// ptframe optimal + slack `Int(width/cellW)`
/// cell
static let layoutSlack: CGFloat = 0.5
/// DECTCEM iOS caret
///
static let hideCursorSequence = "\u{1b}[?25l"
/// web PREVIEW_THEME#0e0f13 / #e7e8ec
static let backgroundColor = UIColor(
red: 14 / 255, green: 15 / 255, blue: 19 / 255, alpha: 1
)
static let foregroundColor = UIColor(
red: 231 / 255, green: 232 / 255, blue: 236 / 255, alpha: 1
)
private static let initialProbeFrame = CGRect(x: 0, y: 0, width: 64, height: 64)
/// `cols`/`rows` `clampedGeometry`
/// nil
static func render(data: String, cols: Int, rows: Int) -> UIImage? {
let font = UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
let terminal = TerminalView(frame: initialProbeFrame, font: font)
defer { terminal.updateUiClosed() } // CADisplayLink
terminal.changeScrollback(nil) // web scrollback: 0
terminal.resize(cols: cols, rows: rows)
terminal.nativeBackgroundColor = backgroundColor
terminal.nativeForegroundColor = foregroundColor
let optimal = terminal.getOptimalFrameSize()
guard optimal.width > 0, optimal.height > 0 else { return nil }
terminal.frame = CGRect(
x: 0, y: 0,
width: optimal.width + layoutSlack, height: optimal.height + layoutSlack
)
terminal.layoutIfNeeded()
terminal.feed(text: data) // ANSI
terminal.feed(text: hideCursorSequence)
return snapshot(of: terminal, contentSize: optimal.size)
}
/// `UIGraphicsImageRenderer` + `layer.render(in:)` window
/// CoreGraphics draw SwiftTerm MetalMetal
/// `layer.render` `setUseMetal`
private static func snapshot(of view: UIView, contentSize: CGSize) -> UIImage? {
let scale = min(1, snapshotTargetWidth / contentSize.width)
let size = CGSize(
width: contentSize.width * scale, height: contentSize.height * scale
)
guard size.width >= 1, size.height >= 1 else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = snapshotScale
format.opaque = true
let renderer = UIGraphicsImageRenderer(size: size, format: format)
return renderer.image { context in
context.cgContext.scaleBy(x: scale, y: scale)
view.layer.render(in: context.cgContext)
}
}
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
import WireProtocol
/// T-iOS-13 · Telemetry chips for a session-list row the iOS mirror of the
/// web's `renderTelemetryGauge` (public/preview-grid.ts:197): context-usage %
/// (warn > 80), $cost to 4 places, model chip, PR badge.
///
/// Staleness: chips grey out when `StatusTelemetry.at` is STRICTLY older than
/// `Tunables.telemetryStaleTtlMs` (same `>` rule as the web's `tg-stale`).
/// The comparison lives in `Model`, computed from an injected `nowMs` so the
/// ViewModel tests are deterministic (task RED list).
///
/// Security (mirrors SEC-H5/SEC-L5): every telemetry string is server-supplied
/// UNTRUSTED input rendered as plain `Text` only; the PR badge becomes a
/// tappable `Link` iff its URL parses as https, otherwise it renders as text.
struct TelemetryChips: View {
/// Immutable render input, derived once per list rebuild.
struct Model: Equatable, Sendable {
let contextUsedPct: Double?
/// Pre-formatted `$X.XXXX` (mirrors the web's `toFixed(4)`).
let costText: String?
let modelName: String?
/// Pre-formatted `PR #N`.
let prText: String?
let prReviewState: String?
/// Tappable PR destination https URLs ONLY (SEC-L5 mirror), else nil.
let prURL: URL?
/// True when `at` is strictly older than `Tunables.telemetryStaleTtlMs`.
let isStale: Bool
/// nil when the session has no telemetry at all (row renders no chips).
init?(telemetry: StatusTelemetry?, nowMs: Int) {
guard let telemetry else { return nil }
isStale = nowMs - telemetry.at > Tunables.telemetryStaleTtlMs
contextUsedPct = telemetry.contextUsedPct
costText = telemetry.costUsd.map { String(format: Format.cost, $0) }
modelName = telemetry.model
if let pr = telemetry.pr {
prText = "PR #\(pr.number)"
prReviewState = pr.reviewState
prURL = Self.httpsOnlyURL(pr.url)
} else {
prText = nil
prReviewState = nil
prURL = nil
}
}
/// `ctx NN%` same rounding as the web label (`Math.round`).
var contextText: String? {
contextUsedPct.map { "ctx \(Int($0.rounded()))%" }
}
/// Mirrors the web's `tg-ctx-warn` threshold (strictly > 80).
var isContextWarning: Bool {
(contextUsedPct ?? 0) > Format.contextWarnPct
}
var isEmpty: Bool {
contextUsedPct == nil && costText == nil && modelName == nil && prText == nil
}
/// SEC-L5 mirror: only https URLs are ever offered as link targets.
static func httpsOnlyURL(_ raw: String) -> URL? {
guard let url = URL(string: raw),
url.scheme?.lowercased() == Format.httpsScheme
else { return nil }
return url
}
}
let model: Model
/// Composed from the frozen `TelemetryChip` primitive each chip is
/// mono-tabular, greys itself out when `isStale`, and the context chip goes
/// amber over the warn threshold. The PR chip becomes a tappable `Link`
/// only when the VM handed us an https URL (SEC-L5 boundary stays in Model).
var body: some View {
HStack(spacing: DS.Space.xs4) {
if let contextText = model.contextText {
TelemetryChip(
systemImage: Symbols.context,
text: contextText,
isStale: model.isStale,
isWarning: model.isContextWarning
)
}
if let costText = model.costText {
TelemetryChip(text: costText, isStale: model.isStale)
}
if let modelName = model.modelName {
TelemetryChip(
systemImage: Symbols.model, text: modelName, isStale: model.isStale
)
}
prBadge
}
}
@ViewBuilder private var prBadge: some View {
if let prText = model.prText {
let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText
let chip = TelemetryChip(
systemImage: Symbols.pr, text: label, isStale: model.isStale
)
if let url = model.prURL {
Link(destination: url) { chip }
} else {
chip
}
}
}
// MARK: - Chip icons (SF Symbols, no magic numbers/colors DS owns those)
private enum Symbols {
static let context = "gauge.medium"
static let model = "cpu"
static let pr = "arrow.triangle.branch"
}
private enum Format {
/// Mirrors web `$${costUsd.toFixed(4)}`.
static let cost = "$%.4f"
/// Mirrors web `tg-ctx-warn` (`contextUsedPct > 80`).
static let contextWarnPct = 80.0
static let httpsScheme = "https"
}
}

View File

@@ -0,0 +1,144 @@
import UIKit
/// T-iPad-3 · /·
///
/// + `UIContextMenuInteraction` **
/// **
/// - `.copySelection` SwiftTerm `copy(_:)`
/// UI hover
/// - `.newInCwd` T-iOS-29 `onNewInCwd` cwd fresh spawn
/// - `.kill` `onKill`wiring `APIClient.killSession`
/// Origin G RO/G
///
/// iPad idiom `isPointerMenuEnabled` iPhone
/// SwiftTerm
/// One context-menu action, each mapped to an existing channel (no new path).
enum TerminalContextAction: String, CaseIterable, Sendable, Equatable {
case copySelection
case newInCwd
case kill
}
/// Display spec for one menu row (Chinese copy + SF Symbol + destructive flag).
struct TerminalContextItem: Equatable, Sendable {
let action: TerminalContextAction
let title: String
let systemImage: String
let isDestructive: Bool
}
enum TerminalContextMenu {
/// named constantsplan §4
enum Copy {
static let copySelection = "复制选区"
static let newInCwd = "在当前目录开新会话"
static let kill = "结束会话"
}
private enum Symbol {
static let copySelection = "doc.on.doc"
static let newInCwd = "plus.rectangle.on.folder"
static let kill = "xmark.circle"
}
/// iPad idiom size class
/// `LayoutPolicy` stack/split iPhone
/// interaction SwiftTerm
static func isPointerMenuEnabled(idiom: UIUserInterfaceIdiom) -> Bool {
idiom == .pad
}
///
static func items(
canCopySelection: Bool,
canNewInCwd: Bool,
canKill: Bool
) -> [TerminalContextItem] {
var result: [TerminalContextItem] = []
if canCopySelection {
result.append(TerminalContextItem(
action: .copySelection, title: Copy.copySelection,
systemImage: Symbol.copySelection, isDestructive: false
))
}
if canNewInCwd {
result.append(TerminalContextItem(
action: .newInCwd, title: Copy.newInCwd,
systemImage: Symbol.newInCwd, isDestructive: false
))
}
if canKill {
result.append(TerminalContextItem(
action: .kill, title: Copy.kill,
systemImage: Symbol.kill, isDestructive: true
))
}
return result
}
}
/// + `items` `perform`
/// wiring `onNewInCwd`T-iOS-29/ `onKill`APIClient.killSession
/// `onCopySelection`/`hasSelection`
@MainActor
struct TerminalContextMenuModel {
let onNewInCwd: (@MainActor () -> Void)?
let onKill: (@MainActor () -> Void)?
let onCopySelection: @MainActor () -> Void
let hasSelection: @MainActor () -> Bool
/// / wiring
/// =
var items: [TerminalContextItem] {
TerminalContextMenu.items(
canCopySelection: hasSelection(),
canNewInCwd: onNewInCwd != nil,
canKill: onKill != nil
)
}
///
func perform(_ action: TerminalContextAction) {
switch action {
case .copySelection:
onCopySelection()
case .newInCwd:
onNewInCwd?()
case .kill:
onKill?()
}
}
}
/// `UIContextMenuInteraction` `makeModel()`
/// / `UIMenu` `UIAction` `model.perform`
/// iPad `TerminalContextMenu.isPointerMenuEnabled`
@MainActor
final class TerminalContextMenuInteractionDelegate: NSObject, UIContextMenuInteractionDelegate {
private let makeModel: @MainActor () -> TerminalContextMenuModel
init(makeModel: @escaping @MainActor () -> TerminalContextMenuModel) {
self.makeModel = makeModel
}
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
let model = makeModel()
let items = model.items
guard !items.isEmpty else { return nil } //
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
UIMenu(title: "", children: items.map { item in
UIAction(
title: item.title,
image: UIImage(systemName: item.systemImage),
attributes: item.isDestructive ? .destructive : []
) { _ in
model.perform(item.action)
}
})
}
}
}

View File

@@ -0,0 +1,251 @@
import Foundation
import HostRegistry
import Observation
import OSLog
import WireProtocol
/// T-iOS-22 · Deep-link routing.
///
/// Two external entry points share ONE validation surface:
/// - `webterminal://open?host=<uuid>&join=<uuid>` (scheme registered in
/// project.yml `CFBundleURLTypes`; web QR `?join=`
/// T-iOS-35 ), and
/// - the WEBTERM_GATE push payload `{sessionId}` (T-iOS-21 reuses
/// `route(from:)` same UUID rules, no second parser).
///
/// (plan T-iOS-22): a deep link is EXTERNAL input. Every field is
/// whitelist-validated (scheme / action / query keys / UUID v4 via the frozen
/// `Validation.isValidSessionId` never re-regexed); ANY invalid part
/// `.ignore` + counter (never crash, never partially apply). The host id
/// resolves ONLY through a `HostStore` lookup (`DeepLinkHandler`) no URL or
/// request is ever built from link contents.
enum DeepLinkRouter {
/// Parse outcome. `.openSession` carries RAW validated ids host
/// EXISTENCE is resolved later against the store (`DeepLinkHandler`).
enum Route: Equatable, Sendable {
/// `webterminal://open` with both ids syntactically valid.
case openSession(hostId: UUID, sessionId: UUID)
/// Push payload with a valid `sessionId` (no host id in the payload
/// T-iOS-21's notification handler owns the resolution strategy).
case gateSession(sessionId: UUID)
/// Anything else. Callers count + log; they never partially apply.
case ignore
}
/// Whitelisted URL shape (`webterminal://open`). Scheme and URL host are
/// case-insensitive per RFC 3986 compared lowercased.
private enum LinkShape {
static let scheme = "webterminal"
static let action = "open"
static let emptyPaths: Set<String> = ["", "/"]
}
/// Whitelisted query keys (exact match; unknown keys are ignored).
private enum QueryKey {
static let host = "host"
static let join = "join"
}
/// Root key of the APNs payload (server contract, T-iOS-20 final shape).
private enum PushKey {
static let sessionId = "sessionId"
}
// MARK: - URL entry (onOpenURL)
static func route(url: URL) -> Route {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme?.lowercased() == LinkShape.scheme,
components.host?.lowercased() == LinkShape.action,
LinkShape.emptyPaths.contains(components.path),
let hostId = uniqueValidatedId(in: components, key: QueryKey.host),
let sessionId = uniqueValidatedId(in: components, key: QueryKey.join)
else { return .ignore }
return .openSession(hostId: hostId, sessionId: sessionId)
}
// MARK: - Push entry (WEBTERM_GATE payload, reused by T-iOS-21)
static func route(from payload: [AnyHashable: Any]) -> Route {
guard let raw = payload[PushKey.sessionId] as? String,
let sessionId = validatedId(raw)
else { return .ignore }
return .gateSession(sessionId: sessionId)
}
// MARK: - Field validation
/// Exactly ONE occurrence of `key`, and its value is a v4 UUID.
/// Duplicates are ambiguous input nil (never partially apply).
private static func uniqueValidatedId(in components: URLComponents, key: String) -> UUID? {
let matches = (components.queryItems ?? []).filter { $0.name == key }
guard matches.count == 1, let raw = matches.first?.value else { return nil }
return validatedId(raw)
}
/// Frozen-contract v4 check (`Validation.isValidSessionId`, plan §3.1)
/// FIRST `UUID(uuidString:)` alone would admit non-v4 ids the server's
/// SESSION_ID_RE rejects.
private static func validatedId(_ raw: String) -> UUID? {
guard Validation.isValidSessionId(raw) else { return nil }
return UUID(uuidString: raw)
}
}
/// User-facing deep-link copy (plan §4: ).
enum DeepLinkCopy {
static let hintTitle = "无法打开链接"
static let unknownHostHint = "链接指向的主机尚未配对,请先扫码完成配对。"
static let hostLoadFailed = "读取已配对主机失败,无法打开链接,请重进 App 后再试。"
static let hintConfirm = ""
}
/// Applies parsed routes to the app: host-store lookup (the ONLY host-id
/// resolution point), the cold-launch stash, the unknown-host pairing hint,
/// and the invalid-link counter. Pure routing stays in `DeepLinkRouter`; this
/// class owns the stateful edges so `AppCoordinator`'s diff stays minimal.
@MainActor
@Observable
final class DeepLinkHandler {
/// Coordinator-provided effects (closures the handler never reaches
/// into navigation state itself).
struct Actions {
/// Open `sessionId` on a RESOLVED, store-known host.
let openSession: @MainActor (HostRegistry.Host, UUID) -> Void
/// Unknown host id surface the pairing flow.
let showPairing: @MainActor () -> Void
}
/// Alert copy for RootView (unknown host / store failure). nil = no alert.
private(set) var hintMessage: String?
/// Invalid deep links dropped so far (plan: `.ignore` + log counter).
private(set) var ignoredCount = 0
@ObservationIgnored private let loadHosts: @Sendable () async throws -> [HostRegistry.Host]
@ObservationIgnored private let actions: Actions
/// Cold-launch gate: `handle(url:)` before `markReady()` stashes instead
/// of applying (the host list / root route may not exist yet).
@ObservationIgnored private var isReady = false
/// Single-slot stash a newer link before readiness replaces the older
/// one (two half-applied navigations would be worse than dropping one).
@ObservationIgnored private var pendingRoute: DeepLinkRouter.Route?
@ObservationIgnored private let logger = Logger(
subsystem: DeepLinkLog.subsystem, category: DeepLinkLog.category
)
init(
loadHosts: @escaping @Sendable () async throws -> [HostRegistry.Host],
actions: Actions
) {
self.loadHosts = loadHosts
self.actions = actions
}
// MARK: - Entry points
/// `.onOpenURL` lands here (via `AppCoordinator.handleDeepLink`). Invalid
/// links are counted and dropped they are NEVER stashed.
func handle(url: URL) async {
let route = DeepLinkRouter.route(url: url)
guard route != .ignore else {
recordIgnored()
return
}
guard isReady else {
pendingRoute = route
return
}
await apply(route)
}
/// Cold-start bootstrap finished (root route decided) flush the stash.
/// Idempotent: the slot is cleared before applying, so a second call
/// never replays.
func markReady() async {
isReady = true
guard let route = pendingRoute else { return }
pendingRoute = nil
await apply(route)
}
func clearHint() {
hintMessage = nil
}
// MARK: - Apply (host id resolves ONLY through the store)
private func apply(_ route: DeepLinkRouter.Route) async {
// `.gateSession` never reaches here in P1: it only exists for the
// push path, whose handling (host resolution incl.) is T-iOS-21.
guard case let .openSession(hostId, sessionId) = route else { return }
do {
let hosts = try await loadHosts()
guard let host = hosts.first(where: { $0.id == hostId }) else {
hintMessage = DeepLinkCopy.unknownHostHint
actions.showPairing()
return
}
actions.openSession(host, sessionId)
} catch {
// Explicit failure copy a broken store must never look like a
// silently dead link (plan §4 error-handling rule).
hintMessage = DeepLinkCopy.hostLoadFailed
logger.error("deep link host-store read failed: \(error)")
}
}
private func recordIgnored() {
ignoredCount += 1
// URL contents are untrusted external input log the counter only,
// never echo the link (log-injection / privacy hygiene).
logger.notice("ignored invalid deep link (total: \(self.ignoredCount))")
}
}
private enum DeepLinkLog {
static let subsystem = "com.yaojia.webterm"
static let category = "deep-link"
}
// MARK: - AppCoordinator wiring (kept here so the coordinator diff stays tiny)
extension AppCoordinator {
/// Factory for the coordinator's lazy `deepLink` property.
func makeDeepLinkHandler() -> DeepLinkHandler {
DeepLinkHandler(
loadHosts: { [environment] in try await environment.hostStore.loadAll() },
actions: DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.openDeepLinkedSession(host: host, sessionId: sessionId)
},
showPairing: { [weak self] in
self?.showPairingForDeepLink()
}
)
)
}
/// RootView's `.onOpenURL` entry (sync SwiftUI context async apply).
/// Fires for BOTH warm links and cold launches on cold start the
/// handler stashes until `bootstrap()` calls `markReady()`.
func handleDeepLink(url: URL) {
Task { await deepLink.handle(url: url) }
}
/// Deep links may target a session while another terminal is open:
/// detach the current one first (`open` is a no-op otherwise its
/// one-foreground-session guard would swallow the link).
private func openDeepLinkedSession(host: HostRegistry.Host, sessionId: UUID) {
if terminalController != nil {
closeTerminal()
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
/// Unknown host id pairing. On the first-run pairing route the pairing
/// screen is already frontmost only the list route needs the sheet.
private func showPairingForDeepLink() {
guard route == .sessions else { return }
presentAddHost()
}
}

View File

@@ -0,0 +1,225 @@
import SwiftUI
import WireProtocol
/// # Primitives reusable SwiftUI building blocks (FROZEN public surface)
///
/// The four component groups compose these by exact name. Each pulls ALL of its
/// constants from `DS`/`StatusStyle`/`DS.Typography` no inline magic. Every
/// primitive ships a `#Preview`.
// MARK: - StatusBadge
/// Color + distinct SF Symbol (+ optional Chinese label) for one status. The
/// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by
/// shape, color AND speech. The symbol scales with Dynamic Type.
struct StatusBadge: View {
let status: DisplayStatus
/// Show the Chinese word next to the symbol (list rows usually don't).
var showsLabel: Bool = false
/// Convenience init from the wire enum.
init(status: DisplayStatus, showsLabel: Bool = false) {
self.status = status
self.showsLabel = showsLabel
}
init(claude: ClaudeStatus, showsLabel: Bool = false) {
self.init(status: DisplayStatus(claude), showsLabel: showsLabel)
}
private var style: StatusStyle { StatusStyle.style(for: status) }
var body: some View {
HStack(spacing: DS.Space.xs4) {
Image(systemName: style.symbolName)
.foregroundStyle(style.color)
.imageScale(.medium)
if showsLabel {
Text(style.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(style.label)
}
}
// MARK: - TelemetryChip
/// One pill of monospaced-tabular telemetry (context %, $cost, model, PR).
/// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the
/// waiting/amber semantic color for over-threshold context.
struct TelemetryChip: View {
/// Optional leading SF Symbol.
var systemImage: String? = nil
let text: String
var isStale: Bool = false
var isWarning: Bool = false
var body: some View {
HStack(spacing: DS.Space.xs2) {
if let systemImage {
Image(systemName: systemImage)
}
Text(text)
}
.font(DS.Typography.mono(.caption2))
.lineLimit(1)
.foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(.quaternary, in: Capsule())
.opacity(isStale ? DS.Opacity.stale : 1)
.grayscale(isStale ? 1 : 0)
}
}
// MARK: - Card
/// Standard card container: card surface + hairline stroke + `md12` radius +
/// standard padding. The uniform card spec for rows, grid cells and panels.
struct Card<Content: View>: View {
/// Inner padding (defaults to `md12`; pass `sm8` for tight rows).
var padding: CGFloat = DS.Space.md12
@ViewBuilder var content: () -> Content
init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) {
self.padding = padding
self.content = content
}
var body: some View {
content()
.padding(padding)
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - SectionHeader
/// A small, secondary section label (Chinese copy passed verbatim by callers).
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
// MARK: - DSButtonStyle
/// The App's button style. `primary` = accent-filled, `secondary` = tinted
/// outline, `destructive` = red-filled. Always `minHitTarget` tall, full
/// width, `md12` radius. Press feedback honors Reduce Motion.
struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .primary
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(kind: kind, configuration: configuration)
}
/// Nested view so we can read `@Environment` (a `ButtonStyle` cannot).
/// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement).
struct DSButtonBody: View {
let kind: Kind
let configuration: Configuration
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(DS.Typography.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
.foregroundStyle(foreground)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(border)
.opacity(opacity)
.animation(
DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion),
value: configuration.isPressed
)
}
private var foreground: Color {
switch kind {
// Gold accent fill needs DARK ink for contrast (mirrors web
// --on-accent), NOT white. Red destructive fill keeps white.
case .primary: return DS.Palette.onAccent
case .destructive: return .white
case .secondary: return DS.Palette.accent
}
}
private var background: Color {
switch kind {
case .primary: return DS.Palette.accent
case .destructive: return DS.Palette.statusStuck
case .secondary: return DS.Palette.card
}
}
@ViewBuilder private var border: some View {
if kind == .secondary {
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
}
}
private var opacity: Double {
if !isEnabled { return DS.Opacity.pressed }
return configuration.isPressed ? DS.Opacity.pressed : 1
}
}
}
// MARK: - Previews
#Preview("StatusBadge") {
VStack(alignment: .leading, spacing: DS.Space.md12) {
ForEach(DisplayStatus.allCases, id: \.self) { status in
StatusBadge(status: status, showsLabel: true)
}
}
.padding(DS.Space.lg16)
}
#Preview("TelemetryChip") {
HStack(spacing: DS.Space.sm8) {
TelemetryChip(text: "ctx 92%", isWarning: true)
TelemetryChip(text: "$0.1234")
TelemetryChip(systemImage: "cpu", text: "opus")
TelemetryChip(text: "PR #7", isStale: true)
}
.padding(DS.Space.lg16)
}
#Preview("Card") {
Card {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
SectionHeader(title: "会话")
Text(verbatim: "web-terminal")
.font(DS.Typography.headline)
Text(verbatim: "2 台设备在看 · 161×50")
.dsMetaText()
}
}
.padding(DS.Space.lg16)
}
#Preview("DSButtonStyle") {
VStack(spacing: DS.Space.md12) {
Button("新建会话") {}.buttonStyle(DSButtonStyle(kind: .primary))
Button("继续上次会话") {}.buttonStyle(DSButtonStyle(kind: .secondary))
Button("结束会话") {}.buttonStyle(DSButtonStyle(kind: .destructive))
}
.tint(DS.Palette.accent)
.padding(DS.Space.lg16)
}

View File

@@ -0,0 +1,86 @@
import SwiftUI
import WireProtocol
/// # Status visuals the SINGLE source (FROZEN public surface)
///
/// Every place that shows a session's Claude-Code status (list rows, badges,
/// thumbnails, project-detail rows, banners) resolves it through here, so the
/// color + shape + label stay identical everywhere. Status is expressed as
/// **color AND a distinct SF Symbol** never color alone (accessibility;
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
/// The seven visual states a status indicator can show. A superset of the wire
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
/// emphasis states:
/// - `pendingApproval` a tool/plan gate is held server-side ("needs me").
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
/// (this type does NOT re-implement that priority callers decide when to
/// use it; here it's only its visual identity).
/// - `exited` the session is over (read-only).
enum DisplayStatus: CaseIterable, Sendable, Equatable {
case working
case waiting
case idle
case stuck
case unknown
case pendingApproval
case exited
/// Bridge from the wire enum. Pure no pending/exited emphasis (callers
/// supply those explicitly, matching the VM's own indicator priority).
init(_ claude: ClaudeStatus) {
switch claude {
case .working: self = .working
case .waiting: self = .waiting
case .idle: self = .idle
case .stuck: self = .stuck
case .unknown: self = .unknown
}
}
}
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
/// itself is pure no UIKit, deterministic, unit-testable.
struct StatusStyle: Equatable, Sendable {
/// Semantic color from `DS.Palette` (never the sole signal see `symbolName`).
let color: Color
/// SF Symbol name. Distinct across all seven statuses so shape alone
/// disambiguates (color-blind safe).
let symbolName: String
/// Chinese status word shown as an optional label and always as the
/// accessibility label.
let label: String
/// The frozen mapping. Each status (color, distinct symbol, label).
static func style(for status: DisplayStatus) -> StatusStyle {
switch status {
case .working:
// solid filled circle actively running
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
case .waiting:
// clock waiting on something
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
case .idle:
// hollow circle quiet/empty (gray, distinct from working's fill)
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
case .stuck:
// triangle alarm
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
case .unknown:
// question mark no signal yet
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
case .pendingApproval:
// filled ! circle "needs me", the single most important prompt
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
case .exited:
// checkered flag session over (mirrors ReconnectBanner's exited icon)
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
}
}
/// Convenience bridge from the wire enum.
static func style(for claude: ClaudeStatus) -> StatusStyle {
style(for: DisplayStatus(claude))
}
}

View File

@@ -0,0 +1,218 @@
import SwiftUI
import UIKit
/// # WebTerm Design System token vocabulary (FROZEN public surface)
///
/// The single source of truth for every visual constant in the App layer.
/// Screens/components must reference these tokens never inline colors,
/// spacings, radii, opacities, durations or haptics. Direction: ""
/// (refined native, Apple HIG), dark-mode-first, amber-gold accent (desktop-matched)
/// continuing the web selection color.
///
/// Vocabulary (all under `DS.`):
/// - `Palette` adaptive `accent` (amber gold, matches desktop) · semantic `status*` colors
/// (color is NEVER the only status signal pair with a symbol,
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
/// `textPrimary`/`textSecondary`/`textTertiary`.
/// - `Space` 2·4·8·12·16·20·24 scale (`xs2``xxl24`). No off-scale gaps.
/// - `Radius` `sm8`/`md12`/`lg16`/`pill`.
/// - `Stroke` `hairline` (1pt border width).
/// - `Opacity` `stale`/`exited`/`pressed` dimming multipliers.
/// - `Layout` `minHitTarget` (44pt HIG minimum).
/// - `Motion` `fast`/`base` eased animations + `gated(_:reduceMotion:)`
/// which collapses to `nil` under Reduce Motion.
/// - `Haptics` `selection`/`success`/`warning` (`@MainActor`).
///
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
enum DS {
// MARK: - Palette
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
/// status colors use the direction's exact hex so light/dark stay on-brand.
enum Palette {
// Accent (amber gold matches the desktop/web theme)
// The desktop web/Electron UI uses --accent #E3A64A ("amber gold",
// public/style.css:14) on a warm near-neutral dark surface. We match it.
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold =
// --accent-2) for adequate contrast on a light background. Used
// sparingly primary actions, selection, active state only. Gold needs
// DARK text on top use `onAccent`, never white/textPrimary.
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
static let accent = Color(uiColor: accentUIColor())
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
/// two schemes deterministically (`resolvedColor(with:)`) without going
/// through the SwiftUIUIKit bridge.
static func accentUIColor() -> UIColor {
UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, alpha: 1) // #E3A64A --accent
: UIColor(red: 0xC9 / 255.0, green: 0x89 / 255.0, blue: 0x2F / 255.0, alpha: 1) // #C9892F --accent-2
}
}
/// Text/icon color to place ON an accent-filled surface (gold needs dark
/// ink for contrast mirrors web --on-accent #1A1305).
static let onAccent = rgb(26, 19, 5)
/// Faint accent wash (selection/soft highlight) web --accent-soft.
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
// Semantic status colors (match the desktop/web status palette)
// These are the ONLY status colors. `StatusStyle` pairs each with a
// distinct SF Symbol so status is never conveyed by color alone. Hex
// mirrors the web's warm status set (public/style.css:18-20) so iOS and
// desktop read the same. `waiting` amber #F5B14C stays distinct from the
// gold accent #E3A64A (brighter/less brown), and is only ever a small
// badge fill, never a large accent surface.
/// working #46D07F (web --green).
static let statusWorking = rgb(70, 208, 127)
/// waiting / needs-me #F5B14C (web --amber).
static let statusWaiting = rgb(245, 177, 76)
/// stuck #FF6B6B (web --red).
static let statusStuck = rgb(255, 107, 107)
/// idle quiet secondary gray (distinguished from `unknown` by shape).
static let statusIdle = Color.secondary
/// unknown / no signal yet gray.
static let statusUnknown = Color.gray
/// exited dimmed secondary (apply `Opacity.exited` on the container).
static let statusExited = Color.secondary
// Timeline event classes (T-iOS-24)
// Semantic colors for the activity-timeline event classes. `waiting`
// and `stuck` reuse the status tokens above (same meaning); `done`
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
// tokens so nothing in the app hardcodes a raw SwiftUI color.
/// tool run indigo, tied to the app accent family (#5E9EFF-ish).
static let timelineTool = rgb(94, 158, 255)
/// user message violet (#AF7BFF), distinct from accent & tool.
static let timelineUser = rgb(175, 123, 255)
// Surfaces & text
/// Base screen background.
static let surface = Color(uiColor: .systemBackground)
/// Card / grouped-content background (a step up from `surface`).
static let card = Color(uiColor: .secondarySystemBackground)
/// Hairline separator/border color.
static let hairline = Color(uiColor: .separator)
/// Primary label color.
static let textPrimary = Color.primary
/// Secondary label color (meta, captions).
static let textSecondary = Color.secondary
/// Tertiary label color (de-emphasized detail).
static let textTertiary = Color(uiColor: .tertiaryLabel)
// Terminal canvas (fixed warm-dark, matches the desktop terminal)
// A terminal reads as dark regardless of app appearance (like the
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
/// Terminal background warm near-black #100F0D (web --bg).
static let terminalBackground = rgb(16, 15, 13)
/// Terminal foreground warm off-white #ECE9E3 (web --text).
static let terminalForeground = rgb(236, 233, 227)
/// Build an opaque sRGB color from 0255 components.
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
}
}
// MARK: - Space
/// Spacing scale 2·4·8·12·16·20·24. Every gap/padding picks one of these;
/// there are no in-between values (the audit's 3/5/6/10/14 all round here).
enum Space {
static let xs2: CGFloat = 2
static let xs4: CGFloat = 4
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
static let xl20: CGFloat = 20
static let xxl24: CGFloat = 24
}
// MARK: - Radius
/// Corner radii. Legacy 6/10 both fold into `sm8`/`md12`.
enum Radius {
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
/// Fully-rounded (capsule/pill).
static let pill: CGFloat = 999
}
// MARK: - Stroke
/// Border widths.
enum Stroke {
/// Hairline card/overlay border.
static let hairline: CGFloat = 1
}
// MARK: - Opacity
/// Dimming multipliers (used with `.opacity()`, sometimes `.grayscale()`).
enum Opacity {
/// Telemetry gone stale (past its TTL).
static let stale: Double = 0.45
/// A session that has exited.
static let exited: Double = 0.55
/// Pressed-state feedback on buttons.
static let pressed: Double = 0.72
}
// MARK: - Layout
/// Layout constants that are not spacings.
enum Layout {
/// HIG minimum touch target (44×44pt).
static let minHitTarget: CGFloat = 44
}
// MARK: - Motion
/// Animation tokens. Subtle, eased (~0.180.25s). ALWAYS route through
/// `gated(_:reduceMotion:)` so Reduce Motion collapses to no motion.
enum Motion {
static let fastDuration: Double = 0.18
static let baseDuration: Double = 0.25
/// Quick affordance (chips, presses).
static let fast = Animation.easeInOut(duration: fastDuration)
/// Standard transition (banners, sheets, list changes).
static let base = Animation.easeInOut(duration: baseDuration)
/// Returns `animation` normally, or `nil` (instant, no motion) when
/// Reduce Motion is enabled. Callers pass the environment flag.
static func gated(_ animation: Animation, reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : animation
}
}
// MARK: - Haptics
/// Tactile feedback for key interactions. `@MainActor` the underlying
/// UIKit generators must be touched on the main thread. Additive to the
/// existing `GateViewModel` gate-arrival haptic (different call sites).
@MainActor
enum Haptics {
/// Light selection tap opening a session, tapping a key.
static func selection() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
/// Success notification a gate approve resolved.
static func success() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
/// Warning notification a destructive/reject decision.
static func warning() {
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
/// # Typography the SF type ramp (FROZEN public surface)
///
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
/// semantic text styles so everything scales with Dynamic Type (the a11y
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
/// timestamps use `mono(_:)` SF Mono with tabular figures so columns line
/// up and digits don't jitter as values change (matches the existing
/// `TelemetryChips`/`Timeline` convention, now centralized).
extension DS {
enum Typography {
// Proportional ramp (Dynamic-Type scaling, semantic styles)
/// Screen hero title.
static let largeTitle = Font.largeTitle
/// Section / prominent title.
static let title = Font.title2
/// Emphasis / card heading.
static let headline = Font.headline
/// Default body text.
static let body = Font.body
/// Slightly smaller body (secondary actions).
static let callout = Font.callout
/// Meta / caption text.
static let caption = Font.caption
// Monospaced (numbers, dimensions, timestamps)
/// SF Mono + tabular figures at the given text style (default `.body`).
/// Use for anything numeric that must align or not jump: `cols×rows`,
/// device/client counts, `$cost`, context %, relative timestamps.
static func mono(_ style: Font.TextStyle = .body) -> Font {
.system(style, design: .monospaced).monospacedDigit()
}
/// The canonical meta-number font: caption-sized mono + tabular.
static let metaMono = mono(.caption)
}
}
// MARK: - MetaText style
/// Secondary + monospaced-tabular styling for a row's numeric meta line
/// ("N · 161×50"). Apply via `.dsMetaText()`.
struct MetaText: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
}
}
extension View {
/// Style a meta/number line as secondary monospaced-tabular text.
func dsMetaText() -> some View {
modifier(MetaText())
}
}

View File

@@ -0,0 +1,234 @@
import Foundation
import WireProtocol
// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` the
// read-only structured git diff (server route src/server.ts:601-618, parser
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
// this file fetches + tolerantly decodes, the screen renders verbatim.
//
// APIClient APIClient T-iOS-38 ownerPLAN §6 Owns
// fetcher `HTTPTransport`
// APIClient ** APIClient T-iOS-38 owner
// follow-up**//
//
// Origin plan §3.4/§5.1iff-G`/projects/diff` RO
// requireAllowedOriginsrc/server.ts:601 "no Origin guard; same
// threat model as /projects"** Origin**
// G
//
// plan §4 web
// normalizeDiffResultpublic/diff.ts:38-102
// file/hunk/line kind .contextstatus
// .modified crash
// MARK: - src/types.ts:445-479App T-iOS-38
/// One diff line's semantic kind. Unknown wire values degrade to `.context`
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
enum DiffLineKind: String, Sendable, Equatable {
case added, removed, context, hunk, meta
}
struct DiffLine: Sendable, Equatable {
let kind: DiffLineKind
/// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
/// display input render via `Text(verbatim:)` only, never Markdown/links.
let text: String
}
struct DiffHunk: Sendable, Equatable {
let header: String
let lines: [DiffLine]
}
/// File-level change status. Unknown wire values degrade to `.modified`
/// (display-only field; the web keeps the raw string for a CSS class).
enum DiffFileStatus: String, Sendable, Equatable {
case modified, added, deleted, renamed, binary, untracked
}
struct DiffFile: Sendable, Equatable {
let oldPath: String
let newPath: String
let status: DiffFileStatus
let added: Int
let removed: Int
let binary: Bool
let hunks: [DiffHunk]
}
struct DiffResult: Sendable, Equatable {
let files: [DiffFile]
let staged: Bool
let truncated: Bool
}
// MARK: - Decodable extension
extension DiffLine: Decodable {
private enum CodingKeys: String, CodingKey { case kind, text }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// kind/text normalizeLine
let rawKind = try container.decode(String.self, forKey: .kind)
text = try container.decode(String.self, forKey: .text)
kind = DiffLineKind(rawValue: rawKind) ?? .context
}
}
extension DiffHunk: Decodable {
private enum CodingKeys: String, CodingKey { case header, lines }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
header = try container.decode(String.self, forKey: .header)
let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
lines = boxes.compactMap(\.value)
}
}
extension DiffFile: Decodable {
private enum CodingKeys: String, CodingKey {
case oldPath, newPath, status, added, removed, binary, hunks
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
oldPath = try container.decode(String.self, forKey: .oldPath)
newPath = try container.decode(String.self, forKey: .newPath)
added = try container.decode(Int.self, forKey: .added)
removed = try container.decode(Int.self, forKey: .removed)
binary = try container.decode(Bool.self, forKey: .binary)
let rawStatus = try container.decode(String.self, forKey: .status)
status = DiffFileStatus(rawValue: rawStatus) ?? .modified
let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
hunks = boxes.compactMap(\.value)
}
}
extension DiffResult: Decodable {
private enum CodingKeys: String, CodingKey { case files, staged, truncated }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staged = try container.decode(Bool.self, forKey: .staged)
truncated = try container.decode(Bool.self, forKey: .truncated)
let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
files = boxes.compactMap(\.value)
}
}
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
/// (not importable across the package boundary) folds together with the
/// fetcher in the T-iOS-38 follow-up.
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
// MARK: - route src/server.ts:602-618
enum DiffFetchError: Error, Equatable {
/// path / / URL
case invalidRequest
/// 400`path query parameter is required`
case pathInvalid
/// 404SEC-H7 + + .git
case projectNotFound
/// 200 DiffResult
case invalidResponse
/// 500 `failed to read diff`
case unexpectedStatus(Int)
}
// MARK: - DiffFetcher
/// Fetch a repo's structured diff over the injected transport. Immutable
/// value; one instance per (endpoint) the screen's VM closes over it.
struct DiffFetcher: Sendable {
let endpoint: HostEndpoint
private let http: any HTTPTransport
/// Route constants`stagedFlag`
/// `req.query['staged'] === '1'`src/server.ts:613 `1`/`0`
/// web fetchDiff `true`/`false` `'1'`
/// staged web Owns
private enum Route {
static let path = "/projects/diff"
static let pathKey = "path"
static let stagedKey = "staged"
static let stagedOn = "1"
static let stagedOff = "0"
static let methodGet = "GET"
}
private enum Status {
static let ok = 200
static let badRequest = 400
static let notFound = 404
}
/// Strict RFC 3986 unreserved set APIClient `Endpoints.
/// unreservedCharacters` `+` Express qs
/// `&`/`=` internal importT-iOS-38
private static let unreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
init(endpoint: HostEndpoint, http: any HTTPTransport) {
self.endpoint = endpoint
self.http = http
}
/// `GET /projects/diff?path=&staged=` `DiffResult`
/// path 400 I/O
func fetch(path: String, staged: Bool) async throws -> DiffResult {
guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
throw DiffFetchError.invalidRequest
}
let (data, response) = try await http.send(request)
switch response.statusCode {
case Status.ok:
guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
throw DiffFetchError.invalidResponse
}
return result
case Status.badRequest:
throw DiffFetchError.pathInvalid
case Status.notFound:
throw DiffFetchError.projectNotFound
default:
throw DiffFetchError.unexpectedStatus(response.statusCode)
}
}
/// Build the RO GET APIClient `APIRoute.urlRequest(for:)`
/// baseURL scheme/host/portpath/query fragment
/// ** stamp Origin**iff-G
private func buildRequest(path: String, staged: Bool) -> URLRequest? {
guard let encodedPath = path.addingPercentEncoding(
withAllowedCharacters: Self.unreservedCharacters
) else { return nil }
guard var components = URLComponents(
url: endpoint.baseURL, resolvingAgainstBaseURL: true
) else { return nil }
components.path = Route.path
components.query = nil
components.fragment = nil
components.user = nil
components.password = nil
let stagedValue = staged ? Route.stagedOn : Route.stagedOff
components.percentEncodedQuery =
"\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.httpMethod = Route.methodGet
return request
}
}

View File

@@ -0,0 +1,310 @@
import APIClient
import Foundation
import HostRegistry
import OSLog
import UIKit
import UserNotifications
import WireProtocol
/// T-iOS-21 · Allow/Deny** App**
/// `UNUserNotificationCenterDelegate.didReceive` notification
/// extension targetService Extension action tap
///
///
/// - push payload ****sessionId `DeepLinkRouter.route(from:)`
/// v4 capability token v4
/// `randomUUID()` src/server.ts:445****
/// `.invalidPayload`
/// - token `hookDecision`
/// /
/// - POST begin/endBackgroundTask didReceive async
/// = completionHandler POST settle
/// - 403token /
// MARK: - Seams
/// Seam over `UIApplication.beginBackgroundTask`/`endBackgroundTask`.
@MainActor
protocol BackgroundTaskRunning: AnyObject {
/// Returns an opaque token for `end(_:)` UIBackgroundTaskIdentifier
func begin(name: String) -> Int
func end(_ token: Int)
}
@MainActor
final class UIApplicationBackgroundTaskRunner: BackgroundTaskRunning {
private var active: [Int: UIBackgroundTaskIdentifier] = [:]
func begin(name: String) -> Int {
var identifier = UIBackgroundTaskIdentifier.invalid
identifier = UIApplication.shared.beginBackgroundTask(withName: name) {
// 线UIKit
MainActor.assumeIsolated { self.expire(identifier) }
}
active[identifier.rawValue] = identifier
return identifier.rawValue
}
func end(_ token: Int) {
guard let identifier = active.removeValue(forKey: token) else { return }
UIApplication.shared.endBackgroundTask(identifier)
}
private func expire(_ identifier: UIBackgroundTaskIdentifier) {
guard identifier != .invalid, active.removeValue(forKey: identifier.rawValue) != nil else {
return
}
UIApplication.shared.endBackgroundTask(identifier)
}
}
/// Seam for the local-notification fallback
@MainActor
protocol LocalNoticePosting: AnyObject {
func post(title: String, body: String) async
}
extension UNNotificationCenterAdapter: LocalNoticePosting {
func post(title: String, body: String) async {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(
identifier: UUID().uuidString, content: content, trigger: nil
)
do {
try await add(request)
} catch {
//
Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
.error("fallback local notice failed: \(error)")
}
}
}
/// payload token
enum PushDecisionCopy {
static let expiredTitle = "审批已失效"
static let expiredBody = "该次批准/拒绝已过期或已被处理,请打开 App 在会话中查看。"
static let failedTitle = "审批发送失败"
static let failedBody = "无法联系主机,请打开 App 在会话中处理。"
}
/// `parse` SendabledidReceive nonisolated
/// MainActorUN isolation
enum ParsedNotificationAction: Sendable, Equatable {
/// Allow/Deny + `{sessionId, token}`
case decision(HookDecision, sessionId: UUID, token: String)
/// done + sessionId
case openSession(sessionId: UUID)
/// payload ""
case invalidPayload
/// dismiss / id no-op
case dismissed
}
// MARK: - Handler
@MainActor
final class NotificationActionHandler: NSObject {
/// Coordinator
struct Actions {
let openSession: @MainActor (HostRegistry.Host, UUID) async -> Void
}
private enum TaskName {
static let decision = "webterm.hook-decision"
}
private let hostStore: any HostStore
private let http: any HTTPTransport
private let backgroundTasks: any BackgroundTaskRunning
private let notices: any LocalNoticePosting
private let actions: Actions
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.actionHandler)
private(set) var invalidPayloadCount = 0
init(
hostStore: any HostStore,
http: any HTTPTransport,
backgroundTasks: any BackgroundTaskRunning,
notices: any LocalNoticePosting,
actions: Actions
) {
self.hostStore = hostStore
self.http = http
self.backgroundTasks = backgroundTasks
self.notices = notices
self.actions = actions
}
// MARK: - Parsepayload
nonisolated static func parse(
actionIdentifier: String, userInfo: [AnyHashable: Any]
) -> ParsedNotificationAction {
switch actionIdentifier {
case GateNotificationCategory.allowActionId:
return decisionAction(.allow, userInfo: userInfo)
case GateNotificationCategory.denyActionId:
return decisionAction(.deny, userInfo: userInfo)
case UNNotificationDefaultActionIdentifier:
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo) else {
return .invalidPayload
}
return .openSession(sessionId: sessionId)
default:
return .dismissed
}
}
private nonisolated static func decisionAction(
_ decision: HookDecision, userInfo: [AnyHashable: Any]
) -> ParsedNotificationAction {
guard case let .gateSession(sessionId) = DeepLinkRouter.route(from: userInfo),
let token = userInfo[PayloadKey.token] as? String,
Validation.isValidSessionId(token) // token = randomUUID() v4
else { return .invalidPayload }
return .decision(decision, sessionId: sessionId, token: token)
}
private enum PayloadKey {
/// capability tokenT-iOS-20 payload 稿 gate
static let token = "token"
}
// MARK: - Handle
func handle(_ action: ParsedNotificationAction) async {
switch action {
case .dismissed:
return
case .invalidPayload:
invalidPayloadCount += 1
// payload/
logger.notice("dropped invalid push payload (total: \(self.invalidPayloadCount))")
case let .openSession(sessionId):
await routeToSession(sessionId)
case let .decision(decision, sessionId, token):
await postDecision(decision, sessionId: sessionId, token: token)
}
}
// MARK: - DecisionAllow/Deny POST /hook/decision
private func postDecision(_ decision: HookDecision, sessionId: UUID, token: String) async {
let taskToken = backgroundTasks.begin(name: TaskName.decision)
defer { backgroundTasks.end(taskToken) }
do {
guard let host = try await resolveHost(sessionId: sessionId) else {
logger.error("hook decision: no paired host lists session — cannot deliver")
await notices.post(
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
)
return
}
try await APIClient(endpoint: host.endpoint, http: http)
.hookDecision(sessionId: sessionId, decision: decision, token: token)
} catch APIClientError.decisionRejected {
// 403token //SEC-C1/M1 App
await notices.post(
title: PushDecisionCopy.expiredTitle, body: PushDecisionCopy.expiredBody
)
} catch {
logger.error("hook decision POST failed: \(error)")
await notices.post(
title: PushDecisionCopy.failedTitle, body: PushDecisionCopy.failedBody
)
}
}
// MARK: - Default tap DeepLinkRouter sessionId
private func routeToSession(_ sessionId: UUID) async {
do {
guard let host = try await resolveHost(sessionId: sessionId) else {
// App
logger.notice("push tap: session not found on any paired host")
return
}
await actions.openSession(host, sessionId)
} catch {
logger.error("push tap: host store read failed: \(error)")
}
}
// MARK: - Host resolutionpayload host handler
/// 线 POST
/// RO `/live-sessions` Origin
private func resolveHost(sessionId: UUID) async throws -> HostRegistry.Host? {
let hosts = try await hostStore.loadAll()
if hosts.count <= 1 { return hosts.first }
for host in hosts {
let sessions = (try? await APIClient(endpoint: host.endpoint, http: http)
.liveSessions()) ?? []
if sessions.contains(where: { $0.id == sessionId }) { return host }
}
return nil
}
}
// MARK: - UNUserNotificationCenterDelegateUNNotificationResponse
extension NotificationActionHandler: UNUserNotificationCenterDelegate {
/// async = completionHandler return
/// "completionHandler only after the POST settles"
/// UN nonisolated MainActor Sendable
/// `ParsedNotificationAction`
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let parsed = Self.parse(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo
)
await handle(parsed)
}
}
// MARK: - AppCoordinator wiring DeepLinkRouter.swift makeDeepLinkHandler
extension AppCoordinator {
/// PushAppDelegate.didFinishLaunching
/// hostStore/http AppEnvironment ad-hoc URLSession/keychain
func makePushWiring() -> (registrar: PushRegistrar, handler: NotificationActionHandler) {
let center = UNNotificationCenterAdapter()
let registrar = PushRegistrar(
hostStore: environment.hostStore,
http: environment.http,
center: center,
remote: UIApplicationRemoteRegistrar()
)
let handler = NotificationActionHandler(
hostStore: environment.hostStore,
http: environment.http,
backgroundTasks: UIApplicationBackgroundTaskRunner(),
notices: center,
actions: NotificationActionHandler.Actions(
openSession: { [weak self] host, sessionId in
await self?.openPushedSession(host: host, sessionId: sessionId)
}
)
)
return (registrar, handler)
}
/// `bootstrap()``route == .loading`
/// deep-link
/// closeopenopenDeepLinkedSession T-iOS-22 private
///
func openPushedSession(host: HostRegistry.Host, sessionId: UUID) async {
await bootstrap()
if terminalController != nil {
closeTerminal()
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
}

View File

@@ -0,0 +1,331 @@
import APIClient
import Foundation
import HostRegistry
import OSLog
import UIKit
import UserNotifications
import WireProtocol
/// T-iOS-21 · APNs registerForRemoteNotifications device
/// token `POST /push/apns-token`builder T-iOS-38
///
/// "document the choice"** [.alert, .sound]**
/// provisional provisional
/// Allow/Deny alert
/// gate `sound: 'default'`src/push/apns.ts:342
///
/// crashdevice token
/// `registerForRemoteNotifications` delegate launch/scene
/// `activate()``registeredHostIds` /
/// upsert 5/min/IP
// MARK: - SeamsUNUserNotificationCenter / UIApplication
/// Seam over the `UNUserNotificationCenter` surface this feature touches.
@MainActor
protocol NotificationCenterClient: AnyObject {
func authorizationStatus() async -> UNAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func setNotificationCategories(_ categories: Set<UNNotificationCategory>)
func add(_ request: UNNotificationRequest) async throws
}
@MainActor
final class UNNotificationCenterAdapter: NotificationCenterClient {
private let center = UNUserNotificationCenter.current()
func authorizationStatus() async -> UNAuthorizationStatus {
// The async `notificationSettings()` returns non-Sendable
// UNNotificationSettings across an isolation hop (Swift 6 error);
// extract only the Sendable status inside the completion instead.
// @Sendable literal: the closure must NOT inherit this class's
// MainActor isolation UNUserNotificationCenter invokes it on a
// background queue, and an isolated closure traps the Swift 6 runtime
// executor check (dispatch_assert_queue_fail; W7 verify boot-crash).
await withCheckedContinuation { continuation in
center.getNotificationSettings { @Sendable settings in
continuation.resume(returning: settings.authorizationStatus)
}
}
}
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
// Completion-handler bridge for the same Swift 6 reason as above (the
// SDK's async variant sends the non-Sendable center across isolation).
try await withCheckedThrowingContinuation { continuation in
center.requestAuthorization(options: options) { @Sendable granted, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: granted)
}
}
}
}
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
center.setNotificationCategories(categories)
}
func add(_ request: UNNotificationRequest) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
center.add(request) { @Sendable error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
}
}
}
}
/// Seam over `UIApplication.registerForRemoteNotifications`.
@MainActor
protocol RemoteNotificationRegistering: AnyObject {
func registerForRemoteNotifications()
}
@MainActor
final class UIApplicationRemoteRegistrar: RemoteNotificationRegistering {
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
}
// MARK: - WEBTERM_GATE category
/// Allow/Deny category **plan T-iOS-21**
/// - Allow `.authenticationRequired` =
/// Face ID/
/// - Deny fail-safe
/// - **** `.foreground` POST UI
enum GateNotificationCategory {
/// `GATE_CATEGORY` src/push/apns.ts:52T-iOS-20 稿
static let identifier = "WEBTERM_GATE"
static let allowActionId = "WEBTERM_ALLOW"
static let denyActionId = "WEBTERM_DENY"
static let allowTitle = "允许"
static let denyTitle = "拒绝"
static func category() -> UNNotificationCategory {
let allow = UNNotificationAction(
identifier: allowActionId, title: allowTitle,
options: [.authenticationRequired]
)
let deny = UNNotificationAction(
identifier: denyActionId, title: denyTitle,
options: [.destructive] // .foreground/.authenticationRequired
)
return UNNotificationCategory(
identifier: identifier, actions: [allow, deny],
intentIdentifiers: [], options: []
)
}
}
// MARK: - PushRegistrar
@MainActor
final class PushRegistrar {
/// provisional doc
static let authorizationOptions: UNAuthorizationOptions = [.alert, .sound]
private let hostStore: any HostStore
private let http: any HTTPTransport
private let center: any NotificationCenterClient
private let remote: any RemoteNotificationRegistering
private let logger = Logger(subsystem: PushLog.subsystem, category: PushLog.registrar)
/// device token hexiOS
///
private(set) var currentTokenHex: String?
/// `currentTokenHex`
private var registeredHostIds: Set<UUID> = []
init(
hostStore: any HostStore,
http: any HTTPTransport,
center: any NotificationCenterClient,
remote: any RemoteNotificationRegistering
) {
self.hostStore = hostStore
self.http = http
self.center = center
self.remote = remote
}
/// launch / scene active category
/// **** = ;
/// /
func activate() async {
center.setNotificationCategories([GateNotificationCategory.category()])
let hosts: [HostRegistry.Host]
do {
hosts = try await hostStore.loadAll()
} catch {
logger.error("push activate: host store read failed: \(error)")
return
}
guard !hosts.isEmpty else {
logger.debug("push activate: no paired hosts — skip authorization")
return
}
guard await ensureAuthorization() else { return }
remote.registerForRemoteNotifications()
}
/// `didRegisterForRemoteNotificationsWithDeviceToken`
func handleDeviceToken(_ deviceToken: Data) async {
let hex = Self.hexToken(deviceToken)
if hex != currentTokenHex {
currentTokenHex = hex
registeredHostIds = []
}
await registerPendingHosts()
}
/// `didFailToRegisterForRemoteNotificationsWithError`
/// / aps-environment entitlement crash
func handleRegistrationFailure(_ error: any Error) {
logger.error("remote notification registration failed: \(error)")
}
/// device token**additive hook** App
/// UI `HostStore.remove(id:)`
/// token
/// APNs 410
func handleHostRemoved(_ host: HostRegistry.Host) async {
registeredHostIds.remove(host.id)
guard let token = currentTokenHex else { return }
do {
try await APIClient(endpoint: host.endpoint, http: http)
.unregisterApnsToken(token)
} catch {
logger.error("APNs token unregister failed for host \(host.id): \(error)")
}
}
// MARK: - Internals
/// notDetermined denied authorized/
/// provisional/ephemeral
private func ensureAuthorization() async -> Bool {
switch await center.authorizationStatus() {
case .notDetermined:
do {
guard try await center.requestAuthorization(
options: Self.authorizationOptions
) else {
logger.notice("push authorization denied by user")
return false
}
return true
} catch {
logger.error("push authorization request failed: \(error)")
return false
}
case .denied:
logger.notice("push authorization previously denied — skip registration")
return false
default:
return true
}
}
private func registerPendingHosts() async {
guard let token = currentTokenHex else { return }
let hosts: [HostRegistry.Host]
do {
hosts = try await hostStore.loadAll()
} catch {
logger.error("push token registration: host store read failed: \(error)")
return
}
for host in hosts where !registeredHostIds.contains(host.id) {
do {
try await APIClient(endpoint: host.endpoint, http: http)
.registerApnsToken(token)
registeredHostIds.insert(host.id)
} catch {
// crash token
///
logger.error("APNs token registration failed for host \(host.id): \(error)")
}
}
}
/// APNs device token hex wire 64160 hexT-iOS-38
static func hexToken(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - PushAppDelegateapp
/// `@UIApplicationDelegateAdaptor` remote-notification
/// UIApplicationDelegate SwiftUI App
///
/// 线wiring SwiftUI
/// `WebTermApp.init` coordinator `bootstrap`
/// `didFinishLaunching` registrar/handler handler
/// UNUserNotificationCenter delegateApple delegate
/// +
/// scenePhase active `WebTermApp`
/// Allow/Deny 30
@MainActor
final class PushAppDelegate: NSObject, UIApplicationDelegate {
/// WebTermApp.init didFinishLaunching
static var bootstrap: AppCoordinator?
/// 宿XCTest XCUITest
/// 线
static var isRunningUnderTests: Bool {
NSClassFromString("XCTestCase") != nil
|| ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil
}
private(set) var registrar: PushRegistrar?
private(set) var actionHandler: NotificationActionHandler?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
guard !Self.isRunningUnderTests, let coordinator = Self.bootstrap else {
Self.bootstrap = nil
return true
}
Self.bootstrap = nil
let wiring = coordinator.makePushWiring()
registrar = wiring.registrar
actionHandler = wiring.handler
UNUserNotificationCenter.current().delegate = wiring.handler
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { await registrar?.handleDeviceToken(deviceToken) }
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: any Error
) {
registrar?.handleRegistrationFailure(error)
}
/// scenePhase active
func activatePush() {
Task { await registrar?.activate() }
}
}
enum PushLog {
static let subsystem = "com.yaojia.webterm"
static let registrar = "push-registrar"
static let actionHandler = "push-action"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

View File

@@ -0,0 +1,6 @@
{
"images" : [
{ "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View File

@@ -0,0 +1,273 @@
import SwiftUI
import WireProtocol
/// T-iOS-27 · Read-only diff viewer web public/diff.ts render-only
/// src/http/diff.ts
///
/// T-iOS-26 ProjectDetail `(endpoint, path)` push/present
/// TerminalScreen per-session cwd
/// TerminalScreenT-iOS-11 Owns toolbar hook线
/// T-iOS-26/29
///
/// SEC-H4 diff ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
/// List diff 2MB
/// Text
struct DiffScreen: View {
@State private var viewModel: DiffViewModel
private enum Metrics {
/// Faint per-line tint alpha for added/removed/hunk rows. This is a
/// **code-diff syntax constant** (like terminal/xterm colors), NOT a
/// card/status design token the frozen `DS.Opacity` scale
/// (stale/exited/pressed) has no semantic slot for a syntax highlight
/// fill. The tint HUE always comes from `DS.Palette` (below); only this
/// scanning-aid alpha is diff-local.
static let lineTintOpacity = 0.12
}
/// T-iOS-26 `(endpoint, path)` +
init(endpoint: HostEndpoint, path: String, http: any HTTPTransport) {
_viewModel = State(initialValue: .forProject(
endpoint: endpoint, path: path, http: http
))
}
/// / VM
init(viewModel: DiffViewModel) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
VStack(spacing: 0) {
scopePicker
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
content
}
.navigationTitle(DiffCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() } // fresh screen one initial fetch
}
// MARK: - staged/unstaged re-fetch VM
private var scopePicker: some View {
Picker(DiffCopy.scopePickerLabel, selection: Binding(
get: { viewModel.staged },
set: { newValue in Task { await viewModel.setStaged(newValue) } }
)) {
Text(DiffCopy.working).tag(false)
Text(DiffCopy.staged).tag(true)
}
.pickerStyle(.segmented)
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty(let truncated):
emptyState(truncated: truncated)
case .failed(let failure):
failedState(failure)
case .loaded(let presentation):
diffList(presentation)
}
}
/// files banner
///
private func emptyState(truncated: Bool) -> some View {
VStack(spacing: 0) {
if truncated { truncatedBanner }
ContentUnavailableView(
DiffCopy.emptyTitle,
systemImage: "checkmark.circle",
description: Text(DiffCopy.emptyDetail)
)
}
}
/// path 400/404 Steps
private func failedState(_ failure: DiffViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(DiffCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
static func failureCopy(_ failure: DiffViewModel.Failure) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (DiffCopy.failedPathInvalid, DiffCopy.failedPathInvalidDetail)
case .notFound:
return (DiffCopy.failedNotFound, DiffCopy.failedNotFoundDetail)
case .unavailable:
return (DiffCopy.failedUnavailable, DiffCopy.failedUnavailableDetail)
}
}
// MARK: - VM
private func diffList(_ presentation: DiffPresentation) -> some View {
List {
if presentation.truncated {
truncatedBanner
.listRowSeparator(.hidden)
}
ForEach(presentation.rows) { row in
rowView(row)
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0) // diff
}
private var truncatedBanner: some View {
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
}
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
switch row.kind {
case .fileHeader(let header):
fileHeaderRow(header)
case .binaryNotice:
Text(DiffCopy.binaryFile)
.font(DS.Typography.caption.italic())
.foregroundStyle(DS.Palette.textSecondary)
.listRowSeparator(.hidden)
case .hunkHeader(let header):
diffTextRow(
header, color: DS.Palette.accent,
background: DS.Palette.accent.opacity(Metrics.lineTintOpacity)
)
case .line(let kind, let text):
diffTextRow(
text, color: Self.lineColor(kind), background: Self.lineBackground(kind)
)
}
}
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
HStack(spacing: DS.Space.sm8) {
// verbatim +
Text(verbatim: header.pathLabel)
.font(DS.Typography.mono(.footnote).weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(verbatim: "+\(header.added)")
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.statusWorking)
Text(verbatim: "-\(header.removed)")
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.statusStuck)
Text(DiffStatusStyle.label(for: header.status))
.font(DS.Typography.caption)
.foregroundStyle(DiffStatusStyle.color(for: header.status))
}
.padding(.top, DS.Space.lg16)
.accessibilityElement(children: .combine)
}
/// One monospaced diff line. UNTRUSTED server bytes: `Text(verbatim:)`,
/// single-line tail truncation (read-only skim view; no wrapping blob).
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
Text(verbatim: text)
.font(DS.Typography.mono(.caption))
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowBackground(background)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(
top: DS.Space.xs2,
leading: DS.Space.md12,
bottom: DS.Space.xs2,
trailing: DS.Space.md12
))
}
// MARK: - kind kind .context
// DS.Paletteadded=working 绿 / removed=stuck /
// hunk=accent App alpha diff
static func lineColor(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return DS.Palette.statusWorking
case .removed: return DS.Palette.statusStuck
case .context: return DS.Palette.textPrimary
case .hunk: return DS.Palette.accent
case .meta: return DS.Palette.textSecondary
}
}
static func lineBackground(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return DS.Palette.statusWorking.opacity(Metrics.lineTintOpacity)
case .removed: return DS.Palette.statusStuck.opacity(Metrics.lineTintOpacity)
case .hunk: return DS.Palette.accent.opacity(Metrics.lineTintOpacity)
case .context, .meta: return .clear
}
}
}
// MARK: - status /
enum DiffStatusStyle {
static func label(for status: DiffFileStatus) -> String {
switch status {
case .modified: return "修改"
case .added: return "新增"
case .deleted: return "删除"
case .renamed: return "重命名"
case .binary: return "二进制"
case .untracked: return "未跟踪"
}
}
static func color(for status: DiffFileStatus) -> Color {
switch status {
case .added, .untracked: return DS.Palette.statusWorking
case .deleted: return DS.Palette.statusStuck
case .renamed: return DS.Palette.accent
case .modified, .binary: return DS.Palette.textSecondary
}
}
}
// MARK: - plan
enum DiffCopy {
static let title = "代码差异"
static let scopePickerLabel = "差异范围"
static let working = "工作区"
static let staged = "已暂存"
static let truncatedBanner = "差异过大,已截断显示(主机侧 DIFF_MAX_BYTES / DIFF_MAX_FILES 限制)。"
static let emptyTitle = "无改动"
static let emptyDetail = "当前范围下没有可显示的改动。"
static let binaryFile = "二进制文件"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不是主机上的 git 仓库或已被移除404"
static let failedUnavailable = "差异加载失败"
static let failedUnavailableDetail = "无法从主机获取 diff请检查连接后重试。"
static let retry = "重试"
}

View File

@@ -0,0 +1,400 @@
import HostRegistry
import SwiftUI
import UIKit
#if !targetEnvironment(simulator)
import VisionKit
#endif
/// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe.
/// Pure presentation over `PairingViewModel` every rule (input validation, the
/// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions)
/// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and
/// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled
/// out on the simulator, where manual entry is the pairing path.
struct PairingScreen: View {
@Bindable var viewModel: PairingViewModel
var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook
@State private var manualURLText = ""
@State private var isShowingScanner = false
@State private var scannerError: String?
var body: some View {
content
.navigationTitle(ScreenCopy.title)
.onChange(of: viewModel.pairedHost) { _, paired in
guard let paired else { return }
onPaired(paired)
}
}
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .idle:
idleView
case .confirming(let pending):
ConfirmHostView(pending: pending, viewModel: viewModel)
case .probing(let pending):
probingView(pending)
case .failed(let pending, let failure):
FailureView(pending: pending, failure: failure, viewModel: viewModel)
case .paired(let host):
pairedView(host)
}
}
private var idleView: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
}
}
if let rejection = viewModel.inputRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.frame(maxWidth: .infinity, alignment: .leading)
}
if PairingScanAvailability.isAvailable {
Button {
scannerError = nil
isShowingScanner = true
} label: {
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
}
.buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim
}
Text(ScreenCopy.qrHint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
@ViewBuilder private var scannerSheet: some View {
#if targetEnvironment(simulator)
// Unreachable: the entry is hidden on the simulator. Kept total.
Text(ScreenCopy.scanUnavailable)
#else
ZStack(alignment: .bottom) {
QRScannerView(
onCode: { payload in
isShowingScanner = false
viewModel.handleScannedCode(payload)
},
onError: { message in scannerError = message }
)
if let scannerError {
Text(scannerError)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.statusStuck)
.padding(DS.Space.md12)
.background(.thinMaterial, in: RoundedRectangle(
cornerRadius: DS.Radius.sm8
))
.padding(DS.Space.lg16)
}
}
#endif
}
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
VStack(spacing: DS.Space.lg16) {
ProgressView()
Text(ScreenCopy.probing(pending.displayAddress))
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func pairedView(_ host: HostRegistry.Host) -> some View {
VStack(spacing: DS.Space.md12) {
Image(systemName: "checkmark.circle.fill")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.statusWorking)
Text(ScreenCopy.paired(host.name))
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
}
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { DS.Haptics.success() }
}
}
/// Confirm page parsed address + §5.4 warning tier + host name.
private struct ConfirmHostView: View {
let pending: PairingViewModel.PendingHost
@Bindable var viewModel: PairingViewModel
var body: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
addressCard
warningTier
VStack(spacing: DS.Space.md12) {
Button(ScreenCopy.connect) {
DS.Haptics.selection()
Task { await viewModel.confirmConnect() }
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.confirmButton")
Button(ScreenCopy.cancel) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
.padding(DS.Space.lg16)
}
}
private var addressCard: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.confirmSectionTitle)
// Single-point-derived origin (UITest asserts this exact string).
Text(pending.displayAddress)
.font(DS.Typography.mono())
.foregroundStyle(DS.Palette.textPrimary)
.textSelection(.enabled)
Divider()
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
.font(DS.Typography.body)
}
}
}
// §5.4 warning tiers LOGIC frozen (switch cases), only presentation restyled.
@ViewBuilder private var warningTier: some View {
switch pending.warning {
case .none:
EmptyView()
case .tailscaleEncrypted: // positive accent badge encrypted transport
Card {
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.accent)
.frame(maxWidth: .infinity, alignment: .leading)
}
case .plaintextLAN: // subtle amber note
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.frame(maxWidth: .infinity, alignment: .leading)
case .publicHostBlocking:
publicWarningCard
}
}
/// Prominent red card + acknowledgement gate (Toggle `hasAcknowledgedPublicRisk`;
/// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged.
private var publicWarningCard: some View {
VStack(alignment: .leading, spacing: DS.Space.md12) {
Label {
Text(ScreenCopy.publicWarning)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
} icon: {
Image(systemName: "exclamationmark.octagon.fill")
.foregroundStyle(DS.Palette.statusStuck)
}
Divider()
Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk)
.font(DS.Typography.callout)
.tint(DS.Palette.accent)
if viewModel.needsPublicRiskAcknowledgement {
Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
}
.padding(DS.Space.md12)
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline)
)
}
}
/// Failure page inline copy + recovery actions (retry / settings / back).
private struct FailureView: View {
let pending: PairingViewModel.PendingHost
let failure: PairingViewModel.FailureDisplay
let viewModel: PairingViewModel
var body: some View {
ScrollView {
VStack(spacing: DS.Space.xl20) {
Card {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
Text(pending.displayAddress)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Label {
Text(failure.message)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
} icon: {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(DS.Palette.statusStuck)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
VStack(spacing: DS.Space.md12) {
let needsSettings = failure.action == .openLocalNetworkSettings
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
Button(ScreenCopy.back) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
.padding(DS.Space.lg16)
}
}
/// The app's Settings pane hosts its toggle (no deep link exists).
private func openAppSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
enum PairingScanAvailability {
/// Simulator: no camera hidden, manual entry is the pairing path. Device:
/// requires VisionKit support (`isSupported` is MainActor-isolated).
@MainActor static var isAvailable: Bool {
#if targetEnvironment(simulator)
return false
#else
return DataScannerViewController.isSupported
#endif
}
}
#if !targetEnvironment(simulator)
/// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins;
/// payload handed to the VM untouched (validation is the VM's job).
private struct QRScannerView: UIViewControllerRepresentable {
let onCode: (String) -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onCode: onCode)
}
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
guard !scanner.isScanning else { return }
do {
try scanner.startScanning()
} catch {
// Explicit surfacing (plan §4): shows inline; manual entry remains.
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
}
}
@MainActor
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
private let onCode: (String) -> Void
private var hasDelivered = false
init(onCode: @escaping (String) -> Void) {
self.onCode = onCode
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard !hasDelivered else { return }
for item in addedItems {
if case .barcode(let barcode) = item,
let payload = barcode.payloadStringValue {
hasDelivered = true
onCode(payload)
return
}
}
}
}
}
#endif
private enum ScreenCopy {
static let title = "配对主机"
static let heroTitle = "连接你的电脑"
static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。"
static let manualSectionTitle = "输入地址"
static let manualPlaceholder = "http://192.168.1.5:3000"
static let manualSubmit = "连接"
static let scanButton = "扫描二维码"
static let scanUnavailable = "模拟器不支持扫码,请手输地址。"
static let qrHint = "在电脑的 web 终端工具栏点「Connect a device」可显示配对二维码也可直接输入它的地址。"
static let confirmSectionTitle = "确认要连接的主机"
static let namePlaceholder = "主机名称"
static let connect = "连接"
static let cancel = "取消"
static let retry = "重试"
static let back = "返回"
static let openSettings = "去设置"
static let tailscaleBadge = "经 Tailscale 加密WireGuard 网络层)"
static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN推荐 tailscale servewss"
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }
static func scannerStartFailed(_ reason: String) -> String {
"无法启动相机扫描:\(reason)。可改用手输地址。"
}
}

View File

@@ -0,0 +1,314 @@
import APIClient
import SwiftUI
import WireProtocol
/// T-iOS-26 · sessions/worktrees/CLAUDE.md + diff
/// T-iOS-27 `DiffScreen(endpoint:path:http:)`+ ""
///
/// ///CLAUDE.md ****
/// `Text(verbatim:)` LocalizedStringKey/Markdown/
struct ProjectDetailScreen: View {
@State private var viewModel: ProjectDetailViewModel
private let endpoint: HostEndpoint
private let http: any HTTPTransport
private let onOpenClaude: (String) -> Void
@State private var isDiffPresented = false
private enum Metrics {
/// CLAUDE.md token
static let claudeMdLineLimit = 40
}
init(
viewModel: ProjectDetailViewModel,
endpoint: HostEndpoint,
http: any HTTPTransport,
onOpenClaude: @escaping (String) -> Void
) {
_viewModel = State(initialValue: viewModel)
self.endpoint = endpoint
self.http = http
self.onOpenClaude = onOpenClaude
}
var body: some View {
content
.navigationTitle(ProjectDetailCopy.title)
.navigationBarTitleDisplayMode(.inline)
.task { await viewModel.load() }
.navigationDestination(isPresented: $isDiffPresented) {
DiffScreen(endpoint: endpoint, path: viewModel.path, http: http)
}
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let failure):
failedState(failure)
case .loaded(let detail):
detailList(detail)
}
}
/// 400/404/500 `{error}` Steps
private func failedState(_ failure: ProjectDetailViewModel.Failure) -> some View {
let copy = Self.failureCopy(failure)
return ContentUnavailableView {
Label(copy.title, systemImage: "exclamationmark.triangle")
} description: {
Text(copy.detail)
} actions: {
Button(ProjectDetailCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
static func failureCopy(
_ failure: ProjectDetailViewModel.Failure
) -> (title: String, detail: String) {
switch failure {
case .pathInvalid:
return (ProjectDetailCopy.failedPathInvalid,
ProjectDetailCopy.failedPathInvalidDetail)
case .notFound:
return (ProjectDetailCopy.failedNotFound,
ProjectDetailCopy.failedNotFoundDetail)
case .unavailable:
return (ProjectDetailCopy.failedUnavailable,
ProjectDetailCopy.failedUnavailableDetail)
}
}
// MARK: - Loaded
private func detailList(_ detail: ProjectDetail) -> some View {
List {
headerSection(detail)
actionsSection(detail)
sessionsSection(detail.sessions)
worktreesSection(detail.worktrees)
claudeMdSection(detail)
}
.listStyle(.insetGrouped)
}
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: detail.name)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
// verbatim +
Text(verbatim: detail.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: DS.Space.sm8) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if detail.dirty == true {
DirtyBadge()
}
}
}
}
}
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
DS.Haptics.selection()
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.listRowInsets(EdgeInsets(
top: DS.Space.sm8, leading: DS.Space.lg16,
bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
if detail.isGit {
Button {
isDiffPresented = true
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
}
@ViewBuilder private func sessionsSection(_ sessions: [ProjectSessionRef]) -> some View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(sessions, id: \.id) { session in
sessionRow(session)
}
}
}
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: DS.Space.sm8) {
// = + + VoiceOver DS 退
StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.font(DS.Typography.body)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
// tabular
Text(ProjectDetailCopy.clientCount(session.clientCount))
.dsMetaText()
}
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
ForEach(worktrees, id: \.path) { worktree in
worktreeRow(worktree)
}
}
}
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if worktree.isMain {
TagBadge(text: ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
TagBadge(text: ProjectDetailCopy.worktreeLocked)
}
}
Text(verbatim: worktree.path)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
}
// MARK: - DS token Projects/
/// + TelemetryChip `.quaternary`
struct DirtyBadge: View {
var body: some View {
Text(ProjectsCopy.dirtyBadge)
.font(DS.Typography.caption.weight(.medium))
// amber + amber
.foregroundStyle(DS.Palette.statusWaiting)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(DS.Palette.statusWaiting.opacity(0.18), in: Capsule())
}
}
/// worktree //accent
struct TagBadge: View {
let text: String
var body: some View {
Text(text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.overlay(
Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - plan §4
enum ProjectDetailCopy {
static let title = "项目详情"
static let openClaude = "在此仓库开新会话"
static let viewDiff = "查看代码差异"
static let sessionsHeader = "运行中的会话"
static let noSessions = "暂无运行中的会话。"
static let sessionExited = "已退出"
static let worktreesHeader = "Worktrees"
static let worktreeMain = ""
static let worktreeCurrent = "当前"
static let worktreeLocked = "已锁定"
static let detachedHead = "(分离 HEAD"
static let claudeMdHeader = "CLAUDE.md"
static let claudeMdPresent = "本仓库包含 CLAUDE.md。"
static let retry = "重试"
static let failedPathInvalid = "路径无效"
static let failedPathInvalidDetail = "请求的项目路径无效(服务器返回 400请从项目列表重新进入。"
static let failedNotFound = "项目未找到"
static let failedNotFoundDetail = "该路径不在主机的项目根目录下或已被移除404"
static let failedUnavailable = "详情加载失败"
static let failedUnavailableDetail = "无法从主机获取项目详情,请检查连接后重试。"
static func clientCount(_ count: Int) -> String {
"\(count) 个客户端"
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
import UIKit
/// T-iPad-4 · Projects **** / / sheet
/// detents 仿 `LayoutPolicy`
/// 100%
///
/// ** `UIUserInterfaceIdiom` `horizontalSizeClass`**
/// iPad Projects ** sheet**form sheet
/// `horizontalSizeClass` **compact** iPhone
/// size class iPhone iPhoneiPad
/// Projects sheet idiom Projects /
/// idiom+ stack/split `LayoutPolicy`
/// size-class
enum ProjectsGridLayout {
/// iPhone / iPad 退
static let singleColumn = 1
/// iPad 2 sheet ~440540pt
static let twoColumnMinWidth: CGFloat = 400
/// iPad 3 /
static let threeColumnMinWidth: CGFloat = 760
/// iPhone`.phone`/ `singleColumn` List
/// iPad`.pad` **** 13 `>= 1`
/// 0
static func columnCount(
availableWidth: CGFloat,
idiom: UIUserInterfaceIdiom
) -> Int {
guard idiom == .pad else { return singleColumn }
if availableWidth >= threeColumnMinWidth { return 3 }
if availableWidth >= twoColumnMinWidth { return 2 }
return singleColumn
}
/// iPad iPhone List
static func usesGrid(idiom: UIUserInterfaceIdiom) -> Bool {
idiom == .pad
}
}
/// T-iPad-4 · Projects sheet detents idiom
enum ProjectsSheetSizing {
/// iPhone `nil`** `.presentationDetents`** sheet
/// iPhone iPad `[.medium, .large]`
///
static func detents(idiom: UIUserInterfaceIdiom) -> Set<PresentationDetent>? {
idiom == .pad ? [.medium, .large] : nil
}
}
// MARK: - iPhone iPad detents modifier
/// `.presentationDetents` modifier nil `content`
/// iPhone sheet
private struct AdaptiveSheetDetents: ViewModifier {
let detents: Set<PresentationDetent>?
func body(content: Content) -> some View {
if let detents {
content.presentationDetents(detents)
} else {
content
}
}
}
extension View {
/// iPhone sheetiPad detents
func adaptiveProjectsSheetDetents(idiom: UIUserInterfaceIdiom) -> some View {
modifier(AdaptiveSheetDetents(detents: ProjectsSheetSizing.detents(idiom: idiom)))
}
}

View File

@@ -0,0 +1,287 @@
import APIClient
import SwiftUI
/// T-iOS-26 · Projects web v0.6 projects namespace
/// + + dirty + Active now +
/// /// `ProjectsViewModel`
///
/// //**** `Text(verbatim:)`
/// LocalizedStringKey/Markdown
struct ProjectsScreen: View {
@Bindable var viewModel: ProjectsViewModel
/// "" AppCoordinator.openProject
var onOpen: (ProjectOpenRequest) -> Void = { _ in }
/// T-iPad-4 · idiom `ProjectsGridLayout`/
/// `ProjectsSheetSizing` iPhone List
/// iPad + sheet detentsidiom
/// @Environment `ProjectsLayout`
/// idiom size classiPad sheet compact
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private enum Copy {
static let emptyNoProjectsTitle = "暂无项目"
static let emptyNoMatchTitle = "无匹配项目"
}
var body: some View {
adaptiveContent
.navigationTitle(ProjectsCopy.title)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchText, prompt: ProjectsCopy.searchPrompt)
.refreshable { await viewModel.refresh() }
.task { await viewModel.load() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
.navigationDestination(for: ProjectRoute.self) { route in
ProjectDetailScreen(
viewModel: viewModel.makeDetailViewModel(path: route.path),
endpoint: viewModel.host.endpoint,
http: viewModel.http,
onOpenClaude: { viewModel.requestOpenClaude(cwd: $0) }
)
}
// T-iPad-4 · iPhone sheet iPad
// detents
.adaptiveProjectsSheetDetents(idiom: idiom)
}
// MARK: - idiom ProjectsGridLayout
/// iPhone `list`iPad
/// `gridList`///prefs `ProjectsViewModel`
///
@ViewBuilder private var adaptiveContent: some View {
if ProjectsGridLayout.usesGrid(idiom: idiom) {
gridList
} else {
list
}
}
// MARK: - List
private var list: some View {
List {
errorRows
if let message = viewModel.emptyStateMessage {
emptyState(message)
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(viewModel.groups) { group in
groupSection(group)
}
}
.listStyle(.insetGrouped)
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
/// symbol + friendly
private func emptyState(_ message: String) -> some View {
ContentUnavailableView {
Label(
viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle,
systemImage: viewModel.isSearching ? "magnifyingglass" : "folder"
)
} description: {
Text(message)
}
}
// MARK: - Gridregular
/// iPad / `ProjectsGridLayout.columnCount`
/// ****GeometryReader/// List
/// builder`groupHeader`/`projectRow`/`errorRows`
/// //prefs
private var gridList: some View {
GeometryReader { proxy in
let columns = ProjectsGridLayout.columnCount(
availableWidth: proxy.size.width,
idiom: idiom
)
ScrollView {
LazyVStack(alignment: .leading, spacing: DS.Space.md12) {
errorRows
if let message = viewModel.emptyStateMessage {
emptyState(message)
.frame(maxWidth: .infinity)
}
ForEach(viewModel.groups) { group in
gridSection(group, columns: columns)
}
}
.padding(DS.Space.lg16)
}
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
ProgressView()
}
}
}
}
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
VStack(alignment: .leading, spacing: DS.Space.sm8) {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
.padding(.top, DS.Space.md12)
}
if !isCollapsed {
LazyVGrid(
columns: gridColumns(columns),
alignment: .leading,
spacing: DS.Space.sm8
) {
ForEach(group.projects, id: \.path) { project in
// = DS Cardsecondary + + md12
Card(padding: DS.Space.md12) {
projectRow(project, group: group)
}
}
}
}
}
}
/// `columns >= 1` `ProjectsGridLayout`
private func gridColumns(_ count: Int) -> [GridItem] {
Array(
repeating: GridItem(.flexible(), spacing: DS.Space.sm8),
count: max(count, ProjectsGridLayout.singleColumn)
)
}
/// prefs
@ViewBuilder private var errorRows: some View {
ForEach(
[
viewModel.fetchErrorMessage,
viewModel.prefsErrorMessage,
viewModel.prefsSyncErrorMessage,
viewModel.openErrorMessage,
].compactMap(\.self),
id: \.self
) { message in
Label(message, systemImage: "exclamationmark.triangle")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.listRowSeparator(.hidden)
}
}
// MARK: - Group sectionflat chromenamespace/other
@ViewBuilder private func groupSection(_ group: ProjectGroup) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
Section {
if !isCollapsed {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
}
}
} header: {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
}
}
}
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
HStack(spacing: DS.Space.sm8) {
if group.isCollapsible {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
// namespace label verbatim
Text(verbatim: group.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
// tabular
Text(verbatim: "\(group.projects.count)")
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textTertiary)
// web
if group.kind != .active && group.activeCount > 0 {
Text(ProjectsCopy.activeCountBadge(group.activeCount))
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.statusWorking)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
.onTapGesture {
guard group.isCollapsible else { return }
Task { await viewModel.toggleCollapsed(key: group.key) }
}
.accessibilityAddTraits(group.isCollapsible ? .isButton : [])
}
// MARK: - Project row
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
NavigationLink(value: ProjectRoute(path: project.path)) {
HStack(spacing: DS.Space.sm8) {
favouriteButton(project)
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: ProjectGrouping.displayLabel(
name: project.name, groupKey: group.key
))
.font(DS.Typography.body.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
projectChips(project)
}
Spacer(minLength: 0)
// DS + + VoiceOver
if ProjectGrouping.hasRunningSession(project) {
StatusBadge(status: .working)
}
}
}
}
private func favouriteButton(_ project: ProjectInfo) -> some View {
Button {
DS.Haptics.selection()
Task { await viewModel.toggleFavourite(path: project.path) }
} label: {
let isFav = viewModel.isFavourite(project.path)
Image(systemName: isFav ? "star.fill" : "star")
.foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary)
}
.buttonStyle(.borderless) // List
}
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
HStack(spacing: DS.Space.sm8) {
if let branch = project.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if project.dirty == true {
DirtyBadge()
}
}
}
}
/// push value-based navigation
struct ProjectRoute: Hashable {
let path: String
}

View File

@@ -0,0 +1,358 @@
import HostRegistry
import SwiftUI
import WireProtocol
/// T-iOS-13 · Session list screen (merged chooser + dashboard). Pure
/// presentation over `SessionListViewModel` polling cadence, badge priority,
/// staleness, optimistic kill and the navigation signal are all VM logic,
/// unit-tested in `SessionListViewModelTests`.
///
/// UX polish (G1-list): the row is restructured for the 5-second glance
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
/// color alone), a monospaced meta line, and telemetry as proper
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
/// `SectionHeader`s (presentation-only the VM already groups exited last).
/// Every color/spacing/radius/font/motion comes from `DS.*`.
///
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
/// `request.sessionId` nil = new session) and `onAddHost` (present the
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
struct SessionListScreen: View {
var viewModel: SessionListViewModel
/// Navigation hook: fired once per `OpenRequest` (unique id per tap).
var onOpen: (SessionListViewModel.OpenRequest) -> Void = { _ in }
/// Host-switch header hook: "" entry (pairing sheet, T-iOS-15).
var onAddHost: () -> Void = {}
/// T-iOS-28 (additive slot) · shared thumbnail pipeline: one cache + one
/// render-concurrency gate across ALL rows (scrolling must never spawn
/// unbounded offscreen terminals). `@State` keeps it stable across body
/// rebuilds; `live()` is cheap (the URLSession is a static shared).
@State private var thumbnails = SessionThumbnailPipeline.live()
var body: some View {
content
// Accent is not injected app-wide; scope it here so native chrome
// (nav bar, bordered controls) picks up the DS indigo. Presentation
// only no behavior change.
.tint(DS.Palette.accent)
.navigationTitle(ScreenCopy.title)
.toolbar { hostMenu }
.onAppear { viewModel.appeared() }
.onDisappear { viewModel.disappeared() }
.onChange(of: viewModel.openRequest) { _, request in
guard let request else { return }
onOpen(request)
}
}
@ViewBuilder private var content: some View {
switch viewModel.emptyState {
case .notPaired:
notPairedView
case .noSessions:
noSessionsView
case nil:
sessionList
}
}
// MARK: - List
private var sessionList: some View {
List {
if let message = viewModel.fetchErrorMessage {
errorRow(message)
}
if let message = viewModel.killErrorMessage {
errorRow(message)
}
newSessionRow
if !activeRows.isEmpty {
Section {
ForEach(activeRows) { row in sessionRow(row) }
} header: {
SectionHeader(title: ScreenCopy.activeSection)
}
}
if !exitedRows.isEmpty {
Section {
ForEach(exitedRows) { row in sessionRow(row) }
} header: {
SectionHeader(title: ScreenCopy.exitedSection)
}
}
}
.listStyle(.plain)
.refreshable { await viewModel.refresh() }
}
/// Split the VM's (already exited-last) rows into the two visual groups.
/// Presentation only no ordering/logic decision lives here.
private var activeRows: [SessionListViewModel.SessionRow] {
viewModel.rows.filter { !$0.info.exited }
}
private var exitedRows: [SessionListViewModel.SessionRow] {
viewModel.rows.filter { $0.info.exited }
}
/// Inviting primary entry accent-tinted card row.
private var newSessionRow: some View {
Button {
viewModel.requestNewSession()
} label: {
NewSessionRow()
}
.buttonStyle(.plain)
.accessibilityIdentifier("sessions.newButton")
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
.listRowBackground(Color.clear)
}
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
}
.buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
.listRowBackground(Color.clear)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.kill(sessionId: row.id) }
} label: {
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
}
}
}
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
/// so adjacent cards breathe.
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
EdgeInsets(
top: vertical, leading: DS.Space.lg16,
bottom: vertical, trailing: DS.Space.lg16
)
}
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive
/// rows imply a host) no slot; the request key carries `lastOutputAt`
/// so unchanged sessions render exactly once (pipeline cache).
private func thumbnailSlot(
for row: SessionListViewModel.SessionRow
) -> SessionThumbnailView? {
guard let endpoint = viewModel.activeHost?.endpoint else { return nil }
return SessionThumbnailView(
request: SessionThumbnailRequest(
endpoint: endpoint,
sessionId: row.id,
lastOutputAt: row.info.lastOutputAt
),
pipeline: thumbnails
)
}
private func errorRow(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
.listRowBackground(Color.clear)
}
// MARK: - Empty states
private var notPairedView: some View {
ContentUnavailableView {
Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot")
} description: {
Text(ScreenCopy.notPairedHint)
} actions: {
Button(ScreenCopy.addHost) { onAddHost() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
private var noSessionsView: some View {
ContentUnavailableView {
Label(ScreenCopy.noSessionsTitle, systemImage: "terminal")
} description: {
Text(ScreenCopy.noSessionsHint)
} actions: {
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("sessions.newButton")
}
}
// MARK: - Host-switch header (multi-host from HostStore)
private var hostMenu: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Menu {
ForEach(viewModel.hosts) { host in
Button {
Task { await viewModel.selectHost(id: host.id) }
} label: {
if host.id == viewModel.activeHost?.id {
Label(host.name, systemImage: "checkmark")
} else {
Text(host.name)
}
}
}
Divider()
Button {
onAddHost()
} label: {
Label(ScreenCopy.addHost, systemImage: "plus")
}
} label: {
Label(
viewModel.activeHost?.name ?? ScreenCopy.hostMenuFallback,
systemImage: "desktopcomputer"
)
}
.accessibilityIdentifier("sessions.hostMenu")
}
}
}
// MARK: - Row
/// Inviting "" card accent icon + accent title on a DS `Card`.
private struct NewSessionRow: View {
var body: some View {
Card(padding: DS.Space.md12) {
HStack(spacing: DS.Space.md12) {
Image(systemName: "plus.circle.fill")
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.accent)
Text(ScreenCopy.newSession)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.accent)
Spacer(minLength: 0)
}
}
}
}
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
/// optional live thumbnail laid out inside a DS `Card`.
private struct SessionRowView: View {
let row: SessionListViewModel.SessionRow
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
/// (no host / preview-less contexts) and the row lays out as before.
var thumbnail: SessionThumbnailView?
var body: some View {
Card(padding: DS.Space.md12) {
HStack(alignment: .top, spacing: DS.Space.md12) {
// pending / exited outrank the raw status (VM decides pending;
// exited is a row fact) resolved to a single DisplayStatus so
// the badge shows color + distinct shape + Chinese VoiceOver.
StatusBadge(status: displayStatus)
.padding(.top, DS.Space.xs2)
VStack(alignment: .leading, spacing: DS.Space.xs4) {
titleLine
Text(meta)
.dsMetaText()
.lineLimit(1)
if let telemetry = row.telemetry, !telemetry.isEmpty {
TelemetryChips(model: telemetry)
}
}
if let thumbnail {
Spacer(minLength: DS.Space.sm8)
thumbnail
}
}
}
.opacity(row.info.exited ? DS.Opacity.exited : 1)
.accessibilityElement(children: .combine)
}
private var titleLine: some View {
HStack(spacing: DS.Space.sm8) {
// T-iOS-23: OSC titles are attacker-controlled already sanitized
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
// interpretation), one line only.
Text(verbatim: title)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if row.isUnread {
unreadDot
}
Spacer(minLength: 0)
}
}
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
/// Accent (indigo) continues the web selection color distinct from the
/// gray/green status shapes.
private var unreadDot: some View {
Circle()
.fill(DS.Palette.accent)
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
.accessibilityLabel(ScreenCopy.unreadLabel)
}
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
/// fact (top precedence); otherwise the VM's badge priority stands
/// (pending outranks the live status). No VM logic re-implemented here.
private var displayStatus: DisplayStatus {
if row.info.exited { return .exited }
switch row.indicator {
case .pendingApproval: return .pendingApproval
case .status(let status): return DisplayStatus(status)
}
}
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
/// display text (untrusted verbatim Text only).
private var title: String {
if let osc = row.title, !osc.isEmpty { return osc }
guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory }
return URL(fileURLWithPath: cwd).lastPathComponent
}
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
/// The exited state is conveyed by the badge + section + dimming, so it is
/// not duplicated here.
private var meta: String {
[
ScreenCopy.clientCount(row.info.clientCount),
"\(row.info.cols)×\(row.info.rows)",
].joined(separator: " · ")
}
}
// MARK: - Screen copy
private enum ScreenCopy {
static let title = "会话"
static let newSession = "新建会话"
static let kill = "结束"
static let addHost = "配对新主机"
static let hostMenuFallback = "主机"
static let notPairedTitle = "还没有配对的主机"
static let notPairedHint = "先配对你电脑上的 web-terminal扫码或手输地址会话会出现在这里。"
static let noSessionsTitle = "主机上没有运行中的会话"
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
static let unknownDirectory = "未知目录"
static let unreadLabel = "有新输出"
static let activeSection = "运行中"
static let exitedSection = "已结束"
static func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"
}
}

View File

@@ -0,0 +1,338 @@
import GameController
import SessionCore
import SwiftTerm
import SwiftUI
import UIKit
/// T-iOS-11 · The terminal screen: SwiftTerm view + key bar + status banner.
///
/// Data flow (byte-shuttle preserved end to end):
/// - inbound: `SessionEvent.output` `TerminalViewModel` sink `feed(text:)`
/// (opaque ANSI/UTF-8 the app NEVER parses terminal semantics);
/// - outbound: SwiftTerm delegate `send` `viewModel.sendInput`,
/// `sizeChanged` `viewModel.sendResize`; KeyBar taps and hardware
/// `UIKeyCommand`s go through `viewModel.send(key:)` every labelbytes
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
/// keyboard never pops (mirrors the web bar's ws.send bypass).
/// - IME: NO custom keydown/composition interception SwiftTerm manages
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
///
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
/// T-iOS-29 · ""toolbar + exit
/// "" closeopen fresh spawncwd
/// `AppCoordinator.openNewSessionInCurrentCwd`nil =
/// / coordinator
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · wiring
/// `APIClient.killSession` Origin G nil = kill
/// /线
var onKillSession: (@MainActor () -> Void)? = nil
/// T-iPad-3 · `GCKeyboard.coalesced != nil`
/// / `KeyBarVisibility`
@State private var hasHardwareKeyboard = GCKeyboard.coalesced != nil
/// T-iPad-3 · KeyBar nil =
@State private var keyBarUserOverride: Bool?
/// DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
static let showKeyBar = "显示快捷键栏"
static let hideKeyBar = "隐藏快捷键栏"
}
/// KeyBar `KeyBarVisibility`
private var isKeyBarVisible: Bool {
KeyBarVisibility.isVisible(
hardwareKeyboardPresent: hasHardwareKeyboard,
userOverride: keyBarUserOverride
)
}
var body: some View {
TerminalHostView(
viewModel: viewModel,
keyBarVisible: isKeyBarVisible,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.ignoresSafeArea(.container, edges: .bottom)
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
.padding(.horizontal, DS.Space.md12)
.padding(.top, DS.Space.sm8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: viewModel.bannerModel
)
.toolbar {
newSessionToolbarItem
keyBarToggleToolbarItem
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
hasHardwareKeyboard = true
}
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
hasHardwareKeyboard = GCKeyboard.coalesced != nil
}
.onAppear { viewModel.start() }
}
/// T-iPad-3 · KeyBar
/// iPhone
/// /
@ToolbarContentBuilder private var keyBarToggleToolbarItem: some ToolbarContent {
if hasHardwareKeyboard {
ToolbarItem(placement: .topBarTrailing) {
Button {
keyBarUserOverride = !isKeyBarVisible
} label: {
Label(
isKeyBarVisible ? Copy.hideKeyBar : Copy.showKeyBar,
systemImage: isKeyBarVisible
? "keyboard.chevron.compact.down" : "keyboard"
)
}
.accessibilityIdentifier("terminal.keyBarToggleButton")
}
}
}
/// Mirrors web `tabs.ts newTab()` (M6): the + affordance opens a fresh
/// session in the active session's cwd, if known.
@ToolbarContentBuilder private var newSessionToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
if let onNewSessionInCwd {
Button {
onNewSessionInCwd()
} label: {
Label(Copy.newSessionInCwd, systemImage: "plus.rectangle.on.folder")
}
.accessibilityIdentifier("terminal.newInCwdButton")
}
}
}
}
// MARK: - Terminal theme
/// Refined terminal theme () matching the desktop: a fixed warm-dark
/// canvas (#100F0D bg / #ECE9E3 fg, web --bg/--text) so the terminal reads the
/// same on iOS and desktop regardless of app appearance, with caret/selection
/// in the one gold accent. Every value routes through `DS` (no literals); the
/// glyph font is Dynamic-Type-aware SF Mono so terminal text respects the
/// user's text-size choice at launch.
private enum TerminalTheme {
@MainActor
static func apply(to terminal: TerminalView) {
terminal.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .regular
)
terminal.nativeBackgroundColor = UIColor(DS.Palette.terminalBackground)
terminal.nativeForegroundColor = UIColor(DS.Palette.terminalForeground)
terminal.caretColor = DS.Palette.accentUIColor()
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
}
}
// MARK: - SwiftTerm bridge
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
private struct TerminalHostView: UIViewRepresentable {
let viewModel: TerminalViewModel
/// T-iPad-3 · KeyBar (`inputAccessoryView`) `KeyBarVisibility`
/// SwiftUI `updateUIView`
let keyBarVisible: Bool
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
var onKillSession: (@MainActor () -> Void)? = nil
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
func makeUIView(context: Context) -> KeyCommandTerminalView {
let terminal = KeyCommandTerminalView(frame: .zero)
terminal.terminalDelegate = context.coordinator
TerminalTheme.apply(to: terminal)
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }
let keyBar = KeyBarView()
keyBar.onKey = { key in viewModel.send(key: key) }
terminal.installKeyBar(keyBar, visible: keyBarVisible)
// T-iPad-3 · / iPad iPhone
// SwiftTerm
terminal.installPointerContextMenuIfSupported(
onNewInCwd: onNewSessionInCwd,
onKill: onKillSession
)
// Output sink: buffered replay flushes now, live bytes follow.
// @MainActor-typed closure feeding off the main actor cannot compile.
viewModel.attachTerminalSink { [weak terminal] text in
terminal?.feed(text: text)
}
return terminal
}
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
// itself is driven by the sink/delegate. The only push is the KeyBar
// visibility (hardware-keyboard aware / user toggle) a no-op unless it
// actually changed, so iPhone (always-visible) never reloads input views.
uiView.setKeyBarVisible(keyBarVisible)
}
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
/// `@preconcurrency` SwiftTerm only calls it from the main thread (the
/// view itself is `@MainActor`), which the runtime check enforces.
@MainActor
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
private let viewModel: TerminalViewModel
init(viewModel: TerminalViewModel) {
self.viewModel = viewModel
}
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
/// raw bytes, passed through verbatim (invariant #9).
func send(source: TerminalView, data: ArraySlice<UInt8>) {
viewModel.sendInput(String(decoding: data, as: UTF8.self))
}
/// Layout produced a new grid server `resize` (SIGWINCH). This is
/// also the latest-writer-wins size claim (v0.4 sizing model).
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
viewModel.sendResize(cols: newCols, rows: newRows)
}
/// Tapped link (OSC 8 / detected URL mirrors web M2 WebLinksAddon).
/// The link string is untrusted terminal output: http(s) only.
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
guard let url = URL(string: link),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return }
UIApplication.shared.open(url)
}
/// OSC 0/2 title (T-iOS-23): hostile input the VM sanitizes
/// (TitleSanitizer) before any UI/registry use, then the wiring
/// surfaces it on the session-list row.
func setTerminalTitle(source: TerminalView, title: String) {
viewModel.setTerminalTitle(title)
}
// cwd surfaces in the session list via the server (T-iOS-13), not
// from the local emulator deliberate no-op.
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
/// OSC 52 lets the HOST write the device clipboard silently declined
/// (server output is untrusted input; plan §4).
func clipboardCopy(source: TerminalView, content: Data) {}
}
}
/// `TerminalView` subclass adding hardware-keyboard `UIKeyCommand`s with the
/// SAME `KeyByteMap` mapping as the key bar (scope decision documented on
/// `HardwareKeyCommands`). Everything else rendering, selection, IME
/// is stock SwiftTerm.
final class KeyCommandTerminalView: TerminalView {
/// Chord outlet; the screen routes it to `TerminalViewModel.send(key:)`.
var onKeyCommand: (@MainActor (KeyByteMap.Key) -> Void)?
/// Retained KeyBar so visibility can toggle it in/out of `inputAccessoryView`
/// (T-iPad-3). The last-applied value avoids reloading input views when the
/// visibility did not change (iPhone stays byte-identical).
private var keyBar: KeyBarView?
private var appliedKeyBarVisible = true
/// Retained so the interaction's delegate outlives menu presentation.
private var contextMenuDelegate: TerminalContextMenuInteractionDelegate?
override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? [])
+ HardwareKeyCommands.build(action: #selector(runHardwareKeyCommand(_:)))
}
@objc private func runHardwareKeyCommand(_ sender: UIKeyCommand) {
guard let key = HardwareKeyCommands.key(matching: sender) else { return }
onKeyCommand?(key)
}
// MARK: - KeyBar install / visibility (T-iPad-3)
/// Install the KeyBar as `inputAccessoryView`, honoring the initial
/// visibility (hidden when a hardware keyboard makes it redundant).
func installKeyBar(_ bar: KeyBarView, visible: Bool) {
keyBar = bar
appliedKeyBarVisible = visible
inputAccessoryView = visible ? bar : nil
}
/// Apply a visibility change; a no-op when unchanged so no needless
/// `reloadInputViews()` (the KeyByteMap routing on `keyBar.onKey` is
/// untouched the same bar is only detached/re-attached).
func setKeyBarVisible(_ visible: Bool) {
guard visible != appliedKeyBarVisible else { return }
appliedKeyBarVisible = visible
inputAccessoryView = visible ? keyBar : nil
reloadInputViews()
}
// MARK: - Pointer context menu (T-iPad-3, iPad only)
/// Install the secondary-click / long-press context menu iPad only, so
/// iPhone long-press keeps SwiftTerm's native selection gesture (zero
/// regression). The delegate builds a fresh model per presentation so the
/// copy item reflects the live selection.
func installPointerContextMenuIfSupported(
onNewInCwd: (@MainActor () -> Void)?,
onKill: (@MainActor () -> Void)?
) {
// `UIDevice.current.userInterfaceIdiom` (not `traitCollection`, which can
// be `.unspecified` before the view joins a window at makeUIView time).
guard TerminalContextMenu.isPointerMenuEnabled(
idiom: UIDevice.current.userInterfaceIdiom
) else { return }
let delegate = TerminalContextMenuInteractionDelegate { [weak self] in
TerminalContextMenuModel(
onNewInCwd: onNewInCwd,
onKill: onKill,
onCopySelection: { [weak self] in self?.copySelectionToPasteboard() },
hasSelection: { [weak self] in self?.hasActiveSelection ?? false }
)
}
contextMenuDelegate = delegate
addInteraction(UIContextMenuInteraction(delegate: delegate))
}
/// Whether a selection exists reuses SwiftTerm's own `copy` eligibility
/// (`canPerformAction` returns `selection.active`); pure read, no bytes.
var hasActiveSelection: Bool {
canPerformAction(#selector(UIResponderStandardEditActions.copy(_:)), withSender: nil)
}
/// Copy the current selection via SwiftTerm's own `copy(_:)` (selection
/// `UIPasteboard`). Pure UI: it never writes to the PTY, so the byte stream
/// is untouched (invariant preserved same as pointer hover highlight).
func copySelectionToPasteboard() {
copy(nil)
}
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
import WireProtocol
/// T-iOS-24 · Full activity-timeline drill-down, presented as a sheet from the
/// away-digestaffordance (TerminalContainerView wiring).
///
/// Mirrors the web timeline panel (public/timeline.ts): rows are
/// "HH:MM · icon · label", newest-first, capped at
/// `TimelineViewModel.maxEvents`. `label` is SERVER text (untrusted display
/// input) rendered via `Text(verbatim:)` only, `lineLimit(1)` (same row
/// discipline as AwayDigestView; web sets it via textContent, SEC-H6).
struct TimelineSheet: View {
let viewModel: TimelineViewModel
var body: some View {
NavigationStack {
content
.navigationTitle(TimelineCopy.title)
.navigationBarTitleDisplayMode(.inline)
}
.presentationDetents([.medium, .large])
.task { await viewModel.load() } // fresh VM per presentation one fetch
}
// MARK: - Phase switch
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .empty:
emptyState
case .failed:
failedState
case .loaded(let events):
eventList(events)
}
}
/// Server replied `[]` covers both "no activity yet" and timeline
/// capture disabled host-side (src/server.ts:589-591). An empty timeline
/// is a normal state, NEVER an error (task spec).
private var emptyState: some View {
ContentUnavailableView(
TimelineCopy.emptyTitle,
systemImage: "clock.badge.questionmark",
description: Text(TimelineCopy.emptyDetail)
)
}
/// Explicit, retryable error state the fetch can fail transiently
/// (LAN hop, host asleep); retry re-runs the same load path.
private var failedState: some View {
ContentUnavailableView {
Label(TimelineCopy.loadFailed, systemImage: "wifi.exclamationmark")
} description: {
Text(TimelineCopy.loadFailedDetail)
} actions: {
Button(TimelineCopy.retry) {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
// MARK: - Rows (newest-first, already ordered by the VM)
private func eventList(_ events: [TimelineEvent]) -> some View {
List {
// Offset identity (not `at`): the server can ingest several
// events in the same millisecond, and rows are static.
ForEach(Array(events.enumerated()), id: \.offset) { _, event in
row(event)
}
}
.listStyle(.plain)
}
private func row(_ event: TimelineEvent) -> some View {
HStack(spacing: DS.Space.md12) {
// Timestamp mono/tabular so HH:mm columns line up (direction).
Text(TimelineRowFormat.timeLabel(atMs: event.at))
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
// class glyph + semantic color (frozen mapping; unit-tested).
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
.font(DS.Typography.callout)
.foregroundStyle(TimelineClassStyle.color(for: event.class))
.frame(width: DS.Space.xxl24)
// Server-derived phrase untrusted: verbatim (never
// LocalizedStringKey/Markdown) + hard single-line truncation.
Text(verbatim: event.label)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.vertical, DS.Space.xs2)
.accessibilityElement(children: .combine)
}
}
// MARK: - class icon / color mapping
/// Glyphs mirror web `timelineIcon` verbatim (public/timeline.ts:88-96).
/// Colors are semantic the web CSS defines NO tl-icon-* colors, so iOS
/// aligns with SessionListScreen's status color convention (waiting=orange,
/// stuck=red) and extends it. Total functions: the server is untrusted, so an
/// unknown class degrades to a neutral glyph/color instead of trapping (even
/// though `APIClient.events` already drops unknown classes defense in depth).
enum TimelineClassStyle {
static let fallbackGlyph = ""
static func glyph(for cls: String) -> String {
switch cls {
case "tool": return "🔧"
case "waiting": return ""
case "done": return ""
case "stuck": return ""
case "user": return "💬"
default: return fallbackGlyph
}
}
static func color(for cls: String) -> Color {
// All from the frozen design system no raw SwiftUI colors (UX finding).
switch cls {
case "tool": return DS.Palette.timelineTool
case "waiting": return DS.Palette.statusWaiting
case "done": return DS.Palette.statusWorking
case "stuck": return DS.Palette.statusStuck
case "user": return DS.Palette.timelineUser
default: return DS.Palette.textSecondary
}
}
}
// MARK: - Row time formatting
/// Mirrors web `formatHHMM` (public/timeline.ts:101-106): 24-hour wall-clock
/// "HH:mm". Fixed POSIX locale so a 12-hour user locale can't leak AM/PM into
/// the fixed format; timezone injectable for deterministic tests.
enum TimelineRowFormat {
private static let millisecondsPerSecond = 1_000.0
static func timeLabel(atMs: Int, timeZone: TimeZone = .current) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "HH:mm"
let date = Date(timeIntervalSince1970: Double(atMs) / millisecondsPerSecond)
return formatter.string(from: date)
}
}
// MARK: - plan
enum TimelineCopy {
static let title = "活动时间线"
static let emptyTitle = "暂无活动"
static let emptyDetail = "会话还没有可展示的事件主机关闭时间线TIMELINE_ENABLED=0时也会显示为空。"
static let loadFailed = "时间线加载失败"
static let loadFailedDetail = "无法从主机获取活动时间线,请检查连接后重试。"
static let retry = "重试"
}

View File

@@ -0,0 +1,164 @@
import Foundation
import Observation
import WireProtocol
/// T-iOS-27 · State for the read-only diff viewer (`DiffScreen`).
///
/// The fetch closure is injected `forProject` `DiffFetcher`
/// App T-iOS-26 ProjectDetail (endpoint, path)
/// fake
/// `DiffFetcher` VM
/// - files `.loaded`/hunk /****
/// diff DIFF_MAX_BYTES 2MB Text
/// - `[]` `.empty`truncated
/// - 400/ `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// `.failed(.unavailable)` `load()`
/// - staged/unstaged `setStaged` fetch no-op
@MainActor
@Observable
final class DiffViewModel {
/// DiffScreen DiffCopy
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
/// Rendering phase an explicit enum so the screen can never show an
/// error and diff rows at the same time (same discipline as Timeline).
enum Phase: Equatable {
case loading
/// filestruncated
case empty(truncated: Bool)
case loaded(DiffPresentation)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// false = unstagedtrue = --staged
private(set) var staged = false
@ObservationIgnored
private let fetch: @Sendable (_ staged: Bool) async throws -> DiffResult
init(fetch: @escaping @Sendable (_ staged: Bool) async throws -> DiffResult) {
self.fetch = fetch
}
/// `DiffScreen(endpoint:path:http:)` T-iOS-26
/// ProjectDetail DiffFetcher
static func forProject(
endpoint: HostEndpoint, path: String, http: any HTTPTransport
) -> DiffViewModel {
let fetcher = DiffFetcher(endpoint: endpoint, http: http)
return DiffViewModel(fetch: { staged in
try await fetcher.fetch(path: path, staged: staged)
})
}
/// Fetch and present. Also thepath: callable again from `.failed`.
func load() async {
phase = .loading
do {
let result = try await fetch(staged)
phase = Self.presentation(for: result)
} catch let error as DiffFetchError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
/// staged/unstaged fetch Steps
func setStaged(_ newValue: Bool) async {
guard newValue != staged else { return }
staged = newValue
await load()
}
// MARK: -
static func presentation(for result: DiffResult) -> Phase {
guard !result.files.isEmpty else {
return .empty(truncated: result.truncated)
}
return .loaded(DiffPresentation(
rows: makeRows(files: result.files), truncated: result.truncated
))
}
/// (binary | hunk )
/// binary hunks web renderDiffFile early return
/// public/diff.ts:163-166id
static func makeRows(files: [DiffFile]) -> [DiffRow] {
var rows: [DiffRow] = []
for file in files {
rows.append(DiffRow(id: rows.count, kind: .fileHeader(header(for: file))))
if file.binary {
rows.append(DiffRow(id: rows.count, kind: .binaryNotice))
continue
}
for hunk in file.hunks {
rows.append(DiffRow(id: rows.count, kind: .hunkHeader(hunk.header)))
for line in hunk.lines {
rows.append(DiffRow(
id: rows.count, kind: .line(kind: line.kind, text: line.text)
))
}
}
}
return rows
}
private static func failure(for error: DiffFetchError) -> Failure {
switch error {
case .invalidRequest, .pathInvalid:
return .pathInvalid
case .projectNotFound:
return .notFound
case .invalidResponse, .unexpectedStatus:
return .unavailable
}
}
/// Rename old new webpublic/diff.ts:146-150
/// newPath `Text(verbatim:)`
private static func header(for file: DiffFile) -> DiffFileHeader {
let pathLabel = file.status == .renamed && file.oldPath != file.newPath
? "\(file.oldPath)\(file.newPath)"
: file.newPath
return DiffFileHeader(
pathLabel: pathLabel, added: file.added,
removed: file.removed, status: file.status
)
}
}
// MARK: -
/// Display-ready flattened diff
struct DiffPresentation: Equatable {
let rows: [DiffRow]
let truncated: Bool
}
/// One lazy-list row. `id` =
struct DiffRow: Equatable, Identifiable {
enum Kind: Equatable {
case fileHeader(DiffFileHeader)
case binaryNotice
case hunkHeader(String)
case line(kind: DiffLineKind, text: String)
}
let id: Int
let kind: Kind
}
/// File-header row payload rename
struct DiffFileHeader: Equatable {
let pathLabel: String
let added: Int
let removed: Int
let status: DiffFileStatus
}

View File

@@ -0,0 +1,319 @@
import Foundation
import Observation
import SessionCore
import UIKit
import WireProtocol
/// Haptic seam (T-iOS-14): gate-arrival feedback is injected so tests can
/// assert "exactly once per gate epoch" without UIKit hardware.
@MainActor
protocol HapticSignaling {
/// A NEW gate (fresh epoch) is waiting for the user's decision.
func gateDidArrive()
}
/// Production haptics: notification-style buzz a gate is Claude asking for a
/// decision (THE steering primitive), not a mere UI tick.
@MainActor
final class GateHaptics: HapticSignaling {
private let generator = UINotificationFeedbackGenerator()
func gateDidArrive() {
generator.notificationOccurred(.warning)
}
}
/// T-iOS-14 · Gate + away-digest state (plan §3.5): a STANDALONE
/// `@MainActor @Observable` VM consuming the SAME `SessionEvent` stream as
/// `TerminalViewModel` `.gate`/`.digest` are its domain, everything else is
/// ignored here. TerminalScreen wiring (who fans the stream out) is T-iOS-15.
///
/// Stale-tap guard (FIRST line): every rendered gate button carries the epoch
/// of the gate it was rendered against; `decide` compares that epoch and the
/// affordance against the LATEST gate event received and silently drops a
/// mismatch, so a slow tap can never resolve a NEWER gate than the one that
/// was on screen (mirrors the web's pendingEpoch nonce,
/// public/terminal-session.ts:98-102). The engine's `GateTracker.canDecide`
/// is the SECOND line (SessionEngine type doc) both must agree to send.
///
/// Plan-gate mapping is frozen in SessionCore
/// (`GateState.Affordance.clientMessage`, mirror of public/tabs.ts:345-347):
/// Approve+Auto acceptEdits, Approve+Review default, Keep Planning
/// reject. There is NO allowAutoMode gating anywhere the web client never
/// gates the plan three-way, and `uiConfig` is reserved for a future
/// permission-mode picker (plan §3.1 note).
@MainActor
@Observable
final class GateViewModel {
// MARK: - Observable UI state
/// The latest gate event received (nil = lifted). This is what taps are
/// validated against.
private(set) var currentGate: GateState?
/// Visible away digest (nil = nothing rendered; all-zero digests never
/// become visible).
private(set) var digest: AwayDigest?
/// True once the user opened the recent-entries detail; expansion cancels
/// the auto-fade (a digest must never vanish while being read).
private(set) var isDigestExpanded = false
/// Tool gate two-button `GateBanner`.
var toolGate: GateState? { gate(of: .tool) }
/// Plan gate three-way `PlanGateSheet`.
var planGate: GateState? { gate(of: .plan) }
// MARK: - Dependencies & plumbing (not observed)
@ObservationIgnored private let engine: SessionEngine
@ObservationIgnored private let events: AsyncStream<SessionEvent>
@ObservationIgnored private let haptics: any HapticSignaling
@ObservationIgnored private let clock: any Clock<Duration>
@ObservationIgnored private var consumeTask: Task<Void, Never>?
@ObservationIgnored private var fadeTask: Task<Void, Never>?
/// Highest epoch that already buzzed the haptic fires ONCE per epoch
/// (sustained-pending refreshes reuse the epoch and stay silent).
@ObservationIgnored private var lastHapticEpoch = 0
/// Ordered decision queue: taps are synchronous, `engine.send` is async
/// one pump task preserves submission order (same pattern as
/// TerminalViewModel's send pump).
@ObservationIgnored private var decisionQueue: [ClientMessage] = []
@ObservationIgnored private var isPumping = false
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
/// Events applied so far `waitUntilProcessed` barrier counter.
@ObservationIgnored private(set) var processedEventCount = 0
/// Decisions handed to the engine `waitUntilForwarded` barrier counter.
@ObservationIgnored private(set) var forwardedDecisionCount = 0
/// Taps dropped by the first-line guard (stale epoch / lifted gate /
/// affordance no longer offered).
@ObservationIgnored private(set) var droppedStaleDecisionCount = 0
/// Completed auto-fades `waitUntilFadeCompleted` barrier counter.
@ObservationIgnored private(set) var fadeCompletedCount = 0
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
@ObservationIgnored private var decisionWaiters: [CountWaiter] = []
@ObservationIgnored private var fadeWaiters: [CountWaiter] = []
// MARK: - Lifecycle
/// - Parameters:
/// - engine: send-side dependency (decisions only open/close belong to
/// the T-iOS-15 lifecycle owner).
/// - events: the event stream to consume; the T-iOS-15 wiring passes a
/// fan-out branch of `engine.events` shared with `TerminalViewModel`.
/// - haptics: gate-arrival feedback (production: `GateHaptics`).
/// - clock: drives the digest auto-fade (production: `ContinuousClock`).
init(
engine: SessionEngine, events: AsyncStream<SessionEvent>,
haptics: any HapticSignaling, clock: any Clock<Duration>
) {
self.engine = engine
self.events = events
self.haptics = haptics
self.clock = clock
}
/// Begin consuming events. Idempotent a second call is a no-op.
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters() // stream over never leave a test hanging
}
}
/// Stop consuming (screen torn down); cancels the fade timer too.
func stop() {
consumeTask?.cancel()
consumeTask = nil
cancelFade()
releaseAllWaiters()
}
// MARK: - Decisions (Approve / Reject / plan three-way)
/// Resolve the held gate with `affordance`, tapped against the gate whose
/// `epoch` the button rendered. First-line stale guard: the epoch must
/// match the LATEST gate received AND the affordance must still be one the
/// current gate offers (a same-epoch kind morph invalidates old buttons).
/// Mismatch dropped, nothing is sent.
func decide(_ affordance: GateState.Affordance, epoch: Int) {
guard let gate = currentGate, gate.epoch == epoch,
gate.affordances.contains(affordance)
else {
droppedStaleDecisionCount += 1
return
}
decisionQueue = decisionQueue + [affordance.clientMessage]
pumpIfIdle()
}
// MARK: - Digest interactions
/// Show the recent-entries detail; cancels the auto-fade so the digest
/// never vanishes while being read (documented decision).
func expandDigest() {
guard digest != nil else { return }
isDigestExpanded = true
cancelFade()
}
/// Explicitly dismiss the digest (the on the summary row).
func dismissDigest() {
digest = nil
isDigestExpanded = false
cancelFade()
}
// MARK: - Event application (MainActor)
private func apply(_ event: SessionEvent) {
switch event {
case .gate(let gate):
applyGate(gate)
case .digest(let digest):
applyDigest(digest)
case .connection, .adopted, .output, .exited, .telemetry:
break // TerminalViewModel's domain (T-iOS-11)
}
processedEventCount += 1
onEventApplied?(event)
eventWaiters = drainWaiters(eventWaiters, reached: processedEventCount)
}
private func applyGate(_ gate: GateState?) {
currentGate = gate
guard let gate, gate.epoch > lastHapticEpoch else { return }
lastHapticEpoch = gate.epoch // exactly one buzz per epoch
haptics.gateDidArrive()
}
/// All-zero digest render nothing (spec). A visible digest starts
/// collapsed and auto-fades after `Tunables.digestFadeDelay` unless the
/// user expands it first.
private func applyDigest(_ incoming: AwayDigest) {
guard !incoming.isEmpty else { return }
cancelFade()
digest = incoming
isDigestExpanded = false
scheduleFade()
}
private func gate(of kind: GateKind) -> GateState? {
guard let currentGate, currentGate.kind == kind else { return nil }
return currentGate
}
// MARK: - Digest auto-fade (injected clock; zero real waits in tests)
private func scheduleFade() {
let clock = self.clock
fadeTask = Task { [weak self] in
do {
try await clock.sleep(for: Tunables.digestFadeDelay, tolerance: nil)
} catch {
return // cancelled: expanded, dismissed, replaced, or stopped
}
self?.completeFade()
}
}
private func completeFade() {
guard !isDigestExpanded else { return } // expand cancels; belt & braces
digest = nil
fadeTask = nil
fadeCompletedCount += 1
fadeWaiters = drainWaiters(fadeWaiters, reached: fadeCompletedCount)
}
private func cancelFade() {
fadeTask?.cancel()
fadeTask = nil
}
// MARK: - Ordered decision pump
private func pumpIfIdle() {
guard !isPumping else { return }
isPumping = true
Task { await self.pumpDecisions() }
}
private func pumpDecisions() async {
while let next = decisionQueue.first {
decisionQueue = Array(decisionQueue.dropFirst())
await engine.send(next)
forwardedDecisionCount += 1
decisionWaiters = drainWaiters(decisionWaiters, reached: forwardedDecisionCount)
}
isPumping = false
}
// MARK: - Deterministic test barriers (no polling, no real sleeps)
/// Suspends until at least `eventCount` events have been applied.
func waitUntilProcessed(eventCount target: Int) async {
await withCheckedContinuation { continuation in
guard processedEventCount < target else {
continuation.resume()
return
}
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `decisionCount` decisions reached the engine.
func waitUntilForwarded(decisionCount target: Int) async {
await withCheckedContinuation { continuation in
guard forwardedDecisionCount < target else {
continuation.resume()
return
}
decisionWaiters = decisionWaiters
+ [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `count` auto-fades have completed.
func waitUntilFadeCompleted(count target: Int) async {
await withCheckedContinuation { continuation in
guard fadeCompletedCount < target else {
continuation.resume()
return
}
fadeWaiters = fadeWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Resumes every waiter satisfied by `count`; returns the still-waiting
/// remainder (immutable style the caller reassigns).
private func drainWaiters(_ waiters: [CountWaiter], reached count: Int) -> [CountWaiter] {
let satisfied = waiters.filter { $0.target <= count }
for waiter in satisfied {
waiter.continuation.resume()
}
return waiters.filter { $0.target > count }
}
private func releaseAllWaiters() {
let all = eventWaiters + decisionWaiters + fadeWaiters
eventWaiters = []
decisionWaiters = []
fadeWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}

View File

@@ -0,0 +1,398 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-12 · Pairing state machine (plan §7 / §5.4).
///
/// Flow: scan / manual URL **confirm gate** two-step probe
/// `Host{id,name}` into the `HostStore` + navigate signal.
///
/// Security invariants (plan §5 / task RED list):
/// - Scan payloads are UNTRUSTED external input: parsed exclusively through
/// `HostEndpoint` (single-point derivation no hand-assembly), non-http(s)
/// rejected with copy, and **zero network happens before the user confirms**
/// (probe already GETs the target; probe spawns a PTY on it).
/// - The §5.4 warning tiers render ON the confirm page; the public-host tier
/// is BLOCKING `confirmConnect` refuses to probe until the user has set
/// `hasAcknowledgedPublicRisk` explicitly.
/// - Every `PairingError` maps to inline copy + a recovery action
/// (`localNetworkDenied` Settings deep-link; `originRejected` surfaces the
/// probe's hint VERBATIM; `atsBlocked` uses the §3.4 wording).
///
/// Documented decisions:
/// - **Manual entry reuses the same confirm state as scanning** (task left it
/// free): one code path, and the §5.4 warning tiers apply uniformly to
/// typed URLs too. Convenience: input without `://` gets an `http://`
/// prefix before the `HostEndpoint` parse (scan payloads get NO such help).
/// - **Host classification is (re)implemented here**: APIClient's
/// `PairingError.isPrivateOrLocalHost` is internal AND too coarse for the
/// tiers (it collapses loopback/Tailscale/RFC1918 into one bucket).
/// Duplication noted for the T-iOS-38 dedup pass.
/// - `.local` (mDNS) hosts over http are shown the plaintext-LAN notice: they
/// resolve to LAN addresses, so the §5.4 "ws:// on an untrusted LAN" row
/// applies to them the same way.
///
/// §3.4 contract ruling (2026-07-04): the injected probe returns the validated
/// `HostEndpoint`; `Host{id: UUID(), name:}` is constructed HERE (id/name are
/// not the probe's to know). Production wiring (T-iOS-15) passes
/// `runPairingProbe` with the real transports.
@MainActor
@Observable
final class PairingViewModel {
/// The probe, injected as a closure so tests can both fake results AND
/// assert non-invocation before the user confirms (task RED list).
typealias Probe = @Sendable (HostEndpoint) async -> Result<HostEndpoint, PairingError>
// MARK: - UI state model
/// §5.4 warning tiers, decided from scheme + host class (see `warning(for:)`).
enum SecurityWarning: Equatable, Sendable {
/// https anywhere private-class, or wsloopback: nothing to warn about.
case none
/// ws100.64/10 or `*.ts.net`: WireGuard already encrypts no
/// plaintext warning, an optional positive badge instead.
case tailscaleEncrypted
/// wsRFC1918 / link-local / `.local`: NON-blocking notice keystrokes
/// and output are sniffable on the same LAN; prefer `tailscale serve`.
case plaintextLAN
/// Public host (http AND https alike, §5.4 table): strongest BLOCKING
/// warning anyone who can reach the port gets a shell.
case publicHostBlocking
var isBlocking: Bool { self == .publicHostBlocking }
}
/// The parsed-but-not-yet-probed target shown on the confirm page.
struct PendingHost: Equatable, Sendable {
let endpoint: HostEndpoint
let warning: SecurityWarning
/// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
/// (browser-Origin serialization) NEVER hand-assembled.
var displayAddress: String { endpoint.originHeader }
}
/// What the failure UI offers besides the message.
enum RecoveryAction: Equatable, Sendable {
case retry
/// iOS Local Network permission was denied deep-link to the app's
/// Settings pane (its toggle lives there).
case openLocalNetworkSettings
}
struct FailureDisplay: Equatable, Sendable {
let message: String
let action: RecoveryAction
}
enum Phase: Equatable {
case idle
case confirming(PendingHost)
case probing(PendingHost)
case failed(PendingHost, FailureDisplay)
case paired(HostRegistry.Host)
}
// MARK: - Observable state
private(set) var phase: Phase = .idle
/// Inline rejection copy for invalid scan/manual input (idle-state error).
private(set) var inputRejection: String?
/// Editable name shown on the confirm page; defaults to the endpoint host
/// and falls back to it when the user clears the field.
var hostName = ""
/// Explicit user acknowledgement for the blocking public-host warning.
var hasAcknowledgedPublicRisk = false
/// Set when confirm was attempted on a blocking warning WITHOUT the
/// acknowledgement the UI highlights the ack control.
private(set) var needsPublicRiskAcknowledgement = false
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
/// observes it to move on to the session list).
private(set) var pairedHost: HostRegistry.Host?
// MARK: - Dependencies (not observed)
@ObservationIgnored private let store: any HostStore
@ObservationIgnored private let probe: Probe
init(store: any HostStore, probe: @escaping Probe) {
self.store = store
self.probe = probe
}
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
/// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
/// payload must already be a full http(s) URL no scheme inference for
/// untrusted external input.
func handleScannedCode(_ payload: String) {
guard canAcceptNewTarget else { return }
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
inputRejection = PairingCopy.scanRejected
return
}
enterConfirming(endpoint)
}
/// Manually typed URL. The user knows what they typed, but it still goes
/// through the SAME confirm state (uniform warning tiers documented
/// decision). Convenience: no `://` `http://` prefix before parsing.
func submitManualURL(_ text: String) {
guard canAcceptNewTarget else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
inputRejection = PairingCopy.manualRejected
return
}
let candidate = trimmed.contains(Self.schemeSeparator)
? trimmed
: Self.defaultManualScheme + trimmed
guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
inputRejection = PairingCopy.manualRejected
return
}
enterConfirming(endpoint)
}
/// Back out of confirm/failed to a clean entry state. Never probes.
func cancel() {
phase = .idle
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
}
// MARK: - Confirm probe store
/// The ONLY way network starts. No-op unless confirming; a blocking
/// warning without explicit acknowledgement refuses and flags the UI.
func confirmConnect() async {
guard case .confirming(let pending) = phase else { return }
if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
needsPublicRiskAcknowledgement = true
return
}
await runProbe(for: pending)
}
/// Re-run the full probe against the same endpoint after a failure.
func retry() async {
guard case .failed(let pending, _) = phase else { return }
await runProbe(for: pending)
}
private var canAcceptNewTarget: Bool {
switch phase {
case .idle, .confirming, .failed:
return true
case .probing, .paired:
return false
}
}
private func enterConfirming(_ endpoint: HostEndpoint) {
inputRejection = nil
hasAcknowledgedPublicRisk = false
needsPublicRiskAcknowledgement = false
hostName = endpoint.baseURL.host ?? ""
phase = .confirming(PendingHost(
endpoint: endpoint, warning: Self.warning(for: endpoint)
))
}
private func runProbe(for pending: PendingHost) async {
needsPublicRiskAcknowledgement = false
phase = .probing(pending)
switch await probe(pending.endpoint) {
case .failure(let error):
phase = .failed(pending, Self.display(for: error))
case .success(let endpoint):
await storePairedHost(endpoint: endpoint, pending: pending)
}
}
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
/// endpoint. A store failure is surfaced explicitly (never swallowed);
/// retry re-runs the whole confirm flow.
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
let host = HostRegistry.Host(
id: UUID(),
name: trimmedName.isEmpty ? fallbackName : trimmedName,
endpoint: endpoint
)
do {
_ = try await store.upsert(host)
} catch {
phase = .failed(pending, FailureDisplay(
message: PairingCopy.storeFailed, action: .retry
))
return
}
pairedHost = host
phase = .paired(host)
}
// MARK: - PairingError copy + action (task RED list, one case each)
static func display(for error: PairingError) -> FailureDisplay {
switch error {
case .localNetworkDenied:
return FailureDisplay(
message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
)
case .hostUnreachable(let underlying):
return FailureDisplay(
message: PairingCopy.hostUnreachable(underlying), action: .retry
)
case .httpOkButNotWebTerminal:
return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
case .originRejected(let hint):
// The probe already derived the complete actionable copy from
// endpoint.originHeader surface it VERBATIM, never re-derive.
return FailureDisplay(message: hint, action: .retry)
case .atsBlocked(let host):
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
case .tlsFailure:
return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
case .timeout:
return FailureDisplay(message: PairingCopy.timeout, action: .retry)
}
}
// MARK: - §5.4 warning tiers
/// Decide the confirm-page warning from scheme + host class. Public hosts
/// block regardless of scheme (§5.4 table: https is included in the
/// public-host confirm warning); otherwise https clears every notice.
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
if hostClass == .publicHost {
return .publicHostBlocking
}
if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
return .none
}
switch hostClass {
case .loopback:
return .none
case .tailscale:
return .tailscaleEncrypted
case .privateLAN:
return .plaintextLAN
case .publicHost:
return .publicHostBlocking // unreachable; keeps the switch total
}
}
/// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
/// internal `isPrivateOrLocalHost` (finer-grained here) T-iOS-38 dedup.
enum HostClass: Equatable, Sendable {
case loopback
case tailscale
case privateLAN
case publicHost
}
static func classifyHost(_ rawHost: String) -> HostClass {
let host = rawHost.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
if host == Self.localhostName {
return .loopback
}
if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
return .tailscale
}
if host.hasSuffix(Self.mdnsSuffix) {
return .privateLAN
}
if let octets = ipv4Octets(host) {
return classifyIPv4(octets)
}
if host.contains(":") {
return classifyIPv6(host)
}
return .publicHost
}
private static func classifyIPv4(_ octets: [Int]) -> HostClass {
switch (octets[0], octets[1]) {
case (127, _):
return .loopback
case (10, _), (192, 168), (169, 254):
return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
case (172, 16...31):
return .privateLAN // RFC1918 172.16/12
case (100, 64...127):
return .tailscale // CGNAT 100.64/10
default:
return .publicHost
}
}
private static func classifyIPv6(_ host: String) -> HostClass {
if host == Self.ipv6Loopback {
return .loopback
}
let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
return (isLinkLocal || isULA) ? .privateLAN : .publicHost
}
private static func ipv4Octets(_ host: String) -> [Int]? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == Self.ipv4OctetCount else { return nil }
let octets = parts.compactMap { Int($0) }
guard octets.count == Self.ipv4OctetCount,
octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
else { return nil }
return octets
}
// MARK: - Named constants (no magic values, plan §4)
private static let schemeSeparator = "://"
private static let defaultManualScheme = "http://"
private static let httpsScheme = "https"
private static let localhostName = "localhost"
private static let tailscaleMagicDNSSuffix = ".ts.net"
private static let mdnsSuffix = ".local"
private static let ipv6Loopback = "::1"
private static let ipv6LinkLocalPrefix = "fe80"
private static let ipv4OctetCount = 4
private static let ipv4OctetRange = 0...255
}
/// User-facing pairing copy (plan §3.4 taxonomy actionable wording; §5.2
/// Local-Network guidance including the iOS 18 restart caveat).
enum PairingCopy {
static let scanRejected =
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
static let manualRejected =
"无法解析这个地址。请输入完整 URL例如 http://192.168.1.5:3000"
static let storeFailed =
"主机已通过验证,但保存到本机失败,请重试。"
static let localNetworkDenied =
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
+ "iOS 18 存在需要重启手机才生效的已知问题)。"
static let notWebTerminal =
"对方在响应 HTTP但不是 web-terminal——端口对吗"
static let tlsFailure =
"TLS 连接失败:证书无效或不受信任。"
static let timeout =
"连接超时。请确认主机在线、与手机在同一网络后重试。"
static func hostUnreachable(_ underlying: String) -> String {
"无法连接主机:\(underlying)"
}
/// §3.4 wording for the ATS cleartext block.
static func atsBlocked(host: String) -> String {
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
+ "请改用 https / tailscale serve或反馈该网段。"
}
}

View File

@@ -0,0 +1,76 @@
import APIClient
import Foundation
import Observation
import WireProtocol
/// T-iOS-26 · `GET /projects/detail?path=` phase
/// DiffViewModel/TimelineViewModel
///
/// fetch `forHost` `APIClient.projectDetail(path:)`
/// builder 400/404/500 `{error}` T-iOS-38
/// fake VM
/// - `.loaded(ProjectDetail)`sessions/worktrees/hasClaudeMd
/// - 400 `.failed(.pathInvalid)`404 `.failed(.notFound)`
/// 500// `.failed(.unavailable)` `load()`
@MainActor
@Observable
final class ProjectDetailViewModel {
/// ProjectDetailScreen
enum Failure: Equatable {
case pathInvalid
case notFound
case unavailable
}
enum Phase: Equatable {
case loading
case loaded(ProjectDetail)
case failed(Failure)
}
private(set) var phase: Phase = .loading
/// /diff
/// builder URL
let path: String
@ObservationIgnored
private let fetch: @Sendable () async throws -> ProjectDetail
init(path: String, fetch: @escaping @Sendable () async throws -> ProjectDetail) {
self.path = path
self.fetch = fetch
}
/// ProjectsViewModel.makeDetailViewModel
static func forHost(
endpoint: HostEndpoint, http: any HTTPTransport, path: String
) -> ProjectDetailViewModel {
let client = APIClient(endpoint: endpoint, http: http)
return ProjectDetailViewModel(path: path, fetch: {
try await client.projectDetail(path: path)
})
}
/// Fetch `.failed`
func load() async {
phase = .loading
do {
phase = .loaded(try await fetch())
} catch let error as APIClientError {
phase = .failed(Self.failure(for: error))
} catch {
phase = .failed(.unavailable) //
}
}
private static func failure(for error: APIClientError) -> Failure {
switch error {
case .projectPathInvalid, .invalidRequest:
return .pathInvalid
case .projectNotFound:
return .notFound
default:
return .unavailable
}
}
}

View File

@@ -0,0 +1,192 @@
import APIClient
import Foundation
/// T-iOS-26 · Projects web v0.6
/// public/projects.tsfilterProjects / sortProjects / groupProjects /
/// displayLabel使
///
/// ** key web **namespace
/// `" active"` / `" other"` key
/// `/prefs.collapsed` key =
/// label UI namespace = key
///
/// Active assert reality`ProjectSessionRef.exited`
/// src/types.ts:262-269running = `!exited` web
/// `hasRunningSession` running ****
enum ProjectGrouping {
/// key `First.Second` namespace
/// public/projects.ts:83-84
static let activeGroupKey = " active"
static let otherGroupKey = " other"
/// namespace MIN_GROUP_SIZE
static let minGroupSize = 2
// MARK: - filter filterProjectsname/path
static func filter(_ projects: [ProjectInfo], query: String) -> [ProjectInfo] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return projects }
let lower = trimmed.lowercased()
return projects.filter {
$0.name.lowercased().contains(lower) || $0.path.lowercased().contains(lower)
}
}
// MARK: - sort sortProjects lastActiveMs
/// JS `Array.sort` web Swift
/// `sorted`
static func sort(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectInfo] {
projects.enumerated().sorted { a, b in
let aFav = favourites.contains(a.element.path)
let bFav = favourites.contains(b.element.path)
if aFav != bFav { return aFav }
let aActive = a.element.lastActiveMs ?? 0
let bActive = b.element.lastActiveMs ?? 0
if aActive != bActive { return aActive > bActive }
return a.offset < b.offset
}.map(\.element)
}
// MARK: - group groupProjects
static func group(_ projects: [ProjectInfo], favourites: Set<String>) -> [ProjectGroup] {
let (namespaceGroups, other) = bucketByNamespace(projects, favourites: favourites)
// flat chrome 退
guard !namespaceGroups.isEmpty else {
return [makeGroup(
key: otherGroupKey, label: ProjectsCopy.allGroupLabel,
kind: .flat, items: projects, favourites: favourites
)]
}
var groups: [ProjectGroup] = []
let active = projects.filter(hasRunningSession)
if !active.isEmpty {
groups.append(makeGroup(
key: activeGroupKey, label: ProjectsCopy.activeGroupLabel,
kind: .active, items: active, favourites: favourites
))
}
groups.append(contentsOf: orderedByRecency(namespaceGroups))
if !other.isEmpty {
groups.append(makeGroup(
key: otherGroupKey, label: ProjectsCopy.otherGroupLabel,
kind: .other, items: other, favourites: favourites
))
}
return groups
}
/// namespace `<key>.`
/// `Billo.Platform.` displayLabel
static func displayLabel(name: String, groupKey: String) -> String {
if groupKey == activeGroupKey || groupKey == otherGroupKey { return name }
let prefix = "\(groupKey)."
guard name.lowercased().hasPrefix(prefix.lowercased()) else { return name }
return String(name.dropFirst(prefix.count))
}
static func hasRunningSession(_ project: ProjectInfo) -> Bool {
project.sessions.contains { !$0.exited }
}
// MARK: - Internals
/// namespace = `'a.b.c'` `'a.b'` nil
/// JS `split('.')`
private static func namespaceKey(_ name: String) -> String? {
let segments = name.split(separator: ".", omittingEmptySubsequences: false)
guard segments.count >= 2 else { return nil }
return segments.prefix(2).joined(separator: ".")
}
/// key JS Map
/// ( namespace, Other )
private static func bucketByNamespace(
_ projects: [ProjectInfo], favourites: Set<String>
) -> (groups: [ProjectGroup], other: [ProjectInfo]) {
var bucketOrder: [String] = []
var buckets: [String: (display: String, items: [ProjectInfo])] = [:]
var other: [ProjectInfo] = []
for project in projects {
guard let namespace = namespaceKey(project.name) else {
other.append(project)
continue
}
let lowerKey = namespace.lowercased()
if var bucket = buckets[lowerKey] {
bucket.items.append(project)
buckets[lowerKey] = bucket
} else {
buckets[lowerKey] = (namespace, [project])
bucketOrder.append(lowerKey)
}
}
var groups: [ProjectGroup] = []
for lowerKey in bucketOrder {
guard let bucket = buckets[lowerKey] else { continue }
if bucket.items.count < minGroupSize {
other.append(contentsOf: bucket.items)
} else {
groups.append(makeGroup(
key: bucket.display, label: bucket.display,
kind: .namespace, items: bucket.items, favourites: favourites
))
}
}
return (groups, other)
}
/// namespace label
/// groupProjects sort
private static func orderedByRecency(_ groups: [ProjectGroup]) -> [ProjectGroup] {
groups.sorted { a, b in
let aMax = maxLastActive(a.projects)
let bMax = maxLastActive(b.projects)
if aMax != bMax { return aMax > bMax }
return a.label.localizedCompare(b.label) == .orderedAscending
}
}
private static func maxLastActive(_ items: [ProjectInfo]) -> Int {
items.reduce(0) { max($0, $1.lastActiveMs ?? 0) }
}
private static func makeGroup(
key: String, label: String, kind: ProjectGroupKind,
items: [ProjectInfo], favourites: Set<String>
) -> ProjectGroup {
ProjectGroup(
key: key, label: label, kind: kind,
projects: sort(items, favourites: favourites),
activeCount: items.filter(hasRunningSession).count
)
}
}
/// web `ProjectGroupKind`
enum ProjectGroupKind: Equatable, Sendable {
case active
case namespace
case other
case flat
}
/// web `ProjectGroup`
struct ProjectGroup: Equatable, Identifiable, Sendable {
/// key web /prefs
let key: String
let label: String
let kind: ProjectGroupKind
///
let projects: [ProjectInfo]
///
let activeCount: Int
var id: String { key }
/// Active now flat chrome
var isCollapsible: Bool { kind == .namespace || kind == .other }
}

View File

@@ -0,0 +1,236 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import WireProtocol
/// T-iOS-26 · Projects web v0.6 projects
/// `GET /projects` + `GET/PUT /prefs` / +
/// ""
///
/// Documented decisions:
/// - **prefs clobber **`PUT /prefs` blob
/// src/server.ts:286-288 `UiPrefs` unknown-key-preserving
/// API web/prefs GET
/// toggle ** PUT** =
/// - **PUT echo ** sanitize
/// + toggle
/// - **prefs load ** web mountProjects.init
/// clobber
/// - APIClient
/// OpenRequest `Validation.isAbsoluteCwd`deep-link
@MainActor
@Observable
final class ProjectsViewModel {
// MARK: - Observable state
private(set) var projects: [ProjectInfo] = []
private(set) var hasLoadedOnce = false
///
private(set) var fetchErrorMessage: String?
/// prefs / clobber
private(set) var prefsErrorMessage: String?
/// PUT /prefs
private(set) var prefsSyncErrorMessage: String?
/// ""
private(set) var openErrorMessage: String?
///
private(set) var favourites: [String] = []
/// key true web/
private(set) var collapsedGroups: [String: Bool] = [:]
/// T-iOS-26 id onChange
private(set) var openRequest: ProjectOpenRequest?
/// .searchable
var searchText = ""
// MARK: - Dependencies & internal state
let host: HostRegistry.Host
@ObservationIgnored let http: any HTTPTransport
/// prefs blobnil =
/// PUT
@ObservationIgnored private var prefsBase: UiPrefs?
private var client: APIClient {
APIClient(endpoint: host.endpoint, http: http)
}
init(host: HostRegistry.Host, http: any HTTPTransport) {
self.host = host
self.http = http
}
// MARK: - Derived view state
/// web
var groups: [ProjectGroup] {
ProjectGrouping.group(
ProjectGrouping.filter(projects, query: searchText),
favourites: Set(favourites)
)
}
var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// caret web renderGrid
func isCollapsed(_ group: ProjectGroup) -> Bool {
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
}
func isFavourite(_ path: String) -> Bool {
favourites.contains(path)
}
/// vs web renderGrid msg
var emptyStateMessage: String? {
guard hasLoadedOnce, fetchErrorMessage == nil else { return nil }
if projects.isEmpty { return ProjectsCopy.emptyNoProjects }
if ProjectGrouping.filter(projects, query: searchText).isEmpty {
return ProjectsCopy.emptyNoMatch
}
return nil
}
// MARK: - Load / refresh
/// prefs+ prefs web init
func load() async {
await loadPrefsIfNeeded()
await refresh()
}
/// +
func refresh() async {
do {
projects = try await client.projects()
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
fetchErrorMessage = ProjectsCopy.fetchFailed(Self.errorDetail(error))
}
}
private func loadPrefsIfNeeded() async {
guard prefsBase == nil else { return }
do {
let prefs = try await client.prefs()
adopt(prefs)
prefsErrorMessage = nil
} catch {
prefsErrorMessage = ProjectsCopy.prefsLoadFailed
}
}
// MARK: - Favourites / collapse prefs
func toggleFavourite(path: String) async {
favourites = favourites.contains(path)
? favourites.filter { $0 != path }
: favourites + [path]
await persistPrefs()
}
func toggleCollapsed(key: String) async {
if collapsedGroups[key] == true {
collapsedGroups = collapsedGroups.filter { $0.key != key }
} else {
collapsedGroups = collapsedGroups.merging([key: true]) { _, new in new }
}
await persistPrefs()
}
/// blob PUT
/// echoprefs
private func persistPrefs() async {
guard let base = prefsBase else { return }
let next = base.withFavourites(favourites).withCollapsed(collapsedGroups)
prefsBase = next // toggle
do {
let echoed = try await client.putPrefs(next)
adopt(echoed)
prefsSyncErrorMessage = nil
} catch {
prefsSyncErrorMessage = ProjectsCopy.prefsSyncFailed(Self.errorDetail(error))
}
}
private func adopt(_ prefs: UiPrefs) {
prefsBase = prefs
favourites = prefs.favourites
collapsedGroups = prefs.collapsed
}
// MARK: - T-iOS-26
/// `attach(null, cwd)` + attach `claude\r` engine
/// attach-first
func requestOpenClaude(cwd: String) {
guard Validation.isAbsoluteCwd(cwd) else {
openErrorMessage = ProjectsCopy.openClaudeInvalidPath
return
}
openErrorMessage = nil
openRequest = ProjectOpenRequest(
id: UUID(), host: host, cwd: cwd,
bootstrapInput: ProjectLaunch.claudeBootstrapInput
)
}
// MARK: - Detail assembly/
func makeDetailViewModel(path: String) -> ProjectDetailViewModel {
.forHost(endpoint: host.endpoint, http: http, path: path)
}
// MARK: - Helpers
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
}
/// "" `SessionListViewModel.OpenRequest`
/// cwd+bootstrap T-iOS-13
/// AppCoordinator.openProject
struct ProjectOpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
///
let cwd: String
/// attach nil = shell
let bootstrapInput: String?
}
/// public/tabs.ts:679 `openProject` cmd
enum ProjectLaunch {
/// Enter `\r`0x0D `\n` CLAUDE.md Gotchas
static let claudeBootstrapInput = "claude\r"
}
/// plan §4
enum ProjectsCopy {
static let title = "项目"
static let searchPrompt = "筛选项目…"
static let activeGroupLabel = "活跃中"
static let otherGroupLabel = "其他"
static let allGroupLabel = "全部项目"
static let dirtyBadge = "未提交"
static let emptyNoProjects = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
static let emptyNoMatch = "没有匹配的项目。"
static let prefsLoadFailed = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
static let openClaudeInvalidPath = "项目路径无效,无法开新会话。"
static func fetchFailed(_ detail: String) -> String {
"刷新项目列表失败:\(detail)"
}
static func prefsSyncFailed(_ detail: String) -> String {
"收藏/折叠状态同步失败:\(detail)"
}
static func activeCountBadge(_ count: Int) -> String {
"\(count) 活跃"
}
}

View File

@@ -0,0 +1,403 @@
import APIClient
import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol
/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
/// one glance answers "do I need to step in?" status dot / pending badge,
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
/// into `TerminalScreen`.
///
/// Polling: while the screen is visible, `GET /live-sessions` every
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
/// paced on an injected `Clock` (FakeClock in tests zero real waits).
/// `disappeared()` cancels the poll task; the leak test asserts the parked
/// sleeper is reclaimed.
///
/// Documented decisions:
/// - **Pending is an overlay, not a list field**: `LiveSessionInfo`
/// (src/types.ts:246-256) carries NO `pending` that signal only exists on
/// the WS status side-channel (the server's pendingApprovals), exactly like
/// the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
/// The T-iOS-15 wiring forwards gate/status events into
/// `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
/// fetch minus `hiddenKilledIds`, so rollback restores the exact original
/// position, and a poll racing the DELETE cannot resurrect the row. A 404
/// on DELETE means "already gone" and counts as success.
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
/// a blank screen on one dropped packet.
@MainActor
@Observable
final class SessionListViewModel {
// MARK: - Row model
/// What the row leads with: the held-approval badge outranks the dot.
enum StatusIndicator: Equatable, Sendable {
case pendingApproval
case status(ClaudeStatus)
/// Mirrors public/tabs.ts:74-80 `claudeIcon`; for a held approval.
var glyph: String {
switch self {
case .pendingApproval: return ""
case .status(.working): return ""
case .status(.waiting): return ""
case .status(.idle): return ""
case .status(.stuck): return ""
case .status(.unknown): return ""
}
}
}
/// Immutable render snapshot of one session (rebuilt wholesale per fetch).
struct SessionRow: Equatable, Identifiable, Sendable {
let info: LiveSessionInfo
let isPendingApproval: Bool
let telemetry: TelemetryChips.Model?
/// Sanitized OSC title (T-iOS-23) nil = no title, row falls back to
/// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
let title: String?
/// `lastOutputAt` strictly newer than the local last-seen watermark
/// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
let isUnread: Bool
var id: UUID { info.id }
var indicator: StatusIndicator {
isPendingApproval ? .pendingApproval : .status(info.status)
}
}
/// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
/// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
/// means repeated taps re-fire `onChange` observers.
struct OpenRequest: Equatable, Sendable, Identifiable {
let id: UUID
let host: HostRegistry.Host
let sessionId: UUID?
}
enum EmptyState: Equatable {
/// No hosts in the store pairing is the only next step.
case notPaired
/// Paired + fetched successfully, but nothing is running.
case noSessions
}
// MARK: - Observable state
private(set) var hosts: [HostRegistry.Host] = []
private(set) var activeHost: HostRegistry.Host?
private(set) var rows: [SessionRow] = []
/// Last poll failed (stale rows stay visible). Cleared by the next success.
private(set) var fetchErrorMessage: String?
/// Last kill failed (row already rolled back).
private(set) var killErrorMessage: String?
/// Host store read failed explicit, never a silent empty list.
private(set) var hostsErrorMessage: String?
private(set) var openRequest: OpenRequest?
var emptyState: EmptyState? {
if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
return nil
}
// MARK: - Dependencies & internal state (not observed)
@ObservationIgnored private let hostStore: any HostStore
@ObservationIgnored private let http: any HTTPTransport
@ObservationIgnored private let clock: any Clock<Duration>
/// Injected time source for telemetry staleness (ms since epoch).
@ObservationIgnored private let nowMs: @Sendable () -> Int
@ObservationIgnored private let unreadStore: any UnreadWatermarkStore
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
/// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
/// NOT pruned to the active host's fetch other hosts' watermarks must
/// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
@ObservationIgnored private var unreadLedger: UnreadLedger
/// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only replay
/// re-emits the OSC sequence on next attach, persistence would only keep
/// stale titles. Other hosts' entries never match a visible row.
@ObservationIgnored private var sessionTitles: [UUID: String] = [:]
@ObservationIgnored private var hasAttemptedHostLoad = false
@ObservationIgnored private var hasLoadedOnce = false
private var apiClient: APIClient? {
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
}
init(
hostStore: any HostStore,
http: any HTTPTransport,
clock: any Clock<Duration>,
unreadStore: any UnreadWatermarkStore,
nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
) {
self.hostStore = hostStore
self.http = http
self.clock = clock
self.unreadStore = unreadStore
self.nowMs = nowMs
unreadLedger = UnreadLedger(watermarks: unreadStore.load())
}
// MARK: - Visibility lifecycle (poll task owner)
/// Screen became visible: load hosts, then poll every `listPollInterval`.
/// Idempotent a second call while polling is a no-op.
func appeared() {
guard pollTask == nil else { return }
pollTask = Task { [weak self] in
await self?.runPollLoop()
}
}
/// Screen left: cancel the poll task (its parked sleep throws
/// `CancellationError` and the loop exits no leaked timer).
func disappeared() {
pollTask?.cancel()
pollTask = nil
releaseFetchWaiters()
}
private func runPollLoop() async {
await reloadHosts()
while !Task.isCancelled {
await refreshOnce()
do {
try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
} catch {
return // cancelled while parked the only way sleep throws
}
}
}
// MARK: - Hosts (multi-host header hook)
/// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
/// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
/// up renames), else falls back to the first host.
func reloadHosts() async {
do {
hosts = try await hostStore.loadAll()
hostsErrorMessage = nil
} catch {
hosts = []
hostsErrorMessage = SessionListCopy.hostsLoadFailed
}
hasAttemptedHostLoad = true
activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
}
/// Switch the list to another paired host: reset per-host state and fetch
/// immediately (the poll loop keeps its own cadence).
func selectHost(id: UUID) async {
guard let host = hosts.first(where: { $0.id == id }),
host.id != activeHost?.id else { return }
activeHost = host
latestFetched = []
hiddenKilledIds = []
pendingSessionIds = []
hasLoadedOnce = false
fetchErrorMessage = nil
killErrorMessage = nil
rebuildRows()
await refreshOnce()
}
// MARK: - Fetch (poll tick + pull-to-refresh share one path)
/// Manual refresh (pull-to-refresh).
func refresh() async {
await refreshOnce()
}
private func refreshOnce() async {
defer {
completedFetchCount += 1
resumeFetchWaiters()
}
guard let client = apiClient else { return }
do {
let sessions = try await client.liveSessions()
let fetchedIds = Set(sessions.map(\.id))
latestFetched = sessions
// Prune overlays for sessions the server no longer reports.
hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
fetchErrorMessage = nil
hasLoadedOnce = true
} catch {
// Keep the stale rows a blank list on one dropped poll is worse.
fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
}
rebuildRows()
}
// MARK: - Pending overlay (fed by the WS status side-channel, T-iOS-15)
func setPendingApproval(sessionId: UUID, pending: Bool) {
pendingSessionIds = pending
? pendingSessionIds.union([sessionId])
: pendingSessionIds.subtracting([sessionId])
rebuildRows()
}
// MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)
/// The user just looked at (or is about to look at) this session: stamp
/// the last-seen watermark NOW and persist it. Called on row tap
/// (`openSession`) and by the coordinator when a terminal closes output
/// that streamed while the user was watching must not relight the dot.
func markSeen(sessionId: UUID) {
unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
unreadStore.save(unreadLedger.watermarks)
rebuildRows()
}
// MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)
/// Surface a terminal-reported OSC title on the session's list row.
/// `title` is UNTRUSTED (host/attacker-controlled OSC payload) sanitized
/// again at THIS boundary regardless of upstream (sanitize is idempotent).
/// Empty after sanitisation clears the entry (web `title.trim() || null`).
func setSessionTitle(sessionId: UUID, title: String) {
let sanitized = TitleSanitizer.sanitize(title)
sessionTitles = sanitized.isEmpty
? sessionTitles.filter { $0.key != sessionId }
: sessionTitles.merging([sessionId: sanitized]) { _, new in new }
rebuildRows()
}
// MARK: - Swipe-to-kill (optimistic + rollback)
func kill(sessionId: UUID) async {
guard let client = apiClient,
latestFetched.contains(where: { $0.id == sessionId }) else { return }
killErrorMessage = nil
hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
rebuildRows()
do {
try await client.killSession(id: sessionId)
} catch APIClientError.sessionNotFound {
// Already gone (exited/reaped/killed elsewhere) removal stands.
} catch {
hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
rebuildRows()
killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
}
}
// MARK: - Navigation signals
/// "+ New session" `sessionId: nil` (server spawns a fresh PTY).
func requestNewSession() {
guard let activeHost else { return }
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
}
/// Open a listed session. Unknown ids are refused at the boundary.
/// Tapping = seeing: the unread watermark is stamped immediately (the
/// terminal replays everything anyway; the dot must not outlive the tap).
func openSession(id: UUID) {
guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
markSeen(sessionId: id)
openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
}
// MARK: - Row derivation (immutable rebuild, single owner)
private func rebuildRows() {
let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
let now = nowMs()
rows = Self.groupingExitedLast(visible).map { info in
SessionRow(
info: info,
isPendingApproval: pendingSessionIds.contains(info.id),
telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
title: sessionTitles[info.id],
isUnread: unreadLedger.isUnread(
sessionId: info.id, lastOutputAt: info.lastOutputAt
)
)
}
}
/// Keep the server's order (newest-first, src/session/manager.ts:194) but
/// group `exited:true` at the bottom order preserved inside each group.
static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
sessions.filter { !$0.exited } + sessions.filter(\.exited)
}
// MARK: - Error copy helpers
/// `APIClientError` already carries user-facing copy; transport-level
/// errors fall back to their localized description.
private static func errorDetail(_ error: any Error) -> String {
(error as? APIClientError)?.message ?? error.localizedDescription
}
private nonisolated static let millisecondsPerSecond = 1_000.0
nonisolated static func currentTimeMs() -> Int {
Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
}
// MARK: - Deterministic test barriers (same pattern as TerminalViewModel)
/// Completed fetch attempts (successful or not, including no-host ticks).
@ObservationIgnored private(set) var completedFetchCount = 0
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
/// Suspends until at least `count` fetches have completed.
func waitUntilFetches(count target: Int) async {
await withCheckedContinuation { continuation in
guard completedFetchCount < target else {
continuation.resume()
return
}
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeFetchWaiters() {
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseFetchWaiters() {
let all = fetchWaiters
fetchWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}
/// User-facing session-list copy (plan §4: explicit errors, actionable ).
enum SessionListCopy {
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
static func fetchFailed(_ detail: String) -> String {
"刷新会话列表失败:\(detail)"
}
static func killFailed(_ detail: String) -> String {
"结束会话失败:\(detail)"
}
}

View File

@@ -0,0 +1,352 @@
import Foundation
import Observation
import SessionCore
import WireProtocol
/// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's
/// `SessionEvent` stream on the MainActor and turns it into UI state output
/// forwarded to SwiftTerm, connection banner, non-retryable failure copy and
/// the read-only exit state. All outbound traffic (key bar, hardware key
/// commands, SwiftTerm delegate) funnels through here into ONE ordered queue.
///
/// Testability / stream-sharing decision (documented per task brief):
/// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel
/// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream.
/// So the events stream is injected SEPARATELY from the engine: today callers
/// pass `engine.events` verbatim (tests do exactly that, over
/// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch
/// instead without touching this class.
///
/// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is
/// `@MainActor`-typed `feed()` off the main actor cannot compile.
@MainActor
@Observable
final class TerminalViewModel {
// MARK: - UI state model
/// Connection banner state (mirrors the web client's status line:
/// public/terminal-session.ts `SessionStatus`).
enum ConnectionBanner: Equatable {
case none
case connecting
/// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s30s).
case reconnecting(attempt: Int, next: Duration)
}
/// Which terminal the user is looking at: a live one, a dead-for-good one
/// (non-retryable failure), or a finished one (read-only).
enum TerminalPhase: Equatable {
case live
/// Non-retryable terminal failure actionable copy instead of a spinner.
case failed(message: String)
/// The shell exited; the terminal stays readable but accepts no input.
case exited(code: Int, reason: String?)
}
/// Terminal geometry snapshot (Equatable for test assertions; the frozen
/// engine API takes a tuple, so `asTuple` bridges).
struct TerminalDims: Equatable, Sendable {
let cols: Int
let rows: Int
var asTuple: (cols: Int, rows: Int) { (cols, rows) }
}
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
/// coupling warning): reconnecting would deterministically fail forever,
/// so the user must change a knob, not wait.
static let replayTooLargeMessage =
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
/// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
/// Read by the wiring layer for `notifyForegrounded(dims:)` the frozen
/// §3.2 signature needs real cols/rows and this is their single source.
private(set) var lastSentDims: TerminalDims?
private(set) var banner: ConnectionBanner = .none
private(set) var phase: TerminalPhase = .live
/// Server-adopted session id (ALWAYS the server-issued one persisting it
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
private(set) var sessionId: UUID?
/// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
/// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
/// boundary; nil = no title (empty after sanitisation).
private(set) var terminalTitle: String?
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
/// is NOT gated here the engine owns terminal-state dropping.
var isReadOnly: Bool { phase != .live }
/// Single render input for `ReconnectBanner`: terminal phases win over
/// transient connection states.
var bannerModel: ReconnectBanner.Model? {
switch phase {
case .failed(let message):
return .failed(message: message)
case .exited(let code, let reason):
return .exited(code: code, reason: reason)
case .live:
break
}
switch banner {
case .none:
return nil
case .connecting:
return .connecting
case .reconnecting(let attempt, let next):
return .reconnecting(attempt: attempt, retryIn: next)
}
}
// MARK: - Dependencies & plumbing (not observed)
@ObservationIgnored private let engine: SessionEngine
@ObservationIgnored private let events: AsyncStream<SessionEvent>
@ObservationIgnored private var consumeTask: Task<Void, Never>?
/// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
/// feeding off the main actor is a compile error.
@ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
/// Output that arrived before the SwiftTerm view existed; flushed in order
/// the moment the sink attaches (replay must never be dropped).
@ObservationIgnored private var pendingOutput: [String] = []
/// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
/// is async one pump task preserves submission order (two quick key taps
/// must never race each other onto the wire).
@ObservationIgnored private var sendQueue: [ClientMessage] = []
@ObservationIgnored private var isPumping = false
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
/// Events applied so far `waitUntilProcessed` barrier counter.
@ObservationIgnored private(set) var processedEventCount = 0
/// Sends handed to the engine so far `waitUntilForwarded` barrier counter.
@ObservationIgnored private(set) var forwardedSendCount = 0
/// Input dropped because the terminal is read-only (exit/failed).
@ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
// MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)
/// List-side registry hook: fires with the ADOPTED sessionId and the
/// SANITIZED title ("" = title cleared the registry drops the entry).
/// Titles arriving before adoption are held and forwarded once on
/// `.adopted` (defensive `attached` always precedes output on the wire).
@ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
/// Latest sanitized title not yet delivered to `onTitleChanged` because
/// no sessionId was known at the time. nil = nothing held.
@ObservationIgnored private var heldTitleForward: String?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
@ObservationIgnored private var sendWaiters: [CountWaiter] = []
// MARK: - Lifecycle
/// - Parameters:
/// - engine: send-side dependency (`send` only `open`/`close` belong to
/// the T-iOS-15 wiring that constructs the engine).
/// - events: the event stream to consume; pass `engine.events` unless a
/// fan-out branch is needed (see type doc).
init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
self.engine = engine
self.events = events
}
/// Begin consuming events. Idempotent a second call is a no-op (the
/// stream has exactly one consumer).
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters() // stream over never leave a test hanging
}
}
/// Stop consuming (screen torn down). Does NOT close the engine detach
/// vs. keep-running is the T-iOS-15 lifecycle owner's call.
func stop() {
consumeTask?.cancel()
consumeTask = nil
releaseAllWaiters()
}
// MARK: - Terminal output sink
/// Attach the SwiftTerm feed target. Buffered output (anything that
/// arrived before the view existed) flushes immediately, in order.
func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
terminalSink = sink
let buffered = pendingOutput
pendingOutput = []
for chunk in buffered {
sink(chunk)
}
}
// MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)
/// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
/// (the single source of truth plan §7 T-iOS-11).
func send(key: KeyByteMap.Key) {
sendInput(KeyByteMap.bytes(for: key))
}
/// Raw input bytes, verbatim (invariant #9 no content filtering).
/// Dropped (and counted) while the terminal is read-only.
func sendInput(_ data: String) {
guard !isReadOnly else {
droppedReadOnlyInputCount += 1
return
}
enqueueSend(.input(data: data))
}
/// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
/// Sanitize FIRST the raw string is untrusted terminal output then
/// surface locally and forward to the list registry (T-iOS-23).
func setTerminalTitle(_ raw: String) {
let sanitized = TitleSanitizer.sanitize(raw)
terminalTitle = sanitized.isEmpty ? nil : sanitized
guard let sessionId else {
heldTitleForward = sanitized
return
}
onTitleChanged?(sessionId, sanitized)
}
/// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded
/// the engine validates bounds and owns terminal-state dropping. Valid
/// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
/// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes
/// the W4 documented deviation; invalid dims never overwrite a good pair).
func sendResize(cols: Int, rows: Int) {
if Validation.isValidResize(cols: cols, rows: rows) {
lastSentDims = TerminalDims(cols: cols, rows: rows)
}
enqueueSend(.resize(cols: cols, rows: rows))
}
// MARK: - Event application (single consumer, MainActor)
private func apply(_ event: SessionEvent) {
switch event {
case .connection(let state):
applyConnection(state)
case .adopted(let id):
sessionId = id // ALWAYS adopt the server-issued id
if let held = heldTitleForward {
heldTitleForward = nil
onTitleChanged?(id, held)
}
case .output(let data):
deliverOutput(data)
case .exited(let code, let reason):
phase = .exited(code: code, reason: reason)
case .gate, .telemetry, .digest:
break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15)
}
processedEventCount += 1
onEventApplied?(event)
resumeEventWaiters()
}
private func applyConnection(_ state: ConnectionState) {
switch state {
case .connecting:
banner = .connecting
case .connected:
banner = .none
case .reconnecting(let attempt, let next):
banner = .reconnecting(attempt: attempt, next: next)
case .closed:
banner = .none // deliberate end; exit/failure phase (if any) stays
case .failed(.replayTooLarge):
banner = .none
phase = .failed(message: Self.replayTooLargeMessage)
}
}
private func deliverOutput(_ data: String) {
guard let terminalSink else {
pendingOutput = pendingOutput + [data]
return
}
terminalSink(data)
}
// MARK: - Ordered send pump
private func enqueueSend(_ message: ClientMessage) {
sendQueue = sendQueue + [message]
guard !isPumping else { return }
isPumping = true
Task { await self.pumpSendQueue() }
}
private func pumpSendQueue() async {
while let next = sendQueue.first {
sendQueue = Array(sendQueue.dropFirst())
await engine.send(next)
forwardedSendCount += 1
resumeSendWaiters()
}
isPumping = false
}
// MARK: - Deterministic test barriers (no polling, no real sleeps)
/// Suspends until at least `eventCount` events have been applied.
func waitUntilProcessed(eventCount target: Int) async {
await withCheckedContinuation { continuation in
guard processedEventCount < target else {
continuation.resume()
return
}
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
/// Suspends until at least `sendCount` messages were handed to the engine.
func waitUntilForwarded(sendCount target: Int) async {
await withCheckedContinuation { continuation in
guard forwardedSendCount < target else {
continuation.resume()
return
}
sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeEventWaiters() {
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func resumeSendWaiters() {
let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount }
sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseAllWaiters() {
let all = eventWaiters + sendWaiters
eventWaiters = []
sendWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}

View File

@@ -0,0 +1,81 @@
import Foundation
import Observation
import WireProtocol
/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
///
/// One VM per presentation (the container view builds a fresh one on each
/// digesttap), so every open re-fetches `GET /live-sessions/:id/events`.
/// The fetch closure is injected production wraps `APIClient.events` via
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
///
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
/// (malformed / unknown-class entries dropped, non-array `[]`) already
/// happened inside `APIClient.events` `TimelineEvent.decodeList`. This VM
/// only distinguishes the three USER-visible outcomes:
/// - `[]` `.empty` the server replies `[]` both for "no activity yet" and
/// for timeline capture disabled (src/server.ts:589-591); NEVER an error;
/// - thrown fetch `.failed` explicit, retryable (`load()` again);
/// - events `.loaded`, ordered exactly like the web panel.
@MainActor
@Observable
final class TimelineViewModel: Identifiable {
/// Rendering phase an explicit enum so the sheet can never show an
/// error and rows at the same time.
enum Phase: Equatable {
case loading
/// Display-ready rows: capped + newest-first (web parity, see
/// `presentation(for:)`).
case loaded([TimelineEvent])
/// Server returned `[]` (no activity OR timeline disabled host-side).
case empty
/// Fetch failed retryable via `load()`.
case failed
}
/// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
static let maxEvents = 50
/// `.sheet(item:)` identity a fresh VM per presentation.
nonisolated let id = UUID()
private(set) var phase: Phase = .loading
@ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]
init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
self.fetch = fetch
}
/// Assembly seam for the digestentry point: no adopted sessionId
/// yet no sheet (defensive a digest only arrives after `attached`, so
/// the id is normally known). Otherwise the id is passed to `source`
/// verbatim on every load.
static func forSession(
_ sessionId: UUID?,
source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
) -> TimelineViewModel? {
guard let sessionId else { return nil }
return TimelineViewModel(fetch: { try await source(sessionId) })
}
/// Fetch and present. Also thepath: callable again from `.failed`.
func load() async {
phase = .loading
do {
let events = try await fetch()
phase = Self.presentation(for: events)
} catch {
phase = .failed // user-facing copy lives in TimelineSheet
}
}
/// Pure presentation reducer, mirroring the web panel's render() pipeline
/// line for line (public/timeline.ts:174-189): the server returns
/// oldest-first `slice(0, maxEvents)` first, THEN reverse, so the sheet
/// shows the same capped slice newest-first as the web timeline panel.
static func presentation(for events: [TimelineEvent]) -> Phase {
guard !events.isEmpty else { return .empty }
return .loaded(Array(events.prefix(maxEvents).reversed()))
}
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
/// T-iOS-15 · App entry: assemble the production dependency graph once and
/// hand it to the coordinator (Pairing SessionList Terminal). All wiring
/// lives under `Wiring/`; this file stays a thin `@main`.
///
/// T-iOS-21线
/// - `@UIApplicationDelegateAdaptor` `PushAppDelegate`remote-notification
/// UIApplicationDelegateSwiftUI `init`
/// `didFinishLaunching` coordinator
/// delegate
/// - scenePhase `.active` `activate()` +
/// token Allow/Deny
@main
struct WebTermApp: App {
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
@Environment(\.scenePhase) private var scenePhase
@State private var coordinator: AppCoordinator
init() {
let coordinator = AppCoordinator(environment: .production())
_coordinator = State(initialValue: coordinator)
PushAppDelegate.bootstrap = coordinator // didFinishLaunching
}
var body: some Scene {
WindowGroup {
RootView(coordinator: coordinator)
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
pushDelegate.activatePush()
}
}
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// T-iPad-2 · size-class `LayoutPolicy.mode` compact
/// iPhone / iPad Slide Over `StackRootView`
/// regular iPad / / Stage Manager `SplitRootView`
///
///
/// **线**PLAN_IOS_IPAD §5 T-iPad-2
/// - ZStack **** split detail
/// `scenePhase != .active` `PrivacyShadePolicy`
/// split detail stack push
/// - `.task` bootstrap / `.onChange(scenePhase)` / `.onOpenURL` / deep-link
/// alert / add-host sheet / Projects sheet `RootView`
/// compact
///
/// size class ****iPad Slide Over regularcompact
/// UI `terminalController`
/// `AppCoordinator` T-iOS-29 `.id`
///
struct AdaptiveRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ZStack {
layoutBranch
if PrivacyShadePolicy.isShadeVisible(for: scenePhase) {
PrivacyShadeView()
}
}
.task { await coordinator.bootstrap() }
.onChange(of: scenePhase) { _, phase in
coordinator.handleScenePhase(phase)
}
.onOpenURL { coordinator.handleDeepLink(url: $0) } // T-iOS-22
.alert(DeepLinkCopy.hintTitle, isPresented: deepLinkHintBinding) {
Button(DeepLinkCopy.hintConfirm) { coordinator.deepLink.clearHint() }
} message: {
Text(coordinator.deepLink.hintMessage ?? "")
}
.sheet(
isPresented: $coordinator.isAddHostPresented,
onDismiss: { coordinator.addHostDismissed() }
) {
addHostSheet
}
.sheet(
isPresented: $coordinator.isProjectsPresented,
onDismiss: { coordinator.projectsDismissed() }
) {
projectsSheet
}
}
// MARK: - Layout branch (the SOLE size-class consumer)
@ViewBuilder private var layoutBranch: some View {
// Split only makes sense once we're in the session list. `.loading` and
// `.pairing` (genuine iPad first-run, no paired host) get the full-screen
// stack flow regardless of size class a split sidebar has nothing to
// list yet and would strand the user on the not-paired empty state
// (T-iPad-5 finding). Route-gate the split branch.
switch LayoutPolicy.mode(horizontalSizeClass: horizontalSizeClass) {
case .split where coordinator.route == .sessions:
SplitRootView(coordinator: coordinator)
default:
StackRootView(coordinator: coordinator)
}
}
// MARK: - Deep-link hint alert (T-iOS-22)
/// unknown host / store failure `RootView`
private var deepLinkHintBinding: Binding<Bool> {
Binding(
get: { coordinator.deepLink.hintMessage != nil },
set: { presented in
guard !presented else { return }
coordinator.deepLink.clearHint()
}
)
}
// MARK: - Add-host sheet (multi-host entry, list header)
@ViewBuilder private var addHostSheet: some View {
if let viewModel = coordinator.addHostPairingViewModel {
NavigationStack {
PairingScreen(viewModel: viewModel) { host in
coordinator.completeAddHost(host)
}
}
}
}
// MARK: - Projects sheet (T-iOS-26)
/// NavigationStack sheet push
/// "" sheet
@ViewBuilder private var projectsSheet: some View {
if let viewModel = coordinator.projectsViewModel {
NavigationStack {
ProjectsScreen(viewModel: viewModel) { request in
coordinator.openProject(request)
}
}
}
}
}

View File

@@ -0,0 +1,302 @@
import Foundation
import HostRegistry
import Observation
import SwiftUI
import WireProtocol
/// T-iOS-15 · Navigation + lifecycle owner: Pairing SessionList Terminal
/// with the production dependency graph (plan §7 T-iOS-15 step 1).
///
/// - Cold start: read the host store once no paired host Pairing, else
/// SessionList (`ColdStartPolicy.initialRoute`).
/// - `SessionListScreen.onOpen` build ONE `TerminalSessionController`
/// (single foreground session, plan §1) and push the terminal.
/// - Back `closeTerminal()` `engine.close()` (detach; PTY keeps running).
/// - scenePhase is forwarded to the open controller (`.background`
/// suspend/close, `.active` resume/rebuild). The privacy shade is view
/// layer (RootView + PrivacyShadePolicy), not coordinator state.
@MainActor
@Observable
final class AppCoordinator {
private(set) var route: ColdStartPolicy.RootRoute = .loading
private(set) var terminalController: TerminalSessionController?
/// First-run pairing VM (root route). Recreated per entry to keep pairing
/// state machines single-shot.
private(set) var rootPairingViewModel: PairingViewModel?
/// Add-host pairing VM (sheet from the list header).
private(set) var addHostPairingViewModel: PairingViewModel?
var isAddHostPresented = false
/// T-iOS-26 · Projects sheet RootView toolbar
/// `SessionListScreen` W7 T-iOS-23 VM
/// prefs
private(set) var projectsViewModel: ProjectsViewModel?
var isProjectsPresented = false
let sessionList: SessionListViewModel
@ObservationIgnored let environment: AppEnvironment
/// T-iOS-22 · Deep-link handler; all routing/wiring logic lives in
/// DeepLinkRouter.swift (incl. the `makeDeepLinkHandler` extension).
@ObservationIgnored private(set) lazy var deepLink: DeepLinkHandler = makeDeepLinkHandler()
init(environment: AppEnvironment) {
self.environment = environment
sessionList = SessionListViewModel(
hostStore: environment.hostStore,
http: environment.http,
clock: ContinuousClock(),
unreadStore: environment.unreadStore
)
}
// MARK: - Cold start
/// Decide the boot route from the host store. A store READ failure routes
/// to the session list, whose own `reloadHosts` surfaces the explicit
/// error copy (never a silent empty pairing screen hiding a broken store).
func bootstrap() async {
guard route == .loading else { return }
do {
let hosts = try await environment.hostStore.loadAll()
route = ColdStartPolicy.initialRoute(pairedHostCount: hosts.count)
} catch {
route = .sessions
}
if route == .pairing {
rootPairingViewModel = makePairingViewModel()
}
await deepLink.markReady() // flush a cold-launch deep link (T-iOS-22)
}
/// First-run pairing done move to the list (the paired host is already
/// in the store PairingViewModel upserts before signalling).
func completeFirstPairing(_ host: HostRegistry.Host) {
rootPairingViewModel = nil
route = .sessions
Task { await sessionList.reloadHosts() }
}
// MARK: - Add-host sheet (list header hook)
func presentAddHost() {
addHostPairingViewModel = makePairingViewModel()
isAddHostPresented = true
}
func completeAddHost(_ host: HostRegistry.Host) {
isAddHostPresented = false
addHostDismissed()
}
/// Sheet gone (paired OR cancelled): drop the VM and refresh hosts the
/// list VM keeps the active host if it still exists.
func addHostDismissed() {
addHostPairingViewModel = nil
Task { await sessionList.reloadHosts() }
}
// MARK: - Projects (T-iOS-26)
/// Toolbar RootView Projects sheet
func presentProjects() {
guard let host = sessionList.activeHost else { return }
projectsViewModel = ProjectsViewModel(host: host, http: environment.http)
isProjectsPresented = true
}
/// Sheet OR VM
func projectsDismissed() {
projectsViewModel = nil
}
/// "" sheet fresh spawn`attach(null, cwd)`+
/// attach `claude\r` engine attach-first
func openProject(_ request: ProjectOpenRequest) {
guard terminalController == nil else { return } // one foreground session
isProjectsPresented = false
projectsViewModel = nil
startTerminal(
host: request.host, sessionId: nil,
spawnCwd: request.cwd, bootstrapInput: request.bootstrapInput
)
}
// MARK: - Terminal open/close
/// `SessionListScreen.onOpen` (one navigation signal per tap) and the
/// "" banner both land here. `sessionId == nil` = new session.
func open(_ request: SessionListViewModel.OpenRequest) {
guard terminalController == nil else { return } // one foreground session
startTerminal(host: request.host, sessionId: request.sessionId)
}
/// controller T-iOS-26 spawn
private func startTerminal(
host: HostRegistry.Host,
sessionId: UUID?,
spawnCwd: String? = nil,
bootstrapInput: String? = nil
) {
let controller = TerminalSessionController(
host: host,
sessionId: sessionId,
environment: environment,
onPendingChanged: { [weak self] sessionId, pending in
self?.sessionList.setPendingApproval(sessionId: sessionId, pending: pending)
},
onTitleChanged: { [weak self] sessionId, title in
// T-iOS-23 · OSC title list row (already sanitized in the
// VM; the list VM sanitizes once more at its own boundary).
self?.sessionList.setSessionTitle(sessionId: sessionId, title: title)
},
spawnCwd: spawnCwd,
bootstrapInput: bootstrapInput
)
terminalController = controller
controller.start()
}
/// Back navigation popped the terminal: explicit detach. Also the first
/// half of every session SWITCH (single live WS invariant, plan §1):
/// list back-nav and `openDeepLinkedSession` both close here before the
/// next `open` one engine at a time, always closeopen with replay.
func closeTerminal() {
// T-iOS-23 · leaving = seen: stamp the unread watermark for the
// adopted session so output watched in the terminal never relights
// the list dot.
if let sessionId = terminalController?.terminalViewModel.sessionId {
sessionList.markSeen(sessionId: sessionId)
}
terminalController?.teardown()
terminalController = nil
}
/// T-iPad-3 · kill
/// `SessionListViewModel.kill` `APIClient.killSession` Origin
/// detach detail adopted no-op
func killCurrentSession() {
guard let sessionId = terminalController?.terminalViewModel.sessionId else { return }
Task { await sessionList.kill(sessionId: sessionId) }
closeTerminal()
}
// MARK: - Split-view sidebar bridge (T-iPad-2)
/// Detail sidebar getter
/// **adopted** adopted / detail nil
/// stack `terminalController` coordinator
///
var selectedSidebarItem: SidebarItem? {
terminalController?.terminalViewModel.sessionId.map(SidebarItem.session)
}
/// `NavigationSplitView` getter detail setter
/// `open` / `presentProjects`
/// set nil detail
var sidebarSelection: Binding<SidebarItem?> {
Binding(
get: { self.selectedSidebarItem },
set: { item in
guard let item else { return }
self.selectSidebarItem(item)
}
)
}
/// sidebar **** open(id) / open(nil) /
/// presentProjects API沿 WS closeopen
/// new-in-cwd / deep-link `.projects` sheet
func selectSidebarItem(_ item: SidebarItem) {
switch item {
case .session(let sessionId):
switchTerminal(sessionId: sessionId)
case .newSession:
switchTerminal(sessionId: nil)
case .projects:
presentProjects()
}
}
/// WS no-op churn
/// `closeTerminal()` last-seen engine detach
/// `open()`**** activeHost no-op
private func switchTerminal(sessionId: UUID?) {
guard let host = sessionList.activeHost else { return }
if let sessionId, selectedSidebarItem == .session(sessionId) { return }
if terminalController != nil { closeTerminal() }
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// MARK: - "" (T-iOS-29)
/// cwd/live-sessions server-adopted
/// id VM controller `spawnCwd`T-iOS-26
/// fresh-spawn nil
/// ProjectsViewModel engine
var currentTerminalCwd: String? {
guard let controller = terminalController else { return nil }
let fromRows = controller.terminalViewModel.sessionId.flatMap { id in
sessionList.rows.first(where: { $0.id == id })?.info.cwd
}
let candidate = fromRows ?? controller.spawnCwd
return candidate.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
}
/// TerminalScreen exit cwd fresh
/// spawn`attach(null, cwd)` web tabs.ts `newTab()` M6
/// WS `closeTerminal()` last-seen engine
/// detach controllercwd
/// no-op bootstrap " shell"" claude"
func openNewSessionInCurrentCwd() {
guard let controller = terminalController else { return }
let host = controller.host
let cwd = currentTerminalCwd
closeTerminal()
startTerminal(host: host, sessionId: nil, spawnCwd: cwd)
}
// MARK: - "" (cold start step 5)
var continueLastSessionId: UUID? {
ColdStartPolicy.continueLastSessionId(
activeHost: sessionList.activeHost,
store: environment.lastSessionStore
)
}
func openContinueLast() {
guard let host = sessionList.activeHost, let sessionId = continueLastSessionId else {
return
}
open(SessionListViewModel.OpenRequest(id: UUID(), host: host, sessionId: sessionId))
}
// MARK: - scenePhase (plan §7 T-iOS-15 step 3)
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .background:
terminalController?.suspend()
case .active:
terminalController?.resumeIfNeeded()
case .inactive:
break // transient; shade covers it at the view layer
@unknown default:
break
}
}
// MARK: - Helpers
private func makePairingViewModel() -> PairingViewModel {
PairingViewModel(store: environment.hostStore, probe: environment.probe)
}
}
/// T-iPad-2 · split sidebar `AppCoordinator`
/// `.session(id)` == `open(id)``.newSession` == `open(nil)`
/// `.projects` == `presentProjects()`
enum SidebarItem: Hashable {
case session(UUID)
case newSession
case projects
}

View File

@@ -0,0 +1,60 @@
import APIClient
import Foundation
import HostRegistry
import SessionCore
import WireProtocol
/// T-iOS-15 · Production dependency graph (composition root). One immutable
/// value assembled at launch; every screen/VM receives its dependencies from
/// here no ad-hoc URLSession/keychain access anywhere else in the App layer.
///
/// Assembly security audit (task , verified at this single point):
/// - All `G` (state-changing) HTTP goes through `APIClient` (kill via
/// SessionListViewModel's client, probe's kill round-trip inside
/// `runPairingProbe`); `URLSessionHTTPTransport` adds no headers of its own.
/// - Origin is derived solely by `HostEndpoint` (WS: URLSessionTermTransport;
/// HTTP: APIClient's route builder) nothing here hand-assembles one.
/// - Secrets: hosts in `KeychainHostStore`
/// (AfterFirstUnlockThisDeviceOnly, hosted keychain test asserts it);
/// UserDefaults carries only the non-secret per-host lastSessionId.
/// - No debug ATS overrides exist (project.yml declares NO
/// NSAllowsArbitraryLoads in any configuration only the five §5.2 CIDR
/// exceptions), and no isSecureTextEntry-style screenshot hacks are used;
/// `UIScreen.isCaptured` detection is SKIPPED per plan (accepted residual
/// risk, local trust model).
struct AppEnvironment: Sendable {
let hostStore: any HostStore
let lastSessionStore: any LastSessionStore
let http: any HTTPTransport
let termTransport: any TermTransport
/// Injected into `PairingViewModel` production is `runPairingProbe`
/// over the real transports (two-step: RO GET, then WS attach + guarded
/// kill; only runs after the user's explicit confirm, T-iOS-12).
let probe: PairingViewModel.Probe
/// T-iOS-23 · unread last-seen watermarks (non-secret; UserDefaults).
/// `var` + default so the memberwise init stays source-compatible for
/// pre-P1 call sites while tests can inject an in-memory fake.
var unreadStore: any UnreadWatermarkStore = UserDefaultsUnreadWatermarkStore()
static func production() -> AppEnvironment {
let http = URLSessionHTTPTransport()
let termTransport = URLSessionTermTransport()
return AppEnvironment(
hostStore: KeychainHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(),
http: http,
termTransport: termTransport,
probe: { endpoint in
await runPairingProbe(endpoint: endpoint, http: http, ws: termTransport)
}
)
}
/// Away-digest source for a session engine: wraps `APIClient.events` per
/// host (the engine never holds an HTTP client plan §3.2).
func makeEventsSource(endpoint: HostEndpoint)
-> @Sendable (UUID) async throws -> [TimelineEvent] {
let client = APIClient(endpoint: endpoint, http: http)
return { id in try await client.events(id: id) }
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
import HostRegistry
/// T-iOS-15 · Cold-start selection logic, kept pure for unit tests
/// (ColdStartPolicyTests): which root screen boots, and whether the session
/// list highlights "" for the active host.
enum ColdStartPolicy {
enum RootRoute: Equatable {
/// Host store not read yet.
case loading
/// No paired host pairing is the only next step (plan §7 step 1).
case pairing
/// At least one paired host the merged chooser/dashboard list.
case sessions
}
static func initialRoute(pairedHostCount: Int) -> RootRoute {
pairedHostCount == 0 ? .pairing : .sessions
}
/// The "" target for the list's highlight banner: the last
/// server-adopted session persisted for the ACTIVE host (nil = no banner).
/// Persistence side: `SessionActivityBridge` (adopted set, exited
/// cleared), so a returned id was live when last seen.
static func continueLastSessionId(
activeHost: HostRegistry.Host?,
store: any LastSessionStore
) -> UUID? {
guard let activeHost else { return nil }
return store.lastSessionId(host: activeHost.id)
}
}

View File

@@ -0,0 +1,53 @@
/// T-iOS-15 · Broadcast adapter for a single-consumer `AsyncStream`.
///
/// `SessionEngine.events` has exactly ONE consumer (engine contract), but the
/// wiring needs three: `TerminalViewModel` (output/connection/exit),
/// `GateViewModel` (gate/digest) and `SessionActivityBridge` (adopted /
/// pending- / last-session persistence). Both VMs were designed for this
/// their `events` parameter is injected separately from the engine precisely
/// so a fan-out branch can be passed instead (their type docs say so).
///
/// Shape: `branchCount` child streams are created up front (immutable `let`s
/// no late subscription, so no missed-element semantics to define); ONE pump
/// task consumes the source and yields to every branch in order. AsyncStream's
/// default unbounded buffering means a slow branch never drops or blocks the
/// others. Source finish which `SessionEngine.close()` guarantees finishes
/// every branch; `cancel()` is the teardown belt-and-braces for rebuilds.
final class EventFanOut<Element: Sendable>: Sendable {
let branches: [AsyncStream<Element>]
private let continuations: [AsyncStream<Element>.Continuation]
private let pumpTask: Task<Void, Never>
init(source: AsyncStream<Element>, branchCount: Int) {
var branches: [AsyncStream<Element>] = []
var continuations: [AsyncStream<Element>.Continuation] = []
for _ in 0..<branchCount {
let (stream, continuation) = AsyncStream<Element>.makeStream()
branches.append(stream)
continuations.append(continuation)
}
self.branches = branches
self.continuations = continuations
let sinks = continuations
pumpTask = Task {
for await element in source {
for sink in sinks {
sink.yield(element)
}
}
for sink in sinks {
sink.finish()
}
}
}
/// Stop pumping and finish every branch immediately (idempotent finish
/// on a finished continuation is a no-op). Used when a suspended terminal
/// stack is torn down and rebuilt.
func cancel() {
pumpTask.cancel()
for sink in continuations {
sink.finish()
}
}
}

View File

@@ -0,0 +1,22 @@
import SwiftUI
/// T-iPad-2 ·
public enum LayoutMode: Equatable {
/// iPhone `NavigationStack` push
case stack
/// iPad `NavigationSplitView`sidebar + detail
case split
}
/// T-iPad-2 · **** size-class 仿 `PrivacyShadePolicy`
/// `if sizeClass == `PLAN_IOS_IPAD §4
/// 100%
///
/// regular iPad / / Stage Manager `.split`
/// compact niliPhone / iPad Slide Over / / size class
/// `.stack`nil 退退 iPhone
public enum LayoutPolicy {
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode {
horizontalSizeClass == .regular ? .split : .stack
}
}

View File

@@ -0,0 +1,70 @@
import SwiftUI
/// T-iOS-15 · Privacy shade (security-critical, plan §7 T-iOS-15).
///
/// iOS writes the app-switcher snapshot to DISK the moment the scene reaches
/// `.background` but the switcher is usually ENTERED at `.inactive`. The
/// shade therefore covers whenever `scenePhase != .active` (the exact rule;
/// `.inactive`-only would let the `.background` snapshot capture terminal
/// content API keys, tokens, source into the on-disk switcher image).
/// Restored on `.active`.
///
/// Pure mapping split from the view so the rule is unit-testable for all
/// three phases (PrivacyShadeTests).
enum PrivacyShadePolicy {
static func isShadeVisible(for scenePhase: ScenePhase) -> Bool {
scenePhase != .active
}
}
/// Opaque cover rendered ABOVE the whole navigation tree (RootView ZStack)
/// deliberately no animation: the snapshot moment must never race a fade.
///
/// Security invariant (do NOT weaken): the FULL-SCREEN base is `DS.Palette.surface`
/// (`.systemBackground`, fully OPAQUE) + `.ignoresSafeArea()`, so no terminal
/// byte can leak into the on-disk switcher snapshot. The branded lockup is a
/// `.regularMaterial` card layered ON TOP of that opaque base (never over the
/// terminal), so the material's translucency is purely decorative it blurs
/// the opaque surface below it, not the PTY content. Accent lock + app name +
/// hint give the shade a refined-native identity instead of a flat cover.
///
/// Scope note (documented): sheets present in a separate presentation layer a
/// root overlay cannot cover but no terminal bytes are ever rendered in a
/// sheet (pairing / plan-gate only), so the root-level shade covers every
/// surface that shows PTY content.
struct PrivacyShadeView: View {
var body: some View {
ZStack {
// OPAQUE full-screen occlusion the security base. Must stay opaque
// and edge-to-edge; the material card below only decorates it.
DS.Palette.surface
.ignoresSafeArea()
VStack(spacing: DS.Space.md12) {
Image(systemName: "lock.fill")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
Text(ShadeCopy.appName)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
Text(ShadeCopy.hint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
.padding(.horizontal, DS.Space.xxl24)
.padding(.vertical, DS.Space.xl20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.lg16))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.lg16)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
}
.accessibilityHidden(true)
}
}
/// Chinese, named constants
private enum ShadeCopy {
static let appName = "WebTerm"
static let hint = "内容已隐藏"
}

View File

@@ -0,0 +1,181 @@
import HostRegistry
import SwiftUI
/// T-iOS-15 / T-iPad-2 · App `NavigationStack` +
/// + sheets/scenePhase/deepLink 线iPad T-iPad-2
///
/// - `RootView` struct `@main` WebTermApp
/// `RootView(coordinator:)` `AdaptiveRootView`
/// accent tint`DS.Palette.accent`DS
/// App //`.borderedProminent`
/// - `AdaptiveRootView` `horizontalSizeClass` `StackRootView`compact
/// `SplitRootView`regulariPad
/// / scenePhase / deepLink / sheets **线**
/// ZStack
///
/// `StackRootView` `RootView` body **** compact
/// iPhone
/// `DS.*` token //
struct RootView: View {
@Bindable var coordinator: AppCoordinator
var body: some View {
AdaptiveRootView(coordinator: coordinator)
// DS tint Tokens.swift
// gate amber orange `.tint`
.tint(DS.Palette.accent)
// web DEFAULT_SETTINGS.theme = 'dark'
// --bg #100F0D
// accent/status
.preferredColorScheme(.dark)
}
}
/// T-iPad-2 · compact iPhone / iPad Slide Over
/// `RootView` `NavigationStack` 线
/// /scenePhase/deepLink/sheets `AdaptiveRootView`
struct StackRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
NavigationStack {
rootContent
.navigationDestination(isPresented: terminalBinding) {
terminalDestination
}
}
}
// MARK: - Route switch
@ViewBuilder private var rootContent: some View {
switch coordinator.route {
case .loading:
ProgressView()
case .pairing:
firstRunPairing
case .sessions:
sessionList
}
}
@ViewBuilder private var firstRunPairing: some View {
if let viewModel = coordinator.rootPairingViewModel {
PairingScreen(viewModel: viewModel) { host in
coordinator.completeFirstPairing(host)
}
} else {
ProgressView()
}
}
private var sessionList: some View {
SessionListScreen(
viewModel: coordinator.sessionList,
onOpen: { coordinator.open($0) },
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
// / DS reduceMotion
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: coordinator.continueLastSessionId
)
// T-iOS-26 · Projects RootView SessionListScreen
// T-iOS-23 W7 leading
// topBarTrailing hostMenu
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
}
// MARK: - "" highlight (cold start step 5)
@ViewBuilder private var continueLastBanner: some View {
if coordinator.continueLastSessionId != nil {
ContinueLastBanner { coordinator.openContinueLast() }
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// MARK: - Terminal push
private var terminalBinding: Binding<Bool> {
Binding(
get: { coordinator.terminalController != nil },
set: { presented in
guard !presented else { return }
coordinator.closeTerminal() // back engine.close() (detach)
}
)
}
@ViewBuilder private var terminalDestination: some View {
if let controller = coordinator.terminalController {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
)
// T-iOS-29 · identity PER CONTROLLER: an in-place session switch
// (new-in-cwd, deep link) swaps the controller while the
// destination stays presented without a new identity SwiftUI
// keeps the old SwiftTerm UIView and the new ViewModel's output
// sink never attaches (makeUIView never re-runs). Also resets the
// container's per-session @State (plan-gate dismissal, timeline).
.id(controller.id)
}
}
}
// MARK: - Continue-last banner (shared stack + split, DRY)
/// 5 CTA stack split sidebar
/// DRY`.regularMaterial` + +
/// accent `DSButtonStyle(.primary)`
/// `action`= `coordinator.openContinueLast()`
struct ContinueLastBanner: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("root.continueLastButton")
.padding(.horizontal, DS.Space.lg16)
.padding(.top, DS.Space.md12)
.padding(.bottom, DS.Space.sm8)
.background(.regularMaterial)
.overlay(alignment: .top) {
Rectangle()
.fill(DS.Palette.hairline)
.frame(height: DS.Stroke.hairline)
}
}
}
// MARK: - Projects toolbar item (shared stack + split, DRY)
/// leading stack split disabled
/// `presentProjects` a11y id tint label accent
struct ProjectsToolbarItem: ToolbarContent {
@Bindable var coordinator: AppCoordinator
var body: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
}
}
/// internal `SplitRootView` /DRY
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"
}

View File

@@ -0,0 +1,129 @@
import Foundation
import HostRegistry
import SessionCore
/// T-iOS-15 · Third fan-out branch: session-level side effects that belong to
/// neither terminal nor gate UI.
///
/// - `.adopted` remember the SERVER-issued id (always adopt it attaching
/// an unknown UUID yields a brand-new session) and persist it per host via
/// `LastSessionStore` (TerminalViewModel's doc assigns this persistence to
/// the T-iOS-15 wiring). This id is also what a backgroundforeground
/// rebuild re-attaches to.
/// - `.gate` forward pending (gate != nil) into
/// `SessionListViewModel.setPendingApproval` the overlay hook the list
/// VM documents ("The T-iOS-15 wiring forwards gate/status events into
/// setPendingApproval"). Gates arriving before adoption have no session to
/// attribute and are dropped (cannot happen on the real wire: `attached` is
/// always first defensive only).
/// - `.exited` clear the overlay AND the persisted lastSessionId: a dead
/// session must not stay flagged nor be offered as "" (documented
/// decision otherwise cold start would silently spawn a NEW session via
/// the unknown-UUID attach path).
///
/// A detach (stream finish without exit) deliberately KEEPS the overlay:
/// the server still holds the gate; the list poll prunes it if the session
/// disappears.
@MainActor
final class SessionActivityBridge {
private(set) var adoptedSessionId: UUID?
private let events: AsyncStream<SessionEvent>
private let hostId: UUID
private let lastSessionStore: any LastSessionStore
private let onPendingChanged: @MainActor (UUID, Bool) -> Void
private var consumeTask: Task<Void, Never>?
// Deterministic test barrier (same pattern as the W3 ViewModels).
private(set) var processedEventCount = 0
private var eventWaiters: [CountWaiter] = []
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
init(
events: AsyncStream<SessionEvent>,
hostId: UUID,
lastSessionStore: any LastSessionStore,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
) {
self.events = events
self.hostId = hostId
self.lastSessionStore = lastSessionStore
self.onPendingChanged = onPendingChanged
}
/// Begin consuming. Idempotent the branch has exactly one consumer.
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters()
}
}
func stop() {
consumeTask?.cancel()
consumeTask = nil
releaseAllWaiters()
}
// MARK: - Event application
private func apply(_ event: SessionEvent) {
switch event {
case .adopted(let sessionId):
adoptedSessionId = sessionId
lastSessionStore.setLastSessionId(sessionId, host: hostId)
case .gate(let gate):
forwardPending(gate != nil)
case .exited:
forwardPending(false)
lastSessionStore.setLastSessionId(nil, host: hostId)
case .connection, .output, .telemetry, .digest:
break // terminal / gate UI domain
}
processedEventCount += 1
resumeEventWaiters()
}
private func forwardPending(_ pending: Bool) {
guard let adoptedSessionId else { return }
onPendingChanged(adoptedSessionId, pending)
}
// MARK: - Test barrier
/// Suspends until at least `eventCount` events have been applied.
func waitUntilProcessed(eventCount target: Int) async {
await withCheckedContinuation { continuation in
guard processedEventCount < target else {
continuation.resume()
return
}
eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeEventWaiters() {
let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseAllWaiters() {
let all = eventWaiters
eventWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}

View File

@@ -0,0 +1,104 @@
import SwiftUI
/// T-iPad-2 · regular iPad / / Stage Manager
/// sidebar = `SessionListScreen` **** +
/// stack leading detail = `TerminalContainerView`
/// + gate/digest
///
///
/// - **detail `TerminalContainerView`** push destination
/// split detail PLAN_IOS_IPAD §1
/// - ** `AppCoordinator` **sidebar
/// `selectSidebarItem` / `open` stack
/// `coordinator.sidebarSelection` highlight sidebar List
/// `selection:``SessionListScreen` List Owns
/// - **detail `.id(controller.id)`** controller
/// SwiftTerm ring bufferidentity T-iOS-29
/// - **** `AdaptiveRootView` ZStack
/// detail `scenePhase != .active`
/// - ****// `DS.*` token Primitive
/// stack
struct SplitRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
NavigationSplitView {
sidebar
} detail: {
detail
}
}
// MARK: - Sidebar + SessionListScreen
private var sidebar: some View {
SessionListScreen(
viewModel: coordinator.sessionList,
onOpen: { request in
// selectSidebarItem
coordinator.selectSidebarItem(sidebarItem(for: request))
},
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: coordinator.continueLastSessionId
)
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
}
/// stack 5
/// `ContinueLastBanner`iPad T-iPad-5 finding
@ViewBuilder private var continueLastBanner: some View {
if coordinator.continueLastSessionId != nil {
ContinueLastBanner { coordinator.openContinueLast() }
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
/// `OpenRequest` sidebar sessionId = /
private func sidebarItem(for request: SessionListViewModel.OpenRequest) -> SidebarItem {
request.sessionId.map(SidebarItem.session) ?? .newSession
}
// MARK: - Detail
@ViewBuilder private var detail: some View {
if let controller = coordinator.terminalController {
NavigationStack {
TerminalContainerView(
controller: controller,
onNewSessionInCwd: { coordinator.openNewSessionInCurrentCwd() },
onKillSession: { coordinator.killCurrentSession() } // T-iPad-3
)
.id(controller.id) // T-iOS-29 · identity per controller
}
} else {
placeholder
.transition(.opacity)
}
}
/// detail accent + DS ContentUnavailableView
/// + + accent App
private var placeholder: some View {
ContentUnavailableView {
Label {
Text(SplitCopy.placeholderTitle)
} icon: {
Image(systemName: "terminal")
.foregroundStyle(DS.Palette.accent)
}
} description: {
Text(SplitCopy.placeholderHint)
}
}
}
/// split / `RootCopy`DRY
private enum SplitCopy {
static let placeholderTitle = "选择或新建会话"
static let placeholderHint = "从左侧选择一个运行中的会话,或新建一个开始工作。"
}

View File

@@ -0,0 +1,183 @@
import SessionCore
import SwiftUI
/// T-iOS-15 · Terminal destination view: TerminalScreen (T-iOS-11) plus the
/// gate/digest surfaces (T-iOS-14) wired to the controller's GateViewModel
/// the " TerminalScreen wiring T-iOS-15" hand-off.
///
/// Layout decisions (documented):
/// - `.id(controller.generation)`: a rebuild (backgroundforeground) swaps the
/// ViewModels; without a new identity SwiftUI would keep the old UIView and
/// `makeUIView` (where the output sink attaches) would never run again.
/// - Digest + tool-gate banner live in a TOP overlay stack: the bottom edge
/// belongs to the keyboard + key bar. TerminalScreen's own ReconnectBanner
/// is also top-aligned, but it hides on `.connected` while gates/digests
/// only arrive connected practical overlap is nil.
/// - Plan gate presents as a medium-detent sheet; swiping it away is allowed
/// (the user may need to scroll the terminal to read the plan), and a
/// "" re-entry chip appears until the gate resolves. Deciding
/// removes the gate server-side `planGate` goes nil sheet dismisses.
struct TerminalContainerView: View {
let controller: TerminalSessionController
/// T-iOS-29 · pass-through to TerminalScreen's toolbar/exit-banner
/// "" action (RootView supplies the coordinator hop).
var onNewSessionInCwd: (@MainActor () -> Void)? = nil
/// T-iPad-3 · pass-through to TerminalScreen's pointer context-menu
/// "" action (root layers supply the coordinator hop). nil on
/// surfaces without a kill affordance (e.g. previews).
var onKillSession: (@MainActor () -> Void)? = nil
/// Epoch of a plan gate the user swiped away suppresses re-present for
/// THAT gate only; a new epoch re-presents automatically.
@State private var dismissedPlanGateEpoch: Int?
/// T-iOS-24 (additive) · Non-nil while the full-timeline sheet is up; a
/// FRESH VM per presentation (each open re-fetches /events).
@State private var timelineViewModel: TimelineViewModel?
/// T-iOS-25 (additive) · Quick-reply palette store per-container over
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
@State private var quickReplyStore = QuickReplyStore()
/// overlay DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private enum Metrics {
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone: a
/// hit-target-tall pill plus a gap. (They rarely co-exist the banner
/// hides on `.connected` while gates/digests only arrive connected.)
static let overlayTopPadding: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
}
private enum Copy {
/// The re-entry chip's own amber StatusBadge carries the "needs me"
/// warning shape, so the copy itself stays plain (no emoji).
static let planGatePending = "计划待批准"
}
var body: some View {
TerminalScreen(
viewModel: controller.terminalViewModel,
onNewSessionInCwd: onNewSessionInCwd,
onKillSession: onKillSession
)
.id(controller.generation)
.navigationBarTitleDisplayMode(.inline)
.overlay(alignment: .top) { topOverlays }
.overlay(alignment: .bottom) { quickReplyOverlay }
.sheet(isPresented: planGateBinding) { planGateSheet }
.sheet(item: $timelineViewModel) { TimelineSheet(viewModel: $0) }
}
// MARK: - Quick-reply chips (T-iOS-25, additive)
/// Chips float at the bottom edge (above the keyboard's safe area) ONLY
/// while a gate is waiting visibility/read-only logic lives in
/// `QuickReplyBar.isVisible`, driven by the SAME fan-out branches the two
/// VMs already consume (no extra branch needed).
@ViewBuilder private var quickReplyOverlay: some View {
QuickReplyBar(
terminalViewModel: controller.terminalViewModel,
gateViewModel: controller.gateViewModel,
store: quickReplyStore
)
.padding(.horizontal, DS.Space.md12)
.padding(.bottom, DS.Space.sm8)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: controller.gateViewModel.currentGate
)
}
// MARK: - Digest + tool gate (top stack)
@ViewBuilder private var topOverlays: some View {
let gateViewModel = controller.gateViewModel
VStack(spacing: DS.Space.sm8) {
if let digest = gateViewModel.digest {
AwayDigestView(
digest: digest,
isExpanded: gateViewModel.isDigestExpanded,
onExpand: { expandDigestAndPresentTimeline() },
onDismiss: { gateViewModel.dismissDigest() }
)
}
if let gate = gateViewModel.toolGate {
GateBanner(gate: gate) { affordance, epoch in
gateViewModel.decide(affordance, epoch: epoch)
}
}
if hasDismissedPendingPlanGate {
planGatePendingChip
}
}
.padding(.horizontal, DS.Space.md12)
.padding(.top, Metrics.overlayTopPadding)
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: gateViewModel.toolGate)
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: gateViewModel.digest)
}
/// The "plan still pending" re-entry chip shown after the user swiped the
/// plan sheet away an amber pending-approval pill that re-presents the
/// sheet on tap.
private var planGatePendingChip: some View {
Button {
dismissedPlanGateEpoch = nil
} label: {
HStack(spacing: DS.Space.xs4) {
StatusBadge(status: .pendingApproval)
Text(Copy.planGatePending)
.font(DS.Typography.callout.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
}
.padding(.horizontal, DS.Space.md12)
.frame(minHeight: DS.Layout.minHitTarget)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.statusWaiting, lineWidth: DS.Stroke.hairline)
)
}
.buttonStyle(.plain)
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// The digestaffordance does BOTH: inline expansion (which cancels
/// the auto-fade, T-iOS-14 semantics the digest must survive under the
/// sheet) and the full-timeline sheet. No adopted sessionId yet
/// `forSession` returns nil inline expand only (defensive; a digest
/// only ever arrives after `attached`).
private func expandDigestAndPresentTimeline() {
controller.gateViewModel.expandDigest()
timelineViewModel = TimelineViewModel.forSession(
controller.terminalViewModel.sessionId,
source: controller.timelineEventsSource
)
}
// MARK: - Plan gate sheet
private var hasDismissedPendingPlanGate: Bool {
guard let gate = controller.gateViewModel.planGate else { return false }
return gate.epoch == dismissedPlanGateEpoch
}
private var planGateBinding: Binding<Bool> {
Binding(
get: {
guard let gate = controller.gateViewModel.planGate else { return false }
return gate.epoch != dismissedPlanGateEpoch
},
set: { presented in
guard !presented else { return }
dismissedPlanGateEpoch = controller.gateViewModel.planGate?.epoch
}
)
}
@ViewBuilder private var planGateSheet: some View {
if let gate = controller.gateViewModel.planGate {
PlanGateSheet(gate: gate) { affordance, epoch in
controller.gateViewModel.decide(affordance, epoch: epoch)
}
}
}
}

View File

@@ -0,0 +1,255 @@
import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol
/// T-iOS-15 · One open terminal = one controller: builds and owns the
/// per-session stack (engine fan-out TerminalViewModel + GateViewModel +
/// SessionActivityBridge) and drives the scenePhase lifecycle.
///
/// Lifecycle (plan §7 T-iOS-15):
/// - `.background` `suspend()`: `engine.close()` a clean detach (the
/// server-side PTY keeps running), never a half-dead socket that iOS will
/// kill mid-suspend anyway.
/// - `.active` after a suspend `resumeIfNeeded()`: `close()` is terminal for
/// an engine, so the stack is REBUILT and re-opened against the
/// server-adopted sessionId. The fresh SwiftTerm view (new `generation`
/// new UIView) replays the full ring buffer and fires `sizeChanged` on first
/// layout that resize is the latest-writer-wins full-screen reclaim
/// (), immediately after the attach.
/// - `.active` with the engine still alive (transient `.inactive`, e.g.
/// control center / app switcher peek): `engine.notifyForegrounded(dims:)`
/// with `TerminalViewModel.lastSentDims` reconnect-if-needed + resize
/// reclaim (latest-writer-wins). Skipped only when no valid dims were ever
/// sent (SwiftTerm has not laid out yet its first `sizeChanged` covers
/// it). The W4 documented deviation is CLOSED (orchestrator added the
/// `lastSentDims` accessor on behalf of the T-iOS-11 owner).
@MainActor
@Observable
final class TerminalSessionController: Identifiable {
let id = UUID()
/// Bumped on every rebuild; the container view uses it as the SwiftUI
/// identity of `TerminalScreen`, forcing a fresh `makeUIView` so the new
/// ViewModel's output sink actually attaches to a new SwiftTerm view.
private(set) var generation = 0
private(set) var terminalViewModel: TerminalViewModel
private(set) var gateViewModel: GateViewModel
/// T-iOS-29 · read-only exposure for the coordinator's new-in-cwd flow
/// (the host to reopen against a controller is host-bound for life).
@ObservationIgnored let host: HostRegistry.Host
@ObservationIgnored private let environment: AppEnvironment
@ObservationIgnored private let onPendingChanged: @MainActor (UUID, Bool) -> Void
/// T-iOS-23 · OSC-title outlet (adopted sessionId + SANITIZED title)
/// re-attached to every rebuilt TerminalViewModel so a background
/// foreground rebuild never silently drops the list-title feed.
@ObservationIgnored private let onTitleChanged: @MainActor (UUID, String) -> Void
/// T-iOS-26 · fresh-spawn `attach(null, cwd)`
/// sessionId nil cwd
/// T-iOS-29 coordinator fresh spawn
/// new-in-cwd cwd 退
@ObservationIgnored let spawnCwd: String?
/// T-iOS-26 · attach `claude\r`engine
/// attach-first attach 线 id
/// suspendresume
@ObservationIgnored private let bootstrapInput: String?
/// open(+bootstrap) ProjectOpenWiringTests
@ObservationIgnored private(set) var openTask: Task<Void, Never>?
@ObservationIgnored private var engine: SessionEngine
@ObservationIgnored private var fanOut: EventFanOut<SessionEvent>
@ObservationIgnored private var bridge: SessionActivityBridge
/// What the next (re)open attaches to: the server-adopted id once known,
/// else the list's requested id (nil = new session).
@ObservationIgnored private var targetSessionId: UUID?
@ObservationIgnored private var isSuspended = false
@ObservationIgnored private var hasStarted = false
@ObservationIgnored private var isTornDown = false
/// Fan-out branch order (single place, no magic indices).
private enum Branch {
static let terminal = 0
static let gate = 1
static let activity = 2
static let count = 3
}
init(
host: HostRegistry.Host,
sessionId: UUID?,
environment: AppEnvironment,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void,
onTitleChanged: @escaping @MainActor (UUID, String) -> Void = { _, _ in },
spawnCwd: String? = nil,
bootstrapInput: String? = nil
) {
self.host = host
self.environment = environment
self.onPendingChanged = onPendingChanged
self.onTitleChanged = onTitleChanged
self.spawnCwd = spawnCwd
self.bootstrapInput = bootstrapInput
self.targetSessionId = sessionId
let stack = Self.makeStack(
host: host, environment: environment, onPendingChanged: onPendingChanged
)
engine = stack.engine
fanOut = stack.fanOut
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged
}
// MARK: - Lifecycle entry points
/// Kick off: consumers first, then the engine connect (events buffer
/// unbounded, so this order is belt-and-braces, not a race fix).
func start() {
guard !hasStarted, !isTornDown else { return }
hasStarted = true
startConsumers()
openEngineAtTarget()
}
/// scenePhase `.background`: clean detach (PTY keeps running).
func suspend() {
guard hasStarted, !isSuspended, !isTornDown else { return }
isSuspended = true
rememberAdoptedSession()
stopConsumers()
let engine = engine
Task { await engine.close() }
}
/// scenePhase `.active`: rebuild-and-reopen if we suspended; with the
/// engine still alive, notifyForegrounded (reconnect + size reclaim).
func resumeIfNeeded() {
guard !isTornDown else { return }
guard isSuspended else {
notifyForegroundedIfPossible()
return
}
isSuspended = false
rebuildStack()
startConsumers()
openEngineAtTarget()
}
/// (Re)open attachfresh spawn id
/// nil cwd attach + attach bootstrapT-iOS-26suspend
/// id bootstrap
private func openEngineAtTarget() {
let engine = engine
let sessionId = targetSessionId
let cwd = sessionId == nil ? spawnCwd : nil
let bootstrap = sessionId == nil ? bootstrapInput : nil
openTask = Task {
await engine.open(sessionId: sessionId, cwd: cwd)
if let bootstrap {
await engine.send(.input(data: bootstrap))
}
}
}
/// Alive-engine `.active` hop: feed the engine the last VALID dims the
/// terminal reported so it reconnects (if dropped during `.inactive`) and
/// re-stamps the PTY size (latest-writer-wins reclaims full screen).
private func notifyForegroundedIfPossible() {
guard hasStarted, let dims = terminalViewModel.lastSentDims else { return }
let engine = engine
Task { await engine.notifyForegrounded(dims: dims.asTuple) }
}
/// Back navigation / screen dismissed: detach for good. Idempotent.
func teardown() {
guard !isTornDown else { return }
isTornDown = true
rememberAdoptedSession()
stopConsumers()
let engine = engine
Task { await engine.close() }
}
// MARK: - Timeline drill-down (T-iOS-24, additive)
/// Per-host timeline fetcher for the container's `TimelineSheet` the
/// same `APIClient.events` wrapper the engine's away-digest uses; the
/// single derivation point stays `AppEnvironment.makeEventsSource`.
var timelineEventsSource: @Sendable (UUID) async throws -> [TimelineEvent] {
environment.makeEventsSource(endpoint: host.endpoint)
}
// MARK: - Stack assembly
private struct Stack {
let engine: SessionEngine
let fanOut: EventFanOut<SessionEvent>
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let bridge: SessionActivityBridge
}
private static func makeStack(
host: HostRegistry.Host,
environment: AppEnvironment,
onPendingChanged: @escaping @MainActor (UUID, Bool) -> Void
) -> Stack {
let engine = SessionEngine(
transport: environment.termTransport,
clock: ContinuousClock(),
endpoint: host.endpoint,
eventsSource: environment.makeEventsSource(endpoint: host.endpoint)
)
let fanOut = EventFanOut(source: engine.events, branchCount: Branch.count)
return Stack(
engine: engine,
fanOut: fanOut,
terminalViewModel: TerminalViewModel(
engine: engine, events: fanOut.branches[Branch.terminal]
),
gateViewModel: GateViewModel(
engine: engine, events: fanOut.branches[Branch.gate],
haptics: GateHaptics(), clock: ContinuousClock()
),
bridge: SessionActivityBridge(
events: fanOut.branches[Branch.activity], hostId: host.id,
lastSessionStore: environment.lastSessionStore,
onPendingChanged: onPendingChanged
)
)
}
private func rebuildStack() {
fanOut.cancel()
let stack = Self.makeStack(
host: host, environment: environment, onPendingChanged: onPendingChanged
)
engine = stack.engine
fanOut = stack.fanOut
terminalViewModel = stack.terminalViewModel
gateViewModel = stack.gateViewModel
bridge = stack.bridge
terminalViewModel.onTitleChanged = onTitleChanged // rebuilt VM re-wired
generation += 1 // new SwiftUI identity fresh SwiftTerm view
}
private func startConsumers() {
terminalViewModel.start()
gateViewModel.start()
bridge.start()
}
private func stopConsumers() {
terminalViewModel.stop()
gateViewModel.stop()
bridge.stop()
}
/// The reopen target: always prefer the server-adopted id (it may differ
/// from what was requested unknown UUIDs mint new sessions).
private func rememberAdoptedSession() {
targetSessionId = bridge.adoptedSessionId ?? targetSessionId
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
import WireProtocol
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
/// the URLSession wrapper for the production side; no package owns it, so the
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
/// every request including the Origin-iff-G rule (plan §3.4 ) and this
/// type only performs the exchange. Adding ANY header/URL logic here would
/// bypass that single audited point (review CRITICAL).
struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
/// include `/live-sessions/:id/preview` raw terminal ring-buffer bytes
/// that may contain printed secrets and `.shared`'s default URLCache
/// writes responses to disk. Ephemeral keeps them memory-only, matching
/// the WS transport and the privacy-shade posture.
init(session: URLSession = URLSession(configuration: .ephemeral)) {
self.session = session
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
// http(s)-only endpoints (HostEndpoint validates) always produce
// an HTTPURLResponse; anything else is a transport-level anomaly.
throw URLError(.badServerResponse)
}
return (data, httpResponse)
}
}

View File

@@ -0,0 +1,44 @@
import Foundation
/// T-iOS-23 · Persistence seam for `SessionCore.UnreadLedger` watermarks.
/// The ledger itself is persistence-agnostic (plan §7) the App layer wires
/// UserDefaults here. NON-SECRET UI state only (plan §5.3 split: Keychain =
/// secrets, UserDefaults = prefs), same tier as `LastSessionStore`.
protocol UnreadWatermarkStore: Sendable {
func load() -> [UUID: Int]
func save(_ watermarks: [UUID: Int])
}
/// UserDefaults-backed implementation with an injectable suite for tests
/// (mirrors `UserDefaultsLastSessionStore`). `@unchecked Sendable`:
/// `UserDefaults` is documented thread-safe and this wrapper adds no mutable
/// state of its own.
struct UserDefaultsUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
private static let key = "unreadWatermarks"
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func load() -> [UUID: Int] {
guard let raw = defaults.dictionary(forKey: Self.key) as? [String: Int] else {
return [:]
}
// Stored data crosses a storage boundary validate every key; garbage
// is dropped, never trusted (two case-variant spellings of one UUID
// collapse via max, so this can never crash on duplicates).
let pairs = raw.compactMap { key, value in
UUID(uuidString: key).map { ($0, value) }
}
return Dictionary(pairs, uniquingKeysWith: max)
}
func save(_ watermarks: [UUID: Int]) {
let plist = Dictionary(
uniqueKeysWithValues: watermarks.map { ($0.key.uuidString, $0.value) }
)
defaults.set(plist, forKey: Self.key)
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-15 · Cold-start selection logic (pure): which root screen boots, and
/// whether the session list should highlight "" for the active host.
@MainActor
@Suite("ColdStartPolicy")
struct ColdStartPolicyTests {
private final class StubLastSessionStore: LastSessionStore, @unchecked Sendable {
private let stored: [UUID: UUID]
init(stored: [UUID: UUID] = [:]) { self.stored = stored }
func lastSessionId(host: UUID) -> UUID? { stored[host] }
func setLastSessionId(_ id: UUID?, host: UUID) {}
}
private static func makeHost(name: String = "mac") throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
}
// MARK: - Root route: no paired host Pairing; else SessionList
@Test("无配对 host → 冷启动进 Pairing")
func noHostsBootsIntoPairing() {
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 0) == .pairing)
}
@Test("有配对 host → 冷启动进 SessionList")
func pairedHostsBootIntoSessionList() {
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 1) == .sessions)
#expect(ColdStartPolicy.initialRoute(pairedHostCount: 3) == .sessions)
}
// MARK: - "" highlight
@Test("active host 存有 lastSessionId → 给出继续上次目标")
func continueLastReturnsStoredSession() throws {
let host = try Self.makeHost()
let sessionId = UUID()
let store = StubLastSessionStore(stored: [host.id: sessionId])
let target = ColdStartPolicy.continueLastSessionId(activeHost: host, store: store)
#expect(target == sessionId)
}
@Test("该 host 无记录 → 无继续上次高亮")
func continueLastNilWithoutRecord() throws {
let host = try Self.makeHost()
let other = UUID()
let store = StubLastSessionStore(stored: [other: UUID()])
#expect(ColdStartPolicy.continueLastSessionId(activeHost: host, store: store) == nil)
}
@Test("尚无 active hosthosts 未加载/为空)→ 无高亮")
func continueLastNilWithoutActiveHost() {
let store = StubLastSessionStore()
#expect(ColdStartPolicy.continueLastSessionId(activeHost: nil, store: store) == nil)
}
}

View File

@@ -0,0 +1,338 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " Validation"
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
/// variant nibble=c v4 8/9/a/b
private static let badVariantId = "11111111-2222-4333-c444-555555555555"
private static func url(_ string: String) throws -> URL {
try #require(URL(string: string))
}
private static func openURL(
host: String = validHostId, join: String = validSessionId
) throws -> URL {
try url("webterminal://open?host=\(host)&join=\(join)")
}
private static func makeHost(id: UUID) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
// MARK: - URL
@Test("合法 open 链接 → .openSession(hostId, sessionId)")
func validLinkRoutes() throws {
let route = DeepLinkRouter.route(url: try Self.openURL())
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("大写 UUID → 接受Validation 大小写不敏感)")
func uppercaseUUIDsAccepted() throws {
let route = DeepLinkRouter.route(
url: try Self.openURL(
host: Self.validHostId.uppercased(),
join: Self.validSessionId.uppercased()
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("未知多余 query 键 → 忽略键本身,链接仍路由")
func unknownExtraKeysIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar"
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
// MARK: - URL .ignore
@Test("scheme 非 webterminal → .ignore")
func wrongSchemeIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("action 非 open → .ignore")
func wrongActionIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("带非空 path → .ignore白名单外的形状")
func extraPathIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("缺 host 键 → .ignore")
func missingHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("缺 join 键 → .ignore")
func missingJoinKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?host=\(Self.validHostId)")
)
#expect(route == .ignore)
}
@Test("重复 host 键 → .ignore歧义输入绝不部分应用")
func duplicateHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("UUID 非 v4version nibble=1→ .ignore钉住复用 Validation")
func nonV4UUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore)
}
@Test("variant nibble 非 8/9/a/b → .ignore")
func badVariantUUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore)
}
@Test("空值 / 垃圾值 / 空 query → .ignore不 crash")
func fuzzedValuesIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore)
#expect(
DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore
)
}
// MARK: - Push payloadWEBTERM_GATET-iOS-21
@Test("payload 含合法 sessionId多余键忽略→ .gateSession")
func gatePayloadRoutes() throws {
let payload: [AnyHashable: Any] = [
"sessionId": Self.validSessionId,
"cls": "gate",
"token": "opaque",
"aps": ["category": "WEBTERM_GATE"],
]
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId))
}
@Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore")
func gatePayloadRejectsInvalid() {
#expect(DeepLinkRouter.route(from: [:]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore)
}
}
/// DeepLinkHandler stash host
@MainActor
@Suite("DeepLinkHandler")
struct DeepLinkHandlerTests {
private enum StubError: Error { case storeFailure }
/// handler AppCoordinator
@MainActor
private final class ActionRecorder {
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
private(set) var pairingShownCount = 0
var actions: DeepLinkHandler.Actions {
DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.opened.append((host, sessionId))
},
showPairing: { [weak self] in
self?.pairingShownCount += 1
}
)
}
}
private static func makeHost(id: UUID = UUID()) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL {
try #require(URL(
string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)"
))
}
/// UUID() v4 uuidString
private static func sessionId() -> UUID { UUID() }
// MARK: - app
@Test("热路径:已知 host → openSession(host, sessionId),无 hint")
func warmKnownHostOpens() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.host == host)
#expect(recorder.opened.first?.sessionId == sessionId)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
@Test("未知 host idUUID 合法但不在 store→ showPairing + 提示文案")
func unknownHostRoutesToPairing() async throws {
let stored = try Self.makeHost()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
}
@Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash")
func storeFailureSurfacesExplicitCopy() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(
loadHosts: { throw StubError.storeFailure },
actions: recorder.actions
)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed)
}
// MARK: - + stash
@Test("非法链接 → ignoredCount+1无任何动作也不入 stash")
func invalidLinkCountedNeverStashed() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x")))
#expect(handler.ignoredCount == 1)
await handler.markReady() // ready stash
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
// MARK: - stash
@Test("冷启动ready 前 handle → 挂起markReady → 恰好应用一次")
func coldLaunchStashAppliesOnReady() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.isEmpty) //
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == sessionId)
await handler.markReady() // stash
#expect(recorder.opened.count == 1)
}
@Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash")
func stashKeepsOnlyLatestLink() async throws {
let host = try Self.makeHost()
let first = Self.sessionId()
let second = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first))
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second))
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == second)
}
// MARK: - hint
@Test("clearHint → hintMessage 归位 nil")
func clearHintResets() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(handler.hintMessage != nil)
handler.clearHint()
#expect(handler.hintMessage == nil)
}
}

View File

@@ -0,0 +1,158 @@
import SwiftUI
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// UX-A · Proves the FROZEN design system: the status mapping is complete and
/// "color + shape" (never color alone), the token scales are well-formed, and
/// the adaptive accent resolves distinctly in light vs dark. Pure logic runs
/// on both iPhone and iPad sims with no rendering.
@Suite("DesignSystem")
struct DesignSystemTests {
// MARK: - StatusStyle: complete, distinct, color + shape
@Test("every status maps to a non-empty symbol + Chinese label")
func everyStatusHasSymbolAndLabel() {
for status in DisplayStatus.allCases {
let style = StatusStyle.style(for: status)
#expect(!style.symbolName.isEmpty, "\(status) has no symbol")
#expect(!style.label.isEmpty, "\(status) has no label")
}
}
@Test("status is never color-alone — all seven symbols are distinct")
func symbolsAreDistinctAcrossStatuses() {
let symbols = DisplayStatus.allCases.map { StatusStyle.style(for: $0).symbolName }
#expect(Set(symbols).count == DisplayStatus.allCases.count)
}
@Test("Chinese labels are all distinct")
func labelsAreDistinct() {
let labels = DisplayStatus.allCases.map { StatusStyle.style(for: $0).label }
#expect(Set(labels).count == DisplayStatus.allCases.count)
}
@Test("the critical trio working/waiting/stuck use distinct colors")
func criticalColorsAreDistinct() {
let working = rgba(StatusStyle.style(for: DisplayStatus.working).color)
let waiting = rgba(StatusStyle.style(for: DisplayStatus.waiting).color)
let stuck = rgba(StatusStyle.style(for: DisplayStatus.stuck).color)
#expect(!approxEqual(working, waiting))
#expect(!approxEqual(working, stuck))
#expect(!approxEqual(waiting, stuck))
}
@Test("semantic status colors match the desktop/web status palette")
func statusColorsMatchDirection() {
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 70, g: 208, b: 127) // #46D07F web --green
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 245, g: 177, b: 76) // #F5B14C web --amber
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 107, b: 107) // #FF6B6B web --red
}
@Test("the wire ClaudeStatus bridge covers all five cases")
func wireBridgeCoversEveryCase() {
let pairs: [(ClaudeStatus, DisplayStatus)] = [
(.working, .working), (.waiting, .waiting), (.idle, .idle),
(.stuck, .stuck), (.unknown, .unknown),
]
for (wire, expected) in pairs {
#expect(DisplayStatus(wire) == expected)
#expect(StatusStyle.style(for: wire) == StatusStyle.style(for: expected))
}
}
// MARK: - Token scales are well-formed
@Test("spacing scale is exactly 2·4·8·12·16·20·24 and strictly increasing")
func spacingScaleIsMonotonic() {
let scale: [CGFloat] = [
DS.Space.xs2, DS.Space.xs4, DS.Space.sm8,
DS.Space.md12, DS.Space.lg16, DS.Space.xl20, DS.Space.xxl24,
]
#expect(scale == [2, 4, 8, 12, 16, 20, 24])
#expect(isStrictlyIncreasing(scale))
}
@Test("radius scale is strictly increasing sm8 < md12 < lg16 < pill")
func radiusScaleIsMonotonic() {
let scale: [CGFloat] = [DS.Radius.sm8, DS.Radius.md12, DS.Radius.lg16, DS.Radius.pill]
#expect(scale == [8, 12, 16, 999])
#expect(isStrictlyIncreasing(scale))
}
@Test("opacity dimming tokens are within (0, 1)")
func opacityTokensAreFractions() {
for value in [DS.Opacity.stale, DS.Opacity.exited, DS.Opacity.pressed] {
#expect(value > 0 && value < 1)
}
}
@Test("minimum hit target meets the 44pt HIG floor")
func hitTargetMeetsHIG() {
#expect(DS.Layout.minHitTarget >= 44)
}
// MARK: - Motion honors Reduce Motion
@Test("motion collapses to nil under Reduce Motion, animates otherwise")
func motionGating() {
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: true) == nil)
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: false) != nil)
#expect(DS.Motion.fastDuration < DS.Motion.baseDuration)
}
// MARK: - Adaptive accent resolves for both schemes
@Test("accent is defined and distinct in light vs dark")
func accentIsAdaptive() {
let accent = DS.Palette.accentUIColor()
let dark = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark))
let light = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
let darkRGBA = rgba(dark)
let lightRGBA = rgba(light)
// Both fully opaque, and visibly different between schemes.
#expect(darkRGBA.a == 1)
#expect(lightRGBA.a == 1)
#expect(!approxEqual(darkRGBA, lightRGBA))
// Desktop amber gold: dark #E3A64A (--accent), light #C9892F (--accent-2).
assertRGBA(darkRGBA, r: 0xE3, g: 0xA6, b: 0x4A)
assertRGBA(lightRGBA, r: 0xC9, g: 0x89, b: 0x2F)
}
// MARK: - Helpers
private typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
private func isStrictlyIncreasing(_ values: [CGFloat]) -> Bool {
zip(values, values.dropFirst()).allSatisfy { $0 < $1 }
}
private func rgba(_ color: Color) -> RGBA {
rgba(UIColor(color))
}
private func rgba(_ color: UIColor) -> RGBA {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
color.getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
}
private func approxEqual(_ lhs: RGBA, _ rhs: RGBA, tolerance: CGFloat = 0.02) -> Bool {
abs(lhs.r - rhs.r) < tolerance
&& abs(lhs.g - rhs.g) < tolerance
&& abs(lhs.b - rhs.b) < tolerance
}
private func assertColor(_ color: Color, r: Int, g: Int, b: Int) {
assertRGBA(rgba(color), r: r, g: g, b: b)
}
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
#expect(abs(value.b - CGFloat(b) / 255) < tolerance)
}
}

View File

@@ -0,0 +1,250 @@
import Foundation
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-27 · Diff `DiffFetcher`App GET /projects/diff
///
/// Steps
/// - GET + path/staged + ** Origin**RO
/// §3.4 iff-G requireAllowedOriginsrc/server.ts:601-618
/// - `staged` `1`/`0` `=== '1'`src/server.ts:613
/// - `DiffResult{files,staged,truncated}` public/diff.ts
/// normalizeDiffResult file/hunk/line
/// kind .context status .modified
/// - 400 .pathInvalid404 .projectNotFoundSEC-H7
/// .unexpectedStatus
/// - path 400
struct DiffFetcherTests {
// MARK: - Fixtures
private static func makeEndpoint() throws -> HostEndpoint {
let url = try #require(URL(string: "http://127.0.0.1:3000"))
return try #require(HostEndpoint(baseURL: url))
}
private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) {
let fake = FakeHTTPTransport()
let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake)
return (fetcher, fake)
}
private static func url(_ s: String) throws -> URL {
try #require(URL(string: s))
}
/// modified + 1 hunk + 4 4 kind
private static let fullBody = Data("""
{
"files": [
{
"oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified",
"added": 2, "removed": 1, "binary": false,
"hunks": [
{ "header": "@@ -1,3 +1,4 @@",
"lines": [
{ "kind": "context", "text": "unchanged" },
{ "kind": "removed", "text": "old line" },
{ "kind": "added", "text": "new line" },
{ "kind": "meta", "text": "No newline at end of file" }
] }
]
}
],
"staged": false,
"truncated": false
}
""".utf8)
// MARK: -
@Test("GET /projects/diff?path=<enc>&staged=0无 OriginRO 端点iff-G 铁律)")
func requestShapeIsReadOnlyGetWithoutOrigin() async throws {
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
)
await fake.queueSuccess(url: expected, body: Self.fullBody)
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
let recorded = await fake.recordedRequests
let request = try #require(recorded.first)
#expect(recorded.count == 1)
#expect(request.httpMethod == "GET")
#expect(request.url == expected)
// RO Origin G§3.4
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
@Test("staged=true → staged=1服务器 === '1' 精确匹配src/server.ts:613")
func stagedSerializesAsOne() async throws {
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
)
await fake.queueSuccess(url: expected, body: Self.fullBody)
_ = try await fetcher.fetch(path: "/tmp/repo", staged: true)
let recorded = await fake.recordedRequests
#expect(recorded.first?.url == expected)
}
@Test("path 严格百分号编码:空格/&/+/= 全部转义unreserved-only 集)")
func pathIsStrictlyPercentEncoded() async throws {
let (fetcher, fake) = try Self.makeFetcher()
// " " %20"&" %26"+" %2B"=" %3D"/" %2F
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0"
)
await fake.queueSuccess(url: expected, body: Self.fullBody)
_ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false)
let recorded = await fake.recordedRequests
#expect(recorded.first?.url == expected)
}
@Test("空 path → .invalidRequest零网络镜像服务器 400 规则,先于 I/O 拒绝)")
func emptyPathRejectedBeforeNetwork() async throws {
let (fetcher, fake) = try Self.makeFetcher()
await #expect(throws: DiffFetchError.invalidRequest) {
_ = try await fetcher.fetch(path: "", staged: false)
}
let recorded = await fake.recordedRequests
#expect(recorded.isEmpty)
}
// MARK: -
@Test("200 完整体 → DiffResult 各字段逐一到位")
func decodesFullBody() async throws {
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
)
await fake.queueSuccess(url: expected, body: Self.fullBody)
let result = try await fetcher.fetch(path: "/tmp/repo", staged: false)
#expect(result.staged == false)
#expect(result.truncated == false)
#expect(result.files.count == 1)
let file = try #require(result.files.first)
#expect(file.newPath == "src/a.ts")
#expect(file.status == .modified)
#expect(file.added == 2)
#expect(file.removed == 1)
#expect(file.binary == false)
let hunk = try #require(file.hunks.first)
#expect(hunk.header == "@@ -1,3 +1,4 @@")
#expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta])
#expect(hunk.lines.map(\.text) == [
"unchanged", "old line", "new line", "No newline at end of file",
])
}
@Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified")
func tolerantDecodingDropsMalformedEntries() async throws {
let (fetcher, fake) = try Self.makeFetcher()
let body = Data("""
{
"files": [
42,
{ "oldPath": 1, "newPath": "x" },
{
"oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future",
"added": 0, "removed": 0, "binary": false,
"hunks": [
{ "lines": [] },
{ "header": "@@ -1 +1 @@",
"lines": [
{ "kind": "sparkle", "text": "mystery" },
{ "kind": "added" },
{ "kind": "added", "text": "kept" }
] }
]
}
],
"staged": true,
"truncated": true
}
""".utf8)
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
)
await fake.queueSuccess(url: expected, body: body)
let result = try await fetcher.fetch(path: "/tmp/repo", staged: true)
#expect(result.truncated == true)
#expect(result.files.count == 1) // 42
let file = try #require(result.files.first)
#expect(file.status == .modified) // status
#expect(file.hunks.count == 1) // header hunk
let lines = try #require(file.hunks.first).lines
#expect(lines.count == 2) // text
#expect(lines.first?.kind == .context) // kind web df-context
#expect(lines.last?.text == "kept")
}
@Test("顶层畸形(缺键 / 非对象 / 非 JSON→ .invalidResponse", arguments: [
#"{"staged": false, "truncated": false}"#,
#"[1, 2, 3]"#,
"not json at all",
])
func malformedTopLevelIsInvalidResponse(raw: String) async throws {
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
)
await fake.queueSuccess(url: expected, body: Data(raw.utf8))
await #expect(throws: DiffFetchError.invalidResponse) {
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
}
}
// MARK: - route src/server.ts:602-618
@Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)")
func statusCodesMapToTypedErrors() async throws {
let cases: [(Int, DiffFetchError)] = [
(400, .pathInvalid),
(404, .projectNotFound),
(500, .unexpectedStatus(500)),
(403, .unexpectedStatus(403)),
]
for (status, expectedError) in cases {
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
)
await fake.queueSuccess(
url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8)
)
await #expect(throws: expectedError) {
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
}
}
}
@Test("传输层错误原样上抛(不吞、不错误分类)")
func transportErrorsPropagate() async throws {
struct Boom: Error {}
let (fetcher, fake) = try Self.makeFetcher()
let expected = try Self.url(
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
)
await fake.queueFailure(url: expected, error: Boom())
await #expect(throws: Boom.self) {
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
}
}
}

View File

@@ -0,0 +1,242 @@
import Foundation
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-27 · Diff `DiffViewModel`phase +
///
/// Steps
/// - `DiffResult{files,staged,truncated}` hunk
/// diff Text
/// - truncated banner
/// - staged/unstaged staged fetch
/// - path 400/404 phase unavailable
/// - binary web renderDiffFile early returnrenamed
@MainActor
@Suite("DiffViewModel")
struct DiffViewModelTests {
// MARK: - Fixtures
private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine {
DiffLine(kind: kind, text: text)
}
private nonisolated static func modifiedFile() -> DiffFile {
DiffFile(
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
added: 1, removed: 1, binary: false,
hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [
line(.removed, "old"),
line(.added, "new"),
])]
)
}
private nonisolated static func result(
files: [DiffFile], staged: Bool = false, truncated: Bool = false
) -> DiffResult {
DiffResult(files: files, staged: staged, truncated: truncated)
}
/// fetch staged @Sendable actor
private actor FetchRecorder {
private(set) var stagedArgs: [Bool] = []
func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] }
}
// MARK: - Phase
@Test("初始phase = .loading、staged = false默认工作区视图")
func initialState() {
let vm = DiffViewModel(fetch: { _ in Self.result(files: []) })
#expect(vm.phase == .loading)
#expect(vm.staged == false)
}
@Test("load 成功(非空)→ .loaded行序文件头 → hunk 头 → 行id 稳定递增")
func loadFlattensRowsInOrder() async throws {
let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) })
await vm.load()
guard case .loaded(let presentation) = vm.phase else {
Issue.record("期望 .loaded实际 \(vm.phase)")
return
}
#expect(presentation.truncated == false)
#expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable
#expect(presentation.rows.map(\.kind) == [
.fileHeader(DiffFileHeader(
pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified
)),
.hunkHeader("@@ -1,2 +1,2 @@"),
.line(kind: .removed, text: "old"),
.line(kind: .added, text: "new"),
])
}
@Test("空 files → .emptytruncated 数据位透传)", arguments: [false, true])
func emptyFilesBecomeEmptyPhase(truncated: Bool) async {
let vm = DiffViewModel(fetch: { _ in
Self.result(files: [], truncated: truncated)
})
await vm.load()
#expect(vm.phase == .empty(truncated: truncated))
}
@Test("truncated + 非空 → .loaded 且 presentation.truncated = truebanner 提示位)")
func truncatedFlagSurvivesIntoPresentation() async throws {
let vm = DiffViewModel(fetch: { _ in
Self.result(files: [Self.modifiedFile()], truncated: true)
})
await vm.load()
guard case .loaded(let presentation) = vm.phase else {
Issue.record("期望 .loaded实际 \(vm.phase)")
return
}
#expect(presentation.truncated == true)
}
// MARK: - web renderDiffFile
@Test("binary 文件:文件头 + 二进制占位hunk 全部短路web early return 同款)")
func binaryFileShortCircuitsHunks() {
let binary = DiffFile(
oldPath: "logo.png", newPath: "logo.png", status: .binary,
added: 0, removed: 0, binary: true,
hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])]
)
let rows = DiffViewModel.makeRows(files: [binary])
#expect(rows.map(\.kind) == [
.fileHeader(DiffFileHeader(
pathLabel: "logo.png", added: 0, removed: 0, status: .binary
)),
.binaryNotice,
])
}
@Test("renamed 且新旧路径不同 → 路径标签 “old → new”web 同款箭头)")
func renamedFileShowsArrowLabel() {
let renamed = DiffFile(
oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed,
added: 0, removed: 0, binary: false, hunks: []
)
let rows = DiffViewModel.makeRows(files: [renamed])
guard case .fileHeader(let header)? = rows.first?.kind else {
Issue.record("期望 fileHeader实际 \(String(describing: rows.first))")
return
}
#expect(header.pathLabel == "old/name.ts → new/name.ts")
}
@Test("untracked0 hunk、非 binary→ 仅文件头行")
func untrackedFileIsHeaderOnly() {
let untracked = DiffFile(
oldPath: "notes.md", newPath: "notes.md", status: .untracked,
added: 0, removed: 0, binary: false, hunks: []
)
let rows = DiffViewModel.makeRows(files: [untracked])
#expect(rows.count == 1)
#expect(rows.first?.kind == .fileHeader(DiffFileHeader(
pathLabel: "notes.md", added: 0, removed: 0, status: .untracked
)))
}
// MARK: - 400/404 Steps
@Test("DiffFetchError → Failure 映射400/非法请求 → pathInvalid404 → notFound其余 → unavailable")
func fetchErrorsMapToFailures() async {
struct RandomError: Error {}
let cases: [(any Error, DiffViewModel.Failure)] = [
(DiffFetchError.pathInvalid, .pathInvalid),
(DiffFetchError.invalidRequest, .pathInvalid),
(DiffFetchError.projectNotFound, .notFound),
(DiffFetchError.invalidResponse, .unavailable),
(DiffFetchError.unexpectedStatus(500), .unavailable),
(RandomError(), .unavailable),
]
for (thrown, expected) in cases {
let vm = DiffViewModel(fetch: { _ in throw thrown })
await vm.load()
#expect(vm.phase == .failed(expected))
}
}
@Test("重试:失败后再次 load 成功 → .loaded错误态可恢复")
func retryAfterFailureRecovers() async {
struct Boom: Error {}
let recorder = FetchRecorder()
let vm = DiffViewModel(fetch: { staged in
await recorder.record(staged)
if await recorder.stagedArgs.count == 1 { throw Boom() }
return Self.result(files: [Self.modifiedFile()])
})
await vm.load()
#expect(vm.phase == .failed(.unavailable))
await vm.load() // DiffScreen
guard case .loaded = vm.phase else {
Issue.record("期望 .loaded实际 \(vm.phase)")
return
}
}
// MARK: - staged/unstaged re-fetch
@Test("setStaged(true) → 以 staged=true 重新 fetch同值再设 → 不重复请求")
func setStagedRefetchesOnlyOnChange() async {
let recorder = FetchRecorder()
let vm = DiffViewModel(fetch: { staged in
await recorder.record(staged)
return Self.result(files: [], staged: staged)
})
await vm.load()
await vm.setStaged(true)
await vm.setStaged(true) // no-op
await vm.setStaged(false)
#expect(vm.staged == false)
#expect(await recorder.stagedArgs == [false, true, false])
}
// MARK: -
@Test("文案常量非空且空态/错误态互不相同")
func copyConstantsAreDistinct() {
#expect(!DiffCopy.title.isEmpty)
#expect(!DiffCopy.truncatedBanner.isEmpty)
#expect(!DiffCopy.emptyTitle.isEmpty)
#expect(!DiffCopy.binaryFile.isEmpty)
#expect(!DiffCopy.retry.isEmpty)
#expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable)
#expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound)
}
@Test("状态标签全函数映射(含全部六种 FileStatus中文")
func statusLabelsAreTotalAndChinese() {
let all: [DiffFileStatus] = [
.modified, .added, .deleted, .renamed, .binary, .untracked,
]
for status in all {
#expect(!DiffStatusStyle.label(for: status).isEmpty)
}
#expect(DiffStatusStyle.label(for: .added) == "新增")
#expect(DiffStatusStyle.label(for: .deleted) == "删除")
}
}

View File

@@ -0,0 +1,79 @@
import Testing
@testable import WebTerm
/// T-iOS-15 · Fan-out adapter tests. `SessionEngine.events` is a
/// single-consumer `AsyncStream`, but TerminalViewModel, GateViewModel AND the
/// session-activity bridge must all observe it `EventFanOut` is the wiring
/// layer's broadcast seam. These tests pin its contract: every branch receives
/// every element, in order, and source termination / cancel() propagate.
@Suite("EventFanOut")
struct EventFanOutTests {
private static let branchCount = 3
@Test("每个分支都按序收到全部元素")
func allBranchesReceiveAllElementsInOrder() async {
// Arrange
let (source, continuation) = AsyncStream<Int>.makeStream()
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
// Act
for value in 1...5 {
continuation.yield(value)
}
continuation.finish()
// Assert: unbounded buffering late consumption still sees everything.
for branch in fanOut.branches {
var received: [Int] = []
for await value in branch {
received.append(value)
}
#expect(received == [1, 2, 3, 4, 5])
}
}
@Test("分支数量与请求一致")
func branchCountMatchesRequest() {
let (source, _) = AsyncStream<Int>.makeStream()
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
#expect(fanOut.branches.count == Self.branchCount)
}
@Test("源结束 → 所有分支结束(消费循环退出,不悬挂)")
func sourceFinishFinishesEveryBranch() async {
// Arrange
let (source, continuation) = AsyncStream<String>.makeStream()
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
// Act
continuation.yield("only")
continuation.finish()
// Assert: every branch loop terminates after draining.
for branch in fanOut.branches {
var count = 0
for await _ in branch {
count += 1
}
#expect(count == 1)
}
}
@Test("cancel() → 分支立即终止teardown 不泄漏消费任务)")
func cancelTerminatesBranches() async {
// Arrange
let (source, continuation) = AsyncStream<Int>.makeStream()
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
// Act: cancel without ever finishing the source.
fanOut.cancel()
continuation.yield(42) // post-cancel input must not hang consumers
// Assert: iteration completes (content is unspecified mid-flight
// termination is the contract under test).
for branch in fanOut.branches {
for await _ in branch {}
}
#expect(Bool(true)) // reaching here = no hang
}
}

View File

@@ -0,0 +1,402 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run
/// against a REAL `SessionEngine` over `TestSupport.FakeTransport` +
/// `FakeClock` (same testability choice as TerminalViewModelTests): the VM
/// consumes `engine.events` exactly as the T-iOS-15 wiring will zero real
/// waits, zero network, and the engine's `GateTracker.canDecide` second-line
/// guard stays live underneath the VM's first-line tap-epoch guard.
@MainActor
@Suite("GateViewModel")
struct GateViewModelTests {
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
private enum ServerFrames {
static func attached(_ id: UUID) -> String {
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
}
static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
if let gate { fields.append("\"gate\":\"\(gate)\"") }
if let detail { fields.append("\"detail\":\"\(detail)\"") }
return "{\(fields.joined(separator: ","))}"
}
}
private struct TestStreamError: Error {}
// MARK: - Test doubles
/// Records haptic firings the "exactly once per gate epoch" probe.
@MainActor
private final class HapticRecorder: HapticSignaling {
private(set) var gateArrivalCount = 0
func gateDidArrive() { gateArrivalCount += 1 }
}
// MARK: - Harness
/// Engine over fakes + the VM under test, wired exactly like production.
@MainActor
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let haptics = HapticRecorder()
let engine: SessionEngine
let viewModel: GateViewModel
init(
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
) throws {
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: eventsSource
)
viewModel = GateViewModel(
engine: engine, events: engine.events, haptics: haptics, clock: clock
)
viewModel.start()
}
/// Standard preamble: open .connecting .connected server confirms
/// the attach. 3 events reach the VM.
func openAndAdopt(_ id: UUID = UUID()) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 3)
}
/// Frames the client sent on connection 0 (attach first, then decisions).
func wireFrames() async -> [String] {
let byConnection = await transport.sentFramesByConnection
return byConnection.first ?? []
}
/// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id`
/// the away window that makes the engine emit exactly one digest.
/// Event count after: 7 (3 preamble + reconnecting + connected +
/// adopted + digest).
func reconnectForDigest(_ id: UUID) async {
await transport.emitError(TestStreamError())
await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting
await clock.waitForSleepers(count: 1) // backoff rung parked
clock.advance(by: .seconds(1))
await transport.emit(frame: ServerFrames.attached(id))
await viewModel.waitUntilProcessed(eventCount: 7)
}
}
// MARK: - Gate routing (tool banner, plan sheet)
@Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither")
func gateRoutingToolVsPlan() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tool gate rises (epoch 1).
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
// Assert: banner state only.
#expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1))
#expect(harness.viewModel.planGate == nil)
// Act: gate lifted.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
// Assert: neither surface renders.
#expect(harness.viewModel.toolGate == nil)
#expect(harness.viewModel.planGate == nil)
// Act: plan gate rises (epoch 2).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Assert: sheet state only.
#expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2))
#expect(harness.viewModel.toolGate == nil)
}
// MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86)
@Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere")
func planThreeWayMappingMirrorsWebClient() async throws {
// Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client
// and never consults /config/ui uiConfig.allowAutoMode is reserved
// for a future permission-mode picker (plan §3.1 note).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.planGate?.epoch)
// Act: all three choices against the held gate (web sends per click).
harness.viewModel.decide(.approveAuto, epoch: epoch)
harness.viewModel.decide(.approveReview, epoch: epoch)
harness.viewModel.decide(.keepPlanning, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 3)
// Assert: exact wire frames, in order never raw `auto`.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
MessageCodec.encode(.approve(mode: .default)),
MessageCodec.encode(.reject),
])
}
@Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject")
func toolTwoWayMapping() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
let epoch = try #require(harness.viewModel.toolGate?.epoch)
// Act
harness.viewModel.decide(.approve, epoch: epoch)
harness.viewModel.decide(.reject, epoch: epoch)
await harness.viewModel.waitUntilForwarded(decisionCount: 2)
// Assert: bare approve (no mode key) then reject.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
MessageCodec.encode(.reject),
])
}
// MARK: - Tap-epoch guard (first line; engine canDecide is the second)
@Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate")
func staleEpochTapIsDroppedNotSent() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
// Act: tap before any gate ever rose dropped.
harness.viewModel.decide(.approve, epoch: 0)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window).
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
// Act: a tap rendered against gate 1 lands now dropped, not sent.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 2)
#expect(harness.viewModel.forwardedDecisionCount == 0)
// Act: a fresh tap against the CURRENT gate (epoch 2) goes through.
harness.viewModel.decide(.approve, epoch: 2)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert: exactly one approve ever reached the wire.
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: nil)),
])
}
@Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through")
func kindMorphSameEpochDropsForeignAffordance() async throws {
// Arrange: tool gate epoch 1, then a sustained-pending refresh flips
// the kind to plan WITHOUT minting a new epoch (GateTracker semantics).
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.viewModel.planGate?.epoch == 1)
// Act: the tap rendered on the old TOOL banner is no longer an offered
// affordance dropped even though the epoch still matches.
harness.viewModel.decide(.approve, epoch: 1)
#expect(harness.viewModel.droppedStaleDecisionCount == 1)
// Act: a choice from the CURRENT plan sheet goes through.
harness.viewModel.decide(.approveAuto, epoch: 1)
await harness.viewModel.waitUntilForwarded(decisionCount: 1)
// Assert
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.approve(mode: .acceptEdits)),
])
}
// MARK: - Haptics (exactly once per gate epoch)
@Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz")
func hapticFiresExactlyOncePerGateEpoch() async throws {
// Arrange
let harness = try Harness()
await harness.openAndAdopt()
#expect(harness.haptics.gateArrivalCount == 0)
// Act: rising edge mints epoch 1 one buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
await harness.viewModel.waitUntilProcessed(eventCount: 4)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: sustained-pending refresh (same epoch, new detail) NO re-buzz.
await harness.transport.emit(
frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read"))
await harness.viewModel.waitUntilProcessed(eventCount: 5)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: gate lifted no buzz for a nil gate.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.viewModel.waitUntilProcessed(eventCount: 6)
#expect(harness.haptics.gateArrivalCount == 1)
// Act: a NEW gate (epoch 2) second buzz.
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.viewModel.waitUntilProcessed(eventCount: 7)
#expect(harness.haptics.gateArrivalCount == 2)
}
// MARK: - Away digest (render / fade / expand)
@Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer")
func digestNonZeroRendersAndAllZeroDoesNot() async throws {
// Arrange: one away-window event (far-future `at` passes the `since`
// filter; the engine stamps the disconnect moment with the real Date).
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
// Act
await harness.reconnectForDigest(sessionId)
// Assert: summary state set, collapsed by default.
#expect(harness.viewModel.digest == AwayDigest(
toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [awayEvent]))
#expect(!harness.viewModel.isDigestExpanded)
// Arrange/Act: an empty away timeline `.digest(.empty)`.
let empty = try Harness(eventsSource: { _ in [] })
await empty.openAndAdopt(sessionId)
await empty.reconnectForDigest(sessionId)
// Assert: nothing rendered, and no fade timer was ever scheduled.
#expect(empty.viewModel.digest == nil)
#expect(empty.clock.pendingSleeperCount == 0)
}
@Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock")
func digestAutoFadesAfterDelay() async throws {
// Arrange: a visible digest.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
#expect(harness.viewModel.digest != nil)
// Act: the fade timer parks on the fake clock; fire it.
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: Tunables.digestFadeDelay)
await harness.viewModel.waitUntilFadeCompleted(count: 1)
// Assert
#expect(harness.viewModel.digest == nil)
}
@Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader")
func expandedDigestNeverFadesAndShowsRecent() async throws {
// Arrange: a visible digest with its fade timer parked.
let sessionId = UUID()
let awayEvent = TimelineEvent(
at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files")
let harness = try Harness(eventsSource: { _ in [awayEvent] })
await harness.openAndAdopt(sessionId)
await harness.reconnectForDigest(sessionId)
await harness.clock.waitForSleepers(count: 1)
// Act: the user expands the summary row.
harness.viewModel.expandDigest()
// Assert: expanded, recent entries available, fade timer CANCELLED.
#expect(harness.viewModel.isDigestExpanded)
#expect(harness.viewModel.digest?.recent == [awayEvent])
#expect(harness.clock.pendingSleeperCount == 0)
// Act: even way past the fade delay the digest stays.
harness.clock.advance(by: Tunables.digestFadeDelay * 10)
#expect(harness.viewModel.digest != nil)
// Act: explicit dismiss clears everything.
harness.viewModel.dismissDigest()
#expect(harness.viewModel.digest == nil)
#expect(!harness.viewModel.isDigestExpanded)
}
// MARK: - Component copy (mirror of public/tabs.ts:326-350)
@Test("banner/sheet copy and choice labels mirror the web client exactly")
func componentCopyMirrorsWebClient() {
// Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}".
let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1)
#expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash")
#expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1))
== "Claude wants to use a tool")
#expect(GateChoiceSpec.specs(for: toolGate).map(\.label)
== ["✓ Approve", "✗ Reject"])
#expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance)
== [.approve, .reject])
// Plan sheet: three-way, exact labels and title.
let planGate = GateState(kind: .plan, detail: nil, epoch: 2)
#expect(GateChoiceSpec.specs(for: planGate).map(\.label)
== ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"])
#expect(GateChoiceSpec.specs(for: planGate).map(\.affordance)
== [.approveAuto, .approveReview, .keepPlanning])
#expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?")
}
@Test("digest summary composes non-zero parts only; user-only activity falls back to a count")
func digestSummaryComposition() {
// Arrange / Act / Assert: all four signal parts.
let full = AwayDigest(
toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: [])
#expect(AwayDigestView.summary(for: full)
== "离开期间:工具调用 3 次 · 等待审批 1 次 · 已完成")
// Stuck-only digest.
let stuck = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: [])
#expect(AwayDigestView.summary(for: stuck) == "离开期间:曾卡住")
// Non-empty digest whose counted signals are all zero (e.g. only
// `user` events) still gets a rendered fallback line.
let userOnly = AwayDigest(
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")])
#expect(AwayDigestView.summary(for: userOnly) == "离开期间:活动 1 条")
}
}

View File

@@ -0,0 +1,132 @@
import SessionCore
import Testing
import UIKit
@testable import WebTerm
/// T-iOS-11 · KeyBar component + hardware key commands (plan §7).
/// The bar's layout mirrors `public/keybar.ts` `KEYBAR_BUTTONS` (order, glyphs,
/// captions, primary flag) and EVERY labelbytes lookup goes through
/// `KeyByteMap` no byte literal exists in the App layer.
@MainActor
@Suite("KeyBar")
struct KeyBarTests {
@MainActor
private final class KeyRecorder {
private(set) var keys: [KeyByteMap.Key] = []
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
}
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
func layoutOrderMirrorsWebKeybarButtons() {
let expectedOrder: [KeyByteMap.Key] = [
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
.arrowLeft, .arrowRight, .slash,
]
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
}
@Test("glyph labels mirror the web bar; Esc is the only primary button")
func labelsAndPrimaryFlagMirrorWeb() {
let expectedLabels = [
"Esc", "Esc²", "⇧Tab", "", "", "", "^C", "^R", "^O", "^L",
"^T", "^B", "^D", "Tab", "", "", "/",
]
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
}
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
func everySpecResolvesThroughKeyByteMap() {
for spec in KeyBarLayout.buttons {
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
"no bytes for \(spec.key.rawValue)")
}
}
// MARK: - KeyBarView (UIKit input accessory)
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
func keyBarViewBuildsOneButtonPerSpec() {
// Arrange / Act
let bar = KeyBarView()
// Assert
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
#expect(bar.keyButtons.map(\.accessibilityLabel)
== KeyBarLayout.buttons.map(\.title))
}
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
func tappingButtonsFiresOnKeyInLayoutOrder() {
// Arrange
let bar = KeyBarView()
let recorder = KeyRecorder()
bar.onKey = { recorder.record($0) }
// Act: tap every button once, in order.
for button in bar.keyButtons {
button.sendActions(for: .touchUpInside)
}
// Assert
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
}
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
(.esc, UIKeyCommand.inputEscape, []),
(.shiftTab, "\t", .shift),
(.ctrlC, "c", .control),
(.ctrlR, "r", .control),
(.ctrlO, "o", .control),
(.ctrlL, "l", .control),
(.ctrlT, "t", .control),
(.ctrlB, "b", .control),
(.ctrlD, "d", .control),
]
#expect(HardwareKeyCommands.chords.count == expected.count)
for (key, input, modifiers) in expected {
let chord = HardwareKeyCommands.chords.first { $0.key == key }
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
#expect(!KeyByteMap.bytes(for: key).isEmpty)
}
}
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
func buildEmitsPrioritizedCommandsAndRoundTrips() {
// Arrange / Act
let commands = HardwareKeyCommands.build(
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
)
// Assert
#expect(commands.count == HardwareKeyCommands.chords.count)
for command in commands {
#expect(command.wantsPriorityOverSystemBehavior)
let key = HardwareKeyCommands.key(matching: command)
#expect(key != nil, "no chord matches \(String(describing: command.input))")
}
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
== HardwareKeyCommands.chords.count)
}
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
func hardwareMappingExcludesPlainKeysAndArrows() {
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
let excluded: Set<KeyByteMap.Key> = [
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
.enter, .tab, .slash, .escEsc,
]
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
#expect(chordKeys.isDisjoint(with: excluded))
}
}

View File

@@ -0,0 +1,33 @@
import Testing
@testable import WebTerm
/// T-iPad-3 · KeyBar KeyBar
/// `inputAccessoryView` iPhone
/// `userOverride`
///
@Suite("KeyBarVisibility (T-iPad-3)")
struct KeyBarVisibilityTests {
// MARK: -
@Test("硬件键盘在场 + 无用户覆盖 → 隐藏(软 KeyBar 冗余)")
func hardwarePresentAutoHides() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: nil) == false)
}
@Test("无硬件键盘 + 无用户覆盖 → 显示iPhone 默认,零回归)")
func noHardwareAutoShows() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: nil) == true)
}
// MARK: -
@Test("用户强制显示压过硬件在场的自动隐藏")
func userForceShowWinsOverHardware() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: true) == true)
}
@Test("用户强制隐藏压过无硬件的自动显示")
func userForceHideWinsOverNoHardware() {
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: false) == false)
}
}

View File

@@ -0,0 +1,79 @@
import Foundation
import HostRegistry
import Security
import Testing
import WireProtocol
/// T-iOS-15 · Keychain-on-simulator assertion (deferred from T-iOS-7).
///
/// `swift test` binaries are unsigned the data-protection keychain answers
/// `errSecMissingEntitlement` (-34018), so the package layer only tests the
/// store against a fake `SecItemShim`. THIS bundle runs signed inside the
/// WebTerm app host, so here the REAL `LiveSecItemShim` path is exercised
/// end-to-end and the §5.3 protection class is asserted on the stored item.
@Suite("KeychainHostStore (real keychain, signed app host)", .serialized)
struct KeychainHostStoreLiveTests {
/// Test-only service/account never collides with the production item.
private static let service = "com.yaojia.webterm.host-registry.livetests"
private static let account = "hosts-live-roundtrip"
private static func makeHost() throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.9:3000"))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: "live-roundtrip", endpoint: endpoint)
}
/// Removes the test item regardless of test outcome.
private static func deleteTestItem() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
SecItemDelete(query as CFDictionary)
}
@Test("真 Keychain 往返upsert/loadAll/remove+ kSecAttrAccessible 属性断言")
func realKeychainRoundtripAndProtectionClass() async throws {
Self.deleteTestItem() // clean slate from any earlier aborted run
defer { Self.deleteTestItem() } // never leave the test item behind
// Arrange: REAL shim (default init), isolated service/account.
let store = KeychainHostStore(service: Self.service, account: Self.account)
let host = try Self.makeHost()
// Act + Assert: upsert visible via loadAll.
let afterUpsert = try await store.upsert(host)
#expect(afterUpsert == [host])
#expect(try await store.loadAll() == [host])
// Assert §5.3: the STORED item's protection class, straight from
// SecItemCopyMatching attributes (not from our own spec constants).
var result: CFTypeRef?
let attributesQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
kSecUseDataProtectionKeychain as String: true,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
let status = SecItemCopyMatching(attributesQuery as CFDictionary, &result)
#expect(status == errSecSuccess, "SecItemCopyMatching failed: \(status)")
let attributes = try #require(result as? [String: Any])
let accessible = try #require(attributes[kSecAttrAccessible as String] as? String)
#expect(
accessible == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
"stored item must be AfterFirstUnlockThisDeviceOnly (§5.3), got \(accessible)"
)
// §5.3: never iCloud-synced the item must not be synchronizable.
let synchronizable = attributes[kSecAttrSynchronizable as String] as? Bool ?? false
#expect(!synchronizable)
// Act + Assert: remove empty store (item deleted).
let afterRemove = try await store.remove(id: host.id)
#expect(afterRemove.isEmpty)
#expect(try await store.loadAll() == [])
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
import Testing
@testable import WebTerm
/// T-iPad-2 · size-class 仿 `PrivacyShadePolicy`
/// regular sidebar+detailcompact/
/// iPhone stack ****
/// `if sizeClass == `PLAN_IOS_IPAD §4
///
/// `UserInterfaceSizeClass` .regular / .compact / nil
/// size class 退退
@Suite("LayoutPolicy (T-iPad-2)")
struct LayoutPolicyTests {
@Test("regular 宽度 → splitiPad 全屏/大分屏sidebar + detail")
func regularIsSplit() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
}
@Test("compact 宽度 → stackiPhone / iPad Slide Over字节级复用现有路径")
func compactIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
}
@Test("nilsize class 未定)→ stack最保守退现有 iPhone 路径)")
func nilIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
}
}

View File

@@ -0,0 +1,114 @@
import APIClient
import Foundation
import HostRegistry
import SessionCore
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-15 · Hosted live-server smoke the automated stand-in for the manual
/// attach walkthrough (plan §7 T-iOS-15; the true device tap-through
/// + app-switcher shade visual check remain DEFERRED to T-iOS-18).
///
/// Drives the REAL production DI graph end-to-end against the repo's real
/// Node server: `runPairingProbe` over `URLSessionHTTPTransport` +
/// `URLSessionTermTransport` (WS upgrade with Origin + guarded kill
/// round-trip) Host stored (InMemory per task) `APIClient.liveSessions`
/// `SessionEngine.open(nil)` input echo output marker `close()`.
@Suite("Live-server smoke (production DI graph)", .serialized)
struct LiveServerSmokeTests {
/// Collects engine output off the single-consumer stream.
private actor OutputObserver {
private(set) var adoptedId: UUID?
private var buffer = ""
private static let tailLength = 400
func recordAdopted(_ id: UUID) { adoptedId = id }
/// Appends a chunk; true once the accumulated output contains `marker`.
func appendAndCheck(_ chunk: String, marker: String) -> Bool {
buffer += chunk
return buffer.contains(marker)
}
var tail: String { String(buffer.suffix(Self.tailLength)) }
}
@Test("探针→host 入库→liveSessions→attach(null)→echo→output→close")
func productionGraphEndToEnd() async throws {
let endpoint = try await SimServerHarness.shared.endpoint()
// Production transports the same instances AppEnvironment wires.
let http = URLSessionHTTPTransport()
let ws = URLSessionTermTransport()
// Pairing probe (two-step: RO GET, WS attach + Origin, guarded kill).
let probed: HostEndpoint
switch await runPairingProbe(endpoint: endpoint, http: http, ws: ws) {
case .failure(let error):
Issue.record("pairing probe failed: \(error)")
return
case .success(let validated):
probed = validated
}
// Host stored (InMemory stand-in is explicitly allowed here).
let store = InMemoryHostStore()
let host = HostRegistry.Host(id: UUID(), name: "smoke", endpoint: probed)
_ = try await store.upsert(host)
#expect(try await store.loadAll().map(\.id) == [host.id])
// Session list over the real HTTP path (RO no Origin).
let api = APIClient(endpoint: probed, http: http)
_ = try await api.liveSessions()
// Real engine: attach(null) shell echo observe evaluated marker.
// Arithmetic dedup: the TYPED command contains "$((...))", only the
// EVALUATED output contains the final marker string.
let base = 4200
let offset = Int.random(in: 1...999)
let marker = "smoke-\(base + offset)"
let command = "echo smoke-$((\(base)+\(offset)))\r"
let engine = SessionEngine(
transport: ws, clock: ContinuousClock(), endpoint: probed,
eventsSource: { id in try await api.events(id: id) }
)
let observer = OutputObserver()
let consumeTask = Task { () -> Bool in
for await event in engine.events {
switch event {
case .adopted(let id):
await observer.recordAdopted(id)
case .output(let chunk):
if await observer.appendAndCheck(chunk, marker: marker) { return true }
default:
break
}
}
return false
}
await engine.open(sessionId: nil, cwd: nil)
await engine.send(.input(data: command)) // queues until attach-ready
let watchdog = Task {
try? await Task.sleep(for: SmokeTunables.outputTimeout)
consumeTask.cancel() // AsyncStream iteration honors cancellation
}
let sawMarker = await consumeTask.value
watchdog.cancel()
await engine.close()
let outputTail = await observer.tail
#expect(sawMarker, "output 未包含 \(marker)tail: \(outputTail)")
// Cleanup: kill the smoke session via the guarded G route (Origin).
let adopted = try #require(await observer.adoptedId, "attached 帧未到达")
do {
try await api.killSession(id: adopted)
} catch APIClientError.sessionNotFound {
// Already gone cleanup goal reached.
}
}
}

View File

@@ -0,0 +1,290 @@
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-29 · new-in-cwd + 退
///
/// 1. ""TerminalScreen / exit
/// `AppCoordinator.openNewSessionInCurrentCwd()` WS
/// closeopen `attach(null, cwd)` web tabs.ts
/// `newTab()` M6 cwdcwd /live-sessions
/// server-adopted id controller spawnCwdT-iOS-26
/// fresh-spawn
/// ProjectsViewModel
/// 2. exited + exit TerminalViewModel P0 +
/// ""src/session/manager.ts:145-153exited
/// reap attach ring buffer + exit
///
/// `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport
/// openTask + waitUntilProcessed ProjectOpenWiringTests
///
@MainActor
@Suite("NewSessionInCwd (T-iOS-29)")
struct NewSessionInCwdTests {
private nonisolated static let base = "http://192.168.1.5:3000"
// MARK: - FixturesSessionSwitcherTests WiringFixture
private struct Fixture {
let transport: FakeTransport
let http: FakeHTTPTransport
let host: HostRegistry.Host
let unreadStore: InMemoryUnreadWatermarkStore
let environment: AppEnvironment
}
private func makeFixture(suiteName: String) throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let transport = FakeTransport()
let http = FakeHTTPTransport()
let unreadStore = InMemoryUnreadWatermarkStore()
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
let defaults = try #require(UserDefaults(suiteName: suiteName))
return Fixture(
transport: transport,
http: http,
host: host,
unreadStore: unreadStore,
environment: AppEnvironment(
hostStore: InMemoryHostStore(hosts: [host]),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: http,
termTransport: transport,
probe: { _ in .failure(.timeout) },
unreadStore: unreadStore
)
)
}
private func listURL() throws -> URL {
try #require(URL(string: "\(Self.base)/live-sessions"))
}
private func sessionJSON(id: UUID, cwd: String?, exited: Bool = false) -> String {
let cwdJSON = cwd.map { ",\"cwd\":\"\($0)\"" } ?? ""
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
+ ",\"clientCount\":0,\"status\":\"idle\",\"exited\":\(exited)"
+ "\(cwdJSON),\"cols\":80,\"rows\":24}"
}
private func listBody(_ entries: [String]) -> Data {
Data("[\(entries.joined(separator: ","))]".utf8)
}
/// VM cwd
private func primeRows(_ coordinator: AppCoordinator, fixture: Fixture, rows: [String]) async throws {
await coordinator.sessionList.reloadHosts()
await fixture.http.queueSuccess(url: try listURL(), body: listBody(rows))
await coordinator.sessionList.refresh()
}
/// attach adoptedopenTask 3
/// connecting/connected/adopted
private func adopt(
_ controller: TerminalSessionController,
transport: FakeTransport,
sessionId: UUID
) async {
await controller.openTask?.value
await transport.emit(
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
)
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
}
/// controller open
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
await controller.openTask?.value
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
}
// MARK: - 1. cwd
@Test("cwd 来自 /live-sessions 行 → 切换后新连接首帧 attach(null, cwd);旧会话记 last-seen")
func newInCwdUsesListRowCwd() async throws {
// Arrange cwd adopted
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.rowCwd")
let coordinator = AppCoordinator(environment: fixture.environment)
let sessionId = UUID()
try await primeRows(
coordinator, fixture: fixture,
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj")]
)
coordinator.open(SessionListViewModel.OpenRequest(
id: UUID(), host: fixture.host, sessionId: sessionId
))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionId)
// Act/
coordinator.openNewSessionInCurrentCwd()
// Assertcloseopen controller cwd fresh spawn
let second = try #require(coordinator.terminalController)
#expect(second !== first)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 2)
#expect(byConnection[1] == [
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
])
// closeTerminal last-seen
#expect(fixture.unreadStore.snapshot[sessionId] != nil)
second.teardown()
}
@Test("行缺席fresh spawn 未入轮询)→ 回退 controller.spawnCwdbootstrap 绝不复注入")
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
// Arrange fresh spawnspawnCwd + claude bootstrap
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.spawnCwd")
let coordinator = AppCoordinator(environment: fixture.environment)
coordinator.openProject(ProjectOpenRequest(
id: UUID(), host: fixture.host, cwd: "/repos/api",
bootstrapInput: ProjectLaunch.claudeBootstrapInput
))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: UUID())
// Act
coordinator.openNewSessionInCurrentCwd()
// Assert cwd attach claude\r " shell"
let second = try #require(coordinator.terminalController)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 2)
#expect(byConnection[1] == [
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api"))
])
second.teardown()
}
@Test("cwd 全未知(无行、无 spawnCwd→ 普通新会话 attach(null, nil)")
func newInCwdWithUnknownCwdOpensPlainSession() async throws {
// Arrange"+ "adopted id
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.unknown")
let coordinator = AppCoordinator(environment: fixture.environment)
coordinator.open(SessionListViewModel.OpenRequest(
id: UUID(), host: fixture.host, sessionId: nil
))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: UUID())
// Act
coordinator.openNewSessionInCurrentCwd()
// Assert
let second = try #require(coordinator.terminalController)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection.count == 2)
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
second.teardown()
}
@Test("服务器行 cwd 非绝对路径(不可信输入)→ 按未知处理attach(null, nil)")
func hostileRelativeCwdIsTreatedAsUnknown() async throws {
// Arrange/ cwd
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.hostile")
let coordinator = AppCoordinator(environment: fixture.environment)
let sessionId = UUID()
try await primeRows(
coordinator, fixture: fixture,
rows: [sessionJSON(id: sessionId, cwd: "repos/../etc")]
)
coordinator.open(SessionListViewModel.OpenRequest(
id: UUID(), host: fixture.host, sessionId: sessionId
))
let first = try #require(coordinator.terminalController)
await adopt(first, transport: fixture.transport, sessionId: sessionId)
// Act
coordinator.openNewSessionInCurrentCwd()
// Assert cwd
let second = try #require(coordinator.terminalController)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
second.teardown()
}
@Test("无打开的终端 → 动作 no-op不 spawn、不连接")
func actionWithoutOpenTerminalIsNoOp() async throws {
// Arrange
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.noop")
let coordinator = AppCoordinator(environment: fixture.environment)
// Act
coordinator.openNewSessionInCurrentCwd()
// Assert
#expect(coordinator.terminalController == nil)
let attempts = await fixture.transport.connectAttempts
#expect(attempts.isEmpty)
}
// MARK: - 2. exited + exit + ""
@Test("exited 会话点开:回放渲染 + exit 横幅只读 → 动作以该行 cwd 开新会话")
func exitedSessionReplaysThenBannerActionReusesCwd() async throws {
// Arrange exited manager.ts:145-153reap
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.exited")
let coordinator = AppCoordinator(environment: fixture.environment)
let sessionId = UUID()
try await primeRows(
coordinator, fixture: fixture,
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj", exited: true)]
)
coordinator.open(SessionListViewModel.OpenRequest(
id: UUID(), host: fixture.host, sessionId: sessionId
))
let first = try #require(coordinator.terminalController)
var fed: [String] = []
first.terminalViewModel.attachTerminalSink { fed.append($0) }
await first.openTask?.value
// Act ring buffer exit
await fixture.transport.emit(
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
)
await fixture.transport.emit(frame: #"{"type":"output","data":"[replay] $ make done"}"#)
await fixture.transport.emit(frame: #"{"type":"exit","code":0}"#)
await first.terminalViewModel.waitUntilProcessed(eventCount: 5)
// Assertexit
#expect(fed == ["[replay] $ make done"])
#expect(first.terminalViewModel.bannerModel == .exited(code: 0, reason: nil))
#expect(first.terminalViewModel.isReadOnly)
// Act"" coordinator
coordinator.openNewSessionInCurrentCwd()
// Assert cwd fresh spawn
let second = try #require(coordinator.terminalController)
await awaitAttachSettled(second)
let byConnection = await fixture.transport.sentFramesByConnection
#expect(byConnection[1] == [
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
])
second.teardown()
}
@Test("横幅'开新会话'只在 exited 态可用failed/连接态不提供)")
func bannerNewSessionAffordanceOnlyWhenExited() {
#expect(ReconnectBanner.isNewSessionActionAvailable(for: .exited(code: 0, reason: nil)))
#expect(ReconnectBanner.isNewSessionActionAvailable(
for: .exited(code: -1, reason: "spawn failed")
))
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .connecting))
#expect(!ReconnectBanner.isNewSessionActionAvailable(
for: .reconnecting(attempt: 1, retryIn: .seconds(1))
))
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .failed(message: "x")))
}
}

View File

@@ -0,0 +1,329 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import TestSupport
import UserNotifications
import WireProtocol
@testable import WebTerm
/// T-iOS-21 · NotificationActionHandler.handleAllow/Deny POST
/// /hook/decision403
/// DeepLinkRouter parse payload
/// NotificationActionParseTests.swift
/// UNNotificationResponse didReceive
/// parse(actionIdentifier:userInfo:) + handle(_:)
@MainActor
@Suite("NotificationActionHandler")
struct NotificationActionHandlerTests {
// MARK: - Fakes
@MainActor
private final class FakeBackgroundTasks: BackgroundTaskRunning {
private(set) var begunNames: [String] = []
private(set) var endedTokens: [Int] = []
private var nextToken = 0
func begin(name: String) -> Int {
begunNames.append(name)
nextToken += 1
return nextToken
}
func end(_ token: Int) { endedTokens.append(token) }
}
@MainActor
private final class FakeNoticePoster: LocalNoticePosting {
private(set) var posted: [(title: String, body: String)] = []
func post(title: String, body: String) async { posted.append((title, body)) }
}
@MainActor
private final class OpenRecorder {
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
var actions: NotificationActionHandler.Actions {
NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in
self?.opened.append((host, sessionId))
})
}
}
// MARK: - Fixtures
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// capability token = crypto.randomUUID()src/server.ts:445 v4
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
private static let hostABase = "http://192.168.1.5:3000"
private static let hostBBase = "http://192.168.1.6:3000"
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
}
private struct Harness {
let handler: NotificationActionHandler
let http: FakeHTTPTransport
let backgroundTasks: FakeBackgroundTasks
let notices: FakeNoticePoster
let recorder: OpenRecorder
}
private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness {
let http = FakeHTTPTransport()
let backgroundTasks = FakeBackgroundTasks()
let notices = FakeNoticePoster()
let recorder = OpenRecorder()
let handler = NotificationActionHandler(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
backgroundTasks: backgroundTasks,
notices: notices,
actions: recorder.actions
)
return Harness(
handler: handler, http: http, backgroundTasks: backgroundTasks,
notices: notices, recorder: recorder
)
}
private static func decisionURL(base: String) throws -> URL {
try #require(URL(string: "\(base)/hook/decision"))
}
private static func liveSessionsURL(base: String) throws -> URL {
try #require(URL(string: "\(base)/live-sessions"))
}
/// RO A B
private static func queueTwoHostProbe(_ harness: Harness) async throws {
await harness.http.queueSuccess(
method: "GET", url: try liveSessionsURL(base: hostABase),
body: liveSessionsBody(containing: nil)
)
await harness.http.queueSuccess(
method: "GET", url: try liveSessionsURL(base: hostBBase),
body: liveSessionsBody(containing: sessionId)
)
}
/// /live-sessions
private static func liveSessionsBody(containing sessionId: String?) -> Data {
guard let sessionId else { return Data("[]".utf8) }
let json = """
[{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting",
"exited":false,"cols":80,"rows":24}]
"""
return Data(json.utf8)
}
// MARK: - handle(.decision)POST /hook/decision
@Test("单主机 Allow → 直接 POST免 RO 探查),体为 {sessionId,decision,token},带 Origin背景任务成对")
func decisionSingleHostPosts() async throws {
let host = try Self.makeHost(base: Self.hostABase)
let harness = Self.makeHarness(hosts: [host])
await harness.http.queueSuccess(
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.allow, sessionId: sessionId, token: Self.token)
)
let requests = await harness.http.recordedRequests
#expect(requests.count == 1)
let request = try #require(requests.first)
#expect(request.httpMethod == "POST")
#expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase)
let body = try JSONDecoder().decode(
[String: String].self, from: try #require(request.httpBody)
)
#expect(body == [
"sessionId": Self.sessionId, "decision": "allow", "token": Self.token,
])
#expect(harness.backgroundTasks.begunNames.count == 1)
#expect(harness.backgroundTasks.endedTokens.count == 1)
#expect(harness.notices.posted.isEmpty)
}
@Test("Deny → decision 字段为 deny")
func decisionDenyPostsDeny() async throws {
let host = try Self.makeHost(base: Self.hostABase)
let harness = Self.makeHarness(hosts: [host])
await harness.http.queueSuccess(
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.deny, sessionId: sessionId, token: Self.token)
)
let request = try #require(await harness.http.recordedRequests.first)
let body = try JSONDecoder().decode(
[String: String].self, from: try #require(request.httpBody)
)
#expect(body["decision"] == "deny")
}
@Test("403token 过期/已用)→ 本地通知兜底提示进 App文案不含 token背景任务成对")
func decisionRejectedPostsExpiredNotice() async throws {
let host = try Self.makeHost(base: Self.hostABase)
let harness = Self.makeHarness(hosts: [host])
await harness.http.queueSuccess(
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.allow, sessionId: sessionId, token: Self.token)
)
#expect(harness.notices.posted.count == 1)
let notice = try #require(harness.notices.posted.first)
#expect(notice.title == PushDecisionCopy.expiredTitle)
#expect(notice.body == PushDecisionCopy.expiredBody)
// capability token
#expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token))
#expect(harness.backgroundTasks.endedTokens.count == 1)
}
@Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对")
func decisionTransportFailurePostsFailedNotice() async throws {
let host = try Self.makeHost(base: Self.hostABase)
let harness = Self.makeHarness(hosts: [host])
await harness.http.queueFailure(
method: "POST", url: try Self.decisionURL(base: Self.hostABase),
error: URLError(.cannotConnectToHost)
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.allow, sessionId: sessionId, token: Self.token)
)
#expect(harness.notices.posted.count == 1)
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
#expect(harness.backgroundTasks.begunNames.count == 1)
#expect(harness.backgroundTasks.endedTokens.count == 1)
}
@Test("多主机 → 先 RO 查 /live-sessions无 Origin定位持有会话的主机再对其 POST")
func decisionMultiHostResolvesOwner() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let harness = Self.makeHarness(hosts: [hostA, hostB])
try await Self.queueTwoHostProbe(harness)
await harness.http.queueSuccess(
method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.allow, sessionId: sessionId, token: Self.token)
)
let requests = await harness.http.recordedRequests
let gets = requests.filter { $0.httpMethod == "GET" }
#expect(gets.count == 2)
for get in gets { // RO Origin
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
}
let posts = requests.filter { $0.httpMethod == "POST" }
#expect(posts.count == 1)
#expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase)))
#expect(harness.notices.posted.isEmpty)
}
@Test("多主机但会话无处可寻 → 不 POST本地通知兜底")
func decisionUnresolvableHostFailsVisibly() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let harness = Self.makeHarness(hosts: [hostA, hostB])
await harness.http.queueSuccess(
method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase),
body: Self.liveSessionsBody(containing: nil)
)
await harness.http.queueSuccess(
method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase),
body: Self.liveSessionsBody(containing: nil)
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(
.decision(.allow, sessionId: sessionId, token: Self.token)
)
let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" }
#expect(posts.isEmpty)
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
}
// MARK: - handle(.openSession)
@Test("单主机点按 → openSession(host, id),不发决策 POST也不开背景任务")
func openSessionSingleHostRoutes() async throws {
let host = try Self.makeHost(base: Self.hostABase)
let harness = Self.makeHarness(hosts: [host])
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(.openSession(sessionId: sessionId))
#expect(harness.recorder.opened.count == 1)
#expect(harness.recorder.opened.first?.host == host)
#expect(harness.recorder.opened.first?.sessionId == sessionId)
#expect(await harness.http.recordedRequests.isEmpty)
#expect(harness.backgroundTasks.begunNames.isEmpty)
}
@Test("多主机点按 → RO 定位后打开持有主机")
func openSessionMultiHostResolves() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let harness = Self.makeHarness(hosts: [hostA, hostB])
try await Self.queueTwoHostProbe(harness)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(.openSession(sessionId: sessionId))
#expect(harness.recorder.opened.first?.host == hostB)
}
@Test("无主机可配 → 不打开、不 crashApp 已被点按拉起,落在列表页即可)")
func openSessionUnresolvedIsSafeNoOp() async throws {
let harness = Self.makeHarness(hosts: [])
let sessionId = try #require(UUID(uuidString: Self.sessionId))
await harness.handler.handle(.openSession(sessionId: sessionId))
#expect(harness.recorder.opened.isEmpty)
}
// MARK: -
@Test(".invalidPayload → 计数 +1无网络、无通知、无路由")
func invalidPayloadCountedOnly() async throws {
let harness = Self.makeHarness(hosts: [])
await harness.handler.handle(.invalidPayload)
#expect(harness.handler.invalidPayloadCount == 1)
#expect(await harness.http.recordedRequests.isEmpty)
#expect(harness.notices.posted.isEmpty)
#expect(harness.recorder.opened.isEmpty)
}
@Test(".dismissed → 完全 no-op")
func dismissedIsNoOp() async throws {
let harness = Self.makeHarness(hosts: [])
await harness.handler.handle(.dismissed)
#expect(harness.handler.invalidPayloadCount == 0)
#expect(await harness.http.recordedRequests.isEmpty)
}
}

View File

@@ -0,0 +1,104 @@
import APIClient
import Foundation
import Testing
import UserNotifications
import WireProtocol
@testable import WebTerm
/// T-iOS-21 · `NotificationActionHandler.parse`payload
///push payload sessionId DeepLinkRouter
/// v4 token v4 .invalidPayload
@MainActor
@Suite("NotificationActionHandler.parse")
struct NotificationActionParseTests {
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// capability token = crypto.randomUUID()src/server.ts:445 v4
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
private static func gateUserInfo(
sessionId: String = sessionId, token: Any? = token
) -> [AnyHashable: Any] {
var info: [AnyHashable: Any] = [
"aps": ["category": "WEBTERM_GATE"],
"sessionId": sessionId,
"cls": "needs-input",
]
if let token { info["token"] = token }
return info
}
@Test("Allow 动作 + 合法 payload → .decision(.allow)")
func parseAllowAction() throws {
let parsed = NotificationActionHandler.parse(
actionIdentifier: GateNotificationCategory.allowActionId,
userInfo: Self.gateUserInfo()
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
#expect(parsed == .decision(.allow, sessionId: sessionId, token: Self.token))
}
@Test("Deny 动作 + 合法 payload → .decision(.deny)")
func parseDenyAction() throws {
let parsed = NotificationActionHandler.parse(
actionIdentifier: GateNotificationCategory.denyActionId,
userInfo: Self.gateUserInfo()
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
#expect(parsed == .decision(.deny, sessionId: sessionId, token: Self.token))
}
@Test("决策动作缺 token / token 非字符串 / token 非 v4 形状 → .invalidPayload")
func parseDecisionRejectsBadToken() {
for bad in [nil, 42 as Any, "not-a-token", "11111111-2222-1333-8444-555555555555"] {
let parsed = NotificationActionHandler.parse(
actionIdentifier: GateNotificationCategory.allowActionId,
userInfo: Self.gateUserInfo(token: bad)
)
#expect(parsed == .invalidPayload)
}
}
@Test("决策动作 sessionId 非法(非 v4 / 缺失)→ .invalidPayload")
func parseDecisionRejectsBadSessionId() {
let badId = NotificationActionHandler.parse(
actionIdentifier: GateNotificationCategory.allowActionId,
userInfo: Self.gateUserInfo(sessionId: "garbage")
)
#expect(badId == .invalidPayload)
let missing = NotificationActionHandler.parse(
actionIdentifier: GateNotificationCategory.denyActionId,
userInfo: ["token": Self.token]
)
#expect(missing == .invalidPayload)
}
@Test("默认点按 + 合法 sessionId → .openSession复用 DeepLinkRouter 同一解析)")
func parseDefaultTapRoutes() throws {
let parsed = NotificationActionHandler.parse(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: Self.gateUserInfo(token: nil) // done token
)
let sessionId = try #require(UUID(uuidString: Self.sessionId))
#expect(parsed == .openSession(sessionId: sessionId))
}
@Test("默认点按 + 非法 payload → .invalidPayload")
func parseDefaultTapRejectsGarbage() {
let parsed = NotificationActionHandler.parse(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: ["sessionId": "'; DROP TABLE sessions;--"]
)
#expect(parsed == .invalidPayload)
}
@Test("dismiss / 未知动作 id → .dismissedno-op")
func parseDismissAndUnknownActions() {
#expect(NotificationActionHandler.parse(
actionIdentifier: UNNotificationDismissActionIdentifier,
userInfo: Self.gateUserInfo()
) == .dismissed)
#expect(NotificationActionHandler.parse(
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
) == .dismissed)
}
}

View File

@@ -0,0 +1,433 @@
import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-12 · PairingViewModel (plan §7 / §5.4). Probe LOGIC is T-iOS-8's
/// domain these tests cover the state mapping around it:
/// scan/manual input confirm gate (ZERO network before the user says go)
/// probe Host into the store + navigate signal, error taxonomy copy +
/// action, and the §5.4 warning tiers.
///
/// Determinism: the probe is injected as a closure; scripted results come from
/// an actor-backed `ProbeScript` (also the non-invocation counter). The
/// end-to-end test runs the REAL `runPairingProbe` over `FakeHTTPTransport` +
/// `FakeTransport` zero real network, zero real waits.
@MainActor
@Suite("PairingViewModel")
struct PairingViewModelTests {
// MARK: - Probe script (scripted results + invocation recording)
private actor ProbeScript {
private(set) var calls: [HostEndpoint] = []
private var results: [Result<HostEndpoint, PairingError>]
/// Empty `results` = always succeed with the probed endpoint.
init(results: [Result<HostEndpoint, PairingError>] = []) {
self.results = results
}
func invoke(_ endpoint: HostEndpoint) -> Result<HostEndpoint, PairingError> {
calls = calls + [endpoint]
guard let next = results.first else { return .success(endpoint) }
results = Array(results.dropFirst())
return next
}
}
private func makeViewModel(
store: any HostStore = InMemoryHostStore(),
script: ProbeScript
) -> PairingViewModel {
PairingViewModel(store: store, probe: { await script.invoke($0) })
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { [] }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
}
// MARK: - Scan confirm gate REAL two-step probe store + navigate
@Test("scan shows the HostEndpoint-parsed address, no network until confirm; then probe → Host into store + navigate signal")
func scanConfirmGateThenRealProbePairsHost() async throws {
// Arrange: REAL runPairingProbe over fakes the strongest proof that
// (a) nothing touches the wire before the user confirms and (b) the
// production closure wiring is exercised end to end.
let http = FakeHTTPTransport()
let ws = FakeTransport()
let store = InMemoryHostStore()
let viewModel = PairingViewModel(
store: store,
probe: { await runPairingProbe(endpoint: $0, http: http, ws: ws) }
)
let base = "http://192.168.1.5:3000"
let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
// Script the full happy path UP FRONT if the VM probed before
// confirm, recordedRequests would already be non-empty below.
await http.queueSuccess(
url: try #require(URL(string: "\(base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(base)/live-sessions/\(probeSessionId)")),
status: 204
)
// Act: scan the web UI QR payload (public/qr.ts encodes location.origin).
viewModel.handleScannedCode(base)
// Assert: confirm state shows the single-point-derived address and the
// scanned host has seen ZERO network traffic (probe would GET, probe
// would spawn a PTY on the target untrusted scan input, plan §5).
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.displayAddress == base)
#expect(pending.endpoint.originHeader == base)
#expect(pending.warning == .plaintextLAN)
#expect(await http.recordedRequests.isEmpty)
#expect(await ws.connectAttempts.isEmpty)
#expect(viewModel.pairedHost == nil)
// Act: name the host, then confirm ONLY now may the probe run.
viewModel.hostName = "书房 Mac"
await viewModel.confirmConnect()
// Assert: paired + navigate signal; Host{id,name} constructed by the
// VM (§3.4 contract ruling) and upserted into the store.
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.name == "书房 Mac")
#expect(host.endpoint == pending.endpoint)
#expect(viewModel.pairedHost == host)
#expect(try await store.loadAll() == [host])
// Assert: exactly the two probe HTTP calls, in order, Origin stamped
// iff guarded (§3.4 ) and one WS attach round-trip, closed.
let requests = await http.recordedRequests
#expect(requests.map(\.httpMethod) == ["GET", "DELETE"])
#expect(requests[0].value(forHTTPHeaderField: "Origin") == nil)
#expect(requests[1].value(forHTTPHeaderField: "Origin") == base)
#expect(await ws.connectAttempts.count == 1)
#expect(await ws.closeCallCount == 1)
// Assert: a stray scan cannot preempt a finished pairing.
viewModel.handleScannedCode("http://10.0.0.9:3000")
#expect(viewModel.phase == .paired(host))
}
// MARK: - Input boundary: scan payloads are untrusted external input
@Test("non-http(s) scan payloads are rejected with copy and zero probe calls", arguments: [
"ftp://192.168.1.5:3000",
"ws://192.168.1.5:3000",
"javascript:alert(1)",
"WIFI:S:mynet;T:WPA;P:hunter2;;",
"",
])
func scanRejectsNonHTTPPayloads(payload: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.handleScannedCode(payload)
// Assert: stays idle, inline rejection copy, probe never invoked.
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.scanRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - Manual entry (documented decision: reuses the confirm state)
@Test("manual entry reuses the confirm state; a bare host:port gets the http:// convenience prefix", arguments: [
("http://192.168.1.5:3000", "http://192.168.1.5:3000"),
("192.168.1.5:3000", "http://192.168.1.5:3000"),
("https://mac.tail1234.ts.net", "https://mac.tail1234.ts.net"),
])
func manualEntryEntersConfirmState(input: String, expectedOrigin: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert: SAME confirm state as the scan path (uniform §5.4 warning
// surface documented T-iOS-12 decision), still zero probe calls.
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming for \(input), got \(viewModel.phase)")
return
}
#expect(pending.endpoint.originHeader == expectedOrigin)
#expect(await script.calls.isEmpty)
}
@Test("unparseable manual input is rejected with copy", arguments: [
"", " ", "://nope", "http://",
])
func manualEntryRejectsUnparseableInput(input: String) async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
// Act
viewModel.submitManualURL(input)
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == PairingCopy.manualRejected)
#expect(await script.calls.isEmpty)
}
// MARK: - §5.4 warning tiers (shown on the confirm page)
@Test("warning tiers follow the §5.4 table", arguments: [
// public host strongest BLOCKING warning, http AND https alike
("http://203.0.113.7:3000", PairingViewModel.SecurityWarning.publicHostBlocking),
("https://example.com", PairingViewModel.SecurityWarning.publicHostBlocking),
// ws:// to RFC1918 / link-local / .local non-blocking plaintext notice
("http://192.168.1.5:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://10.1.2.3:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://172.20.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://169.254.10.2:3000", PairingViewModel.SecurityWarning.plaintextLAN),
("http://mymac.local:3000", PairingViewModel.SecurityWarning.plaintextLAN),
// Tailscale (100.64/10 CGNAT or MagicDNS *.ts.net) no plaintext
// warning (WireGuard already encrypts); positive badge instead
("http://100.101.102.103:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
("http://mac.tail1234.ts.net:3000", PairingViewModel.SecurityWarning.tailscaleEncrypted),
// loopback none; https to a private-class host none
("http://127.0.0.1:3000", PairingViewModel.SecurityWarning.none),
("http://localhost:3000", PairingViewModel.SecurityWarning.none),
("https://192.168.1.5:3000", PairingViewModel.SecurityWarning.none),
("https://mac.tail1234.ts.net", PairingViewModel.SecurityWarning.none),
])
func warningTiersFollowTable(url: String, expected: PairingViewModel.SecurityWarning) throws {
// Arrange
let baseURL = try #require(URL(string: url))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
// Act & Assert
#expect(PairingViewModel.warning(for: endpoint) == expected)
}
@Test("public-host blocking warning requires explicit acknowledgement before any probe")
func blockingWarningGatesTheProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://203.0.113.7:3000")
guard case .confirming(let pending) = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(pending.warning == .publicHostBlocking)
// Act: confirm WITHOUT acknowledging the risk.
await viewModel.confirmConnect()
// Assert: no probe, still confirming, the UI is told to demand the ack.
#expect(await script.calls.isEmpty)
#expect(viewModel.phase == .confirming(pending))
#expect(viewModel.needsPublicRiskAcknowledgement)
// Act: explicit acknowledgement, then confirm again.
viewModel.hasAcknowledgedPublicRisk = true
await viewModel.confirmConnect()
// Assert: probe ran exactly once and pairing completed.
#expect(await script.calls.count == 1)
guard case .paired = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
}
// MARK: - PairingError taxonomy inline copy + recovery action
@Test("every PairingError maps to actionable copy and the right recovery action", arguments: [
(PairingError.localNetworkDenied,
PairingViewModel.RecoveryAction.openLocalNetworkSettings,
["本地网络", "设置"]),
(PairingError.hostUnreachable(underlying: "Connection refused"),
PairingViewModel.RecoveryAction.retry,
["Connection refused"]),
(PairingError.httpOkButNotWebTerminal,
PairingViewModel.RecoveryAction.retry,
["端口"]),
(PairingError.originRejected(hint: "在主机加 ALLOWED_ORIGINS=http://192.168.1.5:3000"),
PairingViewModel.RecoveryAction.retry,
["ALLOWED_ORIGINS=http://192.168.1.5:3000"]),
(PairingError.atsBlocked(host: "198.18.0.1"),
PairingViewModel.RecoveryAction.retry,
["ATS", "198.18.0.1", "tailscale serve", "例外"]),
(PairingError.tlsFailure,
PairingViewModel.RecoveryAction.retry,
["TLS"]),
(PairingError.timeout,
PairingViewModel.RecoveryAction.retry,
["超时"]),
])
func pairingErrorMapsToCopyAndAction(
error: PairingError,
expectedAction: PairingViewModel.RecoveryAction,
requiredFragments: [String]
) async throws {
// Arrange: private-class host so no blocking-warning gate interferes.
let script = ProbeScript(results: [.failure(error)])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed for \(error), got \(viewModel.phase)")
return
}
#expect(failure.action == expectedAction)
#expect(!failure.message.isEmpty)
for fragment in requiredFragments {
#expect(failure.message.contains(fragment),
"copy for \(error) must contain \(fragment)")
}
}
@Test("originRejected surfaces the probe's hint VERBATIM as the whole message")
func originRejectedHintIsVerbatim() async throws {
// Arrange: the hint the probe derives from endpoint.originHeader is
// already the complete actionable copy never rewrap or re-derive it.
let hint = "服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=http://192.168.1.5:3000"
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
let script = ProbeScript(results: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == hint)
}
// MARK: - Retry / cancel
@Test("retry re-runs the probe against the same endpoint and can succeed")
func retryRerunsProbeAfterFailure() async throws {
// Arrange: first probe times out, second succeeds.
let script = ProbeScript(results: [.failure(.timeout)])
let store = InMemoryHostStore()
let viewModel = makeViewModel(store: store, script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
await viewModel.confirmConnect()
guard case .failed = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
// Act
await viewModel.retry()
// Assert: two probe calls, same endpoint, pairing completed.
let calls = await script.calls
#expect(calls.count == 2)
#expect(calls.first == calls.last)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(try await store.loadAll() == [host])
}
@Test("cancel returns to idle without ever probing")
func cancelReturnsToIdleWithoutProbe() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
viewModel.cancel()
// Assert
#expect(viewModel.phase == .idle)
#expect(viewModel.inputRejection == nil)
#expect(await script.calls.isEmpty)
}
// MARK: - Store failure is explicit, never silent
@Test("a store failure after a successful probe surfaces an explicit retryable error")
func storeFailureSurfacesExplicitError() async throws {
// Arrange
let script = ProbeScript()
let viewModel = makeViewModel(store: ThrowingHostStore(), script: script)
viewModel.handleScannedCode("http://192.168.1.5:3000")
// Act
await viewModel.confirmConnect()
// Assert: failed with the dedicated copy; no navigate signal.
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.message == PairingCopy.storeFailed)
#expect(failure.action == .retry)
#expect(viewModel.pairedHost == nil)
}
// MARK: - Host naming
@Test("host name defaults to the endpoint host and user names are trimmed")
func hostNameDefaultsAndTrims() async throws {
// Arrange & Act: untouched name default = endpoint host.
let script = ProbeScript()
let storeA = InMemoryHostStore()
let viewModelA = makeViewModel(store: storeA, script: script)
viewModelA.handleScannedCode("http://192.168.1.5:3000")
#expect(viewModelA.hostName == "192.168.1.5")
await viewModelA.confirmConnect()
// Assert
guard case .paired(let defaultNamed) = viewModelA.phase else {
Issue.record("expected .paired, got \(viewModelA.phase)")
return
}
#expect(defaultNamed.name == "192.168.1.5")
// Arrange & Act: user-typed name is trimmed before storing.
let storeB = InMemoryHostStore()
let viewModelB = makeViewModel(store: storeB, script: script)
viewModelB.handleScannedCode("http://192.168.1.5:3000")
viewModelB.hostName = " 书房 Mac "
await viewModelB.confirmConnect()
// Assert
guard case .paired(let userNamed) = viewModelB.phase else {
Issue.record("expected .paired, got \(viewModelB.phase)")
return
}
#expect(userNamed.name == "书房 Mac")
}
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
import Testing
@testable import WebTerm
/// T-iOS-15 · Privacy-shade state mapping (plan §7 T-iOS-15, security-critical).
///
/// The rule under test is EXACT: the shade covers whenever
/// `scenePhase != .active` NOT just `.inactive`. Entering the app switcher
/// is `.inactive`, but the moment iOS writes the switcher snapshot to disk is
/// `.background`; covering only one of the two leaks terminal content
/// (API keys / tokens / source) into the on-disk snapshot.
@MainActor
@Suite("PrivacyShadePolicy")
struct PrivacyShadeTests {
@Test("scenePhase == .active → 遮罩隐藏(恢复终端)")
func activeShowsTerminal() {
#expect(!PrivacyShadePolicy.isShadeVisible(for: .active))
}
@Test("scenePhase == .inactive → 遮罩可见(切换器入口态)")
func inactiveIsCovered() {
#expect(PrivacyShadePolicy.isShadeVisible(for: .inactive))
}
@Test("scenePhase == .background → 遮罩可见(快照写盘时刻)")
func backgroundIsCovered() {
#expect(PrivacyShadePolicy.isShadeVisible(for: .background))
}
}

View File

@@ -0,0 +1,118 @@
import APIClient
import Foundation
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-26 · ProjectDetailViewModel phase + 400/404/500
/// DiffViewModel
///
/// fetch `forHost` `APIClient.projectDetail(path:)`
/// builder// APIClient VM
@MainActor
@Suite("ProjectDetailViewModel")
struct ProjectDetailViewModelTests {
private nonisolated static func detail(
sessions: [ProjectSessionRef] = [],
worktrees: [WorktreeInfo] = [],
hasClaudeMd: Bool = false
) -> ProjectDetail {
ProjectDetail(
name: "web-terminal", path: "/repos/web-terminal", isGit: true,
branch: "main", dirty: true, worktrees: worktrees,
sessions: sessions, hasClaudeMd: hasClaudeMd, claudeMd: nil
)
}
@Test("初始 .loadingload 成功 → .loadedsessions/worktrees/hasClaudeMd 透传)")
func loadSuccess() async throws {
let payload = Self.detail(
sessions: [ProjectSessionRef(
id: UUID(), title: "web-terminal", status: .working,
clientCount: 2, createdAt: 1, exited: false
)],
worktrees: [WorktreeInfo(
path: "/repos/wt", branch: "feat/x", head: "abc",
isMain: false, isCurrent: true, locked: nil, prunable: nil
)],
hasClaudeMd: true
)
let vm = ProjectDetailViewModel(path: payload.path, fetch: { payload })
#expect(vm.phase == .loading)
await vm.load()
#expect(vm.phase == .loaded(payload))
}
@Test(
"错误映射400→pathInvalid、404→notFound、500/解码/传输→unavailable",
arguments: [
(APIClientError.projectPathInvalid, ProjectDetailViewModel.Failure.pathInvalid),
(APIClientError.projectNotFound, .notFound),
(APIClientError.projectDetailUnavailable, .unavailable),
(APIClientError.invalidResponseBody, .unavailable),
]
)
func errorMapping(
error: APIClientError, expected: ProjectDetailViewModel.Failure
) async {
let vm = ProjectDetailViewModel(path: "/p", fetch: { throw error })
await vm.load()
#expect(vm.phase == .failed(expected))
}
@Test("传输层任意错误 → .unavailable可重试兜底绝不 crash")
func transportErrorFallsBackToUnavailable() async {
let vm = ProjectDetailViewModel(
path: "/p", fetch: { throw URLError(.notConnectedToInternet) }
)
await vm.load()
#expect(vm.phase == .failed(.unavailable))
}
@Test("重试路径failed 后再次 load 成功 → loaded")
func retryAfterFailure() async {
let flag = FailOnceFlag()
let payload = Self.detail()
let vm = ProjectDetailViewModel(path: payload.path, fetch: {
if await flag.consumeShouldFail() { throw APIClientError.projectDetailUnavailable }
return payload
})
await vm.load()
#expect(vm.phase == .failed(.unavailable))
await vm.load()
#expect(vm.phase == .loaded(payload))
}
@Test("failureCopy三种失败各有非空、互不相同的中文文案")
func failureCopyIsDistinct() {
let copies = [
ProjectDetailScreen.failureCopy(.pathInvalid),
ProjectDetailScreen.failureCopy(.notFound),
ProjectDetailScreen.failureCopy(.unavailable),
]
for copy in copies {
#expect(!copy.title.isEmpty)
#expect(!copy.detail.isEmpty)
}
#expect(Set(copies.map(\.title)).count == copies.count)
}
}
/// @Sendable fetch actor
private actor FailOnceFlag {
private var shouldFail = true
func consumeShouldFail() -> Bool {
defer { shouldFail = false }
return shouldFail
}
}

View File

@@ -0,0 +1,211 @@
import APIClient
import Foundation
import Testing
@testable import WebTerm
/// T-iOS-26 · Projects web v0.6
/// public/projects.ts filterProjects / sortProjects / groupProjects /
/// displayLabel
/// - namespace = namespace
/// - < MIN_GROUP_SIZE(2) namespace Other
/// - **** "Active now" sessions[].exited
/// active assert reality
/// - namespace flat chrome 退
/// - key web " active" / " other" / namespace
/// collapsed /prefs key
struct ProjectGroupingTests {
// MARK: - Fixtures
private static func session(exited: Bool) -> ProjectSessionRef {
ProjectSessionRef(
id: UUID(), title: nil, status: .working,
clientCount: 1, createdAt: 0, exited: exited
)
}
private static func project(
_ name: String,
path: String? = nil,
lastActiveMs: Int? = nil,
running: Bool = false,
exitedSession: Bool = false
) -> ProjectInfo {
var sessions: [ProjectSessionRef] = []
if running { sessions.append(session(exited: false)) }
if exitedSession { sessions.append(session(exited: true)) }
return ProjectInfo(
name: name, path: path ?? "/repos/\(name)", isGit: true,
branch: "main", dirty: nil, lastActiveMs: lastActiveMs,
sessions: sessions
)
}
// MARK: - filter name/path filterProjects
@Test("filter空查询原样返回全部含前后空白仅剩空白的查询")
func filterEmptyQueryReturnsAll() {
let projects = [Self.project("a"), Self.project("b")]
#expect(ProjectGrouping.filter(projects, query: "") == projects)
#expect(ProjectGrouping.filter(projects, query: " ") == projects)
}
@Test("filtername 与 path 子串均命中,大小写不敏感")
func filterMatchesNameAndPathCaseInsensitively() {
let byName = Self.project("Billo.Platform.api", path: "/x/one")
let byPath = Self.project("zzz", path: "/Users/dev/BILLO-extras")
let miss = Self.project("other", path: "/y/two")
let result = ProjectGrouping.filter([byName, byPath, miss], query: "billo")
#expect(result == [byName, byPath])
}
// MARK: - sortfavourites lastActiveMs
@Test("sort收藏优先其余按 lastActiveMs 降序,缺失视为 0")
func sortFavouritesFirstThenRecency() {
let fav = Self.project("fav", lastActiveMs: 1)
let newer = Self.project("newer", lastActiveMs: 100)
let older = Self.project("older", lastActiveMs: 50)
let never = Self.project("never", lastActiveMs: nil)
let result = ProjectGrouping.sort(
[never, older, fav, newer], favourites: [fav.path]
)
#expect(result == [fav, newer, older, never])
}
@Test("sort全键相等时保持输入顺序稳定排序镜像 JS stable sort")
func sortIsStableOnTies() {
let a = Self.project("a", lastActiveMs: 5)
let b = Self.project("b", lastActiveMs: 5)
let c = Self.project("c", lastActiveMs: 5)
#expect(ProjectGrouping.sort([a, b, c], favourites: []) == [a, b, c])
}
// MARK: - namespace
@Test("group≥2 成员的 namespace 成组,单成员与无点名塌进 Other")
func groupNamespacesAndOther() {
let apiProj = Self.project("Billo.Platform.api", lastActiveMs: 2)
let webProj = Self.project("Billo.Platform.web", lastActiveMs: 1)
let solo = Self.project("solo.thing")
let plain = Self.project("plain")
let groups = ProjectGrouping.group(
[apiProj, webProj, solo, plain], favourites: []
)
#expect(groups.count == 2)
#expect(groups[0].kind == .namespace)
#expect(groups[0].key == "Billo.Platform")
#expect(groups[0].label == "Billo.Platform")
#expect(groups[0].projects == [apiProj, webProj])
#expect(groups[1].kind == .other)
#expect(groups[1].key == ProjectGrouping.otherGroupKey)
// web groupProjectsother noNamespace
// namespacepublic/projects.ts:163-166 plain
#expect(groups[1].projects == [plain, solo])
}
@Test("groupnamespace 桶大小写不敏感合并,显示名取首见大小写")
func groupBucketsCaseInsensitively() {
let first = Self.project("Billo.Platform.a")
let second = Self.project("billo.platform.b")
let groups = ProjectGrouping.group([first, second], favourites: [])
#expect(groups.count == 1)
#expect(groups[0].key == "Billo.Platform")
#expect(groups[0].projects == [first, second])
}
@Test("group没有任何 namespace 组 → 单一 flat 组(全部项目,无 chrome 回退)")
func groupFlatFallback() {
let solo = Self.project("solo.thing", lastActiveMs: 1)
let plain = Self.project("plain", lastActiveMs: 2)
let groups = ProjectGrouping.group([solo, plain], favourites: [])
#expect(groups.count == 1)
#expect(groups[0].kind == .flat)
#expect(groups[0].key == ProjectGrouping.otherGroupKey)
#expect(groups[0].projects == [plain, solo]) // recency
#expect(groups[0].isCollapsible == false)
}
@Test("group运行中的项目复制进置顶 Active 组activeCount 逐组统计")
func groupActivePinning() {
let runningA = Self.project("Billo.Platform.api", lastActiveMs: 2, running: true)
let idleB = Self.project("Billo.Platform.web", lastActiveMs: 1)
let exitedOnly = Self.project("Billo.Platform.cli", exitedSession: true)
let groups = ProjectGrouping.group(
[runningA, idleB, exitedOnly], favourites: []
)
#expect(groups.count == 2)
#expect(groups[0].kind == .active)
#expect(groups[0].key == ProjectGrouping.activeGroupKey)
#expect(groups[0].projects == [runningA]) // exited running
#expect(groups[0].activeCount == 1)
#expect(groups[1].kind == .namespace)
#expect(groups[1].projects.contains(runningA)) //
#expect(groups[1].activeCount == 1)
#expect(groups[0].isCollapsible == false) // Active now
#expect(groups[1].isCollapsible == true)
}
@Test("groupnamespace 组按组内最新 lastActiveMs 降序,平局按 label 升序")
func groupOrdersNamespacesByRecencyThenLabel() {
let older1 = Self.project("Aaa.Team.x", lastActiveMs: 10)
let older2 = Self.project("Aaa.Team.y", lastActiveMs: 20)
let newer1 = Self.project("Zzz.Team.x", lastActiveMs: 5)
let newer2 = Self.project("Zzz.Team.y", lastActiveMs: 99)
let tieA1 = Self.project("Bbb.Tie.x", lastActiveMs: 20)
let tieA2 = Self.project("Bbb.Tie.y", lastActiveMs: 3)
let groups = ProjectGrouping.group(
[older1, older2, newer1, newer2, tieA1, tieA2], favourites: []
)
#expect(groups.map(\.key) == ["Zzz.Team", "Aaa.Team", "Bbb.Tie"])
}
// MARK: - displayLabel namespace
@Test("displayLabelnamespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
func displayLabelStripsNamespacePrefix() {
#expect(ProjectGrouping.displayLabel(
name: "Billo.Platform.api", groupKey: "Billo.Platform"
) == "api")
#expect(ProjectGrouping.displayLabel(
name: "billo.platform.api", groupKey: "Billo.Platform"
) == "api")
#expect(ProjectGrouping.displayLabel(
name: "unrelated", groupKey: "Billo.Platform"
) == "unrelated")
#expect(ProjectGrouping.displayLabel(
name: "Billo.Platform.api", groupKey: ProjectGrouping.activeGroupKey
) == "Billo.Platform.api")
#expect(ProjectGrouping.displayLabel(
name: "Billo.Platform.api", groupKey: ProjectGrouping.otherGroupKey
) == "Billo.Platform.api")
}
@Test("group组内排序收藏优先favourites 参与 sortProjects")
func groupSortsFavouritesFirstWithinGroup() {
let apiProj = Self.project("Ns.Grp.api", lastActiveMs: 9)
let webProj = Self.project("Ns.Grp.web", lastActiveMs: 1)
let groups = ProjectGrouping.group(
[apiProj, webProj], favourites: [webProj.path]
)
#expect(groups.count == 1)
#expect(groups[0].projects == [webProj, apiProj])
}
}

View File

@@ -0,0 +1,90 @@
import APIClient
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-26 · "" 线`TerminalSessionController`
/// `spawnCwd` + `bootstrapInput` 线 = `attach(null, cwd)`
/// `input("claude\r")`engine attach-first bootstrap
/// attach 线 sessionId cwd bootstrap
///
/// `SessionEngine` over `FakeTransport`
/// `openTask` + `waitUntilProcessed` open+send attach
///
@MainActor
@Suite("ProjectOpenWiring")
struct ProjectOpenWiringTests {
private struct Fixture {
let transport: FakeTransport
let host: HostRegistry.Host
let environment: AppEnvironment
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let transport = FakeTransport()
let defaults = try #require(UserDefaults(suiteName: "ProjectOpenWiringTests"))
return Fixture(
transport: transport,
host: HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint),
environment: AppEnvironment(
hostStore: InMemoryHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: FakeHTTPTransport(),
termTransport: transport,
probe: { _ in .failure(.timeout) }
)
)
}
/// controller open+bootstrap Task send /
/// attach .connecting/.connected VM
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
await controller.openTask?.value
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
}
@Test("spawn 变体:帧序 = attach(null, cwd) → input(claude\\r)")
func spawnSendsAttachWithCwdThenBootstrapInput() async throws {
let fixture = try makeFixture()
let controller = TerminalSessionController(
host: fixture.host, sessionId: nil, environment: fixture.environment,
onPendingChanged: { _, _ in },
spawnCwd: "/repos/api",
bootstrapInput: ProjectLaunch.claudeBootstrapInput
)
controller.start()
await awaitAttachSettled(controller)
let frames = await fixture.transport.sentFrames
#expect(frames == [
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")),
MessageCodec.encode(.input(data: ProjectLaunch.claudeBootstrapInput)),
])
controller.teardown()
}
@Test("常规打开(带 sessionId只有 attach无 cwd、无 bootstrap")
func plainOpenSendsBareAttach() async throws {
let fixture = try makeFixture()
let sessionId = UUID()
let controller = TerminalSessionController(
host: fixture.host, sessionId: sessionId,
environment: fixture.environment,
onPendingChanged: { _, _ in }
)
controller.start()
await awaitAttachSettled(controller)
let frames = await fixture.transport.sentFrames
#expect(frames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
controller.teardown()
}
}

Some files were not shown because too many files have changed in this diff Show More