From 5098643355b72932bbad79bc2c0303cea1d3109e Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 5 Jul 2026 00:13:14 +0200 Subject: [PATCH] feat(ios): W3 UI layer + integration CI + ntfy docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ios.yml | 65 +- docs/PROGRESS_LOG.md | 10 +- .../WebTerm/Components/AwayDigestView.swift | 111 ++++ ios/App/WebTerm/Components/GateBanner.swift | 86 +++ ios/App/WebTerm/Components/KeyBar.swift | 227 +++++++ .../WebTerm/Components/PlanGateSheet.swift | 73 +++ .../WebTerm/Components/ReconnectBanner.swift | 96 +++ .../WebTerm/Components/TelemetryChips.swift | 127 ++++ ios/App/WebTerm/Screens/PairingScreen.swift | 350 +++++++++++ .../WebTerm/Screens/SessionListScreen.swift | 235 +++++++ ios/App/WebTerm/Screens/TerminalScreen.swift | 145 +++++ .../WebTerm/ViewModels/GateViewModel.swift | 319 ++++++++++ .../WebTerm/ViewModels/PairingViewModel.swift | 398 ++++++++++++ .../ViewModels/SessionListViewModel.swift | 351 +++++++++++ .../ViewModels/TerminalViewModel.swift | 301 +++++++++ ios/App/WebTermTests/GateViewModelTests.swift | 402 ++++++++++++ ios/App/WebTermTests/KeyBarTests.swift | 132 ++++ .../WebTermTests/PairingViewModelTests.swift | 433 +++++++++++++ .../SessionListViewModelTests.swift | 581 +++++++++++++++++ .../WebTermTests/TerminalViewModelTests.swift | 280 +++++++++ ios/IntegrationTests/LiveServerTests.swift | 13 - .../MirrorLifecycleTests.swift | 100 +++ ios/IntegrationTests/OriginGuardTests.swift | 67 ++ ios/IntegrationTests/OriginSpikeTests.swift | 591 ------------------ ios/IntegrationTests/Package.swift | 9 +- ios/IntegrationTests/ProtocolFlowTests.swift | 74 +++ ios/IntegrationTests/ReplayTests.swift | 79 +++ ios/IntegrationTests/ServerHarness.swift | 260 ++++++++ ios/IntegrationTests/WSTestClient.swift | 281 +++++++++ ios/IntegrationTests/scripts/coverage-gate.sh | 72 +++ .../Sources/SessionCore/KeyByteMap.swift | 57 ++ .../SessionCoreTests/KeyByteMapTests.swift | 77 +++ ios/README.md | 160 +++++ 33 files changed, 5946 insertions(+), 616 deletions(-) create mode 100644 ios/App/WebTerm/Components/AwayDigestView.swift create mode 100644 ios/App/WebTerm/Components/GateBanner.swift create mode 100644 ios/App/WebTerm/Components/KeyBar.swift create mode 100644 ios/App/WebTerm/Components/PlanGateSheet.swift create mode 100644 ios/App/WebTerm/Components/ReconnectBanner.swift create mode 100644 ios/App/WebTerm/Components/TelemetryChips.swift create mode 100644 ios/App/WebTerm/Screens/PairingScreen.swift create mode 100644 ios/App/WebTerm/Screens/SessionListScreen.swift create mode 100644 ios/App/WebTerm/Screens/TerminalScreen.swift create mode 100644 ios/App/WebTerm/ViewModels/GateViewModel.swift create mode 100644 ios/App/WebTerm/ViewModels/PairingViewModel.swift create mode 100644 ios/App/WebTerm/ViewModels/SessionListViewModel.swift create mode 100644 ios/App/WebTerm/ViewModels/TerminalViewModel.swift create mode 100644 ios/App/WebTermTests/GateViewModelTests.swift create mode 100644 ios/App/WebTermTests/KeyBarTests.swift create mode 100644 ios/App/WebTermTests/PairingViewModelTests.swift create mode 100644 ios/App/WebTermTests/SessionListViewModelTests.swift create mode 100644 ios/App/WebTermTests/TerminalViewModelTests.swift delete mode 100644 ios/IntegrationTests/LiveServerTests.swift create mode 100644 ios/IntegrationTests/MirrorLifecycleTests.swift create mode 100644 ios/IntegrationTests/OriginGuardTests.swift delete mode 100644 ios/IntegrationTests/OriginSpikeTests.swift create mode 100644 ios/IntegrationTests/ProtocolFlowTests.swift create mode 100644 ios/IntegrationTests/ReplayTests.swift create mode 100644 ios/IntegrationTests/ServerHarness.swift create mode 100644 ios/IntegrationTests/WSTestClient.swift create mode 100755 ios/IntegrationTests/scripts/coverage-gate.sh create mode 100644 ios/Packages/SessionCore/Sources/SessionCore/KeyByteMap.swift create mode 100644 ios/Packages/SessionCore/Tests/SessionCoreTests/KeyByteMapTests.swift create mode 100644 ios/README.md diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 220937c..b9a50d0 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -1,5 +1,19 @@ -# iOS CI skeleton (T-iOS-1). T-iOS-16 hardens this: real Node-server -# integration tests + the per-package 80% coverage gate (PLAN_IOS_CLIENT §9). +# 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/

