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

20 KiB
Raw Blame History

PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)

落地方案文档。目标:让已完成的 iPhone 客户端(PLAN_IOS_CLIENT.mdP0+P1+P2 已交付,已合入 develop原生适配 iPad——大屏分栏、双向布局、指针/硬件键盘,而不分叉出第二套 UI。 拓扑决策:单一代码库 + size-class 自适应——NavigationSplitView 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)自动退化为现有 stack。iPhone 行为字节级不变。 状态:已交付并合入 developT-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}.swiftComponents/TerminalContextMenu.swiftScreens/ProjectsLayout.swiftLayoutPolicyTests/SidebarSelectionTests/ KeyBarVisibilityTests/TerminalContextMenuTests/ProjectsLayoutTests/ProjectsLayoutUITests 均在树内; project.yml 三个 target 的 TARGETED_DEVICE_FAMILY 均为 "1,2"ios.yml 有 iPad 单测腿与 iPad UI-test 腿。 本文是 iPhone 计划之上的布局适配层,不改协议/会话模型/纯逻辑包;沿用 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 该条——iPad 自己就是宽屏 writer。
  • 双向布局横竖屏、Split View、Slide Over、Stage Manager 尺寸变化全程 size-class 自适应,绝不写死方向。
  • 硬件键盘为一等公民iPad 常接键盘——现有 UIKeyCommand 全键位复用;软键盘 KeyBarinputAccessoryView)在有硬件键盘时可隐、无则保留。
  • 指针/悬停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. 契约(自适应决策的可测核)

// 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 全部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.ymldevice family、iPad 方向、必要 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 产物capability↔usage 一一对应不变)

W1 · 自适应导航壳(核心)

T-iPad-2 · AdaptiveRootView + NavigationSplitView + LayoutPolicy [x] · ~2 pd

  • Owns: Wiring/{AdaptiveRootView,SplitRootView,LayoutMode}.swift(新)、Wiring/RootView.swift(改:现有 stack 抽成 StackRootView 子视图供 compact 复用)、Wiring/AppCoordinator.swiftsidebar 选中 ↔ 路由最小桥)、对应测试
  • 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 从 regular→compact 切换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}.swiftScreens/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 ManagerDEFERRED,手工清单在 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]、五段 CIDR、usage 一一对应);分栏后隐私遮罩仍遮 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 + W23∥42 + W3 0.5 ≈ 5 人日(并行后墙钟更短)。


8. 与现有文档的关系

  • 本文只改 ios/App/WebTerm/** + project.yml/ios.ymlios/Packages/**src/public/ 零改动。
  • PLAN_IOS_CLIENT.md布局适配层,复用其 §3 契约/§4 标准/§5 安全/§6 并行规则;把该计划 §0 非目标里的「iPad 优化布局」提取为本期。
  • 多设备共享 PTY 的尺寸张力(PROGRESS_LOG 记录的「宽桌面+窄手机折行」iPad 因自身是宽屏 writer 而天然缓解,非本计划新机制。
  • 冲突裁决iPhone 计划管协议/会话/安全模型;本文只加自适应布局,不改之。