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

31 KiB
Raw Blame History

ios-completion — 收尾波6 项整改 + P2 全波 + Android 令牌对齐

编排 doc。orchestrator 冻结的跨 agent 契约在 §1任何 builder 不得偏离 任务/Owns 表在 §2验收在 §3。进度记录仍归 docs/PROGRESS_LOG.mdorchestrator 独写)。

审计依据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 账号 = 免费个人 teamTeam ID C738Z66SRWO=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.tssrc/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
限流 42910 次/分钟/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 存储, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlykSecAttrSynchronizable(沿用 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.tsAndroid 只作交叉校验; 两者若不一致,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.ymlios/App/WebTerm/WebTerm.entitlements(新)

A1 必须一次把后续所有波次需要的 Info.plist 键都加好(它是 project.yml 的唯一 owner NSMicrophoneUsageDescriptionNSSpeechRecognitionUsageDescriptionT-iOS-31UIBackgroundModes: [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+ 语音 PTTT-iOS-31
C4 主题 + Dynamic TypeT-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.mdios/README.mddocs/PLAN_IOS_CLIENT.mddocs/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-1183docs/plans/w4-worktree-lifecycle.md、 web 参照实现 public/projects.tsvalidateBranchNameClient :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/xv1.2-fix)→ nil。

B. 创建 worktreePOST /projects/worktreeG 10. RED非法分支名 → 不发请求(记录一次调用计数 == 0只显示校验错误。 11. RED.ok(CreateWorktreeResult)phase == .created(path:branch:),且把服务器返回的 canonical path/branch 当真相(不回显本地输入)。 12. REDbase 留空 → 请求体的 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. prunePOST /projects/worktree/pruneG破坏性 → 需确认) 18. RED未确认confirmPrune 未调用)→ 零请求。 19. RED确认后 .ok(pruned: []) → 幂等文案「没有可回收的 worktree」非错误态。 20. RED.ok(pruned: [a,b]) → 文案含数量 2。 21. RED.rejected(404, msg) → 显示服务器文案。

D. removeDELETE /projects/worktreeG两级确认 22. RED主 worktreeisMain)→ UI 不提供 removecanRemove == falselocked 同样 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. REDGET /sessions 成功 → 仅保留 cwd 落在本项目路径内的会话 cwd == pathcwd.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" 结尾必须是 \r0x0D不是 \n)。 34. RED非法 id 的历史行 canResume == falseUI 不提供恢复按钮)。 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

  1. REDsync == nil(非 git 目录)→ 无同步带nil不发任何 git 请求。
  2. RED↑0 ↓0 + fetch 在 1h 内 → 唯一允许的绿色「已同步」。
  3. RED↑0 ↓0 + lastFetchMs 超过 FETCH_STALE_MS(=3600_000) → 不绿 带「未核实」。
  4. REDlastFetchMs == nil(从未 fetch→ 视为 stale不绿。
  5. REDupstream == nil → 「无上游」,无 ↑↓不绿(最易犯的 bug
  6. REDdetached == true → 「分离 HEAD」↑↓fetch 按钮禁用(canFetch == false)。
  7. REDahead == nil(读不到)→ 显示 ↑ — 而非 ↑ 0,且不绿。
  8. REDdirtyCount == nil → 「未检查」,不是 clean0 → clean3 → 3。
  9. REDgit log 未推送边界:upstream != nil 时边界画在最后一个 unpushed == true 之后, 且只画一次;unpushed 只在严格 true 时生效。
  10. REDupstream == nil → 完全不画边界。
  11. RED未推送提交排在已推送之下老日期 merge→ 边界仍在最后一个 unpushed 之后Set 为准,非行号)。
  12. REDstage/commit/push/fetch 的 .rejected 一律把服务器 error 原样显示;nil message → 兜底文案。
  13. REDcommit 成功 → 清空输入框 + 显示短 sha.rejected(409)(无暂存)→ 保留输入框内容。
  14. REDpush .rejected(401) → 「主机上的 git 凭据」文案,与访问令牌 401 混淆 unauthorizedPolicy == .routeDefined§1.1)。
  15. RED任一写操作进行中 → isBusy,重复点击只发一次。
  16. RED重命名文件 stage → 请求体同时含 oldPathnewPath(镜像 web

T-iOS-33 · 终端内搜索 —— C3 追加

真源SwiftTerm 1.13.0 已带搜索 APITerminalViewSearch.swiftfindNext(_:options:scrollToResult:)findPrevious(...)clearSearch()SearchOptions(caseSensitive:false, regex:false, wholeWord:false) 默认值与 xterm.js search addon 一致。web 参照 public/search.tsEnter=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单个空格 " "提交(镜像 webinput.value 原样进 findNext,空格是合法搜索词)。
  3. RED" foo " → 原样传(不 trim、不改大小写,逐字符等于用户输入)。

B. 方向与引擎调用(TerminalSearchModel over fake TerminalSearching 4. REDfind(.next) → 恰一次 searchNext(term:)term 逐字符等于 querysearchPrevious。 5. REDfind(.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 == falseoutcome == .idle。 11. REDpresent()→truedismiss()→false。 12. REDdismiss() → 恰一次 searchClear()(镜像 web hide() → hooks.clear()+ 清空 query + outcome == .idle。 13. REDpresent() 调用任何引擎方法(开面板 ≠ 搜索)。 14. RED改 query → outcome 复位 .idle(旧的「无匹配」不得挂在新词上)。

D. 不变式:搜索是纯读 15. RED整个搜索流程后真 TerminalViewModel.forwardedSendCount == 0(零 PTY 字节byte-shuttle 不变)。 16. RED终端已 .exitedread-only时搜索照常可用find 仍打引擎)。

E. SwiftTerm 绑定Accept命中并高亮 17. REDKeyCommandTerminalView feed 一段文本后 searchNext(term:) 命中 → 返回 true hasActiveSelection == true= SwiftTerm 选区高亮);搜不存在的词 → false。 18. REDsearchClear()hasActiveSelection == false(高亮清掉)。

T-iOS-31 · 语音 PTT + 确认 —— C3 追加

真源(三件套逐条移植 webplan §7「端口匹配器 / 1.5s 撤销 / epoch 防误发」= port voice-commands.ts 的匹配器): public/voice.tsPTT 生命周期、autoSend:false 默认 = 不自动回车)、 public/voice-commands.ts(整句精确匹配器 + 否定守卫 + MIN_APPROVE_CONFIDENCE=0.6)、 public/voice-confirm.tsDEFAULT_WINDOW_MS = 1500 可撤销窗口)、 SessionCore/GateState.swiftepoch 防陈旧决策的既有先例 canDecide(epoch:))。 文件 ios/App/WebTermTests/VoicePTTTests.swift