/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: 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: @@ -7,12 +21,22 @@ on: 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: @@ -23,10 +47,20 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode 16.3 run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer - - name: swift test (${{ matrix.package }}) - run: swift test --package-path ios/Packages/${{ matrix.package }} + - name: swift test + own-sources coverage gate (>= 80%) + run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }} - app-build: + 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 @@ -36,10 +70,25 @@ jobs: run: brew install xcodegen - name: Generate project run: cd ios && xcodegen generate - - name: Build (iPhone 16 simulator) + - name: xcodebuild test (WebTermTests, iPhone 16 simulator) run: | xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ -destination 'platform=iOS Simulator,name=iPhone 16' \ - CODE_SIGNING_ALLOWED=NO build - - name: swift test (IntegrationTests scaffold) + CODE_SIGNING_ALLOWED=NO 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 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index e629249..955e45f 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -42,7 +42,15 @@ - **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)全数在源码落实。 -- 待续: W3(UI 四路+T-iOS-16/17)→ W4 → W5。**W3 编排决策**: 四个 UI 任务全编进同一 App target,主树并行 xcodebuild 必互踩 → [11→12→13→14] 串行链 ∥ T-iOS-16 ∥ T-iOS-17;VM 单测需 WebTermTests target,project.yml 归 T-iOS-1 Owns → orchestrator 预先增补(协调点,同 W1 manifest 先例)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。 +- **[x] W3 UI+提前项(7 agents: [T-iOS-11→12→13→14] 串行链 ∥ 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 集成**。 +- 待续: W4(T-iOS-15 汇合接线)→ W5(T-iOS-18/19 验收)。**偏差(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 应用。 diff --git a/ios/App/WebTerm/Components/AwayDigestView.swift b/ios/App/WebTerm/Components/AwayDigestView.swift new file mode 100644 index 0000000..c00222c --- /dev/null +++ b/ios/App/WebTerm/Components/AwayDigestView.swift @@ -0,0 +1,111 @@ +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 { + static let spacing: CGFloat = 8 + static let rowSpacing: CGFloat = 4 + static let horizontalPadding: CGFloat = 14 + static let verticalPadding: CGFloat = 10 + static let cornerRadius: CGFloat = 12 + /// 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: Metrics.spacing) { + summaryRow + if isExpanded { + recentRows + } + } + .padding(.horizontal, Metrics.horizontalPadding) + .padding(.vertical, Metrics.verticalPadding) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius)) + .accessibilityElement(children: .contain) + } + + private var summaryRow: some View { + HStack(spacing: Metrics.spacing) { + Image(systemName: "clock.arrow.circlepath") + .foregroundStyle(.secondary) + Text(Self.summary(for: digest)) + .font(.footnote.weight(.medium)) + .lineLimit(1) + Spacer(minLength: Metrics.spacing) + if !isExpanded { + Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand) + .labelStyle(.iconOnly) + } + Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss) + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .font(.footnote) + } + + /// Newest `maxRecentRows` entries, oldest→newest. Server text (`label`) is + /// untrusted display input — rendered as plain Text only. + private var recentRows: some View { + VStack(alignment: .leading, spacing: Metrics.rowSpacing) { + ForEach( + Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()), + id: \.offset + ) { _, event in + recentRow(event) + } + } + } + + private func recentRow(_ event: TimelineEvent) -> some View { + HStack(spacing: Metrics.spacing) { + Text(Self.time(of: event)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + Text(event.label) + .font(.caption) + .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) + } +} diff --git a/ios/App/WebTerm/Components/GateBanner.swift b/ios/App/WebTerm/Components/GateBanner.swift new file mode 100644 index 0000000..74cf86b --- /dev/null +++ b/ios/App/WebTerm/Components/GateBanner.swift @@ -0,0 +1,86 @@ +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 affordance→ClientMessage 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 Metrics { + static let spacing: CGFloat = 8 + static let horizontalPadding: CGFloat = 14 + static let verticalPadding: CGFloat = 10 + static let cornerRadius: CGFloat = 12 + 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")" + } + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.spacing) { + Text(Self.message(for: gate)) + .font(.footnote.weight(.semibold)) + .lineLimit(Metrics.messageLineLimit) + HStack(spacing: Metrics.spacing) { + ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in + decisionButton(spec) + } + } + } + .padding(.horizontal, Metrics.horizontalPadding) + .padding(.vertical, Metrics.verticalPadding) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius)) + .accessibilityElement(children: .contain) + } + + private func decisionButton(_ spec: GateChoiceSpec) -> some View { + Button(spec.label) { + onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .tint(tint(for: spec.affordance)) + } + + private func tint(for affordance: GateState.Affordance) -> Color { + switch affordance { + case .approve, .approveAuto, .approveReview: return .green + case .reject: return .red + case .keepPlanning: return .indigo + } + } +} diff --git a/ios/App/WebTerm/Components/KeyBar.swift b/ios/App/WebTerm/Components/KeyBar.swift new file mode 100644 index 0000000..b7ec8c1 --- /dev/null +++ b/ios/App/WebTerm/Components/KeyBar.swift @@ -0,0 +1,227 @@ +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 label→bytes 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: - 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"), + ] +} + +private enum KeyBarMetrics { + static let barHeight: CGFloat = 52 + static let buttonSpacing: CGFloat = 6 + static let contentInset: CGFloat = 8 + static let buttonInsets = NSDirectionalEdgeInsets( + top: 4, leading: 9, bottom: 4, trailing: 9 + ) +} + +// 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 { + var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .plain() + 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) + button.accessibilityLabel = spec.title + 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 + } +} diff --git a/ios/App/WebTerm/Components/PlanGateSheet.swift b/ios/App/WebTerm/Components/PlanGateSheet.swift new file mode 100644 index 0000000..d36a243 --- /dev/null +++ b/ios/App/WebTerm/Components/PlanGateSheet.swift @@ -0,0 +1,73 @@ +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 Metrics { + static let spacing: CGFloat = 12 + static let padding: CGFloat = 20 + static let captionSpacing: CGFloat = 2 + } + + var body: some View { + VStack(alignment: .leading, spacing: Metrics.spacing) { + Text(Self.title) + .font(.headline) + ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in + choiceButton(spec) + } + } + .padding(Metrics.padding) + .presentationDetents([.medium]) + .accessibilityElement(children: .contain) + } + + private func choiceButton(_ spec: GateChoiceSpec) -> some View { + Button { + onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate + } label: { + VStack(alignment: .leading, spacing: Metrics.captionSpacing) { + Text(spec.label) + .font(.body.weight(.medium)) + Text(Self.caption(for: spec.affordance)) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.bordered) + .tint(tint(for: 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 "拒绝本次工具调用" + } + } + + private func tint(for affordance: GateState.Affordance) -> Color { + switch affordance { + case .approve, .approveAuto, .approveReview: return .green + case .reject: return .red + case .keepPlanning: return .indigo + } + } +} diff --git a/ios/App/WebTerm/Components/ReconnectBanner.swift b/ios/App/WebTerm/Components/ReconnectBanner.swift new file mode 100644 index 0000000..67a5edf --- /dev/null +++ b/ios/App/WebTerm/Components/ReconnectBanner.swift @@ -0,0 +1,96 @@ +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 + + private enum Metrics { + static let contentSpacing: CGFloat = 8 + static let horizontalPadding: CGFloat = 14 + static let verticalPadding: CGFloat = 8 + static let backgroundOpacity = 0.9 + } + + var body: some View { + HStack(spacing: Metrics.contentSpacing) { + if isSpinning { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: iconName) + } + Text(text) + .font(.footnote) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, Metrics.horizontalPadding) + .padding(.vertical, Metrics.verticalPadding) + .background(tint.opacity(Metrics.backgroundOpacity), in: Capsule()) + .foregroundStyle(.white) + .accessibilityElement(children: .combine) + } + + 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" + } + } + + private var tint: Color { + switch model { + case .connecting: return .gray + case .reconnecting: return .orange + case .failed: return .red + case .exited: return .indigo + } + } + + 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) · 终端已只读" + } +} diff --git a/ios/App/WebTerm/Components/TelemetryChips.swift b/ios/App/WebTerm/Components/TelemetryChips.swift new file mode 100644 index 0000000..7c9ee24 --- /dev/null +++ b/ios/App/WebTerm/Components/TelemetryChips.swift @@ -0,0 +1,127 @@ +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 + + var body: some View { + HStack(spacing: Metrics.chipSpacing) { + if let contextText = model.contextText { + Text(contextText) + .foregroundStyle(model.isContextWarning ? Color.orange : Color.secondary) + } + if let costText = model.costText { + chip(costText) + } + if let modelName = model.modelName { + chip(modelName) + } + prBadge + } + .font(.caption2.monospacedDigit()) + .lineLimit(1) + .opacity(model.isStale ? Metrics.staleOpacity : 1) + .grayscale(model.isStale ? 1 : 0) + } + + @ViewBuilder private var prBadge: some View { + if let prText = model.prText { + let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText + if let url = model.prURL { + Link(label, destination: url) + } else { + chip(label) + } + } + } + + private func chip(_ text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .padding(.horizontal, Metrics.chipHorizontalPadding) + .background(.quaternary, in: Capsule()) + } + + // MARK: - Named constants (no magic numbers, plan §4) + + private enum Metrics { + static let chipSpacing: CGFloat = 6 + static let chipHorizontalPadding: CGFloat = 5 + static let staleOpacity = 0.45 + } + + 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" + } +} diff --git a/ios/App/WebTerm/Screens/PairingScreen.swift b/ios/App/WebTerm/Screens/PairingScreen.swift new file mode 100644 index 0000000..ea60cb4 --- /dev/null +++ b/ios/App/WebTerm/Screens/PairingScreen.swift @@ -0,0 +1,350 @@ +import HostRegistry +import SwiftUI +import UIKit +#if !targetEnvironment(simulator) +import VisionKit +#endif + +/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry + +/// probe UI. 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. +/// +/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run +/// screen, and the session list header presents the SAME screen as a sheet to +/// add/switch hosts (the "多 host 切换入口" of the task's step list). +/// +/// Scanner: VisionKit `DataScannerViewController` — compiled out for the +/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the +/// pairing path; on device the entry also hides when scanning is unsupported. +/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2). +struct PairingScreen: View { + @Bindable var viewModel: PairingViewModel + /// Navigate-on-paired hook for the T-iOS-15 wiring. + var onPaired: (HostRegistry.Host) -> Void = { _ in } + + @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) + } + } + + // MARK: - Idle: manual entry + scan entry + + private var idleView: some View { + Form { + Section(ScreenCopy.manualSectionTitle) { + TextField(ScreenCopy.manualPlaceholder, text: $manualURLText) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(ScreenCopy.manualSubmit) { + viewModel.submitManualURL(manualURLText) + } + } + if let rejection = viewModel.inputRejection { + Section { + Text(rejection).foregroundStyle(.red) + } + } + if PairingScanAvailability.isAvailable { + Section { + Button { + scannerError = nil + isShowingScanner = true + } label: { + Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder") + } + } + } + Section { + Text(ScreenCopy.qrHint) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .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) + .foregroundStyle(.red) + .padding() + .background(.thinMaterial, in: RoundedRectangle( + cornerRadius: Metrics.errorCornerRadius + )) + .padding() + } + } + #endif + } + + // MARK: - Probing / paired + + private func probingView(_ pending: PairingViewModel.PendingHost) -> some View { + VStack(spacing: Metrics.stackSpacing) { + ProgressView() + Text(ScreenCopy.probing(pending.displayAddress)) + .foregroundStyle(.secondary) + } + .padding() + } + + private func pairedView(_ host: HostRegistry.Host) -> some View { + VStack(spacing: Metrics.stackSpacing) { + Image(systemName: "checkmark.circle.fill") + .font(.largeTitle) + .foregroundStyle(.green) + Text(ScreenCopy.paired(host.name)) + } + .padding() + } +} + +// MARK: - Confirm page (parsed address + §5.4 warning tier + name) + +private struct ConfirmHostView: View { + let pending: PairingViewModel.PendingHost + @Bindable var viewModel: PairingViewModel + + var body: some View { + Form { + Section(ScreenCopy.confirmSectionTitle) { + // Single-point-derived scheme://host[:port] — never hand-built. + Text(pending.displayAddress) + .font(.system(.body, design: .monospaced)) + TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName) + } + warningSection + Section { + Button(ScreenCopy.connect) { + Task { await viewModel.confirmConnect() } + } + Button(ScreenCopy.cancel, role: .cancel) { + viewModel.cancel() + } + } + } + } + + @ViewBuilder private var warningSection: some View { + switch pending.warning { + case .none: + EmptyView() + case .tailscaleEncrypted: + Section { + Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield") + .foregroundStyle(.green) + } + case .plaintextLAN: + Section { + Label(ScreenCopy.plaintextNotice, systemImage: "eye") + .foregroundStyle(.orange) + } + case .publicHostBlocking: + Section { + Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill") + .foregroundStyle(.red) + .font(.headline) + Toggle(ScreenCopy.publicAcknowledge, + isOn: $viewModel.hasAcknowledgedPublicRisk) + if viewModel.needsPublicRiskAcknowledgement { + Text(ScreenCopy.publicAckRequired) + .foregroundStyle(.red) + .font(.footnote) + } + } + } + } +} + +// MARK: - Failure page (inline copy + recovery action) + +private struct FailureView: View { + let pending: PairingViewModel.PendingHost + let failure: PairingViewModel.FailureDisplay + let viewModel: PairingViewModel + + var body: some View { + Form { + Section(pending.displayAddress) { + Label(failure.message, systemImage: "xmark.octagon") + .foregroundStyle(.red) + } + Section { + if failure.action == .openLocalNetworkSettings { + Button(ScreenCopy.openSettings) { openAppSettings() } + } + Button(ScreenCopy.retry) { + Task { await viewModel.retry() } + } + Button(ScreenCopy.back, role: .cancel) { + viewModel.cancel() + } + } + } + } + + /// The app's Settings pane hosts its 本地网络 toggle — there is no public + /// deep link straight to 隐私 → 本地网络 (plan §5.2 guidance). + private func openAppSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} + +// MARK: - Scan availability + +enum PairingScanAvailability { + /// Simulator: no camera → entry hidden, manual entry is the pairing path + /// (task ruling). Device: also requires VisionKit support. + /// (`DataScannerViewController.isSupported` is MainActor-isolated.) + @MainActor static var isAvailable: Bool { + #if targetEnvironment(simulator) + return false + #else + return DataScannerViewController.isSupported + #endif + } +} + +// MARK: - VisionKit scanner (device only) + +#if !targetEnvironment(simulator) +/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST +/// recognized code wins (the web QR fills the screen — no tap-to-pick needed). +/// The payload is 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, never a silent swallow (plan §4): camera + // denied/busy shows inline in the sheet; 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 + +// MARK: - Screen constants + +private enum Metrics { + static let stackSpacing: CGFloat = 12 + static let errorCornerRadius: CGFloat = 8 +} + +private enum ScreenCopy { + static let title = "配对主机" + 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 serve(wss)。" + 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)。可改用手输地址。" + } +} diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift new file mode 100644 index 0000000..06f8fe6 --- /dev/null +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -0,0 +1,235 @@ +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`. +/// +/// 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 = {} + + var body: some View { + content + .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) + } + Button { + viewModel.requestNewSession() + } label: { + Label(ScreenCopy.newSession, systemImage: "plus.circle.fill") + } + ForEach(viewModel.rows) { row in + Button { + viewModel.openSession(id: row.id) + } label: { + SessionRowView(row: row) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { await viewModel.kill(sessionId: row.id) } + } label: { + Label(ScreenCopy.kill, systemImage: "xmark.circle.fill") + } + } + } + } + .refreshable { await viewModel.refresh() } + } + + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle") + .font(.footnote) + .foregroundStyle(.red) + } + + // MARK: - Empty states + + private var notPairedView: some View { + ContentUnavailableView { + Label(ScreenCopy.notPairedTitle, systemImage: "personalhotspot") + } description: { + Text(ScreenCopy.notPairedHint) + } actions: { + Button(ScreenCopy.addHost) { onAddHost() } + .buttonStyle(.borderedProminent) + } + } + + private var noSessionsView: some View { + ContentUnavailableView { + Label(ScreenCopy.noSessionsTitle, systemImage: "terminal") + } description: { + Text(ScreenCopy.noSessionsHint) + } actions: { + Button(ScreenCopy.newSession) { viewModel.requestNewSession() } + .buttonStyle(.borderedProminent) + } + } + + // 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" + ) + } + } + } +} + +// MARK: - Row + +private struct SessionRowView: View { + let row: SessionListViewModel.SessionRow + + var body: some View { + HStack(alignment: .top, spacing: Metrics.rowSpacing) { + indicator + .frame(width: Metrics.indicatorWidth) + VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) { + Text(title) + .font(.body) + .lineLimit(1) + Text(meta) + .font(.caption) + .foregroundStyle(.secondary) + if let telemetry = row.telemetry, !telemetry.isEmpty { + TelemetryChips(model: telemetry) + } + } + } + .opacity(row.info.exited ? Metrics.exitedOpacity : 1) + .accessibilityElement(children: .combine) + } + + /// ⚠ badge outranks the status dot (VM decides via `indicator`). + @ViewBuilder private var indicator: some View { + switch row.indicator { + case .pendingApproval: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel(ScreenCopy.pendingBadgeLabel) + case .status(let status): + Circle() + .fill(Self.dotColor(status)) + .frame(width: Metrics.dotSize, height: Metrics.dotSize) + .padding(.top, Metrics.dotTopPadding) + .accessibilityLabel(Text(status.rawValue)) + } + } + + /// `cwd` is server-supplied display text (untrusted → plain Text only). + private var title: String { + guard let cwd = row.info.cwd, !cwd.isEmpty else { return ScreenCopy.unknownDirectory } + return URL(fileURLWithPath: cwd).lastPathComponent + } + + private var meta: String { + var parts = [ + ScreenCopy.clientCount(row.info.clientCount), + "\(row.info.cols)×\(row.info.rows)", + ] + if row.info.exited { + parts.append(ScreenCopy.exitedTag) + } + return parts.joined(separator: " · ") + } + + private static func dotColor(_ status: ClaudeStatus) -> Color { + switch status { + case .working: return .green + case .waiting: return .orange + case .idle: return .blue + case .stuck: return .red + case .unknown: return .gray + } + } + + private enum Metrics { + static let rowSpacing: CGFloat = 10 + static let rowInnerSpacing: CGFloat = 3 + static let indicatorWidth: CGFloat = 18 + static let dotSize: CGFloat = 10 + static let dotTopPadding: CGFloat = 5 + static let exitedOpacity = 0.55 + } +} + +// 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 exitedTag = "已退出" + static let pendingBadgeLabel = "等待审批" + + static func clientCount(_ count: Int) -> String { + "\(count) 台设备在看" + } +} diff --git a/ios/App/WebTerm/Screens/TerminalScreen.swift b/ios/App/WebTerm/Screens/TerminalScreen.swift new file mode 100644 index 0000000..7d9fdfd --- /dev/null +++ b/ios/App/WebTerm/Screens/TerminalScreen.swift @@ -0,0 +1,145 @@ +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 label→bytes +/// 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 + + private enum Metrics { + static let bannerHorizontalPadding: CGFloat = 12 + static let bannerTopPadding: CGFloat = 8 + } + + var body: some View { + TerminalHostView(viewModel: viewModel) + .ignoresSafeArea(.container, edges: .bottom) + .overlay(alignment: .top) { + if let model = viewModel.bannerModel { + ReconnectBanner(model: model) + .padding(.horizontal, Metrics.bannerHorizontalPadding) + .padding(.top, Metrics.bannerTopPadding) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.default, value: viewModel.bannerModel) + .onAppear { viewModel.start() } + } +} + +// 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 + + func makeCoordinator() -> Coordinator { + Coordinator(viewModel: viewModel) + } + + func makeUIView(context: Context) -> KeyCommandTerminalView { + let terminal = KeyCommandTerminalView(frame: .zero) + terminal.terminalDelegate = context.coordinator + + let viewModel = viewModel + terminal.onKeyCommand = { key in viewModel.send(key: key) } + + let keyBar = KeyBarView() + keyBar.onKey = { key in viewModel.send(key: key) } + terminal.inputAccessoryView = keyBar + + // 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, nothing to push here. + } + + /// 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) { + 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) + } + + // Title/cwd surface in the session list via the server (T-iOS-13/23), + // not from the local emulator — deliberate no-ops. + func setTerminalTitle(source: TerminalView, title: String) {} + 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)? + + 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) + } +} diff --git a/ios/App/WebTerm/ViewModels/GateViewModel.swift b/ios/App/WebTerm/ViewModels/GateViewModel.swift new file mode 100644 index 0000000..771429d --- /dev/null +++ b/ios/App/WebTerm/ViewModels/GateViewModel.swift @@ -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 + @ObservationIgnored private let haptics: any HapticSignaling + @ObservationIgnored private let clock: any Clock + @ObservationIgnored private var consumeTask: Task? + @ObservationIgnored private var fadeTask: Task? + /// 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 + } + + @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, + haptics: any HapticSignaling, clock: any Clock + ) { + 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() + } + } +} diff --git a/ios/App/WebTerm/ViewModels/PairingViewModel.swift b/ios/App/WebTerm/ViewModels/PairingViewModel.swift new file mode 100644 index 0000000..a96dacd --- /dev/null +++ b/ios/App/WebTerm/ViewModels/PairingViewModel.swift @@ -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 + + // 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 ws→loopback: nothing to warn about. + case none + /// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no + /// plaintext warning, an optional positive badge instead. + case tailscaleEncrypted + /// ws→RFC1918 / 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,或反馈该网段。" + } +} diff --git a/ios/App/WebTerm/ViewModels/SessionListViewModel.swift b/ios/App/WebTerm/ViewModels/SessionListViewModel.swift new file mode 100644 index 0000000..b83e80b --- /dev/null +++ b/ios/App/WebTerm/ViewModels/SessionListViewModel.swift @@ -0,0 +1,351 @@ +import APIClient +import Foundation +import HostRegistry +import Observation +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? + + 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 + /// Injected time source for telemetry staleness (ms since epoch). + @ObservationIgnored private let nowMs: @Sendable () -> Int + @ObservationIgnored private var pollTask: Task? + @ObservationIgnored private var latestFetched: [LiveSessionInfo] = [] + @ObservationIgnored private var hiddenKilledIds: Set = [] + @ObservationIgnored private var pendingSessionIds: Set = [] + @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, + nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs + ) { + self.hostStore = hostStore + self.http = http + self.clock = clock + self.nowMs = nowMs + } + + // 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: - 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. + func openSession(id: UUID) { + guard let activeHost, rows.contains(where: { $0.id == id }) else { return } + 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) + ) + } + } + + /// 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 + } + + @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)" + } +} diff --git a/ios/App/WebTerm/ViewModels/TerminalViewModel.swift b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift new file mode 100644 index 0000000..c480e8b --- /dev/null +++ b/ios/App/WebTerm/ViewModels/TerminalViewModel.swift @@ -0,0 +1,301 @@ +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 1s→30s). + 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?) + } + + /// 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 或调高客户端上限" + + 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? + + /// 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 + @ObservationIgnored private var consumeTask: Task? + /// 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)? + + private struct CountWaiter { + let target: Int + let continuation: CheckedContinuation + } + + @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) { + 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)) + } + + /// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded — + /// the engine validates bounds and owns terminal-state dropping. + func sendResize(cols: Int, rows: Int) { + 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 + 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() + } + } +} diff --git a/ios/App/WebTermTests/GateViewModelTests.swift b/ios/App/WebTermTests/GateViewModelTests.swift new file mode 100644 index 0000000..9654909 --- /dev/null +++ b/ios/App/WebTermTests/GateViewModelTests.swift @@ -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 条") + } +} diff --git a/ios/App/WebTermTests/KeyBarTests.swift b/ios/App/WebTermTests/KeyBarTests.swift new file mode 100644 index 0000000..0df7374 --- /dev/null +++ b/ios/App/WebTermTests/KeyBarTests.swift @@ -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 label→bytes 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 = [ + .arrowUp, .arrowDown, .arrowLeft, .arrowRight, + .enter, .tab, .slash, .escEsc, + ] + let chordKeys = Set(HardwareKeyCommands.chords.map(\.key)) + #expect(chordKeys.isDisjoint(with: excluded)) + } +} diff --git a/ios/App/WebTermTests/PairingViewModelTests.swift b/ios/App/WebTermTests/PairingViewModelTests.swift new file mode 100644 index 0000000..4c8edf5 --- /dev/null +++ b/ios/App/WebTermTests/PairingViewModelTests.swift @@ -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] + + /// Empty `results` = always succeed with the probed endpoint. + init(results: [Result] = []) { + self.results = results + } + + func invoke(_ endpoint: HostEndpoint) -> Result { + 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") + } +} diff --git a/ios/App/WebTermTests/SessionListViewModelTests.swift b/ios/App/WebTermTests/SessionListViewModelTests.swift new file mode 100644 index 0000000..4550a1f --- /dev/null +++ b/ios/App/WebTermTests/SessionListViewModelTests.swift @@ -0,0 +1,581 @@ +import APIClient +import Foundation +import HostRegistry +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-13 · SessionListViewModel (merged chooser + dashboard, plan §7). +/// +/// Covers the task RED list end to end, deterministically (zero real waits): +/// - poll cadence on `FakeClock` (`waitForSleepers` barrier + cancellation +/// leak-check on leave), +/// - status dot mapping for all 5 states + the `pending:true` ⚠ badge priority +/// (overlay — `/live-sessions` carries NO pending field, src/types.ts:246-256), +/// - telemetry staleness at the exact `Tunables.telemetryStaleTtlMs` boundary +/// via an injected now-provider, +/// - swipe-to-kill: optimistic removal proven BEFORE the server replies (gated +/// transport), Origin stamped on the DELETE, rollback on failure, +/// - server order kept with `exited:true` grouped at the bottom, +/// - "+ New session" navigation signal carrying `sessionId: nil`. +/// +/// All HTTP goes through the REAL `APIClient` over `FakeHTTPTransport` +/// (task brief: LiveSessionInfo decode exists — exercise it, don't stub rows). +@MainActor +@Suite("SessionListViewModel") +struct SessionListViewModelTests { + private nonisolated static let base = "http://192.168.1.5:3000" + /// Fixed "now" injected into the VM — staleness must be deterministic. + private nonisolated static let nowMs = 1_700_000_100_000 + + // MARK: - Fixtures + + private func makeHost( + _ urlString: String = SessionListViewModelTests.base, + name: String = "书房 Mac" + ) throws -> HostRegistry.Host { + let url = try #require(URL(string: urlString)) + let endpoint = try #require(HostEndpoint(baseURL: url)) + return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint) + } + + private func listURL(_ base: String = SessionListViewModelTests.base) throws -> URL { + try #require(URL(string: "\(base)/live-sessions")) + } + + private func deleteURL( + id: UUID, base: String = SessionListViewModelTests.base + ) throws -> URL { + try #require(URL(string: "\(base)/live-sessions/\(id.uuidString.lowercased())")) + } + + /// One `/live-sessions` entry in the server's exact JSON shape. + private func sessionJSON( + id: UUID, + createdAt: Int = 1_700_000_000_000, + clientCount: Int = 1, + status: String = "working", + exited: Bool = false, + cwd: String? = "/Users/dev/proj", + cols: Int = 80, + rows: Int = 24, + telemetry: String? = nil + ) -> String { + let cwdJSON = cwd.map { "\"\($0)\"" } ?? "null" + let telemetryJSON = telemetry.map { ",\"telemetry\":\($0)" } ?? "" + return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":\(createdAt)" + + ",\"clientCount\":\(clientCount),\"status\":\"\(status)\",\"exited\":\(exited)" + + ",\"cwd\":\(cwdJSON),\"cols\":\(cols),\"rows\":\(rows)\(telemetryJSON)}" + } + + private func listBody(_ entries: [String]) -> Data { + Data("[\(entries.joined(separator: ","))]".utf8) + } + + private func makeViewModel( + hosts: [HostRegistry.Host], + http: any HTTPTransport, + clock: any Clock = FakeClock(), + nowMs: @escaping @Sendable () -> Int = { SessionListViewModelTests.nowMs } + ) -> SessionListViewModel { + SessionListViewModel( + hostStore: InMemoryHostStore(hosts: hosts), + http: http, + clock: clock, + nowMs: nowMs + ) + } + + /// Loads hosts + performs one manual fetch — the no-polling arrangement + /// used by every test that does not exercise the poll loop itself. + private func loadOnce(_ viewModel: SessionListViewModel) async { + await viewModel.reloadHosts() + await viewModel.refresh() + } + + private struct StoreFailure: Error {} + + private actor ThrowingHostStore: HostStore { + func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() } + func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { [] } + func remove(id: UUID) async throws -> [HostRegistry.Host] { [] } + } + + /// HTTPTransport wrapper that parks requests of one method until the test + /// opens the gate — the deterministic way to observe the OPTIMISTIC state + /// while the DELETE is still in flight. + private actor GatedHTTPTransport: HTTPTransport { + private let inner: FakeHTTPTransport + private let gatedMethod: String + private var isOpen = false + private var gatedArrivalCount = 0 + private var gateOpeners: [CheckedContinuation] = [] + private var arrivalWaiters: [CheckedContinuation] = [] + + init(inner: FakeHTTPTransport, gatedMethod: String) { + self.inner = inner + self.gatedMethod = gatedMethod.uppercased() + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + if (request.httpMethod ?? "GET").uppercased() == gatedMethod { + gatedArrivalCount += 1 + for waiter in arrivalWaiters { waiter.resume() } + arrivalWaiters = [] + if !isOpen { + await withCheckedContinuation { gateOpeners.append($0) } + } + } + return try await inner.send(request) + } + + /// Suspends until at least one gated request has arrived (and parked). + func waitForGatedArrival() async { + guard gatedArrivalCount == 0 else { return } + await withCheckedContinuation { arrivalWaiters.append($0) } + } + + func openGate() { + isOpen = true + for opener in gateOpeners { opener.resume() } + gateOpeners = [] + } + } + + // MARK: - Poll cadence + leave stops cleanly (task RED item 1) + + @Test("visible: polls /live-sessions every listPollInterval; leaving cancels the parked sleeper — no leaked task, zero further traffic") + func pollingCadenceAndLeaveStopsCleanly() async throws { + // Arrange: 3 queued list responses — one per expected poll tick. + let clock = FakeClock() + let http = FakeHTTPTransport() + let host = try makeHost() + let url = try listURL() + let sessionId = UUID() + for _ in 0..<3 { + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) + } + let viewModel = makeViewModel(hosts: [host], http: http, clock: clock) + + // Act: appear (twice — idempotence: no second poll loop may spawn). + viewModel.appeared() + viewModel.appeared() + await viewModel.waitUntilFetches(count: 1) + + // Assert: first fetch landed and rows are decoded LiveSessionInfo. + #expect(viewModel.rows.map(\.id) == [sessionId]) + #expect(viewModel.emptyState == nil) + + // Act + Assert: each interval elapsing triggers exactly one more fetch. + await clock.waitForSleepers(count: 1) + clock.advance(by: Tunables.listPollInterval) + await viewModel.waitUntilFetches(count: 2) + + await clock.waitForSleepers(count: 1) + clock.advance(by: Tunables.listPollInterval) + await viewModel.waitUntilFetches(count: 3) + + // Act: leave the screen while the poll task is parked in sleep. + await clock.waitForSleepers(count: 1) + viewModel.disappeared() + + // Assert: cancellation reclaimed the sleeper synchronously (no leaked + // timer) and a long stretch of fake time produces zero new requests. + #expect(clock.pendingSleeperCount == 0) + clock.advance(by: Tunables.listPollInterval * 10) + let requestCount = await http.recordedRequests.count + #expect(requestCount == 3) + } + + // MARK: - Empty states + + @Test("no paired hosts → .notPaired empty state and ZERO network traffic") + func notPairedEmptyStateWithoutTraffic() async throws { + // Arrange + let http = FakeHTTPTransport() + let viewModel = makeViewModel(hosts: [], http: http) + + // Act + viewModel.appeared() + await viewModel.waitUntilFetches(count: 1) + + // Assert + #expect(viewModel.emptyState == .notPaired) + #expect(await http.recordedRequests.isEmpty) + viewModel.disappeared() + } + + @Test("empty list from the host → .noSessions; a later non-empty fetch clears it") + func noSessionsEmptyStateThenPopulated() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let url = try listURL() + await http.queueSuccess(url: url, body: listBody([])) + let viewModel = makeViewModel(hosts: [host], http: http) + + // Act & Assert: loaded + empty → .noSessions (distinct from not-paired). + await loadOnce(viewModel) + #expect(viewModel.emptyState == .noSessions) + + // Act & Assert: pull-to-refresh path fetches on demand and clears it. + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: UUID())])) + await viewModel.refresh() + #expect(viewModel.rows.count == 1) + #expect(viewModel.emptyState == nil) + } + + @Test("host store failure surfaces explicit copy — never a silent empty list") + func hostStoreFailureIsExplicit() async throws { + // Arrange + let viewModel = SessionListViewModel( + hostStore: ThrowingHostStore(), + http: FakeHTTPTransport(), + clock: FakeClock(), + nowMs: { Self.nowMs } + ) + + // Act + await viewModel.reloadHosts() + + // Assert + #expect(viewModel.hosts.isEmpty) + #expect(viewModel.hostsErrorMessage == SessionListCopy.hostsLoadFailed) + } + + // MARK: - Status dots (5 states) + pending ⚠ badge priority + + @Test("status dot maps every ClaudeStatus state from the wire", arguments: [ + ("working", ClaudeStatus.working), + ("waiting", ClaudeStatus.waiting), + ("idle", ClaudeStatus.idle), + ("unknown", ClaudeStatus.unknown), + ("stuck", ClaudeStatus.stuck), + ]) + func statusDotMapsFiveStates(raw: String, expected: ClaudeStatus) async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let sessionId = UUID() + await http.queueSuccess( + url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: raw)]) + ) + let viewModel = makeViewModel(hosts: [host], http: http) + + // Act + await loadOnce(viewModel) + + // Assert + #expect(viewModel.rows.first?.indicator == .status(expected)) + } + + @Test("pending overlay wins over the status dot with a ⚠ badge, and clears back") + func pendingBadgeTakesPriority() async throws { + // Arrange: a WORKING session — the strongest contrast to the badge. + let http = FakeHTTPTransport() + let host = try makeHost() + let sessionId = UUID() + await http.queueSuccess( + url: try listURL(), + body: listBody([sessionJSON(id: sessionId, status: "working")]) + ) + let viewModel = makeViewModel(hosts: [host], http: http) + await loadOnce(viewModel) + #expect(viewModel.rows.first?.indicator == .status(.working)) + + // Act: gate arrives (status frame pending:true → T-iOS-15 wiring calls this). + viewModel.setPendingApproval(sessionId: sessionId, pending: true) + + // Assert: badge PRIORITY — status stays working underneath. + #expect(viewModel.rows.first?.indicator == .pendingApproval) + #expect(viewModel.rows.first?.info.status == .working) + + // Act & Assert: gate resolved → dot returns. + viewModel.setPendingApproval(sessionId: sessionId, pending: false) + #expect(viewModel.rows.first?.indicator == .status(.working)) + } + + @Test("indicator glyphs mirror public/tabs.ts claudeIcon, ⚠ for pending") + func indicatorGlyphsMirrorWeb() { + #expect(SessionListViewModel.StatusIndicator.status(.working).glyph == "⚙") + #expect(SessionListViewModel.StatusIndicator.status(.waiting).glyph == "⏳") + #expect(SessionListViewModel.StatusIndicator.status(.idle).glyph == "✓") + #expect(SessionListViewModel.StatusIndicator.status(.stuck).glyph == "⚠") + #expect(SessionListViewModel.StatusIndicator.status(.unknown).glyph == "") + #expect(SessionListViewModel.StatusIndicator.pendingApproval.glyph == "⚠") + } + + // MARK: - Telemetry staleness (injected now-provider, exact boundary) + + @Test("telemetry chips grey strictly past telemetryStaleTtlMs; at the boundary they stay live; no telemetry → no chips") + func telemetryStalenessBoundary() async throws { + // Arrange: `at` exactly TTL old (NOT stale — web rule is strict `>`, + // public/preview-grid.ts), one 1 ms older (stale), one without telemetry. + let http = FakeHTTPTransport() + let host = try makeHost() + let boundaryId = UUID() + let staleId = UUID() + let bareId = UUID() + let boundaryAt = Self.nowMs - Tunables.telemetryStaleTtlMs + let staleAt = Self.nowMs - Tunables.telemetryStaleTtlMs - 1 + await http.queueSuccess(url: try listURL(), body: listBody([ + sessionJSON(id: boundaryId, telemetry: "{\"at\":\(boundaryAt),\"costUsd\":0.5}"), + sessionJSON(id: staleId, telemetry: "{\"at\":\(staleAt),\"costUsd\":0.5}"), + sessionJSON(id: bareId), + ])) + let viewModel = makeViewModel(hosts: [host], http: http) + + // Act + await loadOnce(viewModel) + + // Assert + let rowsById = Dictionary(uniqueKeysWithValues: viewModel.rows.map { ($0.id, $0) }) + #expect(rowsById[boundaryId]?.telemetry?.isStale == false) + #expect(rowsById[staleId]?.telemetry?.isStale == true) + #expect(rowsById[bareId]?.telemetry == nil) + } + + @Test("chip model mirrors the web gauge: $ cost to 4 places, ctx warn > 80, PR link https-only") + func telemetryChipModelContent() throws { + // Arrange: http:// PR URL — the SEC-L5 mirror must refuse to link it. + let telemetry = StatusTelemetry( + contextUsedPct: 91.4, + costUsd: 0.1234, + model: "opus", + pr: PrInfo(number: 7, url: "http://github.com/x/pull/7", reviewState: "approved"), + at: Self.nowMs + ) + + // Act + let model = try #require(TelemetryChips.Model(telemetry: telemetry, nowMs: Self.nowMs)) + + // Assert + #expect(model.costText == "$0.1234") + #expect(model.contextText == "ctx 91%") + #expect(model.isContextWarning) + #expect(model.modelName == "opus") + #expect(model.prText == "PR #7") + #expect(model.prReviewState == "approved") + #expect(model.prURL == nil) // http never becomes a tappable link + #expect(!model.isStale) + + // Act & Assert: https URL IS linkable; low ctx carries no warning. + let httpsTelemetry = StatusTelemetry( + contextUsedPct: 40, + pr: PrInfo(number: 7, url: "https://github.com/x/pull/7"), + at: Self.nowMs + ) + let httpsModel = try #require( + TelemetryChips.Model(telemetry: httpsTelemetry, nowMs: Self.nowMs) + ) + #expect(httpsModel.prURL != nil) + #expect(!httpsModel.isContextWarning) + #expect(TelemetryChips.Model(telemetry: nil, nowMs: Self.nowMs) == nil) + } + + // MARK: - Ordering: server order kept, exited grouped at the bottom + + @Test("rows keep the server's order with exited sessions grouped last, order preserved inside each group") + func orderingGroupsExitedAtBottom() async throws { + // Arrange: server (newest-first) order interleaves exited sessions. + let http = FakeHTTPTransport() + let host = try makeHost() + let liveA = UUID() + let exitedB = UUID() + let liveC = UUID() + let exitedD = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([ + sessionJSON(id: liveA), + sessionJSON(id: exitedB, exited: true), + sessionJSON(id: liveC), + sessionJSON(id: exitedD, exited: true), + ])) + let viewModel = makeViewModel(hosts: [host], http: http) + + // Act + await loadOnce(viewModel) + + // Assert + #expect(viewModel.rows.map(\.id) == [liveA, liveC, exitedB, exitedD]) + } + + // MARK: - Swipe-to-kill: optimistic + Origin + rollback + + @Test("kill removes the row optimistically BEFORE the server replies, stamps Origin on the DELETE, stays removed on 204") + func killOptimisticRemovalThenConfirmed() async throws { + // Arrange: DELETE parks at a gate so the in-flight state is observable. + let inner = FakeHTTPTransport() + let gated = GatedHTTPTransport(inner: inner, gatedMethod: "DELETE") + let host = try makeHost() + let targetId = UUID() + let otherId = UUID() + await inner.queueSuccess( + url: try listURL(), + body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)]) + ) + await inner.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 204) + let viewModel = makeViewModel(hosts: [host], http: gated) + await loadOnce(viewModel) + #expect(viewModel.rows.map(\.id) == [targetId, otherId]) + + // Act: swipe-to-kill; hold the DELETE at the gate. + let killTask = Task { await viewModel.kill(sessionId: targetId) } + await gated.waitForGatedArrival() + + // Assert: the row is ALREADY gone while the server has not answered. + #expect(viewModel.rows.map(\.id) == [otherId]) + #expect(viewModel.killErrorMessage == nil) + + // Act: let the 204 through. + await gated.openGate() + await killTask.value + + // Assert: still removed; the DELETE carried the exact Origin (G 铁律). + #expect(viewModel.rows.map(\.id) == [otherId]) + let deleteRequest = await inner.recordedRequests.first { $0.httpMethod == "DELETE" } + #expect(deleteRequest?.url == (try deleteURL(id: targetId))) + #expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == Self.base) + } + + @Test("kill failure rolls the row back at its original position with explicit copy") + func killFailureRollsBack() async throws { + // Arrange: the Origin guard rejects the DELETE (403). + let http = FakeHTTPTransport() + let host = try makeHost() + let targetId = UUID() + let otherId = UUID() + await http.queueSuccess( + url: try listURL(), + body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)]) + ) + await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 403) + let viewModel = makeViewModel(hosts: [host], http: http) + await loadOnce(viewModel) + + // Act + await viewModel.kill(sessionId: targetId) + + // Assert: rollback restored the ORIGINAL position, copy is actionable. + #expect(viewModel.rows.map(\.id) == [targetId, otherId]) + #expect(viewModel.killErrorMessage + == SessionListCopy.killFailed(APIClientError.forbidden.message)) + } + + @Test("kill on an already-gone session (404) keeps it removed without an error") + func killAlreadyGoneStaysRemoved() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let targetId = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: targetId)])) + await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 404) + let viewModel = makeViewModel(hosts: [host], http: http) + await loadOnce(viewModel) + + // Act + await viewModel.kill(sessionId: targetId) + + // Assert: 404 = session already dead — the optimistic removal was right. + #expect(viewModel.rows.isEmpty) + #expect(viewModel.killErrorMessage == nil) + } + + // MARK: - Navigation signals + + @Test("+ New session emits an OpenRequest with sessionId nil; row taps carry the id; repeats re-fire") + func navigationSignals() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let sessionId = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) + let viewModel = makeViewModel(hosts: [host], http: http) + await loadOnce(viewModel) + #expect(viewModel.openRequest == nil) + + // Act & Assert: "+ New session" → sessionId nil (attach(null) downstream). + viewModel.requestNewSession() + let newRequest = try #require(viewModel.openRequest) + #expect(newRequest.sessionId == nil) + #expect(newRequest.host == host) + + // Act & Assert: opening a listed session carries its id. + viewModel.openSession(id: sessionId) + let openRequest = try #require(viewModel.openRequest) + #expect(openRequest.sessionId == sessionId) + #expect(openRequest.host == host) + + // Act & Assert: the SAME tap again is a fresh signal (unique id). + viewModel.openSession(id: sessionId) + let repeated = try #require(viewModel.openRequest) + #expect(repeated.sessionId == sessionId) + #expect(repeated.id != openRequest.id) + + // Act & Assert: unknown ids are refused at the boundary. + viewModel.openSession(id: UUID()) + #expect(viewModel.openRequest?.id == repeated.id) + } + + // MARK: - Host switching (multi-host header hook) + + @Test("switching hosts refetches from the new endpoint and resets the list") + func hostSwitchRefetches() async throws { + // Arrange: two paired hosts with distinct session lists. + let baseB = "http://10.0.0.7:3000" + let hostA = try makeHost() + let hostB = try makeHost(baseB, name: "工作室 Mac") + let http = FakeHTTPTransport() + let sessionA = UUID() + let sessionB = UUID() + await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionA)])) + await http.queueSuccess( + url: try listURL(baseB), body: listBody([sessionJSON(id: sessionB)]) + ) + let viewModel = makeViewModel(hosts: [hostA, hostB], http: http) + await loadOnce(viewModel) + #expect(viewModel.activeHost == hostA) + #expect(viewModel.rows.map(\.id) == [sessionA]) + + // Act + await viewModel.selectHost(id: hostB.id) + + // Assert: list now comes from host B; navigation carries host B. + #expect(viewModel.activeHost == hostB) + #expect(viewModel.rows.map(\.id) == [sessionB]) + viewModel.requestNewSession() + #expect(viewModel.openRequest?.host == hostB) + let lastURL = await http.recordedRequests.last?.url + #expect(lastURL == (try listURL(baseB))) + } + + // MARK: - Fetch failure keeps the stale list (explicit error, no wipe) + + @Test("a failed poll keeps the last good rows and surfaces copy; the next success clears it") + func fetchFailureKeepsStaleRows() async throws { + // Arrange + let http = FakeHTTPTransport() + let host = try makeHost() + let url = try listURL() + let sessionId = UUID() + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) + await http.queueSuccess(url: url, status: 500) + await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)])) + let viewModel = makeViewModel(hosts: [host], http: http) + await loadOnce(viewModel) + #expect(viewModel.rows.map(\.id) == [sessionId]) + + // Act: the 500 poll. + await viewModel.refresh() + + // Assert: stale rows survive, error copy is explicit (includes status). + #expect(viewModel.rows.map(\.id) == [sessionId]) + let message = try #require(viewModel.fetchErrorMessage) + #expect(message.contains("500")) + #expect(viewModel.emptyState == nil) + + // Act & Assert: recovery clears the error. + await viewModel.refresh() + #expect(viewModel.fetchErrorMessage == nil) + } +} diff --git a/ios/App/WebTermTests/TerminalViewModelTests.swift b/ios/App/WebTermTests/TerminalViewModelTests.swift new file mode 100644 index 0000000..44b7e99 --- /dev/null +++ b/ios/App/WebTermTests/TerminalViewModelTests.swift @@ -0,0 +1,280 @@ +import Foundation +import SessionCore +import TestSupport +import Testing +import WireProtocol +@testable import WebTerm + +/// T-iOS-11 · TerminalViewModel (plan §7). All tests run against a REAL +/// `SessionEngine` over `TestSupport.FakeTransport` + `FakeClock` (the task's +/// documented testability choice): the VM consumes `engine.events` exactly as +/// production will, with zero real waits and zero network. +/// +/// Determinism: the VM's internal `waitUntilProcessed(eventCount:)` / +/// `waitUntilForwarded(sendCount:)` barriers and the `onEventApplied` tap are +/// the "event reached the VM" signals — never polling, never sleeping. +@MainActor +@Suite("TerminalViewModel") +struct TerminalViewModelTests { + // 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 output(_ data: String) -> String { + #"{"type":"output","data":"\#(data)"}"# + } + + static func exit(code: Int, reason: String) -> String { + #"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"# + } + } + + private struct TestStreamError: Error {} + + // MARK: - Harness + + /// Engine over fakes + the VM under test, wired exactly like production + /// (T-iOS-15 passes `engine.events`; a fan-out branch arrives only when + /// GateViewModel shares the stream). + @MainActor + private final class Harness { + let transport = FakeTransport() + let clock = FakeClock() + let engine: SessionEngine + let viewModel: TerminalViewModel + + init() 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: { _ in [] } + ) + viewModel = TerminalViewModel(engine: engine, events: engine.events) + viewModel.start() + } + + /// Standard preamble: open → .connecting → .connected → server confirms + /// the attach. 3 events reach the VM. + func openAndAdopt(_ id: UUID) async { + await engine.open(sessionId: nil, cwd: nil) + await transport.emit(frame: ServerFrames.attached(id)) + await viewModel.waitUntilProcessed(eventCount: 3) + } + } + + /// Records `(banner, phase)` after every applied event — the deterministic + /// way to observe transient states (`.connecting` is often already + /// superseded by the time a barrier-based await resumes). + @MainActor + private final class StateRecorder { + private(set) var banners: [TerminalViewModel.ConnectionBanner] = [] + + func attach(to viewModel: TerminalViewModel) { + viewModel.onEventApplied = { [weak self, weak viewModel] _ in + guard let self, let viewModel else { return } + self.banners = self.banners + [viewModel.banner] + } + } + } + + // MARK: - Output forwarding (feed order, MainActor by construction) + + @Test("output forwards to the terminal sink in arrival order; a late sink flushes the buffer first") + func outputForwardsInOrderAndLateSinkFlushesBuffer() async throws { + // Arrange + let harness = try Harness() + let sessionId = UUID() + await harness.openAndAdopt(sessionId) + + // Act: two chunks arrive BEFORE any sink exists (screen not built yet). + await harness.transport.emit(frame: ServerFrames.output("one")) + await harness.transport.emit(frame: ServerFrames.output("two")) + await harness.viewModel.waitUntilProcessed(eventCount: 5) + + // The sink attaches late (SwiftTerm view just got created): buffered + // chunks must flush immediately, in order. The sink closure is + // @MainActor-typed — feed-on-main is compiler-enforced (Swift 6). + let received = Recorder() + harness.viewModel.attachTerminalSink { received.append($0) } + + // Assert: buffered flush preserved order. + #expect(received.chunks == ["one", "two"]) + + // Act: live output after the sink is attached appends behind it. + await harness.transport.emit(frame: ServerFrames.output("three")) + await harness.viewModel.waitUntilProcessed(eventCount: 6) + + // Assert + #expect(received.chunks == ["one", "two", "three"]) + #expect(harness.viewModel.sessionId == sessionId) + } + + @MainActor + private final class Recorder { + private(set) var chunks: [String] = [] + func append(_ chunk: String) { chunks = chunks + [chunk] } + } + + @Test("start() is idempotent — a second start never double-delivers output") + func startIsIdempotent() async throws { + // Arrange + let harness = try Harness() + harness.viewModel.start() // second call must be a no-op + await harness.openAndAdopt(UUID()) + let received = Recorder() + harness.viewModel.attachTerminalSink { received.append($0) } + + // Act + await harness.transport.emit(frame: ServerFrames.output("once")) + await harness.viewModel.waitUntilProcessed(eventCount: 4) + + // Assert + #expect(received.chunks == ["once"]) + } + + // MARK: - Reconnect banner + + @Test("banner: connecting → hidden on connect → reconnecting(attempt) on drop → hidden again after recovery") + func bannerFollowsConnectionLifecycle() async throws { + // Arrange + let harness = try Harness() + let recorder = StateRecorder() + recorder.attach(to: harness.viewModel) + await harness.openAndAdopt(UUID()) + + // Act: the transport drops → engine enters backoff (1s first rung). + await harness.transport.emitError(TestStreamError()) + await harness.viewModel.waitUntilProcessed(eventCount: 4) + + // Assert: the reconnect banner is up, carrying attempt + retry delay. + #expect(harness.viewModel.banner + == .reconnecting(attempt: 1, next: .seconds(1))) + #expect(harness.viewModel.bannerModel + == .reconnecting(attempt: 1, retryIn: .seconds(1))) + + // Act: fire the backoff timer — the engine reconnects and re-attaches. + await harness.clock.waitForSleepers(count: 1) + harness.clock.advance(by: .seconds(1)) + await harness.viewModel.waitUntilProcessed(eventCount: 5) + + // Assert: banner hidden again; full observed banner timeline is exact + // (events: connecting, connected, adopted, reconnecting, connected). + #expect(harness.viewModel.banner == .none) + #expect(harness.viewModel.bannerModel == nil) + #expect(recorder.banners == [ + .connecting, + .none, + .none, + .reconnecting(attempt: 1, next: .seconds(1)), + .none, + ]) + } + + // MARK: - Non-retryable failure (replayTooLarge) + + @Test("replayTooLarge → non-retrying failed state with the actionable SCROLLBACK_BYTES copy; no reconnect attempt") + func replayTooLargeBecomesNonRetryingErrorWithActionableCopy() async throws { + // Arrange + let harness = try Harness() + await harness.openAndAdopt(UUID()) + + // Act: the transport fails the way an oversized single-frame replay + // does (EMSGSIZE → typed error, T-iOS-9). + await harness.transport.emitError(TermTransportError.replayTooLarge) + await harness.viewModel.waitUntilProcessed(eventCount: 4) + + // Assert: explicit error state with the plan §3.2 actionable copy — + // NOT a spinner, NOT a reconnecting banner. + #expect(harness.viewModel.phase + == .failed(message: TerminalViewModel.replayTooLargeMessage)) + #expect(TerminalViewModel.replayTooLargeMessage + == "服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限") + #expect(harness.viewModel.bannerModel + == .failed(message: TerminalViewModel.replayTooLargeMessage)) + #expect(harness.viewModel.isReadOnly) + + // Assert: deterministic failure never enters the backoff loop — the + // one original connect stays the only one, and no retry timer sleeps. + #expect(await harness.transport.connectAttempts.count == 1) + #expect(harness.clock.pendingSleeperCount == 0) + } + + // MARK: - Exit → read-only + + @Test("exited → read-only terminal + exit banner; input is dropped, never sent") + func exitedBecomesReadOnlyAndDropsInput() async throws { + // Arrange + let harness = try Harness() + await harness.openAndAdopt(UUID()) + + // Act + await harness.transport.emit(frame: ServerFrames.exit(code: 0, reason: "bye")) + await harness.viewModel.waitUntilProcessed(eventCount: 4) + + // Assert: read-only exit state, surfaced as the exit banner. + #expect(harness.viewModel.phase == .exited(code: 0, reason: "bye")) + #expect(harness.viewModel.isReadOnly) + #expect(harness.viewModel.bannerModel == .exited(code: 0, reason: "bye")) + + // Act: typing after exit — both key-bar and delegate paths. + harness.viewModel.send(key: .esc) + harness.viewModel.sendInput("x") + + // Assert: dropped at the VM boundary (counted, not forwarded). + #expect(harness.viewModel.droppedReadOnlyInputCount == 2) + #expect(harness.viewModel.forwardedSendCount == 0) + let frames = await harness.transport.sentFramesByConnection[0] + #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) + } + + // MARK: - Outbound sends (single ordered pipeline through KeyByteMap) + + @Test("key + text + resize forward to the engine in submission order; key bytes come from KeyByteMap") + func keyAndTextSendsForwardInOrderThroughKeyByteMap() async throws { + // Arrange + let harness = try Harness() + await harness.openAndAdopt(UUID()) + + // Act: rapid mixed submissions (two taps around typed text + a fit). + harness.viewModel.send(key: .esc) + harness.viewModel.sendInput("abc") + harness.viewModel.send(key: .enter) + harness.viewModel.sendResize(cols: 120, rows: 40) + await harness.viewModel.waitUntilForwarded(sendCount: 4) + + // Assert: exact wire frames, in order, bytes resolved via KeyByteMap + // (Esc = 0x1B, Enter = \r — never \n). + let frames = await harness.transport.sentFramesByConnection[0] + #expect(frames == [ + MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), + MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .esc))), + MessageCodec.encode(.input(data: "abc")), + MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .enter))), + MessageCodec.encode(.resize(cols: 120, rows: 40)), + ]) + } + + @Test("resize is NOT gated by read-only (engine owns terminal-state dropping)") + func resizeStillForwardsAfterExit() async throws { + // Arrange: exited session (read-only for INPUT only). + let harness = try Harness() + await harness.openAndAdopt(UUID()) + await harness.transport.emit(frame: ServerFrames.exit(code: 1, reason: "done")) + await harness.viewModel.waitUntilProcessed(eventCount: 4) + + // Act: a layout pass after exit still reports dims; the VM forwards and + // the ENGINE (already terminal) is the layer that drops it. + harness.viewModel.sendResize(cols: 80, rows: 24) + await harness.viewModel.waitUntilForwarded(sendCount: 1) + + // Assert: VM forwarded (no read-only drop); no wire frame appeared + // because the engine discarded it post-exit. + #expect(harness.viewModel.droppedReadOnlyInputCount == 0) + let frames = await harness.transport.sentFramesByConnection[0] + #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))]) + } +} diff --git a/ios/IntegrationTests/LiveServerTests.swift b/ios/IntegrationTests/LiveServerTests.swift deleted file mode 100644 index 82f4ff5..0000000 --- a/ios/IntegrationTests/LiveServerTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Testing -import WireProtocol - -/// T-iOS-1 scaffold placeholder — real integration tests against a live Node -/// server land in T-iOS-2 (OriginSpikeTests) and T-iOS-16 (CI harness). -@Test("脚手架:IntegrationTests 包可编译并能 import WireProtocol") -func integrationTestsScaffoldCompiles() { - // Arrange / Act - let contract = WireProtocolPackage.packageName - - // Assert - #expect(contract == "WireProtocol") -} diff --git a/ios/IntegrationTests/MirrorLifecycleTests.swift b/ios/IntegrationTests/MirrorLifecycleTests.swift new file mode 100644 index 0000000..fd4b152 --- /dev/null +++ b/ios/IntegrationTests/MirrorLifecycleTests.swift @@ -0,0 +1,100 @@ +// +// MirrorLifecycleTests.swift — T-iOS-16 测试 4/6/7:JOIN mirror 与会话生命周期 +// +// 4. 双客户端 JOIN mirror:A、B 同 attach 一个会话(新 attach 加入镜像、绝不 +// 踢人,src/session/manager.ts:108-174),A input,B 收到 output。 +// 6. DELETE /live-sessions/:id(kill)→ 镜像客户端观察到 WS close —— 不是 +// exit 帧:manager.killById 先 ws.close() 全部客户端再 kill PTY +// (src/session/manager.ts:202-212),exit 广播只投给 OPEN socket +// (sendIfOpen);且该 id 从 GET /live-sessions 消失。 +// 7. 自然退出广播:A input "exit\r" → bash 退出 → pty.onExit 向仍开着的 +// socket 广播 {type:'exit'}(src/session/session.ts:146-151)→ 镜像 B 收到。 + +import Foundation +import Testing +import WireProtocol + +@Suite("T-iOS-16 镜像与生命周期(真服务器)", .serialized, .timeLimit(.minutes(5))) +struct MirrorLifecycleTests { + + @Test("双客户端 JOIN mirror:B attach 同一会话拿到同 id,A input → B 收 output") + func mirrorJoinSharesOutput() async throws { + // Arrange: A 开新会话,B 以同 id 加入(镜像,不踢 A)。 + let server = try await ServerHarness.shared.server() + let clientA = WSTestClient(server: server, origin: server.origin) + let clientB = WSTestClient(server: server, origin: server.origin) + defer { + clientA.close() + clientB.close() + } + let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil) + let adoptedByB = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId) + #expect(adoptedByB == sessionId, "镜像 attach 应采用同一会话 id(JOIN 不踢)") + + // Act: A 打字 —— 哨兵 $((1300+37)) 只有 shell 执行才产出 1337。 + var readerB = TranscriptReader(client: clientB) + try await clientA.send(shellCommand("echo \"MIR:$((1300+37))\"")) + + // Assert: B(镜像端)收到 A 命令的输出广播。 + try await readerB.awaitContains("MIR:1337") + + // Cleanup + clientA.close() + clientB.close() + await killSessionBestEffort(server: server, id: sessionId) + } + + @Test("DELETE kill → 镜像观察到 WS close(非 exit 帧),id 从 /live-sessions 消失") + func killClosesMirrorSocketsWithoutExitFrame() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let clientA = WSTestClient(server: server, origin: server.origin) + let clientB = WSTestClient(server: server, origin: server.origin) + defer { + clientA.close() + clientB.close() + } + let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil) + _ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId) + let idString = sessionId.uuidString.lowercased() + + // Act: 带 Origin 的 kill(G 类端点,src/server.ts:354-359)。 + let status = try await deleteLiveSession(server: server, id: idString, origin: server.origin) + + // Assert: 204;id 立即从列表消失(killById 同步移除);镜像端观察到 close + // 且【没有】exit 帧(close-first 语义,src/session/manager.ts:202-212)。 + #expect(status == 204, "kill 应 204,实际 \(status)") + let idsAfter = try await liveSessionIds(server: server) + #expect(!idsAfter.contains(idString), "kill 后 id 不应再出现在 GET /live-sessions") + + let observedB = await drainUntilClose(client: clientB) + #expect(observedB.closed, "镜像客户端 B 应观察到 WS close") + #expect(!observedB.sawExit, "kill 路径不广播 exit 帧(先 close 后 kill)") + } + + @Test("自然退出广播:A input exit → 镜像 B 收到 {type:'exit'} 帧") + func naturalExitBroadcastsExitFrameToMirror() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let clientA = WSTestClient(server: server, origin: server.origin) + let clientB = WSTestClient(server: server, origin: server.origin) + defer { + clientA.close() + clientB.close() + } + let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil) + _ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId) + + // Act: A 让 shell 自然退出。 + try await clientA.send(shellCommand("exit")) + + // Assert: 镜像 B 收到 exit 帧(pty.onExit 广播,src/session/session.ts:146-151)。 + let exit = try await awaitExitFrame(client: clientB) + #expect(exit.code == 0, "bash `exit` 应干净退出,实际 code=\(exit.code)") + + // Cleanup(已退出的会话残留由服务器回收;kill 尽力而为)。 + clientA.close() + clientB.close() + await killSessionBestEffort(server: server, id: sessionId) + } +} diff --git a/ios/IntegrationTests/OriginGuardTests.swift b/ios/IntegrationTests/OriginGuardTests.swift new file mode 100644 index 0000000..6ae415f --- /dev/null +++ b/ios/IntegrationTests/OriginGuardTests.swift @@ -0,0 +1,67 @@ +// +// OriginGuardTests.swift — T-iOS-16 测试 5:Origin 守卫回归(吸收 spike①④) +// +// plan §5.1 的自动化化身。安全注:任何"为了过 CI 放宽 Origin 断言"= CRITICAL—— +// 401/403 的精确断言不许软化成"连接失败即可"。 +// - WS 升级无 Origin → 401(src/server.ts:646-651,默认拒空 Origin) +// - WS 升级端口失配 Origin → 401(src/http/origin.ts:47-51 端口精确比对; +// 注意 :443/:80 这类默认端口被两侧 new URL() 对称规范化,不是失配案例) +// - DELETE /live-sessions/:id 无 Origin → 403(G 守卫先于 id 查找, +// src/server.ts:332-339 requireAllowedOrigin) + +import Foundation +import Testing +import WireProtocol + +@Suite("T-iOS-16 Origin 守卫(真服务器)", .serialized, .timeLimit(.minutes(5))) +struct OriginGuardTests { + + @Test("无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)") + func upgradeWithoutOriginIsRejectedWith401() async throws { + let server = try await ServerHarness.shared.server() + let client = WSTestClient(server: server, origin: nil) + defer { client.close() } + + let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout) + + #expect(failure != nil, "无 Origin 升级必须失败") + #expect(client.handshakeStatusCode == 401, + "应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") + } + + @Test("端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)") + func portMismatchedOriginIsRejectedWith401() async throws { + let server = try await ServerHarness.shared.server() + let mismatchPort = server.port == HarnessTunables.mismatchedOriginPort + ? HarnessTunables.mismatchedOriginPortFallback + : HarnessTunables.mismatchedOriginPort + let badOrigin = "http://127.0.0.1:\(mismatchPort)" + let client = WSTestClient(server: server, origin: badOrigin) + defer { client.close() } + + let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout) + + #expect(failure != nil, "端口失配 Origin 升级必须失败") + #expect(client.handshakeStatusCode == 401, + "应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") + } + + @Test("DELETE /live-sessions/:id 无 Origin → 403;带 Origin 的同样请求 → 404(差分证明 403 来自守卫)") + func deleteWithoutOriginIsRejectedWith403() async throws { + // Arrange: 用一个不存在的随机 id —— 守卫在 id 查找之前执行,无需真会话。 + let server = try await ServerHarness.shared.server() + let bogusId = UUID().uuidString.lowercased() + + // Act + let statusWithoutOrigin = try await deleteLiveSession( + server: server, id: bogusId, origin: nil) + let statusWithOrigin = try await deleteLiveSession( + server: server, id: bogusId, origin: server.origin) + + // Assert: 无 Origin 走不到业务逻辑(403);带 Origin 走到了但 id 不存在(404)。 + #expect(statusWithoutOrigin == 403, + "无 Origin 的 DELETE 应 403(src/server.ts:332-339),实际 \(statusWithoutOrigin)") + #expect(statusWithOrigin == 404, + "带 Origin 但 id 不存在应 404(src/server.ts:354-359),实际 \(statusWithOrigin)") + } +} diff --git a/ios/IntegrationTests/OriginSpikeTests.swift b/ios/IntegrationTests/OriginSpikeTests.swift deleted file mode 100644 index 71c9078..0000000 --- a/ios/IntegrationTests/OriginSpikeTests.swift +++ /dev/null @@ -1,591 +0,0 @@ -// -// OriginSpikeTests.swift — T-iOS-2 Day-1 spike(自动化部分 ①–④) -// -// 对本仓库的【真 Node 服务器】实测 URLSessionWebSocketTask 的两个 make-or-break -// 平台事实(plan §7 T-iOS-2): -// ① 无 Origin 的 WS 升级被 401 拒绝(src/server.ts:646-651,默认拒空 Origin) -// ② 自定义 `Origin` header 能被 URLSessionWebSocketTask 发出并精确匹配白名单 -// → 升级成功、attach(null) 收到 attached(这是 MED 置信度事实的一锤定音) -// ③ ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()): -// 默认 maximumMessageSize=1MiB 收不下 >1MiB 回放帧(withKnownIssue 记录复现), -// 设 16 MiB 后成功;对抗变体:ESC/C0 密集输出(JSON \uXXXX 转义膨胀)仍成功 -// ④ 端口失配的 Origin(http://127.0.0.1:9999)→ 401(src/http/origin.ts:47-51 -// 端口精确比对;注意 :443 这类默认端口会被 new URL() 双向规范化,不是失配案例) -// -// 运行方式(可复现): -// A. 默认自举:`swift test --package-path ios/IntegrationTests` -// 自动从本仓库 spawn `node_modules/.bin/tsx src/server.ts`(repo 根由 -// #filePath 推导,可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1 的空闲端口, -// SHELL_PATH=/bin/bash、USE_TMUX=0。无需 ALLOWED_ORIGINS:服务器的 -// deriveAllowedOrigins 恒包含 http://127.0.0.1:(src/config.ts:187-226)。 -// 服务器进程经 atexit 回收;日志落在 $TMPDIR/webterm-spike-server-.log。 -// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, -// 这样其自身 origin 天然在白名单内)。 -// -// 注意:本文件**刻意不 import WireProtocol**(T-iOS-3 并行建设中,契约未冻结完成); -// 16 MiB 上限在 SpikeTunables 里本地复刻,正式常量归 WireProtocol.Tunables。 - -import Darwin -import Foundation -import Testing -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -// ─── 常量(无魔法数字;正式版见 WireProtocol.Tunables,T-iOS-3) ─────────────── - -private enum SpikeTunables { - /// plan §3.2.1 `Tunables.maxWSMessageBytes` 的本地复刻(不 import 并行包)。 - /// ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。 - static let maxWSMessageBytes = 16 * 1024 * 1024 - /// src/config.ts:41 DEFAULT_WS_PATH。 - static let wsPath = "/term" - /// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。 - static let bulkOutputBytes = 1_500_000 - /// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB, - /// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。 - static let escSequenceRepeats = 400_000 - static let escSequenceRawBytes = escSequenceRepeats * 4 - /// spike ④ 的失配端口(若真实端口撞车则退避到 fallback)。 - static let mismatchedOriginPort = 9999 - static let mismatchedOriginPortFallback = 9998 - static let serverReadyTimeout: Duration = .seconds(40) - static let serverReadyPollInterval: Duration = .milliseconds(200) - static let frameTimeout: Duration = .seconds(15) - static let outputAccumulationTimeout: Duration = .seconds(90) -} - -private enum SpikeError: Error, CustomStringConvertible { - case setup(String) - case timeout(String) - - var description: String { - switch self { - case .setup(let detail): return "setup: \(detail)" - case .timeout(let detail): return "timeout: \(detail)" - } - } -} - -// ─── 服务器端点快照(不可变) ─────────────────────────────────────────────── - -private struct SpikeServer: Sendable { - let baseURL: URL - let origin: String - let wsURL: URL - let port: Int - - /// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。 - static func make(baseURL: URL) throws -> SpikeServer { - guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else { - throw SpikeError.setup("server URL 必须是 http(s),got \(baseURL)") - } - guard let host = baseURL.host, !host.isEmpty else { - throw SpikeError.setup("server URL 缺 host: \(baseURL)") - } - guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { - throw SpikeError.setup("server URL 无法解析: \(baseURL)") - } - comps.scheme = scheme == "https" ? "wss" : "ws" - comps.path = SpikeTunables.wsPath - comps.query = nil - guard let wsURL = comps.url else { - throw SpikeError.setup("无法派生 ws URL: \(baseURL)") - } - // 浏览器式 Origin 序列化:非默认端口显式保留(spike 环境恒为显式端口)。 - let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)" - let port = baseURL.port ?? (scheme == "https" ? 443 : 80) - return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port) - } -} - -// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ──────── -// -// 实测:Swift Testing runner 退出时不跑 atexit handler(首轮运行留下孤儿 tsx), -// 所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin 是本测试 -// 进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端关闭 → -// watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。 - -private enum ServerProcessRegistry { - private static let lock = NSLock() - nonisolated(unsafe) private static var process: Process? - /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 - nonisolated(unsafe) private static var deathPipe: Pipe? - nonisolated(unsafe) private static var installedAtexit = false - - static func register(_ p: Process) { - lock.lock() - defer { lock.unlock() } - process = p - deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) - guard !installedAtexit else { return } - installedAtexit = true - atexit { ServerProcessRegistry.killNow() } - } - - static func killNow() { - lock.lock() - defer { lock.unlock() } - if let p = process, p.isRunning { p.terminate() } - process = nil - } - - private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe { - let pipe = Pipe() - let watchdog = Process() - watchdog.executableURL = URL(fileURLWithPath: "/bin/sh") - watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"] - watchdog.standardInput = pipe - watchdog.standardOutput = FileHandle.nullDevice - watchdog.standardError = FileHandle.nullDevice - try watchdog.run() - return pipe - } -} - -// ─── 服务器 harness(actor 单例:spawn 一次,全 suite 复用) ───────────────── - -private actor ServerHarness { - static let shared = ServerHarness() - private var cached: SpikeServer? - - func ensureServer() async throws -> SpikeServer { - if let cached { return cached } - let server: SpikeServer - if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { - guard let url = URL(string: external) else { - throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") - } - server = try SpikeServer.make(baseURL: url) - try await awaitReady(server, process: nil, logURL: nil) - } else { - let spawned = try spawnLocalServer() - ServerProcessRegistry.register(spawned.process) - do { - try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) - } catch { - ServerProcessRegistry.killNow() - throw error - } - server = spawned.server - } - cached = server - return server - } - - private func spawnLocalServer() throws -> (server: SpikeServer, process: Process, logURL: URL) { - let repoRoot = try locateRepoRoot() - let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") - guard FileManager.default.isExecutableFile(atPath: tsx.path) else { - throw SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install") - } - let port = try findFreeLoopbackPort() - guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { - throw SpikeError.setup("无法构造 baseURL, port=\(port)") - } - let server = try SpikeServer.make(baseURL: baseURL) - - let logURL = FileManager.default.temporaryDirectory - .appending(path: "webterm-spike-server-\(port).log") - FileManager.default.createFile(atPath: logURL.path, contents: nil) - let logHandle = try FileHandle(forWritingTo: logURL) - - let process = Process() - process.executableURL = tsx - process.arguments = ["src/server.ts"] - process.currentDirectoryURL = repoRoot - var env = ProcessInfo.processInfo.environment - env["PORT"] = String(port) - env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0 - env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) - env["USE_TMUX"] = "0" - env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" - process.environment = env - process.standardOutput = logHandle - process.standardError = logHandle - try process.run() - return (server, process, logURL) - } - - private func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws { - let probeURL = server.baseURL.appending(path: "live-sessions") - let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout - while ContinuousClock.now < deadline { - if let process, !process.isRunning { - throw SpikeError.setup( - "服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") - } - if let (data, response) = try? await URLSession.shared.data(from: probeURL), - (response as? HTTPURLResponse)?.statusCode == 200, - (try? JSONSerialization.jsonObject(with: data)) is [Any] { - return - } - try await Task.sleep(for: SpikeTunables.serverReadyPollInterval) - } - throw SpikeError.timeout( - "服务器 \(server.baseURL) 在 \(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")") - } -} - -private func locateRepoRoot() throws -> URL { - if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { - return URL(fileURLWithPath: override, isDirectory: true) - } - // #filePath = /ios/IntegrationTests/OriginSpikeTests.swift - return URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // IntegrationTests - .deletingLastPathComponent() // ios - .deletingLastPathComponent() // repo root -} - -/// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略, -/// 若真撞车 awaitReady 会用日志报错)。 -private func findFreeLoopbackPort() throws -> Int { - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { throw SpikeError.setup("socket() 失败: errno \(errno)") } - defer { close(fd) } - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = 0 - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - let bound = withUnsafePointer(to: &addr) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { - bind(fd, $0, socklen_t(MemoryLayout.size)) - } - } - guard bound == 0 else { throw SpikeError.setup("bind() 失败: errno \(errno)") } - - var out = sockaddr_in() - var len = socklen_t(MemoryLayout.size) - let named = withUnsafeMutablePointer(to: &out) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } - } - guard named == 0 else { throw SpikeError.setup("getsockname() 失败: errno \(errno)") } - return Int(UInt16(bigEndian: out.sin_port)) -} - -// ─── WS 客户端薄封装(每测一个;不可变持有 task) ─────────────────────────── - -private final class SpikeWSClient: @unchecked Sendable { - let task: URLSessionWebSocketTask - - /// - Parameters: - /// - origin: nil = 不带 Origin header(spike ①)。 - /// - maxMessageBytes: nil = 保持平台默认 1 MiB(spike ③ 复现分支)。 - init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) { - var request = URLRequest(url: server.wsURL) - request.timeoutInterval = 15 - if let origin { - // 平台事实核心:Origin 不在 reserved-header 列表,setValue 应生效。 - request.setValue(origin, forHTTPHeaderField: "Origin") - } - let task = URLSession.shared.webSocketTask(with: request) - if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } - self.task = task - task.resume() - } - - /// 握手响应状态码(升级被拒时应为 401;成功时 101)。 - var handshakeStatusCode: Int? { - (task.response as? HTTPURLResponse)?.statusCode - } - - func sendFrame(_ object: [String: Any]) async throws { - let data = try JSONSerialization.data(withJSONObject: object) - guard let text = String(data: data, encoding: .utf8) else { - throw SpikeError.setup("帧序列化非 UTF-8") - } - try await task.send(.string(text)) - } - - /// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。 - func receiveText(timeout: Duration) async throws -> String { - try await withTimeout(timeout) { [self] in - while true { - switch try await task.receive() { - case .string(let text): return text - case .data: continue - @unknown default: continue - } - } - } - } - - /// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。 - func handshakeFailure(timeout: Duration) async -> (any Error)? { - do { - _ = try await receiveText(timeout: timeout) - return nil - } catch { - return error - } - } - - func close() { - task.cancel(with: .normalClosure, reason: nil) - } -} - -private func withTimeout( - _ timeout: Duration, - _ operation: @escaping @Sendable () async throws -> T -) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: timeout) - throw SpikeError.timeout("操作超过 \(timeout)") - } - guard let first = try await group.next() else { - throw SpikeError.timeout("task group 无结果") - } - group.cancelAll() - return first - } -} - -// ─── 帧解码(镜像服务器"非法帧静默丢弃"的韧性,src/protocol.ts:45-86) ──────── - -private struct ServerFrame: Decodable { - let type: String - let data: String? - let sessionId: String? - let code: Int? -} - -private func decodeFrame(_ text: String) -> ServerFrame? { - guard let data = text.data(using: .utf8) else { return nil } - // 解码失败 → nil → 调用方跳过该帧(不 crash、不猜),与服务器同语义。 - return try? JSONDecoder().decode(ServerFrame.self, from: data) -} - -// ─── 协议流程助手 ─────────────────────────────────────────────────────────── - -/// attach 帧:sessionId 键必须显式存在(null 也要显式,src/protocol.ts:132-134)。 -private func attachFrame(sessionId: String?) -> [String: Any] { - ["type": "attach", "sessionId": sessionId ?? NSNull()] -} - -/// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。 -private func inputFrame(command: String) -> [String: Any] { - ["type": "input", "data": command + "\r"] -} - -/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached -/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。 -private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String { - try await client.sendFrame(attachFrame(sessionId: sessionId)) - let deadline = ContinuousClock.now + SpikeTunables.frameTimeout - while ContinuousClock.now < deadline { - let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) - guard let frame = decodeFrame(text) else { continue } - if frame.type == "attached", let id = frame.sessionId { return id } - } - throw SpikeError.timeout("attach 后未收到 attached 帧") -} - -/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。 -private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int { - var total = 0 - let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout - while ContinuousClock.now < deadline { - let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout) - guard let frame = decodeFrame(text), frame.type == "output" else { continue } - total += (frame.data ?? "").utf8.count - if total >= minBytes { return total } - } - throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时") -} - -private struct ReplaySummary: Sendable { - let adoptedSessionId: String - let totalOutputBytes: Int - let containsSoftReset: Bool -} - -/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。 -private func replayResult( - client: SpikeWSClient, sessionId: String, minBytes: Int -) async -> Result { - do { - try await client.sendFrame(attachFrame(sessionId: sessionId)) - var adopted: String? - var total = 0 - var sawSoftReset = false - let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout - while ContinuousClock.now < deadline { - let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout) - guard let frame = decodeFrame(text) else { continue } - switch frame.type { - case "attached": - adopted = frame.sessionId - case "output": - let data = frame.data ?? "" - total += data.utf8.count - // 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170) - if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true } - default: - break - } - if let adopted, total >= minBytes { - return .success(ReplaySummary( - adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset)) - } - } - return .failure(SpikeError.timeout( - "回放不完整: attached=\(adopted ?? "无") bytes=\(total)/\(minBytes)")) - } catch { - return .failure(error) - } -} - -/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 = -/// EMSGSIZE "Message too long";plan §1 记作 "ENOBUFS(40)",Darwin 上 ENOBUFS -/// 实为 55 — 两个码都接受,实测结果记录在返回日志条目里)。 -private func isMessageTooLongError(_ error: any Error) -> Bool { - func matches(_ ns: NSError) -> Bool { - ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS)) - } - let ns = error as NSError - if matches(ns) { return true } - if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) } - return false -} - -private func describeError(_ error: any Error) -> String { - let ns = error as NSError - let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError) - .map { " underlying=\($0.domain)#\($0.code)" } ?? "" - return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)" -} - -/// 测后清理:DELETE /live-sessions/:id 是 G 类端点,必须带 Origin(403 否则, -/// src/server.ts:332-339)。清理尽力而为——失败只影响宿主残留一个 bash 会话, -/// 不影响断言结果,故显式忽略错误。 -private func killSession(server: SpikeServer, id: String) async { - var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)")) - request.httpMethod = "DELETE" - request.setValue(server.origin, forHTTPHeaderField: "Origin") - _ = try? await URLSession.shared.data(for: request) -} - -// ─── Spike 断言 ①–④ ──────────────────────────────────────────────────────── - -@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5))) -struct OriginSpikeTests { - - @Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)") - func upgradeWithoutOriginIsRejectedWith401() async throws { - let server = try await ServerHarness.shared.ensureServer() - let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil) - defer { client.close() } - - let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) - - #expect(failure != nil, "无 Origin 升级必须失败") - #expect(client.handshakeStatusCode == 401, - "应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") - } - - @Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached") - func customOriginHeaderIsSentAndAttachSucceeds() async throws { - let server = try await ServerHarness.shared.ensureServer() - let client = SpikeWSClient( - server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) - defer { client.close() } - - let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil) - - #expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)") - #expect(client.handshakeStatusCode == 101, - "升级应 101,实际 \(String(describing: client.handshakeStatusCode))") - client.close() - await killSession(server: server, id: sessionId) - } - - @Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功") - func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws { - let server = try await ServerHarness.shared.ensureServer() - - // 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。 - let generator = SpikeWSClient( - server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) - let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) - try await generator.sendFrame( - inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'")) - let produced = try await accumulateOutput( - client: generator, minBytes: SpikeTunables.bulkOutputBytes) - #expect(produced >= SpikeTunables.bulkOutputBytes) - generator.close() - - // 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。 - let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil) - let defaultResult = await replayResult( - client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes) - defaultClient.close() - if case .failure(let error) = defaultResult { - #expect(isMessageTooLongError(error), - "失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))") - } - withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") { - _ = try defaultResult.get() - } - - // 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。 - let bigClient = SpikeWSClient( - server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) - let summary = try await replayResult( - client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get() - #expect(summary.adoptedSessionId == sessionId) - #expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes) - bigClient.close() - await killSession(server: server, id: sessionId) - } - - @Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功") - func escDenseReplayStillSucceedsAt16MiB() async throws { - let server = try await ServerHarness.shared.ensureServer() - - let generator = SpikeWSClient( - server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) - let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) - // perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("")。 - try await generator.sendFrame( - inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'")) - let produced = try await accumulateOutput( - client: generator, minBytes: SpikeTunables.escSequenceRawBytes) - #expect(produced >= SpikeTunables.escSequenceRawBytes) - generator.close() - - let bigClient = SpikeWSClient( - server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes) - let summary = try await replayResult( - client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get() - #expect(summary.adoptedSessionId == sessionId) - #expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes) - #expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)") - bigClient.close() - await killSession(server: server, id: sessionId) - } - - @Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)") - func portMismatchedOriginIsRejectedWith401() async throws { - let server = try await ServerHarness.shared.ensureServer() - let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort - ? SpikeTunables.mismatchedOriginPortFallback - : SpikeTunables.mismatchedOriginPort - // 注意::443/:80 这类 scheme 默认端口会被两侧 new URL() 对称规范化 - // (src/http/origin.ts:31-51),不构成失配;本测只断言显式端口失配。 - let badOrigin = "http://127.0.0.1:\(mismatchPort)" - let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil) - defer { client.close() } - - let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout) - - #expect(failure != nil, "端口失配 Origin 升级必须失败") - #expect(client.handshakeStatusCode == 401, - "应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")") - } -} diff --git a/ios/IntegrationTests/Package.swift b/ios/IntegrationTests/Package.swift index 639df34..5f1b808 100644 --- a/ios/IntegrationTests/Package.swift +++ b/ios/IntegrationTests/Package.swift @@ -1,7 +1,9 @@ // swift-tools-version: 6.0 -// T-iOS-1 scaffold shell. Real live-server tests land in T-iOS-2 -// (OriginSpikeTests, against `npm start`) and T-iOS-16 (CI integration). +// T-iOS-16 integration-CI package: Swift Testing suites against the REAL Node +// server (self-bootstrapped by ServerHarness — spawns `tsx src/server.ts` on an +// ephemeral loopback port; see ServerHarness.swift for the run modes). // Flat layout (plan §2): test sources live directly in ios/IntegrationTests/. +// `scripts/` holds the per-package coverage gate used by .github/workflows/ios.yml. import PackageDescription let package = Package( @@ -14,7 +16,8 @@ let package = Package( .testTarget( name: "IntegrationTests", dependencies: [.product(name: "WireProtocol", package: "WireProtocol")], - path: "." + path: ".", + exclude: ["scripts"] ), ], swiftLanguageModes: [.v6] diff --git a/ios/IntegrationTests/ProtocolFlowTests.swift b/ios/IntegrationTests/ProtocolFlowTests.swift new file mode 100644 index 0000000..19828f1 --- /dev/null +++ b/ios/IntegrationTests/ProtocolFlowTests.swift @@ -0,0 +1,74 @@ +// +// ProtocolFlowTests.swift — T-iOS-16 测试 1-2:基本协议流程(吸收 spike②) +// +// 1. attach(null) → attached → input "echo hi\r" → output 含 "hi" +// (附加强断言 RUN:42:证明命令确被 shell 执行,而不是只看到输入回显) +// 2. resize(120,40) → `stty size` 输出 "40 120";边界 resize 1/1000 各一发, +// 以 stty 实测尺寸生效证明"被接受"(服务器对非法 resize 是静默丢弃, +// src/protocol.ts:113-115 —— 只有尺寸真变了才证明帧被采纳),且连接存活。 +// +// 客户端帧一律 MessageCodec 编码、服务器帧一律 MessageCodec 解码 —— 本 suite +// 同时是冻结契约与真服务器之间的防漂移闸门(plan §9)。 + +import Foundation +import Testing +import WireProtocol + +@Suite("T-iOS-16 协议流程(真服务器)", .serialized, .timeLimit(.minutes(5))) +struct ProtocolFlowTests { + + @Test("attach(null)→attached(101);echo hi → output 含 hi 且命令确被执行") + func attachEchoRoundtrip() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let client = WSTestClient(server: server, origin: server.origin) + defer { client.close() } + + // Act: attach(null) → attached(spike② 阳性对照:自定义 Origin 升级 101) + let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil) + #expect(client.handshakeStatusCode == 101, + "升级应 101,实际 \(String(describing: client.handshakeStatusCode))") + + var reader = TranscriptReader(client: client) + try await client.send(shellCommand("echo hi")) + try await reader.awaitContains("hi") + + // 强断言:$((6*7)) 只有 shell 真执行才会变成 42(回显里只有字面量表达式)。 + try await client.send(shellCommand("echo \"RUN:$((6*7))\"")) + try await reader.awaitContains("RUN:42") + + // Cleanup + client.close() + await killSessionBestEffort(server: server, id: sessionId) + } + + @Test("resize(120,40) → stty size = 40 120;边界 1/1000 均被采纳且连接存活") + func resizeTakesEffectIncludingBoundaries() async throws { + // Arrange + let server = try await ServerHarness.shared.server() + let client = WSTestClient(server: server, origin: server.origin) + defer { client.close() } + let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil) + var reader = TranscriptReader(client: client) + + // Act + Assert: 常规尺寸(任务字面用例)。SZ: 哨兵防止把输入回显误判成输出 + //(回显里是字面量 $(stty size),只有命令执行结果才含 "SZ: ")。 + try await client.send(.resize(cols: 120, rows: 40)) + try await client.send(shellCommand("echo \"SZ:$(stty size)\"")) + try await reader.awaitContains("SZ:40 120") + + // 边界下限:1×1 被采纳(stty 实测 1 1),连接未断。 + try await client.send(.resize(cols: 1, rows: 1)) + try await client.send(shellCommand("echo \"SZ:$(stty size)\"")) + try await reader.awaitContains("SZ:1 1") + + // 边界上限:1000×1000 被采纳,连接未断。 + try await client.send(.resize(cols: 1000, rows: 1000)) + try await client.send(shellCommand("echo \"SZ:$(stty size)\"")) + try await reader.awaitContains("SZ:1000 1000") + + // Cleanup + client.close() + await killSessionBestEffort(server: server, id: sessionId) + } +} diff --git a/ios/IntegrationTests/ReplayTests.swift b/ios/IntegrationTests/ReplayTests.swift new file mode 100644 index 0000000..e73b916 --- /dev/null +++ b/ios/IntegrationTests/ReplayTests.swift @@ -0,0 +1,79 @@ +// +// ReplayTests.swift — T-iOS-16 测试 3:>1 MiB 单帧回放(吸收 spike③ + 对抗变体) +// +// ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()), +// JSON 会把控制字节 \uXXXX 转义膨胀 1-6×(src/protocol.ts:186): +// - 复现分支:平台默认 maximumMessageSize=1 MiB 收不下(withKnownIssue 常驻记录, +// 若"意外通过"说明平台行为变了,须重新评估 Tunables.maxWSMessageBytes) +// - 修复分支:Tunables.maxWSMessageBytes(16 MiB)→ 回放完整 +// - 对抗变体:ESC/C0 密集输出(转义膨胀 ~6×)→ 仍完整 + +import Foundation +import Testing +import WireProtocol + +@Suite("T-iOS-16 回放上限(真服务器)", .serialized, .timeLimit(.minutes(5))) +struct ReplayTests { + + @Test(">1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);Tunables 16MiB 成功") + func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws { + let server = try await ServerHarness.shared.server() + + // 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。 + let generator = WSTestClient(server: server, origin: server.origin) + let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) + try await generator.send(shellCommand( + "perl -e 'print \"a\" x \(HarnessTunables.bulkOutputBytes)'")) + let produced = try await accumulateOutputBytes( + client: generator, minBytes: HarnessTunables.bulkOutputBytes) + #expect(produced >= HarnessTunables.bulkOutputBytes) + generator.close() + + // 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。 + let defaultClient = WSTestClient(server: server, origin: server.origin, maxMessageBytes: nil) + let defaultResult = await replayResult( + client: defaultClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes) + defaultClient.close() + if case .failure(let error) = defaultResult { + #expect(isMessageTooLongError(error), + "失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))") + } + withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") { + _ = try defaultResult.get() + } + + // 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。 + let bigClient = WSTestClient(server: server, origin: server.origin) + let summary = try await replayResult( + client: bigClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes).get() + #expect(summary.adoptedSessionId == sessionId) + #expect(summary.totalOutputBytes >= HarnessTunables.bulkOutputBytes) + bigClient.close() + await killSessionBestEffort(server: server, id: sessionId) + } + + @Test("对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍完整") + func escDenseReplayStillSucceedsAt16MiB() async throws { + let server = try await ServerHarness.shared.server() + + let generator = WSTestClient(server: server, origin: server.origin) + let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil) + // perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("[0m")。 + try await generator.send(shellCommand( + "perl -e 'print \"\\e[0m\" x \(HarnessTunables.escSequenceRepeats)'")) + let produced = try await accumulateOutputBytes( + client: generator, minBytes: HarnessTunables.escSequenceRawBytes) + #expect(produced >= HarnessTunables.escSequenceRawBytes) + generator.close() + + let bigClient = WSTestClient(server: server, origin: server.origin) + let summary = try await replayResult( + client: bigClient, sessionId: sessionId, + minBytes: HarnessTunables.escSequenceRawBytes).get() + #expect(summary.adoptedSessionId == sessionId) + #expect(summary.totalOutputBytes >= HarnessTunables.escSequenceRawBytes) + #expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)") + bigClient.close() + await killSessionBestEffort(server: server, id: sessionId) + } +} diff --git a/ios/IntegrationTests/ServerHarness.swift b/ios/IntegrationTests/ServerHarness.swift new file mode 100644 index 0000000..b0b345f --- /dev/null +++ b/ios/IntegrationTests/ServerHarness.swift @@ -0,0 +1,260 @@ +// +// ServerHarness.swift — T-iOS-16 集成测试基础设施:真 Node 服务器自举 +// +// 由 T-iOS-2 的 OriginSpikeTests.swift(591 行单文件)拆分而来(plan §7 T-iOS-16 +// 明确批准 harness + suites 拆分)。本文件只管服务器生命周期: +// - spawn 一次本仓库真服务器(`node_modules/.bin/tsx src/server.ts`),全部 +// suite 复用同一进程(会话彼此独立,测试并行安全); +// - 就绪探测(GET /live-sessions 返回 JSON 数组); +// - 进程回收:death-pipe watchdog + atexit 双保险(Swift Testing runner 退出 +// 时不可靠地跑 atexit,watchdog 是主机制)。 +// +// 运行方式(可复现): +// A. 默认自举:`swift test --package-path ios/IntegrationTests` +// repo 根由 #filePath 推导(可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1 +// 的空闲端口,SHELL_PATH=/bin/bash(确定性输出)、USE_TMUX=0。无需 +// ALLOWED_ORIGINS:服务器的 deriveAllowedOrigins 恒包含 +// http://127.0.0.1:(src/config.ts:187-226)。 +// 日志落在 $TMPDIR/webterm-integration-server-.log。 +// B. 外部服务器:WEBTERM_SERVER_URL=http://127.0.0.1:(须为 loopback, +// 这样其自身 origin 天然在白名单内)。 +// +// 与 spike 版的关键差异:WireProtocol 已冻结(T-iOS-3),Origin/wsURL 一律由 +// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。 + +import Darwin +import Foundation +import WireProtocol +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// ─── 常量(无魔法数字;线协议常量一律取 WireProtocol.Tunables/WireConstants) ── + +enum HarnessTunables { + /// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。 + static let bulkOutputBytes = 1_500_000 + /// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB, + /// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。 + static let escSequenceRepeats = 400_000 + static let escSequenceRawBytes = escSequenceRepeats * 4 + /// Origin 失配用端口(若真实端口撞车则退避到 fallback)。 + static let mismatchedOriginPort = 9999 + static let mismatchedOriginPortFallback = 9998 + static let serverReadyTimeout: Duration = .seconds(40) + static let serverReadyPollInterval: Duration = .milliseconds(200) + /// 单帧等待上限(attach→attached 等短往返)。 + static let frameTimeout: Duration = .seconds(15) + /// 等待某个 shell 输出标记出现的上限(含 bash 冷启动)。 + static let markerTimeout: Duration = .seconds(30) + /// 大流量累计(>1MiB 灌入/回放)的上限。 + static let outputAccumulationTimeout: Duration = .seconds(90) + /// 等待服务器主动关闭 WS 的上限(kill 路径)。 + static let closeObserveTimeout: Duration = .seconds(30) +} + +enum HarnessError: Error, CustomStringConvertible { + case setup(String) + case timeout(String) + + var description: String { + switch self { + case .setup(let detail): return "setup: \(detail)" + case .timeout(let detail): return "timeout: \(detail)" + } + } +} + +// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ──────────────── + +struct TestServer: Sendable { + let endpoint: HostEndpoint + + var baseURL: URL { endpoint.baseURL } + var wsURL: URL { endpoint.wsURL } + /// 白名单内的 Origin —— 由 HostEndpoint 单点派生(plan §5.1,禁止手拼)。 + var origin: String { endpoint.originHeader } + var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) } + + /// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。 + static func make(baseURL: URL) throws -> TestServer { + guard let endpoint = HostEndpoint(baseURL: baseURL) else { + throw HarnessError.setup("server URL 必须是带 host 的 http(s),got \(baseURL)") + } + return TestServer(endpoint: endpoint) + } +} + +// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ──────── +// +// 实测(T-iOS-2):Swift Testing runner 退出时不跑 atexit handler(首轮运行留下 +// 孤儿 tsx),所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin +// 是本测试进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端 +// 关闭 → watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。 + +private enum ServerProcessRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var process: Process? + /// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。 + nonisolated(unsafe) private static var deathPipe: Pipe? + nonisolated(unsafe) private static var installedAtexit = false + + static func register(_ p: Process) { + lock.lock() + defer { lock.unlock() } + process = p + deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier) + guard !installedAtexit else { return } + installedAtexit = true + atexit { ServerProcessRegistry.killNow() } + } + + static func killNow() { + lock.lock() + defer { lock.unlock() } + if let p = process, p.isRunning { p.terminate() } + process = nil + } + + private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe { + let pipe = Pipe() + let watchdog = Process() + watchdog.executableURL = URL(fileURLWithPath: "/bin/sh") + watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"] + watchdog.standardInput = pipe + watchdog.standardOutput = FileHandle.nullDevice + watchdog.standardError = FileHandle.nullDevice + try watchdog.run() + return pipe + } +} + +// ─── 服务器 harness(actor 单例:spawn 一次,全部 suite 复用) ───────────────── +// +// Swift Testing 默认跨 suite 并行 → 多个 suite 会并发调 server()。actor 是可重入 +// 的:首个调用在 awaitReady 挂起时,后来者会重入进来 —— 若用"缓存成品"模式就会 +// 重复 spawn。所以缓存的是 in-flight Task(同步写入,后来者 await 同一个)。 + +actor ServerHarness { + static let shared = ServerHarness() + private var bootTask: Task? + + func server() async throws -> TestServer { + if let bootTask { return try await bootTask.value } + let task = Task { try await Self.bootstrap() } + bootTask = task + return try await task.value + } + + private static func bootstrap() async throws -> TestServer { + if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] { + guard let url = URL(string: external) else { + throw HarnessError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)") + } + let server = try TestServer.make(baseURL: url) + try await awaitReady(server, process: nil, logURL: nil) + return server + } + let spawned = try spawnLocalServer() + ServerProcessRegistry.register(spawned.process) + do { + try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL) + } catch { + ServerProcessRegistry.killNow() + throw error + } + return spawned.server + } + + private static func spawnLocalServer() throws -> (server: TestServer, process: Process, logURL: URL) { + let repoRoot = try locateRepoRoot() + let tsx = repoRoot.appending(path: "node_modules/.bin/tsx") + guard FileManager.default.isExecutableFile(atPath: tsx.path) else { + throw HarnessError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install/npm ci") + } + let port = try findFreeLoopbackPort() + guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else { + throw HarnessError.setup("无法构造 baseURL, port=\(port)") + } + let server = try TestServer.make(baseURL: baseURL) + + let logURL = FileManager.default.temporaryDirectory + .appending(path: "webterm-integration-server-\(port).log") + FileManager.default.createFile(atPath: logURL.path, contents: nil) + let logHandle = try FileHandle(forWritingTo: logURL) + + let process = Process() + process.executableURL = tsx + process.arguments = ["src/server.ts"] + process.currentDirectoryURL = repoRoot + var env = ProcessInfo.processInfo.environment + env["PORT"] = String(port) + env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0 + env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置) + env["USE_TMUX"] = "0" + env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin" + process.environment = env + process.standardOutput = logHandle + process.standardError = logHandle + try process.run() + return (server, process, logURL) + } + + private static func awaitReady(_ server: TestServer, process: Process?, logURL: URL?) async throws { + let probeURL = server.baseURL.appending(path: "live-sessions") + let deadline = ContinuousClock.now + HarnessTunables.serverReadyTimeout + while ContinuousClock.now < deadline { + if let process, !process.isRunning { + throw HarnessError.setup( + "服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")") + } + if let (data, response) = try? await URLSession.shared.data(from: probeURL), + (response as? HTTPURLResponse)?.statusCode == 200, + (try? JSONSerialization.jsonObject(with: data)) is [Any] { + return + } + try await Task.sleep(for: HarnessTunables.serverReadyPollInterval) + } + throw HarnessError.timeout( + "服务器 \(server.baseURL) 在 \(HarnessTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")") + } +} + +func locateRepoRoot() throws -> URL { + if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] { + return URL(fileURLWithPath: override, isDirectory: true) + } + // #filePath = /ios/IntegrationTests/ServerHarness.swift + return URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // IntegrationTests + .deletingLastPathComponent() // ios + .deletingLastPathComponent() // repo root +} + +/// bind(port=0) 拿一个空闲 loopback 端口(close 后交给服务器绑定;竞态窗口可忽略, +/// 若真撞车 awaitReady 会用日志报错)。 +func findFreeLoopbackPort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw HarnessError.setup("socket() 失败: errno \(errno)") } + defer { close(fd) } + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = 0 + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bound = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") } + + var out = sockaddr_in() + var len = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &out) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } + } + guard named == 0 else { throw HarnessError.setup("getsockname() 失败: errno \(errno)") } + return Int(UInt16(bigEndian: out.sin_port)) +} diff --git a/ios/IntegrationTests/WSTestClient.swift b/ios/IntegrationTests/WSTestClient.swift new file mode 100644 index 0000000..b513ce4 --- /dev/null +++ b/ios/IntegrationTests/WSTestClient.swift @@ -0,0 +1,281 @@ +// +// WSTestClient.swift — T-iOS-16 集成测试的 WS 客户端与协议流程助手 +// +// 由 OriginSpikeTests.swift 拆分而来。与 spike 版的关键差异:帧的编/解码一律走 +// 冻结契约 `MessageCodec`(T-iOS-3)——集成测试因此同时守护"客户端复刻的协议" +// 与服务器实现之间的漂移(plan §9:这是 Origin spike 的常驻化)。 +// 默认 `maximumMessageSize = Tunables.maxWSMessageBytes`(16 MiB,plan §3.2.1); +// 只有 spike③ 的复现分支显式传 nil 保持平台默认 1 MiB。 + +import Foundation +import WireProtocol +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// ─── WS 客户端薄封装(每测一个;不可变持有 task) ─────────────────────────── + +final class WSTestClient: @unchecked Sendable { + let task: URLSessionWebSocketTask + + /// - Parameters: + /// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自 + /// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。 + /// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB); + /// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。 + init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) { + var request = URLRequest(url: server.wsURL) + request.timeoutInterval = 15 + if let origin { + // T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。 + request.setValue(origin, forHTTPHeaderField: "Origin") + } + let task = URLSession.shared.webSocketTask(with: request) + if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes } + self.task = task + task.resume() + } + + /// 握手响应状态码(升级被拒时应为 401;成功时 101)。 + var handshakeStatusCode: Int? { + (task.response as? HTTPURLResponse)?.statusCode + } + + /// 经冻结契约编码后发送(客户端帧永远走 MessageCodec,不手拼 JSON)。 + func send(_ message: ClientMessage) async throws { + try await task.send(.string(MessageCodec.encode(message))) + } + + /// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。 + func receiveText(timeout: Duration) async throws -> String { + try await withTimeout(timeout) { [self] in + while true { + switch try await task.receive() { + case .string(let text): return text + case .data: continue + @unknown default: continue + } + } + } + } + + /// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。 + func handshakeFailure(timeout: Duration) async -> (any Error)? { + do { + _ = try await receiveText(timeout: timeout) + return nil + } catch { + return error + } + } + + func close() { + task.cancel(with: .normalClosure, reason: nil) + } +} + +func withTimeout( + _ timeout: Duration, + _ operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: timeout) + throw HarnessError.timeout("操作超过 \(timeout)") + } + guard let first = try await group.next() else { + throw HarnessError.timeout("task group 无结果") + } + group.cancelAll() + return first + } +} + +// ─── 协议流程助手(解码一律 MessageCodec.decodeServer;非法帧丢弃,同服务器语义) ── + +/// input 帧:Enter 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。 +func shellCommand(_ line: String) -> ClientMessage { + .input(data: line + "\r") +} + +/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached +/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。 +func attachAndAwaitAttached(client: WSTestClient, sessionId: UUID?) async throws -> UUID { + try await client.send(.attach(sessionId: sessionId, cwd: nil)) + let deadline = ContinuousClock.now + HarnessTunables.frameTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout) + if case let .attached(id)? = MessageCodec.decodeServer(text) { return id } + } + throw HarnessError.timeout("attach 后未收到 attached 帧") +} + +/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。 +func accumulateOutputBytes(client: WSTestClient, minBytes: Int) async throws -> Int { + var total = 0 + let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: HarnessTunables.outputAccumulationTimeout) + guard case let .output(data)? = MessageCodec.decodeServer(text) else { continue } + total += data.utf8.count + if total >= minBytes { return total } + } + throw HarnessError.timeout("输出累计 \(total)/\(minBytes) 字节后超时") +} + +/// 顺序累积 output 数据、等待标记出现(标记可跨帧,故必须整段累积再查找)。 +/// 值类型:每个测试自己持有一份,随用随弃(不可变风格;transcript 只增不改)。 +struct TranscriptReader { + let client: WSTestClient + private(set) var transcript = "" + + mutating func awaitContains( + _ marker: String, timeout: Duration = HarnessTunables.markerTimeout + ) async throws { + let deadline = ContinuousClock.now + timeout + while !transcript.contains(marker) { + guard ContinuousClock.now < deadline else { + throw HarnessError.timeout( + "等 \"\(marker)\" 超时;transcript 尾部: …\(transcript.suffix(200))") + } + let text = try await client.receiveText(timeout: timeout) + if case let .output(data)? = MessageCodec.decodeServer(text) { + transcript += data + } + } + } +} + +struct ReplaySummary: Sendable { + let adoptedSessionId: UUID + let totalOutputBytes: Int + let containsSoftReset: Bool +} + +/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。 +func replayResult( + client: WSTestClient, sessionId: UUID, minBytes: Int +) async -> Result { + do { + try await client.send(.attach(sessionId: sessionId, cwd: nil)) + var adopted: UUID? + var total = 0 + var sawSoftReset = false + let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout) + switch MessageCodec.decodeServer(text) { + case let .attached(id)?: + adopted = id + case let .output(data)?: + total += data.utf8.count + // 回放前缀 soft-reset \x1b[0m(src/types.ts:167-170 / M2)。 + if !sawSoftReset, data.contains(WireConstants.replaySoftResetPrefix) { + sawSoftReset = true + } + default: + break + } + if let adopted, total >= minBytes { + return .success(ReplaySummary( + adoptedSessionId: adopted, totalOutputBytes: total, + containsSoftReset: sawSoftReset)) + } + } + return .failure(HarnessError.timeout( + "回放不完整: attached=\(adopted.map(\.uuidString) ?? "无") bytes=\(total)/\(minBytes)")) + } catch { + return .failure(error) + } +} + +/// 读到 socket 结束为止:返回(是否见到 exit 帧,是否观察到 close)。 +/// 本地等待超时(HarnessError)≠ close —— 二者显式区分,绝不混淆断言语义。 +func drainUntilClose( + client: WSTestClient, timeout: Duration = HarnessTunables.closeObserveTimeout +) async -> (sawExit: Bool, closed: Bool) { + var sawExit = false + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + do { + let text = try await client.receiveText(timeout: timeout) + if case .exit? = MessageCodec.decodeServer(text) { sawExit = true } + } catch is HarnessError { + return (sawExit, false) // 本地超时:socket 还开着 + } catch { + return (sawExit, true) // receive 抛平台错误 = 流已结束(close/断开) + } + } + return (sawExit, false) +} + +/// 等 exit 帧(自然退出广播路径,src/session/session.ts:146-151)。 +func awaitExitFrame( + client: WSTestClient, timeout: Duration = HarnessTunables.markerTimeout +) async throws -> (code: Int, reason: String?) { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + let text = try await client.receiveText(timeout: timeout) + if case let .exit(code, reason)? = MessageCodec.decodeServer(text) { + return (code, reason) + } + } + throw HarnessError.timeout("未收到 exit 帧") +} + +// ─── 超限失败模式判定(spike③ 复现分支) ──────────────────────────────────── + +/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 = +/// EMSGSIZE "Message too long";plan §1 勘误 —— 原文误标 ENOBUFS,Darwin 上 +/// ENOBUFS 实为 55,两个码都接受)。 +func isMessageTooLongError(_ error: any Error) -> Bool { + func matches(_ ns: NSError) -> Bool { + ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS)) + } + let ns = error as NSError + if matches(ns) { return true } + if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) } + return false +} + +func describeError(_ error: any Error) -> String { + let ns = error as NSError + let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError) + .map { " underlying=\($0.domain)#\($0.code)" } ?? "" + return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)" +} + +// ─── HTTP 助手(G 类端点带 Origin;RO GET 一律不带 —— plan §3.4 铁律) ──────── + +/// DELETE /live-sessions/:id(G 类,src/server.ts:354-359)。origin=nil 用于 +/// 守卫负路径(应得 403)。返回 HTTP 状态码。 +func deleteLiveSession(server: TestServer, id: String, origin: String?) async throws -> Int { + var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)")) + request.httpMethod = "DELETE" + if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") } + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw HarnessError.setup("DELETE /live-sessions/\(id) 非 HTTP 响应") + } + return http.statusCode +} + +/// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。 +func liveSessionIds(server: TestServer) async throws -> [String] { + let url = server.baseURL.appending(path: "live-sessions") + let (data, response) = try await URLSession.shared.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200, + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + throw HarnessError.setup("GET /live-sessions 非预期形状") + } + return array.compactMap { $0["id"] as? String } +} + +/// 测后清理:尽力而为 —— 失败只影响宿主残留一个 bash 会话,不影响断言结果, +/// 故显式忽略错误(这是唯一允许吞错的地方,且吞的只是清理路径)。 +func killSessionBestEffort(server: TestServer, id: UUID) async { + _ = try? await deleteLiveSession( + server: server, id: id.uuidString.lowercased(), origin: server.origin) +} diff --git a/ios/IntegrationTests/scripts/coverage-gate.sh b/ios/IntegrationTests/scripts/coverage-gate.sh new file mode 100755 index 0000000..4c63555 --- /dev/null +++ b/ios/IntegrationTests/scripts/coverage-gate.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# coverage-gate.sh — T-iOS-16 per-package OWN-SOURCES coverage gate (>= 80%). +# +# Usage: coverage-gate.sh # e.g. coverage-gate.sh WireProtocol +# Env: COVERAGE_THRESHOLD= # default 80 (red/green demo knob) +# +# WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command +# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/' +# | jq '.data[0].totals.lines.percent >= 80' +# reads the export TOTALS, which include every STATICALLY-LINKED DEPENDENCY +# source compiled into the test binary (e.g. WireProtocol sources inside +# SessionCore's test run). That lets a package's own weak coverage hide behind a +# well-covered dependency (or vice versa). The corrected filter below keeps ONLY +# files under Packages/

/Sources/ (excluding *Placeholder* scaffolding) and +# recomputes line coverage from the per-file summaries. +# +# Exit codes: 0 = gate passed; 1 = below threshold, filter matched no sources, +# or any build/test failure (set -e). + +set -euo pipefail + +PKG="${1:?usage: coverage-gate.sh (WireProtocol|SessionCore|HostRegistry|APIClient)}" +THRESHOLD="${COVERAGE_THRESHOLD:-80}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PKG_DIR="$REPO_ROOT/ios/Packages/$PKG" + +if [ ! -d "$PKG_DIR" ]; then + echo "coverage-gate($PKG): package dir not found: $PKG_DIR" >&2 + exit 1 +fi + +# 1) Run the package tests with coverage instrumentation (test failure => gate red). +swift test --package-path "$PKG_DIR" --enable-code-coverage + +# 2) Locate the test binary and the merged profile (paths per plan §9). +BIN="$(swift build --package-path "$PKG_DIR" --show-bin-path)/${PKG}PackageTests.xctest/Contents/MacOS/${PKG}PackageTests" +PROF="$(dirname "$(swift test --package-path "$PKG_DIR" --show-codecov-path)")/default.profdata" + +# 3) Export per-file summaries and keep ONLY this package's own production +# sources: path must contain /Packages//Sources/ and must not be a +# *Placeholder* scaffold file. Dependency sources (other packages), Tests/, +# TestSupport and .build shims all fail the path filter automatically. +read -r COVERED TOTAL < <( + xcrun llvm-cov export -summary-only "$BIN" -instr-profile "$PROF" \ + | jq -r --arg pkg "$PKG" ' + [.data[0].files[] + | select(.filename | contains("/Packages/" + $pkg + "/Sources/")) + | select(.filename | contains("Placeholder") | not) + | .summary.lines] + | "\(map(.covered) | add // 0) \(map(.count) | add // 0)"' +) + +if [ "$TOTAL" -eq 0 ]; then + echo "coverage-gate($PKG): FAIL — filter matched no own-source files under" \ + "Packages/$PKG/Sources/ (filter bug or empty package?)" >&2 + exit 1 +fi + +# LC_ALL=C on both awk calls: a comma-decimal locale would print "100,00", +# which awk then compares as a STRING against the threshold (silent false FAIL). +PCT="$(LC_ALL=C awk -v c="$COVERED" -v t="$TOTAL" 'BEGIN { printf "%.2f", c * 100 / t }')" +echo "coverage-gate($PKG): own-sources line coverage ${PCT}% (${COVERED}/${TOTAL} lines, threshold ${THRESHOLD}%)" + +if ! LC_ALL=C awk -v p="$PCT" -v th="$THRESHOLD" 'BEGIN { exit !(p >= th) }'; then + echo "coverage-gate($PKG): FAIL — below threshold ${THRESHOLD}%" >&2 + exit 1 +fi + +echo "coverage-gate($PKG): PASS" diff --git a/ios/Packages/SessionCore/Sources/SessionCore/KeyByteMap.swift b/ios/Packages/SessionCore/Sources/SessionCore/KeyByteMap.swift new file mode 100644 index 0000000..d57c0d6 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/KeyByteMap.swift @@ -0,0 +1,57 @@ +/// Key-name → terminal byte-string table (T-iOS-11; pure data, no I/O). +/// +/// Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` — +/// the SINGLE source of truth for every label→bytes lookup on iOS: the KeyBar +/// buttons AND the hardware `UIKeyCommand` mapping both resolve through here +/// (plan §7 T-iOS-11). Hand-writing an escape sequence anywhere in the App +/// layer is a review finding. +/// +/// Gotcha carried over from the web client: Enter is `\r` (0x0D, carriage +/// return), NOT `\n` — synthesized input with a linefeed breaks TUIs. +public enum KeyByteMap { + /// One key the bar/hardware mapping can send. Raw values mirror the web + /// `KEY_MAP` property names; declaration order mirrors the web table. + public enum Key: String, Sendable, CaseIterable { + case esc + case escEsc + case shiftTab + case arrowUp + case arrowDown + case arrowLeft + case arrowRight + case enter + case ctrlC + case ctrlR + case ctrlO + case ctrlL + case ctrlT + case ctrlB + case ctrlD + case tab + case slash + } + + /// The exact byte string to send as `ClientMessage.input(data:)` for `key`. + /// Values are verbatim from public/keybar.ts KEY_MAP (see file doc). + public static func bytes(for key: Key) -> String { + switch key { + case .esc: return "\u{1B}" + case .escEsc: return "\u{1B}\u{1B}" + case .shiftTab: return "\u{1B}[Z" + case .arrowUp: return "\u{1B}[A" + case .arrowDown: return "\u{1B}[B" + case .arrowLeft: return "\u{1B}[D" + case .arrowRight: return "\u{1B}[C" + case .enter: return "\r" // NOT "\n" (CLAUDE.md gotcha) + case .ctrlC: return "\u{03}" + case .ctrlR: return "\u{12}" + case .ctrlO: return "\u{0F}" + case .ctrlL: return "\u{0C}" + case .ctrlT: return "\u{14}" + case .ctrlB: return "\u{02}" + case .ctrlD: return "\u{04}" + case .tab: return "\t" + case .slash: return "/" + } + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/KeyByteMapTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/KeyByteMapTests.swift new file mode 100644 index 0000000..775b1c9 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/KeyByteMapTests.swift @@ -0,0 +1,77 @@ +import Testing +@testable import SessionCore + +/// T-iOS-11 · KeyByteMap (plan §7). The key-bar byte table must mirror +/// `public/keybar.ts` `KEY_MAP` byte-for-byte — every entry is compared +/// against an independently written expectation (raw scalar values, not the +/// implementation's own literals) so a typo in either side turns the suite red. +@Suite("KeyByteMap") +struct KeyByteMapTests { + /// Expected byte strings, transcribed key-by-key from public/keybar.ts + /// KEY_MAP (the web client's single source of truth). Values are built + /// from Unicode scalars so this table cannot share a typo with the + /// implementation's string literals. + private static let expectedBytesByKey: [KeyByteMap.Key: [UInt8]] = [ + .esc: [0x1B], // '\x1b' + .escEsc: [0x1B, 0x1B], // '\x1b\x1b' + .shiftTab: [0x1B, 0x5B, 0x5A], // '\x1b[Z' + .arrowUp: [0x1B, 0x5B, 0x41], // '\x1b[A' + .arrowDown: [0x1B, 0x5B, 0x42], // '\x1b[B' + .arrowLeft: [0x1B, 0x5B, 0x44], // '\x1b[D' + .arrowRight: [0x1B, 0x5B, 0x43], // '\x1b[C' + .enter: [0x0D], // '\r' — carriage return, NOT '\n' + .ctrlC: [0x03], + .ctrlR: [0x12], + .ctrlO: [0x0F], + .ctrlL: [0x0C], + .ctrlT: [0x14], + .ctrlB: [0x02], + .ctrlD: [0x04], + .tab: [0x09], // '\t' + .slash: [0x2F], // '/' + ] + + @Test("every key resolves to the exact bytes of public/keybar.ts KEY_MAP") + func everyEntryMatchesWebKeyMapByteForByte() { + for key in KeyByteMap.Key.allCases { + // Arrange + let expected = Self.expectedBytesByKey[key] + + // Act + let actual = Array(KeyByteMap.bytes(for: key).utf8) + + // Assert + #expect(actual == expected, "bytes mismatch for \(key.rawValue)") + } + } + + @Test("the table covers exactly the 17 web KEY_MAP entries — no more, no less") + func tableCoversExactlyTheWebKeyMapEntries() { + #expect(KeyByteMap.Key.allCases.count == Self.expectedBytesByKey.count) + #expect(Set(KeyByteMap.Key.allCases).count == KeyByteMap.Key.allCases.count) + } + + @Test("Enter sends carriage return (0x0D), never linefeed (gotcha: \\r not \\n)") + func enterIsCarriageReturnNotLinefeed() { + #expect(KeyByteMap.bytes(for: .enter) == "\r") + #expect(KeyByteMap.bytes(for: .enter) != "\n") + } + + @Test("arrow CSI letters: up=A down=B left=D right=C (left/right are the classic swap)") + func arrowFinalLettersAreNotSwapped() { + #expect(KeyByteMap.bytes(for: .arrowUp).hasSuffix("A")) + #expect(KeyByteMap.bytes(for: .arrowDown).hasSuffix("B")) + #expect(KeyByteMap.bytes(for: .arrowLeft).hasSuffix("D")) + #expect(KeyByteMap.bytes(for: .arrowRight).hasSuffix("C")) + } + + @Test("raw values mirror the web KEY_MAP property names (stable lookup identity)") + func rawValuesMirrorWebKeyNames() { + let expectedNames = [ + "esc", "escEsc", "shiftTab", "arrowUp", "arrowDown", "arrowLeft", + "arrowRight", "enter", "ctrlC", "ctrlR", "ctrlO", "ctrlL", "ctrlT", + "ctrlB", "ctrlD", "tab", "slash", + ] + #expect(KeyByteMap.Key.allCases.map(\.rawValue) == expectedNames) + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..91ee9e3 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,160 @@ +# WebTerm iOS Client + +Native iOS client for the web-terminal server in the repository root. It is a +**pure remote client**: it speaks the same WebSocket wire protocol and HTTP +endpoints as the web frontend and requires **zero server changes** in P0. + +## Build & Run + +Requirements: macOS 15, Xcode 16.3 (Swift 6.1), [XcodeGen](https://github.com/yonaskolb/XcodeGen), +iOS 18.4 simulator (iPhone 16). SwiftTerm 1.13 is resolved for the App target only. + +```bash +cd ios +xcodegen generate # project.yml → WebTerm.xcodeproj +open WebTerm.xcodeproj # or run the full test suite headless: +xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \ + -destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' test +``` + +Layout: + +| Path | What | +|---|---| +| `App/` | SwiftUI app glue + `WebTermTests` unit-test bundle | +| `Packages/WireProtocol` | Frozen wire contract (frames, validation, tunables) | +| `Packages/SessionCore` | `SessionEngine` actor: connect/replay/reconnect/gate | +| `Packages/HostRegistry` | Paired hosts + last-session persistence | +| `Packages/APIClient` | HTTP endpoints (guarded routes carry `Origin`) | +| `Packages/TestSupport` | `FakeTransport` / `FakeClock` / `FakeHTTPTransport` | +| `IntegrationTests/` | Tests against a real Node server (`npm start`) | + +Per-package tests: `swift test --package-path Packages/`. + +The server runs from the repo root: `npm install && npm start` (see the root +[README](../README.md)). + +## ntfy notification bridge (P0 "host finds the phone") + +Until APNs push lands (P1, T-iOS-20/21), the phone is notified via the +**existing** [ntfy](https://ntfy.sh) bridge that already ships with +`npm run setup-hooks`. **No new code** — this chapter documents and verifies +the shipped behavior. + +### What it does + +When Claude Code (running inside a web-terminal session on the host) fires a +hook event, an extra `curl` posts to your ntfy topic: + +| Claude Code hook event | ntfy `Priority` header | Meaning | +|---|---|---| +| `PermissionRequest` (held gate) | `high` | **NEEDS-INPUT** — Claude is waiting for approval | +| `Stop` / `SessionEnd` | `low` | **DONE** — the task finished / session ended | + +Install logic: `scripts/setup-hooks.mjs:229-238` (high on `PermissionRequest` +at `:231-233`, low on `Stop`/`SessionEnd` at `:235-237`). The command itself is +built by `buildNtfyCommand` (`scripts/setup-hooks.mjs:91-101`). + +### Default-off: env unset ⇒ zero side effects + +The bridge is **opt-in at install time**: `npm run setup-hooks` only adds the +ntfy hooks when **both** `WEBTERM_NTFY_URL` and `WEBTERM_NTFY_TOPIC` are set in +the environment (gate at `scripts/setup-hooks.mjs:262-264`, guarded install at +`:229`). With the vars unset, the written `settings.json` contains no ntfy +entry at all (verified: fresh install without env → `grep -c WEBTERM_NTFY +settings.json` = 0, and the install log has no ntfy line). + +Two further layers keep it inert outside web-terminal: + +- Every installed ntfy command starts with `[ -n "$WEBTERM_HOOK_URL" ] && …` + (`scripts/setup-hooks.mjs:93`) — in a normal terminal that var is unset, so + the hook is a no-op. +- The session env is the only carrier: the server forwards `WEBTERM_NTFY_*` + into each spawned session via the `...process.env` spread + (`src/session/session.ts:95-102`, comment at `:99-100`). Nothing is + hardcoded server-side. + +### Setup + +1. **Install the ntfy app** on the iPhone (App Store) and allow notifications. +2. **Generate a random topic and subscribe to it.** On the public `ntfy.sh` + server, **the topic name is the password**: anyone who knows it can read + your notifications and publish to the channel. Use a random string, e.g. + `openssl rand -hex 12`. +3. **Export the env vars, then install the hooks and start the server from + that same shell** (the install gate reads env at `setup-hooks.mjs:262-264`; + at runtime the curl expands `$WEBTERM_NTFY_*` from the session env, which + inherits the server's env — `src/session/session.ts:95-102`): + + ```bash + export WEBTERM_NTFY_URL=https://ntfy.sh # or your self-hosted ntfy + export WEBTERM_NTFY_TOPIC= + # optional, self-hosted/paid access control only: + export WEBTERM_NTFY_TOKEN=tk_... # env only — never a literal + npm run setup-hooks + # expect: Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → https://ntfy.sh/ + npm start + ``` + + Confirmation log line: `scripts/setup-hooks.mjs:296-298`. +4. Restart any running `claude` sessions (the installer prints this reminder). + +### What the notification contains (payload minimization) + +Verified against the shipped command (`scripts/setup-hooks.mjs:91-101`): the +curl sends an **empty POST body** — there is no `-d`/`--data` flag at all. The +only information that leaves the host is: + +- **which topic** was posted to (your private random channel), and +- the **`Priority` header** (`high` = needs input, `low` = done). + +No session id, no `cwd`, no command content, no tool names — strictly less +than even a "sessionId prefix + status word" payload. In the ntfy app both +signals show ntfy's default message text; you tell them apart by the priority +rendering (high-priority notifications are visually marked and can bypass +quiet delivery). + +The token is referenced as `Bearer $WEBTERM_NTFY_TOKEN` +(`scripts/setup-hooks.mjs:95`) — a shell variable expanded at hook-fire time. +It is **never written into `settings.json` or any command literal** (SEC-C6; +regression-tested by `test/setup-hooks.test.ts:410-421`, re-run for this +verification: 49/49 passed). + +### What it does NOT cover: STUCK + +`stuck` is a **server-derived** state: `manager.sweepStuck` +(`src/session/manager.ts:268`) infers it from output silence while a session +looks busy. **No Claude Code hook event fires for it**, so a hook-side bridge +physically cannot send a STUCK notification. This gap is closed in P1 by APNs +(T-iOS-20), which pushes from the server's own event bus. + +### Turning it off / P1 supersession + +Once P1 APNs lands (T-iOS-20/21), this bridge is superseded and can be +disabled. Any of: + +- Re-run `npm run setup-hooks` **without** the `WEBTERM_NTFY_*` env vars — the + reinstall pass strips previously installed ntfy groups (marker cleanup, + `scripts/setup-hooks.mjs:185-205`; the ntfy command contains the + `WEBTERM_HOOK_URL` marker via its guard at `:93`, verified: reinstall + without env → 0 ntfy entries remain). +- `npm run setup-hooks -- --remove` — removes all web-terminal hooks. + +### End-to-end phone check — DEFERRED(需真机/用户操作) + +Not runnable in this environment (requires the user's phone and mutating the +user's live Claude Code hook config). Exact manual steps: + +1. iPhone: install ntfy, subscribe to `` on `https://ntfy.sh`, + allow notifications. +2. Mac: `export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=`, + run `npm run setup-hooks`, confirm the log line + `Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)`, then `npm start` + from the same shell. +3. Open the web terminal, start a session, run `claude`, and give it a task + that triggers a permission gate (e.g. a Bash command that is not + pre-allowed). +4. When the gate is held (tab badge shows waiting-for-approval), the iPhone + should receive a **high-priority** ntfy notification within seconds. +5. Approve and let the task finish — a **low-priority** notification arrives + on `Stop`/`SessionEnd`.