Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -2,7 +2,10 @@
> 落地方案文档。目标:给 web-terminal 做一个 **iPhone 原生 App**,把 vibe-coding 的"走开—被叫回—两次手势处理完"闭环装进口袋。
> 拓扑/框架选型:**Phased-Native —— SwiftUI + SwiftTerm4 个纯 SwiftPM 包 + 薄 App 胶水;前台会话单条活 WS其余 HTTP 轮询服务器零改动P0 零触点P1 仅声明的附加触点,见 §0.3**。
> 状态:**规划中2026-07-04未开工**。
> 状态:**已交付并合入 `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**,而是在最终返回消息末尾附上**可直接粘贴的日志条目**(状态 / 改动文件与函数 / 验证命令+结果 / 决策与偏差 / 阻塞 / 下一步),由主会话统一追加。
@@ -62,6 +65,11 @@
除此之外**服务器 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. 整体架构 / 进程模型
@@ -156,6 +164,11 @@ web-terminal/
└── 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 侧,无模拟器,秒级)。
@@ -420,13 +433,19 @@ NSCameraUsageDescription: "扫描 web 终端的配对二维码" # P0 必需:T-
```
> 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 列表(含未来 authMaterial 占位)——`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`,不进 iCloud 同步。
- **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 必经配对流程。
@@ -490,15 +509,100 @@ W5 验收(report-only, 并行)
## 7. 任务清单
> 状态图例:`[ ]` TODO · `[~]` 进行中 · `[x]` 完成 · `[!]` 受阻。ID 稳定,永不重编号。
> 状态图例:`[ ]` 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
> 覆盖率验证命令(各包):`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/**` **32** `@Test`10 基线 + 8 令牌端到端 + 8 令牌策略漂移守卫 + 6 worktree 生命周期端到端)+ `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 零 findingsLOG 条目)。**DEFERRED**:真机锁屏/unread 目视等,手工清单在 LOG |
| T-iOS-31 语音 PTT | `[x]` | `Components/{VoicePTT,VoicePTTBanner,SpeechDictation}.swift` + `KeyBar` 🎤 键 + `VoicePTTTests` **40** `@Test`。**DEFERRED**:真机口述→确认→注入 |
| T-iOS-32 Worktree + `--resume` 历史 | `[x]` | `Screens/{WorktreeSheet,ResumeHistorySheet}.swift``ViewModels/{WorktreeViewModel,ResumeHistoryViewModel}.swift` + `WorktreeViewModelTests`(22)/`ResumeHistoryViewModelTests`(12)/`ProjectResumeLaunchTests`(7)。**Accept 的"端到端一次"已闭合**(收尾波):`IntegrationTests/WorktreeLifecycleTests.swift` **6 例**在临时 git fixture 仓上对真服务器真建/真删/真 prune——断言的是服务端**推导**出的路径与分支(`feature/e2e-worktree` → 目录 `feature-e2e-worktree`,原始分支名绝不出现在路径里),并用 `git worktree list --porcelain``.git/worktrees/` 双向核对;含 G 路由纪律的差分腿(无 `Origin` → 403 且零副作用,同一请求加 `Origin` → 200与非法分支名不创建;`GET /sessions` 用 **HOME 隔离**的专用服务器逐字段解码(否则会打到开发者真实 Claude 历史CI 上又退化成 `[]` 什么都证明不了) |
| 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 · XSXL 逐点仍 52pt 零回归 · 未封顶 AX5 溢出旧固定高的护栏)。**取舍**:键帽字体封顶在 `DS.Typography.numericClamp`(.accessibility2),故 AX3AX5 的字号不再增长(不封顶时 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 包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
- **收尾波2026-07-30把四条全部闭合**:④ worktree 端到端 → 见 T-iOS-32 行;① AX5 键栏裁切 → 已修(见 T-iOS-34 行);
② 端到端令牌腿 → `IntegrationTests/AccessTokenGateTests.swift` **8 例**对真服务器(带 `WEBTERM_TOKEN` 自举)
跑通:对令牌放行 / 错令牌 401 终态且 **connect 计数 == 1**(另有可重试失败的对照组证明"零重试"不是计时假象)/
令牌不替代 Origin合法 cookie + 外域 Origin 仍拒)/ `POST /auth` 的 204+Set-Cookie、401、204-无-Set-Cookie 三态;
另加 `TokenPolicyDriftTests.swift` **8 例**漂移守卫(运行期读 `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 工程 `[ ]` · ~0.5 pd
#### 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**:
@@ -508,7 +612,10 @@ W5 验收(report-only, 并行)
- **Accept**: `xcodegen generate && xcodebuild … build` 通过;空 App 在模拟器启动
- **安全注**: ATS 键从本文 §5.2 逐字誊写,不许"先放开回头再收"。
#### T-iOS-2 · Day-1 双 spike评审强制`[ ]` · ~1 pd
#### 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 本身就是测试)**:
@@ -517,7 +624,7 @@ W5 验收(report-only, 并行)
- **Accept**: 4 条自动化断言绿(其中 ③ 的"复现失败"分支用 `withKnownIssue` 记录);真机清单逐项有结论
- **安全注**: 这是对 "URLSessionWebSocketTask 可发自定义 Origin"MED 置信度)的一锤定音;若失败 → `[!] BLOCKED`orchestrator 决策切 Starscream 备胎,**不得自行引入依赖**。
#### T-iOS-3 · `WireProtocol` 契约包(冻结,含共享 I/O 边界类型)`[ ]` · ~1.25 pd
#### 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`:
@@ -535,7 +642,7 @@ W5 验收(report-only, 并行)
- **Accept**: `swift test --package-path ios/Packages/WireProtocol` 全绿;覆盖率 ≥ 80%
- **安全注**: 服务器是不可信输入源——decode 对模糊输入永不 crash用随机字节 fuzz 一轮)。
#### T-iOS-4 · TestSupport 测试替身 `[ ]` · ~0.25 pd
#### 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 才存在)
@@ -543,7 +650,7 @@ W5 验收(report-only, 并行)
#### W1 · 叶子包(全部并行)
#### T-iOS-5 · `ReconnectMachine` + `PingScheduler` `[ ]` · ~0.5 pd
#### 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)**:
@@ -555,7 +662,7 @@ W5 验收(report-only, 并行)
- **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 `[ ]` · ~0.5 pd
#### 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)**:
@@ -567,7 +674,7 @@ W5 验收(report-only, 并行)
- [ ] `limit` 截断 recent空 events → 全零 digestUI 可据此不渲染)
- **Accept**: 对应 filter 全绿reducer 纯函数、不可变
#### T-iOS-7 · `HostRegistry` 包 `[ ]` · ~0.5 pd
#### 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` 缝测:
@@ -580,7 +687,7 @@ W5 验收(report-only, 并行)
- **Accept**: `swift test --package-path ios/Packages/HostRegistry` 全绿(覆盖率计法见 §9KeychainHostStore 以 shim 注入计入)
- **安全注**: Keychain 属性错一个字 = 凭据可被备份带走review 时对照 §5.3。
#### T-iOS-8 · `APIClient` 包 + 配对探针 `[ ]` · ~1 pd
#### 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`:
@@ -598,7 +705,7 @@ W5 验收(report-only, 并行)
#### W2 · 连接核心
#### T-iOS-9 · `URLSessionTermTransport` `[ ]` · ~1 pd
#### 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 服务器):
@@ -613,7 +720,7 @@ W5 验收(report-only, 并行)
- **Accept**: filter 全绿T-iOS-16 的真服务器测试是它的最终验收
- **安全注**: Origin 单点取自 `HostEndpoint.originHeader`,本文件出现字符串拼接 origin = review CRITICAL。
#### T-iOS-10 · `SessionEngine` actor `[ ]` · ~1.5 pd
#### 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
@@ -633,7 +740,7 @@ W5 验收(report-only, 并行)
#### W3 · UI 胶水(全部并行;只依赖 §3 接口 + 替身)
#### T-iOS-11 · `TerminalScreen` + `KeyBar` `[ ]` · ~1 pd
#### 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)**:
@@ -645,7 +752,7 @@ W5 验收(report-only, 并行)
- **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`[ ]` · ~0.5 pd
#### 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 测过,这里测状态映射):
@@ -657,7 +764,7 @@ W5 验收(report-only, 并行)
- **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`[ ]` · ~1 pd
#### 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:
@@ -670,7 +777,7 @@ W5 验收(report-only, 并行)
- **Steps(实现, GREEN)**: [ ] 下拉刷新 [ ] host 切换 header [ ] 空态(无会话/未配对)
- **Accept**: VM 测试全绿
#### T-iOS-14 · `GateBanner` + `PlanGateSheet` + `AwayDigestView` `[ ]` · ~1 pd
#### 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)**:
@@ -683,14 +790,14 @@ W5 验收(report-only, 并行)
#### W4 · 集成(汇合点)
#### T-iOS-15 · App 接线 + 生命周期 `[ ]` · ~0.5 pd
#### 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 服务器)`[ ]` · ~0.5 pd
#### 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
@@ -705,7 +812,8 @@ W5 验收(report-only, 并行)
- **Accept**: CI job 绿覆盖率门**演示过一次红**故意把某包压到 80% 下或抬高阈值再回绿这是"客户端复刻的协议契约"的持续防漂移闸门
- **安全注**: 本任务是 §5.1 的自动化化身任何"为了过 CI 放宽 Origin 断言"= CRITICAL
#### T-iOS-17 · ntfy 桥验证 + 文档P0 临时通知,**零新代码**`[ ]` · ~0.1 pd
#### 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 经事件总线补上
@@ -714,10 +822,12 @@ W5 验收(report-only, 并行)
#### W5 · 验收report-onlyG4只报告不改码findings 标 owning task 派回)
#### T-iOS-18 · 验收走查 F-iOS-1…13真机`[ ]` · ~0.25 pd
#### 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
#### 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 豁免)、**真机核切后台开切换器 快照卡片是遮罩非终端内容**。
@@ -729,68 +839,70 @@ W5 验收(report-only, 并行)
> 波次:**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 注册端点 `[ ]` · ~2 pd
#### 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` 字段 `[ ]` · ~0.25 pd
#### 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`[ ]` · ~0.5 pd
#### 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 `[ ]` · ~1.5 pd
#### 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 `[ ]` · ~1 pd
#### 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 标题)`[ ]` · ~2 pd
#### 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完整时间线钻取`[ ]` · ~1 pd
#### 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 + 常用语面板 `[ ]` · ~1.5 pd
#### 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 `[ ]` · ~2 pd
#### 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 查看器(只读)`[ ]` · ~1.5 pd
#### 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`[ ]` · ~1.5 pd
#### 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 + 退出会话清理 `[ ]` · ~1 pd
#### 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 验收 + 安全复核 `[ ]` · ~1 pdreport-only
#### 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 相应减负)。
@@ -800,13 +912,25 @@ W5 验收(report-only, 并行)
### 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 防误发`[ ]` ~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
- **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 测试 + 端到端一次
- **T-iOS-33** · 终端内搜索 `[ ]` ~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**: 亮暗主题 + 最大字号不破版
- **T-iOS-35** · web `?join=` 互通 QR 双向`[ ]` ~0.5 pd。**Wave**: W9 · **Owns**: `DeepLinkRouter.swift` 增量`?join=` 解析)· **Depends**: T-iOS-22 · **Accept**: 手机扫 web 分享 QR 直达同会话
- **T-iOS-36** · P2 验收 `[ ]` ~1 pdreport-only)。**Wave**: W10 · **Owns**: 无源码 · **Depends**: T-iOS-3135
- **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)。默认仍是深色零回归)。
- **缺口已闭合收尾波2026-07-30**: `KeyBarMetrics.barHeight` 从常量 52pt 改为 `max(minHitTarget, keycapHeight(category)) + sm8``intrinsicContentSize`/init frame/`apply(contentSizeCategory:)` 三处同源并经 iOS 17 `registerForTraitChanges` 支持运行期改档`withKnownIssue` 已删。**取舍**键帽字体封顶 `.accessibility2`不封顶时 AX5 108.24pt会把终端吃掉沿用 DS 对密集内容的既有策略实测条高 XSXL 52 / XXL 55 / XXXL 59 / AccM 68 / AccLAX5 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` 分支 + `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 人天。**
@@ -863,7 +987,15 @@ W5 验收(report-only, 并行)
每任务**RED** Steps(测试) 先写失败测试)→ **GREEN**最小实现过测)→ **REFACTOR**对照 §4 清单)。测试命名讲行为`test("未知 UUID attach 后采用服务器新发的 id")`AAA 结构
### 覆盖率门(≥ 80%,只量 4 个包)
### 覆盖率门(≥ 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
@@ -931,9 +1063,16 @@ XCUITest 只保一条 happy path配对→attach→输入→gate approve
| 单 WS 设计与多会话切换器P1的张力 | 低 | 切换 = 重放恢复,成本近零;若实测不适再评估观察者 WS |
**待你拍板的开放项**
1. Bundle id / 产品名 / 图标App 叫什么?`webterminal://` scheme 是否可用/要改名?)
2. Apple 付费开发者账号($99/年)何时开——决定 P1 APNs 与 TestFlight 分发起点P0 期间接受自签 sideload
3. 最低系统版本iOS 17可分发下限还是 18/26个人工具availability 噪音最小)?
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)。

