feat(ios): W3 UI layer + integration CI + ntfy docs

T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
This commit is contained in:
Yaojia Wang
2026-07-05 00:13:14 +02:00
parent 5c8c87fb7a
commit 5098643355
33 changed files with 5946 additions and 616 deletions

View File

@@ -1,5 +1,19 @@
# iOS CI skeleton (T-iOS-1). T-iOS-16 hardens this: real Node-server # iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
# integration tests + the per-package 80% coverage gate (PLAN_IOS_CLIENT §9). #
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
# iPhone 16 simulator): ViewModels/components of the app glue layer.
# 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
# is the standing anti-drift gate between the Swift-replicated protocol
# and the server implementation — hence the src/** path triggers below.
name: ios name: ios
on: on:
@@ -7,12 +21,22 @@ on:
paths: paths:
- "ios/**" - "ios/**"
- ".github/workflows/ios.yml" - ".github/workflows/ios.yml"
# integration layer guards client<->server protocol drift:
- "src/**"
- "package.json"
- "package-lock.json"
pull_request: pull_request:
paths: paths:
- "ios/**" - "ios/**"
- ".github/workflows/ios.yml" - ".github/workflows/ios.yml"
- "src/**"
- "package.json"
- "package-lock.json"
jobs: 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: package-tests:
runs-on: macos-15 runs-on: macos-15
strategy: strategy:
@@ -23,10 +47,20 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (${{ matrix.package }}) - name: swift test + own-sources coverage gate (>= 80%)
run: swift test --package-path ios/Packages/${{ matrix.package }} 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 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -36,10 +70,25 @@ jobs:
run: brew install xcodegen run: brew install xcodegen
- name: Generate project - name: Generate project
run: cd ios && xcodegen generate run: cd ios && xcodegen generate
- name: Build (iPhone 16 simulator) - name: xcodebuild test (WebTermTests, iPhone 16 simulator)
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \ -destination 'platform=iOS Simulator,name=iPhone 16' \
CODE_SIGNING_ALLOWED=NO build CODE_SIGNING_ALLOWED=NO test
- name: swift test (IntegrationTests scaffold)
# 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 run: swift test --package-path ios/IntegrationTests

View File

@@ -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-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 硬上限,内聚裁量已记)。 - **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)全数在源码落实 - **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 必互踩 [11121314] 串行链 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-11121314] 串行链 T-iOS-16 T-iOS-17;协调点 = orchestrator 预建 WebTermTests 单测 bundle(project.yml+5 包依赖,模拟器实测过)并单独提交)**:
- **T-iOS-11 Terminal+KeyBar**: KeyByteMap(SessionCore 纯数据)逐字节复刻 keybar.ts 17 (⏎=\r \n 专项断言);TerminalViewModel 消费 engine.events,replayTooLarge不可重试态+话术逐字断言;**决策**: 硬件 UIKeyCommand 仅注册 Esc/⇧Tab/Ctrl 和弦,箭头不注册(固定 CSI 会破 DECCKM 应用光标模式,vim/htop 错乱)——有排除测试;OSC52 剪贴板拒绝链接仅 http(s)。
- **T-iOS-12 Pairing**: Phase 状态机;扫码confirmingHost,**确认前零网络**(recordedRequests/connectAttempts 空断言);公网需显式勾选确认5.4 四层警告 13 组参数化3.4 裁定落地(VM 构造 Host store);host 分级 VM 内重实现(APIClient internal 粒度不足,重复已标注归 T-iOS-38 去重);真机 DataScanner 编译隔离,generic/platform=iOS 构建过
- **T-iOS-13 SessionList**: 轮询 Tunables.listPollInterval,离开断言 0 sleeper+10 周期零请求(无泄漏);**发现**: LiveSessionInfo pending 字段(已核 manager.list())→ 徽标经 setPendingApproval overlay T-iOS-15 gate 事件接入;kill 乐观移除+404=成功/403 回滚;staleness 严格 > 镜像 web;PR 链接 https-only(SEC-L5 镜像)。
- **T-iOS-14 Gate/Digest UI**: GateViewModel 独立 VM(events 与 engine 分离注入,T-iOS-15 fan-out 零改动);三选一映射唯一事实源=SessionCore Affordance.clientMessage(acceptEdits/default/reject,App 层零 allowAutoMode 逻辑);tap-epoch+affordance 双守卫;haptic 每 epoch 恰一次(HapticSignaling 注入);digest 自动淡出 FakeClock 驱动;文案逐字镜像 tabs.ts:326-350。
- **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。
- **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。
- **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。
- 待续: 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,未提交;纯文档,零代码改动) ### ✅ 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 应用。 **多 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 应用。

