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>
This commit is contained in:
@@ -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 的 `<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 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 的 <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 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<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: 模拟器多尺寸目测**
|
||||
|
||||
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 <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 记录**
|
||||
- 新建 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
|
||||
<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`。
|
||||
Reference in New Issue
Block a user