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.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -146,4 +146,284 @@ A1 必须一次把**后续所有波次需要的 Info.plist 键**都加好(它
## 4. P2 RED 测试清单(由各 P2 builder 在开工第一步追加到本节)
<!-- C3 / C4 在此追加 T-iOS-31/33/34/35 的 RED 清单 -->
### 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` 同一通路)。