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:
65
.github/workflows/ios.yml
vendored
65
.github/workflows/ios.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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 必互踩 → [11→12→13→14] 串行链 ∥ T-iOS-16 ∥ T-iOS-17;VM 单测需 WebTermTests target,project.yml 归 T-iOS-1 Owns → orchestrator 预先增补(协调点,同 W1 manifest 先例)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
- **[x] W3 UI+提前项(7 agents: [T-iOS-11→12→13→14] 串行链 ∥ T-iOS-16 ∥ T-iOS-17;协调点 = orchestrator 预建 WebTermTests 单测 bundle(project.yml+5 包依赖,模拟器实测过)并单独提交)**:
|
||||||
|
- **T-iOS-11 Terminal+KeyBar**: KeyByteMap(SessionCore 纯数据)逐字节复刻 keybar.ts 全 17 键(⏎=\r 非 \n 专项断言);TerminalViewModel 消费 engine.events,replayTooLarge→不可重试态+话术逐字断言;**决策**: 硬件 UIKeyCommand 仅注册 Esc/⇧Tab/Ctrl 和弦,箭头不注册(固定 CSI 会破 DECCKM 应用光标模式,vim/htop 错乱)——有排除测试;OSC52 剪贴板拒绝、链接仅 http(s)。
|
||||||
|
- **T-iOS-12 Pairing**: Phase 状态机;扫码→confirmingHost,**确认前零网络**(recordedRequests/connectAttempts 空断言);公网需显式勾选确认;§5.4 四层警告 13 组参数化;§3.4 裁定落地(VM 构造 Host 入 store);host 分级 VM 内重实现(APIClient 的 internal 粒度不足,重复已标注归 T-iOS-38 去重);真机 DataScanner 编译隔离,generic/platform=iOS 构建过。
|
||||||
|
- **T-iOS-13 SessionList**: 轮询 Tunables.listPollInterval,离开断言 0 sleeper+10 周期零请求(无泄漏);**发现**: LiveSessionInfo 无 pending 字段(已核 manager.list())→ ⚠ 徽标经 setPendingApproval overlay 由 T-iOS-15 从 gate 事件接入;kill 乐观移除+404=成功/403 回滚;staleness 严格 > 镜像 web;PR 链接 https-only(SEC-L5 镜像)。
|
||||||
|
- **T-iOS-14 Gate/Digest UI**: GateViewModel 独立 VM(events 与 engine 分离注入,T-iOS-15 fan-out 零改动);三选一映射唯一事实源=SessionCore Affordance.clientMessage(acceptEdits/default/reject,App 层零 allowAutoMode 逻辑);tap-epoch+affordance 双守卫;haptic 每 epoch 恰一次(HapticSignaling 注入);digest 自动淡出 FakeClock 驱动;文案逐字镜像 tabs.ts:326-350。
|
||||||
|
- **T-iOS-16 集成 CI**: 吸收 spike 重构 harness;10 测对真自举 Node 服务器(echo/stty resize 1..1000/16MiB+ESC/C0 对抗回放/JOIN mirror/401/401/403/kill→WS close 而非 exit 帧/自然 exit 广播);ios.yml=包矩阵+coverage-gate.sh(own-Sources 过滤,修正 §9 已知缺陷)+App 测试+集成 job;覆盖率门红→绿演示实录;CI 平台未跑(如实注明)。
|
||||||
|
- **T-iOS-17 ntfy 文档**: ios/README.md;只读验证(引 setup-hooks.mjs file:line),**未触碰用户真实 hook 配置**(orchestrator 安全约束);payload 最小化逐行核;真机端到端 DEFERRED。
|
||||||
|
- **W3 验证(独立 agent,全项 PASS)**: 包套件 59+3+77+30+45;App 55 测 TEST SUCCEEDED;集成 10 测+1 known issue(故意的 1MiB 复现);语义抽查 5/5(KeyByteMap 逐字节 16 项、三选一字面量+零 allowAutoMode、零网络断言实测在 :60、轮询走 Tunables、CI 覆盖率 own-Sources 过滤);Owns 审计零越界。**累计 224 单测 + 10 集成**。
|
||||||
|
- 待续: W4(T-iOS-15 汇合接线)→ W5(T-iOS-18/19 验收)。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||||
|
|
||||||
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
|
### ✅ 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 应用。
|
||||||
|
|||||||
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal file
111
ios/App/WebTerm/Components/AwayDigestView.swift
Normal 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, oldest→newest. Server text (`label`) is
|
||||||
|
/// untrusted display input — rendered as plain Text only.
|
||||||
|
private var recentRows: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
|
||||||
|
ForEach(
|
||||||
|
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
|
||||||
|
id: \.offset
|
||||||
|
) { _, event in
|
||||||
|
recentRow(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recentRow(_ event: TimelineEvent) -> some View {
|
||||||
|
HStack(spacing: Metrics.spacing) {
|
||||||
|
Text(Self.time(of: event))
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(event.label)
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func time(of event: TimelineEvent) -> String {
|
||||||
|
let millisecondsPerSecond = 1_000.0
|
||||||
|
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
|
||||||
|
return date.formatted(date: .omitted, time: .shortened)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
ios/App/WebTerm/Components/GateBanner.swift
Normal file
86
ios/App/WebTerm/Components/GateBanner.swift
Normal 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 affordance→ClientMessage mapping
|
||||||
|
/// itself is frozen in SessionCore (`GateState.Affordance.clientMessage`).
|
||||||
|
struct GateChoiceSpec: Equatable {
|
||||||
|
let label: String
|
||||||
|
let affordance: GateState.Affordance
|
||||||
|
|
||||||
|
/// Ordered button set for `gate` (tool → two, plan → three), driven by the
|
||||||
|
/// frozen `GateState.affordances` list.
|
||||||
|
static func specs(for gate: GateState) -> [GateChoiceSpec] {
|
||||||
|
gate.affordances.map { GateChoiceSpec(label: label(for: $0), affordance: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func label(for affordance: GateState.Affordance) -> String {
|
||||||
|
switch affordance {
|
||||||
|
case .approveAuto: return "✓ Approve + Auto"
|
||||||
|
case .approveReview: return "✓ Approve + Review"
|
||||||
|
case .keepPlanning: return "✎ Keep Planning"
|
||||||
|
case .approve: return "✓ Approve"
|
||||||
|
case .reject: return "✗ Reject"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// T-iOS-14 · Two-button banner for an ordinary TOOL gate (plan gates get the
|
||||||
|
/// three-way `PlanGateSheet`). Render-only: state lives in `GateViewModel`.
|
||||||
|
/// Every tap reports the affordance PLUS the epoch of the gate THIS banner
|
||||||
|
/// rendered — the VM drops stale taps (first-line guard); wiring into
|
||||||
|
/// TerminalScreen is T-iOS-15.
|
||||||
|
struct GateBanner: View {
|
||||||
|
let gate: GateState
|
||||||
|
let onDecide: (GateState.Affordance, Int) -> Void
|
||||||
|
|
||||||
|
private enum Metrics {
|
||||||
|
static let spacing: CGFloat = 8
|
||||||
|
static let horizontalPadding: CGFloat = 14
|
||||||
|
static let verticalPadding: CGFloat = 10
|
||||||
|
static let cornerRadius: CGFloat = 12
|
||||||
|
static let messageLineLimit = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Banner headline, mirror of public/tabs.ts:329:
|
||||||
|
/// `Claude wants to use ${pendingTool ?? 'a tool'}`. `detail` is
|
||||||
|
/// server-supplied display text (untrusted) — rendered as plain Text only.
|
||||||
|
static func message(for gate: GateState) -> String {
|
||||||
|
"Claude wants to use \(gate.detail ?? "a tool")"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Metrics.spacing) {
|
||||||
|
Text(Self.message(for: gate))
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.lineLimit(Metrics.messageLineLimit)
|
||||||
|
HStack(spacing: Metrics.spacing) {
|
||||||
|
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
|
||||||
|
decisionButton(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Metrics.horizontalPadding)
|
||||||
|
.padding(.vertical, Metrics.verticalPadding)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
|
||||||
|
Button(spec.label) {
|
||||||
|
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(tint(for: spec.affordance))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tint(for affordance: GateState.Affordance) -> Color {
|
||||||
|
switch affordance {
|
||||||
|
case .approve, .approveAuto, .approveReview: return .green
|
||||||
|
case .reject: return .red
|
||||||
|
case .keepPlanning: return .indigo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
ios/App/WebTerm/Components/KeyBar.swift
Normal file
227
ios/App/WebTerm/Components/KeyBar.swift
Normal 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 label→bytes lookup resolves through
|
||||||
|
/// `KeyByteMap` (SessionCore) — no byte literal lives in this file.
|
||||||
|
///
|
||||||
|
/// The bar is installed as the terminal's `inputAccessoryView` and sends via
|
||||||
|
/// the ViewModel/engine DIRECTLY (bypassing SwiftTerm's text path so a tap
|
||||||
|
/// never pops or fights the soft keyboard — same reason the web bar bypasses
|
||||||
|
/// xterm and calls `ws.send`).
|
||||||
|
|
||||||
|
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
|
||||||
|
|
||||||
|
/// One key-bar button: glyph, short function caption (shown under the glyph,
|
||||||
|
/// self-documenting on touch) and the full title for accessibility.
|
||||||
|
struct KeyBarButtonSpec: Equatable, Sendable {
|
||||||
|
let key: KeyByteMap.Key
|
||||||
|
let label: String
|
||||||
|
let caption: String
|
||||||
|
let title: String
|
||||||
|
let isPrimary: Bool
|
||||||
|
|
||||||
|
init(key: KeyByteMap.Key, label: String, caption: String, title: String,
|
||||||
|
isPrimary: Bool = false) {
|
||||||
|
self.key = key
|
||||||
|
self.label = label
|
||||||
|
self.caption = caption
|
||||||
|
self.title = title
|
||||||
|
self.isPrimary = isPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Button order/copy transcribed from `public/keybar.ts` `KEYBAR_BUTTONS`
|
||||||
|
/// (most-used Claude Code keys first). The 🎤 voice button is P2 (T-iOS-31).
|
||||||
|
enum KeyBarLayout {
|
||||||
|
static let buttons: [KeyBarButtonSpec] = [
|
||||||
|
KeyBarButtonSpec(key: .esc, label: "Esc", caption: "中断",
|
||||||
|
title: "Esc — interrupt Claude / dismiss", isPrimary: true),
|
||||||
|
KeyBarButtonSpec(key: .escEsc, label: "Esc²", caption: "回溯",
|
||||||
|
title: "Esc Esc — rewind / clear draft"),
|
||||||
|
KeyBarButtonSpec(key: .shiftTab, label: "⇧Tab", caption: "模式",
|
||||||
|
title: "Shift+Tab — cycle plan / auto-accept mode"),
|
||||||
|
KeyBarButtonSpec(key: .arrowUp, label: "↑", caption: "上一个",
|
||||||
|
title: "Up — previous option / history"),
|
||||||
|
KeyBarButtonSpec(key: .arrowDown, label: "↓", caption: "下一个",
|
||||||
|
title: "Down — next option / history"),
|
||||||
|
KeyBarButtonSpec(key: .enter, label: "⏎", caption: "确认",
|
||||||
|
title: "Enter — confirm"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlC, label: "^C", caption: "取消",
|
||||||
|
title: "Ctrl+C — cancel / quit"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlR, label: "^R", caption: "搜历史",
|
||||||
|
title: "Ctrl+R — reverse-search command history"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlO, label: "^O", caption: "详情",
|
||||||
|
title: "Ctrl+O — expand transcript / tool detail"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlL, label: "^L", caption: "重绘",
|
||||||
|
title: "Ctrl+L — redraw screen"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlT, label: "^T", caption: "任务",
|
||||||
|
title: "Ctrl+T — toggle task list"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlB, label: "^B", caption: "后台",
|
||||||
|
title: "Ctrl+B — background running task"),
|
||||||
|
KeyBarButtonSpec(key: .ctrlD, label: "^D", caption: "退出",
|
||||||
|
title: "Ctrl+D — exit session (EOF)"),
|
||||||
|
KeyBarButtonSpec(key: .tab, label: "Tab", caption: "补全",
|
||||||
|
title: "Tab — complete / toggle"),
|
||||||
|
KeyBarButtonSpec(key: .arrowLeft, label: "←", caption: "左移",
|
||||||
|
title: "Left — move cursor left"),
|
||||||
|
KeyBarButtonSpec(key: .arrowRight, label: "→", caption: "右移",
|
||||||
|
title: "Right — move cursor right"),
|
||||||
|
KeyBarButtonSpec(key: .slash, label: "/", caption: "命令",
|
||||||
|
title: "Slash — command launcher"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum KeyBarMetrics {
|
||||||
|
static let barHeight: CGFloat = 52
|
||||||
|
static let buttonSpacing: CGFloat = 6
|
||||||
|
static let contentInset: CGFloat = 8
|
||||||
|
static let buttonInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 4, leading: 9, bottom: 4, trailing: 9
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KeyBarView (inputAccessoryView)
|
||||||
|
|
||||||
|
/// Horizontally scrolling key bar, installed as the terminal's
|
||||||
|
/// `inputAccessoryView`. Taps call `onKey` — the screen routes them to
|
||||||
|
/// `TerminalViewModel.send(key:)`, which resolves bytes via `KeyByteMap`.
|
||||||
|
final class KeyBarView: UIInputView {
|
||||||
|
/// Tap outlet. `@MainActor`-typed so the handler can drive the ViewModel.
|
||||||
|
var onKey: (@MainActor (KeyByteMap.Key) -> Void)?
|
||||||
|
/// Built buttons in layout order (test-visible).
|
||||||
|
private(set) var keyButtons: [UIButton] = []
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(
|
||||||
|
frame: CGRect(x: 0, y: 0, width: 0, height: KeyBarMetrics.barHeight),
|
||||||
|
inputViewStyle: .keyboard
|
||||||
|
)
|
||||||
|
allowsSelfSizing = true
|
||||||
|
buildBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable, message: "KeyBarView is code-built only")
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
CGSize(width: UIView.noIntrinsicMetric, height: KeyBarMetrics.barHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildBar() {
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.spacing = KeyBarMetrics.buttonSpacing
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
for spec in KeyBarLayout.buttons {
|
||||||
|
let button = makeButton(for: spec)
|
||||||
|
keyButtons = keyButtons + [button]
|
||||||
|
stack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scroll = UIScrollView()
|
||||||
|
scroll.showsHorizontalScrollIndicator = false
|
||||||
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
scroll.addSubview(stack)
|
||||||
|
addSubview(scroll)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
scroll.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
scroll.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
stack.leadingAnchor.constraint(
|
||||||
|
equalTo: scroll.contentLayoutGuide.leadingAnchor,
|
||||||
|
constant: KeyBarMetrics.contentInset
|
||||||
|
),
|
||||||
|
stack.trailingAnchor.constraint(
|
||||||
|
equalTo: scroll.contentLayoutGuide.trailingAnchor,
|
||||||
|
constant: -KeyBarMetrics.contentInset
|
||||||
|
),
|
||||||
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
||||||
|
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
||||||
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
|
||||||
|
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .plain()
|
||||||
|
var label = AttributedString(spec.label)
|
||||||
|
label.font = UIFont.monospacedSystemFont(
|
||||||
|
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
|
||||||
|
weight: .semibold
|
||||||
|
)
|
||||||
|
config.attributedTitle = label
|
||||||
|
var caption = AttributedString(spec.caption)
|
||||||
|
caption.font = UIFont.preferredFont(forTextStyle: .caption2)
|
||||||
|
caption.foregroundColor = .secondaryLabel
|
||||||
|
config.attributedSubtitle = caption
|
||||||
|
config.titleAlignment = .center
|
||||||
|
config.contentInsets = KeyBarMetrics.buttonInsets
|
||||||
|
|
||||||
|
let button = UIButton(configuration: config)
|
||||||
|
button.accessibilityLabel = spec.title
|
||||||
|
button.addAction(
|
||||||
|
UIAction { [weak self] _ in self?.onKey?(spec.key) },
|
||||||
|
for: .touchUpInside
|
||||||
|
)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||||
|
|
||||||
|
/// Hardware-keyboard chords routed through the SAME `KeyByteMap` mapping
|
||||||
|
/// (plan §7 T-iOS-11). Scope decision (documented deviation): only Esc,
|
||||||
|
/// Shift+Tab and the Ctrl chords are registered —
|
||||||
|
/// - arrows stay SwiftTerm-native: a fixed CSI override would break
|
||||||
|
/// application-cursor-keys mode (DECCKM: vim/htop expect `ESC O A`);
|
||||||
|
/// - Enter/Tab/"/" are ordinary typing SwiftTerm already delivers;
|
||||||
|
/// - Esc·Esc is simply two Esc presses.
|
||||||
|
/// UIKeyCommand interception replaces (never duplicates) the responder's own
|
||||||
|
/// press handling, so each chord is sent exactly once.
|
||||||
|
enum HardwareKeyCommands {
|
||||||
|
struct Chord: Equatable {
|
||||||
|
let key: KeyByteMap.Key
|
||||||
|
let input: String
|
||||||
|
let modifiers: UIKeyModifierFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
static let chords: [Chord] = [
|
||||||
|
Chord(key: .esc, input: UIKeyCommand.inputEscape, modifiers: []),
|
||||||
|
Chord(key: .shiftTab, input: "\t", modifiers: .shift),
|
||||||
|
Chord(key: .ctrlC, input: "c", modifiers: .control),
|
||||||
|
Chord(key: .ctrlR, input: "r", modifiers: .control),
|
||||||
|
Chord(key: .ctrlO, input: "o", modifiers: .control),
|
||||||
|
Chord(key: .ctrlL, input: "l", modifiers: .control),
|
||||||
|
Chord(key: .ctrlT, input: "t", modifiers: .control),
|
||||||
|
Chord(key: .ctrlB, input: "b", modifiers: .control),
|
||||||
|
Chord(key: .ctrlD, input: "d", modifiers: .control),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// One prioritized `UIKeyCommand` per chord, all firing `action`.
|
||||||
|
/// (`UIKeyCommand` is `@MainActor` in the SDK, hence the isolation.)
|
||||||
|
@MainActor
|
||||||
|
static func build(action: Selector) -> [UIKeyCommand] {
|
||||||
|
chords.map { chord in
|
||||||
|
let command = UIKeyCommand(
|
||||||
|
action: action, input: chord.input, modifierFlags: chord.modifiers
|
||||||
|
)
|
||||||
|
command.wantsPriorityOverSystemBehavior = true
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse lookup for the command handler: which chord fired.
|
||||||
|
@MainActor
|
||||||
|
static func key(matching command: UIKeyCommand) -> KeyByteMap.Key? {
|
||||||
|
chords.first {
|
||||||
|
$0.input == command.input && $0.modifiers == command.modifierFlags
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
}
|
||||||
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal file
73
ios/App/WebTerm/Components/PlanGateSheet.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal file
96
ios/App/WebTerm/Components/ReconnectBanner.swift
Normal 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) · 终端已只读"
|
||||||
|
}
|
||||||
|
}
|
||||||
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal file
127
ios/App/WebTerm/Components/TelemetryChips.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal file
350
ios/App/WebTerm/Screens/PairingScreen.swift
Normal 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 serve(wss)。"
|
||||||
|
static let publicWarning =
|
||||||
|
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
|
||||||
|
static let publicAcknowledge = "我已了解风险,仍要连接"
|
||||||
|
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
|
||||||
|
|
||||||
|
static func probing(_ address: String) -> String {
|
||||||
|
"正在验证 \(address) …"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func paired(_ name: String) -> String {
|
||||||
|
"已配对:\(name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scannerStartFailed(_ reason: String) -> String {
|
||||||
|
"无法启动相机扫描:\(reason)。可改用手输地址。"
|
||||||
|
}
|
||||||
|
}
|
||||||
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal file
235
ios/App/WebTerm/Screens/SessionListScreen.swift
Normal 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) 台设备在看"
|
||||||
|
}
|
||||||
|
}
|
||||||
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal file
145
ios/App/WebTerm/Screens/TerminalScreen.swift
Normal 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 label→bytes
|
||||||
|
/// lookup via `KeyByteMap`, bypassing SwiftTerm's text path so the soft
|
||||||
|
/// keyboard never pops (mirrors the web bar's ws.send bypass).
|
||||||
|
/// - IME: NO custom keydown/composition interception — SwiftTerm manages
|
||||||
|
/// composition itself (CLAUDE.md gotcha; plan §7 T-iOS-11).
|
||||||
|
///
|
||||||
|
/// Navigation/lifecycle wiring (engine open/close, scenePhase) lands in
|
||||||
|
/// T-iOS-15 — this screen only renders and routes.
|
||||||
|
struct TerminalScreen: View {
|
||||||
|
let viewModel: TerminalViewModel
|
||||||
|
|
||||||
|
private enum Metrics {
|
||||||
|
static let bannerHorizontalPadding: CGFloat = 12
|
||||||
|
static let bannerTopPadding: CGFloat = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TerminalHostView(viewModel: viewModel)
|
||||||
|
.ignoresSafeArea(.container, edges: .bottom)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if let model = viewModel.bannerModel {
|
||||||
|
ReconnectBanner(model: model)
|
||||||
|
.padding(.horizontal, Metrics.bannerHorizontalPadding)
|
||||||
|
.padding(.top, Metrics.bannerTopPadding)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.default, value: viewModel.bannerModel)
|
||||||
|
.onAppear { viewModel.start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftTerm bridge
|
||||||
|
|
||||||
|
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
|
||||||
|
/// KeyBar is installed as `inputAccessoryView`; hardware chords come from the
|
||||||
|
/// `KeyCommandTerminalView` subclass. Both route through the ViewModel.
|
||||||
|
private struct TerminalHostView: UIViewRepresentable {
|
||||||
|
let viewModel: TerminalViewModel
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> KeyCommandTerminalView {
|
||||||
|
let terminal = KeyCommandTerminalView(frame: .zero)
|
||||||
|
terminal.terminalDelegate = context.coordinator
|
||||||
|
|
||||||
|
let viewModel = viewModel
|
||||||
|
terminal.onKeyCommand = { key in viewModel.send(key: key) }
|
||||||
|
|
||||||
|
let keyBar = KeyBarView()
|
||||||
|
keyBar.onKey = { key in viewModel.send(key: key) }
|
||||||
|
terminal.inputAccessoryView = keyBar
|
||||||
|
|
||||||
|
// Output sink: buffered replay flushes now, live bytes follow.
|
||||||
|
// @MainActor-typed closure — feeding off the main actor cannot compile.
|
||||||
|
viewModel.attachTerminalSink { [weak terminal] text in
|
||||||
|
terminal?.feed(text: text)
|
||||||
|
}
|
||||||
|
return terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: KeyCommandTerminalView, context: Context) {
|
||||||
|
// State-driven UI lives in SwiftUI (banner overlay); the terminal view
|
||||||
|
// itself is driven by the sink/delegate, nothing to push here.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SwiftTerm's delegate is a pre-concurrency protocol; the conformance is
|
||||||
|
/// `@preconcurrency` — SwiftTerm only calls it from the main thread (the
|
||||||
|
/// view itself is `@MainActor`), which the runtime check enforces.
|
||||||
|
@MainActor
|
||||||
|
final class Coordinator: NSObject, @preconcurrency TerminalViewDelegate {
|
||||||
|
private let viewModel: TerminalViewModel
|
||||||
|
|
||||||
|
init(viewModel: TerminalViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User typed into SwiftTerm (soft/hardware keyboard, IME result):
|
||||||
|
/// raw bytes, passed through verbatim (invariant #9).
|
||||||
|
func send(source: TerminalView, data: ArraySlice<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal file
319
ios/App/WebTerm/ViewModels/GateViewModel.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
398
ios/App/WebTerm/ViewModels/PairingViewModel.swift
Normal file
398
ios/App/WebTerm/ViewModels/PairingViewModel.swift
Normal 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 ws→loopback: nothing to warn about.
|
||||||
|
case none
|
||||||
|
/// ws→100.64/10 or `*.ts.net`: WireGuard already encrypts — no
|
||||||
|
/// plaintext warning, an optional positive badge instead.
|
||||||
|
case tailscaleEncrypted
|
||||||
|
/// ws→RFC1918 / link-local / `.local`: NON-blocking notice — keystrokes
|
||||||
|
/// and output are sniffable on the same LAN; prefer `tailscale serve`.
|
||||||
|
case plaintextLAN
|
||||||
|
/// Public host (http AND https alike, §5.4 table): strongest BLOCKING
|
||||||
|
/// warning — anyone who can reach the port gets a shell.
|
||||||
|
case publicHostBlocking
|
||||||
|
|
||||||
|
var isBlocking: Bool { self == .publicHostBlocking }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parsed-but-not-yet-probed target shown on the confirm page.
|
||||||
|
struct PendingHost: Equatable, Sendable {
|
||||||
|
let endpoint: HostEndpoint
|
||||||
|
let warning: SecurityWarning
|
||||||
|
|
||||||
|
/// `scheme://host[:port]` via `HostEndpoint`'s single-point derivation
|
||||||
|
/// (browser-Origin serialization) — NEVER hand-assembled.
|
||||||
|
var displayAddress: String { endpoint.originHeader }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the failure UI offers besides the message.
|
||||||
|
enum RecoveryAction: Equatable, Sendable {
|
||||||
|
case retry
|
||||||
|
/// iOS Local Network permission was denied → deep-link to the app's
|
||||||
|
/// Settings pane (its 本地网络 toggle lives there).
|
||||||
|
case openLocalNetworkSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailureDisplay: Equatable, Sendable {
|
||||||
|
let message: String
|
||||||
|
let action: RecoveryAction
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Phase: Equatable {
|
||||||
|
case idle
|
||||||
|
case confirming(PendingHost)
|
||||||
|
case probing(PendingHost)
|
||||||
|
case failed(PendingHost, FailureDisplay)
|
||||||
|
case paired(HostRegistry.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observable state
|
||||||
|
|
||||||
|
private(set) var phase: Phase = .idle
|
||||||
|
/// Inline rejection copy for invalid scan/manual input (idle-state error).
|
||||||
|
private(set) var inputRejection: String?
|
||||||
|
/// Editable name shown on the confirm page; defaults to the endpoint host
|
||||||
|
/// and falls back to it when the user clears the field.
|
||||||
|
var hostName = ""
|
||||||
|
/// Explicit user acknowledgement for the blocking public-host warning.
|
||||||
|
var hasAcknowledgedPublicRisk = false
|
||||||
|
/// Set when confirm was attempted on a blocking warning WITHOUT the
|
||||||
|
/// acknowledgement — the UI highlights the ack control.
|
||||||
|
private(set) var needsPublicRiskAcknowledgement = false
|
||||||
|
/// Navigate signal: set exactly once when pairing completes (T-iOS-15
|
||||||
|
/// observes it to move on to the session list).
|
||||||
|
private(set) var pairedHost: HostRegistry.Host?
|
||||||
|
|
||||||
|
// MARK: - Dependencies (not observed)
|
||||||
|
|
||||||
|
@ObservationIgnored private let store: any HostStore
|
||||||
|
@ObservationIgnored private let probe: Probe
|
||||||
|
|
||||||
|
init(store: any HostStore, probe: @escaping Probe) {
|
||||||
|
self.store = store
|
||||||
|
self.probe = probe
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input boundaries (untrusted, validated via HostEndpoint)
|
||||||
|
|
||||||
|
/// QR scan result (`public/qr.ts` encodes `location.origin`). Strict: the
|
||||||
|
/// payload must already be a full http(s) URL — no scheme inference for
|
||||||
|
/// untrusted external input.
|
||||||
|
func handleScannedCode(_ payload: String) {
|
||||||
|
guard canAcceptNewTarget else { return }
|
||||||
|
let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let url = URL(string: trimmed), let endpoint = HostEndpoint(baseURL: url) else {
|
||||||
|
inputRejection = PairingCopy.scanRejected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enterConfirming(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually typed URL. The user knows what they typed, but it still goes
|
||||||
|
/// through the SAME confirm state (uniform warning tiers — documented
|
||||||
|
/// decision). Convenience: no `://` → `http://` prefix before parsing.
|
||||||
|
func submitManualURL(_ text: String) {
|
||||||
|
guard canAcceptNewTarget else { return }
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
inputRejection = PairingCopy.manualRejected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let candidate = trimmed.contains(Self.schemeSeparator)
|
||||||
|
? trimmed
|
||||||
|
: Self.defaultManualScheme + trimmed
|
||||||
|
guard let url = URL(string: candidate), let endpoint = HostEndpoint(baseURL: url) else {
|
||||||
|
inputRejection = PairingCopy.manualRejected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enterConfirming(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back out of confirm/failed to a clean entry state. Never probes.
|
||||||
|
func cancel() {
|
||||||
|
phase = .idle
|
||||||
|
inputRejection = nil
|
||||||
|
hasAcknowledgedPublicRisk = false
|
||||||
|
needsPublicRiskAcknowledgement = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Confirm → probe → store
|
||||||
|
|
||||||
|
/// The ONLY way network starts. No-op unless confirming; a blocking
|
||||||
|
/// warning without explicit acknowledgement refuses and flags the UI.
|
||||||
|
func confirmConnect() async {
|
||||||
|
guard case .confirming(let pending) = phase else { return }
|
||||||
|
if pending.warning.isBlocking && !hasAcknowledgedPublicRisk {
|
||||||
|
needsPublicRiskAcknowledgement = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await runProbe(for: pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-run the full probe against the same endpoint after a failure.
|
||||||
|
func retry() async {
|
||||||
|
guard case .failed(let pending, _) = phase else { return }
|
||||||
|
await runProbe(for: pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAcceptNewTarget: Bool {
|
||||||
|
switch phase {
|
||||||
|
case .idle, .confirming, .failed:
|
||||||
|
return true
|
||||||
|
case .probing, .paired:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enterConfirming(_ endpoint: HostEndpoint) {
|
||||||
|
inputRejection = nil
|
||||||
|
hasAcknowledgedPublicRisk = false
|
||||||
|
needsPublicRiskAcknowledgement = false
|
||||||
|
hostName = endpoint.baseURL.host ?? ""
|
||||||
|
phase = .confirming(PendingHost(
|
||||||
|
endpoint: endpoint, warning: Self.warning(for: endpoint)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runProbe(for pending: PendingHost) async {
|
||||||
|
needsPublicRiskAcknowledgement = false
|
||||||
|
phase = .probing(pending)
|
||||||
|
switch await probe(pending.endpoint) {
|
||||||
|
case .failure(let error):
|
||||||
|
phase = .failed(pending, Self.display(for: error))
|
||||||
|
case .success(let endpoint):
|
||||||
|
await storePairedHost(endpoint: endpoint, pending: pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.4 ruling: `Host{id,name}` is constructed here, from the PROBED
|
||||||
|
/// endpoint. A store failure is surfaced explicitly (never swallowed);
|
||||||
|
/// retry re-runs the whole confirm flow.
|
||||||
|
private func storePairedHost(endpoint: HostEndpoint, pending: PendingHost) async {
|
||||||
|
let trimmedName = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fallbackName = endpoint.baseURL.host ?? endpoint.originHeader
|
||||||
|
let host = HostRegistry.Host(
|
||||||
|
id: UUID(),
|
||||||
|
name: trimmedName.isEmpty ? fallbackName : trimmedName,
|
||||||
|
endpoint: endpoint
|
||||||
|
)
|
||||||
|
do {
|
||||||
|
_ = try await store.upsert(host)
|
||||||
|
} catch {
|
||||||
|
phase = .failed(pending, FailureDisplay(
|
||||||
|
message: PairingCopy.storeFailed, action: .retry
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pairedHost = host
|
||||||
|
phase = .paired(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PairingError → copy + action (task RED list, one case each)
|
||||||
|
|
||||||
|
static func display(for error: PairingError) -> FailureDisplay {
|
||||||
|
switch error {
|
||||||
|
case .localNetworkDenied:
|
||||||
|
return FailureDisplay(
|
||||||
|
message: PairingCopy.localNetworkDenied, action: .openLocalNetworkSettings
|
||||||
|
)
|
||||||
|
case .hostUnreachable(let underlying):
|
||||||
|
return FailureDisplay(
|
||||||
|
message: PairingCopy.hostUnreachable(underlying), action: .retry
|
||||||
|
)
|
||||||
|
case .httpOkButNotWebTerminal:
|
||||||
|
return FailureDisplay(message: PairingCopy.notWebTerminal, action: .retry)
|
||||||
|
case .originRejected(let hint):
|
||||||
|
// The probe already derived the complete actionable copy from
|
||||||
|
// endpoint.originHeader — surface it VERBATIM, never re-derive.
|
||||||
|
return FailureDisplay(message: hint, action: .retry)
|
||||||
|
case .atsBlocked(let host):
|
||||||
|
return FailureDisplay(message: PairingCopy.atsBlocked(host: host), action: .retry)
|
||||||
|
case .tlsFailure:
|
||||||
|
return FailureDisplay(message: PairingCopy.tlsFailure, action: .retry)
|
||||||
|
case .timeout:
|
||||||
|
return FailureDisplay(message: PairingCopy.timeout, action: .retry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - §5.4 warning tiers
|
||||||
|
|
||||||
|
/// Decide the confirm-page warning from scheme + host class. Public hosts
|
||||||
|
/// block regardless of scheme (§5.4 table: https is included in the
|
||||||
|
/// public-host confirm warning); otherwise https clears every notice.
|
||||||
|
static func warning(for endpoint: HostEndpoint) -> SecurityWarning {
|
||||||
|
let hostClass = classifyHost(endpoint.baseURL.host ?? "")
|
||||||
|
if hostClass == .publicHost {
|
||||||
|
return .publicHostBlocking
|
||||||
|
}
|
||||||
|
if endpoint.baseURL.scheme?.lowercased() == Self.httpsScheme {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
switch hostClass {
|
||||||
|
case .loopback:
|
||||||
|
return .none
|
||||||
|
case .tailscale:
|
||||||
|
return .tailscaleEncrypted
|
||||||
|
case .privateLAN:
|
||||||
|
return .plaintextLAN
|
||||||
|
case .publicHost:
|
||||||
|
return .publicHostBlocking // unreachable; keeps the switch total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address classes relevant to §5.4. NOTE: near-duplicate of APIClient's
|
||||||
|
/// internal `isPrivateOrLocalHost` (finer-grained here) — T-iOS-38 dedup.
|
||||||
|
enum HostClass: Equatable, Sendable {
|
||||||
|
case loopback
|
||||||
|
case tailscale
|
||||||
|
case privateLAN
|
||||||
|
case publicHost
|
||||||
|
}
|
||||||
|
|
||||||
|
static func classifyHost(_ rawHost: String) -> HostClass {
|
||||||
|
let host = rawHost.lowercased()
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) // IPv6 brackets
|
||||||
|
if host == Self.localhostName {
|
||||||
|
return .loopback
|
||||||
|
}
|
||||||
|
if host.hasSuffix(Self.tailscaleMagicDNSSuffix) {
|
||||||
|
return .tailscale
|
||||||
|
}
|
||||||
|
if host.hasSuffix(Self.mdnsSuffix) {
|
||||||
|
return .privateLAN
|
||||||
|
}
|
||||||
|
if let octets = ipv4Octets(host) {
|
||||||
|
return classifyIPv4(octets)
|
||||||
|
}
|
||||||
|
if host.contains(":") {
|
||||||
|
return classifyIPv6(host)
|
||||||
|
}
|
||||||
|
return .publicHost
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifyIPv4(_ octets: [Int]) -> HostClass {
|
||||||
|
switch (octets[0], octets[1]) {
|
||||||
|
case (127, _):
|
||||||
|
return .loopback
|
||||||
|
case (10, _), (192, 168), (169, 254):
|
||||||
|
return .privateLAN // RFC1918 10/8, 192.168/16 · link-local 169.254/16
|
||||||
|
case (172, 16...31):
|
||||||
|
return .privateLAN // RFC1918 172.16/12
|
||||||
|
case (100, 64...127):
|
||||||
|
return .tailscale // CGNAT 100.64/10
|
||||||
|
default:
|
||||||
|
return .publicHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classifyIPv6(_ host: String) -> HostClass {
|
||||||
|
if host == Self.ipv6Loopback {
|
||||||
|
return .loopback
|
||||||
|
}
|
||||||
|
let isLinkLocal = host.hasPrefix(Self.ipv6LinkLocalPrefix)
|
||||||
|
let isULA = host.hasPrefix("fc") || host.hasPrefix("fd") // fc00::/7
|
||||||
|
return (isLinkLocal || isULA) ? .privateLAN : .publicHost
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ipv4Octets(_ host: String) -> [Int]? {
|
||||||
|
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||||
|
guard parts.count == Self.ipv4OctetCount else { return nil }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == Self.ipv4OctetCount,
|
||||||
|
octets.allSatisfy({ Self.ipv4OctetRange.contains($0) })
|
||||||
|
else { return nil }
|
||||||
|
return octets
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Named constants (no magic values, plan §4)
|
||||||
|
|
||||||
|
private static let schemeSeparator = "://"
|
||||||
|
private static let defaultManualScheme = "http://"
|
||||||
|
private static let httpsScheme = "https"
|
||||||
|
private static let localhostName = "localhost"
|
||||||
|
private static let tailscaleMagicDNSSuffix = ".ts.net"
|
||||||
|
private static let mdnsSuffix = ".local"
|
||||||
|
private static let ipv6Loopback = "::1"
|
||||||
|
private static let ipv6LinkLocalPrefix = "fe80"
|
||||||
|
private static let ipv4OctetCount = 4
|
||||||
|
private static let ipv4OctetRange = 0...255
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing pairing copy (plan §3.4 taxonomy → actionable wording; §5.2
|
||||||
|
/// Local-Network guidance including the iOS 18 restart caveat).
|
||||||
|
enum PairingCopy {
|
||||||
|
static let scanRejected =
|
||||||
|
"二维码不是 http(s) 地址,无法配对。请扫描 web 终端工具栏「Connect a device」弹出的二维码。"
|
||||||
|
static let manualRejected =
|
||||||
|
"无法解析这个地址。请输入完整 URL,例如 http://192.168.1.5:3000"
|
||||||
|
static let storeFailed =
|
||||||
|
"主机已通过验证,但保存到本机失败,请重试。"
|
||||||
|
static let localNetworkDenied =
|
||||||
|
"无法访问本地网络——「本地网络」权限可能被拒绝。请到 设置 → 隐私与安全性 → 本地网络 打开 WebTerm 的开关"
|
||||||
|
+ "(iOS 18 存在需要重启手机才生效的已知问题)。"
|
||||||
|
static let notWebTerminal =
|
||||||
|
"对方在响应 HTTP,但不是 web-terminal——端口对吗?"
|
||||||
|
static let tlsFailure =
|
||||||
|
"TLS 连接失败:证书无效或不受信任。"
|
||||||
|
static let timeout =
|
||||||
|
"连接超时。请确认主机在线、与手机在同一网络后重试。"
|
||||||
|
|
||||||
|
static func hostUnreachable(_ underlying: String) -> String {
|
||||||
|
"无法连接主机:\(underlying)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// §3.4 wording for the ATS cleartext block.
|
||||||
|
static func atsBlocked(host: String) -> String {
|
||||||
|
"明文 HTTP 被 ATS 拦截——\(host) 所在 IP 段不在 App 例外列表内,"
|
||||||
|
+ "请改用 https / tailscale serve,或反馈该网段。"
|
||||||
|
}
|
||||||
|
}
|
||||||
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal file
351
ios/App/WebTerm/ViewModels/SessionListViewModel.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
301
ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Normal file
301
ios/App/WebTerm/ViewModels/TerminalViewModel.swift
Normal 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 1s→30s).
|
||||||
|
case reconnecting(attempt: Int, next: Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which terminal the user is looking at: a live one, a dead-for-good one
|
||||||
|
/// (non-retryable failure), or a finished one (read-only).
|
||||||
|
enum TerminalPhase: Equatable {
|
||||||
|
case live
|
||||||
|
/// Non-retryable terminal failure — actionable copy instead of a spinner.
|
||||||
|
case failed(message: String)
|
||||||
|
/// The shell exited; the terminal stays readable but accepts no input.
|
||||||
|
case exited(code: Int, reason: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
|
||||||
|
/// coupling warning): reconnecting would deterministically fail forever,
|
||||||
|
/// so the user must change a knob, not wait.
|
||||||
|
static let replayTooLargeMessage =
|
||||||
|
"服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"
|
||||||
|
|
||||||
|
private(set) var banner: ConnectionBanner = .none
|
||||||
|
private(set) var phase: TerminalPhase = .live
|
||||||
|
/// Server-adopted session id (ALWAYS the server-issued one — persisting it
|
||||||
|
/// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
|
||||||
|
private(set) var sessionId: UUID?
|
||||||
|
|
||||||
|
/// Read-only = no input reaches the PTY (exit / terminal failure). Resize
|
||||||
|
/// is NOT gated here — the engine owns terminal-state dropping.
|
||||||
|
var isReadOnly: Bool { phase != .live }
|
||||||
|
|
||||||
|
/// Single render input for `ReconnectBanner`: terminal phases win over
|
||||||
|
/// transient connection states.
|
||||||
|
var bannerModel: ReconnectBanner.Model? {
|
||||||
|
switch phase {
|
||||||
|
case .failed(let message):
|
||||||
|
return .failed(message: message)
|
||||||
|
case .exited(let code, let reason):
|
||||||
|
return .exited(code: code, reason: reason)
|
||||||
|
case .live:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch banner {
|
||||||
|
case .none:
|
||||||
|
return nil
|
||||||
|
case .connecting:
|
||||||
|
return .connecting
|
||||||
|
case .reconnecting(let attempt, let next):
|
||||||
|
return .reconnecting(attempt: attempt, retryIn: next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dependencies & plumbing (not observed)
|
||||||
|
|
||||||
|
@ObservationIgnored private let engine: SessionEngine
|
||||||
|
@ObservationIgnored private let events: AsyncStream<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal file
402
ios/App/WebTermTests/GateViewModelTests.swift
Normal 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 条")
|
||||||
|
}
|
||||||
|
}
|
||||||
132
ios/App/WebTermTests/KeyBarTests.swift
Normal file
132
ios/App/WebTermTests/KeyBarTests.swift
Normal 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 label→bytes lookup goes through
|
||||||
|
/// `KeyByteMap` — no byte literal exists in the App layer.
|
||||||
|
@MainActor
|
||||||
|
@Suite("KeyBar")
|
||||||
|
struct KeyBarTests {
|
||||||
|
@MainActor
|
||||||
|
private final class KeyRecorder {
|
||||||
|
private(set) var keys: [KeyByteMap.Key] = []
|
||||||
|
func record(_ key: KeyByteMap.Key) { keys = keys + [key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout data (mirror of KEYBAR_BUTTONS)
|
||||||
|
|
||||||
|
@Test("button order mirrors public/keybar.ts KEYBAR_BUTTONS exactly")
|
||||||
|
func layoutOrderMirrorsWebKeybarButtons() {
|
||||||
|
let expectedOrder: [KeyByteMap.Key] = [
|
||||||
|
.esc, .escEsc, .shiftTab, .arrowUp, .arrowDown, .enter, .ctrlC,
|
||||||
|
.ctrlR, .ctrlO, .ctrlL, .ctrlT, .ctrlB, .ctrlD, .tab,
|
||||||
|
.arrowLeft, .arrowRight, .slash,
|
||||||
|
]
|
||||||
|
#expect(KeyBarLayout.buttons.map(\.key) == expectedOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("glyph labels mirror the web bar; Esc is the only primary button")
|
||||||
|
func labelsAndPrimaryFlagMirrorWeb() {
|
||||||
|
let expectedLabels = [
|
||||||
|
"Esc", "Esc²", "⇧Tab", "↑", "↓", "⏎", "^C", "^R", "^O", "^L",
|
||||||
|
"^T", "^B", "^D", "Tab", "←", "→", "/",
|
||||||
|
]
|
||||||
|
#expect(KeyBarLayout.buttons.map(\.label) == expectedLabels)
|
||||||
|
#expect(KeyBarLayout.buttons.filter(\.isPrimary).map(\.key) == [.esc])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("every button spec resolves to real bytes through KeyByteMap (single source of truth)")
|
||||||
|
func everySpecResolvesThroughKeyByteMap() {
|
||||||
|
for spec in KeyBarLayout.buttons {
|
||||||
|
#expect(!KeyByteMap.bytes(for: spec.key).isEmpty,
|
||||||
|
"no bytes for \(spec.key.rawValue)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - KeyBarView (UIKit input accessory)
|
||||||
|
|
||||||
|
@Test("KeyBarView builds one button per spec with the web title as accessibility label")
|
||||||
|
func keyBarViewBuildsOneButtonPerSpec() {
|
||||||
|
// Arrange / Act
|
||||||
|
let bar = KeyBarView()
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(bar.keyButtons.count == KeyBarLayout.buttons.count)
|
||||||
|
#expect(bar.keyButtons.map(\.accessibilityLabel)
|
||||||
|
== KeyBarLayout.buttons.map(\.title))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("tapping button i fires onKey with layout key i (bytes resolved downstream via KeyByteMap)")
|
||||||
|
func tappingButtonsFiresOnKeyInLayoutOrder() {
|
||||||
|
// Arrange
|
||||||
|
let bar = KeyBarView()
|
||||||
|
let recorder = KeyRecorder()
|
||||||
|
bar.onKey = { recorder.record($0) }
|
||||||
|
|
||||||
|
// Act: tap every button once, in order.
|
||||||
|
for button in bar.keyButtons {
|
||||||
|
button.sendActions(for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(recorder.keys == KeyBarLayout.buttons.map(\.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hardware keyboard (UIKeyCommand, same KeyByteMap mapping)
|
||||||
|
|
||||||
|
@Test("hardware chords: Esc, Shift+Tab and the 7 Ctrl chords map through KeyByteMap")
|
||||||
|
func hardwareChordsCoverEscShiftTabAndCtrlChords() {
|
||||||
|
let expected: [(KeyByteMap.Key, String, UIKeyModifierFlags)] = [
|
||||||
|
(.esc, UIKeyCommand.inputEscape, []),
|
||||||
|
(.shiftTab, "\t", .shift),
|
||||||
|
(.ctrlC, "c", .control),
|
||||||
|
(.ctrlR, "r", .control),
|
||||||
|
(.ctrlO, "o", .control),
|
||||||
|
(.ctrlL, "l", .control),
|
||||||
|
(.ctrlT, "t", .control),
|
||||||
|
(.ctrlB, "b", .control),
|
||||||
|
(.ctrlD, "d", .control),
|
||||||
|
]
|
||||||
|
#expect(HardwareKeyCommands.chords.count == expected.count)
|
||||||
|
for (key, input, modifiers) in expected {
|
||||||
|
let chord = HardwareKeyCommands.chords.first { $0.key == key }
|
||||||
|
#expect(chord?.input == input, "input mismatch for \(key.rawValue)")
|
||||||
|
#expect(chord?.modifiers == modifiers, "modifiers mismatch for \(key.rawValue)")
|
||||||
|
#expect(!KeyByteMap.bytes(for: key).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("build() emits one prioritized UIKeyCommand per chord and key(matching:) round-trips")
|
||||||
|
func buildEmitsPrioritizedCommandsAndRoundTrips() {
|
||||||
|
// Arrange / Act
|
||||||
|
let commands = HardwareKeyCommands.build(
|
||||||
|
action: #selector(UIResponder.becomeFirstResponder) // any selector; not invoked
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(commands.count == HardwareKeyCommands.chords.count)
|
||||||
|
for command in commands {
|
||||||
|
#expect(command.wantsPriorityOverSystemBehavior)
|
||||||
|
let key = HardwareKeyCommands.key(matching: command)
|
||||||
|
#expect(key != nil, "no chord matches \(String(describing: command.input))")
|
||||||
|
}
|
||||||
|
#expect(Set(commands.compactMap { HardwareKeyCommands.key(matching: $0) }).count
|
||||||
|
== HardwareKeyCommands.chords.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("hardware mapping never hijacks plain typing keys or arrows (SwiftTerm owns them — DECCKM)")
|
||||||
|
func hardwareMappingExcludesPlainKeysAndArrows() {
|
||||||
|
// Arrows must stay SwiftTerm-native: a fixed \u{1B}[A override would
|
||||||
|
// break application-cursor-keys mode (vim/htop send \u{1B}OA there).
|
||||||
|
// Enter/Tab/"/" are ordinary typing; Esc·Esc is two Esc presses.
|
||||||
|
let excluded: Set<KeyByteMap.Key> = [
|
||||||
|
.arrowUp, .arrowDown, .arrowLeft, .arrowRight,
|
||||||
|
.enter, .tab, .slash, .escEsc,
|
||||||
|
]
|
||||||
|
let chordKeys = Set(HardwareKeyCommands.chords.map(\.key))
|
||||||
|
#expect(chordKeys.isDisjoint(with: excluded))
|
||||||
|
}
|
||||||
|
}
|
||||||
433
ios/App/WebTermTests/PairingViewModelTests.swift
Normal file
433
ios/App/WebTermTests/PairingViewModelTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal file
581
ios/App/WebTermTests/SessionListViewModelTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
280
ios/App/WebTermTests/TerminalViewModelTests.swift
Normal file
280
ios/App/WebTermTests/TerminalViewModelTests.swift
Normal 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))])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
100
ios/IntegrationTests/MirrorLifecycleTests.swift
Normal file
100
ios/IntegrationTests/MirrorLifecycleTests.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
//
|
||||||
|
// MirrorLifecycleTests.swift — T-iOS-16 测试 4/6/7:JOIN mirror 与会话生命周期
|
||||||
|
//
|
||||||
|
// 4. 双客户端 JOIN mirror:A、B 同 attach 一个会话(新 attach 加入镜像、绝不
|
||||||
|
// 踢人,src/session/manager.ts:108-174),A input,B 收到 output。
|
||||||
|
// 6. DELETE /live-sessions/:id(kill)→ 镜像客户端观察到 WS close —— 不是
|
||||||
|
// exit 帧:manager.killById 先 ws.close() 全部客户端再 kill PTY
|
||||||
|
// (src/session/manager.ts:202-212),exit 广播只投给 OPEN socket
|
||||||
|
// (sendIfOpen);且该 id 从 GET /live-sessions 消失。
|
||||||
|
// 7. 自然退出广播:A input "exit\r" → bash 退出 → pty.onExit 向仍开着的
|
||||||
|
// socket 广播 {type:'exit'}(src/session/session.ts:146-151)→ 镜像 B 收到。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
|
||||||
|
@Suite("T-iOS-16 镜像与生命周期(真服务器)", .serialized, .timeLimit(.minutes(5)))
|
||||||
|
struct MirrorLifecycleTests {
|
||||||
|
|
||||||
|
@Test("双客户端 JOIN mirror:B attach 同一会话拿到同 id,A input → B 收 output")
|
||||||
|
func mirrorJoinSharesOutput() async throws {
|
||||||
|
// Arrange: A 开新会话,B 以同 id 加入(镜像,不踢 A)。
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let clientA = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let clientB = WSTestClient(server: server, origin: server.origin)
|
||||||
|
defer {
|
||||||
|
clientA.close()
|
||||||
|
clientB.close()
|
||||||
|
}
|
||||||
|
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
|
||||||
|
let adoptedByB = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
|
||||||
|
#expect(adoptedByB == sessionId, "镜像 attach 应采用同一会话 id(JOIN 不踢)")
|
||||||
|
|
||||||
|
// Act: A 打字 —— 哨兵 $((1300+37)) 只有 shell 执行才产出 1337。
|
||||||
|
var readerB = TranscriptReader(client: clientB)
|
||||||
|
try await clientA.send(shellCommand("echo \"MIR:$((1300+37))\""))
|
||||||
|
|
||||||
|
// Assert: B(镜像端)收到 A 命令的输出广播。
|
||||||
|
try await readerB.awaitContains("MIR:1337")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
clientA.close()
|
||||||
|
clientB.close()
|
||||||
|
await killSessionBestEffort(server: server, id: sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DELETE kill → 镜像观察到 WS close(非 exit 帧),id 从 /live-sessions 消失")
|
||||||
|
func killClosesMirrorSocketsWithoutExitFrame() async throws {
|
||||||
|
// Arrange
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let clientA = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let clientB = WSTestClient(server: server, origin: server.origin)
|
||||||
|
defer {
|
||||||
|
clientA.close()
|
||||||
|
clientB.close()
|
||||||
|
}
|
||||||
|
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
|
||||||
|
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
|
||||||
|
let idString = sessionId.uuidString.lowercased()
|
||||||
|
|
||||||
|
// Act: 带 Origin 的 kill(G 类端点,src/server.ts:354-359)。
|
||||||
|
let status = try await deleteLiveSession(server: server, id: idString, origin: server.origin)
|
||||||
|
|
||||||
|
// Assert: 204;id 立即从列表消失(killById 同步移除);镜像端观察到 close
|
||||||
|
// 且【没有】exit 帧(close-first 语义,src/session/manager.ts:202-212)。
|
||||||
|
#expect(status == 204, "kill 应 204,实际 \(status)")
|
||||||
|
let idsAfter = try await liveSessionIds(server: server)
|
||||||
|
#expect(!idsAfter.contains(idString), "kill 后 id 不应再出现在 GET /live-sessions")
|
||||||
|
|
||||||
|
let observedB = await drainUntilClose(client: clientB)
|
||||||
|
#expect(observedB.closed, "镜像客户端 B 应观察到 WS close")
|
||||||
|
#expect(!observedB.sawExit, "kill 路径不广播 exit 帧(先 close 后 kill)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("自然退出广播:A input exit → 镜像 B 收到 {type:'exit'} 帧")
|
||||||
|
func naturalExitBroadcastsExitFrameToMirror() async throws {
|
||||||
|
// Arrange
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let clientA = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let clientB = WSTestClient(server: server, origin: server.origin)
|
||||||
|
defer {
|
||||||
|
clientA.close()
|
||||||
|
clientB.close()
|
||||||
|
}
|
||||||
|
let sessionId = try await attachAndAwaitAttached(client: clientA, sessionId: nil)
|
||||||
|
_ = try await attachAndAwaitAttached(client: clientB, sessionId: sessionId)
|
||||||
|
|
||||||
|
// Act: A 让 shell 自然退出。
|
||||||
|
try await clientA.send(shellCommand("exit"))
|
||||||
|
|
||||||
|
// Assert: 镜像 B 收到 exit 帧(pty.onExit 广播,src/session/session.ts:146-151)。
|
||||||
|
let exit = try await awaitExitFrame(client: clientB)
|
||||||
|
#expect(exit.code == 0, "bash `exit` 应干净退出,实际 code=\(exit.code)")
|
||||||
|
|
||||||
|
// Cleanup(已退出的会话残留由服务器回收;kill 尽力而为)。
|
||||||
|
clientA.close()
|
||||||
|
clientB.close()
|
||||||
|
await killSessionBestEffort(server: server, id: sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
ios/IntegrationTests/OriginGuardTests.swift
Normal file
67
ios/IntegrationTests/OriginGuardTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// OriginGuardTests.swift — T-iOS-16 测试 5:Origin 守卫回归(吸收 spike①④)
|
||||||
|
//
|
||||||
|
// plan §5.1 的自动化化身。安全注:任何"为了过 CI 放宽 Origin 断言"= CRITICAL——
|
||||||
|
// 401/403 的精确断言不许软化成"连接失败即可"。
|
||||||
|
// - WS 升级无 Origin → 401(src/server.ts:646-651,默认拒空 Origin)
|
||||||
|
// - WS 升级端口失配 Origin → 401(src/http/origin.ts:47-51 端口精确比对;
|
||||||
|
// 注意 :443/:80 这类默认端口被两侧 new URL() 对称规范化,不是失配案例)
|
||||||
|
// - DELETE /live-sessions/:id 无 Origin → 403(G 守卫先于 id 查找,
|
||||||
|
// src/server.ts:332-339 requireAllowedOrigin)
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
|
||||||
|
@Suite("T-iOS-16 Origin 守卫(真服务器)", .serialized, .timeLimit(.minutes(5)))
|
||||||
|
struct OriginGuardTests {
|
||||||
|
|
||||||
|
@Test("无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
|
||||||
|
func upgradeWithoutOriginIsRejectedWith401() async throws {
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let client = WSTestClient(server: server, origin: nil)
|
||||||
|
defer { client.close() }
|
||||||
|
|
||||||
|
let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout)
|
||||||
|
|
||||||
|
#expect(failure != nil, "无 Origin 升级必须失败")
|
||||||
|
#expect(client.handshakeStatusCode == 401,
|
||||||
|
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
|
||||||
|
func portMismatchedOriginIsRejectedWith401() async throws {
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let mismatchPort = server.port == HarnessTunables.mismatchedOriginPort
|
||||||
|
? HarnessTunables.mismatchedOriginPortFallback
|
||||||
|
: HarnessTunables.mismatchedOriginPort
|
||||||
|
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
|
||||||
|
let client = WSTestClient(server: server, origin: badOrigin)
|
||||||
|
defer { client.close() }
|
||||||
|
|
||||||
|
let failure = await client.handshakeFailure(timeout: HarnessTunables.frameTimeout)
|
||||||
|
|
||||||
|
#expect(failure != nil, "端口失配 Origin 升级必须失败")
|
||||||
|
#expect(client.handshakeStatusCode == 401,
|
||||||
|
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("DELETE /live-sessions/:id 无 Origin → 403;带 Origin 的同样请求 → 404(差分证明 403 来自守卫)")
|
||||||
|
func deleteWithoutOriginIsRejectedWith403() async throws {
|
||||||
|
// Arrange: 用一个不存在的随机 id —— 守卫在 id 查找之前执行,无需真会话。
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
let bogusId = UUID().uuidString.lowercased()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let statusWithoutOrigin = try await deleteLiveSession(
|
||||||
|
server: server, id: bogusId, origin: nil)
|
||||||
|
let statusWithOrigin = try await deleteLiveSession(
|
||||||
|
server: server, id: bogusId, origin: server.origin)
|
||||||
|
|
||||||
|
// Assert: 无 Origin 走不到业务逻辑(403);带 Origin 走到了但 id 不存在(404)。
|
||||||
|
#expect(statusWithoutOrigin == 403,
|
||||||
|
"无 Origin 的 DELETE 应 403(src/server.ts:332-339),实际 \(statusWithoutOrigin)")
|
||||||
|
#expect(statusWithOrigin == 404,
|
||||||
|
"带 Origin 但 id 不存在应 404(src/server.ts:354-359),实际 \(statusWithOrigin)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
//
|
|
||||||
// OriginSpikeTests.swift — T-iOS-2 Day-1 spike(自动化部分 ①–④)
|
|
||||||
//
|
|
||||||
// 对本仓库的【真 Node 服务器】实测 URLSessionWebSocketTask 的两个 make-or-break
|
|
||||||
// 平台事实(plan §7 T-iOS-2):
|
|
||||||
// ① 无 Origin 的 WS 升级被 401 拒绝(src/server.ts:646-651,默认拒空 Origin)
|
|
||||||
// ② 自定义 `Origin` header 能被 URLSessionWebSocketTask 发出并精确匹配白名单
|
|
||||||
// → 升级成功、attach(null) 收到 attached(这是 MED 置信度事实的一锤定音)
|
|
||||||
// ③ ring-buffer 回放是单帧全量(src/session/session.ts attachWs → buffer.snapshot()):
|
|
||||||
// 默认 maximumMessageSize=1MiB 收不下 >1MiB 回放帧(withKnownIssue 记录复现),
|
|
||||||
// 设 16 MiB 后成功;对抗变体:ESC/C0 密集输出(JSON \uXXXX 转义膨胀)仍成功
|
|
||||||
// ④ 端口失配的 Origin(http://127.0.0.1:9999)→ 401(src/http/origin.ts:47-51
|
|
||||||
// 端口精确比对;注意 :443 这类默认端口会被 new URL() 双向规范化,不是失配案例)
|
|
||||||
//
|
|
||||||
// 运行方式(可复现):
|
|
||||||
// A. 默认自举:`swift test --package-path ios/IntegrationTests`
|
|
||||||
// 自动从本仓库 spawn `node_modules/.bin/tsx src/server.ts`(repo 根由
|
|
||||||
// #filePath 推导,可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1 的空闲端口,
|
|
||||||
// SHELL_PATH=/bin/bash、USE_TMUX=0。无需 ALLOWED_ORIGINS:服务器的
|
|
||||||
// deriveAllowedOrigins 恒包含 http://127.0.0.1:<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.Tunables,T-iOS-3) ───────────────
|
|
||||||
|
|
||||||
private enum SpikeTunables {
|
|
||||||
/// plan §3.2.1 `Tunables.maxWSMessageBytes` 的本地复刻(不 import 并行包)。
|
|
||||||
/// ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。
|
|
||||||
static let maxWSMessageBytes = 16 * 1024 * 1024
|
|
||||||
/// src/config.ts:41 DEFAULT_WS_PATH。
|
|
||||||
static let wsPath = "/term"
|
|
||||||
/// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。
|
|
||||||
static let bulkOutputBytes = 1_500_000
|
|
||||||
/// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB,
|
|
||||||
/// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。
|
|
||||||
static let escSequenceRepeats = 400_000
|
|
||||||
static let escSequenceRawBytes = escSequenceRepeats * 4
|
|
||||||
/// spike ④ 的失配端口(若真实端口撞车则退避到 fallback)。
|
|
||||||
static let mismatchedOriginPort = 9999
|
|
||||||
static let mismatchedOriginPortFallback = 9998
|
|
||||||
static let serverReadyTimeout: Duration = .seconds(40)
|
|
||||||
static let serverReadyPollInterval: Duration = .milliseconds(200)
|
|
||||||
static let frameTimeout: Duration = .seconds(15)
|
|
||||||
static let outputAccumulationTimeout: Duration = .seconds(90)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum SpikeError: Error, CustomStringConvertible {
|
|
||||||
case setup(String)
|
|
||||||
case timeout(String)
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .setup(let detail): return "setup: \(detail)"
|
|
||||||
case .timeout(let detail): return "timeout: \(detail)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 服务器端点快照(不可变) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
private struct SpikeServer: Sendable {
|
|
||||||
let baseURL: URL
|
|
||||||
let origin: String
|
|
||||||
let wsURL: URL
|
|
||||||
let port: Int
|
|
||||||
|
|
||||||
/// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。
|
|
||||||
static func make(baseURL: URL) throws -> SpikeServer {
|
|
||||||
guard let scheme = baseURL.scheme, scheme == "http" || scheme == "https" else {
|
|
||||||
throw SpikeError.setup("server URL 必须是 http(s),got \(baseURL)")
|
|
||||||
}
|
|
||||||
guard let host = baseURL.host, !host.isEmpty else {
|
|
||||||
throw SpikeError.setup("server URL 缺 host: \(baseURL)")
|
|
||||||
}
|
|
||||||
guard var comps = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
|
||||||
throw SpikeError.setup("server URL 无法解析: \(baseURL)")
|
|
||||||
}
|
|
||||||
comps.scheme = scheme == "https" ? "wss" : "ws"
|
|
||||||
comps.path = SpikeTunables.wsPath
|
|
||||||
comps.query = nil
|
|
||||||
guard let wsURL = comps.url else {
|
|
||||||
throw SpikeError.setup("无法派生 ws URL: \(baseURL)")
|
|
||||||
}
|
|
||||||
// 浏览器式 Origin 序列化:非默认端口显式保留(spike 环境恒为显式端口)。
|
|
||||||
let origin = baseURL.port.map { "\(scheme)://\(host):\($0)" } ?? "\(scheme)://\(host)"
|
|
||||||
let port = baseURL.port ?? (scheme == "https" ? 443 : 80)
|
|
||||||
return SpikeServer(baseURL: baseURL, origin: origin, wsURL: wsURL, port: port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ────────
|
|
||||||
//
|
|
||||||
// 实测:Swift Testing runner 退出时不跑 atexit handler(首轮运行留下孤儿 tsx),
|
|
||||||
// 所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin 是本测试
|
|
||||||
// 进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端关闭 →
|
|
||||||
// watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
|
||||||
|
|
||||||
private enum ServerProcessRegistry {
|
|
||||||
private static let lock = NSLock()
|
|
||||||
nonisolated(unsafe) private static var process: Process?
|
|
||||||
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
|
||||||
nonisolated(unsafe) private static var deathPipe: Pipe?
|
|
||||||
nonisolated(unsafe) private static var installedAtexit = false
|
|
||||||
|
|
||||||
static func register(_ p: Process) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
process = p
|
|
||||||
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
|
|
||||||
guard !installedAtexit else { return }
|
|
||||||
installedAtexit = true
|
|
||||||
atexit { ServerProcessRegistry.killNow() }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func killNow() {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
if let p = process, p.isRunning { p.terminate() }
|
|
||||||
process = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
|
||||||
let pipe = Pipe()
|
|
||||||
let watchdog = Process()
|
|
||||||
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
||||||
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
|
|
||||||
watchdog.standardInput = pipe
|
|
||||||
watchdog.standardOutput = FileHandle.nullDevice
|
|
||||||
watchdog.standardError = FileHandle.nullDevice
|
|
||||||
try watchdog.run()
|
|
||||||
return pipe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 服务器 harness(actor 单例:spawn 一次,全 suite 复用) ─────────────────
|
|
||||||
|
|
||||||
private actor ServerHarness {
|
|
||||||
static let shared = ServerHarness()
|
|
||||||
private var cached: SpikeServer?
|
|
||||||
|
|
||||||
func ensureServer() async throws -> SpikeServer {
|
|
||||||
if let cached { return cached }
|
|
||||||
let server: SpikeServer
|
|
||||||
if let external = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"] {
|
|
||||||
guard let url = URL(string: external) else {
|
|
||||||
throw SpikeError.setup("WEBTERM_SERVER_URL 不是合法 URL: \(external)")
|
|
||||||
}
|
|
||||||
server = try SpikeServer.make(baseURL: url)
|
|
||||||
try await awaitReady(server, process: nil, logURL: nil)
|
|
||||||
} else {
|
|
||||||
let spawned = try spawnLocalServer()
|
|
||||||
ServerProcessRegistry.register(spawned.process)
|
|
||||||
do {
|
|
||||||
try await awaitReady(spawned.server, process: spawned.process, logURL: spawned.logURL)
|
|
||||||
} catch {
|
|
||||||
ServerProcessRegistry.killNow()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
server = spawned.server
|
|
||||||
}
|
|
||||||
cached = server
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
private func spawnLocalServer() throws -> (server: SpikeServer, process: Process, logURL: URL) {
|
|
||||||
let repoRoot = try locateRepoRoot()
|
|
||||||
let tsx = repoRoot.appending(path: "node_modules/.bin/tsx")
|
|
||||||
guard FileManager.default.isExecutableFile(atPath: tsx.path) else {
|
|
||||||
throw SpikeError.setup("找不到 \(tsx.path) — 先在 repo 根目录跑 npm install")
|
|
||||||
}
|
|
||||||
let port = try findFreeLoopbackPort()
|
|
||||||
guard let baseURL = URL(string: "http://127.0.0.1:\(port)") else {
|
|
||||||
throw SpikeError.setup("无法构造 baseURL, port=\(port)")
|
|
||||||
}
|
|
||||||
let server = try SpikeServer.make(baseURL: baseURL)
|
|
||||||
|
|
||||||
let logURL = FileManager.default.temporaryDirectory
|
|
||||||
.appending(path: "webterm-spike-server-\(port).log")
|
|
||||||
FileManager.default.createFile(atPath: logURL.path, contents: nil)
|
|
||||||
let logHandle = try FileHandle(forWritingTo: logURL)
|
|
||||||
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = tsx
|
|
||||||
process.arguments = ["src/server.ts"]
|
|
||||||
process.currentDirectoryURL = repoRoot
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
|
||||||
env["PORT"] = String(port)
|
|
||||||
env["BIND_HOST"] = "127.0.0.1" // 测试期间绝不监听 0.0.0.0
|
|
||||||
env["SHELL_PATH"] = "/bin/bash" // 确定性输出(不吃用户 zsh 配置)
|
|
||||||
env["USE_TMUX"] = "0"
|
|
||||||
env["PATH"] = (env["PATH"] ?? "") + ":/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
|
|
||||||
process.environment = env
|
|
||||||
process.standardOutput = logHandle
|
|
||||||
process.standardError = logHandle
|
|
||||||
try process.run()
|
|
||||||
return (server, process, logURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func awaitReady(_ server: SpikeServer, process: Process?, logURL: URL?) async throws {
|
|
||||||
let probeURL = server.baseURL.appending(path: "live-sessions")
|
|
||||||
let deadline = ContinuousClock.now + SpikeTunables.serverReadyTimeout
|
|
||||||
while ContinuousClock.now < deadline {
|
|
||||||
if let process, !process.isRunning {
|
|
||||||
throw SpikeError.setup(
|
|
||||||
"服务器进程提前退出(exit \(process.terminationStatus))— 日志: \(logURL?.path ?? "n/a")")
|
|
||||||
}
|
|
||||||
if let (data, response) = try? await URLSession.shared.data(from: probeURL),
|
|
||||||
(response as? HTTPURLResponse)?.statusCode == 200,
|
|
||||||
(try? JSONSerialization.jsonObject(with: data)) is [Any] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try await Task.sleep(for: SpikeTunables.serverReadyPollInterval)
|
|
||||||
}
|
|
||||||
throw SpikeError.timeout(
|
|
||||||
"服务器 \(server.baseURL) 在 \(SpikeTunables.serverReadyTimeout) 内未就绪 — 日志: \(logURL?.path ?? "n/a")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func locateRepoRoot() throws -> URL {
|
|
||||||
if let override = ProcessInfo.processInfo.environment["WEBTERM_REPO_ROOT"] {
|
|
||||||
return URL(fileURLWithPath: override, isDirectory: true)
|
|
||||||
}
|
|
||||||
// #filePath = <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 header(spike ①)。
|
|
||||||
/// - maxMessageBytes: nil = 保持平台默认 1 MiB(spike ③ 复现分支)。
|
|
||||||
init(server: SpikeServer, origin: String?, maxMessageBytes: Int?) {
|
|
||||||
var request = URLRequest(url: server.wsURL)
|
|
||||||
request.timeoutInterval = 15
|
|
||||||
if let origin {
|
|
||||||
// 平台事实核心:Origin 不在 reserved-header 列表,setValue 应生效。
|
|
||||||
request.setValue(origin, forHTTPHeaderField: "Origin")
|
|
||||||
}
|
|
||||||
let task = URLSession.shared.webSocketTask(with: request)
|
|
||||||
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
|
|
||||||
self.task = task
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 握手响应状态码(升级被拒时应为 401;成功时 101)。
|
|
||||||
var handshakeStatusCode: Int? {
|
|
||||||
(task.response as? HTTPURLResponse)?.statusCode
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFrame(_ object: [String: Any]) async throws {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: object)
|
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
|
||||||
throw SpikeError.setup("帧序列化非 UTF-8")
|
|
||||||
}
|
|
||||||
try await task.send(.string(text))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。
|
|
||||||
func receiveText(timeout: Duration) async throws -> String {
|
|
||||||
try await withTimeout(timeout) { [self] in
|
|
||||||
while true {
|
|
||||||
switch try await task.receive() {
|
|
||||||
case .string(let text): return text
|
|
||||||
case .data: continue
|
|
||||||
@unknown default: continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。
|
|
||||||
func handshakeFailure(timeout: Duration) async -> (any Error)? {
|
|
||||||
do {
|
|
||||||
_ = try await receiveText(timeout: timeout)
|
|
||||||
return nil
|
|
||||||
} catch {
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func close() {
|
|
||||||
task.cancel(with: .normalClosure, reason: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func withTimeout<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 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。
|
|
||||||
private func inputFrame(command: String) -> [String: Any] {
|
|
||||||
["type": "input", "data": command + "\r"]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached
|
|
||||||
/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。
|
|
||||||
private func attachAndAwaitAttached(client: SpikeWSClient, sessionId: String?) async throws -> String {
|
|
||||||
try await client.sendFrame(attachFrame(sessionId: sessionId))
|
|
||||||
let deadline = ContinuousClock.now + SpikeTunables.frameTimeout
|
|
||||||
while ContinuousClock.now < deadline {
|
|
||||||
let text = try await client.receiveText(timeout: SpikeTunables.frameTimeout)
|
|
||||||
guard let frame = decodeFrame(text) else { continue }
|
|
||||||
if frame.type == "attached", let id = frame.sessionId { return id }
|
|
||||||
}
|
|
||||||
throw SpikeError.timeout("attach 后未收到 attached 帧")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。
|
|
||||||
private func accumulateOutput(client: SpikeWSClient, minBytes: Int) async throws -> Int {
|
|
||||||
var total = 0
|
|
||||||
let deadline = ContinuousClock.now + SpikeTunables.outputAccumulationTimeout
|
|
||||||
while ContinuousClock.now < deadline {
|
|
||||||
let text = try await client.receiveText(timeout: SpikeTunables.outputAccumulationTimeout)
|
|
||||||
guard let frame = decodeFrame(text), frame.type == "output" else { continue }
|
|
||||||
total += (frame.data ?? "").utf8.count
|
|
||||||
if total >= minBytes { return total }
|
|
||||||
}
|
|
||||||
throw SpikeError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ReplaySummary: Sendable {
|
|
||||||
let adoptedSessionId: String
|
|
||||||
let totalOutputBytes: Int
|
|
||||||
let containsSoftReset: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。
|
|
||||||
private func replayResult(
|
|
||||||
client: SpikeWSClient, sessionId: String, minBytes: Int
|
|
||||||
) async -> Result<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[0m(src/types.ts:167-170)
|
|
||||||
if !sawSoftReset, data.contains("\u{1B}[0m") { sawSoftReset = true }
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if let adopted, total >= minBytes {
|
|
||||||
return .success(ReplaySummary(
|
|
||||||
adoptedSessionId: adopted, totalOutputBytes: total, containsSoftReset: sawSoftReset))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return .failure(SpikeError.timeout(
|
|
||||||
"回放不完整: attached=\(adopted ?? "无") bytes=\(total)/\(minBytes)"))
|
|
||||||
} catch {
|
|
||||||
return .failure(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 =
|
|
||||||
/// EMSGSIZE "Message too long";plan §1 记作 "ENOBUFS(40)",Darwin 上 ENOBUFS
|
|
||||||
/// 实为 55 — 两个码都接受,实测结果记录在返回日志条目里)。
|
|
||||||
private func isMessageTooLongError(_ error: any Error) -> Bool {
|
|
||||||
func matches(_ ns: NSError) -> Bool {
|
|
||||||
ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
|
|
||||||
}
|
|
||||||
let ns = error as NSError
|
|
||||||
if matches(ns) { return true }
|
|
||||||
if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func describeError(_ error: any Error) -> String {
|
|
||||||
let ns = error as NSError
|
|
||||||
let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError)
|
|
||||||
.map { " underlying=\($0.domain)#\($0.code)" } ?? ""
|
|
||||||
return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测后清理:DELETE /live-sessions/:id 是 G 类端点,必须带 Origin(403 否则,
|
|
||||||
/// src/server.ts:332-339)。清理尽力而为——失败只影响宿主残留一个 bash 会话,
|
|
||||||
/// 不影响断言结果,故显式忽略错误。
|
|
||||||
private func killSession(server: SpikeServer, id: String) async {
|
|
||||||
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
|
|
||||||
request.httpMethod = "DELETE"
|
|
||||||
request.setValue(server.origin, forHTTPHeaderField: "Origin")
|
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Spike 断言 ①–④ ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Suite("T-iOS-2 Day-1 spike:URLSessionWebSocketTask 对真服务器", .serialized, .timeLimit(.minutes(5)))
|
|
||||||
struct OriginSpikeTests {
|
|
||||||
|
|
||||||
@Test("spike①: 无 Origin 的 WS 升级被 401 拒绝(服务器默认拒空 Origin)")
|
|
||||||
func upgradeWithoutOriginIsRejectedWith401() async throws {
|
|
||||||
let server = try await ServerHarness.shared.ensureServer()
|
|
||||||
let client = SpikeWSClient(server: server, origin: nil, maxMessageBytes: nil)
|
|
||||||
defer { client.close() }
|
|
||||||
|
|
||||||
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
|
|
||||||
|
|
||||||
#expect(failure != nil, "无 Origin 升级必须失败")
|
|
||||||
#expect(client.handshakeStatusCode == 401,
|
|
||||||
"应收 401(src/server.ts:646-651),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("spike②: 自定义 Origin 精确匹配 → 升级成功,attach(null) 收到 attached")
|
|
||||||
func customOriginHeaderIsSentAndAttachSucceeds() async throws {
|
|
||||||
let server = try await ServerHarness.shared.ensureServer()
|
|
||||||
let client = SpikeWSClient(
|
|
||||||
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
|
|
||||||
defer { client.close() }
|
|
||||||
|
|
||||||
let sessionId = try await attachAndAwaitAttached(client: client, sessionId: nil)
|
|
||||||
|
|
||||||
#expect(UUID(uuidString: sessionId) != nil, "attached.sessionId 应为 UUID,got \(sessionId)")
|
|
||||||
#expect(client.handshakeStatusCode == 101,
|
|
||||||
"升级应 101,实际 \(String(describing: client.handshakeStatusCode))")
|
|
||||||
client.close()
|
|
||||||
await killSession(server: server, id: sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("spike③: >1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);16MiB 成功")
|
|
||||||
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
|
|
||||||
let server = try await ServerHarness.shared.ensureServer()
|
|
||||||
|
|
||||||
// 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。
|
|
||||||
let generator = SpikeWSClient(
|
|
||||||
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
|
|
||||||
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
|
||||||
try await generator.sendFrame(
|
|
||||||
inputFrame(command: "perl -e 'print \"a\" x \(SpikeTunables.bulkOutputBytes)'"))
|
|
||||||
let produced = try await accumulateOutput(
|
|
||||||
client: generator, minBytes: SpikeTunables.bulkOutputBytes)
|
|
||||||
#expect(produced >= SpikeTunables.bulkOutputBytes)
|
|
||||||
generator.close()
|
|
||||||
|
|
||||||
// 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。
|
|
||||||
let defaultClient = SpikeWSClient(server: server, origin: server.origin, maxMessageBytes: nil)
|
|
||||||
let defaultResult = await replayResult(
|
|
||||||
client: defaultClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes)
|
|
||||||
defaultClient.close()
|
|
||||||
if case .failure(let error) = defaultResult {
|
|
||||||
#expect(isMessageTooLongError(error),
|
|
||||||
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
|
|
||||||
}
|
|
||||||
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
|
|
||||||
_ = try defaultResult.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。
|
|
||||||
let bigClient = SpikeWSClient(
|
|
||||||
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
|
|
||||||
let summary = try await replayResult(
|
|
||||||
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.bulkOutputBytes).get()
|
|
||||||
#expect(summary.adoptedSessionId == sessionId)
|
|
||||||
#expect(summary.totalOutputBytes >= SpikeTunables.bulkOutputBytes)
|
|
||||||
bigClient.close()
|
|
||||||
await killSession(server: server, id: sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("spike③-对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍成功")
|
|
||||||
func escDenseReplayStillSucceedsAt16MiB() async throws {
|
|
||||||
let server = try await ServerHarness.shared.ensureServer()
|
|
||||||
|
|
||||||
let generator = SpikeWSClient(
|
|
||||||
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
|
|
||||||
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
|
||||||
// perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("[0m")。
|
|
||||||
try await generator.sendFrame(
|
|
||||||
inputFrame(command: "perl -e 'print \"\\e[0m\" x \(SpikeTunables.escSequenceRepeats)'"))
|
|
||||||
let produced = try await accumulateOutput(
|
|
||||||
client: generator, minBytes: SpikeTunables.escSequenceRawBytes)
|
|
||||||
#expect(produced >= SpikeTunables.escSequenceRawBytes)
|
|
||||||
generator.close()
|
|
||||||
|
|
||||||
let bigClient = SpikeWSClient(
|
|
||||||
server: server, origin: server.origin, maxMessageBytes: SpikeTunables.maxWSMessageBytes)
|
|
||||||
let summary = try await replayResult(
|
|
||||||
client: bigClient, sessionId: sessionId, minBytes: SpikeTunables.escSequenceRawBytes).get()
|
|
||||||
#expect(summary.adoptedSessionId == sessionId)
|
|
||||||
#expect(summary.totalOutputBytes >= SpikeTunables.escSequenceRawBytes)
|
|
||||||
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
|
|
||||||
bigClient.close()
|
|
||||||
await killSession(server: server, id: sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("spike④: 端口失配的 Origin → 401(端口精确比对;默认端口规范化不是失配案例)")
|
|
||||||
func portMismatchedOriginIsRejectedWith401() async throws {
|
|
||||||
let server = try await ServerHarness.shared.ensureServer()
|
|
||||||
let mismatchPort = server.port == SpikeTunables.mismatchedOriginPort
|
|
||||||
? SpikeTunables.mismatchedOriginPortFallback
|
|
||||||
: SpikeTunables.mismatchedOriginPort
|
|
||||||
// 注意::443/:80 这类 scheme 默认端口会被两侧 new URL() 对称规范化
|
|
||||||
// (src/http/origin.ts:31-51),不构成失配;本测只断言显式端口失配。
|
|
||||||
let badOrigin = "http://127.0.0.1:\(mismatchPort)"
|
|
||||||
let client = SpikeWSClient(server: server, origin: badOrigin, maxMessageBytes: nil)
|
|
||||||
defer { client.close() }
|
|
||||||
|
|
||||||
let failure = await client.handshakeFailure(timeout: SpikeTunables.frameTimeout)
|
|
||||||
|
|
||||||
#expect(failure != nil, "端口失配 Origin 升级必须失败")
|
|
||||||
#expect(client.handshakeStatusCode == 401,
|
|
||||||
"应收 401(src/http/origin.ts:47-51),实际 status=\(String(describing: client.handshakeStatusCode)) err=\(failure.map(describeError) ?? "nil")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]
|
||||||
|
|||||||
74
ios/IntegrationTests/ProtocolFlowTests.swift
Normal file
74
ios/IntegrationTests/ProtocolFlowTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
ios/IntegrationTests/ReplayTests.swift
Normal file
79
ios/IntegrationTests/ReplayTests.swift
Normal 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.maxWSMessageBytes(16 MiB)→ 回放完整
|
||||||
|
// - 对抗变体:ESC/C0 密集输出(转义膨胀 ~6×)→ 仍完整
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import WireProtocol
|
||||||
|
|
||||||
|
@Suite("T-iOS-16 回放上限(真服务器)", .serialized, .timeLimit(.minutes(5)))
|
||||||
|
struct ReplayTests {
|
||||||
|
|
||||||
|
@Test(">1MiB 单帧回放 — 默认 1MiB 上限复现失败(known issue);Tunables 16MiB 成功")
|
||||||
|
func replayOver1MiBFailsAtDefaultLimitAndSucceedsAt16MiB() async throws {
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
|
||||||
|
// 造数据:attach(null) 后灌 >1MiB 普通输出,再 detach(PTY/ring 存活)。
|
||||||
|
let generator = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
||||||
|
try await generator.send(shellCommand(
|
||||||
|
"perl -e 'print \"a\" x \(HarnessTunables.bulkOutputBytes)'"))
|
||||||
|
let produced = try await accumulateOutputBytes(
|
||||||
|
client: generator, minBytes: HarnessTunables.bulkOutputBytes)
|
||||||
|
#expect(produced >= HarnessTunables.bulkOutputBytes)
|
||||||
|
generator.close()
|
||||||
|
|
||||||
|
// 复现分支:平台默认 maximumMessageSize(1 MiB) → 回放必失败。
|
||||||
|
let defaultClient = WSTestClient(server: server, origin: server.origin, maxMessageBytes: nil)
|
||||||
|
let defaultResult = await replayResult(
|
||||||
|
client: defaultClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes)
|
||||||
|
defaultClient.close()
|
||||||
|
if case .failure(let error) = defaultResult {
|
||||||
|
#expect(isMessageTooLongError(error),
|
||||||
|
"失败模式应为 NSPOSIXErrorDomain EMSGSIZE(40)/ENOBUFS(55),实际 \(describeError(error))")
|
||||||
|
}
|
||||||
|
withKnownIssue("默认 maximumMessageSize=1MiB 收不下 >1MiB 单帧 ring-buffer 回放 — 平台限制,修法即 Tunables.maxWSMessageBytes=16MiB(若此处'意外通过',说明平台行为变了,须重新评估)") {
|
||||||
|
_ = try defaultResult.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复分支:16 MiB → 全量回放成功,attached 采用同一 sessionId。
|
||||||
|
let bigClient = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let summary = try await replayResult(
|
||||||
|
client: bigClient, sessionId: sessionId, minBytes: HarnessTunables.bulkOutputBytes).get()
|
||||||
|
#expect(summary.adoptedSessionId == sessionId)
|
||||||
|
#expect(summary.totalOutputBytes >= HarnessTunables.bulkOutputBytes)
|
||||||
|
bigClient.close()
|
||||||
|
await killSessionBestEffort(server: server, id: sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("对抗: ESC/C0 密集输出(JSON 转义膨胀~6×)回放,16MiB 仍完整")
|
||||||
|
func escDenseReplayStillSucceedsAt16MiB() async throws {
|
||||||
|
let server = try await ServerHarness.shared.server()
|
||||||
|
|
||||||
|
let generator = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let sessionId = try await attachAndAwaitAttached(client: generator, sessionId: nil)
|
||||||
|
// perl 的 \e = ESC(0x1B):每 4 原始字节 JSON 转义成 9 字节("[0m")。
|
||||||
|
try await generator.send(shellCommand(
|
||||||
|
"perl -e 'print \"\\e[0m\" x \(HarnessTunables.escSequenceRepeats)'"))
|
||||||
|
let produced = try await accumulateOutputBytes(
|
||||||
|
client: generator, minBytes: HarnessTunables.escSequenceRawBytes)
|
||||||
|
#expect(produced >= HarnessTunables.escSequenceRawBytes)
|
||||||
|
generator.close()
|
||||||
|
|
||||||
|
let bigClient = WSTestClient(server: server, origin: server.origin)
|
||||||
|
let summary = try await replayResult(
|
||||||
|
client: bigClient, sessionId: sessionId,
|
||||||
|
minBytes: HarnessTunables.escSequenceRawBytes).get()
|
||||||
|
#expect(summary.adoptedSessionId == sessionId)
|
||||||
|
#expect(summary.totalOutputBytes >= HarnessTunables.escSequenceRawBytes)
|
||||||
|
#expect(summary.containsSoftReset, "回放应含 ESC[0m(转义序列在回放中原样保留)")
|
||||||
|
bigClient.close()
|
||||||
|
await killSessionBestEffort(server: server, id: sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
260
ios/IntegrationTests/ServerHarness.swift
Normal file
260
ios/IntegrationTests/ServerHarness.swift
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
//
|
||||||
|
// ServerHarness.swift — T-iOS-16 集成测试基础设施:真 Node 服务器自举
|
||||||
|
//
|
||||||
|
// 由 T-iOS-2 的 OriginSpikeTests.swift(591 行单文件)拆分而来(plan §7 T-iOS-16
|
||||||
|
// 明确批准 harness + suites 拆分)。本文件只管服务器生命周期:
|
||||||
|
// - spawn 一次本仓库真服务器(`node_modules/.bin/tsx src/server.ts`),全部
|
||||||
|
// suite 复用同一进程(会话彼此独立,测试并行安全);
|
||||||
|
// - 就绪探测(GET /live-sessions 返回 JSON 数组);
|
||||||
|
// - 进程回收:death-pipe watchdog + atexit 双保险(Swift Testing runner 退出
|
||||||
|
// 时不可靠地跑 atexit,watchdog 是主机制)。
|
||||||
|
//
|
||||||
|
// 运行方式(可复现):
|
||||||
|
// A. 默认自举:`swift test --package-path ios/IntegrationTests`
|
||||||
|
// repo 根由 #filePath 推导(可用 WEBTERM_REPO_ROOT 覆盖),绑定 127.0.0.1
|
||||||
|
// 的空闲端口,SHELL_PATH=/bin/bash(确定性输出)、USE_TMUX=0。无需
|
||||||
|
// ALLOWED_ORIGINS:服务器的 deriveAllowedOrigins 恒包含
|
||||||
|
// http://127.0.0.1:<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-3),Origin/wsURL 一律由
|
||||||
|
// `HostEndpoint` 单点派生(plan §5.1 铁律:禁止手拼),不再本地复刻常量。
|
||||||
|
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import WireProtocol
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ─── 常量(无魔法数字;线协议常量一律取 WireProtocol.Tunables/WireConstants) ──
|
||||||
|
|
||||||
|
enum HarnessTunables {
|
||||||
|
/// 灌入的普通输出字节数:> URLSessionWebSocketTask 默认 1 MiB(1_048_576)。
|
||||||
|
static let bulkOutputBytes = 1_500_000
|
||||||
|
/// 对抗用例:"\u{1B}[0m"(4 字节)重复次数 → 原始 1.6 MB,
|
||||||
|
/// JSON 转义后 ≈ ×2.25 ≈ 3.6 MB(>1MiB、<16MiB、<2MiB ring 容量)。
|
||||||
|
static let escSequenceRepeats = 400_000
|
||||||
|
static let escSequenceRawBytes = escSequenceRepeats * 4
|
||||||
|
/// Origin 失配用端口(若真实端口撞车则退避到 fallback)。
|
||||||
|
static let mismatchedOriginPort = 9999
|
||||||
|
static let mismatchedOriginPortFallback = 9998
|
||||||
|
static let serverReadyTimeout: Duration = .seconds(40)
|
||||||
|
static let serverReadyPollInterval: Duration = .milliseconds(200)
|
||||||
|
/// 单帧等待上限(attach→attached 等短往返)。
|
||||||
|
static let frameTimeout: Duration = .seconds(15)
|
||||||
|
/// 等待某个 shell 输出标记出现的上限(含 bash 冷启动)。
|
||||||
|
static let markerTimeout: Duration = .seconds(30)
|
||||||
|
/// 大流量累计(>1MiB 灌入/回放)的上限。
|
||||||
|
static let outputAccumulationTimeout: Duration = .seconds(90)
|
||||||
|
/// 等待服务器主动关闭 WS 的上限(kill 路径)。
|
||||||
|
static let closeObserveTimeout: Duration = .seconds(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HarnessError: Error, CustomStringConvertible {
|
||||||
|
case setup(String)
|
||||||
|
case timeout(String)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .setup(let detail): return "setup: \(detail)"
|
||||||
|
case .timeout(let detail): return "timeout: \(detail)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 服务器端点快照(不可变;派生全部走 HostEndpoint 冻结契约) ────────────────
|
||||||
|
|
||||||
|
struct TestServer: Sendable {
|
||||||
|
let endpoint: HostEndpoint
|
||||||
|
|
||||||
|
var baseURL: URL { endpoint.baseURL }
|
||||||
|
var wsURL: URL { endpoint.wsURL }
|
||||||
|
/// 白名单内的 Origin —— 由 HostEndpoint 单点派生(plan §5.1,禁止手拼)。
|
||||||
|
var origin: String { endpoint.originHeader }
|
||||||
|
var port: Int { endpoint.baseURL.port ?? (endpoint.baseURL.scheme == "https" ? 443 : 80) }
|
||||||
|
|
||||||
|
/// 显式校验 + 派生(服务器/env 是不可信输入,边界处全验证)。
|
||||||
|
static func make(baseURL: URL) throws -> TestServer {
|
||||||
|
guard let endpoint = HostEndpoint(baseURL: baseURL) else {
|
||||||
|
throw HarnessError.setup("server URL 必须是带 host 的 http(s),got \(baseURL)")
|
||||||
|
}
|
||||||
|
return TestServer(endpoint: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 服务器进程回收(death-pipe watchdog + atexit 双保险,防孤儿 tsx) ────────
|
||||||
|
//
|
||||||
|
// 实测(T-iOS-2):Swift Testing runner 退出时不跑 atexit handler(首轮运行留下
|
||||||
|
// 孤儿 tsx),所以主回收机制是 death-pipe:spawn 一个 /bin/sh watchdog,其 stdin
|
||||||
|
// 是本测试进程持有的管道写端 —— 测试进程无论以何种方式退出(正常/崩溃),写端
|
||||||
|
// 关闭 → watchdog 的 `read` 收到 EOF → kill 服务器 pid。atexit 仅作快速路径兜底。
|
||||||
|
|
||||||
|
private enum ServerProcessRegistry {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
nonisolated(unsafe) private static var process: Process?
|
||||||
|
/// 持有写端防 deinit 关闭;测试进程死亡时由内核关闭 → watchdog 触发。
|
||||||
|
nonisolated(unsafe) private static var deathPipe: Pipe?
|
||||||
|
nonisolated(unsafe) private static var installedAtexit = false
|
||||||
|
|
||||||
|
static func register(_ p: Process) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
process = p
|
||||||
|
deathPipe = try? spawnDeathWatchdog(serverPid: p.processIdentifier)
|
||||||
|
guard !installedAtexit else { return }
|
||||||
|
installedAtexit = true
|
||||||
|
atexit { ServerProcessRegistry.killNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func killNow() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if let p = process, p.isRunning { p.terminate() }
|
||||||
|
process = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spawnDeathWatchdog(serverPid: Int32) throws -> Pipe {
|
||||||
|
let pipe = Pipe()
|
||||||
|
let watchdog = Process()
|
||||||
|
watchdog.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||||
|
watchdog.arguments = ["-c", "read _; kill \(serverPid) 2>/dev/null"]
|
||||||
|
watchdog.standardInput = pipe
|
||||||
|
watchdog.standardOutput = FileHandle.nullDevice
|
||||||
|
watchdog.standardError = FileHandle.nullDevice
|
||||||
|
try watchdog.run()
|
||||||
|
return pipe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 服务器 harness(actor 单例:spawn 一次,全部 suite 复用) ─────────────────
|
||||||
|
//
|
||||||
|
// Swift Testing 默认跨 suite 并行 → 多个 suite 会并发调 server()。actor 是可重入
|
||||||
|
// 的:首个调用在 awaitReady 挂起时,后来者会重入进来 —— 若用"缓存成品"模式就会
|
||||||
|
// 重复 spawn。所以缓存的是 in-flight Task(同步写入,后来者 await 同一个)。
|
||||||
|
|
||||||
|
actor ServerHarness {
|
||||||
|
static let shared = ServerHarness()
|
||||||
|
private var bootTask: Task<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))
|
||||||
|
}
|
||||||
281
ios/IntegrationTests/WSTestClient.swift
Normal file
281
ios/IntegrationTests/WSTestClient.swift
Normal 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 MiB,plan §3.2.1);
|
||||||
|
// 只有 spike③ 的复现分支显式传 nil 保持平台默认 1 MiB。
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import WireProtocol
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// ─── WS 客户端薄封装(每测一个;不可变持有 task) ───────────────────────────
|
||||||
|
|
||||||
|
final class WSTestClient: @unchecked Sendable {
|
||||||
|
let task: URLSessionWebSocketTask
|
||||||
|
|
||||||
|
/// - Parameters:
|
||||||
|
/// - origin: nil = 不带 Origin header(守卫负路径)。带值时必须来自
|
||||||
|
/// `TestServer.origin`(HostEndpoint 单点派生)或刻意构造的失配值。
|
||||||
|
/// - maxMessageBytes: 默认 `Tunables.maxWSMessageBytes`(16 MiB);
|
||||||
|
/// nil = 保持平台默认 1 MiB(仅 spike③ 复现分支)。
|
||||||
|
init(server: TestServer, origin: String?, maxMessageBytes: Int? = Tunables.maxWSMessageBytes) {
|
||||||
|
var request = URLRequest(url: server.wsURL)
|
||||||
|
request.timeoutInterval = 15
|
||||||
|
if let origin {
|
||||||
|
// T-iOS-2 已定案的平台事实:Origin 不在 reserved-header 列表,setValue 生效。
|
||||||
|
request.setValue(origin, forHTTPHeaderField: "Origin")
|
||||||
|
}
|
||||||
|
let task = URLSession.shared.webSocketTask(with: request)
|
||||||
|
if let maxMessageBytes { task.maximumMessageSize = maxMessageBytes }
|
||||||
|
self.task = task
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 握手响应状态码(升级被拒时应为 401;成功时 101)。
|
||||||
|
var handshakeStatusCode: Int? {
|
||||||
|
(task.response as? HTTPURLResponse)?.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 经冻结契约编码后发送(客户端帧永远走 MessageCodec,不手拼 JSON)。
|
||||||
|
func send(_ message: ClientMessage) async throws {
|
||||||
|
try await task.send(.string(MessageCodec.encode(message)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收下一条文本帧;binary 帧防御性丢弃(服务器只发文本,但不信任它)。
|
||||||
|
func receiveText(timeout: Duration) async throws -> String {
|
||||||
|
try await withTimeout(timeout) { [self] in
|
||||||
|
while true {
|
||||||
|
switch try await task.receive() {
|
||||||
|
case .string(let text): return text
|
||||||
|
case .data: continue
|
||||||
|
@unknown default: continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 升级失败探测:能收到帧/正常等待 → nil;handshake 抛错 → 返回错误。
|
||||||
|
func handshakeFailure(timeout: Duration) async -> (any Error)? {
|
||||||
|
do {
|
||||||
|
_ = try await receiveText(timeout: timeout)
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
task.cancel(with: .normalClosure, reason: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTimeout<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 是 \r(0x0D),不是 \n(CLAUDE.md gotcha)。
|
||||||
|
func shellCommand(_ line: String) -> ClientMessage {
|
||||||
|
.input(data: line + "\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// attach 后等 attached 帧;容忍先到的 output/status(attachWs 在 attached
|
||||||
|
/// 之前就回放 snapshot,src/session/session.ts:158-170 / src/server.ts:721-733)。
|
||||||
|
func attachAndAwaitAttached(client: WSTestClient, sessionId: UUID?) async throws -> UUID {
|
||||||
|
try await client.send(.attach(sessionId: sessionId, cwd: nil))
|
||||||
|
let deadline = ContinuousClock.now + HarnessTunables.frameTimeout
|
||||||
|
while ContinuousClock.now < deadline {
|
||||||
|
let text = try await client.receiveText(timeout: HarnessTunables.frameTimeout)
|
||||||
|
if case let .attached(id)? = MessageCodec.decodeServer(text) { return id }
|
||||||
|
}
|
||||||
|
throw HarnessError.timeout("attach 后未收到 attached 帧")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 累计 output 帧的原始字节数(UTF-8),直到 ≥ minBytes。
|
||||||
|
func accumulateOutputBytes(client: WSTestClient, minBytes: Int) async throws -> Int {
|
||||||
|
var total = 0
|
||||||
|
let deadline = ContinuousClock.now + HarnessTunables.outputAccumulationTimeout
|
||||||
|
while ContinuousClock.now < deadline {
|
||||||
|
let text = try await client.receiveText(timeout: HarnessTunables.outputAccumulationTimeout)
|
||||||
|
guard case let .output(data)? = MessageCodec.decodeServer(text) else { continue }
|
||||||
|
total += data.utf8.count
|
||||||
|
if total >= minBytes { return total }
|
||||||
|
}
|
||||||
|
throw HarnessError.timeout("输出累计 \(total)/\(minBytes) 字节后超时")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顺序累积 output 数据、等待标记出现(标记可跨帧,故必须整段累积再查找)。
|
||||||
|
/// 值类型:每个测试自己持有一份,随用随弃(不可变风格;transcript 只增不改)。
|
||||||
|
struct TranscriptReader {
|
||||||
|
let client: WSTestClient
|
||||||
|
private(set) var transcript = ""
|
||||||
|
|
||||||
|
mutating func awaitContains(
|
||||||
|
_ marker: String, timeout: Duration = HarnessTunables.markerTimeout
|
||||||
|
) async throws {
|
||||||
|
let deadline = ContinuousClock.now + timeout
|
||||||
|
while !transcript.contains(marker) {
|
||||||
|
guard ContinuousClock.now < deadline else {
|
||||||
|
throw HarnessError.timeout(
|
||||||
|
"等 \"\(marker)\" 超时;transcript 尾部: …\(transcript.suffix(200))")
|
||||||
|
}
|
||||||
|
let text = try await client.receiveText(timeout: timeout)
|
||||||
|
if case let .output(data)? = MessageCodec.decodeServer(text) {
|
||||||
|
transcript += data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReplaySummary: Sendable {
|
||||||
|
let adoptedSessionId: UUID
|
||||||
|
let totalOutputBytes: Int
|
||||||
|
let containsSoftReset: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reattach 并接收全量回放。成功条件:收到 attached 且 output ≥ minBytes。
|
||||||
|
func replayResult(
|
||||||
|
client: WSTestClient, sessionId: UUID, minBytes: Int
|
||||||
|
) async -> Result<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[0m(src/types.ts:167-170 / M2)。
|
||||||
|
if !sawSoftReset, data.contains(WireConstants.replaySoftResetPrefix) {
|
||||||
|
sawSoftReset = true
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if let adopted, total >= minBytes {
|
||||||
|
return .success(ReplaySummary(
|
||||||
|
adoptedSessionId: adopted, totalOutputBytes: total,
|
||||||
|
containsSoftReset: sawSoftReset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .failure(HarnessError.timeout(
|
||||||
|
"回放不完整: attached=\(adopted.map(\.uuidString) ?? "无") bytes=\(total)/\(minBytes)"))
|
||||||
|
} catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读到 socket 结束为止:返回(是否见到 exit 帧,是否观察到 close)。
|
||||||
|
/// 本地等待超时(HarnessError)≠ close —— 二者显式区分,绝不混淆断言语义。
|
||||||
|
func drainUntilClose(
|
||||||
|
client: WSTestClient, timeout: Duration = HarnessTunables.closeObserveTimeout
|
||||||
|
) async -> (sawExit: Bool, closed: Bool) {
|
||||||
|
var sawExit = false
|
||||||
|
let deadline = ContinuousClock.now + timeout
|
||||||
|
while ContinuousClock.now < deadline {
|
||||||
|
do {
|
||||||
|
let text = try await client.receiveText(timeout: timeout)
|
||||||
|
if case .exit? = MessageCodec.decodeServer(text) { sawExit = true }
|
||||||
|
} catch is HarnessError {
|
||||||
|
return (sawExit, false) // 本地超时:socket 还开着
|
||||||
|
} catch {
|
||||||
|
return (sawExit, true) // receive 抛平台错误 = 流已结束(close/断开)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (sawExit, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等 exit 帧(自然退出广播路径,src/session/session.ts:146-151)。
|
||||||
|
func awaitExitFrame(
|
||||||
|
client: WSTestClient, timeout: Duration = HarnessTunables.markerTimeout
|
||||||
|
) async throws -> (code: Int, reason: String?) {
|
||||||
|
let deadline = ContinuousClock.now + timeout
|
||||||
|
while ContinuousClock.now < deadline {
|
||||||
|
let text = try await client.receiveText(timeout: timeout)
|
||||||
|
if case let .exit(code, reason)? = MessageCodec.decodeServer(text) {
|
||||||
|
return (code, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw HarnessError.timeout("未收到 exit 帧")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 超限失败模式判定(spike③ 复现分支) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// 超 maximumMessageSize 的失败模式:NSPOSIXErrorDomain 40(Darwin errno 40 =
|
||||||
|
/// EMSGSIZE "Message too long";plan §1 勘误 —— 原文误标 ENOBUFS,Darwin 上
|
||||||
|
/// ENOBUFS 实为 55,两个码都接受)。
|
||||||
|
func isMessageTooLongError(_ error: any Error) -> Bool {
|
||||||
|
func matches(_ ns: NSError) -> Bool {
|
||||||
|
ns.domain == NSPOSIXErrorDomain && (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
|
||||||
|
}
|
||||||
|
let ns = error as NSError
|
||||||
|
if matches(ns) { return true }
|
||||||
|
if let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError { return matches(underlying) }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeError(_ error: any Error) -> String {
|
||||||
|
let ns = error as NSError
|
||||||
|
let underlying = (ns.userInfo[NSUnderlyingErrorKey] as? NSError)
|
||||||
|
.map { " underlying=\($0.domain)#\($0.code)" } ?? ""
|
||||||
|
return "\(ns.domain)#\(ns.code)\(underlying): \(ns.localizedDescription)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP 助手(G 类端点带 Origin;RO GET 一律不带 —— plan §3.4 铁律) ────────
|
||||||
|
|
||||||
|
/// DELETE /live-sessions/:id(G 类,src/server.ts:354-359)。origin=nil 用于
|
||||||
|
/// 守卫负路径(应得 403)。返回 HTTP 状态码。
|
||||||
|
func deleteLiveSession(server: TestServer, id: String, origin: String?) async throws -> Int {
|
||||||
|
var request = URLRequest(url: server.baseURL.appending(path: "live-sessions/\(id)"))
|
||||||
|
request.httpMethod = "DELETE"
|
||||||
|
if let origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw HarnessError.setup("DELETE /live-sessions/\(id) 非 HTTP 响应")
|
||||||
|
}
|
||||||
|
return http.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /live-sessions(RO,无 Origin)→ 当前会话 id 列表。
|
||||||
|
func liveSessionIds(server: TestServer) async throws -> [String] {
|
||||||
|
let url = server.baseURL.appending(path: "live-sessions")
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200,
|
||||||
|
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else {
|
||||||
|
throw HarnessError.setup("GET /live-sessions 非预期形状")
|
||||||
|
}
|
||||||
|
return array.compactMap { $0["id"] as? String }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测后清理:尽力而为 —— 失败只影响宿主残留一个 bash 会话,不影响断言结果,
|
||||||
|
/// 故显式忽略错误(这是唯一允许吞错的地方,且吞的只是清理路径)。
|
||||||
|
func killSessionBestEffort(server: TestServer, id: UUID) async {
|
||||||
|
_ = try? await deleteLiveSession(
|
||||||
|
server: server, id: id.uuidString.lowercased(), origin: server.origin)
|
||||||
|
}
|
||||||
72
ios/IntegrationTests/scripts/coverage-gate.sh
Executable file
72
ios/IntegrationTests/scripts/coverage-gate.sh
Executable 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"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/// Key-name → terminal byte-string table (T-iOS-11; pure data, no I/O).
|
||||||
|
///
|
||||||
|
/// Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` —
|
||||||
|
/// the SINGLE source of truth for every label→bytes lookup on iOS: the KeyBar
|
||||||
|
/// buttons AND the hardware `UIKeyCommand` mapping both resolve through here
|
||||||
|
/// (plan §7 T-iOS-11). Hand-writing an escape sequence anywhere in the App
|
||||||
|
/// layer is a review finding.
|
||||||
|
///
|
||||||
|
/// Gotcha carried over from the web client: Enter is `\r` (0x0D, carriage
|
||||||
|
/// return), NOT `\n` — synthesized input with a linefeed breaks TUIs.
|
||||||
|
public enum KeyByteMap {
|
||||||
|
/// One key the bar/hardware mapping can send. Raw values mirror the web
|
||||||
|
/// `KEY_MAP` property names; declaration order mirrors the web table.
|
||||||
|
public enum Key: String, Sendable, CaseIterable {
|
||||||
|
case esc
|
||||||
|
case escEsc
|
||||||
|
case shiftTab
|
||||||
|
case arrowUp
|
||||||
|
case arrowDown
|
||||||
|
case arrowLeft
|
||||||
|
case arrowRight
|
||||||
|
case enter
|
||||||
|
case ctrlC
|
||||||
|
case ctrlR
|
||||||
|
case ctrlO
|
||||||
|
case ctrlL
|
||||||
|
case ctrlT
|
||||||
|
case ctrlB
|
||||||
|
case ctrlD
|
||||||
|
case tab
|
||||||
|
case slash
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact byte string to send as `ClientMessage.input(data:)` for `key`.
|
||||||
|
/// Values are verbatim from public/keybar.ts KEY_MAP (see file doc).
|
||||||
|
public static func bytes(for key: Key) -> String {
|
||||||
|
switch key {
|
||||||
|
case .esc: return "\u{1B}"
|
||||||
|
case .escEsc: return "\u{1B}\u{1B}"
|
||||||
|
case .shiftTab: return "\u{1B}[Z"
|
||||||
|
case .arrowUp: return "\u{1B}[A"
|
||||||
|
case .arrowDown: return "\u{1B}[B"
|
||||||
|
case .arrowLeft: return "\u{1B}[D"
|
||||||
|
case .arrowRight: return "\u{1B}[C"
|
||||||
|
case .enter: return "\r" // NOT "\n" (CLAUDE.md gotcha)
|
||||||
|
case .ctrlC: return "\u{03}"
|
||||||
|
case .ctrlR: return "\u{12}"
|
||||||
|
case .ctrlO: return "\u{0F}"
|
||||||
|
case .ctrlL: return "\u{0C}"
|
||||||
|
case .ctrlT: return "\u{14}"
|
||||||
|
case .ctrlB: return "\u{02}"
|
||||||
|
case .ctrlD: return "\u{04}"
|
||||||
|
case .tab: return "\t"
|
||||||
|
case .slash: return "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
160
ios/README.md
Normal 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`.
|
||||||
Reference in New Issue
Block a user