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

26 KiB
Raw Blame History

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 默认值)。
  • TeamIDBYL4KQHMTN
  • 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 不入 gitLibbox.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.dartclient/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 /Users/wangjia/code/pangolin/scripts/build-libbox.sh apple

Expected: 末尾打印 ✅ 产物: /tmp/pangolin-libbox/sing-box/Libbox.xcframework

  • Step 2: 验证含 iOS 切片

Run:

ls -1 /tmp/pangolin-libbox/sing-box/Libbox.xcframework/

Expected: 列出含 ios-arm64ios-arm64_x86_64-simulatormacos-arm64_x86_64(可能还有 tvos)等目录。

  • Step 3: 拷入 iOS 工程

Run:

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:

cd /Users/wangjia/code/pangolin && git status --porcelain client/ios/Frameworks/

Expected: 无输出(框架未被 git 跟踪)。

  • Step 6: Commit
cd /Users/wangjia/code/pangolin
git add client/ios/.gitignore
git commit -m "build(client/ios): 引入 iOS libbox 框架 + gitignore 排除产物"

Phase B — Xcode 接线与签名

Files:

  • Modify: client/ios/Runner.xcodeproj/project.pbxproj(经 Xcode GUI 改,避免手改 pbxproj 出错)

Interfaces:

  • Consumes: client/ios/Frameworks/Libbox.xcframeworkTask A1
  • Produces: 可编译的 PacketTunnel 扩展 target,零外部 @rpath 依赖。

照 macOS 扩展 target 的链接清单(macos/Runner.xcodeproj 的 PacketTunnel Frameworks build phaseLibbox + NetworkExtension + libresolv + SystemConfiguration),iOS 把 AppKit 去掉。

  • Step 1: 打开工程

Run:

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.xcframeworkAdd Other… 选 Task A1 拷入的)

  • NetworkExtension.framework

  • SystemConfiguration.framework

  • libresolv.tbd

  • Step 3: 设 Build Settings

PacketTunnel target → Build Settings

  • Other Linker FlagsOTHER_LDFLAGS)→ 置为(删除任何继承值;防 CocoaPods 标志把 flutter_secure_storage 链进扩展)。
  • Framework Search Paths$(PROJECT_DIR)/Frameworks
  • Development TeamDEVELOPMENT_TEAM)→ BYL4KQHMTN

Runner(主 Apptarget → Build SettingsDevelopment TeamBYL4KQHMTN

  • Step 4: 确认 Signing & Capabilities

RunnerPacketTunnel 两 target 的 Signing & Capabilities 均含:

  • App Groupsgroup.com.pangolin.pangolinVpn

  • PacketTunnelNetwork Extensions → Packet Tunnel Providerentitlements 文件已声明,确认未报红)。

  • Step 5: 编译验证

Run:

cd /Users/wangjia/code/pangolin/client && flutter build ios --debug --no-codesign

Expected: 构建成功(Swift 此时尚未引用 libbox,仅验证链接配置不破坏构建)。

  • Step 6: 验证扩展零外部 @rpath 依赖

Run:

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:

grep -c "DEVELOPMENT_TEAM = BYL4KQHMTN" /Users/wangjia/code/pangolin/client/ios/Runner.xcodeproj/project.pbxproj

Expected: ≥ 2(主 App + 扩展)。

  • Step 8: Commit
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(_:_:_:)LibboxPlatformInterfaceProtocolLibboxOverrideOptionsLibboxSetup(真实框架 API,与 macOS 一致);VpnManageroptions["config"] 传配置、经 App Group key lastConfigJson 缓存。
  • Produces: class PacketTunnelProviderfinal class PangolinPlatformInterfacefinal class InterfaceIteratorfunc simpleError(_:)

背景:现有 iOS 文件用的是不存在于真实框架的旧 APILibboxNewService / 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) 顶部常量:

// 改这两行(原 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 顶部加内存监控成员:

final class PacketTunnelProvider: NEPacketTunnelProvider {
    private var commandServer: LibboxCommandServer?
    private var platform: PangolinPlatformInterface?
    private let memoryMonitor = MemoryMonitor()   // ← 新增(iOS 内存红线打点)

(c) startTunnel 成功处(completionHandler(nil) 之前)启动监控——在 self.commandServer = server 之后加:

