From e11c06225ddc1a31582022ab2c5886674f12f546 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 22 Jun 2026 19:22:05 +0800 Subject: [PATCH] =?UTF-8?q?plan(client/ios):=20iOS+iPad=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AE=9E=E6=96=BD=E8=AE=A1=E5=88=92(6=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5/12=20=E4=BB=BB=E5=8A=A1,TestFlight=20=E9=87=8C?= =?UTF-8?q?=E7=A8=8B=E7=A2=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 补 libbox / B Xcode 接线 / C Provider 重写对齐 macOS / D 真机连通+内存双闸 / E iPad 横竖屏适配 / F TestFlight 分发。 Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-22-ios-ipad-support.md | 618 ++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-ios-ipad-support.md diff --git a/docs/superpowers/plans/2026-06-22-ios-ipad-support.md b/docs/superpowers/plans/2026-06-22-ios-ipad-support.md new file mode 100644 index 0000000..b0e8e68 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-ios-ipad-support.md @@ -0,0 +1,618 @@ +# 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 Group(iOS 格式)**:`group.com.pangolin.pangolinVpn`(注意:非 macOS 的 `.` 前缀)。 +- **扩展链接铁律**:`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 phase:Libbox + 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`(主 App)target → 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 Provider(entitlements 文件已声明,确认未报红)。 + +- [ ] **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 的 . 前缀)。 +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 +``` +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 1–4。 +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 _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 +UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + +``` + +- [ ] **Step 2: 模拟器多尺寸目测** + +Run(iPad 模拟器跑起来): +```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 +``` +连接 → 验出网(同 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 记录** + - 新建 app,Bundle ID 选 `com.pangolin.pangolinVpn`,填基本信息。 + +### Task F2: 导出合规 + Archive + 上传 TestFlight + +**Files:** +- Modify: `client/ios/Runner/Info.plist`(导出合规键) + +- [ ] **Step 1: 标注导出合规** + +在 `client/ios/Runner/Info.plist` 加(避免每次上传被问加密合规;如使用标准加密走豁免,按实际填): +```xml +ITSAppUsesNonExemptEncryption + +``` + +- [ ] **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 --apiIssuer +``` +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 的 ``/`` 为运行期真实值占位(环境相关,不可硬编码),非逻辑占位;其余步骤均含可执行命令与期望输出。 +- **类型一致**:`PangolinPlatformInterface`/`InterfaceIterator`/`simpleError`/`commandServer`/`memoryMonitor` 命名跨任务一致;IPC key `getStatus`/`getMemory` 与 `VpnManager.sendProviderMessage` 一致;配置 key 统一为 `config`。