Files
pangolin/docs/superpowers/plans/2026-06-22-ios-ipad-support.md
T
wangjia e11c06225d plan(client/ios): iOS+iPad 支持实施计划(6 阶段/12 任务,TestFlight 里程碑)
A 补 libbox / B Xcode 接线 / C Provider 重写对齐 macOS / D 真机连通+内存双闸 /
E iPad 横竖屏适配 / F TestFlight 分发。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 19:22:05 +08:00

619 lines
26 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.
# iOS + iPad 支持 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 Pangolin Flutter 客户端的 iOS 隧道从「能编译的 M3 PoC 桩」推进到「iPhone/iPad 真机真正连通、扛得住 NE 内存红线、可推 TestFlight」,iPad 共用同一 iOS target 并完成布局+横屏适配。
**Architecture:** iOS PacketTunnel 扩展按 macOS 已验证的 libbox 集成方式重写(`LibboxNewCommandServer` + `LibboxPlatformInterfaceProtocol` + 真 utun fd),删除 PoC 的 socketpair 手工桥接;主 App 侧 `VpnManager` 已基本可用,仅需对齐配置 key。iPad 复用已有 `form_factor.dart`(宽≥600 自动走 `TabletShell`+ `tablet_shell.dart`,补横竖屏 orientation 与大屏校验。分发走 App Store Connect / TestFlight。
**Tech Stack:** Swift / NetworkExtension / libbox(sing-box v1.13.13 via sagernet gomobile fork) / Flutter(Riverpod) / Xcode / App Store Connect。
## Global Constraints
每个任务的要求都隐含包含本节。值均逐字取自设计稿与代码。
- **sing-box 版本**`v1.13.13`(与节点对齐,`SINGBOX_VERSION` 默认值)。
- **TeamID**`BYL4KQHMTN`
- **Bundle ID**:主 App `com.pangolin.pangolinVpn`;扩展 `com.pangolin.pangolinVpn.PacketTunnel`
- **App GroupiOS 格式)**`group.com.pangolin.pangolinVpn`(注意:非 macOS 的 `<TeamID>.` 前缀)。
- **扩展链接铁律**`Libbox.xcframework` **只 Link 不 Embed**(静态库);PacketTunnel target 设 `OTHER_LDFLAGS = ""`(切断 CocoaPods 继承标志,否则会把 `flutter_secure_storage` 链进扩展);`otool -L` 扩展二进制应**零外部 `@rpath` 依赖**。
- **libbox 集成铁律**`startTunnel` 在后台队列启 libbox(防三方死锁);`startOrReloadService` **传非空** `LibboxOverrideOptions()`(传 nil → SIGSEGV);`startDefaultInterfaceMonitor` **阻塞到首个 path 更新再返回**(否则 `no available network interface`)。
- **配置来源**sing-box 配置由服务端 `BuildClientConfig` 渲染、原样下发,客户端不拼;DNS 劫持首条规则随配置下发(服务端已处理,iOS 无需改)。
- **红线词**:用户可见文案不得出现 VPN / 翻墙 / 科学上网 等词;用「加速」口径。
- **内存红线**iOS NE 进程 ~50MB(旧设备 ~15MB),jetsam 超限杀进程——头号风险。
- **设计 token 单源**:颜色/间距走 token,禁硬编码 hex;`pangolin_tokens.gen.dart` 勿手改。
- **平台**iOS 12+,设备族 `"1,2"`iPhone+iPad,已设)。
- **Frameworks 不入 git**`Libbox.xcframework` 本地构建,加 `.gitignore` 排除。
---
## File Structure
| 文件 | 责任 | 动作 |
|---|---|---|
| `client/ios/Frameworks/Libbox.xcframework` | iOS+sim libbox 静态框架 | 构建产物放入(不入 git) |
| `client/ios/.gitignore` | 排除框架产物 | 追加一行 |
| `client/ios/Runner.xcodeproj/project.pbxproj` | 扩展链接 Libbox+系统框架、Team、OTHER_LDFLAGS | Xcode 改 |
| `client/ios/PacketTunnel/PacketTunnelProvider.swift` | NE 扩展:libbox 集成(重写对齐 macOS | 重写 |
| `client/ios/Runner/VpnManager.swift` | 主 App 侧隧道控制 | 微调(配置 key 已是 `config`,核对即可) |
| `client/ios/Runner/Info.plist` | iPad orientation | 改 |
| `client/test/responsive/form_factor_test.dart` | 断点判定测试 | 新建 |
| `client/lib/Runner/Info.plist`(导出合规) | `ITSAppUsesNonExemptEncryption` | 改 |
参照(只读,不改):`client/macos/PacketTunnel/PacketTunnelProvider.swift`(金标准)、`client/lib/core/responsive/form_factor.dart``client/lib/shell/tablet_shell.dart`
---
## Phase A — libbox iOS 框架(解阻塞)
### Task A1: 构建含 iOS 切片的 Libbox.xcframework 并放入 iOS 工程
**Files:**
- Create: `client/ios/Frameworks/Libbox.xcframework`(构建产物拷入)
- Modify: `client/ios/.gitignore`(追加排除)
**Interfaces:**
- Produces: `client/ios/Frameworks/Libbox.xcframework`,含 `ios-arm64`(真机) 与 `ios-arm64_x86_64-simulator`(模拟器) slice,供 Task B1 链接。
- [ ] **Step 1: 构建全 Apple xcframework**
Run(需 macOS + Xcode CLT + Go;耗时数分钟到十几分钟):
```bash
bash /Users/wangjia/code/pangolin/scripts/build-libbox.sh apple
```
Expected: 末尾打印 `✅ 产物: /tmp/pangolin-libbox/sing-box/Libbox.xcframework`
- [ ] **Step 2: 验证含 iOS 切片**
Run:
```bash
ls -1 /tmp/pangolin-libbox/sing-box/Libbox.xcframework/
```
Expected: 列出含 `ios-arm64``ios-arm64_x86_64-simulator``macos-arm64_x86_64`(可能还有 tvos)等目录。
- [ ] **Step 3: 拷入 iOS 工程**
Run:
```bash
rm -rf /Users/wangjia/code/pangolin/client/ios/Frameworks/Libbox.xcframework
mkdir -p /Users/wangjia/code/pangolin/client/ios/Frameworks
cp -R /tmp/pangolin-libbox/sing-box/Libbox.xcframework /Users/wangjia/code/pangolin/client/ios/Frameworks/
```
(保留 macOS 既有 `client/macos/Frameworks/Libbox.xcframework` 不动,避免扰动已验证的 macOS 构建。)
- [ ] **Step 4: gitignore 排除框架**
`client/ios/.gitignore` 追加一行(仿 macOS 的 `client/macos/.gitignore:10`):
```
**/Frameworks/Libbox.xcframework/
```
- [ ] **Step 5: 验证已忽略**
Run:
```bash
cd /Users/wangjia/code/pangolin && git status --porcelain client/ios/Frameworks/
```
Expected: 无输出(框架未被 git 跟踪)。
- [ ] **Step 6: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/ios/.gitignore
git commit -m "build(client/ios): 引入 iOS libbox 框架 + gitignore 排除产物"
```
---
## Phase B — Xcode 接线与签名
### Task B1: 把 Libbox + 系统框架链入 PacketTunnel 扩展(Link-only),设 Team 与 OTHER_LDFLAGS
**Files:**
- Modify: `client/ios/Runner.xcodeproj/project.pbxproj`(经 Xcode GUI 改,避免手改 pbxproj 出错)
**Interfaces:**
- Consumes: `client/ios/Frameworks/Libbox.xcframework`Task A1
- Produces: 可编译的 PacketTunnel 扩展 target,零外部 `@rpath` 依赖。
照 macOS 扩展 target 的链接清单(`macos/Runner.xcodeproj` 的 PacketTunnel Frameworks build phaseLibbox + NetworkExtension + libresolv + SystemConfiguration),iOS 把 AppKit 去掉。
- [ ] **Step 1: 打开工程**
Run:
```bash
open /Users/wangjia/code/pangolin/client/ios/Runner.xcworkspace
```
- [ ] **Step 2: 给 PacketTunnel target 链接框架(Link-only**
在 Xcode 选 `PacketTunnel` target → General → **Frameworks and Libraries**,添加并将 Embed 全设为 **Do Not Embed**
- `Frameworks/Libbox.xcframework`Add Other… 选 Task A1 拷入的)
- `NetworkExtension.framework`
- `SystemConfiguration.framework`
- `libresolv.tbd`
- [ ] **Step 3: 设 Build Settings**
`PacketTunnel` target → Build Settings
- `Other Linker Flags``OTHER_LDFLAGS`)→ 置为**空**(删除任何继承值;防 CocoaPods 标志把 `flutter_secure_storage` 链进扩展)。
- `Framework Search Paths``$(PROJECT_DIR)/Frameworks`
- `Development Team``DEVELOPMENT_TEAM`)→ `BYL4KQHMTN`
`Runner`(主 Apptarget → Build Settings`Development Team``BYL4KQHMTN`
- [ ] **Step 4: 确认 Signing & Capabilities**
`Runner``PacketTunnel` 两 target 的 Signing & Capabilities 均含:
- **App Groups** → `group.com.pangolin.pangolinVpn`
- `PacketTunnel`**Network Extensions** → Packet Tunnel Providerentitlements 文件已声明,确认未报红)。
- [ ] **Step 5: 编译验证**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter build ios --debug --no-codesign
```
Expected: 构建成功(Swift 此时尚未引用 libbox,仅验证链接配置不破坏构建)。
- [ ] **Step 6: 验证扩展零外部 @rpath 依赖**
Run:
```bash
otool -L /Users/wangjia/code/pangolin/client/build/ios/iphoneos/Runner.app/PlugIns/PacketTunnel.appex/PacketTunnel
```
Expected: 仅 `/usr/lib/*``/System/Library/Frameworks/*`**无** `@rpath/...`(尤其无 `flutter_secure_storage`、无 `@rpath/Libbox`——静态库不应出现)。
- [ ] **Step 7: 验证 Team 已写入**
Run:
```bash
grep -c "DEVELOPMENT_TEAM = BYL4KQHMTN" /Users/wangjia/code/pangolin/client/ios/Runner.xcodeproj/project.pbxproj
```
Expected: ≥ 2(主 App + 扩展)。
- [ ] **Step 8: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/ios/Runner.xcodeproj/project.pbxproj
git commit -m "build(client/ios): PacketTunnel 链接 libbox+系统框架(Link-only) + 设 Team/OTHER_LDFLAGS"
```
---
## Phase C — iOS Provider 重写(对齐 macOS 验证过的 libbox API
### Task C1: 用 macOS CommandServer+PlatformInterface 方式重写 iOS PacketTunnelProvider
**Files:**
- Modify(整体重写): `client/ios/PacketTunnel/PacketTunnelProvider.swift`
- Reference(只读): `client/macos/PacketTunnel/PacketTunnelProvider.swift`
- Keep: `client/ios/PacketTunnel/MemoryMonitor.swift`(复用)
**Interfaces:**
- Consumes: `LibboxNewCommandServer(_:_:_:)``LibboxPlatformInterfaceProtocol``LibboxOverrideOptions``LibboxSetup`(真实框架 API,与 macOS 一致);`VpnManager``options["config"]` 传配置、经 App Group key `lastConfigJson` 缓存。
- Produces: `class PacketTunnelProvider``final class PangolinPlatformInterface``final class InterfaceIterator``func simpleError(_:)`
**背景**:现有 iOS 文件用的是**不存在于真实框架的旧 API**(`LibboxNewService` / `LibboxPlatformInterface` / `openTun(_:)->Int32` + socketpair 桥接),链入真实 Libbox 后编不过。本任务用 macOS 金标准结构整体替换,只改三处:App Group 前缀、log subsystem、配置 key;并保留 `MemoryMonitor` 接入与精简 IPC。
- [ ] **Step 1: 以 macOS 文件为基底重写**
`client/macos/PacketTunnel/PacketTunnelProvider.swift` 全文作为基底写入 `client/ios/PacketTunnel/PacketTunnelProvider.swift`,并施加以下**精确改动**
(a) 顶部常量:
```swift
// macOS iOS
private let log = Logger(subsystem: "com.pangolin.pangolinVpn.PacketTunnel", category: "provider")
// iOS App Group group. macOS <TeamID>.
private let appGroup = "group.com.pangolin.pangolinVpn"
```
(b) 在 `final class PacketTunnelProvider` 顶部加内存监控成员:
```swift
final class PacketTunnelProvider: NEPacketTunnelProvider {
private var commandServer: LibboxCommandServer?
private var platform: PangolinPlatformInterface?
private let memoryMonitor = MemoryMonitor() // iOS 线
```
(c) `startTunnel` 成功处(`completionHandler(nil)` 之前)启动监控——在 `self.commandServer = server` 之后加:
```swift
self.commandServer = server
self.memoryMonitor.start() //
log.info("startTunnel: service started")
completionHandler(nil)
```
(d) `stopTunnel` 开头停监控并打峰值——在 `try? commandServer?.closeService()` 之前加:
```swift
log.info("stopTunnel reason=\(reason.rawValue)")
memoryMonitor.stop() //
log.info("memory peak: \(self.memoryMonitor.summaryString(), privacy: .public)") //
try? commandServer?.closeService()
```
(e) `resolveConfig` 的 options key 改为 `config`(匹配 `VpnManager.start`)并补 App Group 缓存回退:
```swift
private func resolveConfig(_ options: [String: NSObject]?) throws -> String {
if let inline = options?["config"] as? String, !inline.isEmpty {
return inline
}
// App Group UserDefaults VpnManager key=lastConfigJson
if let shared = UserDefaults(suiteName: appGroup),
let cached = shared.string(forKey: "lastConfigJson"), !cached.isEmpty {
return cached
}
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw simpleError("no app group container")
}
let content = (try? String(contentsOf: base.appendingPathComponent("config.json"),
encoding: .utf8)) ?? ""
guard !content.isEmpty else { throw simpleError("empty sing-box config") }
return content
}
```
(f) `handleAppMessage` 替换为支持 `getStatus` / `getMemory` 的 IPC(对齐 `VpnManager.sendProviderMessage`;删 PoC 的 socketpair/selectOutbound 等无对应内核 API 的分支):
```swift
override func handleAppMessage(_ messageData: Data,
completionHandler: ((Data?) -> Void)?) {
guard let req = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any],
let action = req["action"] as? String else { completionHandler?(nil); return }
var resp: [String: Any] = [:]
switch action {
case "getStatus":
resp["status"] = (commandServer != nil) ? "on" : "error"
case "getMemory":
resp["memory"] = memoryMonitor.summaryString()
resp["peakRssBytes"] = memoryMonitor.peakResidentBytes
default:
resp["error"] = "unknown action: \(action)"
}
completionHandler?((try? JSONSerialization.data(withJSONObject: resp)) ?? nil)
}
```
(g) `PangolinPlatformInterface``InterfaceIterator``simpleError` 三部分**原样照搬 macOS**iOS 全部 API 可用:`tunFd()` 的 utun 扫描、`IP_BOUND_IF`/`IPV6_BOUND_IF``getifaddrs` 在 iOS 同样有效,与 sing-box-for-apple 一致)。`import` 段照搬 macOS`Darwin/Foundation/Libbox/Network/NetworkExtension/os`,无 AppKit)。
- [ ] **Step 2: 编译验证(链入真实 libbox)**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter build ios --debug --no-codesign
```
Expected: 构建成功;不再出现 `LibboxNewService` / `LibboxPlatformInterface` 等旧符号未定义错误。
- [ ] **Step 3: 静态核对铁律**
Run:
```bash
grep -nE "DispatchQueue.global|LibboxOverrideOptions\(\)|firstUpdate.wait|options\?\[\"config\"\]" \
/Users/wangjia/code/pangolin/client/ios/PacketTunnel/PacketTunnelProvider.swift
```
Expected: 四项均命中(后台队列、非空 OverrideOptions、阻塞 interface monitor、config key)。
- [ ] **Step 4: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/ios/PacketTunnel/PacketTunnelProvider.swift
git commit -m "feat(client/ios): Provider 对齐 macOS libbox 集成(CommandServer+utun fd)+ 内存监控"
```
---
## Phase D — 真机连通与内存验收
### Task D1: iPhone 真机连通 + 内存基线
**Files:** 无代码改动(验证任务;如需签名修复改 `project.pbxproj`
**Interfaces:**
- Consumes: Task C1 的 Provider`VpnManager`(已实现 load/save/start/stop)。
- [ ] **Step 1: 真机安装运行**
USB 接入开发者已登记的 iPhone(设备 UDID 已在 App ID 描述文件中):
```bash
cd /Users/wangjia/code/pangolin/client && flutter run --release -d <iphone-device-id>
```
Expected: app 在真机启动。
- [ ] **Step 2: 触发连接并看日志**
app 内点连接。同时在 macOS 终端开 device console 过滤扩展日志:
```bash
log stream --predicate 'subsystem == "com.pangolin.pangolinVpn.PacketTunnel"' --info
```
Expected: 依次出现 `startTunnel``startTunnel: service started`**无** `no available network interface`、无 SIGSEGV 崩溃。
- [ ] **Step 3: 验证真出网**
真机连接态下,Safari 打开一个境外站点(如 example.com 以外的被墙站点)。
Expected: 能打开(DNS 解析不失败 + 数据走节点)。
- [ ] **Step 4: 读内存峰值**
连接保持 10 分钟后,app 内触发 `getMemory` IPC(或读 device console 的 `memory peak` 行)。
Expected: `peakRssBytes` < 设备 jetsam 上限(新设备目标 < 50MB;旧设备 < 15MB);进程未被系统杀。
- [ ] **Step 5: 记录结论**
把内存实测峰值与设备型号记到 `docs/ios-ipad-support-design.html` 的「头号风险」卡片下(追加一行实测数据)。若**超限** → 执行 Task D2;若达标 → 跳过 D2。
- [ ] **Step 6: Commit(如有文档更新)**
```bash
cd /Users/wangjia/code/pangolin
git add docs/ios-ipad-support-design.html
git commit -m "docs(client/ios): 记录 iPhone 真机连通 + NE 内存实测峰值"
```
### Task D2:(条件触发)内存超限 → 本地 .srs 预取瘦身
**仅当 Task D1 Step 4 内存超限时执行。** 头号风险缓解的第二道闸:避免启动期把远程 `geoip-cn.srs`/`geosite-cn.srs` 下载进 NE 进程内存。
**Files:**
- Modify: `server/internal/httpapi/clientconfig.go`rule_set 由 `type:"remote"` 改为本地 `type:"local",path:...`,仅对 iOS 客户端或全平台)
- Create: `client/ios/Runner/Resources/geoip-cn.srs``geosite-cn.srs`(随 app 打包)
- Modify: `client/ios/Runner/AppDelegate.swift`(启动时把 bundle 内 .srs 拷到 App Group 容器)
**Interfaces:**
- Consumes: 控制面 `/v1/rules/*.srs`(构建期预取一次)。
- Produces: App Group 容器内 `geoip-cn.srs`/`geosite-cn.srs` 本地文件;下发配置引用其本地路径。
- [ ] **Step 1: 预取 .srs 打入 app bundle**
Run(从节点拉一次,提交进仓库):
```bash
curl -fsSL http://<节点公网>:8080/v1/rules/geoip-cn.srs -o /Users/wangjia/code/pangolin/client/ios/Runner/Resources/geoip-cn.srs
curl -fsSL http://<节点公网>:8080/v1/rules/geosite-cn.srs -o /Users/wangjia/code/pangolin/client/ios/Runner/Resources/geosite-cn.srs
```
Expected: 两个 `.srs` 文件落地,非空。在 Xcode 把 `Resources/` 加入 Runner target 的 Copy Bundle Resources。
- [ ] **Step 2: 启动期拷到 App Group 容器**
`AppDelegate.swift``application(_:didFinishLaunchingWithOptions:)` 内,加把 bundle 内 `.srs` 拷到 `group.com.pangolin.pangolinVpn` 容器的逻辑(若不存在再拷):
```swift
let group = "group.com.pangolin.pangolinVpn"
if let base = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) {
for name in ["geoip-cn", "geosite-cn"] {
let dst = base.appendingPathComponent("\(name).srs")
if !FileManager.default.fileExists(atPath: dst.path),
let src = Bundle.main.url(forResource: name, withExtension: "srs") {
try? FileManager.default.copyItem(at: src, to: dst)
}
}
}
```
- [ ] **Step 3: 服务端配置引用本地路径**
`server/internal/httpapi/clientconfig.go``split_cn` 分支,把 `geoip-cn`/`geosite-cn` 两条 rule_set 由 `type:"remote"`(带 url/download_detour)改为 `type:"local"` + `path` 指向 App Group 容器内的 `.srs`(路径由客户端启动期已知,作为渲染参数传入或约定固定相对路径)。
Expected: 下发配置中两条 rule_set 为 `"type":"local"`
- [ ] **Step 4: Go 测试**
Run:
```bash
cd /Users/wangjia/code/pangolin/server && go test ./internal/httpapi/...
```
Expected: PASS(含 BuildClientConfig 渲染测试,确认 local 分支正确)。
- [ ] **Step 5: 真机复测内存**
重复 Task D1 Step 14。
Expected: `peakRssBytes` 降到设备 jetsam 上限以下。
- [ ] **Step 6: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add server/internal/httpapi/clientconfig.go client/ios/Runner/AppDelegate.swift
git commit -m "perf(client/ios): 本地 .srs 预取替代远程下载,压低 NE 内存峰值"
```
---
## Phase E — iPad UI 适配(布局 + 横屏)
### Task E1: form_factor 断点判定测试(锁定 iPad → tablet
**Files:**
- Create: `client/test/responsive/form_factor_test.dart`
- Reference: `client/lib/core/responsive/form_factor.dart`
**Interfaces:**
- Consumes: `FormFactorContext.formFactor`(已有:非桌面平台宽≥600 → `.tablet`)。
- [ ] **Step 1: 写失败测试**
```dart
// client/test/responsive/form_factor_test.dart
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pangolin_vpn/core/responsive/form_factor.dart';
Future<FormFactor> _ffAt(WidgetTester tester, Size size) async {
late FormFactor ff;
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(MediaQuery(
data: MediaQueryData.fromView(tester.view),
child: Builder(builder: (c) { ff = c.formFactor; return const SizedBox(); }),
));
return ff;
}
void main() {
testWidgets('iPad 竖屏(768) → tablet', (t) async {
expect(await _ffAt(t, const Size(768, 1024)), FormFactor.tablet);
});
testWidgets('iPad 横屏(1024) → tablet', (t) async {
expect(await _ffAt(t, const Size(1024, 768)), FormFactor.tablet);
});
testWidgets('iPhone(390) → mobile', (t) async {
expect(await _ffAt(t, const Size(390, 844)), FormFactor.mobile);
});
}
```
- [ ] **Step 2: 跑测试看通过/失败**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter test test/responsive/form_factor_test.dart
```
Expected: 3 测试全 PASS(现有 `form_factor.dart` 逻辑已满足;本测试锁定行为防回归)。若 iPhone case 因默认平台非 iOS 而判成 tablet,则说明测试环境 `defaultTargetPlatform` 默认 android(非桌面),390<600 仍 → mobile,应 PASS。
- [ ] **Step 3: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/test/responsive/form_factor_test.dart
git commit -m "test(client): 锁定 form_factor 断点(iPad→tablet, iPhone→mobile)"
```
### Task E2: 开 iPad 横竖屏 orientation + 大屏校验
**Files:**
- Modify: `client/ios/Runner/Info.plist`
- Reference: `client/lib/shell/tablet_shell.dart``design/ui_kits/tablet/`
**Interfaces:**
- Consumes: `TabletShell`(已实现侧栏+内容区双栏)。
- [ ] **Step 1: 开 iPad 全向**
`client/ios/Runner/Info.plist` 设 iPad 支持全部方向(`UISupportedInterfaceOrientations~ipad`):
```xml
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
```
- [ ] **Step 2: 模拟器多尺寸目测**
RuniPad 模拟器跑起来):
```bash
cd /Users/wangjia/code/pangolin/client && flutter run -d "iPad (10th generation)"
```
逐屏(connect / nodes / stats / account)竖屏与横屏各看一遍。
Expected: 走 `TabletShell`(侧栏+双栏内容),无溢出(无黄黑条)、不空旷、不拉伸;对照 `design/ui_kits/tablet/tabapp.jsx` 原型布局一致。
- [ ] **Step 3: analyze 通过**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter analyze
```
Expected: No issues。
- [ ] **Step 4: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/ios/Runner/Info.plist client/lib/shell/tablet_shell.dart
git commit -m "feat(client/ios): 开 iPad 全向 orientation + 平板布局校验"
```
### Task E3: iPad 真机连通验收
- [ ] **Step 1: iPad 真机跑连接**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter run --release -d <ipad-device-id>
```
连接 → 验出网(同 Task D1 Step 2–3);竖横屏切换布局正常。
Expected: iPad 连通且布局达标;内存峰值同样 < 上限。
---
## Phase F — 分发到 TestFlight
### Task F1:(用户操作)开发者后台注册 — App ID / NE 能力 / App Group / 描述文件
**这是用户在 developer.apple.com / App Store Connect 手点的清单,Claude 无法代点。**
- [ ] **Step 1: 注册 App ID(两个)**
- `com.pangolin.pangolinVpn`(主 App)— 勾 **App Groups**、**Network Extensions**。
- `com.pangolin.pangolinVpn.PacketTunnel`(扩展)— 勾 **App Groups**、**Network Extensions** → Packet Tunnel Provider。
- [ ] **Step 2: 建 App Group**
- `group.com.pangolin.pangolinVpn`,关联上面两个 App ID。
- [ ] **Step 3: 描述文件**
- 为两个 App ID 各建 App Store 分发描述文件(或用自动签名);登记测试设备 UDID(开发阶段)。
- [ ] **Step 4: App Store Connect 建 app 记录**
- 新建 appBundle ID 选 `com.pangolin.pangolinVpn`,填基本信息。
### Task F2: 导出合规 + Archive + 上传 TestFlight
**Files:**
- Modify: `client/ios/Runner/Info.plist`(导出合规键)
- [ ] **Step 1: 标注导出合规**
`client/ios/Runner/Info.plist` 加(避免每次上传被问加密合规;如使用标准加密走豁免,按实际填):
```xml
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
```
- [ ] **Step 2: 递增构建号**
确认 `client/pubspec.yaml` 的 build number`+N`)已递增(驱动 `FLUTTER_BUILD_NUMBER` → 扩展与主 App 的 `CFBundleVersion`)。
- [ ] **Step 3: Archive**
Run:
```bash
cd /Users/wangjia/code/pangolin/client && flutter build ipa --release
```
Expected: 产出 `build/ios/ipa/*.ipa`,签名成功。
- [ ] **Step 4: 上传 TestFlight**
Run(或用 Xcode Organizer / Transporter):
```bash
xcrun altool --upload-app -f /Users/wangjia/code/pangolin/client/build/ios/ipa/*.ipa \
-t ios --apiKey <KEY_ID> --apiIssuer <ISSUER_ID>
```
Expected: 上传成功;App Store Connect → TestFlight 出现构建(处理后可加外部测试者)。
- [ ] **Step 5: Commit**
```bash
cd /Users/wangjia/code/pangolin
git add client/ios/Runner/Info.plist client/pubspec.yaml
git commit -m "release(client/ios): 导出合规标注 + 构建号递增,准备 TestFlight"
```
---
## 验收标准(Definition of Done
1.`Libbox.xcframework` 含 iOS 切片;扩展 `otool -L` 零外部 `@rpath`A1 Step2 / B1 Step6)。
2. ✅ iPhone 真机:连接 → sing-box 起来 → 打开境外站点(D1 Step2–3)。
3. ✅ iPad 真机:连通 + 竖/横屏布局对照原型无拉伸/空旷(E2、E3)。
4.`MemoryMonitor` 真机峰值 < 设备 jetsam 上限,10 分钟不被杀(D1 Step4,必要时 D2)。
5. ✅ 成功 Archive 并上传 TestFlight,外部测试者可装可连(F2)。
---
## Self-Review 记录
- **Spec 覆盖**:设计稿 5 工作线 → Phase A(①libbox) / B+C(②Provider) / C(③App Group IPC,并入 VpnManager 既有实现) / E(④iPad) / F(⑤分发);头号风险内存闸 → D1+D2 双闸。全覆盖。
- **占位符**D2 的 `<节点公网>`、F 的 `<KEY_ID>`/`<device-id>` 为运行期真实值占位(环境相关,不可硬编码),非逻辑占位;其余步骤均含可执行命令与期望输出。
- **类型一致**`PangolinPlatformInterface`/`InterfaceIterator`/`simpleError`/`commandServer`/`memoryMonitor` 命名跨任务一致;IPC key `getStatus`/`getMemory``VpnManager.sendProviderMessage` 一致;配置 key 统一为 `config`