View File

@@ -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, oldestnewest. 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)
}
}

View File

@@ -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 affordanceClientMessage mapping
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
struct GateChoiceSpec: Equatable {
let label: String
let affordance: GateState.Affordance
/// Ordered button set for `gate` (tool two, plan three), driven by the
/// frozen `GateState.affordances` list.
static func specs(for gate: GateState) -> [GateChoiceSpec] {
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
}
static func label(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "✓ Approve + Auto"
case .approveReview: return "✓ Approve + Review"
case .keepPlanning: return "✎ Keep Planning"
case .approve: return "✓ Approve"
case .reject: return "✗ Reject"
}
}
}
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
/// rendered the VM drops stale taps (first-line guard); wiring into
/// TerminalScreen is T-iOS-15.
struct GateBanner: View {
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum 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
}
}
}

View File

@@ -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 labelbytes lookup resolves through
/// `KeyByteMap` (SessionCore) no byte literal lives in this file.
///
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
/// never pops or fights the soft keyboard same reason the web bar bypasses
/// xterm and calls `ws.send`).
// MARK: - 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
}
}

View File

@@ -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
}
}
}

View File

@@ -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) · 终端已只读"
}
}

View File

@@ -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"
}
}

View File

@@ -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 servewss"
static let publicWarning =
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
static func probing(_ address: String) -> String {
"正在验证 \(address)"
}
static func paired(_ name: String) -> String {
"已配对:\(name)"
}
static func scannerStartFailed(_ reason: String) -> String {
"无法启动相机扫描:\(reason)。可改用手输地址。"
}
}

View File

@@ -0,0 +1,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) 台设备在看"
}
}

View File

@@ -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 labelbytes
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
/// keyboard never pops (mirrors the web bar's ws.send bypass).
/// - IME: NO custom keydown/composition interception SwiftTerm manages
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
///
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
/// T-iOS-15 this screen only renders and routes.
struct TerminalScreen: View {
let viewModel: TerminalViewModel
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<UInt8>) {
viewModel.sendInput(String(decoding: data, as: UTF8.self))
}
/// Layout produced a new grid server `resize` (SIGWINCH). This is
/// also the latest-writer-wins size claim (v0.4 sizing model).
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return } // pre-layout noise
viewModel.sendResize(cols: newCols, rows: newRows)
}
/// Tapped link (OSC 8 / detected URL mirrors web M2 WebLinksAddon).
/// The link string is untrusted terminal output: http(s) only.
func requestOpenLink(source: TerminalView, link: String, params: [String: String]) {
guard let url = URL(string: link),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https"
else { return }
UIApplication.shared.open(url)
}
// 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)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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<Duration>
/// Injected time source for telemetry staleness (ms since epoch).
@ObservationIgnored private let nowMs: @Sendable () -> Int
@ObservationIgnored private var pollTask: Task<Void, Never>?
@ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
@ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
@ObservationIgnored private var pendingSessionIds: Set<UUID> = []
@ObservationIgnored private var hasAttemptedHostLoad = false
@ObservationIgnored private var hasLoadedOnce = false
private var apiClient: APIClient? {
activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
}
init(
hostStore: any HostStore,
http: any HTTPTransport,
clock: any Clock<Duration>,
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<Void, Never>
}
@ObservationIgnored private var fetchWaiters: [CountWaiter] = []
/// Suspends until at least `count` fetches have completed.
func waitUntilFetches(count target: Int) async {
await withCheckedContinuation { continuation in
guard completedFetchCount < target else {
continuation.resume()
return
}
fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
}
}
private func resumeFetchWaiters() {
let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
for waiter in satisfied {
waiter.continuation.resume()
}
}
private func releaseFetchWaiters() {
let all = fetchWaiters
fetchWaiters = []
for waiter in all {
waiter.continuation.resume()
}
}
}
/// User-facing session-list copy (plan §4: explicit errors, actionable ).
enum SessionListCopy {
static let hostsLoadFailed = "读取已配对主机失败,请关闭本页后重进。"
static func fetchFailed(_ detail: String) -> String {
"刷新会话列表失败:\(detail)"
}
static func killFailed(_ detail: String) -> String {
"结束会话失败:\(detail)"
}
}

View File

@@ -0,0 +1,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 1s30s).
case reconnecting(attempt: Int, next: Duration)
}
/// Which terminal the user is looking at: a live one, a dead-for-good one
/// (non-retryable failure), or a finished one (read-only).
enum TerminalPhase: Equatable {
case live
/// Non-retryable terminal failure actionable copy instead of a spinner.
case failed(message: String)
/// The shell exited; the terminal stays readable but accepts no input.
case exited(code: Int, reason: String?)
}
/// 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<SessionEvent>
@ObservationIgnored private var consumeTask: Task<Void, Never>?
/// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
/// feeding off the main actor is a compile error.
@ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
/// Output that arrived before the SwiftTerm view existed; flushed in order
/// the moment the sink attaches (replay must never be dropped).
@ObservationIgnored private var pendingOutput: [String] = []
/// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
/// is async one pump task preserves submission order (two quick key taps
/// must never race each other onto the wire).
@ObservationIgnored private var sendQueue: [ClientMessage] = []
@ObservationIgnored private var isPumping = false
// MARK: - Test-visible diagnostics & deterministic barriers (internal)
/// Events applied so far `waitUntilProcessed` barrier counter.
@ObservationIgnored private(set) var processedEventCount = 0
/// Sends handed to the engine so far `waitUntilForwarded` barrier counter.
@ObservationIgnored private(set) var forwardedSendCount = 0
/// Input dropped because the terminal is read-only (exit/failed).
@ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
/// Test tap, called after each event is applied (state already coherent).
@ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?
private struct CountWaiter {
let target: Int
let continuation: CheckedContinuation<Void, Never>
}
@ObservationIgnored private var eventWaiters: [CountWaiter] = []
@ObservationIgnored private var sendWaiters: [CountWaiter] = []
// MARK: - Lifecycle
/// - Parameters:
/// - engine: send-side dependency (`send` only `open`/`close` belong to
/// the T-iOS-15 wiring that constructs the engine).
/// - events: the event stream to consume; pass `engine.events` unless a
/// fan-out branch is needed (see type doc).
init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
self.engine = engine
self.events = events
}
/// Begin consuming events. Idempotent a second call is a no-op (the
/// stream has exactly one consumer).
func start() {
guard consumeTask == nil else { return }
consumeTask = Task { [weak self] in
guard let events = self?.events else { return }
for await event in events {
guard let self else { return }
self.apply(event)
}
self?.releaseAllWaiters() // stream over never leave a test hanging
}
}
/// Stop consuming (screen torn down). Does NOT close the engine detach
/// vs. keep-running is the T-iOS-15 lifecycle owner's call.
func stop() {
consumeTask?.cancel()
consumeTask = nil
releaseAllWaiters()
}
// MARK: - Terminal output sink
/// Attach the SwiftTerm feed target. Buffered output (anything that
/// arrived before the view existed) flushes immediately, in order.
func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
terminalSink = sink
let buffered = pendingOutput
pendingOutput = []
for chunk in buffered {
sink(chunk)
}
}
// MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)
/// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
/// (the single source of truth plan §7 T-iOS-11).
func send(key: KeyByteMap.Key) {
sendInput(KeyByteMap.bytes(for: key))
}
/// Raw input bytes, verbatim (invariant #9 no content filtering).
/// Dropped (and counted) while the terminal is read-only.
func sendInput(_ data: String) {
guard !isReadOnly else {
droppedReadOnlyInputCount += 1
return
}
enqueueSend(.input(data: data))
}
/// 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()
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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<Duration> = 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<Void, Never>] = []
private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []
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)
}
}

