Files
web-terminal/docs/PLAN_IOS_IPAD.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

239 lines
20 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.

# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1+P2 已交付,**已合入 `develop`****原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
> 状态:**已交付并合入 `develop`**T-iPad-1…4 全部落地T-iPad-5 验收 PASS_WITH_FINDINGS4/4 findings 已修,真机项 DEFERRED
> 逐任务状态见 §5 各任务标题上的方框;**Steps 里的 `[ ]` 是原始规格清单,不是状态**(执行记录在 `PROGRESS_LOG.md`)。
> 2026-07-30 由 ios-completion 收尾波按源码核对:`Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`、
> `Components/TerminalContextMenu.swift`、`Screens/ProjectsLayout.swift` 与 `LayoutPolicyTests`/`SidebarSelectionTests`/
> `KeyBarVisibilityTests`/`TerminalContextMenuTests`/`ProjectsLayoutTests`/`ProjectsLayoutUITests` 均在树内;
> `project.yml` 三个 target 的 `TARGETED_DEVICE_FAMILY` 均为 `"1,2"``ios.yml` 有 iPad 单测腿与 iPad UI-test 腿。
> 本文是 iPhone 计划之上的**布局适配层**,不改协议/会话模型/纯逻辑包;沿用 [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) 的 §3 契约、§4 工程标准、§5 安全模型、§6 并行规则。冲突以 iPhone 计划为准。
> **G1 日志铁律**`PROGRESS_LOG.md` 由 orchestrator 独写;被派 subagent 不写 LOG在返回消息末尾附可粘贴条目。
---
## 0. 目标与范围
### 做什么
在 iPadiPadOS 17+)上把「口袋驾驶舱」升级为「桌面级驾驶舱」,利用大屏做手机做不到的事:
- **分栏常驻**:左 sidebar = 会话列表(+ Projects 入口),右 detail = 终端 + gate/digest 叠层——不用来回 push/pop一眼看全 + 直接介入。
- **终端更宽**iPad 全屏能放下接近桌面的列数,直接缓解「宽桌面 + 窄手机同看一个全屏 TUI 折行成竖条」的多设备张力(见 [PROGRESS_LOG](./PROGRESS_LOG.md) 该条——iPad 自己就是宽屏 writer。
- **双向布局**横竖屏、Split View、Slide Over、Stage Manager 尺寸变化全程 size-class 自适应,绝不写死方向。
- **硬件键盘为一等公民**iPad 常接键盘——现有 `UIKeyCommand` 全键位复用;软键盘 KeyBar`inputAccessoryView`)在有硬件键盘时可隐、无则保留。
- **指针/悬停**iPadOS 指针 hover 高亮、右键次要点击上下文菜单kill/新建/在 cwd 开)。
### 不做(本期范围外)
- **多窗口 / 多场景(`UISceneConfiguration` 多实例、拖拽会话到新窗口)**iPad 能开两个终端并排是诱人的,但涉及场景生命周期重构 + 每场景独立 SessionEngine单列一期见 §7 后续)。本期 = 单场景自适应分栏。
- **Stage Manager 外接显示器专属布局**:本期只保证 Stage Manager 下尺寸变化不崩、布局自适应;不做外接屏专属多窗排布。
- **Apple Pencil、拖放文件进终端、Mac Catalyst**:非目标(同 iPhone 计划 §0
- **纯逻辑包改动**`WireProtocol/SessionCore/HostRegistry/APIClient` 与设备无关,**一行不改**(若发现某常量隐含 iPhone 假设 → 回对应包 owner不在本计划直改
- **服务器 / `public/` / `src/`**零触点iPad 只是又一个说同一协议的客户端)。
### 关键约束iPhone 零回归(硬性)
**任何自适应改动必须先满足 compact 宽度 == 现有 iPhone 行为**。每个任务的验收都含一条「iPhone 模拟器全套件仍绿 + 目视无变化」。self-checkcompact size class 下走的代码路径应与改动前逐帧一致(分栏只在 regular 宽度激活)。
---
## 1. 整体架构 / 适配策略
```
size class 驱动的单一根视图AdaptiveRootView
┌─────────────────────────────┴─────────────────────────────┐
horizontalSizeClass == .compact horizontalSizeClass == .regular
iPhone / iPad Slide Over / 小分屏) iPad 全屏 / 大分屏 / Stage Manager 大窗)
│ │
现有 NavigationStack NavigationSplitView
列表 → push 终端(字节级不变) ├ sidebar: 会话列表 + Projects section
└ detail : TerminalContainerViewgate/digest 叠层)
└───────────────── 共享同一 AppCoordinator / 同一 SessionEngine ─────────────────┘
隐私遮罩ZStack 顶层,!= .active· scenePhase · deep link 全部设备无关,原样复用
```
**为什么自适应而非 iPad 分叉**`horizontalSizeClass` 是同一 App 内运行时可变量iPad 拉出 Slide Over 立刻从 regular 变 compact——分叉两套 UI 无法应对同一次运行内的尺寸切换,且双倍维护。自适应 = 一套代码、一处 size-class 分支、compact 分支就是现有已测代码。
**为什么 detail 复用 TerminalContainerView 不动**:终端 + gate + digest 的组装T-iOS-15/24 的 `TerminalContainerView`)与它挂在 push destination 还是 split detail 无关——只换外层容器内容零改。SessionEngine 单活 WS、latest-writer-wins、隐私遮罩、生命周期全部设备无关直接复用。
**列宽收益(自动,非新逻辑)**:终端列数由 SwiftTerm 的 `sizeChanged` 从其视图宽度 + 字号算出T-iOS-11 已实现detail 面板越宽 → cols 越多 → 越接近桌面宽度。**无新代码**,是分栏的自然结果。
**依赖方向**iPad 适配全部落在 **App 胶水层**`ios/App/WebTerm/**`);纯包不动;自适应「决策」抽成纯函数(`LayoutMode` 之于 size class仿 `PrivacyShadePolicy` 先例以便单测SwiftUI 布局本身靠模拟器目视 + XCUITest。
---
## 2. 目录结构(新增/改动)
```
ios/App/WebTerm/
├── Wiring/
│ ├── RootView.swift # 改:抽 compact 分支为子视图,加 regular 分支入口T-iPad-2
│ ├── AdaptiveRootView.swift # ★新size-class 分支 + LayoutMode 决策消费T-iPad-2
│ ├── SplitRootView.swift # ★新NavigationSplitViewsidebar+detailT-iPad-2
│ ├── LayoutMode.swift # ★新:纯函数 size class → LayoutMode.stack/.splitT-iPad-2
│ └── AppCoordinator.swift # 改sidebar 选中态 ↔ 现有 terminal/projects 路由桥(最小增量)
├── Screens/
│ ├── SessionListScreen.swift # 改sidebar 语境下的选中高亮 + Projects section自适应compact 不变)
│ └── ProjectsScreen.swift # 改regular 宽度下作为 sidebar section / 多列网格compact 仍是 sheet
├── Components/
│ ├── KeyBar.swift # 改硬件键盘在场时可隐T-iPad-3
│ └── TerminalContextMenu.swift # ★新:指针右键/长按上下文菜单kill/新建/cwd 开T-iPad-3
├── project.yml # 改TARGETED_DEVICE_FAMILY "1,2" + iPad 方向/plistT-iPad-1
└── WebTermTests/ , WebTermUITests/ # 各任务的测试
```
`ios/Packages/**` 零改动。
---
## 3. 契约(自适应决策的可测核)
```swift
// LayoutMode.swift — 纯函数,唯一 size-class 决策点(仿 PrivacyShadePolicy
public enum LayoutMode: Equatable { case stack, split }
public enum LayoutPolicy {
/// regular 宽度 → splitsidebar+detailcompact → stack现有 iPhone 路径)。
/// 唯一判据,禁止在视图里散落 sizeClass 分支。
public static func mode(horizontalSizeClass: UserInterfaceSizeClass?) -> LayoutMode
}
// SidebarSelection — split 模式下 sidebar 选中态(与现有 AppCoordinator 路由等价映射)
enum SidebarItem: Hashable { case session(UUID), newSession, projects }
```
隐私遮罩、scenePhase、deep link、SessionEngine 契约**沿用 iPhone 计划 §3不重定义**。
---
## 4. 工程标准
沿用 [PLAN_IOS_CLIENT.md §4](./PLAN_IOS_CLIENT.md) 全部TDD 强制、不可变、文件 ≤400 行、函数 <50 早返回无魔法数字系统边界验证显式错误conventional commits覆盖率仅计 4 个纯包)。追加两条 iPad 专属
- **size-class 分支只出现在一处**`LayoutPolicy`视图内严禁散落 `if sizeClass == …`
- **iPhone 零回归是每个任务的验收前置**compact 路径 == 改动前)。
---
## 5. 任务清单
> 状态图例 `[ ]`/`[~]`/`[x]`/`[!]` 同 iPhone 计划。ID 稳定:`T-iPad-N`。测试框架 Swift TestingXCUITest 仅 UI happy path。
### W0 · 可安装性(串行,先行)
#### T-iPad-1 · device family + iPad plist/方向 `[x]` · ~0.5 pd
- **Owns**: `ios/project.yml`device familyiPad 方向必要 plist)、`.github/workflows/ios.yml` iPad 模拟器测试腿
- **Depends**:
- **Steps(测试先行)**:
- [ ] `TARGETED_DEVICE_FAMILY` `"1"` `"1,2"`project + target level——注意 XcodeGen target 默认覆盖 iPhone 计划 W5 finding target 显式设
- [ ] iPad 方向键全开`UISupportedInterfaceOrientations~ipad` Portrait/Landscape/UpsideDowniPhone 方向键**不动**
- [ ] ATS 五段 CIDR / usage description 原样设备无关复核仍在
- [ ] `xcodegen generate` 拆构建产物 plist 断言 `UIDeviceFamily [1,2]`
- [ ] ios.yml 加一条 `xcodebuild test` 腿跑 iPad 模拟器iPad Pro 11" iPad (A16)
- **Accept**: iPad 模拟器 `xcodebuild build` 通过空跑现有全套件绿此刻仍是 iPhone 布局放大不崩产物 plist `[1,2]`
- **安全注**: device family 放开后 T-iPad-5 ipa 核对须覆盖 iPad 产物capabilityusage 一一对应不变
### W1 · 自适应导航壳(核心)
#### T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy `[x]` · ~2 pd
- **Owns**: `Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift`)、`Wiring/RootView.swift`现有 stack 抽成 `StackRootView` 子视图供 compact 复用)、`Wiring/AppCoordinator.swift`sidebar 选中 路由最小桥)、对应测试
- **Depends**: T-iPad-1
- **Steps(测试先行, RED)** `LayoutPolicyTests` + `SidebarSelectionTests`:
- [ ] `LayoutPolicy.mode(.compact) == .stack``.mode(.regular) == .split``.mode(nil) == .stack`未知按最保守 = 现有路径)
- [ ] SidebarItem AppCoordinator 路由等价`.session(id)` == 现有 open(id)`.newSession` == open(nil)`.projects` == 现有 projects 呈现——**同一 coordinator APIsplit 只是另一个触发面**断言不新增会话生命周期路径
- [ ] compact 分支渲染的视图树 == 改动前 `RootView`快照/结构断言隐私遮罩仍 ZStack 顶层scenePhase/deepLink/sheet 全在
- [ ] size class regularcompact 切换iPad Slide Over时选中态保持终端不重建复用 T-iOS-29 `.id(controller.id)` 稳定性断言 controller 不换
- **Steps(实现, GREEN)**:
- [ ] `AdaptiveRootView` `@Environment(\.horizontalSizeClass)` `LayoutPolicy.mode` `StackRootView`现有`SplitRootView`
- [ ] `SplitRootView` = `NavigationSplitView { sidebar } detail { TerminalContainerView 或空态 }`sidebar = SessionListScreen选中绑定+ Projects section 入口detail = 「选择或新建会话」占位
- [ ] 隐私遮罩/scenePhase/deepLink/task(bootstrap) 上提到 `AdaptiveRootView`两分支共享绝不重复布线
- **Accept**: iPad 模拟器横竖屏 + Split View 拉入拉出全程分栏/退栈自适应无崩**iPhone 模拟器全套件绿 + 目视逐屏无变化**`LayoutPolicy` 覆盖率 100%
- **安全注**: 隐私遮罩必须仍是**两分支共同的** ZStack 顶层——split detail 的终端字节同样要被 `!= .active` 遮住新增一条 iPad 遮罩目视验收Stage Manager 切走 detail 终端被遮
### W2 · 逐面适配(并行,文件互斥)
#### T-iPad-3 · 终端面板KeyBar 自适应 + 指针上下文菜单 `[x]` · ~1 pd
- **Owns**: `Components/{KeyBar,TerminalContextMenu}.swift``Screens/TerminalScreen.swift`增量)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-4
- **Steps(测试先行)**:
- [ ] KeyBar 可见性纯谓词`GCKeyboard.coalesced != nil`硬件键盘在场)→ 默认隐 用户可手动切谓词单测硬件态注入
- [ ] 上下文菜单右键/长按= {在 cwd 开新会话kill复制选区}——动作复用现有 OpenRequest/killSession 通道断言不新增网络路径kill 仍带 Origin
- [ ] 指针 hover 高亮不改字节流 UI
- **Accept**: iPad 接键盘时 KeyBar 自动隐去键盘复现右键菜单在 iPad 生效iPhone 无硬件键盘时 KeyBar 行为不变
- **安全注**: 上下文菜单的 kill/开会话是既有 G/RO 通道的又一触发面——测试名标复用 APIClient无绕过
#### T-iPad-4 · Projects/Timeline/sheet 大屏化 `[x]` · ~1 pd
- **Owns**: `Screens/ProjectsScreen.swift`增量)、Projects/Timeline 呈现方式regular `.sheet` /`.presentationDetents` sidebar section)、测试
- **Depends**: T-iPad-2 · **Parallel-safe**: T-iPad-3
- **Steps(测试先行)**:
- [ ] Projects 网格列数随宽度自适应纯函数compact=1 列现状regular=多列)——列数决策单测
- [ ] regular 宽度下 Projects 作为 sidebar section detail compact 仍走现有 sheet分组/收藏/prefs 往返逻辑零改——只换容器
- [ ] Timeline/其它 sheet iPad 用合适 detent/尺寸不占满全屏
- **Accept**: iPad Projects 多列Timeline 合理尺寸iPhone 全部走原有 sheet 路径不变prefs 未知键往返T-iOS-38 不变量回归绿
### W3 · 验收report-only, G4
#### T-iPad-5 · iPad 验收 + iPhone 回归 + 安全核对 `[~]` · ~0.5 pd
- **已执行**模拟器侧 PASS_WITH_FINDINGSiPhone 16 / iPad Pro 11 双套件绿iPhone 零回归硬门守住4 findings2 MED / 2 LOW orchestrator 修完 4/4iPad 分栏 sidebar+detail 截图确认
- **缺口**:① 真机 iPad分栏手势 / 硬件键盘全键位 / 指针 hover 右键 / Stage Manager**DEFERRED**手工清单在 `PROGRESS_LOG.md`;② iPad happy-path XCUITest **接受推迟**split 选中逻辑已由 `SidebarSelectionTests` 覆盖单跑 711 min 且脆);③ **release ipa **核对未做免费个人 team 无分发通道)—— T-iOS-19 同一缺口 P2 新增的麦克风/语音识别 usage description 也应一并核
- **Depends**: T-iPad-2/3/4 · **Owns**: 无源码report-onlyfindings 派回 owner
- **Steps**:
- [ ] **F-iPad 走查**(§6 清单逐条分栏横竖屏Split View/Slide Over/Stage Manager 尺寸切换硬件键盘/指针终端更宽列数遮罩覆盖 detail
- [ ] **iPhone 零回归**iPhone 16 全套件绿 + 逐屏目视对比无变化compact 路径未动
- [ ] **安全** iPad 产物 ipa 核对UIDeviceFamily [1,2]、五段 CIDRusage 一一对应分栏后隐私遮罩仍遮 detail 终端上下文菜单/键盘触发的 G 调用仍走 APIClient Origin
- [ ] 真机项 iPad 手势/键盘/指针目视Stage Manager 实机)→ DEFERRED附手工清单
---
## 6. 测试与验收
### 设备矩阵
| | 环境 |
|---|---|
| 纯逻辑LayoutPolicy/谓词 | macOS `swift test` / 宿主 WebTermTests |
| App 自适应 | iPhone 16compact 回归+ iPad Pro 11"regular+ **iPad Split View同一次运行内 regular↔compact 切换)** |
| XCUITest | iPad happy path 1 sidebar 选会话 detail attach 输入 gate approve复用 iPhone happy path 骨架换分栏断言 |
| 真机必测 | iPad 分栏手势硬件键盘全键位指针 hover/右键Stage Manager1 DEFERRED 至有设备 |
### 验收演示脚本F-style
- **F-iPad-1** iPad 全屏 sidebar 会话列表 + detail 终端同屏选另一会话 detail 即时切换回放恢复)。
- **F-iPad-2** 横竖屏旋转 布局自适应终端 resize 重绘无错位
- **F-iPad-3** 拉出 Slide Overcompact)→ 自动退化为 stack== iPhone 布局推回全屏 恢复分栏全程会话不断终端不重建
- **F-iPad-4** iPad 接硬件键盘 KeyBar 自动隐`UIKeyCommand` 全键位可用拔键盘 KeyBar 回来
- **F-iPad-5** 指针右键会话行/终端 上下文菜单kill/新建/cwd 生效
- **F-iPad-6** iPad 全屏终端列数明显多于 iPhone接近桌面 TUI 折行大幅缓解
- **F-iPad-7** Stage Manager/切后台 detail 终端被隐私遮罩覆盖快照不泄露)。
- **F-iPad-8**回归iPhone 16 逐屏与适配前目视一致全套件绿
---
## 7. 风险与开放问题
| 风险 / 问题 | 影响 | 缓解 / 待定 |
|---|---|---|
| `NavigationSplitView` size-class 频繁切换Slide Over 反复拉时状态/选中丢失 | | 选中态提升到 AppCoordinator设备无关单一真值T-iPad-2 专项测切换保持终端 `.id(controller.id)` 稳定性已在 P1 验证 |
| 隐私遮罩在 split detail 漏遮 | 安全 | 遮罩上提到 AdaptiveRootView 两分支共享 ZStack 顶层T-iPad-2/5 双重目视 |
| iPhone 回归compact 分支被无意改动 | | 每任务验收前置 iPhone 全绿 + 目视compact 分支复用现有 StackRootView 原样 |
| SwiftTerm 在超宽 detail 的性能/选择手感 | | 复用 iPhone 已测路径真机目视 |
| 多窗口诱惑导致 scope 膨胀 | | 本期明确单场景(§0 非目标多窗口另立计划 |
**待你拍板** —— 三项均已落定2026-07-30 按源码核对
1. ~~iPad 最低系统版本~~ **已定**iPadOS **17** iPhone 一致`project.yml` 单一 `deploymentTarget: iOS 17.0`无独立 iPad 下限)。
2. ~~本期是否要指针右键上下文菜单~~ **已做**`Components/TerminalContextMenu.swift`复制选区 / cwd 开新会话 / 结束会话全部路由既有通道kill 仍经 APIClient Origin+ `TerminalContextMenuTests`
3. ~~多窗口~~ **确认推迟**本期单场景(§0 非目标拖会话开新窗并排两终端仍未开工另立计划
**工作量合计**W0 0.5 + W1 2 + W2342 + W3 0.5 **5 人日**并行后墙钟更短)。
---
## 8. 与现有文档的关系
- 本文只改 `ios/App/WebTerm/**` + `project.yml`/`ios.yml``ios/Packages/**``src/``public/` 零改动
- [PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md) **布局适配层**复用其 §3 契约4 标准5 安全6 并行规则把该计划 §0 非目标里的iPad 优化布局提取为本期
- 多设备共享 PTY 的尺寸张力[PROGRESS_LOG](./PROGRESS_LOG.md) 记录的宽桌面+窄手机折行」):iPad 因自身是宽屏 writer **天然缓解**非本计划新机制
- 冲突裁决iPhone 计划管协议/会话/安全模型本文只加自适应布局不改之