# 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":""}`,`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=` | `server.ts:1375` | | 令牌字符集 | `[A-Za-z0-9._~+/=-]`,长度 16–512 | CLAUDE.md / config 校验 | **实现决定(冻结)**: - 原生客户端**自己知道令牌**,因此 **直接手写 `Cookie: webterm_auth=` 请求头**即可, **不解析 `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** | SessionCore:WS 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 agent,report-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 ` 历史(`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 \r"`, 结尾必须是 `\r`(0x0D,不是 `\n`)。 34. RED:非法 id 的历史行 `canResume == false`(UI 不提供恢复按钮)。 35. RED:cwd 非绝对路径的历史条目 → 不可恢复(`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. RED:git log 未推送边界:`upstream != nil` 时边界画在最后一个 `unpushed == true` 之后, 且只画一次;`unpushed` 只在严格 `true` 时生效。 45. RED:`upstream == nil` → 完全不画边界。 46. RED:未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后(Set 为准,非行号)。 47. RED:stage/commit/push/fetch 的 `.rejected` 一律把服务器 `error` 原样显示;`nil` message → 兜底文案。 48. RED:commit 成功 → 清空输入框 + 显示短 sha;`.rejected(409)`(无暂存)→ 保留输入框内容。 49. RED:push `.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 追加 真源(三件套逐条移植 web,plan §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. RED:gate 是 `.plan` → `.text`(v1 只作用 tool gate)。 27. RED:tool gate + 「确认」/「批准」/`ok`/`go ahead` → `.approve`。 28. RED:归一化(小写 + 去标点 + 合并空白):`OK.`/「好的!」命中;`don't` → `dont`。 29. RED:**整句精确**:「确认一下这个」→ `.text`(绝不子串匹配,否则顺口一句就批了 shell)。 30. RED:tool 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. RED:epoch 在**撤销窗口内**变(confirm 已过、1.5s 内切会话)→ 到期仍零注入 + `.staleEpoch`。 46. RED:epoch 不变 → 正常注入(防止闸门过严的回归保护)。 47. RED:口述时 sessionId 还没 adopt(nil)、确认时已 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. RED:defaults 里被塞脏值 → 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. RED:critical 三色(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:两档 fg↔bg 对比度 ≥ 7:1(终端是正文,AAA 门槛)。 23. RED:caret / selection 用该档 accent 解析值(不写死深色档的金)。 24. RED:`DS.Palette.terminalBackgroundUIColor()` 是**动态** UIColor:light/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=`;`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=` → `.joinShared(origin:"http://192.168.1.5:3000", sessionId:)`。 31. RED:`https://mac.ts.net/?join=` → origin 省略默认端口(`:443` 不出现)。 32. RED:`HTTP://192.168.1.5:3000/?join=`(大写 scheme/host)→ origin 归一化为小写。 33. RED:path 为 `""` 与 `"/"` 都接受(`location.origin + "/?join="` 产出 `/`)。 **B. 白名单纪律(http(s) 是比自定义 scheme 大得多的输入面,故**更严**)** 34. RED:`join` 值非 v4(`Validation.isValidSessionId` 判定)→ `.ignore`。 35. RED:重复 `join` 键 → `.ignore`(歧义输入绝不部分应用)。 36. RED:**多余 query 键**(`/?join=&x=1`)→ `.ignore`(web 形状精确只有一个键)。 37. RED:非空 path(`/manage.html?join=`)→ `.ignore`。 38. RED:带 fragment(`/?join=#x`)→ `.ignore`。 39. RED:带 userinfo(`http://u:p@host/?join=`)→ `.ignore`(二维码钓鱼形状)。 40. RED:非 http(s) scheme(`ftp://`、`file://`、`javascript:`)→ `.ignore`。 41. RED:`?join=` 但**无 host**(`http:///?join=`)→ `.ignore`。 42. RED(**有意改写既有用例**):`https://open?host=&join=` 仍 `.ignore` —— 但理由从"scheme 不是 webterminal"变成"web 形状只许**单一** `join` 键"。 原用例名/注释同步改写,避免留下已失效的断言语义。 43. RED:`webterminal://open?host=&join=` 等既有拒绝路径**逐条不变**(自定义 scheme 分支零回归)。 **C. 解析主机:只认已配对(二维码是不可信输入,绝不静默配对)** 44. RED:origin 命中已配对主机 → `openSession(host, sessionId)` 恰一次,无 hint。 45. RED:origin 未配对 → **零** open、`showPairing` 一次、hint == `DeepLinkCopy.unknownHostHint` (**不得**据链接内容自动新增主机)。 46. RED:hint 文案**不回显**链接内容(不含 origin 串、不含 sessionId)—— 防钓鱼/防日志注入。 47. RED:origin 比较走 `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` 同一通路)。