View File

@@ -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))])
}
}

View File

@@ -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")
}

View File

@@ -0,0 +1,100 @@
//
// MirrorLifecycleTests.swift T-iOS-16 4/6/7JOIN mirror
//
// 4. JOIN mirrorAB attach attach
// src/session/manager.ts:108-174A inputB output
// 6. DELETE /live-sessions/:idkill WS close
// exit manager.killById ws.close() kill PTY
// src/session/manager.ts:202-212exit 广 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)
}
}

View File

@@ -0,0 +1,67 @@
//
// OriginGuardTests.swift T-iOS-16 5Origin spike
//
// plan §5.1 " CI Origin "= CRITICAL
// 401/403 ""
// - WS Origin 401src/server.ts:646-651 Origin
// - WS Origin 401src/http/origin.ts:47-51
// :443/:80 new URL()
// - DELETE /live-sessions/:id Origin 403G 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)")
}
}

View File

@@ -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
// Originhttp://127.0.0.1:9999 401src/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/bashUSE_TMUX=0 ALLOWED_ORIGINS
// deriveAllowedOrigins http://127.0.0.1:<port>src/config.ts:187-226
// atexit $TMPDIR/webterm-spike-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> 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.TunablesT-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 MiB1_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-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
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
}
}
// harnessactor 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 = <repo>/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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard bound == 0 else { throw SpikeError.setup("bind() 失败: errno \(errno)") }
var out = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.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 headerspike
/// - maxMessageBytes: nil = 1 MiBspike
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
}
}
}
}
/// / nilhandshake
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<T: Sendable>(
_ 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 \r0x0D \nCLAUDE.md gotcha
private func inputFrame(command: String) -> [String: Any] {
["type": "input", "data": command + "\r"]
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/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<ReplaySummary, any Error> {
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[0msrc/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 40Darwin 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 Origin403
/// src/server.ts:332-339宿 bash
///
private func killSession(server: SpikeServer, id: String) async {
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
request.httpMethod = "DELETE"
request.setValue(server.origin, forHTTPHeaderField: "Origin")
_ = try? await URLSession.shared.data(for: request)
}
// Spike
@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5)))
struct OriginSpikeTests {
@Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
func upgradeWithoutOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "无 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
@Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached")
func customOriginHeaderIsSentAndAttachSucceeds() async throws {
let server = try await ServerHarness.shared.ensureServer()
let client = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
defer { client.close() }
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
#expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)")
#expect(client.handshakeStatusCode == 101,
"升级应 101,实际 \(String(describing: client.handshakeStatusCode))")
client.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功")
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
// :attach(null) >1MiB , detach(PTY/ring )
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.bulkOutputBytes)
#expect(produced >= SpikeTunables.bulkOutputBytes)
generator.close()
// : maximumMessageSize(1 MiB)
let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil)
let defaultResult = await replayResult(
client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes)
defaultClient.close()
if case .failure(let error) = defaultResult {
#expect(isMessageTooLongError(error),
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
}
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
_ = try defaultResult.get()
}
// :16 MiB ,attached sessionId
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes)
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功")
func escDenseReplayStillSucceedsAt16MiB() async throws {
let server = try await ServerHarness.shared.ensureServer()
let generator = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
// perl \e = ESC(0x1B): 4 JSON 9 ("")
try await generator.sendFrame(
inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'"))
let produced = try await accumulateOutput(
client: generator, minBytes: SpikeTunables.escSequenceRawBytes)
#expect(produced >= SpikeTunables.escSequenceRawBytes)
generator.close()
let bigClient = SpikeWSClient(
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
let summary = try await replayResult(
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get()
#expect(summary.adoptedSessionId == sessionId)
#expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes)
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
bigClient.close()
await killSession(server: server, id: sessionId)
}
@Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
func portMismatchedOriginIsRejectedWith401() async throws {
let server = try await ServerHarness.shared.ensureServer()
let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort
? SpikeTunables.mismatchedOriginPortFallback
: SpikeTunables.mismatchedOriginPort
// ::443/:80 scheme new URL()
// (src/http/origin.ts:31-51),;
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil)
defer { client.close() }
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
#expect(failure != nil, "端口失配 Origin 升级必须失败")
#expect(client.handshakeStatusCode == 401,
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
}
}

View File

@@ -1,7 +1,9 @@
// swift-tools-version: 6.0 // swift-tools-version: 6.0
// T-iOS-1 scaffold shell. Real live-server tests land in T-iOS-2 // T-iOS-16 integration-CI package: Swift Testing suites against the REAL Node
// (OriginSpikeTests, against `npm start`) and T-iOS-16 (CI integration). // 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/. // 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 import PackageDescription
let package = Package( let package = Package(
@@ -14,7 +16,8 @@ let package = Package(
.testTarget( .testTarget(
name: "IntegrationTests", name: "IntegrationTests",
dependencies: [.product(name: "WireProtocol", package: "WireProtocol")], dependencies: [.product(name: "WireProtocol", package: "WireProtocol")],
path: "." path: ".",
exclude: ["scripts"]
), ),
], ],
swiftLanguageModes: [.v6] swiftLanguageModes: [.v6]

View File

@@ -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:<rows> <cols>"
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)
}
}

