Files
web-terminal/docs/PLAN_IOS_CLIENT.md
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

1083 lines
111 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PLAN_IOS_CLIENT.md — iOS 原生客户端SwiftUI + SwiftTerm"口袋驾驶舱"
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm4 个纯 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](./TECH_DOC.md)why+ [ARCHITECTURE.md](./ARCHITECTURE.md)how桌面版先例见 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md);远程访问/中继演进见 [PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)。
> 完成情况记录在 [PROGRESS_LOG.md](./PROGRESS_LOG.md)。工作流约束见 [CLAUDE.md](../CLAUDE.md)(查 PLAN → 做子任务(TDD) → 验证 → 更新 LOG
> **G1 日志铁律**`PROGRESS_LOG.md` 由 **orchestrator 独写**,不在任何任务的 `Owns:` 里;被派的 subagent **不写 LOG**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
---
## 0. 目标与范围
### 做什么
一个 iPhone 原生 AppiOS 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 Planninggate 到达触发 haptics。
- **"离开期间发生了什么"digest**:重连后终端顶部渲染 away-digest来源 `GET /live-sessions/:id/events`)。
- **主机找得到手机**P0 复用**既有** ntfy 桥(`npm run setup-hooks` 已内置,`WEBTERM_NTFY_URL`+`WEBTERM_NTFY_TOPIC` 设置即装零新代码NEEDS-INPUT/DONEP1 换 APNs + 锁屏 Allow/Denynotification action → **Face ID/通行码确认(`.authenticationRequired`**`POST /hook/decision`,仍不启动 App UI
- **移动键位补全**native `inputAccessoryView` key-barEsc / Shift+Tab / 方向 / Ctrl-C…字节表逐字节复刻 `public/keybar.ts`+ 硬件键盘 `UIKeyCommand`
- **配对即用**:扫 web UI 的 QR`qr.ts` origin 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](./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 `liveactivity` pushP1 之后才有条件)。
- **后台常驻 WS**iOS 切后台数秒即 suspend做不到也不装能做到设计基石就是 foreground-reattach。
- **前端/服务器重构**`public/``src/` 一行不改(触点见 §0.3)。
### 已被验证掉的大风险(关键复用点,引现有代码为证)
服务器**早就为"多客户端、断线重连"设计好了**iOS 客户端只是又一个说同一协议的端:
```ts
// 会话/连接解耦: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=lowenv 已被 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_auth` cookiew5-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 AppSwiftUI─────────────────────────────┐
│ │
│ 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 716118per-session 常驻连接是在跟平台打一场必输的仗,还会制造大量"看着连着其实死了"的僵尸状态。前台会话独占唯一活 WS切会话 = detach + attach服务器回放 ring-buffer切换成本 ≈ 0后台会话的"有没有新动静"靠 `/live-sessions` 轮询P0→ APNsP1。这与服务器的 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-171JSON 会把控制字节 `\uXXXX` 转义膨胀 16×src/protocol.ts:186最坏 ≈ 6 × SCROLLBACK_BYTES默认 2 MiB——必须设 `Tunables.maxWSMessageBytes = 16 MiB`(≥ 6×默认 + 帧包络)**;即便如此 SCROLLBACK_BYTES 是服务器 env 可调、客户端运行时无法得知(协议无 config 握手),**超限时 `receive()` 以 NSPOSIXErrorDomain code 40EMSGSIZE"Message too long"T-iOS-2 spike 实测勘误——原文误标 ENOBUFSDarwin ENOBUFS=55归类以 40 为主、55 兜底)失败——必须归类为不可重试的 `connection(.failed(.replayTooLarge))` 显式错误态并给可操作话术("服务器 scrollback 超过客户端上限,请调低 SCROLLBACK_BYTES 或调高客户端上限"),绝不喂进 backoff 重连循环**(否则确定性无限重试);**`receive()` 一次只交付一条消息,必须循环 re-arm**,忘了就静默断流;**没有自动 ping25 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.1SessionCore/HostRegistry/APIClient/TestSupport 全部只 import WireProtocol叶子包之间零耦合W0 的 TestSupport 就能编译。
**为什么 v1 只做 LAN + Tailscale、不碰 relay**Tailscale iOS App 是系统级 VPNNetwork 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](./PLAN_NATIVE_TUNNEL.md) 引入,
> 不属于本计划)。`App/WebTerm/` 也比本文的示意树多出 `DesignSystem/`、`Wiring/`、`Push/` 三个目录
> (分别来自 UX 打磨、iPad 适配/接线、P1 推送)。依赖方向仍严格单向向下,`WireProtocol` 仍是唯一冻结契约。
**构建管线**(本地与 CI 同路径):
1. `brew install xcodegen && cd ios && xcodegen generate``WebTerm.xcodeproj`(不入库)。
2. 各包独立测试:`swift test --package-path ios/Packages/<Pkg>`(纯 mac 侧,无模拟器,秒级)。
3. App 构建:`xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm -destination 'platform=iOS Simulator,name=iPhone 16' build test`
4. 集成CImacOS 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`
```swift
// 伪代码 —— 不可变、显式错误处理(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` — 连接核心
```swift
// 伪代码 —— 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 | §1URLSessionWebSocketTask 无自动 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 转义系数16×控制字节→`\uXXXX`src/protocol.ts:186SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`§3.2 / T-iOS-9/10 |
### 3.3 `HostRegistry`
```swift
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`
```swift
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-04T-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`(独立 VMT-iOS-14消费同一 events 流的 `.gate/.digest`T-iOS-15 接线。Swift 6 strict concurrency 编译期强制 socket→main 的 hop。`scenePhase == .active``engine.notifyForegrounded(dims:)`(重连 + 补发 resizelatest-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 类内。
- **文件 200400 行典型,硬上限 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` envsrc/config.ts:187-226。因此
- App 必须在 **(a) WS 升级**(否则 401src/server.ts:646-651**(b) 所有 `G` 类变更 HTTP**(否则 403src/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/portsrc/http/origin.ts:31-51https 默认端口写不写 `:443` 均匹配WHATWG URL 把默认端口规范化为空串双向对称。PairingError 话术只引导"加与拨号 URL 一致的 `ALLOWED_ORIGINS`",不引入端口迷信。
### 5.2 ATS 与 Local NetworkInfo.plist 精确键,只开最小口子)
```yaml
# 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-environment` **entitlement**,故它走 `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` 的 capability `token` **只经 push payload 到达、用后即弃**(服务器侧本就单次有效+过期src/server.ts:503-525限频 10/min/IPApp 端绝不落盘。
- 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 MITMOrigin 校验对此零防护):
| 目标 | 提示 |
|---|---|
| https/wss | 无 |
| ws:// → 100.64.0.0/10 或 *.ts.netMagicDNS | 无明文警告WireGuard 网络层已加密);可选正向"经 Tailscale 加密"徽标 |
| ws:// → loopback | 无 |
| ws:// → RFC1918/link-local | **非阻断明文提示**:流量未加密、键击可被同网嗅探,仅限可信 LAN优先 `tailscale serve`wss |
| http/ws → 公网(非 RFC1918https 也含在"公网 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](./DEPLOY_RELAY.md)、[PLAN_RELAY_RUN_PHASE0.md](./PLAN_RELAY_RUN_PHASE0.md)。更关键的relay-e2e 的密码学X25519 握手、HKDF 方向分key、`nonce=f(seq)` 确定性 AEAD、epoch-in-key 回放)刚经 99-agent 对抗审计修完 F1F6——**用 Swift 重写 = 重开每一个已关闭的 finding且一条既有回归测试都带不走**F6 恰是第一轮审计漏掉的那类交互)。故 v1 远程 = LAN + Tailscalerelay 上线后先评估 WKWebView 嵌 relay-web字节级复用已审计 TSSwift 移植仅在有跨语言 KAT + 独立审计预算时考虑。
---
## 6. 多 Agent 并行规则与波次
并行三条铁律(同 [PLAN.md](./PLAN.md) §0违反会互相踩踏
1. **文件所有权独占**:每任务 `Owns:` 独占创建/修改权。`ios/Packages/WireProtocol/**`**T-iOS-3 独占并冻结**,其余任务只读 import要加共享类型 → 回 T-iOS-3 改契约,不得各自另立。
2. **只依赖接口不依赖实现**`Depends:` 指"需要对方接口/产物"§3 的签名在 W0 冻结,故多数任务可与依赖方并行编码,集成时汇合。
3. **G1**LOG 由 orchestrator 独写subagent 返回可粘贴条目(见页首)。
subagent **不能问用户、不能互相通信**:遇到本文+ARCHITECTURE+TECH_DOC 未定义的歧义 → 停下返回 `[!] BLOCKED`**绝不猜**。单批并行控制在 **~35 个 agent**。验收任务 report-onlyG4findings 标 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/**` **10** `@Test` + `scripts/coverage-gate.sh` + `ios.yml` 六个 job含 iPad 单测腿/iPad UI 腿/iOS-17 底线腿)。**未核实**GH Actions 平台侧的运行结果(本环境 `gh` 未登录) |
| 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` 尚未在**产物层**复核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 零 findingsLOG 条目)。**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 | `[~]` | `DesignSystem/{AppTheme,TerminalPalette}.swift``Screens/SettingsScreen.swift``Tokens/Typography` 浅色档 + `AppThemeTests`(24)/`DynamicTypeLayoutTests`(8)。**缺口(已实测、未修)**AX5 下键栏需 ~108.24pt 而 `KeyBarMetrics.barHeight` 固定 52pt → 键帽裁切,以 `withKnownIssue` 钉住(`KeyBar.swift` 不在该任务 Owns |
| T-iOS-35 web `?join=` 互通 | `[x]` | `DeepLinkRouter.swift``.joinShared` 分支 + `DeepLinkJoinTests` **19**(含改写后的既有拒绝用例) |
| T-iOS-36 P2 验收 | `[~]` | 本波 Wave D 的验收 agent 正在执行;**结果与真实数字以 `PROGRESS_LOG.md` 的该条目为准**,本文不预写结论 |
**说明**:上表的"测试数"是 `@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 包commit `a5fa843`)。
- **CI 三条死腿修复**app/iPad 腿缺 `npm ci` 导致 `LiveServerSmokeTests` 硬失败iOS-17 腿在缺 runtime 时静默报绿;
另补 iPad UI-test 腿(同 commit
- **签名解锁**`DEVELOPMENT_TEAM` + target 级 `CODE_SIGN_IDENTITY` + `WEBTERM_PUSH_ENTITLEMENTS` env 开关
commit `c4f8b5b`;真机构建在免费 team 上 `BUILD SUCCEEDED`7 天临时 profile
- **已知未闭合缺口(供后续任务领走)**:① AX5 键栏裁切(见 T-iOS-34 行);② **无端到端令牌腿**——
`IntegrationTests` 与模拟器内 live-server smoke 都不带 `WEBTERM_TOKEN` 起服务器grep 无 `WEBTERM_TOKEN`/`webterm_auth`
令牌路径目前只有单元级覆盖;③ T-iOS-19 的产物层复核未覆盖 P2 新增的两条 usage description
④ worktree 建/删的**端到端**一次T-iOS-32 Accept既无自动化腿也未手工跑。
### 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 targetiOS 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` 产物 gitignoreCI 骨架跑 `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 种 `ClientMessage` encode 后的 JSON 与服务器 `parseClientMessage` 接受的形状逐键一致(`attach` 必含 `sessionId`null 也要显式src/protocol.ts:132-134
- [ ] `decodeServer` 5 种合法帧解出对应 case`attached` 的 sessionId 非 UUID → nil
- [ ] 非法帧全表 → nil 且不 throw坏 JSON、未知 type、`resize` cols=0/1001/非整数、`input.data` 非 string、缺字段
- [ ] `Validation.isValidSessionId`:合法 UUID v4 过;`abc123`、UUID v1、大写混合按服务器正则同判向量从 `test/protocol.test.ts` 移植)
- [ ] `isAbsoluteCwd("/a")==true``("a")==false``("")==false`
- [ ] `telemetry` 帧全可选字段缺省可解、`at` 缺失 → nil
- [ ] roundtrip property任意合法 ClientMessage `encode→(服务器视角)decode` 不变形——**`approve.mode` 除外**:服务器 `parseClientMessage` 刻意丢 mode、对一切 approve 帧返回裸 `{type:'approve'}`src/protocol.ts:77-79mode 由 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 + `/term`
- [ ] `TimelineEvent` 解码:合法条目 + 未知 `class` → nil消费方丢弃该条
- **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-4FakeClock · **Parallel-safe**: T-iOS-6/7/8
- **Steps(测试先行, RED)**:
- [ ] 断线序列 → 重试延迟 1s,2s,4s,8s,16s,30s,30s封顶
- [ ] `connected` 输入 → backoff 归零,下次断线从 1s 重来
- [ ] `foregrounded`/`userRetry` → 立即 `connectNow`,不等定时器
- [ ] reduce 是纯函数同输入同输出原值不被改Equatable 断言旧快照)
- [ ] PingScheduler25s 触发 ping1 次 pong 丢失容忍、连续 2 次 → 发出 disconnected 信号FakeClock 推进驱动,测试 0 真实等待
- **Steps(实现, GREEN)**: [ ] §3.2 签名 [ ] 常量一律读 `Tunables`WireProtocolT-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 reduceevents 里 3 tool + 1 waiting + done → `{toolRuns:3, waitingCount:1, sawDone:true}`
- [ ] `since` 过滤:早于离开时刻的事件不计入
- [ ] `limit` 截断 recent空 events → 全零 digestUI 可据此不渲染)
- **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` 逻辑对 fake `SecItemShim`add/update/delete 走对分支、错误码显式映射(**不可**在 `swift test` 直连真 Keychain——unsigned 测试二进制对 data-protection keychain 必得 `-34018 errSecMissingEntitlement`;省掉 `kSecUseDataProtectionKeychain` 又会落到 legacy 文件 keychain、验证不了 §5.3 语义)
- [ ] lastSessionId 存取/清除
-`HostEndpoint` originHeader/wsURL 派生向量已移入 T-iOS-3 的 WireProtocol 测试——本包不再定义该类型)
- **Steps(实现, GREEN)**: [ ] `SecItemShim` 协议 + 真实现(`kSecUseDataProtectionKeychain` + AfterFirstUnlockThisDeviceOnly[ ] UserDefaults LastSessionStore [ ] 真 Keychain 路径 + `kSecAttrAccessible` 属性断言放 **xcodebuild 模拟器测试**(签名宿主 AppT-iOS-15/16 管线)
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9KeychainHostStore 以 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服务器视为不可信
- [ ] `hookDecision` body 形状 `{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`
- **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment按端点精确`hookDecision` body ≤ 4 KBsrc/server.ts:503、≤10 次/分/IPsrc/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-2spike 结论、T-iOS-3 · **Parallel-safe**: T-iOS-10接口并行
- **Steps(测试先行, RED)** — 对 in-process 本地 WS echo测试内起 `NWListener` 或复用 IntegrationTests 服务器):
- [ ] 升级请求含 `Origin` header 且等于 endpoint.originHeader
- [ ] `maximumMessageSize == Tunables.maxWSMessageBytes`16 MiB——直接断言 task 配置
- [ ] receive 循环持续 re-arm连发 100 帧全部到达、顺序不乱
- [ ] 服务器关闭close frame→ frames stream finish错误 → stream throw两种可区分
- [ ] receive 失败 NSPOSIXErrorDomain code 40EMSGSIZE"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 spawnsrc/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``/`
- [ ] VMengine 发 `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`delegate `send``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.2 `NSCameraUsageDescription`)读 web UI `qr.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:45 `STATUSLINE_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` → 三选一 sheetApprove+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 降级影响(降级只打 raw `auto`src/server.ts:765-766raw `auto` 仅保留给未来权限模式切换器§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-1114 全部 · **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-1115/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 mirrorA、B 同 attachA inputB 收 output
- [ ] 无 Origin → 401错 Origin → 401DELETE 无 Origin → 403G 守卫回归)
- [ ] `DELETE /live-sessions/:id`kill→ 镜像客户端观察到 **WS close****不是** `exit` 帧——`manager.killById``ws.close()` 全部客户端再 killsrc/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
- **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-238env :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-onlyG4只报告不改码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-20payload 形状、T-iOS-23 依赖 T-iOS-37lastOutputAt 字段),其余 W7 任务T-iOS-22/24/25/27/28/29不等 W6T-iOS-38APIClient 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 的双 e2eenv 三件套缺失即整体 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-525APNs payload 不含命令内容
#### T-iOS-37 · server: `LiveSessionInfo.lastOutputAt` 字段 `[x]` · ~0.25 pd
- **Wave**: W6 · **Owns**: `src/types.ts` `LiveSessionInfo` 增量字段`src/session/manager.ts` `list()` 一行映射并更新其 "omitted by design" 注释)、对应测试增量**声明的服务器触点TypeScript 任务**遵循根仓库 PLAN 工作流 §0.3
- **Depends**: · **Parallel-safe**: T-iOS-20W7 全部
- **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 /prefs` builders 与解码 + Tests)——W7 期间 APIClient 文件**只有本任务可改**对齐 T-iOS-3 冻结契约模式避免 T-iOS-21/26 同波踩踏
- **Depends**: T-iOS-8T-iOS-20 token 注册 builder 的端点形状——可接口先行并行编码形状定稿时汇合 · **Parallel-safe**: T-iOS-20/22/23/24/25/27/28/29均不碰 APIClient 文件
- **Steps(测试先行)**: [ ] token 注册 builderG Origin[ ] projects/detail/prefs buildersPUT Origin与解码 [ ] doc comment 端点约束push subscribe body 8 KB、≤5 //IPsrc/server.ts:73,461-466`PUT /prefs` 64 KBsrc/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` + 对应 TestsAPIClient token 注册 builder T-iOS-38
- **Depends**: T-iOS-20payload 形状)、T-iOS-38token 注册 builder · **Parallel-safe**: T-iOS-2229
- **Steps(测试先行)**: [ ] `UNNotificationCategory` Allow/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 targetService 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 标题经 SwiftTerm `setTerminalTitle` delegate 上浮到列表——**标题是主机/攻击者可控输入,入列表前过净化器**:截断 `Tunables.titleMaxLength`(256);剥 Unicode 双向覆写与零宽字符U+200B200F、U+202A202E、U+20662069——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 状态才浮出对齐 web `quick-reply.ts` 行为
#### T-iOS-26 · Projects列表 + 详情 + 在仓库起 Claude `[x]` · ~2 pd
- **Wave**: W7 · **Owns**: `App/WebTerm/Screens/{ProjectsScreen,ProjectDetailScreen}.swift` + VM Testsprojects/detail/prefs APIClient builders T-iOS-38本任务只消费
- **Depends**: T-iOS-38builders · **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 pdreport-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.5T-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`](./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 Testsepoch 防误发若需 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 键顺序/标签零变化+ `VoicePTTTests` 40 转写清洗 / 匹配器 / 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` + TestsAPIClient 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 确认绝不自动重试)、`--resume` id 白名单后才拼命令行`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)。默认仍是深色零回归)。
- **缺口已实测未修**: AX5 下键栏两行需 **108.24pt**`KeyBarMetrics.barHeight` 固定 **52pt**44+8)→ 键帽裁切 `withKnownIssue` 钉在 `DynamicTypeLayoutTests`修好后该用例会报 "known issue was not recorded"提醒删标记)。`Components/KeyBar.swift` 不在该任务 Owns需另派
- **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` 分支 + `DeepLinkJoinTests` 19 含把原"scheme 不是 webterminal"的拒绝理由改写为"web 形状只许单一 `join` "主机身份只经 `HostStore`+`HostEndpoint.originHeader` 解析未配对 origin 一律走配对页且 hint 不回显链接内容
- **DEFERRED**: 用真手机相机扫 web 分享 QR 的目视走查模拟器无相机)。
- **T-iOS-36** · P2 验收 `[~]` ~1 pdreport-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-3135
- **缺口**: ios-completion 收尾波的 Wave D 验收 agent 执行中**真实数字与结论以 `PROGRESS_LOG.md` 的该条目为准**本文不预写"通过"。
**总计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-1114 |
| 8 | T-iOS-18 T-iOS-19 | 验收 report-only |
| P1 | T-iOS-20 T-iOS-37 T-iOS-38 其余 W7 并行 21 20/3823 3726 38 | W6 W7 不整体串行 P1 波次注 |
> 单批 ~35 agent 甜区worktree 前提:**W0T-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-2129 | builder | sonnet(28 haiku) | worktree | 常规并行 |
| T-iOS-30/36 验收 | reviewer | sonnet | | report-only |
| T-iOS-3135P2 | 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%。
```bash
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 的常驻化
### 设备矩阵
| | 环境 |
|---|---|
| 单元/ | macOSswift test无模拟器 |
| App/XCUITest | iPhone 16 模拟器iOS 26 SDK+ iOS 17 最低目标模拟器各一轮 |
| 真机必测项 | 键盘/IME/key-barQR 扫码hapticsLocal 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-barEsc/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 通知P0P1 换 APNs 后:锁屏长按 → Allow → **Face ID/通行码确认(`.authenticationRequired`** → 不开 AppMac 侧放行Face ID 设备上仍是两次手势 + 一瞥Deny 无需解锁)。
- **F-iOS-12** 负路径:服务器未白名单该 Origin → 配对页出现可操作话术(含 ALLOWED_ORIGINS 提示Local Network 被拒 → 引导话术。
- **F-iOS-13** 列表 swipe-to-kill → 会话消失Mac 侧 PTY 确认被杀exited 会话点开见末屏 + exit 横幅。
- **F-iOS-14**P1push 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 必死 → 后台会话信号有轮询延迟 | 中 | 设计即前台单 WSP1 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 |
**待你拍板的开放项**
1. ~~Bundle id / 产品名 / 图标~~ **已定**`com.yaojia.webterm` / WebTerm / "Orbit" 图标(`project.yml`
deep-link scheme `webterminal://` 已注册并在用,另接受 web 的 `http(s)://…/?join=` 形状T-iOS-35
2. ~~Apple 付费开发者账号何时开~~ **现状已定、后果已量化**:用的是**免费个人 team**`DEVELOPMENT_TEAM=C738Z66SRW`)。
真机构建可用7 天临时 profile`-allowProvisioningUpdates`,实测 `BUILD SUCCEEDED`
**Push Notifications capability 免费 team 不支持** —— `aps-environment` 因此做成 `WEBTERM_PUSH_ENTITLEMENTS`
env 开关(默认不挂;挂上则真机构建报
`Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability`)。
**APNs 真机端到端 + TestFlight 仍待付费账号**release 构建还需把 `aps-environment` 翻成 `production`
操作细节见 [`ios/README.md`](../ios/README.md#signing-device-builds-and-the-push-entitlements-switch)。
3. ~~最低系统版本~~ **已定**iOS **17.0**`project.yml` `deploymentTarget`CI 另有一条 iOS-17 底线腿。
4. ~~P0 的 ntfy 桥要不要做成默认关闭~~ **已解决**:桥已随 `npm run setup-hooks` 出货且默认关闭(`WEBTERM_NTFY_URL`/`WEBTERM_NTFY_TOPIC` 未设即完全无副作用——无需新做T-iOS-17 只验证/写文档。
5. ~~Tailscale 场景是否直接推荐 `tailscale serve`~~ **已定**:推荐 `tailscale 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 任务不越界改 serverT-iOS-20 作为 server 任务遵循根仓库工作流。
- 冲突裁决:**ARCHITECTURE 管 how、TECH_DOC 管 why/scope**;本文是它们之上的"原生客户端"新层,不改协议/会话模型只消费之。§0/§3 引用的 `file:line` 线协议事实以**代码现状**为准——若代码演进导致引用失效,更新本文并记 LOG。
- 与 [DESKTOP_PLAN.md](./DESKTOP_PLAN.md) 平行desktop 是"内嵌服务器的壳"iOS 是"纯远端客户端"——二者都不改动核心,互不依赖。
- 与 relay 计划族([PLAN_RELAY_INDEX.md](./PLAN_RELAY_INDEX.md)的边界v1 明确不接 relay§5.5relay 可部署后,接入方案另立计划文档,不回改本文任务。