A. 转写清洗(安全边界,纯函数 VoiceTranscript 19. RED普通文本 → 首尾 trim 后原样。 20. RED\r0x0D剥除(口述绝不自己回车执行命令;等价 web autoSend:false)。 21. RED\n/\t/ESC \u{1b}/其它 C0-C1 控制字符 → 全部剥除。 22. RED多行 → 折成单行(换行变空格 + 合并连续空白)。 23. REDmaxLength → 截断到上限。 24. RED清洗后为空 → isInjectable == false(不进确认态)。

B. 匹配器移植(VoiceCommandMatcher,逐条镜像 voice-commands.ts 25. RED无 held gate → 任何话语都是 .text(含「确认」)。 26. REDgate 是 .plan.textv1 只作用 tool gate。 27. REDtool gate + 「确认」/「批准」/ok/go ahead.approve。 28. RED归一化小写 + 去标点 + 合并空白):OK./「好的!」命中;don'tdont。 29. RED整句精确:「确认一下这个」→ .text(绝不子串匹配,否则顺口一句就批了 shell。 30. REDtool gate + 「拒绝」/「取消」/stop.reject。 31. RED否定守卫「不批准」→ .reject;「不清楚」→ .textnow no 前缀否定 → .text。 32. RED置信度门.approve 且 confidence < 0.6 → 降级 .text.reject 不受置信度影响。 33. RED空/纯标点话语 → .text

C. 确认 + 1.5s 撤销窗口FakeClock零真睡 34. REDpressDown().listeningpartial 回调更新 .listening(partial:)。 35. REDpressUp() 得空转写 → .idle + lastResolution == .empty零注入。 36. REDpressUp() 得有效转写 → .confirming(...)零注入(「确认前绝不注入」)。 37. RED.confirmingcancel().idle + .discarded + 零注入。 38. REDconfirm().undoWindow,此刻仍零注入。 39. RED时钟 +1.4s → 仍零注入;到 1.5s → 恰一次注入,内容 == 清洗后的转写,lastResolution == .committed(.text(...))。 40. RED.undoWindowundo() → 零注入 + .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. REDVoiceEpochPolicy.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.count17无麦克风键。 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 #C9892Fweb --accent-2)但状态色/时间线色/ accentSoft/终端画布仍是单值深色专用;终端主题只在 Screens/TerminalScreen.swift:223-235 makeUIView 时套一次。web 参照 public/settings.tsDEFAULT_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. REDAppTheme.allCases 顺序 = 跟随系统 → 深色 → 浅色选择器顺序即此顺序UI 不重排)。
  4. REDAppTheme(rawValue:) 白名单:"system"/"dark"/"light" 命中,其它(含空串、"Dark")→ nil。

B. 持久化(AppThemeCodec 纯函数 + ThemeStore over fake defaults 5. REDdecode(nil)(从未设置)→ .dark —— 默认必须等于今天硬锁的深色(零回归)。 6. REDdecode("solarized") / decode("") / decode("DARK").dark(未知值不崩、不落 system。 7. RED三档 encode → decode 往返恒等。 8. REDThemeStore 空 defaults → .dark,且不写盘(首次读不产生写)。 9. REDselect(.light)theme == .light 且 defaults 落 "light";同一 defaults 新建 store → .light(跨启动)。 10. REDselect 同一档两次 → 只写一次(不产生多余写)。 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. REDstatusWorking/statusWaiting/statusStuck/timelineTool/timelineUserlightdark 下解析值不同(即:真的有浅色变体,不是解锁开关就完事)。 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/stucklight 档仍两两可辨(不因变暗而糊成一片)。 18. REDaccentSoft 两档不同,且都仍是 washalpha < 0.3,不许变成实心块)。 19. REDonAccent 两档相同(金色填充在两档都要深色墨,故它是固定值),且与 accent 对比 ≥ 4.5:1。

E. 终端画布跟随主题(TerminalPalette 20. REDcolors(for: .dark).background == #100F0D.foreground == #ECE9E3(桌面 web --bg/--text,零回归)。 21. REDcolors(for: .light).background == #F6F7F9.foreground == #1A1D24(镜像 web THEMES.light)。 22. RED两档 fg↔bg 对比度 ≥ 7:1终端是正文AAA 门槛)。 23. REDcaret / selection 用该档 accent 解析值(不写死深色档的金)。 24. REDDS.Palette.terminalBackgroundUIColor()动态 UIColorlight/dark 解析值不同 (今天是单值常量 → RED

F. Dynamic Type 不破版(UIHostingController.sizeThatFits / UIKit systemLayoutSizeFittingAX5 25. REDGateBanner 在 320pt 宽容器、.accessibility5 下:宽度 ≤ 320无横向溢出、 高度有限且 > 标准字号高度(= 真的长高而不是被裁)。 26. REDTelemetryChiplineLimit(1) 的等宽数字)在 AX5 下宽度 ≤ 320AX2 与 AX5 同高 DS.Typography.numericClamp 夹取生效 —— 表格数字不许炸版;落地时把原计划的 "增幅 ≤ 2.2×"换成了这条更强、更确定的等式断言)。 27. REDdsMetaText() 的元信息行在 AX5 下同样受夹取(与 26 同一策略,单一出处)。 28. REDDSButtonStyle 在 AX5 下高度 ≥ DS.Layout.minHitTarget 且标签可换行不横向溢出。 29. RED外部件度量,越界只报不改AX5 下键帽两行所需高度 vs 固定 barHeight44+8。 实测 108.24pt vs 52pt → 裁切footnote 行高 52.51 + caption2 行高 47.73 + 内衬 8Components/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.tsGET /?join=<id>DeepLinkRouter.swift:56-65 今天只认 webterminal://open http(s) 一律 .ignoreDeepLinkRouterTests.swift:84 钉住了它 —— 本任务有意改写该用例)。 主机身份仍HostStore 解析(HostEndpoint.originHeader 是冻结的唯一 origin 派生点)。 文件 ios/App/WebTermTests/DeepLinkRouterTests.swift(增补 + 有意改写 1 例)。

A. 接受 web 分享形状 30. REDhttp://192.168.1.5:3000/?join=<v4>.joinShared(origin:"http://192.168.1.5:3000", sessionId:)。 31. REDhttps://mac.ts.net/?join=<v4> → origin 省略默认端口(:443 不出现)。 32. REDHTTP://192.168.1.5:3000/?join=<v4>(大写 scheme/host→ origin 归一化为小写。 33. REDpath 为 """/" 都接受(location.origin + "/?join=" 产出 /)。

B. 白名单纪律http(s) 是比自定义 scheme 大得多的输入面,故更严**** 34. REDjoin 值非 v4Validation.isValidSessionId 判定)→ .ignore。 35. RED重复 join 键 → .ignore(歧义输入绝不部分应用)。 36. RED多余 query 键/?join=<v4>&x=1)→ .ignoreweb 形状精确只有一个键)。 37. RED非空 path/manage.html?join=<v4>)→ .ignore。 38. RED带 fragment/?join=<v4>#x)→ .ignore。 39. RED带 userinfohttp://u:p@host/?join=<v4>)→ .ignore(二维码钓鱼形状)。 40. RED非 http(s) schemeftp://file://javascript:)→ .ignore。 41. RED?join=无 hosthttp:///?join=<v4>)→ .ignore。 42. RED有意改写既有用例https://open?host=<v4>&join=<v4>.ignore —— 但理由从"scheme 不是 webterminal"变成"web 形状只许单一 join 键"。 原用例名/注释同步改写,避免留下已失效的断言语义。 43. REDwebterminal://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.originHeaderhttp://host:3000http://HOST:3000/ 视为同一主机; http://host:3001(端口不同)不是同一主机。 48. REDhttps://host/http://host/scheme 不同)不是同一主机。 49. RED冷启动 stash 对 .joinShared 同样生效ready 前 handle → markReady 后恰应用一次)。 50. RED非法 web 链接 → ignoredCount + 1,且不入 stash(与既有 .ignore 同一通路)。