View File

@@ -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.maxWSMessageBytes16 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)
}
}

View File

@@ -0,0 +1,260 @@
//
// ServerHarness.swift T-iOS-16 Node
//
// T-iOS-2 OriginSpikeTests.swift591 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 退
// atexitwatchdog
//
//
// A. `swift test --package-path ios/IntegrationTests`
// repo #filePath WEBTERM_REPO_ROOT 127.0.0.1
// SHELL_PATH=/bin/bashUSE_TMUX=0
// ALLOWED_ORIGINS deriveAllowedOrigins
// http://127.0.0.1:<port>src/config.ts:187-226
// $TMPDIR/webterm-integration-server-<port>.log
// B. WEBTERM_SERVER_URL=http://127.0.0.1:<port> loopback
// origin
//
// spike WireProtocol T-iOS-3Origin/wsURL
// `HostEndpoint` plan §5.1
import Darwin
import Foundation
import WireProtocol
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
// 线 WireProtocol.Tunables/WireConstants
enum HarnessTunables {
/// > URLSessionWebSocketTask 1 MiB1_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)
/// attachattached
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-2Swift Testing runner 退 atexit handler
// tsx death-pipespawn /bin/sh watchdog stdin
// 退/
// watchdog `read` EOF kill pidatexit
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
}
}
// harnessactor spawn suite
//
// Swift Testing suite suite server()actor
// awaitReady ""
// spawn in-flight Task await
actor ServerHarness {
static let shared = ServerHarness()
private var bootTask: Task<TestServer, any Error>?
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 = <repo>/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<sockaddr_in>.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<sockaddr_in>.size))
}
}
guard bound == 0 else { throw HarnessError.setup("bind() 失败: errno \(errno)") }
var out = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.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))
}

View File

@@ -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 MiBplan §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
}
}
}
}
/// / nilhandshake
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<T: Sendable>(
_ 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 \r0x0D \nCLAUDE.md gotcha
func shellCommand(_ line: String) -> ClientMessage {
.input(data: line + "\r")
}
/// attach attached output/statusattachWs attached
/// snapshotsrc/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<ReplaySummary, any Error> {
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[0msrc/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 40Darwin errno 40 =
/// EMSGSIZE "Message too long"plan §1 ENOBUFSDarwin
/// 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 OriginRO GET plan §3.4
/// DELETE /live-sessions/:idG src/server.ts:354-359origin=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-sessionsRO 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)
}

View File

@@ -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 <PackageName> # e.g. coverage-gate.sh WireProtocol
# Env: COVERAGE_THRESHOLD=<percent> # 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/<P>/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 <PackageName> (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/<PKG>/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"

View File

@@ -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 labelbytes 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 "/"
}
}
}

View File

@@ -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)
}
}

160
ios/README.md Normal file
View File

@@ -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/<Name>`.
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=<your-random-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/<topic>
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 `<your-random-topic>` on `https://ntfy.sh`,
allow notifications.
2. Mac: `export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=<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`.