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.
430 lines
31 KiB
Markdown
430 lines
31 KiB
Markdown
# 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._~+/=-]`,长度 16–512 | 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** | 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 <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. 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=<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. RED:path 为 `""` 与 `"/"` 都接受(`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. 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` 同一通路)。
|