feat(ipad): T-iPad-1 — device family [1,2] + iPad orientations + CI leg

Opens WebTerm to iPhone + iPad: TARGETED_DEVICE_FAMILY 1→1,2 (project + all
targets), iPad gets all four orientations (~ipad key), ios.yml gains an
ipad-tests leg (iPad Pro 11 sim). Built plist verified UIDeviceFamily [1,2];
app launches natively full-screen on iPad Pro 11 sim (no letterbox). Adaptive
layout (NavigationSplitView) lands in T-iPad-2. Plan: docs/PLAN_IOS_IPAD.md.
This commit is contained in:
Yaojia Wang
2026-07-05 18:29:30 +02:00
parent 9c0097a305
commit 77502ec4fe
3 changed files with 265 additions and 6 deletions

View File

@@ -80,6 +80,25 @@ jobs:
-destination 'platform=iOS Simulator,name=iPhone 16' \
test
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
# runner); the Swift ServerHarness then boots the server itself.

231
docs/PLAN_IOS_IPAD.md Normal file
View File

@@ -0,0 +1,231 @@
# PLAN_IOS_IPAD.md — iPad 适配(自适应布局,非分叉)
> 落地方案文档。目标:让已完成的 iPhone 客户端([PLAN_IOS_CLIENT.md](./PLAN_IOS_CLIENT.md)P0+P1 已交付,分支 `feat/ios-client`**原生适配 iPad**——大屏分栏、双向布局、指针/硬件键盘,而**不分叉出第二套 UI**。
> 拓扑决策:**单一代码库 + size-class 自适应**——`NavigationSplitView` 在 regular 宽度iPad 全屏/大分屏)给 sidebar+detail在 compact 宽度iPhone、iPad Slide Over/小分屏)**自动退化为现有 stack**。iPhone 行为字节级不变。
> 状态:**规划中2026-07-05未开工**。
> 本文是 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/方向 `[ ]` · ~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 `[ ]` · ~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 自适应 + 指针上下文菜单 `[ ]` · ~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 大屏化 `[ ]` · ~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
- **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 非目标多窗口另立计划 |
**待你拍板**
1. iPad 最低系统版本iPadOS 17 iPhone 一致还是抬到 18/26
2. 本期是否要指针右键上下文菜单T-iPad-3 后半)——纯锦上添花可砍到后续
3. 多窗口拖会话开新窗并排两终端确认放到下一期
**工作量合计**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 计划管协议/会话/安全模型本文只加自适应布局不改之

View File

@@ -19,7 +19,7 @@ settings:
SWIFT_VERSION: "6.0" # Swift 6 language mode
SWIFT_STRICT_CONCURRENCY: complete
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
TARGETED_DEVICE_FAMILY: "1" # iPhone only (iPad layout is out of v1 scope)
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad (T-iPad-1; adaptive layout)
CODE_SIGN_STYLE: Automatic
packages:
@@ -55,9 +55,10 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.yaojia.webterm
# Target-level on purpose (T-iOS-19 finding): XcodeGen's target platform
# default ("1,2") overrides a project-level value, shipping an
# installable-but-untested iPad layout. Target-level wins.
TARGETED_DEVICE_FAMILY: "1"
# default overrides a project-level value. iPad adaptation (T-iPad-1)
# opens this to iPhone + iPad; the adaptive layout (LayoutPolicy /
# NavigationSplitView, T-iPad-2) makes the iPad layout first-class.
TARGETED_DEVICE_FAMILY: "1,2"
info:
path: App/WebTerm/Resources/Info.plist
properties:
@@ -69,10 +70,18 @@ targets:
CFBundleURLTypes:
- CFBundleURLName: com.yaojia.webterm.deeplink
CFBundleURLSchemes: [webterminal]
# iPhone: portrait + both landscapes (no upside-down — matches P0).
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
# iPad (T-iPad-1): all four orientations — iPad users hold it any way,
# and Split View / Stage Manager assume full orientation freedom.
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
# ── Security-critical keys, transcribed verbatim from PLAN_IOS_CLIENT §5.2 ──
# NO NSAllowsArbitraryLoads anywhere (release OR debug): the five CIDR
# exceptions below cover LAN + hotspot + Tailscale CGNAT + simulator
@@ -114,7 +123,7 @@ targets:
settings:
base:
GENERATE_INFOPLIST_FILE: true
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/WebTerm.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/WebTerm"
BUNDLE_LOADER: "$(TEST_HOST)"
@@ -131,7 +140,7 @@ targets:
settings:
base:
GENERATE_INFOPLIST_FILE: true
TARGETED_DEVICE_FAMILY: "1"
TARGETED_DEVICE_FAMILY: "1,2"
TEST_TARGET_NAME: WebTerm
schemes: