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