View File

@@ -1,8 +1,13 @@
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1 已交付,分支 `feat/ios-client`**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1+P2 已交付,**已合入 `develop`****原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
> 状态:**规划中2026-07-05未开工**
> 状态:**已交付并合入 `develop`**T-iPad-1…4 全部落地T-iPad-5 验收 PASS_WITH_FINDINGS4/4 findings 已修,真机项 DEFERRED
> 逐任务状态见 §5 各任务标题上的方框;**Steps 里的 `[ ]` 是原始规格清单,不是状态**(执行记录在 `PROGRESS_LOG.md`)。
> 2026-07-30 由 ios-completion 收尾波按源码核对:`Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`、
> `Components/TerminalContextMenu.swift`、`Screens/ProjectsLayout.swift` 与 `LayoutPolicyTests`/`SidebarSelectionTests`/
> `KeyBarVisibilityTests`/`TerminalContextMenuTests`/`ProjectsLayoutTests`/`ProjectsLayoutUITests` 均在树内;
> `project.yml` 三个 target 的 `TARGETED_DEVICE_FAMILY` 均为 `"1,2"``ios.yml` 有 iPad 单测腿与 iPad UI-test 腿。
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
> **G1 日志铁律**`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG在返回消息末尾附可粘贴条目。
@@ -118,7 +123,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W0 · 可安装性(串行,先行)
#### T-iPad-1 · device family + iPad plist/方向 `[ ]` · ~0.5 pd
#### T-iPad-1 · device family + iPad plist/方向 `[x]` · ~0.5 pd
- **Owns**: `ios/project.yml`device familyiPad 方向必要 plist)、`.github/workflows/ios.yml` iPad 模拟器测试腿
- **Depends**:
- **Steps(测试先行)**:
@@ -132,7 +137,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W1 · 自适应导航壳(核心)
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[ ]` · ~2 pd
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[x]` · ~2 pd
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`)、`Wiring/RootView.swift`现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`sidebar 选中 路由最小桥)、对应测试
- **Depends**: T-iPad-1
- **Steps(测试先行, RED)** `LayoutPolicyTests` + `SidebarSelectionTests`:
@@ -149,7 +154,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W2 · 逐面适配(并行,文件互斥)
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[ ]` · ~1 pd
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[x]` · ~1 pd
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift``Screens/TerminalScreen.swift`增量)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
- **Steps(测试先行)**:
@@ -159,7 +164,7 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
- **Accept**: iPad 接键盘时 KeyBar 自动隐去键盘复现右键菜单在 iPad 生效iPhone 无硬件键盘时 KeyBar 行为不变
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标复用 APIClient无绕过
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[ ]` · ~1 pd
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[x]` · ~1 pd
- **Owns**: `Screens/ProjectsScreen.swift`增量)、Projects/Timeline 呈现方式regular `.sheet` /`.presentationDetents` sidebar section)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
- **Steps(测试先行)**:
@@ -170,7 +175,9 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
### W3 · 验收report-only, G4
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[ ]` · ~0.5 pd
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[~]` · ~0.5 pd
- **已执行**模拟器侧 PASS_WITH_FINDINGSiPhone 16 / iPad Pro 11 双套件绿iPhone 零回归硬门守住4 findings2 MED / 2 LOW orchestrator 修完 4/4iPad 分栏 sidebar+detail 截图确认
- **缺口**:① 真机 iPad分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager**DEFERRED**手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**split 选中逻辑已由 `SidebarSelectionTests` 覆盖单跑 711 min 且脆);③ **release ipa **核对未做免费个人 team 无分发通道)—— T-iOS-19 同一缺口 P2 新增的麦克风/语音识别 usage description 也应一并核
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码report-onlyfindings 派回 owner
- **Steps**:
- [ ] **F-iPad 走查**(§6 清单逐条分栏横竖屏Split View/Slide Over/Stage Manager 尺寸切换硬件键盘/指针终端更宽列数遮罩覆盖 detail
@@ -214,10 +221,10 @@ enum SidebarItem: Hashable { case session(UUID), newSession, projects }
| SwiftTerm 在超宽 detail 的性能/选择手感 | | 复用 iPhone 已测路径真机目视 |
| 多窗口诱惑导致 scope 膨胀 | | 本期明确单场景(§0 非目标多窗口另立计划 |
**待你拍板**
1. iPad 最低系统版本iPadOS 17 iPhone 一致还是抬到 18/26
2. 本期是否要指针右键上下文菜单T-iPad-3 后半)——纯锦上添花可砍到后续
3. 多窗口拖会话开新窗并排两终端确认放到下一期
**待你拍板** —— 三项均已落定2026-07-30 按源码核对
1. ~~iPad 最低系统版本~~ **已定**iPadOS **17** iPhone 一致`project.yml` 单一 `deploymentTarget: iOS 17.0`无独立 iPad 下限)。
2. ~~本期是否要指针右键上下文菜单~~ **已做**`Components/TerminalContextMenu.swift`复制选区 / cwd 开新会话 / 结束会话全部路由既有通道kill 仍经 APIClient Origin+ `TerminalContextMenuTests`
3. ~~多窗口~~ **确认推迟**本期单场景(§0 非目标拖会话开新窗并排两终端仍未开工另立计划
**工作量合计**W0 0.5 + W1 2 + W2342 + W3 0.5 **5 人日**并行后墙钟更短)。