                self.commandServer = server
                self.memoryMonitor.start()        // ← 新增
                log.info("startTunnel: service started")
                completionHandler(nil)

(d) stopTunnel 开头停监控并打峰值——在 try? commandServer?.closeService() 之前加:

        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 缓存回退:

    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 的分支):

    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) PangolinPlatformInterfaceInterfaceIteratorsimpleError 三部分原样照搬 macOSiOS 全部 API 可用:tunFd() 的 utun 扫描、IP_BOUND_IF/IPV6_BOUND_IFgetifaddrs 在 iOS 同样有效,与 sing-box-for-apple 一致)。import 段照搬 macOSDarwin/Foundation/Libbox/Network/NetworkExtension/os,无 AppKit)。

  • Step 2: 编译验证(链入真实 libbox)

Run:

cd /Users/wangjia/code/pangolin/client && flutter build ios --debug --no-codesign

Expected: 构建成功;不再出现 LibboxNewService / LibboxPlatformInterface 等旧符号未定义错误。

  • Step 3: 静态核对铁律

Run:

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
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 的 ProviderVpnManager(已实现 load/save/start/stop)。

  • Step 1: 真机安装运行

USB 接入开发者已登记的 iPhone(设备 UDID 已在 App ID 描述文件中):

cd /Users/wangjia/code/pangolin/client && flutter run --release -d <iphone-device-id>

Expected: app 在真机启动。

  • Step 2: 触发连接并看日志

app 内点连接。同时在 macOS 终端开 device console 过滤扩展日志:

log stream --predicate 'subsystem == "com.pangolin.pangolinVpn.PacketTunnel"' --info

Expected: 依次出现 startTunnelstartTunnel: 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(如有文档更新)
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.gorule_set 由 type:"remote" 改为本地 type:"local",path:...,仅对 iOS 客户端或全平台)
  • Create: client/ios/Runner/Resources/geoip-cn.srsgeosite-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(从节点拉一次,提交进仓库):

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.swiftapplication(_:didFinishLaunchingWithOptions:) 内,加把 bundle 内 .srs 拷到 group.com.pangolin.pangolinVpn 容器的逻辑(若不存在再拷):

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.gosplit_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:

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
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: 写失败测试

// 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:

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
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.dartdesign/ui_kits/tablet/

Interfaces:

  • Consumes: TabletShell(已实现侧栏+内容区双栏)。

  • Step 1: 开 iPad 全向

client/ios/Runner/Info.plist 设 iPad 支持全部方向(UISupportedInterfaceOrientations~ipad):

<key>UISupportedInterfaceOrientations~ipad</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
    <string>UIInterfaceOrientationPortraitUpsideDown</string>
    <string>UIInterfaceOrientationLandscapeLeft</string>
    <string>UIInterfaceOrientationLandscapeRight</string>
</array>
  • Step 2: 模拟器多尺寸目测

RuniPad 模拟器跑起来):

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:

cd /Users/wangjia/code/pangolin/client && flutter analyze

Expected: No issues。

  • Step 4: Commit
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:

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 GroupsNetwork Extensions
    • com.pangolin.pangolinVpn.PacketTunnel(扩展)— 勾 App GroupsNetwork 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 加(避免每次上传被问加密合规;如使用标准加密走豁免,按实际填):

<key>ITSAppUsesNonExemptEncryption</key>
<false/>
  • Step 2: 递增构建号

确认 client/pubspec.yaml 的 build number+N)已递增(驱动 FLUTTER_BUILD_NUMBER → 扩展与主 App 的 CFBundleVersion)。

  • Step 3: Archive

Run:

cd /Users/wangjia/code/pangolin/client && flutter build ipa --release

Expected: 产出 build/ios/ipa/*.ipa,签名成功。

  • Step 4: 上传 TestFlight

Run(或用 Xcode Organizer / Transporter):

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
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 零外部 @rpathA1 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/getMemoryVpnManager.sendProviderMessage 一致;配置 key 统一为 config