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.
31 KiB
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)。
用户已定的三个范围问题:
- P2 全 5 项(T-iOS-31…35)在范围内 —— 计划 §7 要求"未按 P0 规格扩写 RED 清单前不得分派", 故每个 P2 builder 的第一步就是扩写自己那一项的 RED 测试清单(写进本 doc §4 追加区),再进入 GREEN。
- Apple 账号 = 免费个人 team(Team ID
C738Z66SRW,O=Yaojia Wang)。 ⇒ Push Notifications capability 不可用:aps-environmententitlement 若默认挂上,真机构建会直接失败。 entitlements 必须做成默认不挂、env 开关(见 A1)。 - 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 报"尝试过多,稍后再试"。
- 204 有
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 条规则)
- RED:空串 → 非 nil 错误文案(不得放行到网络)。
- RED:长度 > 250 → 报错;== 250 → 通过。
- RED:含空格 / 控制字符(
\u{0}…\u{20}、\u{7f})→ 报错。 - RED:以
-开头 → 报错(否则会变成 git flag)。 - RED:含
..、以.lock结尾 → 报错。 - RED:含
~ ^ : ? * [ \任一 → 报错。 - RED:含
@{→ 报错。 - RED:以
/开头、以/结尾、含//→ 报错。 - 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。
- RED:
sync == nil(非 git 目录)→ 无同步带(nil),不发任何 git 请求。 - RED:
↑0 ↓0+ fetch 在 1h 内 → 唯一允许的绿色「已同步」。 - RED:
↑0 ↓0+lastFetchMs超过FETCH_STALE_MS(=3600_000) → 不绿,↓带「未核实」。 - RED:
lastFetchMs == nil(从未 fetch)→ 视为 stale,不绿。 - RED:
upstream == nil→ 「无上游」,无↑↓,不绿(最易犯的 bug)。 - RED:
detached == true→ 「分离 HEAD」,无↑↓,fetch 按钮禁用(canFetch == false)。 - RED:
ahead == nil(读不到)→ 显示↑ —而非↑ 0,且不绿。 - RED:
dirtyCount == nil→ 「未检查」,不是 clean;0→ clean;3→ 3。 - RED:git log 未推送边界:
upstream != nil时边界画在最后一个unpushed == true之后, 且只画一次;unpushed只在严格true时生效。 - RED:
upstream == nil→ 完全不画边界。 - RED:未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后(Set 为准,非行号)。
- RED:stage/commit/push/fetch 的
.rejected一律把服务器error原样显示;nilmessage → 兜底文案。 - RED:commit 成功 → 清空输入框 + 显示短 sha;
.rejected(409)(无暂存)→ 保留输入框内容。 - RED:push
.rejected(401)→ 「主机上的 git 凭据」文案,不与访问令牌 401 混淆 (unauthorizedPolicy == .routeDefined,§1.1)。 - RED:任一写操作进行中 →
isBusy,重复点击只发一次。 - 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)
- RED:空串 → 不可提交,且零引擎调用(不把空词丢给 SwiftTerm)。
- RED:单个空格
" "→ 可提交(镜像 web:input.value原样进findNext,空格是合法搜索词)。 - 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)
- RED:
.system.colorScheme == nil(= 交给系统),.dark → .dark,.light → .light。 - RED:三档 label / SF Symbol 各自非空且互不相同(选择器要能区分)。
- RED:
AppTheme.allCases顺序 = 跟随系统 → 深色 → 浅色(选择器顺序即此顺序,UI 不重排)。 - 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 同一通路)。