View File

@@ -24,6 +24,42 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`)
- **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。
- **编排**: 契约先冻结在 [`docs/plans/ios-completion.md`](./plans/ios-completion.md) §1(orchestrator 亲自核服务端源码),再派 3 个 Workflow 共 22 个 agent。**A 串行 → B 并行 5 → C 串行 4 → D 并行 3 → E 条件 → 收尾波 4**。C 波刻意串行:新增 `.swift` 必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行必互踩。
- 中途整个 C+D 波(7 个 agent)被 provider 侧 `529 Overloaded`/`ECONNRESET` 打挂;`resumeFromRunId` 把 A/B 从缓存回放、只重跑失败的,零返工。
- **冻结契约(§1.1,踩过的坑都在里面)**: cookie 名 `webterm_auth`;`POST /auth``{"token":"…"}`;**`Accept` 头不能含 `text/html`**——含了会被当表单走 302 而不是 204/401;**204 但无 `Set-Cookie` = 服务端根本没开鉴权**,绝不能据此认为"已认证";原生客户端**自己手写 `Cookie` 头**,不解析 `Set-Cookie`、不用系统 cookie jar(URLSession/OkHttp 的 jar 在 WS upgrade 上行为不一致且难测);**令牌不替代 Origin**,两者都要带。
- **真机构建解锁(最高杠杆,一次关掉 7 项验收阻塞)**: `DEVELOPMENT_TEAM=C738Z66SRW` + **target 级** `CODE_SIGN_IDENTITY`(废弃串 `"iPhone Developer"` 是 XcodeGen 的 product-type preset 在 target 层注入的,project 级怎么写都盖不住)→ 免费个人 team 上 `** BUILD SUCCEEDED **`,`-allowProvisioningUpdates` 现场签发 7 天 profile。**`aps-environment` 做成 env 开关且默认不挂**(`WEBTERM_PUSH_ENTITLEMENTS`),并**反向验证过必要性**:打开后真机构建报 `Personal development teams … do not support the Push Notifications capability`
- **顺手挖出一个会卡死全波的坑**: SwiftTerm 用浮动版本 `from: 1.13.0`,而 `.xcodeproj` 是 gitignore 的 → **任何人一 `xcodegen generate` 就解析到 1.15.0**,它在 1.14.0 新增的 `public var hasActiveSelection``TerminalScreen.swift` 本地同名属性冲突,且**加 `override` 修不了**(上游是 `public` 不是 `open`)。A1 先钉死版本止血,C3 删掉本地属性改用上游的、再抬到 1.15.0 根治。
- **交付**(全部实测,非引用):
| | 之前 | 之后 |
|---|---|---|
| 包测试 | 310 | **452** |
| App 测试 | 296(1 失败) | **550**(iPhone + iPad 各一遍,**0 known issue**) |
| 集成测试 | 10 | **32** |
| Android 测试 | 687 | **691** |
| ClientTLS 覆盖率 | 55.76%(**不在门内**) | **89.49%**(已入门) |
| 覆盖率门 | 4 个包 | **5 个包全过**(最低余量 +9.49pp) |
| 真机构建 | `BUILD FAILED` | **`BUILD SUCCEEDED`** |
- **令牌**: 两端全链路(APIClient `/auth` 探针 + Keychain/Keystore 存储 + WS upgrade 带 Cookie + 401 终态不进退避环)。
- **git 面板 parity**: iOS 补齐 `/projects/{log,pr,git/*,worktree*}` + `/sessions` + 队列——这是与 Android 和 web 差了两个月的那一块。
- **P2 全 5 项**: 语音 PTT(epoch 防误发)、终端内搜索、worktree 生命周期、主题 + Dynamic Type、web `?join=` 互通。
- **CI**: 修好 iOS 三条腿(缺 `npm ci` 会**硬失败**不是 skip / 缺 iPad UI 腿 / iOS 17 缺 runtime 时**静默报绿**),新建 `android.yml`(此前 Android 零 CI)。
- **复核抓到并已修的 2 个 HIGH**: ① iOS 的 WS 令牌是**与主机无关**地解析的,混合机群(一台开令牌一台没开)下令牌门主机永远开不了终端,且 App 内无解 → 改为每主机一个 transport;② Android 把主机自身的 git 凭据 401(`src/http/git-ops.ts:108`)误报成"你的访问令牌错了",因为通用 401 映射跑在路由映射前面 → git 写路由标 `ROUTE_DEFINED`,保住服务端原文案。
- **orchestrator 的两个裁定**: ① 令牌策略在 iOS 有 3 份、Android 1 份,**不解冻 `WireProtocol` 去收敛**(它是冻结的跨语言契约,共享密钥的 helper 不该进去),改为在 `IntegrationTests` 加漂移守卫——它是全树唯一能同时依赖三个包的目标;② `PairingProbe.swift` 越界改动是我特批的(B1 已收工释放该包),复核把它记为 LOW,已知悉。
- **诚实的未闭合项**: release ipa 层核对(免费 team 无分发通道);真机人工腿(口述 / 扫码 / 锁屏 Face ID / IME);APNs 端到端(需付费账号);**两个 workflow 从未在任何 runner 上跑过**——本仓库唯一 remote 是自建 Gitea,GH Actions 从来没执行过,本地已逐条复跑其命令;Android instrumented 腿(令牌静态加密的唯一证明)设为 `workflow_dispatch` 触发,理由写在文件头:没人见它绿过的腿若设为必过,只会逼出一个假绿。
- **一个诚实的取舍**: 键栏在 AX3AX5 的**字号**封顶在 `.accessibility2`。T-iOS-34 的验收原文是"最大字号不破版",现在任何档都不裁切;但"AX5 键帽以 AX5 字号渲染"是假的——不封顶时 AX5 需 108.24pt,会把终端吃掉。封顶值由测试钉死不得漂移。
- **合并回 develop 时撞上了 `android-blocker-fixes`**(同期另一会话,见下一条)。**两边各自独立实现了 `WEBTERM_TOKEN` 闸**:它们走响应式(探针失败→提示→提交),本波走前置式(确认页填→`POST /auth`→Keystore)。合并是**组合**不是二选一——两条入口汇到同一个 `performProbe` 收口,两条路径都把令牌落到 WS upgrade 会读的那个 store。
- **测试全绿不等于合并正确**:1224 个 Android 测试 0 失败的情况下,单独跑的语义复核仍抓出 **3 个真缺陷,其中 2 个是合并引入的**——
**解除配对不再擦除凭据**(🔴 安全):`HostRemover` 擦 cookie 但不擦新加的 `AccessTokenStore`。令牌就是 cookie 的值(整个 shell 的凭据),于是解绑后仍留在磁盘**且是活的**——`AccessTokenSource` 按 origin 取而不是按配对记录取,同一 URL 重新添加、令牌框留空会**静默用残留令牌认证**。两个分支单独都没这个洞。
**"两套 cookie 机制能安全共存"是错的**(🟠):复核 agent 反编译了本仓库钉死的 OkHttp 4.12.0 的 `BridgeInterceptor` 并用真 `MockWebServer` 复现——判据是"jar 非空"而**不是**"jar 里有这台主机的 `webterm_auth`",且是 `header()` **整头替换**。实测:jar 里有个无关 cookie(代理下发的)→ 令牌被从 REST **和 WS upgrade** 上整个抹掉;jar 里有陈旧 `webterm_auth` → 发出去的是陈旧值。修法是给 jar 按名过滤(读/写/水合三处),并补上**production 接线**下的回归测试——原有两个测试一个用 `NO_COOKIES` 建 client、一个用 `AccessTokenSource.NONE` 建 jar,**从来没有两者同时活着**,这正是缺陷能溜过去的原因。
**索引里的 AndroidManifest 是非法 XML**(🔴):git 把两边的 `android:allowBackup="false"` 自动合成了**重复属性**,无冲突标记。工作树被修好了但没 `git add`,照常 commit 会提交坏的那份、Android 构建在 manifest 解析就挂。
- 另修 2 项:`204 无 Set-Cookie`(=服务端没开鉴权)在响应式路径上被错误持久化,违反 §1.1 冻结规则;`ROUTE_DEFINED` 范围过宽——服务端唯一路由自有的 401 是 `src/http/git-ops.ts:108`,只有 stage/commit/push/fetch 能到达,worktree 三条根本产生不了,却被钉成 ROUTE_DEFINED(令牌门主机上会把 gate-401 显示成 git 拒绝)。iOS 侧同样的过宽 pinning 一并修。
- **合并后实测**: Android **1236** 测试 0 失败(10 模块)、kover 四个受门模块 9296%、iOS 452 包测 + **32** 集成测(对**合并后**的真服务器,含 develop 新加的孤儿 tmux 那套)+ 550 App 测 0 known issue + 覆盖率门 5/5。`src/` 与 develop HEAD 逐字节相同。
- **遗留(非缺陷,是设计取舍)**: 两套 cookie 机制仍并存,按名过滤后不会互相抹掉,但 jar 仍优先于手写头。要让 store 无条件胜出得二选一(收敛成单机制 = 重写一边的测试套,或加 app→network interceptor 传递),那是带真实设计决策的后续项,不是 merge 能顺手做的。已用一条测试把当前优先级钉死。
### 🤖 [x] Android 客户端"能装但不能用"修复 — 7 个真实缺陷已修,设备上 67 个 instrumented 测试全绿(2026-07-30,worktree `android-blocker-fixes`)
- **起因**: 例行问"android 客户端完成状况如何"。`android/PROGRESS_ANDROID.md` 写着 **"✅ ANDROID CLIENT COMPLETE — all 36 plan tasks landed"**,617 个 JVM 测试全绿,APK 也出得来。**这个"完成"只在编译层面成立** —— 从来没在任何真机或模拟器上跑过,而一旦跑,第一个动作就崩。审计(45 agent,含逐条对抗验证)+ 修复(多波 agent)已把 blocker 清掉。

View File

@@ -0,0 +1,429 @@
# ios-completion — 收尾波6 项整改 + P2 全波 + Android 令牌对齐
> 编排 doc。**orchestrator 冻结的跨 agent 契约在 §1任何 builder 不得偏离**
> 任务/Owns 表在 §2验收在 §3。进度记录仍归 `docs/PROGRESS_LOG.md`orchestrator 独写)。
审计依据2026-07-29 的 16-agent iOS 完成度审计43 计划任务 27 DONE / 11 PARTIAL / 5 MISSING
用户已定的三个范围问题:
1. **P2 全 5 项T-iOS-31…35在范围内** —— 计划 §7 要求"未按 P0 规格扩写 RED 清单前不得分派"
故每个 P2 builder 的**第一步就是扩写自己那一项的 RED 测试清单**(写进本 doc §4 追加区),再进入 GREEN。
2. **Apple 账号 = 免费个人 team**Team ID `C738Z66SRW`O=`Yaojia Wang`)。
**Push Notifications capability 不可用**`aps-environment` entitlement 若默认挂上,真机构建会直接失败。
entitlements 必须做成**默认不挂、env 开关**(见 A1
3. **Android 的 `WEBTERM_TOKEN` 支持一起补**(与 iOS 侧文件完全不相交,可并行)。
---
## 1. 冻结契约FROZEN — orchestrator 已核对服务端源码builder 只许实现不许改)
### 1.1 访问令牌(`WEBTERM_TOKEN`)—— 原生客户端接入方式
服务端事实(`src/http/auth.ts``src/server.ts:340-430,1353-1385`
| 项 | 值 | 出处 |
|---|---|---|
| Cookie 名 | **`webterm_auth`** | `auth.ts:30` `AUTH_COOKIE_NAME` |
| Cookie TTL | 2592000 秒30 天) | `auth.ts:34` |
| 登录端点 | **`POST /auth`** | `server.ts:393` |
| 请求体 | `{"token":"<t>"}``Content-Type: application/json` | `server.ts:396,408-409` |
| **`Accept` 头** | **必须不含 `text/html`** | `server.ts:366-369,410` —— 含 `text/html` 会被当成表单走 302 重定向,而不是 204/401 |
| 成功 | **204**,带 `Set-Cookie: webterm_auth=…` | `server.ts:412-414` |
| 令牌错误 | **401** + `{"error":"invalid token"}` | `server.ts:418` |
| 限流 | **429**10 次/分钟/IP | `server.ts:100,399-402` |
| **服务端未启用鉴权** | **204 但没有 `Set-Cookie`** | `server.ts:404-407` |
| 鉴权后 | 每个 HTTP 请求 + **WS upgrade** 都要带 `Cookie: webterm_auth=<t>` | `server.ts:1375` |
| 令牌字符集 | `[A-Za-z0-9._~+/=-]`,长度 16512 | CLAUDE.md / config 校验 |
**实现决定(冻结)**
- 原生客户端**自己知道令牌**,因此 **直接手写 `Cookie: webterm_auth=<t>` 请求头**即可,
**不解析 `Set-Cookie`**、不依赖系统 cookie 存储。理由URLSession/OkHttp 的 cookie jar 在
WS upgrade 上的行为不一致且难测;手写头与既有 `Origin` 手写头是同一个模式(`Endpoints.swift:81`
可被纯函数单测钉死。
- `POST /auth` 只用作**配对期的一次性校验探针**
- 204 **有** `Set-Cookie` ⇒ 令牌正确,保存;
- 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**,令牌不必保存(**不得**据此认为"已认证"
- 401 ⇒ 令牌错UI 报"令牌不正确"429 ⇒ UI 报"尝试过多,稍后再试"。
- **`Cookie` 头与 `Origin` 头正交**:令牌**不替代** Origin 检查,两者都要带(`server.ts:1363-1379` 是先 Origin 再 cookie
- **令牌是密级材料**:存 Keychain / Android Keystore-backed 存储,
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`、**禁 `kSecAttrSynchronizable`**(沿用 `SecItemShim.swift` 既有约定);
**绝不写日志、绝不进 URL query、绝不进崩溃报告**
- **401 语义**:任何 RO/G 请求收到 401 ⇒ 抛类型化 `.unauthorized`(不是通用网络错),
UI 引导去补令牌。WS upgrade 收 401 ⇒ **终态,不进退避环**(与 `.replayTooLarge` 同级处理)。
### 1.2 git 面板端点(照 Android 已实现的形状;服务端为唯一真源)
Origin-iff-G 规则(`Endpoints.swift:81`)照旧:**写操作必带 `Origin`,只读绝不带**。
| 端点 | 方法 | R/W | Android 参考实现 |
|---|---|---|---|
| `/projects/log` | GET | RO | `api-client/.../models/GitLog.kt` |
| `/projects/pr` | GET | RO | 同上 |
| `/projects/worktree/state` | GET | RO | — |
| `/projects/git/stage` | POST | **G** | `models/GitWrite.kt` |
| `/projects/git/commit` | POST | **G** | 同上 |
| `/projects/git/push` | POST | **G** | 同上 |
| `/projects/git/fetch` | POST | **G** | — |
| `/projects/worktree` | POST | **G** | `routes/GitRouteShapeTest.kt` |
| `/projects/worktree/prune` | POST | **G** | 同上 |
| `/sessions` | GET | RO | —(`claude --resume` 历史T-iOS-32 |
| `/live-sessions/:id/queue` | POST | **G** | —w2 pty 注入队列) |
**真源是 `src/` 的实现**`src/http/projects.ts` / `src/server.ts`Android 只作交叉校验;
两者若不一致,**以 `src/` 为准并在返回条目里报告该不一致**。
### 1.3 免费 team 的签名与 entitlements冻结
- `DEVELOPMENT_TEAM = C738Z66SRW`
- `CODE_SIGN_IDENTITY` 的历史值 `"iPhone Developer"` 是**已废弃**串,改 `"Apple Development"`(或删掉让 Automatic 自己选)。
- `WebTerm.entitlements`(含 `aps-environment`**默认不挂**。挂载由 **env 开关**控制,
未设时 `CODE_SIGN_ENTITLEMENTS` 必须为空 —— 免费 team 挂上就真机构建失败。
开关名冻结为 **`WEBTERM_PUSH_ENTITLEMENTS`**(值=entitlements 相对路径),并写进 `ios/README.md`
- `UIBackgroundModes: [remote-notification]` 可以无条件加(背景模式不是 capability免费 team 不受限)。
---
## 2. 任务表Owns 铁律:不得编辑本任务 Owns 之外的文件)
### Wave A串行1 agent—— 全员依赖的工程根
| ID | 任务 | Owns |
|---|---|---|
| **A1** | 签名解锁 + Info.plist 全量键 + 可选 entitlements | `ios/project.yml``ios/App/WebTerm/WebTerm.entitlements`(新) |
A1 必须一次把**后续所有波次需要的 Info.plist 键**都加好(它是 project.yml 的唯一 owner
`NSMicrophoneUsageDescription``NSSpeechRecognitionUsageDescription`T-iOS-31
`UIBackgroundModes: [remote-notification]`T-iOS-21
### Wave B并行 5 agent目录互斥—— 包层 + CI + Android
| ID | 任务 | Owns |
|---|---|---|
| **B1** | APIClient 全部新端点§1.1 `/auth` + §1.2 全表)+ 模型 + 测试覆盖率≥80% | `ios/Packages/APIClient/**` |
| **B2** | HostRegistry按主机存访问令牌Keychain§1.1 存储约定)+ 迁移安全 | `ios/Packages/HostRegistry/**` |
| **B3** | SessionCoreWS upgrade 带 `Cookie`、401→终态 `.unauthorized`、不进退避环 | `ios/Packages/SessionCore/**` |
| **B4** | ClientTLS 覆盖率 55.76%→≥80% + 纳入覆盖率门 + 修 CI 三条腿 | `ios/Packages/ClientTLS/Tests/**``ios/IntegrationTests/scripts/coverage-gate.sh``.github/workflows/ios.yml` |
| **B5** | Android `WEBTERM_TOKEN` 支持§1.1 同一契约OkHttp 侧) | `android/**` |
### Wave C**串行** 4 agent—— App 层
串行原因:新增 `.swift` 文件必须 `xcodegen generate` 重写**共享的** `.xcodeproj`,并行会互相踩;
串行后无文件归属冲突,每个 agent 可自由跑 `xcodegen + xcodebuild` 全量验证。
| ID | 任务 | 内容 |
|---|---|---|
| **C1** | 令牌 UI + 传输接线;移除主机 UI顺带修 `PushRegistrar.handleHostRemoved` 死钩子) |
| **C2** | git 面板 UI + worktree 表(**含 T-iOS-32**+ `claude --resume` 历史 |
| **C3** | 终端内搜索(**T-iOS-33**+ 语音 PTT**T-iOS-31** |
| **C4** | 主题 + Dynamic Type**T-iOS-34**+ web `?join=` 互通(**T-iOS-35** |
### Wave D并行 3 agentreport-only / 文档)
| ID | 任务 |
|---|---|
| **D1** | 安全复核:令牌路径(两端)+ entitlements + 无令牌泄漏(日志/URL/崩溃) |
| **D2** | 全量验收(**T-iOS-36** + T-iOS-18/19/30 的机器可执行部分):包测/App 测/集成/覆盖率门/模拟器构建/真机构建尝试,只报真实数字 |
| **D3** | 文档:`README.md``ios/README.md``docs/PLAN_IOS_CLIENT.md``docs/PLAN_IOS_IPAD.md` 勾选与过期表述 |
### Wave E串行条件触发
| ID | 任务 |
|---|---|
| **E1** | 修 D1/D2 报出的 CRITICAL/HIGH无则跳过 |
---
## 3. 验收门(每个 builder 自查D2 复核)
- TDD先 RED 再 GREEN§4 的 RED 清单是 P2 任务的前置交付物)。
- `swift build` + `swift test` 本包全绿;改到 App 层则 `xcodegen generate` + iPhone 16 Pro 模拟器 `xcodebuild ... test` 全绿。
- 受门包覆盖率 ≥80%B4 后 ClientTLS 也进门)。
-`TODO`/`FIXME`/占位实现;零硬编码密钥;令牌零日志。
- **诚实报告**:跑不了的(真机/付费账号/CI 平台)写 DEFERRED 并给出手工步骤,**不得报"通过"**。
---
## 4. P2 RED 测试清单(由各 P2 builder 在开工第一步追加到本节)
### T-iOS-32 · Worktree 生命周期create/prune/remove+ `claude --resume` 历史 —— C2 追加
真源:`src/http/worktrees.ts` / `src/server.ts:1095-1183``docs/plans/w4-worktree-lifecycle.md`
web 参照实现 `public/projects.ts``validateBranchNameClient` :982、`confirmAndRemoveWorktree` :431、
`confirmAndPruneWorktrees` :455、`renderNewWorktreeForm` :998、`newTabForResume` `public/tabs.ts:918`)。
APIClient 侧 builder/状态码映射已在 B1 测过125 tests故以下全部是 **App 层归约**测试,
文件 `ios/App/WebTermTests/WorktreeViewModelTests.swift` / `ResumeHistoryViewModelTests.swift`
**A. 分支名校验(纯函数 `WorktreeBranchRule.validate`,逐条镜像 web 的 9 条规则)**
1. RED空串 → 非 nil 错误文案(不得放行到网络)。
2. RED长度 > 250 → 报错;== 250 → 通过。
3. RED含空格 / 控制字符(`\u{0}``\u{20}``\u{7f}`)→ 报错。
4. RED`-` 开头 → 报错(否则会变成 git flag
5. RED`..`、以 `.lock` 结尾 → 报错。
6. RED`~ ^ : ? * [ \` 任一 → 报错。
7. RED`@{` → 报错。
8. RED`/` 开头、以 `/` 结尾、含 `//` → 报错。
9. RED合法名`feat/x``v1.2-fix`)→ nil。
**B. 创建 worktree`POST /projects/worktree`G**
10. RED非法分支名 → **不发请求**(记录一次调用计数 == 0只显示校验错误。
11. RED`.ok(CreateWorktreeResult)``phase == .created(path:branch:)`,且把服务器返回的
canonical `path`/`branch` 当真相(不回显本地输入)。
12. RED`base` 留空 → 请求体的 `base` 为 nil服务器读作「从 HEAD 切」),**不得**送 `""`
13. RED`.rejected(status:403, message:"…")` → 原样显示服务器安全文案(不得吞、不得自造)。
14. RED`.rejected(status:500, message:nil)` → 兜底中文文案(非空、可读)。
15. RED`.rateLimited` → 专用「请稍后再试」文案,且**不自动重试**。
16. RED抛出的 `APIClientError.unauthorized` → 引导补令牌的文案(不与 500 混淆)。
17. RED创建进行中 `isBusy == true` 且重复提交只发一次请求。
**C. prune`POST /projects/worktree/prune`G破坏性 → 需确认)**
18. RED未确认`confirmPrune` 未调用)→ 零请求。
19. RED确认后 `.ok(pruned: [])` → 幂等文案「没有可回收的 worktree」非错误态。
20. RED`.ok(pruned: [a,b])` → 文案含数量 2。
21. RED`.rejected(404, msg)` → 显示服务器文案。
**D. remove`DELETE /projects/worktree`G两级确认**
22. RED主 worktree`isMain`)→ UI 不提供 remove`canRemove == false`locked 同样 false。
23. RED第一次确认 → 以 `force: false` 发一次请求。
24. RED`.rejected(409, msg)` → 进入 `.forceConfirming`**不自动**重试),并带上服务器文案。
25. RED`.forceConfirming` 下用户取消 → 零第二次请求。
26. RED`.forceConfirming` 下用户确认 → 第二次请求 `force: true`
27. RED`.rejected(400)`(非 409→ 直接错误态,**不**进入 force 分支。
**E. `claude --resume <id>` 历史(`GET /sessions`**
28. RED`GET /sessions` 成功 → 仅保留 cwd 落在本项目路径内的会话
`cwd == path``cwd.hasPrefix(path + "/")`),其余过滤掉。
29. RED路径前缀不得半路匹配`/repos/web-terminal-old` 不属于 `/repos/web-terminal`
30. RED服务器顺序mtime 新→旧)保持不变,客户端不重排。
31. RED空结果 → `.empty` 而非 `.failed`(服务器 `~/.claude/projects` 缺失时回 `[]`)。
32. RED加载失败 → `.failed(可重试)``load()` 可重试成功。
33. RED**安全web 侧缺失的校验**`ProjectResumeCommand.bootstrapInput(sessionId:)`
对 id 做 `[A-Za-z0-9._-]{1,128}` 白名单 —— `abc; rm -rf ~`、含空格/反引号/`$(`/`\n` 的 id
→ 返回 nil**绝不**拼进 PTY 命令行);合法 UUID stem → `"claude --resume <id>\r"`
结尾必须是 `\r`0x0D不是 `\n`)。
34. RED非法 id 的历史行 `canResume == false`UI 不提供恢复按钮)。
35. REDcwd 非绝对路径的历史条目 → 不可恢复(`Validation.isAbsoluteCwd` 纪律)。
### 附C2 git 面板(非 P2但同批交付RED 清单
`ios/App/WebTermTests/GitPanelPresentationTests.swift` / `GitPanelViewModelTests.swift`
真源 `docs/plans/w6-project-git-panel.md` + `public/projects.ts:615-745 makeSyncBand`
36. RED`sync == nil`(非 git 目录)→ 无同步带nil不发任何 git 请求。
37. RED`↑0 ↓0` + fetch 在 1h 内 → **唯一**允许的绿色「已同步」。
38. RED`↑0 ↓0` + `lastFetchMs` 超过 `FETCH_STALE_MS`(=3600_000) → **不绿**`↓` 带「未核实」。
39. RED`lastFetchMs == nil`(从未 fetch→ 视为 stale不绿。
40. RED`upstream == nil` → 「无上游」,无 `↑↓`**不绿**(最易犯的 bug
41. RED`detached == true` → 「分离 HEAD」`↑↓`fetch 按钮禁用(`canFetch == false`)。
42. RED`ahead == nil`(读不到)→ 显示 `↑ —` 而非 `↑ 0`,且不绿。
43. RED`dirtyCount == nil` → 「未检查」,**不是** clean`0` → clean`3` → 3。
44. REDgit log 未推送边界:`upstream != nil` 时边界画在最后一个 `unpushed == true` 之后,
且只画一次;`unpushed` 只在严格 `true` 时生效。
45. RED`upstream == nil` → 完全不画边界。
46. RED未推送提交排在已推送之下老日期 merge→ 边界仍在最后一个 unpushed 之后Set 为准,非行号)。
47. REDstage/commit/push/fetch 的 `.rejected` 一律把服务器 `error` 原样显示;`nil` message → 兜底文案。
48. REDcommit 成功 → 清空输入框 + 显示短 sha`.rejected(409)`(无暂存)→ 保留输入框内容。
49. REDpush `.rejected(401)` → 「主机上的 git 凭据」文案,**不**与访问令牌 401 混淆
`unauthorizedPolicy == .routeDefined`§1.1)。
50. RED任一写操作进行中 → `isBusy`,重复点击只发一次。
51. RED重命名文件 stage → 请求体同时含 `oldPath``newPath`(镜像 web
### T-iOS-33 · 终端内搜索 —— C3 追加
真源SwiftTerm 1.13.0 已带搜索 API`TerminalViewSearch.swift``findNext(_:options:scrollToResult:)`
`findPrevious(...)``clearSearch()``SearchOptions(caseSensitive:false, regex:false, wholeWord:false)`
默认值与 xterm.js search addon 一致。web 参照 `public/search.ts`Enter=next / Shift+Enter=prev /
Esc=关闭并 `hooks.clear()``public/terminal-session.ts:547-553` 把三个 hook 落到 SearchAddon
`style.css:812` searchbox 固定在**右上**)。文件 `ios/App/WebTermTests/TerminalSearchTests.swift`
**A. 查询提交策略(纯函数 `TerminalSearchQuery.isSubmittable`**
1. RED空串 → 不可提交,且**零引擎调用**(不把空词丢给 SwiftTerm
2. RED单个空格 `" "` → **可**提交(镜像 web`input.value` 原样进 `findNext`,空格是合法搜索词)。
3. RED`" foo "` → 原样传(**不 trim、不改大小写**,逐字符等于用户输入)。
**B. 方向与引擎调用(`TerminalSearchModel` over fake `TerminalSearching`**
4. RED`find(.next)` → 恰一次 `searchNext(term:)`term 逐字符等于 query`searchPrevious`
5. RED`find(.previous)` → 恰一次 `searchPrevious`,零 `searchNext`
6. RED引擎回 true → `outcome == .found`
7. RED引擎回 false → `outcome == .notFound`,且有非空「无匹配」文案。
8. RED连续三次 `find(.next)` → 三次引擎调用(搜索游标由 SwiftTerm 自己推进,客户端不缓存)。
9. RED未 attach 引擎(`searcher == nil`)→ 不崩、`outcome` 保持 `.idle`(预览/测试环境)。
**C. 打开/关闭生命周期**
10. RED初始 `isPresented == false``outcome == .idle`
11. RED`present()`→true`dismiss()`→false。
12. RED`dismiss()` → 恰一次 `searchClear()`(镜像 web `hide() → hooks.clear()`+ 清空 query + `outcome == .idle`
13. RED`present()` **不**调用任何引擎方法(开面板 ≠ 搜索)。
14. RED改 query → `outcome` 复位 `.idle`(旧的「无匹配」不得挂在新词上)。
**D. 不变式:搜索是纯读**
15. RED整个搜索流程后真 `TerminalViewModel.forwardedSendCount == 0`(零 PTY 字节byte-shuttle 不变)。
16. RED终端已 `.exited`read-only时搜索照常可用`find` 仍打引擎)。
**E. SwiftTerm 绑定Accept命中并高亮**
17. RED`KeyCommandTerminalView` feed 一段文本后 `searchNext(term:)` 命中 → 返回 true **且**
`hasActiveSelection == true`= SwiftTerm 选区高亮);搜不存在的词 → false。
18. RED`searchClear()``hasActiveSelection == false`(高亮清掉)。
### T-iOS-31 · 语音 PTT + 确认 —— C3 追加
真源(三件套逐条移植 webplan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port `voice-commands.ts` 的匹配器):
`public/voice.ts`PTT 生命周期、`autoSend:false` 默认 = **不自动回车**)、
`public/voice-commands.ts`(整句精确匹配器 + 否定守卫 + `MIN_APPROVE_CONFIDENCE=0.6`)、
`public/voice-confirm.ts``DEFAULT_WINDOW_MS = 1500` 可撤销窗口)、
`SessionCore/GateState.swift`epoch 防陈旧决策的既有先例 `canDecide(epoch:)`)。
文件 `ios/App/WebTermTests/VoicePTTTests.swift`
**A. 转写清洗(安全边界,纯函数 `VoiceTranscript`**
19. RED普通文本 → 首尾 trim 后原样。
20. RED`\r`0x0D**剥除**(口述绝不自己回车执行命令;等价 web `autoSend:false`)。
21. RED`\n`/`\t`/ESC `\u{1b}`/其它 C0-C1 控制字符 → 全部剥除。
22. RED多行 → 折成单行(换行变空格 + 合并连续空白)。
23. RED`maxLength` → 截断到上限。
24. RED清洗后为空 → `isInjectable == false`(不进确认态)。
**B. 匹配器移植(`VoiceCommandMatcher`,逐条镜像 `voice-commands.ts`**
25. RED无 held gate → 任何话语都是 `.text`(含「确认」)。
26. REDgate 是 `.plan``.text`v1 只作用 tool gate
27. REDtool gate + 「确认」/「批准」/`ok`/`go ahead``.approve`
28. RED归一化小写 + 去标点 + 合并空白):`OK.`/「好的!」命中;`don't``dont`
29. RED**整句精确**:「确认一下这个」→ `.text`(绝不子串匹配,否则顺口一句就批了 shell
30. REDtool gate + 「拒绝」/「取消」/`stop``.reject`
31. RED否定守卫「不批准」→ `.reject`;「不清楚」→ `.text``now` **不**被 `no` 前缀否定 → `.text`
32. RED置信度门`.approve` 且 confidence < 0.6 降级 `.text``.reject` 不受置信度影响
33. RED/纯标点话语 `.text`
**C. 确认 + 1.5s 撤销窗口FakeClock零真睡**
34. RED`pressDown()` `.listening`partial 回调更新 `.listening(partial:)`
35. RED`pressUp()` 得空转写 `.idle` + `lastResolution == .empty`**零注入**。
36. RED`pressUp()` 得有效转写 `.confirming(...)`**零注入**(「确认前绝不注入」)。
37. RED`.confirming` `cancel()` `.idle` + `.discarded` + 零注入
38. RED`confirm()` `.undoWindow`此刻**仍零注入**。
39. RED时钟 +1.4s 仍零注入 1.5s 恰一次注入内容 == 清洗后的转写`lastResolution == .committed(.text(...))`
40. RED`.undoWindow` `undo()` 零注入 + `.undone`再推进 10s **也不**注入任务已取消)。
41. RED注入内容**不以 `\r` 结尾**用户自己按 —— 误听绝不自动执行)。
42. RED `.confirming` 态调 `confirm()` 无副作用
43. RED重复 `confirm()` arm 一次只注入一次
**D. epoch 防误发(两道闸)**
44. RED口述确认之间 epoch 变了 `confirm()` 直接丢弃零注入 + `.staleEpoch`
45. REDepoch **撤销窗口内**confirm 已过1.5s 内切会话)→ 到期仍零注入 + `.staleEpoch`
46. REDepoch 不变 正常注入防止闸门过严的回归保护)。
47. RED口述时 sessionId 还没 adoptnil)、确认时已 adopt 视为变化 零注入
48. RED`VoiceEpochPolicy.invalidates``.reconnecting` true重连后服务器可能给的是新 PTY
客户端分辨不了 走安全方向`.connecting`/`.none` false`VoiceEpochSource` 累加 generation
**E. 权限与失败路径**
49. RED麦克风授权被拒 `.denied(.microphone)`文案指向 设置隐私麦克风零录音零注入
50. RED语音识别授权被拒 `.denied(.speech)`文案指向语音识别开关
51. RED识别器抛错 `.failed(message:)`零注入可再按重试
52. RED终端只读`.exited`)→ `pressDown()` 拒绝`.readOnly`**不启动录音**`startCount == 0`)。
**F. 键栏集成KeyBar 零回归)**
53. RED不提供语音闭包 按钮数 == `KeyBarLayout.buttons.count`17无麦克风键
54. RED提供语音闭包 末尾多一个 🎤/「语音 17 键顺序与 accessibilityLabel **完全不变**
55. RED麦克风键 touchDown `onVoiceDown` 恰一次touchUpInside `onVoiceUp` 恰一次
**绝不** `onKey`麦克风不映射任何 `KeyByteMap` 字节)。
56. RED匹配到 `.approve` 且注入了 gate 走同一 1.5s 窗口到期调 `decide(.approve)` ****注入文本
### T-iOS-34 · 主题(跟随系统/深色/浅色)+ Dynamic Type —— C4 追加
真源`Wiring/RootView.swift:30` 今天**硬锁** `.preferredColorScheme(.dark)`无浅色通路
`DesignSystem/Tokens.swift` 已声明浅色 accent `#C9892F`web `--accent-2`但状态色/时间线色/
`accentSoft`/终端画布仍是**单值深色专用**终端主题只在 `Screens/TerminalScreen.swift:223-235`
`makeUIView` 时套一次web 参照 `public/settings.ts``DEFAULT_SETTINGS.theme='dark'`
`THEMES.light = {background:'#f6f7f9', foreground:'#1a1d24'}`)。
文件 `ios/App/WebTermTests/AppThemeTests.swift` / `DynamicTypeLayoutTests.swift`
**A. 主题模型(纯值 `AppTheme`**
1. RED`.system.colorScheme == nil`= 交给系统`.dark → .dark``.light → .light`
2. RED三档 label / SF Symbol 各自非空且**互不相同**选择器要能区分)。
3. RED`AppTheme.allCases` 顺序 = 跟随系统 深色 浅色选择器顺序即此顺序UI 不重排)。
4. RED`AppTheme(rawValue:)` 白名单`"system"/"dark"/"light"` 命中其它含空串`"Dark"`)→ nil
**B. 持久化(`AppThemeCodec` 纯函数 + `ThemeStore` over fake defaults**
5. RED`decode(nil)`从未设置)→ **`.dark`** —— 默认必须等于今天硬锁的深色零回归)。
6. RED`decode("solarized")` / `decode("")` / `decode("DARK")` `.dark`未知值不崩不落 system)。
7. RED三档 `encode → decode` 往返恒等
8. RED`ThemeStore` defaults `.dark`**不写盘**首次读不产生写)。
9. RED`select(.light)` `theme == .light` defaults `"light"`同一 defaults 新建 store `.light`跨启动)。
10. RED`select` 同一档两次 只写一次不产生多余写)。
11. REDdefaults 里被塞脏值 store 读作 `.dark`****清空用户其它键
**C. 有效配色解析(`AppTheme.resolvedScheme(system:)`**
12. RED`.system` + 系统 `.light` `.light``.system` + 系统 `.dark` `.dark`透传)。
13. RED`.dark`/`.light` 无视系统值 强制自身
**D. 浅色通路的 token 审计(`UIColor.resolvedColor(with:)` 逐档解析)**
14. RED`statusWorking`/`statusWaiting`/`statusStuck`/`timelineTool`/`timelineUser` **light**
**dark** 下解析值**不同**真的有浅色变体不是解锁开关就完事)。
15. RED深色档的值**逐字节不变**#46D07F / #F5B14C / #FF6B6B / #5E9EFF / #AF7BFF)—— 深色零回归
16. RED上述 5 色在**两档**下相对该档 `systemBackground` WCAG 对比度 ** 3:1**
非文本 UI 组件门槛1.4.11)。今天的 `#46D07F`/`#F5B14C`/`#FF6B6B` 在白底只有
1.96/1.60/2.74:1 —— 这是 RED 的核心
17. REDcritical 三色working/waiting/stuck **light** 档仍两两可辨不因变暗而糊成一片)。
18. RED`accentSoft` 两档不同**都仍是 wash**alpha < 0.3不许变成实心块)。
19. RED`onAccent` 两档**相同**金色填充在两档都要深色墨故它是固定值且与 accent 对比 4.5:1
**E. 终端画布跟随主题(`TerminalPalette`**
20. RED`colors(for: .dark).background == #100F0D``.foreground == #ECE9E3`桌面 web `--bg/--text`零回归)。
21. RED`colors(for: .light).background == #F6F7F9``.foreground == #1A1D24`镜像 web `THEMES.light`)。
22. RED两档 fgbg 对比度 7:1终端是正文AAA 门槛)。
23. REDcaret / selection 用该档 accent 解析值不写死深色档的金)。
24. RED`DS.Palette.terminalBackgroundUIColor()` **动态** UIColorlight/dark 解析值不同
今天是单值常量 RED)。
**F. Dynamic Type 不破版(`UIHostingController.sizeThatFits` / UIKit `systemLayoutSizeFitting`AX5**
25. RED`GateBanner` 320pt 宽容器`.accessibility5` 宽度 320无横向溢出)、
高度有限且 > 标准字号高度(= 真的长高而不是被裁)。
26. RED`TelemetryChip``lineLimit(1)` 的等宽数字)在 AX5 下宽度 ≤ 320且**AX2 与 AX5 同高**
`DS.Typography.numericClamp` 夹取生效 —— 表格数字不许炸版;落地时把原计划的
"增幅 ≤ 2.2×"换成了这条更强、更确定的等式断言)。
27. RED`dsMetaText()` 的元信息行在 AX5 下同样受夹取(与 26 同一策略,单一出处)。
28. RED`DSButtonStyle` 在 AX5 下高度 ≥ `DS.Layout.minHitTarget` 且标签可换行不横向溢出。
29. RED**外部件度量,越界只报不改**AX5 下键帽两行所需高度 vs 固定 `barHeight`44+8
实测 **108.24pt vs 52pt → 裁切**footnote 行高 52.51 + caption2 行高 47.73 + 内衬 8
`Components/KeyBar.swift` 不在 C4 Owns故以 `withKnownIssue` 记为已知缺口(修好后用例会
报 "known issue was not recorded",提醒删标记)。
注:不能用 `performAsCurrent { KeyBarView() }` 量 —— `UIFont.preferredFont(forTextStyle:)`
读 App 级 content size category不看 `UITraitCollection.current`,那样量出的是默认字号
38pt的**假绿**;改用 `compatibleWith:` 取真实 AX5 行高。
### T-iOS-35 · web 分享 QR`?join=`)互通 —— C4 追加
真源:`public/share.ts:55` 的 URL 形状 **`${location.origin}/?join=${sessionId}`**
`src/server.ts``GET /?join=<id>``DeepLinkRouter.swift:56-65` 今天只认 `webterminal://open`
http(s) 一律 `.ignore``DeepLinkRouterTests.swift:84` 钉住了它 —— 本任务**有意**改写该用例)。
主机身份仍**只**经 `HostStore` 解析(`HostEndpoint.originHeader` 是冻结的唯一 origin 派生点)。
文件 `ios/App/WebTermTests/DeepLinkRouterTests.swift`(增补 + 有意改写 1 例)。
**A. 接受 web 分享形状**
30. RED`http://192.168.1.5:3000/?join=<v4>``.joinShared(origin:"http://192.168.1.5:3000", sessionId:)`
31. RED`https://mac.ts.net/?join=<v4>` → origin 省略默认端口(`:443` 不出现)。
32. RED`HTTP://192.168.1.5:3000/?join=<v4>`(大写 scheme/host→ origin 归一化为小写。
33. REDpath 为 `""``"/"` 都接受(`location.origin + "/?join="` 产出 `/`)。
**B. 白名单纪律http(s) 是比自定义 scheme 大得多的输入面,故**更严****
34. RED`join` 值非 v4`Validation.isValidSessionId` 判定)→ `.ignore`
35. RED重复 `join` 键 → `.ignore`(歧义输入绝不部分应用)。
36. RED**多余 query 键**`/?join=<v4>&x=1`)→ `.ignore`web 形状精确只有一个键)。
37. RED非空 path`/manage.html?join=<v4>`)→ `.ignore`
38. RED带 fragment`/?join=<v4>#x`)→ `.ignore`
39. RED带 userinfo`http://u:p@host/?join=<v4>`)→ `.ignore`(二维码钓鱼形状)。
40. RED非 http(s) scheme`ftp://``file://``javascript:`)→ `.ignore`
41. RED`?join=` 但**无 host**`http:///?join=<v4>`)→ `.ignore`
42. RED**有意改写既有用例**`https://open?host=<v4>&join=<v4>``.ignore`
—— 但理由从"scheme 不是 webterminal"变成"web 形状只许**单一** `join` 键"。
原用例名/注释同步改写,避免留下已失效的断言语义。
43. RED`webterminal://open?host=&join=` 等既有拒绝路径**逐条不变**(自定义 scheme 分支零回归)。
**C. 解析主机:只认已配对(二维码是不可信输入,绝不静默配对)**
44. REDorigin 命中已配对主机 → `openSession(host, sessionId)` 恰一次,无 hint。
45. REDorigin 未配对 → **零** open、`showPairing` 一次、hint == `DeepLinkCopy.unknownHostHint`
**不得**据链接内容自动新增主机)。
46. REDhint 文案**不回显**链接内容(不含 origin 串、不含 sessionId—— 防钓鱼/防日志注入。
47. REDorigin 比较走 `HostEndpoint.originHeader``http://host:3000``http://HOST:3000/` 视为同一主机;
`http://host:3001`(端口不同)**不是**同一主机。
48. RED`https://host/``http://host/`scheme 不同)**不是**同一主机。
49. RED冷启动 stash 对 `.joinShared` 同样生效ready 前 handle → markReady 后恰应用一次)。
50. RED非法 web 链接 → `ignoredCount + 1`,且**不入 stash**(与既有 `.ignore` 同一通路)。