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

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

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

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

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

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

430 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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` 同一通路)。