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 字节("[0m")。
- 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`.