Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a029dd222 | |||
| fea6f82299 | |||
| 5513377221 | |||
| acb9d8052b | |||
| 7be0eb4c08 | |||
| 3fb645ed40 | |||
| 6618929092 | |||
| baaf21df36 | |||
| 47c1e3329f | |||
| 4a76134ca9 | |||
| 55f6b1a19e | |||
| 721371d806 | |||
| 1cc52075e9 | |||
| def3bb34b8 | |||
| bb031a04c1 | |||
| e7e59a786d | |||
| b59101abf1 |
@@ -113,18 +113,26 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 多账号切换(见 docs/ci-multi-account-signing-design.html):从 signing.env
|
||||
# 单源读 SIGNING_ACCOUNT(us|cn),下方按它选账号的证书/ASC 密钥集。
|
||||
- name: Resolve signing account
|
||||
id: acct
|
||||
run: |
|
||||
. scripts/signing.env
|
||||
echo "account=${SIGNING_ACCOUNT:?signing.env 缺 SIGNING_ACCOUNT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compile (macOS System Extension app)
|
||||
env:
|
||||
# 左=脚本期望的 env 名(勿改),右=实际密钥名。Apple 证书/公证密钥是
|
||||
# 账号级、跨项目唯一 → 放【全局(用户级)密钥】用通用短名,pangolin/jiu 各自
|
||||
# 在此映射到自己脚本的 env。Developer ID Application 证书=站外分发,一张签所有 app。
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
# 账号级密钥(Developer ID 证书 + ASC Key,跨该账号所有 app 共享):按
|
||||
# SIGNING_ACCOUNT 选——us→SIGN_US_*(用户级)、cn→旧通用名(用户级,不动、
|
||||
# 供其他 app 继续用)。描述文件是 app 级(绑 bundle id)→ 仓库级,与账号无关。
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_DEVELOPER_ID_P12 || secrets.DEVELOPER_ID_P12 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_DEVELOPER_ID_PASSWORD || secrets.P12_PASSWORD }}
|
||||
APPSTORE_API_KEY_ID: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_KEY_ID || secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_ISSUER_ID || secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_KEY_P8 || secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
MACOS_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }}
|
||||
MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_PROVISION_PROFILE_BASE64 }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/compile-macos.sh "$REF_NAME"
|
||||
|
||||
@@ -147,17 +155,23 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve signing account
|
||||
id: acct
|
||||
run: |
|
||||
. scripts/signing.env
|
||||
echo "account=${SIGNING_ACCOUNT:?signing.env 缺 SIGNING_ACCOUNT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compile & upload to TestFlight (iOS)
|
||||
env:
|
||||
# 证书=账号级 →【全局】通用短名(Apple Distribution 证书,签 iOS App Store/TestFlight);
|
||||
# 描述文件=与 bundle id 绑定 →【项目级】pangolin 仓库密钥。
|
||||
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_P12 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_PASSWORD }}
|
||||
# 账号级(Apple Distribution 证书 + ASC Key)按 SIGNING_ACCOUNT 选;
|
||||
# 描述文件=与 bundle id 绑定 → 仓库级、与账号无关。
|
||||
IOS_DIST_CERT_P12_BASE64: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_APPLE_DIST_P12 || secrets.IOS_DIST_P12 }}
|
||||
IOS_DIST_CERT_PASSWORD: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_APPLE_DIST_PASSWORD || secrets.IOS_DIST_PASSWORD }}
|
||||
APPSTORE_API_KEY_ID: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_KEY_ID || secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_ISSUER_ID || secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_ASC_KEY_P8 || secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
IOS_APP_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }}
|
||||
IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: bash scripts/ci/compile-ios.sh "$REF_NAME"
|
||||
# No artifact upload — compile-ios.sh uploads straight to TestFlight via
|
||||
|
||||
@@ -183,9 +183,10 @@ cd web/website && npm run gen:tokens
|
||||
|
||||
**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒)
|
||||
- **自包含**:`PacketTunnel` target 设 `OTHER_LDFLAGS = ""`(切断继承项目级 CocoaPods 链接标志,否则会把 `flutter_secure_storage` 链进扩展);`Libbox.xcframework` **只 Link 不 Embed**(它是静态库)。验证:`otool -L` 扩展二进制应**零 `@rpath` 外部依赖**。
|
||||
- **bundle 名 = 标识符**:`PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel`。
|
||||
- **bundle 名 = 标识符**:`PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel`。
|
||||
- 扩展 `Info.plist` 必须有 **`NSSystemExtensionUsageDescription`**(网络扩展类别强制,主 app 的不顶用)。
|
||||
- **App Group 用 macOS 原生格式 `<TeamID>.<name>`**(`BYL4KQHMTN.com.pangolin.pangolin`,非 iOS 的 `group.` 前缀);`NEMachServiceName` 以其为前缀。
|
||||
- **App Group 用 macOS 原生格式 `<TeamID>.<name>`**(`44WULXM6SV.com.yanmeiai.pangolin`,非 iOS 的 `group.` 前缀);`NEMachServiceName` 以其为前缀。
|
||||
- **签名身份单一真相源 `scripts/signing.env`**(Team ID / 实体名 / Bundle ID / App Group)。shell 脚本 source 它;原生文件(pbxproj/entitlements/plist/swift)的字面量由 `node scripts/gen-signing.mjs` 从它回填(**勿手改这些标识符**),`ci/check-codegen-drift.sh` 有 drift 闸。迁账号/改 Bundle ID 只改 signing.env 跑一次 codegen。详见 `docs/apple-account-migration-runbook.html`。
|
||||
- 沙箱扩展补 `network.client` / `network.server`;`get-task-allow=false` + 签名加 `--timestamp`。
|
||||
|
||||
**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意)
|
||||
@@ -206,7 +207,7 @@ cd web/website && npm run gen:tokens
|
||||
|
||||
同样内嵌 libbox,与 macOS 同「CommandServer 模型」,但形态/打包不同:
|
||||
|
||||
- **iOS/iPad**:`NEPacketTunnelProvider` 扩展(`client/ios/PacketTunnel/`),经 `NETunnelProviderManager` 管理(非 sysext);App Group 用 **iOS 的 `group.` 前缀**(`group.com.pangolin.pangolinVpn`)。`ctl_info`/`sockaddr_ctl` iOS SDK 不发,靠 `PacketTunnel-Bridging-Header.h` 手写声明。
|
||||
- **iOS/iPad**:`NEPacketTunnelProvider` 扩展(`client/ios/PacketTunnel/`),经 `NETunnelProviderManager` 管理(非 sysext);App Group 用 **iOS 的 `group.` 前缀**(`group.com.yanmeiai.pangolin`)。`ctl_info`/`sockaddr_ctl` iOS SDK 不发,靠 `PacketTunnel-Bridging-Header.h` 手写声明。
|
||||
- **Android**:`PangolinVpnService`(`client/android/.../PangolinVpnService.kt`),VpnService + libbox **同进程**;明文联调 API 需 manifest `usesCleartextTraffic`(Android 9+ 默认禁 http)。
|
||||
- iOS/Android 与 macOS 同走 Dart `VpnNativeBridge`,所以 Dart 侧修复(状态/统计流)四端共享。
|
||||
|
||||
@@ -216,7 +217,7 @@ cd web/website && npm run gen:tokens
|
||||
- ⚠️ **Android libbox Java 包名是 `io.nekohasekai.libbox`**(不是 `libbox`),Kotlin import 用前者。
|
||||
|
||||
**真机装机(老忘——有现成脚本,别手搓 xcodebuild/签名)**:
|
||||
- **iOS**:`API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad "<设备名/id>"`(`local_test.sh ios-devices` 列已连设备)。脚本自动:flutter build ipa(**公司分发证书** Apple Distribution: Yanmei / Team `BYL4KQHMTN`,ad-hoc)→ 核验签名主体(防无声退回个人证书)→ `xcrun devicectl` 装机。`API_URL` 决定客户端连哪个控制面(prod = `https://api.yanmeiai.com`)。
|
||||
- **iOS**:`API_URL=https://api.yanmeiai.com bash scripts/local_test.sh ipad "<设备名/id>"`(`local_test.sh ios-devices` 列已连设备)。脚本自动:flutter build ipa(**公司分发证书** Apple Distribution: Yanmei AI LLC / Team `44WULXM6SV`,ad-hoc)→ 核验签名主体(防无声退回个人证书)→ `xcrun devicectl` 装机。`API_URL` 决定客户端连哪个控制面(prod = `https://api.yanmeiai.com`)。
|
||||
- iOS libbox 是 **gitignore 产物**,新 worktree 常缺 → 从主仓拷免重建:`cp -R /Users/wangjia/code/pangolin/client/ios/Frameworks/Libbox.xcframework client/ios/Frameworks/`(routing 等功能不碰 libbox 接口,主仓那份兼容)。
|
||||
- 新设备首次装报 `0xe8008012`(描述文件不含 UDID)→ 需 xcodebuild `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`(脚本 die 里给了兜底命令);chen 的设备(chen-macbook/chen-iphone)已注册。装机走**公司**分发证书,禁个人开发证书(记忆 `ios-install-team-cert`)。
|
||||
|
||||
|
||||
@@ -58,3 +58,10 @@ fi
|
||||
echo "→ 校验原型 data-i18n key 全 resolve (check-proto-i18n.mjs) ..."
|
||||
node design/prototype/tools/check-proto-i18n.mjs
|
||||
echo "✅ 原型 i18n 无漂移:I18N 块与单源一致、data-i18n 全 resolve。"
|
||||
|
||||
# ── 签名身份单源:scripts/signing.env → 原生文件字面量(pbxproj/entitlements/plist/swift) ──
|
||||
# 迁账号/改 Bundle ID 只改 signing.env,跑 gen-signing.mjs 回填;此闸防「改了 env 没重生成」
|
||||
# 或「手改了原生里的 Team/Bundle/App Group」导致签名标识符与单源分叉。
|
||||
echo "→ 校验签名标识符与 scripts/signing.env 一致 (scripts/gen-signing.mjs --check) ..."
|
||||
node scripts/gen-signing.mjs --check
|
||||
echo "✅ 签名标识符无漂移:原生字面量与 signing.env 单源一致。"
|
||||
|
||||
@@ -40,7 +40,7 @@ final class MemoryMonitor {
|
||||
|
||||
// ── 状态 ────────────────────────────────────────────────────
|
||||
private var timer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "com.pangolin.memory-monitor", qos: .utility)
|
||||
private let queue = DispatchQueue(label: "com.yanmeiai.pangolin.memory-monitor", qos: .utility)
|
||||
private(set) var peakResidentBytes: UInt64 = 0
|
||||
private(set) var minAvailableBytes: UInt64 = UInt64.max
|
||||
private(set) var sampleCount: Int = 0
|
||||
@@ -48,7 +48,7 @@ final class MemoryMonitor {
|
||||
init(
|
||||
interval: TimeInterval = 10,
|
||||
warningThreshold: UInt64 = 5 * 1024 * 1024,
|
||||
subsystem: String = "com.pangolin.pangolinVpn.PacketTunnel"
|
||||
subsystem: String = "com.yanmeiai.pangolin.PacketTunnel"
|
||||
) {
|
||||
self.interval = interval
|
||||
self.warningThreshold = warningThreshold
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
PacketTunnel.entitlements — PacketTunnel Extension Target Entitlements
|
||||
Bundle ID: com.pangolin.pangolinVpn.PacketTunnel
|
||||
Bundle ID: com.yanmeiai.pangolin.PacketTunnel
|
||||
|
||||
申请说明:
|
||||
1. com.apple.developer.networking.networkextension
|
||||
→ Extension Bundle ID 须单独在 Apple 开发者后台注册并开启:
|
||||
developer.apple.com → Identifiers → com.pangolin.pangolinVpn.PacketTunnel
|
||||
developer.apple.com → Identifiers → com.yanmeiai.pangolin.PacketTunnel
|
||||
→ Network Extensions → Packet Tunnel Provider(勾选)
|
||||
2. com.apple.security.application-groups
|
||||
→ 与主 App 使用相同 Group(group.com.pangolin.pangolinVpn)。
|
||||
→ 与主 App 使用相同 Group(group.com.yanmeiai.pangolin)。
|
||||
Extension 通过 UserDefaults(suiteName:) 写入最新 stats/status,
|
||||
主 App 在下次启动时读取(异常崩溃恢复场景)。
|
||||
-->
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- App Group:与主 App 共享 UserDefaults / 文件容器 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.pangolin.pangolinVpn</string>
|
||||
<string>group.com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -15,9 +15,9 @@ import Network
|
||||
import NetworkExtension
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.pangolin.pangolinVpn.PacketTunnel", category: "provider")
|
||||
private let log = Logger(subsystem: "com.yanmeiai.pangolin.PacketTunnel", category: "provider")
|
||||
// iOS App Group 用 group. 前缀(非 macOS 的 <TeamID>. 前缀)。
|
||||
private let appGroup = "group.com.pangolin.pangolinVpn"
|
||||
private let appGroup = "group.com.yanmeiai.pangolin"
|
||||
|
||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer?
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = com.pangolin;
|
||||
ORGANIZATIONNAME = "Yanmei AI LLC";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
@@ -481,14 +481,14 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -610,14 +610,14 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -634,14 +634,14 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -657,7 +657,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
@@ -670,7 +670,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = PacketTunnel;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -689,7 +689,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
@@ -702,7 +702,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = PacketTunnel;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -721,7 +721,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Frameworks",
|
||||
@@ -734,7 +734,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = PacketTunnel;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
Runner.entitlements — 主 App Target Entitlements
|
||||
Bundle ID: com.pangolin.pangolinVpn
|
||||
Bundle ID: com.yanmeiai.pangolin
|
||||
|
||||
申请说明:
|
||||
1. com.apple.developer.networking.networkextension
|
||||
→ "packet-tunnel-provider" 须在 Apple 开发者后台显式申请。
|
||||
申请路径:developer.apple.com → Certificates, IDs & Profiles
|
||||
→ Identifiers → com.pangolin.pangolinVpn → Capabilities
|
||||
→ Identifiers → com.yanmeiai.pangolin → Capabilities
|
||||
→ Network Extensions → Packet Tunnel Provider(勾选)
|
||||
2. com.apple.security.application-groups
|
||||
→ group.com.pangolin.pangolinVpn 须在 App Groups 注册。
|
||||
→ group.com.yanmeiai.pangolin 须在 App Groups 注册。
|
||||
主 App 与 PacketTunnel extension 共享同一 Group,
|
||||
用于 UserDefaults(suiteName:) / FileManager containerURL(forSecurityApplicationGroupIdentifier:)
|
||||
传递少量共享状态(上次节点 tag、连接时间戳等)。
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- App Group:主 App ↔ PacketTunnel extension 共享容器 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.pangolin.pangolinVpn</string>
|
||||
<string>group.com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -65,9 +65,9 @@ class VpnManager: NSObject {
|
||||
private var tunnelManager: NETunnelProviderManager?
|
||||
|
||||
// Bundle ID 与 PacketTunnel target PRODUCT_BUNDLE_IDENTIFIER 保持同步
|
||||
private let extensionBundleId = "com.pangolin.pangolinVpn.PacketTunnel"
|
||||
private let extensionBundleId = "com.yanmeiai.pangolin.PacketTunnel"
|
||||
// App Group:与 PacketTunnel extension 共享 UserDefaults 容器
|
||||
private let appGroup = "group.com.pangolin.pangolinVpn"
|
||||
private let appGroup = "group.com.yanmeiai.pangolin"
|
||||
|
||||
private var statusObserver: NSObjectProtocol?
|
||||
|
||||
|
||||
@@ -343,6 +343,9 @@ abstract class AppText {
|
||||
String get capOn;
|
||||
String get connectNow; // 连接键 off 态圆内文字
|
||||
String get secure; // 连接键圆内「已加密」
|
||||
String get disconnectTitle; // 断开确认弹窗标题
|
||||
String get disconnectBody; // 断开确认弹窗正文
|
||||
String get disconnectAction; // 断开确认弹窗「断开」按钮
|
||||
|
||||
// ── 底部 / 侧栏导航 ──
|
||||
String get tabConnect;
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsEn extends AppText {
|
||||
@override
|
||||
String get secure => 'SECURE';
|
||||
@override
|
||||
String get disconnectTitle => 'Disconnect?';
|
||||
@override
|
||||
String get disconnectBody => 'Your connection is encrypted. Are you sure you want to disconnect?';
|
||||
@override
|
||||
String get disconnectAction => 'Disconnect';
|
||||
@override
|
||||
String get tabConnect => 'Connect';
|
||||
@override
|
||||
String get tabServers => 'Servers';
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsEs extends AppText {
|
||||
@override
|
||||
String get secure => 'SEGURO';
|
||||
@override
|
||||
String get disconnectTitle => '¿Desconectar?';
|
||||
@override
|
||||
String get disconnectBody => 'Tu conexión está cifrada. ¿Seguro que quieres desconectar?';
|
||||
@override
|
||||
String get disconnectAction => 'Desconectar';
|
||||
@override
|
||||
String get tabConnect => 'Conectar';
|
||||
@override
|
||||
String get tabServers => 'Servidores';
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsJa extends AppText {
|
||||
@override
|
||||
String get secure => 'セキュア';
|
||||
@override
|
||||
String get disconnectTitle => '切断しますか?';
|
||||
@override
|
||||
String get disconnectBody => '接続は暗号化されています。切断してもよろしいですか?';
|
||||
@override
|
||||
String get disconnectAction => '切断';
|
||||
@override
|
||||
String get tabConnect => '接続';
|
||||
@override
|
||||
String get tabServers => 'サーバー';
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsKo extends AppText {
|
||||
@override
|
||||
String get secure => '보안';
|
||||
@override
|
||||
String get disconnectTitle => '연결 해제하시겠습니까?';
|
||||
@override
|
||||
String get disconnectBody => '연결이 암호화되어 있습니다. 정말 연결을 해제하시겠습니까?';
|
||||
@override
|
||||
String get disconnectAction => '연결 해제';
|
||||
@override
|
||||
String get tabConnect => '연결';
|
||||
@override
|
||||
String get tabServers => '서버';
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsRu extends AppText {
|
||||
@override
|
||||
String get secure => 'ЗАЩИЩЕНО';
|
||||
@override
|
||||
String get disconnectTitle => 'Отключиться?';
|
||||
@override
|
||||
String get disconnectBody => 'Ваше соединение зашифровано. Действительно отключиться?';
|
||||
@override
|
||||
String get disconnectAction => 'Отключить';
|
||||
@override
|
||||
String get tabConnect => 'Подключение';
|
||||
@override
|
||||
String get tabServers => 'Серверы';
|
||||
|
||||
@@ -27,6 +27,12 @@ class StringsZh extends AppText {
|
||||
@override
|
||||
String get secure => '已加密';
|
||||
@override
|
||||
String get disconnectTitle => '断开连接?';
|
||||
@override
|
||||
String get disconnectBody => '当前连接已加密。确定要断开吗?';
|
||||
@override
|
||||
String get disconnectAction => '断开';
|
||||
@override
|
||||
String get tabConnect => '连接';
|
||||
@override
|
||||
String get tabServers => '节点';
|
||||
|
||||
@@ -43,8 +43,8 @@ Future<void> main(List<String> args) async {
|
||||
await windowManager.setMinimumSize(const Size(720, 560));
|
||||
await windowManager.setSize(const Size(920, 640));
|
||||
await windowManager.center();
|
||||
// 窗口标题用中文品牌名(不带 "VPN" 字眼);英文环境下原生标题为 "Pangolin"。
|
||||
await windowManager.setTitle('穿山甲');
|
||||
// 窗口标题用英文品牌名(不带 "VPN" 字眼;默认 UI 语言为英文,标题保持一致)。
|
||||
await windowManager.setTitle('Pangolin');
|
||||
// 自启:首帧前就隐藏窗口(避免闪现),托盘已就绪可随时唤回;自动连接逻辑照常跑。
|
||||
if (bootAutostart) {
|
||||
await windowManager.hide();
|
||||
@@ -109,7 +109,7 @@ class _PangolinAppState extends ConsumerState<PangolinApp> {
|
||||
Widget build(BuildContext context) {
|
||||
final mode = ref.watch(themeModeProvider);
|
||||
return MaterialApp(
|
||||
title: '穿山甲',
|
||||
title: 'Pangolin',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
darkTheme: PangolinTheme.dark,
|
||||
|
||||
@@ -60,15 +60,6 @@ class AccountPage extends ConsumerWidget {
|
||||
isFree: isFree,
|
||||
email: email,
|
||||
expiresLabel: expiresLabel,
|
||||
// Renew/Upgrade 直达购买页(套餐 + 渠道 switch),不再经 PlansScreen。
|
||||
onUpgrade: () => open(
|
||||
NavView.purchase,
|
||||
PurchaseScreen(
|
||||
t: t,
|
||||
onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
// 账户信息
|
||||
@@ -156,13 +147,11 @@ class _PlanBanner extends StatelessWidget {
|
||||
required this.isFree,
|
||||
required this.email,
|
||||
required this.expiresLabel,
|
||||
required this.onUpgrade,
|
||||
});
|
||||
final AppText t;
|
||||
final bool isFree;
|
||||
final String email;
|
||||
final String expiresLabel;
|
||||
final VoidCallback onUpgrade;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -193,7 +182,6 @@ class _PlanBanner extends StatelessWidget {
|
||||
child: Text(t.freePlanName, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 11)),
|
||||
),
|
||||
])),
|
||||
_UpgradeButton(label: t.upgradeBtn, onTap: onUpgrade),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
@@ -222,40 +210,11 @@ class _PlanBanner extends StatelessWidget {
|
||||
Text(expiresLabel.isEmpty ? t.proMember : '${t.proMember} · $expiresLabel',
|
||||
style: TextStyle(color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 12)),
|
||||
])),
|
||||
FilledButton(
|
||||
onPressed: onUpgrade,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: PangolinColors.white,
|
||||
foregroundColor: PangolinColors.clay700,
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
),
|
||||
child: Text(t.upgradeBtn, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpgradeButton extends StatelessWidget {
|
||||
const _UpgradeButton({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: c.accent,
|
||||
foregroundColor: c.fgOnAccent,
|
||||
shape: const StadiumBorder(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
),
|
||||
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.text});
|
||||
|
||||
@@ -21,6 +21,47 @@ class ConnectPage extends ConsumerWidget {
|
||||
final bool isWide;
|
||||
final VoidCallback onOpenNodes;
|
||||
|
||||
// 连接键点击:off/connecting 直接 toggle;on 态(已连接)先弹二次确认再断开,
|
||||
// 避免误触瞬断加密连接。
|
||||
Future<void> _handleToggle(BuildContext context, WidgetRef ref, VpnPhase phase, AppText t) async {
|
||||
final notifier = ref.read(connectionProvider.notifier);
|
||||
if (phase != VpnPhase.on) {
|
||||
notifier.toggle();
|
||||
return;
|
||||
}
|
||||
final c = context.pangolin;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: c.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
|
||||
title: Row(children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(color: c.dangerSubtle, shape: BoxShape.circle),
|
||||
child: Icon(PangolinIcons.alertTriangle, size: 18, color: c.danger)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(t.disconnectTitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700))),
|
||||
]),
|
||||
content: Text(t.disconnectBody, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(t.devCancel, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(t.disconnectAction,
|
||||
style: PangolinText.sm.copyWith(color: c.danger, fontWeight: FontWeight.w700))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) notifier.toggle();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
@@ -53,7 +94,7 @@ class ConnectPage extends ConsumerWidget {
|
||||
elapsed: conn.elapsed,
|
||||
offLabel: t.connectNow,
|
||||
secureLabel: t.secure,
|
||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
||||
onTap: () => _handleToggle(context, ref, conn.phase, t),
|
||||
);
|
||||
|
||||
final captionWidget = Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
@@ -94,7 +135,7 @@ class ConnectPage extends ConsumerWidget {
|
||||
offLabel: t.connectNow,
|
||||
secureLabel: t.secure,
|
||||
size: 176,
|
||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
||||
onTap: () => _handleToggle(context, ref, conn.phase, t),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(caption,
|
||||
|
||||
@@ -12,7 +12,6 @@ import '../state/payment_provider.dart';
|
||||
import '../widgets/page_body.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
import '../widgets/sub_scaffold.dart';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
import '../widgets/plan_card.dart';
|
||||
import '../widgets/seg_switch.dart';
|
||||
|
||||
@@ -39,7 +38,6 @@ class PurchaseScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
||||
AppText get t => widget.t;
|
||||
bool get _zh => t.lang == AppLang.zh;
|
||||
|
||||
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
|
||||
PayChannel _channel = PayChannel.usdt;
|
||||
@@ -51,18 +49,22 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
||||
_ => sku,
|
||||
};
|
||||
|
||||
bool _buying = false; // 防连点:导航切走前的窗口内挡住第二次 tap,避免下双单
|
||||
|
||||
Future<void> _buy(PayCatalogItem item) async {
|
||||
await ref.read(paymentFlowProvider.notifier).start(item, _channel.method);
|
||||
if (!mounted) return;
|
||||
final st = ref.read(paymentFlowProvider);
|
||||
if (st.phase == PaymentPhase.awaitingPayment) {
|
||||
widget.onOrderCreated?.call();
|
||||
} else if (st.phase == PaymentPhase.failed) {
|
||||
// 下单失败给可见 toast,别让用户以为「点了没反应」。
|
||||
showPangolinToast(
|
||||
context,
|
||||
(_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed,
|
||||
);
|
||||
if (_buying) return;
|
||||
_buying = true;
|
||||
final notifier = ref.read(paymentFlowProvider.notifier);
|
||||
// start() 同步先把相位置 creating,随即导航到支付页 —— 用户点「立即购买」即刻
|
||||
// 看到页面切换 + 转圈,不再在购买页干等 createOrder 网络往返(原「点了卡一下」的
|
||||
// 错觉根因)。下单结果(awaitingPayment/failed)由支付页自渲染(failed 带重试/
|
||||
// 返回),这里不再回吐 toast。
|
||||
final fut = notifier.start(item, _channel.method);
|
||||
widget.onOrderCreated?.call();
|
||||
try {
|
||||
await fut;
|
||||
} finally {
|
||||
_buying = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -32,15 +34,29 @@ AppText appTextFor(AppLang lang) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
|
||||
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
|
||||
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。
|
||||
/// 当前语言。首启无用户选择时**跟随系统语言**(设备 locale → 支持的 AppLang,
|
||||
/// 命不中回退英文);用户在设置/账户页显式切换后持久化到 shared_preferences
|
||||
/// (key `pg_lang`,存枚举 name),此后一律以用户选择为准、不再看系统。
|
||||
class LocaleNotifier extends StateNotifier<AppLang> {
|
||||
LocaleNotifier() : super(AppLang.en) {
|
||||
LocaleNotifier() : super(_systemDefault()) {
|
||||
_load();
|
||||
}
|
||||
static const _key = 'pg_lang';
|
||||
|
||||
/// 设备语言 → 支持的 AppLang。取平台首选 locale 的语言码(zh/ja/ko/ru/es 命中
|
||||
/// 对应语种,其余一律英文)。构造期同步可用,无需 await。
|
||||
static AppLang _systemDefault() {
|
||||
try {
|
||||
final code = ui.PlatformDispatcher.instance.locale.languageCode.toLowerCase();
|
||||
for (final l in AppLang.values) {
|
||||
if (l.name == code) return l;
|
||||
}
|
||||
} catch (_) {
|
||||
/* 取系统 locale 失败 → 英文 */
|
||||
}
|
||||
return AppLang.en;
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getString(_key);
|
||||
@@ -53,7 +69,7 @@ class LocaleNotifier extends StateNotifier<AppLang> {
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* 读失败保持默认 en */
|
||||
/* 读失败保持系统默认 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart' show AppLifecycleListener;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/payment.dart';
|
||||
@@ -63,19 +64,27 @@ class PaymentFlowState {
|
||||
}
|
||||
|
||||
class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState());
|
||||
PaymentFlowController(Ref ref) : _ref = ref, super(const PaymentFlowState()) {
|
||||
// 用户付款后从微信/支付宝返回 app → 立刻补查一次单,不必干等下一个轮询拍。
|
||||
// (等待付款期切后台去付款,iOS 会挂起 Dart Timer,回前台首拍可能迟到十几秒——
|
||||
// 正是「付完刷新不及时」的主因;回前台即查把这段砍到一次网络往返。)
|
||||
_lifecycle = AppLifecycleListener(onResume: () {
|
||||
if (state.phase == PaymentPhase.awaitingPayment) unawaited(pollOnce());
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer
|
||||
/// (widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。[cancel] 对
|
||||
/// `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
|
||||
/// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer、
|
||||
/// 不挂生命周期监听(widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。
|
||||
/// [cancel] 对 `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
|
||||
@visibleForTesting
|
||||
PaymentFlowController.fixed(super.state) : _ref = null;
|
||||
|
||||
final Ref? _ref;
|
||||
Timer? _poll;
|
||||
AppLifecycleListener? _lifecycle;
|
||||
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
|
||||
|
||||
static const _pollInterval = Duration(seconds: 3);
|
||||
static const _pollInterval = Duration(seconds: 2);
|
||||
|
||||
bool get _isMobile {
|
||||
if (kIsWeb) return false;
|
||||
@@ -229,6 +238,9 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
|
||||
void _startPolling() {
|
||||
_poll?.cancel();
|
||||
// 立刻查一次:Timer.periodic 首拍要等满一个间隔才发,下单/续单后先补一拍,
|
||||
// 把「已付款但还没轮到第一拍」的空窗抹掉。
|
||||
unawaited(pollOnce());
|
||||
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
|
||||
}
|
||||
|
||||
@@ -239,6 +251,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lifecycle?.dispose();
|
||||
_stopPolling();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@ class TrayService with TrayListener, WindowListener {
|
||||
await trayManager.setIcon(
|
||||
Platform.isWindows ? 'assets/tray_icon.ico' : 'assets/tray_icon.png',
|
||||
);
|
||||
await trayManager.setToolTip('穿山甲 Pangolin');
|
||||
await trayManager.setToolTip('Pangolin');
|
||||
await trayManager.setContextMenu(Menu(items: [
|
||||
MenuItem(key: 'show', label: '显示主界面'),
|
||||
MenuItem(key: 'show', label: 'Show Window'),
|
||||
MenuItem.separator(),
|
||||
MenuItem(key: 'quit', label: '退出'),
|
||||
MenuItem(key: 'quit', label: 'Quit'),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<key>NetworkExtension</key>
|
||||
<dict>
|
||||
<!-- NEMachServiceName 必须以扩展所属的某个 App Group 为前缀(Apple 规则)。
|
||||
App Group 用 macOS 原生格式 BYL4KQHMTN.com.pangolin.pangolin,mach 名 = 该 group
|
||||
App Group 用 macOS 原生格式 44WULXM6SV.com.yanmeiai.pangolin,mach 名 = 该 group
|
||||
+ .PacketTunnel。参照可工作的 Tailscale:group=<Team>.io.tailscale.ipn.macsys、
|
||||
mach=该 group + .network-extension。 -->
|
||||
<key>NEMachServiceName</key>
|
||||
<string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
|
||||
<string>44WULXM6SV.com.yanmeiai.pangolin.PacketTunnel</string>
|
||||
<key>NEProviderClasses</key>
|
||||
<dict>
|
||||
<key>com.apple.networkextension.packet-tunnel</key>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
非 iOS 的 group. 前缀——sysextd realize 前校验请求方/扩展时要求此格式。 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
||||
<string>44WULXM6SV.com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -14,10 +14,10 @@ import Network
|
||||
import NetworkExtension
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.pangolin.pangolin.PacketTunnel", category: "provider")
|
||||
private let log = Logger(subsystem: "com.yanmeiai.pangolin.PacketTunnel", category: "provider")
|
||||
// macOS 原生 App Group 格式 <TeamID>.<name>(非 iOS 的 group. 前缀)。
|
||||
// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
|
||||
private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
|
||||
private let appGroup = "44WULXM6SV.com.yanmeiai.pangolin"
|
||||
|
||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer?
|
||||
|
||||
@@ -662,7 +662,7 @@
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
||||
@@ -677,7 +677,7 @@
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
||||
@@ -692,7 +692,7 @@
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
||||
@@ -758,7 +758,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -893,7 +893,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -916,7 +916,7 @@
|
||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -961,7 +961,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -982,8 +982,8 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -1011,7 +1011,7 @@
|
||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -1032,8 +1032,8 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_CODE_SIGN_FLAGS = "--timestamp";
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Pangolin PacketTunnel DevID";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -1060,7 +1060,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
||||
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
@@ -1079,8 +1079,8 @@
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
PRODUCT_NAME = pangolin_vpn
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 com.pangolin. All rights reserved.
|
||||
PRODUCT_COPYRIGHT = Copyright © 2026 Yanmei AI LLC. All rights reserved.
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
||||
<string>$(AppIdentifierPrefix)com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.pangolin.pangolin</string>
|
||||
<string>group.com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
-->
|
||||
</dict>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<string>Pangolin</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -5,7 +5,7 @@ import ServiceManagement
|
||||
class MainFlutterWindow: NSWindow {
|
||||
// launch_at_startup 插件在 macOS 没自带实现,需宿主接 MethodChannel。学 Tailscale 用
|
||||
// SMAppService.loginItem 注册内置的 PangolinLoginHelper(登录时它带 --autostart 拉起主 app)。
|
||||
private let loginHelperID = "com.pangolin.pangolin.LoginHelper"
|
||||
private let loginHelperID = "com.yanmeiai.pangolin.LoginHelper"
|
||||
|
||||
override func awakeFromNib() {
|
||||
let flutterViewController = FlutterViewController()
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
||||
<string>$(AppIdentifierPrefix)com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
<!-- 与 PacketTunnel sysext 共享 App Group。必须用 macOS 原生格式 <TeamID>.<name>
|
||||
(BYL4KQHMTN.com.pangolin.pangolin),非 iOS 的 group. 前缀——否则 sysextd 在 realize
|
||||
(44WULXM6SV.com.yanmeiai.pangolin),非 iOS 的 group. 前缀——否则 sysextd 在 realize
|
||||
暂存前校验请求方 app 时认定 app group 非法而拒绝(参照可工作的 Tailscale)。 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
||||
<string>44WULXM6SV.com.yanmeiai.pangolin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -17,15 +17,15 @@ import os.log
|
||||
|
||||
// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被
|
||||
// 系统 redact 成 <private>,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用,
|
||||
// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.pangolin.pangolin"'`。
|
||||
private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn")
|
||||
// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.yanmeiai.pangolin"'`。
|
||||
private let vpnLogObj = OSLog(subsystem: "com.yanmeiai.pangolin", category: "vpn")
|
||||
private func vpnLog(_ message: String) {
|
||||
os_log("%{public}@", log: vpnLogObj, type: .default, message)
|
||||
NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到
|
||||
}
|
||||
|
||||
final class VpnChannel: NSObject {
|
||||
private static let tunnelBundleId = "com.pangolin.pangolin.PacketTunnel"
|
||||
private static let tunnelBundleId = "com.yanmeiai.pangolin.PacketTunnel"
|
||||
|
||||
private var statusSink: FlutterEventSink?
|
||||
private var statsSink: FlutterEventSink?
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
HELPER_NAME="PangolinLoginHelper"
|
||||
HELPER_ID="com.pangolin.pangolin.LoginHelper"
|
||||
HELPER_ID="com.yanmeiai.pangolin.LoginHelper"
|
||||
SRC="${SRCROOT}/login_helper/main.swift"
|
||||
DEST="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Library/LoginItems/${HELPER_NAME}.app"
|
||||
WORK="${DERIVED_FILE_DIR}/login_helper"
|
||||
|
||||
@@ -71,6 +71,19 @@ void main() {
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded);
|
||||
});
|
||||
|
||||
test('start() 立即补一拍轮询:付款已到账则不等首个周期直接 succeeded', () async {
|
||||
// 修复「付完刷新不及时」:_startPolling 首拍立即发,而非干等一个 _pollInterval。
|
||||
final api = _FakePaymentApi()..activated = true;
|
||||
final c = _container(api);
|
||||
final ctl = c.read(paymentFlowProvider.notifier);
|
||||
await ctl.start(item, 'crypto');
|
||||
// 首拍是 unawaited,给微任务队列一次排空机会再断言。
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(api.statusCalls, greaterThanOrEqualTo(1),
|
||||
reason: '下单后应立刻补查一拍,不干等首个 Timer 周期');
|
||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded);
|
||||
});
|
||||
|
||||
test('stopPolling() 只停 Timer,不远程 cancel、不重置 state', () async {
|
||||
final api = _FakePaymentApi();
|
||||
final c = _container(api);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
; 编译: ISCC.exe pangolin.iss (或用 Inno Setup Compiler 打开)
|
||||
; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在
|
||||
; ..\..\build\windows\x64\runner\Release
|
||||
#define MyAppName "穿山甲 Pangolin"
|
||||
#define MyAppName "Pangolin"
|
||||
#define MyAppVersion "1.0.73"
|
||||
#define MyAppPublisher "Pangolin"
|
||||
#define MyAppExeName "pangolin_vpn.exe"
|
||||
|
||||
@@ -65,6 +65,30 @@
|
||||
"ru": "ЗАЩИЩЕНО",
|
||||
"es": "SEGURO"
|
||||
},
|
||||
"disconnectTitle": {
|
||||
"zh": "断开连接?",
|
||||
"en": "Disconnect?",
|
||||
"ja": "切断しますか?",
|
||||
"ko": "연결 해제하시겠습니까?",
|
||||
"ru": "Отключиться?",
|
||||
"es": "¿Desconectar?"
|
||||
},
|
||||
"disconnectBody": {
|
||||
"zh": "当前连接已加密。确定要断开吗?",
|
||||
"en": "Your connection is encrypted. Are you sure you want to disconnect?",
|
||||
"ja": "接続は暗号化されています。切断してもよろしいですか?",
|
||||
"ko": "연결이 암호화되어 있습니다. 정말 연결을 해제하시겠습니까?",
|
||||
"ru": "Ваше соединение зашифровано. Действительно отключиться?",
|
||||
"es": "Tu conexión está cifrada. ¿Seguro que quieres desconectar?"
|
||||
},
|
||||
"disconnectAction": {
|
||||
"zh": "断开",
|
||||
"en": "Disconnect",
|
||||
"ja": "切断",
|
||||
"ko": "연결 해제",
|
||||
"ru": "Отключить",
|
||||
"es": "Desconectar"
|
||||
},
|
||||
"tabConnect": {
|
||||
"zh": "连接",
|
||||
"en": "Connect",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Apple 开发者账号迁移 Runbook(中国 → 美国 Yanmei AI LLC)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:52px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
pre .c{color:#6b7385}
|
||||
pre .r{color:var(--bad)}
|
||||
pre .g{color:var(--ok)}
|
||||
pre .y{color:var(--warn)}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.you{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.me{background:rgba(224,136,79,.16);color:var(--accent)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card.warn{border-left:3px solid var(--warn)}
|
||||
.card.ok{border-left:3px solid var(--ok)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
.ok-c{color:var(--ok)} .bad-c{color:var(--bad)} .warn-c{color:var(--warn)}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:6px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.kbd{font-family:var(--mono);font-size:.85em;color:var(--accent)}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
|
||||
a{color:var(--accent2)}
|
||||
.who{font-size:11px;font-weight:700;letter-spacing:.03em}
|
||||
ol.steps{counter-reset:s;list-style:none;padding-left:0}
|
||||
ol.steps>li{counter-increment:s;position:relative;padding:2px 0 2px 40px;margin:14px 0}
|
||||
ol.steps>li::before{content:counter(s);position:absolute;left:0;top:0;width:26px;height:26px;border-radius:50%;background:var(--panel2);border:1px solid var(--border);color:var(--accent);font-family:var(--mono);font-size:13px;font-weight:700;display:flex;align-items:center;justify-content:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>Apple 开发者账号迁移 Runbook</h1>
|
||||
<p class="sub">Pangolin 客户端 · 中国「岩美北京技术」→ 美国「Yanmei AI LLC」· 目标:产物与仓库<strong>无中国开发者账号任何痕迹</strong> · 2026-09-06</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>一句话:</strong>这是一次<strong>干净切割</strong>——启用全新 Bundle ID(<code>com.yanmeiai.*</code>)、全新 Team、全新证书/描述文件/公证凭据,与旧中国账号<strong>零依赖</strong>(无需清理旧 App ID)。
|
||||
分三块:<span class="tag you">你做</span> Apple 后台建资产 + gitea 传新 secret;<span class="tag me">我做</span> 仓库代码里 Team ID / Bundle ID / App Group / 实体名的全量替换;<span class="tag me">我做</span> 本机钥匙串 + 公证凭据配置。
|
||||
顺序很重要:<strong>先建 Apple 资产 → 我改代码 → 传 gitea → 验证首个构建</strong>。
|
||||
</div>
|
||||
|
||||
<div class="card ok">
|
||||
<h3>✅ 进度:代码侧已完成(Team ID = <code>44WULXM6SV</code>)</h3>
|
||||
<p>仓库代码(§3)已全量迁到美国身份并收敛成单一真相源 <code>scripts/signing.env</code> + codegen。<strong>剩下的是你在 Apple 后台建资产(§1)、本机公证凭据(§2)、gitea 换 secret(§4)、首个构建验证(§5)。</strong>建资产时把两张证书精确全名 + ASC Key 的 Key/Issuer ID 发我,我做 §2。</p>
|
||||
</div>
|
||||
|
||||
<h2>0 · 命名总表(新账号下的目标身份)</h2>
|
||||
<table>
|
||||
<tr><th>项</th><th>旧(中国,待清除)</th><th>新(美国 Yanmei AI LLC)</th></tr>
|
||||
<tr><td>Team ID</td><td><code>BYL4KQHMTN</code></td><td><code>44WULXM6SV</code>(你提供)</td></tr>
|
||||
<tr><td>法人实体(证书 CN 内含)</td><td>Yanmei (beijing) Technology Co., Ltd</td><td>Yanmei AI LLC</td></tr>
|
||||
<tr><td>macOS 主 app Bundle ID</td><td><code>com.pangolin.pangolin</code></td><td><code>com.yanmeiai.pangolin</code></td></tr>
|
||||
<tr><td>macOS 扩展 Bundle ID</td><td><code>com.pangolin.pangolin.PacketTunnel</code></td><td><code>com.yanmeiai.pangolin.PacketTunnel</code></td></tr>
|
||||
<tr><td>iOS 主 app Bundle ID</td><td><code>com.pangolin.pangolinVpn</code></td><td><code>com.yanmeiai.pangolin</code></td></tr>
|
||||
<tr><td>iOS 扩展 Bundle ID</td><td><code>com.pangolin.pangolinVpn.PacketTunnel</code></td><td><code>com.yanmeiai.pangolin.PacketTunnel</code></td></tr>
|
||||
<tr><td>iOS App Group</td><td><code>group.com.pangolin.pangolinVpn</code></td><td><code>group.com.yanmeiai.pangolin</code></td></tr>
|
||||
<tr><td>macOS App Group(原生 <code><Team>.</code> 格式)</td><td><code>BYL4KQHMTN.com.pangolin.pangolin</code></td><td><code>44WULXM6SV.com.yanmeiai.pangolin</code></td></tr>
|
||||
<tr><td>macOS <code>NEMachServiceName</code></td><td><code>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</code></td><td><code>44WULXM6SV.com.yanmeiai.pangolin.PacketTunnel</code></td></tr>
|
||||
</table>
|
||||
<p class="small">iOS App Group 不含 Team ID(<code>group.</code> 前缀,跨账号可移植);macOS 用原生 <code><TeamID>.<name></code> 格式,<strong>Team 一换前缀必变</strong>——这也是必须改代码而非纯换证书的根本原因。旧 macOS 安装的登录态存在旧 App Group 容器里,切换后清空、用户需重登(重签本就如此,可接受)。</p>
|
||||
|
||||
<h2>1 · Apple 后台建资产 <span class="tag you">你做</span></h2>
|
||||
<p>全部在 <a href="https://developer.apple.com/account">developer.apple.com/account</a>(美国账号)完成。按顺序:</p>
|
||||
|
||||
<h3>1.1 记下 Team ID</h3>
|
||||
<p>Membership details → <b>Team ID</b> → 发我。</p>
|
||||
|
||||
<h3>1.2 注册 2 个 App ID(Identifiers)</h3>
|
||||
<p>Certificates, IDs & Profiles → <b>Identifiers</b> → + → App IDs → App。因 iOS/macOS 现共用同一 Bundle ID 字符串,<strong>只需 2 个唯一 App ID</strong>(各覆盖两端):</p>
|
||||
<table>
|
||||
<tr><th>Description</th><th>Bundle ID(Explicit)</th><th>勾选的 Capabilities</th></tr>
|
||||
<tr><td>Pangolin(主 app)</td><td><code>com.yanmeiai.pangolin</code></td><td>App Groups、Network Extensions、<strong>System Extension</strong></td></tr>
|
||||
<tr><td>Pangolin PacketTunnel(扩展)</td><td><code>com.yanmeiai.pangolin.PacketTunnel</code></td><td>App Groups、Network Extensions</td></tr>
|
||||
</table>
|
||||
<p class="small"><strong>主 app 三个能力缺一不可</strong>——尤其 <b>System Extension</b>:macOS 主 app 靠 <code>com.apple.developer.system-extension.install</code> 装载 sysext,少了它 Developer ID profile 不含该权限,构建报「Provisioning profile doesn't include the System Extension capability」。改了 App ID 能力后,相关 profile 会变 Invalid,须去 Profiles 重新 Generate + Download。扩展 App ID 只需 Network Extensions + App Groups。</p>
|
||||
|
||||
<h3>1.3 创建 App Group</h3>
|
||||
<p>Identifiers → 右上下拉切到 <b>App Groups</b> → + → 填 <code>group.com.yanmeiai.pangolin</code>(Description 随意)。回到上面 4 个 App ID,编辑其 <b>App Groups</b> capability → Configure → 勾上这个 group。</p>
|
||||
<div class="card"><p class="small"><b>macOS 端说明:</b>macOS 用的是原生 <code>44WULXM6SV.com.yanmeiai.pangolin</code>(team 作用域容器,不带 <code>group.</code>),<strong>不需要在后台单独注册</strong>——只要 App ID 勾了 App Groups capability、且用该 Team 的证书签名即可生效。后台注册的 <code>group.com.yanmeiai.pangolin</code> 供 iOS 使用。</p></div>
|
||||
|
||||
<h3>1.4 生成 2 张证书(Certificates)</h3>
|
||||
<p>用「钥匙串访问 → 证书助理 → 从证书颁发机构请求证书」生成一个 <code>CertificateSigningRequest.certSigningRequest</code>(存到磁盘),后台上传它换证书;下载后<strong>双击导入 login 钥匙串</strong>。</p>
|
||||
<ul>
|
||||
<li><b>Developer ID Application</b>(macOS 站外分发 + 公证用)→ 下载 <code>developerID_application.cer</code>。</li>
|
||||
<li><b>Apple Distribution</b>(iOS ad-hoc / App Store / TestFlight)→ 下载 <code>distribution.cer</code>。</li>
|
||||
</ul>
|
||||
<p class="small">导入后,两张证书的主体会是 <code>Developer ID Application: Yanmei AI LLC (44WULXM6SV)</code> 与 <code>Apple Distribution: Yanmei AI LLC (44WULXM6SV)</code>。<strong>请把这两串完整名字发我</strong>(<code>security find-identity -v -p codesigning</code> 可打印),我要把它们精确写进 <code>local_test.sh</code>。</p>
|
||||
|
||||
<h3>1.5 导出 .p12(给 CI 用)</h3>
|
||||
<p>钥匙串里分别右键两张证书(连带私钥)→ 导出为 .p12,设一个导出密码(记进 Bitwarden,别写文件)。得到 <code>developerID.p12</code> 与 <code>ios_dist.p12</code>——第 4 节转 base64 传 gitea。</p>
|
||||
|
||||
<h3>1.6 创建 4 个描述文件(Profiles)</h3>
|
||||
<p>Profiles → +:</p>
|
||||
<table>
|
||||
<tr><th>类型</th><th>关联 App ID</th><th>证书</th><th>用途</th></tr>
|
||||
<tr><td><b>Developer ID</b>(macOS,含 Network Extensions)</td><td><code>com.yanmeiai.pangolin</code></td><td>Developer ID Application</td><td>macOS 主 app</td></tr>
|
||||
<tr><td><b>Developer ID</b>(macOS)</td><td><code>com.yanmeiai.pangolin.PacketTunnel</code></td><td>Developer ID Application</td><td>macOS sysext</td></tr>
|
||||
<tr><td><b>App Store</b>(iOS)</td><td><code>com.yanmeiai.pangolin</code></td><td>Apple Distribution</td><td>iOS 主 app</td></tr>
|
||||
<tr><td><b>App Store</b>(iOS)</td><td><code>com.yanmeiai.pangolin.PacketTunnel</code></td><td>Apple Distribution</td><td>iOS 扩展</td></tr>
|
||||
</table>
|
||||
<p class="small">各自下载 <code>.provisionprofile</code>(macOS)/ <code>.mobileprovision</code>(iOS)。CI 会用它们;本机 <code>local_test.sh</code> 的 macOS 流程也从固定目录读这两个 macOS profile(第 3 节代码替换里会核对路径)。</p>
|
||||
|
||||
<h3>1.7 App Store Connect API Key</h3>
|
||||
<p><a href="https://appstoreconnect.apple.com/access/integrations/api">App Store Connect → Users and Access → Integrations → App Store Connect API</a> → 生成一个 <b>Team Key</b>(Role: Developer 或 App Manager)→ 记下 <b>Key ID</b> 与 <b>Issuer ID</b>,下载 <code>AuthKey_XXXX.p8</code>(只能下一次,妥存 Bitwarden)。用于公证(notarytool)与 CI 上传。</p>
|
||||
|
||||
<h2>2 · 本机配置 <span class="tag me">我做</span>(你把证书/凭据准备好后)</h2>
|
||||
<ol class="steps">
|
||||
<li>确认新证书已在 login 钥匙串:我跑 <code>security find-identity -v -p codesigning</code> 核对 <code>Developer ID Application: Yanmei AI LLC</code> 与 <code>Apple Distribution: Yanmei AI LLC</code> 在列。</li>
|
||||
<li>建公证凭据 profile(<code>local_test.sh</code> 用的 <code>pangolin-notary</code>)——用 1.7 的 ASC Key:
|
||||
<pre>xcrun notarytool store-credentials pangolin-notary \
|
||||
--key <span class="y">AuthKey_XXXX.p8</span> --key-id <span class="y"><KEY_ID></span> --issuer <span class="y"><ISSUER_ID></span></pre>
|
||||
这一步把凭据存进本机钥匙串,不入 git。(此命令需交互,可能由你在终端用 <span class="kbd">! …</span> 跑,我给你现成命令。)</li>
|
||||
<li>(可选)清理旧账号残留:删除 login 钥匙串里旧的 <code>… Yanmei (beijing) Technology</code> 两张证书,避免签名时误选旧身份。</li>
|
||||
</ol>
|
||||
|
||||
<h2>3 · 仓库代码替换 <span class="tag ok">已完成</span>(单一真相源 + codegen)</h2>
|
||||
<div class="card ok"><p class="small"><b>已落地,且不再是「散落硬编码」。</b>签名身份收敛成<strong>单一真相源</strong>,今后迁账号/改 Bundle ID <strong>只改一个文件跑一次 codegen</strong>,不用再全仓找替换。</p></div>
|
||||
|
||||
<h3>3.1 单一真相源 <code>scripts/signing.env</code></h3>
|
||||
<p>Team ID / 实体名 / Bundle ID / App Group 全在这里(当前已是美国身份:Team <code>44WULXM6SV</code>、<code>Yanmei AI LLC</code>、<code>com.yanmeiai.pangolin</code>)。三个签名脚本(<code>local_test.sh</code> / <code>ci/compile-macos.sh</code> / <code>ci/compile-ios.sh</code>)直接 <code>source</code> 它。</p>
|
||||
|
||||
<h3>3.2 原生文件由 codegen 回填</h3>
|
||||
<p>Xcode/Swift 侧的标识符无法 source 变量、只能字面量,故由 <code>node scripts/gen-signing.mjs</code> 从 signing.env <strong>结构化回填</strong> 16 个原生文件(两端 <code>project.pbxproj</code> 的 <code>DEVELOPMENT_TEAM</code>/Bundle ID、各 <code>*.entitlements</code> 的 App Group/app-identifier、macOS <code>Info.plist</code> 的 <code>NEMachServiceName</code>、各 Swift 的 <code>appGroup</code>/subsystem/loginHelper 等)。codegen 用「形状」匹配、<strong>不含任何旧字面值</strong>——自身无痕,未来再迁仍生效。</p>
|
||||
<pre><span class="c"># 迁账号/改 Bundle ID 的唯一操作:</span>
|
||||
vim scripts/signing.env <span class="c"># 改 3 个基值</span>
|
||||
node scripts/gen-signing.mjs <span class="c"># 回填全部原生文件</span></pre>
|
||||
|
||||
<h3>3.3 drift 闸防漂移</h3>
|
||||
<p><code>ci/check-codegen-drift.sh</code> 跑 <code>gen-signing.mjs --check</code>:若改了 signing.env 没重生成、或手改了原生标识符,即失败。与 token/l10n 同一套 codegen 单源治理。</p>
|
||||
|
||||
<h3>3.4 已验证</h3>
|
||||
<ul>
|
||||
<li>6 份 entitlements/plist <code>plutil -lint</code> 全 OK;两端 <code>project.pbxproj</code> <code>xcodebuild -list</code> 可解析,Runner build settings 解析为新 Team/Bundle。</li>
|
||||
<li><code>gen-signing.mjs --check</code> 幂等 0 drift;完整 codegen drift 闸(token/l10n/proto-i18n/signing)全绿。</li>
|
||||
<li>实体名(证书主体来自证书本身,换证后二进制即美国实体)+ 注释/ORGANIZATIONNAME 等 Apple 侧残留痕迹已清。</li>
|
||||
</ul>
|
||||
<div class="card warn"><p class="small"><b>范围说明:</b>未动 Android 包名 <code>com.pangolin.pangolin_vpn</code>(Play Store 身份、自签名、<strong>非 Apple 账号痕迹</strong>,改动会破坏 Play 更新,另议)与 Windows/桌面少量元数据串。<code>codesign</code> 层面 Apple 产物已零中国痕迹。</p></div>
|
||||
|
||||
<h2>4 · gitea Secrets 换新 <span class="tag you">你做</span></h2>
|
||||
<p>gitea → 仓库 <code>wangjia/pangolin</code> → Settings → Actions → <b>Secrets</b>(注意历史经验:Apple 签名 secret 配在 <strong>用户级</strong>非仓库级,见记忆 <code>pangolin-apple-signing-assets</code>——按你当初配的层级更新)。<strong>名字不变,只换值</strong>:</p>
|
||||
<table>
|
||||
<tr><th>Secret 名</th><th>新值来源</th></tr>
|
||||
<tr><td><code>DEVELOPER_ID_P12</code></td><td><code>base64 -i developerID.p12</code>(1.5)</td></tr>
|
||||
<tr><td><code>P12_PASSWORD</code></td><td>导出 developerID.p12 时设的密码</td></tr>
|
||||
<tr><td><code>MACOS_APP_PROVISION_PROFILE_BASE64</code></td><td>macOS 主 app 的 <code>.provisionprofile</code> base64(1.6)</td></tr>
|
||||
<tr><td><code>MACOS_SYSEXT_PROVISION_PROFILE_BASE64</code></td><td>macOS sysext 的 <code>.provisionprofile</code> base64</td></tr>
|
||||
<tr><td><code>IOS_DIST_P12</code></td><td><code>base64 -i ios_dist.p12</code></td></tr>
|
||||
<tr><td><code>IOS_DIST_PASSWORD</code></td><td>导出 ios_dist.p12 时设的密码</td></tr>
|
||||
<tr><td><code>IOS_APP_PROVISIONING_PROFILE_BASE64</code></td><td>iOS 主 app 的 <code>.mobileprovision</code> base64</td></tr>
|
||||
<tr><td><code>IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64</code></td><td>iOS 扩展的 <code>.mobileprovision</code> base64</td></tr>
|
||||
<tr><td><code>APPSTORE_API_KEY_ID</code></td><td>1.7 的 Key ID</td></tr>
|
||||
<tr><td><code>APPSTORE_API_ISSUER_ID</code></td><td>1.7 的 Issuer ID</td></tr>
|
||||
<tr><td><code>APPSTORE_API_KEY_P8_BASE64</code></td><td><code>base64 -i AuthKey_XXXX.p8</code></td></tr>
|
||||
</table>
|
||||
<p class="small">macOS 转 base64:<code>base64 -i 文件</code>(默认单行,直接粘 gitea)。Android 的 <code>RELEASE_KEYSTORE</code>/<code>KEY_PASSWORD</code> 是自签名,与 Apple 账号无关,<strong>不用动</strong>。</p>
|
||||
|
||||
<h2>5 · 验证 + 首个构建 <span class="tag me">我做</span> / <span class="tag you">你配合</span></h2>
|
||||
<ol class="steps">
|
||||
<li><b>本机 macOS:</b><code>bash scripts/local_test.sh macos</code>(build+签名+公证+staple+App Group 一致性闸)。</li>
|
||||
<li><b>iOS 装机:</b>iPhone/iPad 连上后 <code>bash scripts/local_test.sh ipad "<设备>"</code>——脚本会核验签名主体,若还是旧证书会 die。</li>
|
||||
<li><b>CI 发版:</b>打 tag <code>client-vX.Y.Z</code> 触发 gitea 流水线,用新 secret 构建。首次建议先跑一次看签名步骤绿。</li>
|
||||
<li><b>无痕核验:</b>对产物二进制跑:
|
||||
<pre>codesign -dvvv <span class="c"># 看 Authority / TeamIdentifier</span> Runner.app 2>&1 | grep -E "Authority|TeamIdentifier"
|
||||
<span class="c"># 期望:Yanmei AI LLC + 44WULXM6SV,无 BYL4KQHMTN / beijing</span></pre>
|
||||
再全仓 <code>grep -rn "BYL4KQHMTN\|beijing" client scripts .gitea</code> 应为空。</li>
|
||||
</ol>
|
||||
|
||||
<div class="card ok">
|
||||
<h3>✅ 无痕验收清单</h3>
|
||||
<ul>
|
||||
<li>产物二进制 <code>codesign -dvvv</code>:Authority = <code>Yanmei AI LLC</code>、TeamIdentifier = <code>44WULXM6SV</code>。</li>
|
||||
<li><code>grep -rn "BYL4KQHMTN" client scripts .gitea docs</code> → 空。</li>
|
||||
<li><code>grep -rin "beijing\|岩美" client scripts .gitea</code> → 空。</li>
|
||||
<li>Info.plist / entitlements 里 App Group / NEMachServiceName 全为 <code>44WULXM6SV.com.yanmeiai.pangolin*</code>。</li>
|
||||
<li>gitea 12 个 Apple secret 全为新值;旧 p12/profile 不再被任何 workflow 引用。</li>
|
||||
<li>公证记录归属新账号(notarytool history 显示新 Team)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>6 · 顺序与责任一览</h2>
|
||||
<table>
|
||||
<tr><th>#</th><th>步骤</th><th>谁</th><th>依赖</th></tr>
|
||||
<tr><td>1</td><td>Apple 后台建 Team/App ID/Group/证书/Profile/ASC Key(§1)</td><td><span class="tag you">你</span></td><td>—</td></tr>
|
||||
<tr><td>2</td><td>把 Team ID + 两张证书全名 + ASC Key 信息发我</td><td><span class="tag you">你</span></td><td>1</td></tr>
|
||||
<tr><td>3</td><td>本机钥匙串核对 + 建 <code>pangolin-notary</code> 公证凭据(§2)</td><td><span class="tag me">我</span></td><td>2</td></tr>
|
||||
<tr><td>4</td><td>仓库代码替换 → signing.env 单源 + codegen(§3)<span class="tag ok">已完成</span></td><td><span class="tag me">我</span></td><td>—</td></tr>
|
||||
<tr><td>5</td><td>gitea 12 个 secret 换新值(§4)</td><td><span class="tag you">你</span></td><td>1</td></tr>
|
||||
<tr><td>6</td><td>本机 macOS/iOS 构建验证(§5.1–5.2)</td><td><span class="tag me">我</span></td><td>3,4</td></tr>
|
||||
<tr><td>7</td><td>打 tag 触发 CI 发版 + 无痕核验(§5.3–5.4)</td><td><span class="tag me">我</span>+<span class="tag you">你</span></td><td>4,5</td></tr>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
<p class="small">相关:<a href="macos-sysext-realize-troubleshooting.html">macOS sysext realize 排障</a> · <a href="ios-ipad-support-design.html">iOS/iPad 支持设计</a> · 签名资产位置见项目记忆 <code>pangolin-apple-signing-assets</code>。本文随迁移推进更新。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CI/CD 多账号签名设计(Apple 多账号 × 多 App)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:29px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:52px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:28px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:12.5px;line-height:1.6;color:#cdd3df}
|
||||
pre .c{color:#6b7385}
|
||||
pre .k{color:#e0b84f}
|
||||
pre .s{color:#8fca7a}
|
||||
pre .r{color:var(--bad)}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.acc{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.app{background:rgba(224,136,79,.16);color:var(--accent)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card.ok{border-left:3px solid var(--ok)}
|
||||
.card.warn{border-left:3px solid var(--warn)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:6px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:40px 0}
|
||||
a{color:var(--accent2)}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
@media(max-width:720px){.grid2{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>CI/CD 多账号签名设计</h1>
|
||||
<p class="sub">Apple 多签名账号(中国 <span class="tag acc">CN</span> / 美国 <span class="tag acc">US</span> / …)× 多 App(pangolin / dudu / jiu …)· 按配置切换 · 2026-09-07</p>
|
||||
|
||||
<div class="lead">
|
||||
<strong>一句话:</strong>把 CI 签名拆成正交的两维——<b>「用哪个账号」</b>(account,跨 App 共享证书/ASC Key)和 <b>「哪个 App」</b>(bundle 专属描述文件)。App 在自己仓库的 <code>signing.env</code> 里声明 <code>SIGNING_ACCOUNT=us|cn</code> 一行即完成切换;工作流据此从 gitea 取对应账号的密钥集。新增账号=在 gitea 存一套 <code>SIGN_<ACCT>_*</code>;新增 App=拷工作流骨架 + 填 signing.env + 传本仓 profile。<b>零证书重复、切换即改一行。</b>
|
||||
</div>
|
||||
|
||||
<h2>1. 目标与约束</h2>
|
||||
<ul>
|
||||
<li><b>多账号并存</b>:CN(岩美北京 <code>BYL4KQHMTN</code>)与 US(Yanmei AI LLC <code>44WULXM6SV</code>)两套签名同时可用,互不干扰。</li>
|
||||
<li><b>配置切换</b>:某 App 用哪个账号,由该 App 仓库内一处配置决定,改一行即切,无需动密钥。</li>
|
||||
<li><b>多 App 复用</b>:pangolin 之后 dudu/jiu 等都要发版;同账号的多个 App 共享同一套证书/ASC Key,不重复存。</li>
|
||||
<li><b>产物无痕</b>:某 App 选了 US,产物里不得有 CN 痕迹(由各仓 <code>signing.env</code> 单源 + <code>gen-signing.mjs</code> 保证,见 <a href="apple-account-migration-runbook.html">迁移 Runbook</a>)。</li>
|
||||
<li><b>不动 Android</b>:Android keystore 自签、与 Apple 账号无关,始终 per-App 仓库级。</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. 核心心智模型:身份 ⟂ 密钥,account ⟂ app</h2>
|
||||
<p>两组正交的拆分,是整个设计的地基:</p>
|
||||
<div class="grid2">
|
||||
<div class="card"><h3>身份 vs 密钥</h3>
|
||||
<p><b>身份</b>(非机密,入 git,单源):Team ID / 实体名 / Bundle ID / App Group。已在各仓 <code>scripts/signing.env</code>。<br>
|
||||
<b>密钥</b>(机密,存 gitea):证书 <code>.p12</code> / 私钥密码 / ASC <code>.p8</code> / 描述文件 base64。</p>
|
||||
</div>
|
||||
<div class="card"><h3>account-scoped vs app-scoped</h3>
|
||||
<p><b>account 级</b>(一个 Apple 账号一套,跨该账号所有 App 共享):Developer ID / Apple Distribution 证书、ASC API Key。<br>
|
||||
<b>app 级</b>(每个 App 各一份,因 bundle ID 不同):Provisioning Profiles。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small">关键洞察:<b>证书是账号级的</b>(一张 Developer ID 证书能签该账号下任意 App),<b>描述文件是 App 级的</b>(绑定具体 bundle ID)。所以证书按账号存一次共享,描述文件按 App 存本仓——这条拆分让「多账号 × 多 App」不产生 N×M 的密钥爆炸。</p>
|
||||
|
||||
<h2>3. 三层配置模型</h2>
|
||||
<table>
|
||||
<tr><th>层</th><th>放哪</th><th>内容</th><th>粒度</th></tr>
|
||||
<tr><td><b>① 身份单源</b></td><td>各 App 仓 <code>scripts/signing.env</code>(入 git)</td><td>Team/实体/Bundle/AppGroup + <b><code>SIGNING_ACCOUNT=us|cn</code></b>(新增字段,即切换开关)</td><td><span class="tag app">per-app</span></td></tr>
|
||||
<tr><td><b>② 账号密钥集</b></td><td>gitea <b>用户级</b> secret(跨仓共享)</td><td><code>SIGN_<ACCT>_*</code>:Developer ID .p12 + 密码、Apple Distribution .p12 + 密码、ASC Key ID/Issuer/p8</td><td><span class="tag acc">per-account</span></td></tr>
|
||||
<tr><td><b>③ App 描述文件</b></td><td>gitea <b>仓库级</b> secret</td><td>macOS App/Sysext + iOS App/Ext 四个 <code>*_PROVISION*_BASE64</code></td><td><span class="tag app">per-app</span></td></tr>
|
||||
</table>
|
||||
<p class="small">当前你的 gitea 是单用户账号拥有所有仓,「用户级」即事实上的「组织级共享层」;「仓库级」覆盖用户级同名值。Android <code>RELEASE_KEYSTORE</code>/<code>KEY_PASSWORD</code> 属 ③ 的 per-app,不变。</p>
|
||||
|
||||
<h2>4. gitea 密钥命名规范</h2>
|
||||
<h3>② 账号级(用户级,每个账号一套)</h3>
|
||||
<pre><span class="c"># 美国账号</span>
|
||||
SIGN_US_DEVELOPER_ID_P12 <span class="c"># Developer ID Application .p12 base64(macOS)</span>
|
||||
SIGN_US_DEVELOPER_ID_PASSWORD
|
||||
SIGN_US_APPLE_DIST_P12 <span class="c"># Apple Distribution .p12 base64(iOS)</span>
|
||||
SIGN_US_APPLE_DIST_PASSWORD
|
||||
SIGN_US_ASC_KEY_ID <span class="c"># 8G78KGHL5C</span>
|
||||
SIGN_US_ASC_ISSUER_ID
|
||||
SIGN_US_ASC_KEY_P8 <span class="c"># AuthKey_*.p8 base64</span>
|
||||
<span class="c"># 中国账号(把现有同类值改名到此前缀,或保留旧名让 cn 分支指向旧名)</span>
|
||||
SIGN_CN_DEVELOPER_ID_P12 / _PASSWORD / SIGN_CN_APPLE_DIST_P12 / … / SIGN_CN_ASC_*</pre>
|
||||
<h3>③ App 级(仓库级,每个 App 各一份)</h3>
|
||||
<pre>MACOS_APP_PROVISION_PROFILE_BASE64
|
||||
MACOS_SYSEXT_PROVISION_PROFILE_BASE64
|
||||
IOS_APP_PROVISIONING_PROFILE_BASE64
|
||||
IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64</pre>
|
||||
<p class="small">描述文件绑定 bundle ID,天然 per-App;放仓库级,各 App 互不影响。</p>
|
||||
|
||||
<h2>5. 工作流:读账号 → 映射密钥</h2>
|
||||
<p>现有 <code>deploy-client.yml</code> 每个 build job 本就有「secret → env 变量」映射层(<code>compile-*.sh</code> 消费)。切换只需把固定的 <code>secrets.X</code> 换成<b>按 <code>SIGNING_ACCOUNT</code> 条件选择</b>。</p>
|
||||
<h3>5.1 读出账号(一步,从 signing.env 或仓库变量)</h3>
|
||||
<pre><span class="k">- name:</span> Resolve signing account
|
||||
<span class="k">id:</span> acct
|
||||
<span class="k">run:</span> <span class="c"># 从提交进仓的 signing.env 读单源,避免再设一处 gitea 变量</span>
|
||||
. scripts/signing.env
|
||||
echo "account=${SIGNING_ACCOUNT:?signing.env 缺 SIGNING_ACCOUNT}" >> "$GITHUB_OUTPUT"</pre>
|
||||
<h3>5.2 按账号映射证书/ASC(三元表达式,2 账号足够;多账号见 §7)</h3>
|
||||
<pre><span class="k">- name:</span> Compile (macOS)
|
||||
<span class="k">env:</span>
|
||||
<span class="k">ACCT:</span> ${{ steps.acct.outputs.account }}
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ env.ACCT == 'us'
|
||||
&& secrets.SIGN_US_DEVELOPER_ID_P12 || secrets.SIGN_CN_DEVELOPER_ID_P12 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ env.ACCT == 'us'
|
||||
&& secrets.SIGN_US_DEVELOPER_ID_PASSWORD || secrets.SIGN_CN_DEVELOPER_ID_PASSWORD }}
|
||||
APPSTORE_API_KEY_ID: ${{ env.ACCT == 'us'
|
||||
&& secrets.SIGN_US_ASC_KEY_ID || secrets.SIGN_CN_ASC_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ env.ACCT == 'us'
|
||||
&& secrets.SIGN_US_ASC_ISSUER_ID || secrets.SIGN_CN_ASC_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ env.ACCT == 'us'
|
||||
&& secrets.SIGN_US_ASC_KEY_P8 || secrets.SIGN_CN_ASC_KEY_P8 }}
|
||||
<span class="c"># ③ 描述文件是仓库级、per-app、与账号无关 → 直接引用</span>
|
||||
MACOS_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }}
|
||||
MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_PROVISION_PROFILE_BASE64 }}
|
||||
<span class="k">run:</span> bash scripts/ci/compile-macos.sh "$REF_NAME"</pre>
|
||||
<p class="small"><code>compile-macos.sh</code> 内部已 <code>source signing.env</code> 拿 Team/Bundle,与上面注入的证书/描述文件对齐 → 天然一致。iOS job 同理(<code>SIGN_<ACCT>_APPLE_DIST_*</code>)。</p>
|
||||
|
||||
<h2>6. 多 App 复用:抽成共享动作</h2>
|
||||
<p>把「解析账号 + 导入证书到临时钥匙串 + 装描述文件」抽成一个 <b>composite action</b>(<code>.gitea/actions/apple-sign/action.yml</code>)或共享脚本,各 App 的 workflow 调用它,只传 <code>account</code> + 各自 profile。</p>
|
||||
<pre><span class="c"># 某 App 的 deploy-client.yml 里</span>
|
||||
<span class="k">- uses:</span> ./.gitea/actions/apple-sign
|
||||
<span class="k">with:</span>
|
||||
account: ${{ steps.acct.outputs.account }}
|
||||
<span class="c"># 证书/ASC 由 action 内部按 account 从 SIGN_<ACCT>_* 取(secret 需显式透传)</span></pre>
|
||||
<p class="small">gitea/GitHub 的 composite action 不自动继承 secret,须由调用方 <code>with</code>/<code>env</code> 显式传入 → §5 的三元映射留在调用方,action 收「已解析的值」。<b>新增 App</b> = 拷 workflow 骨架 + 写 <code>signing.env</code>(含 SIGNING_ACCOUNT) + 传 4 个仓库级 profile;账号侧零改动。</p>
|
||||
|
||||
<h2>7. 操作手册</h2>
|
||||
<table>
|
||||
<tr><th>场景</th><th>做什么</th></tr>
|
||||
<tr><td><b>新增一个签名账号</b>(如再开个欧洲实体)</td><td>在 gitea 用户级存一套 <code>SIGN_<NEW>_*</code>;§5 的三元链加一档(或改 §7.1 的映射表法)。</td></tr>
|
||||
<tr><td><b>新增一个 App</b>(同已有账号)</td><td>拷 workflow 骨架;App 仓 <code>signing.env</code> 填 <code>SIGNING_ACCOUNT</code> + 身份;建该 App 的 4 个描述文件传<b>仓库级</b>。证书/ASC 复用账号级,<b>不新增</b>。</td></tr>
|
||||
<tr><td><b>切换某 App 的账号</b></td><td>改该仓 <code>signing.env</code> 的 <code>SIGNING_ACCOUNT</code>(+ Team/Bundle 等身份、跑 <code>gen-signing.mjs</code>);换该仓的 4 个描述文件为新账号的。证书 secret 不用碰。</td></tr>
|
||||
</table>
|
||||
<h3>7.1 账号 > 2 时:用映射步骤替代三元链</h3>
|
||||
<p>三元 <code>a && x || y</code> 只宜 2 档。多账号改用一个 shell 映射步骤,把 <code>SIGN_<ACCT>_*</code> 落成通用 env(需把该账号所有 secret 传进该步,用 <code>case "$ACCT"</code> 选)。或每账号一个 <code>--env-file</code>。本质是把「选择」从 YAML 表达式挪进脚本,可扩展到任意账号数。</p>
|
||||
|
||||
<h2>8. 落地分期</h2>
|
||||
<div class="card ok"><h3>Phase 0 · 立即解锁 pangolin(最小改动,前向兼容)</h3>
|
||||
<p>不动 workflow:把 US 证书/ASC/描述文件用<b>现有通用名</b>(<code>DEVELOPER_ID_P12</code>…)存到 <b>pangolin 仓库级</b>。仓库级覆盖用户级中国值 → pangolin CI 用美国、其他 App 用户级中国不变。<b>今天就能发版。</b></p>
|
||||
<p class="small">代价:还没有 <code>SIGNING_ACCOUNT</code> 开关,是「按仓库物理隔离」而非「按配置切换」;后续升 Phase 1 要把这批 secret 改名。</p></div>
|
||||
<div class="card"><h3>Phase 1 · 完整多账号模型(本设计)</h3>
|
||||
<p>落 <code>SIGN_<ACCT>_*</code> 账号层 + <code>signing.env</code> 的 <code>SIGNING_ACCOUNT</code> + workflow 三元映射 + composite action。第 2 个 App 迁移、或想要「配置切换」时做。</p></div>
|
||||
<p><b>建议</b>:pangolin 想尽快发版就先 Phase 0;若你不急发版、想一步到位,直接 Phase 1(多改一次 workflow,免日后改名返工)。两者产物完全一致,差别只在 secret 组织。</p>
|
||||
|
||||
<h2>9. 待核实的 gitea/forgejo 能力</h2>
|
||||
<ul>
|
||||
<li><b>用户级 secret 跨仓共享 + 仓库级覆盖同名</b>:现网已在用(记忆 <code>pangolin-apple-signing-assets</code>)→ ✅。</li>
|
||||
<li><b>表达式三元 <code>&& ||</code> + <code>secrets.*</code> 在 <code>env:</code></b>:GitHub 兼容语法,act_runner 应支持;上线前用一个 dummy secret 验一次。</li>
|
||||
<li><b>composite action(本仓 <code>./.gitea/actions/*</code>)</b>:forgejo runner 支持度需实测;不支持则退回「共享 shell 脚本」(scripts/ci/apple-sign-common.sh)。</li>
|
||||
<li><b>仓库变量 <code>vars.*</code></b>:本设计改用 <code>signing.env</code> 读 <code>SIGNING_ACCOUNT</code>,不依赖 <code>vars</code>,规避版本差异。</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
<p class="small">关联:<a href="apple-account-migration-runbook.html">Apple 账号迁移 Runbook</a> · 签名单源见 <code>scripts/signing.env</code> + <code>scripts/gen-signing.mjs</code> · 现网 secret 清单/层级见记忆 <code>pangolin-apple-signing-assets</code> / <code>pangolin-client-release-ci</code>。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
|
||||
<h2>设计方案 / Specs</h2>
|
||||
<a class="doc" href="ci-multi-account-signing-design.html">
|
||||
<div class="t">CI/CD 多账号签名设计(Apple 多账号 × 多 App)<span class="tag html">HTML</span></div>
|
||||
<div class="d">CI 签名拆成正交两维:account(用哪个 Apple 账号,证书/ASC Key 跨 App 共享)× app(bundle 专属描述文件)。App 在自己仓 signing.env 声明 SIGNING_ACCOUNT=us|cn 一行切换;工作流三元映射从 gitea 取对应账号 SIGN_<ACCT>_* 密钥集。三层配置(身份单源/账号级证书/App级profile)+ 命名规范 + composite action 复用 + 新增账号/App/切换操作手册 + Phase 0(仓库级快速解锁)/Phase 1(完整模型)分期。零证书重复、切换即改一行。</div>
|
||||
<div class="path">docs/ci-multi-account-signing-design.html</div>
|
||||
</a>
|
||||
<a class="doc" href="notifications-design.html">
|
||||
<div class="t">系统通知机制(Spec ③)<span class="tag html">HTML</span></div>
|
||||
<div class="d">App 内统一通知收件箱(铃铛+列表),六类:重要/新特性/新闻/到账(个人定向)/版本/活动。单 notices 表 + user_id(NULL=广播);已读用服务端 last_read_at 水位多端同步;GET /v1/notices(合并广播+定向,unread_count) + POST /read。生产三路:nodectl notice 子命令(手工) / 事件钩子同事务(购买开通·邀请·首充·TG 到账,零孤儿通知) / 发版脚本联动插 version 公告。邮件仅 important+显式 --email(SMTP 直发+email_sent_at 幂等)。不做:APNs/FCM(二期)/逐条已读/偏好开关/六语内容。原型先行:两端通知视图先统一(桌面pill vs 移动icon)再动代码。</div>
|
||||
@@ -235,6 +240,11 @@
|
||||
</a>
|
||||
|
||||
<h2>排障 / Runbook</h2>
|
||||
<a class="doc" href="apple-account-migration-runbook.html">
|
||||
<div class="t">Apple 开发者账号迁移 Runbook(中国 → 美国)<span class="tag html">HTML</span></div>
|
||||
<div class="d">把 Pangolin 的 Apple 签名从中国「岩美北京技术」(Team BYL4KQHMTN)整体迁到美国「Yanmei AI LLC」,做到产物与仓库无中国账号痕迹。干净切割:全新 Bundle ID(com.yanmeiai.*)+ 全新 Team/证书/描述文件/公证凭据,零依赖旧账号。含 Apple 后台建资产、本机钥匙串+公证、仓库 Team ID/Bundle ID/App Group/实体名全量替换点、gitea 12 个 secret 换新、无痕验收清单与责任顺序表。</div>
|
||||
<div class="path">docs/apple-account-migration-runbook.html</div>
|
||||
</a>
|
||||
<a class="doc" href="connect-latency-urltest.html">
|
||||
<div class="t">连接页延迟(urltest)· 跨端实现 + 排障 <span class="tag html">HTML</span></div>
|
||||
<div class="d">连接页"延迟"= 内核 urltest(非直连实测)。根因链:服务端配置无 clash_api、libbox 单 client Group 不回调、Dart 广播流抢 EventChannel sink、连接态直连实测假值。各端(macOS/iOS/Android/Windows)注入 clash_api + 本地 HTTP 取法 + 排障打点 + libbox 构建前置(Android 要 JDK 17、包名 io.nekohasekai.libbox)。</div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# cert import, provisioning-profile install by UUID, ExportOptions.plist,
|
||||
# `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin:
|
||||
# - pangolin's iOS app ships a NEPacketTunnelProvider **app extension**
|
||||
# (client/ios/PacketTunnel/, bundle com.pangolin.pangolinVpn.PacketTunnel)
|
||||
# alongside the main app (com.pangolin.pangolinVpn) — jiu has no
|
||||
# (client/ios/PacketTunnel/, bundle com.yanmeiai.pangolin.PacketTunnel)
|
||||
# alongside the main app (com.yanmeiai.pangolin) — jiu has no
|
||||
# extension at all, just the one app target. So this script needs TWO
|
||||
# distribution provisioning profiles (app + extension), not one, and the
|
||||
# ExportOptions.plist provisioningProfiles dict needs both bundle-id ->
|
||||
@@ -17,7 +17,7 @@
|
||||
# embedded core. This script MUST run `scripts/build-libbox.sh apple ios`
|
||||
# before `flutter build ipa`, or the IPA links no VPN kernel (mirrors the
|
||||
# equivalent step in compile-android.sh / compile-macos.sh).
|
||||
# - Team ID BYL4KQHMTN is hardcoded as a script constant rather than a
|
||||
# - Team ID 44WULXM6SV is hardcoded as a script constant rather than a
|
||||
# secret (unlike jiu's IOS_TEAM_ID secret): it's already public inside
|
||||
# this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj
|
||||
# DEVELOPMENT_TEAM, scripts/local_test.sh SIGN_ID) — not sensitive, no
|
||||
@@ -33,9 +33,9 @@
|
||||
# IOS_DIST_CERT_P12_BASE64 Apple Distribution 证书(.p12)base64
|
||||
# IOS_DIST_CERT_PASSWORD .p12 导出密码
|
||||
# IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app,
|
||||
# com.pangolin.pangolinVpn)base64
|
||||
# com.yanmeiai.pangolin)base64
|
||||
# IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(扩展,
|
||||
# com.pangolin.pangolinVpn.PacketTunnel)base64
|
||||
# com.yanmeiai.pangolin.PacketTunnel)base64
|
||||
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
|
||||
# App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。
|
||||
#
|
||||
@@ -83,9 +83,11 @@ fi
|
||||
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
TEAM_ID="BYL4KQHMTN"
|
||||
APP_BUNDLE_ID="com.pangolin.pangolinVpn"
|
||||
EXT_BUNDLE_ID="com.pangolin.pangolinVpn.PacketTunnel"
|
||||
# 签名身份单一真相源(../signing.env);迁移账号只改 signing.env。
|
||||
. "${BASH_SOURCE[0]%/*}/../signing.env"
|
||||
TEAM_ID="$SIGNING_TEAM_ID"
|
||||
APP_BUNDLE_ID="$SIGNING_APP_BUNDLE_ID"
|
||||
EXT_BUNDLE_ID="$SIGNING_EXT_BUNDLE_ID"
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain
|
||||
# Developer ID import, inside-out codesign, notarytool submit --wait, staple,
|
||||
# ditto zip), but pangolin's macOS app is heavier than jiu's plain window:
|
||||
# - it embeds a System Extension (com.pangolin.pangolin.PacketTunnel,
|
||||
# - it embeds a System Extension (com.yanmeiai.pangolin.PacketTunnel,
|
||||
# `.systemextension` bundle under Contents/Library/SystemExtensions/)
|
||||
# that needs ITS OWN Developer ID provisioning profile + entitlements,
|
||||
# signed separately, inside-out, before the outer app is signed — see
|
||||
@@ -92,11 +92,13 @@ echo "==> compile-macos: tag=${TAG} version=${VER} build=${BUILD}"
|
||||
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||
# 签名身份单一真相源(../signing.env);迁移账号只改 signing.env。
|
||||
. "${BASH_SOURCE[0]%/*}/../signing.env"
|
||||
SIGN_ID_PREFIX="Developer ID Application"
|
||||
TEAM_ID="BYL4KQHMTN"
|
||||
APP_BUNDLE_ID="com.pangolin.pangolin"
|
||||
SYSEXT_BUNDLE_ID="com.pangolin.pangolin.PacketTunnel"
|
||||
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
|
||||
TEAM_ID="$SIGNING_TEAM_ID"
|
||||
APP_BUNDLE_ID="$SIGNING_APP_BUNDLE_ID"
|
||||
SYSEXT_BUNDLE_ID="$SIGNING_EXT_BUNDLE_ID"
|
||||
APP_GROUP="$SIGNING_MACOS_APP_GROUP"
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db"
|
||||
|
||||
@@ -52,6 +52,18 @@ install -m755 /tmp/pangolin-server "$BIN/pangolin-server"
|
||||
install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent"
|
||||
install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate"
|
||||
install -m755 /tmp/pangolin-nodectl "$BIN/pangolin-nodectl"
|
||||
# ── 控制面收口(幂等):HTTP API 只绑 loopback,经 cloudflared 隧道对外,关公网 8080 ──
|
||||
# 现网 server.env 曾漂移成 ADDR=:8080(全网卡明文暴露 http://<公网IP>:8080,绕过 CF/TLS)。
|
||||
# 这里在重启前把它纠回 127.0.0.1:8080 并撤掉 ufw 放行,随本次部署自动生效——不再手改线上。
|
||||
# 幂等:仅当精确匹配公网形态 ADDR=:8080 才改;已收口则跳过。备份原文件。
|
||||
if grep -qxF 'ADDR=:8080' /etc/pangolin/server.env; then
|
||||
cp -p /etc/pangolin/server.env "/etc/pangolin/server.env.bak-addr-$TAG"
|
||||
sed -i 's/^ADDR=:8080\$/ADDR=127.0.0.1:8080/' /etc/pangolin/server.env
|
||||
echo "==> deploy-server: ADDR 收口 :8080 -> 127.0.0.1:8080"
|
||||
fi
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw delete allow 8080/tcp >/dev/null 2>&1 || true
|
||||
fi
|
||||
systemctl start pangolin-server
|
||||
systemctl is-active pangolin-server
|
||||
REMOTE
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
// gen-signing.mjs — 从 scripts/signing.env(单一真相源)回填所有原生文件里的
|
||||
// Apple 签名标识符(Team ID / Bundle ID / App Group / 签名主体)。
|
||||
//
|
||||
// 为什么用 codegen 而非硬编码:Apple 工具链要求这些值以字面量出现在 pbxproj /
|
||||
// entitlements / plist / swift 里,无法像 shell 那样 source 一个变量;又分散在
|
||||
// 十几处,迁账号极易漏。本脚本让 signing.env 成为唯一手改点,一键回填全部字面量。
|
||||
//
|
||||
// 关键设计:所有匹配都是**结构化正则**(匹配 `DEVELOPMENT_TEAM = X`、native app
|
||||
// group 形状等),**不含任何旧的具体值**——所以①本脚本自身无「中国账号痕迹」;
|
||||
// ②未来再迁账号(改 signing.env 里的值)仍能正确回填,不依赖旧字面量。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/gen-signing.mjs 回填(就地改文件)
|
||||
// node scripts/gen-signing.mjs --check drift 闸:若有文件会被改动则退出 1(不改文件)
|
||||
//
|
||||
// 注:源码注释里遗留的旧标识符是内部字符串、不进产物;本脚本只管功能性字面量
|
||||
// (会被编译/签名进产物的),注释去痕由一次性清理处理。
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const CLIENT = join(ROOT, 'client');
|
||||
|
||||
// ── 读 signing.env 的 3 个基值(去引号),派生其余 ───────────────────────────
|
||||
function readEnv() {
|
||||
const txt = readFileSync(join(HERE, 'signing.env'), 'utf8');
|
||||
const pick = (k) => {
|
||||
const m = txt.match(new RegExp('^' + k + '="?([^"\\n]+)"?\\s*$', 'm'));
|
||||
if (!m) throw new Error(`signing.env 缺 ${k}`);
|
||||
return m[1].trim();
|
||||
};
|
||||
const TEAM = pick('SIGNING_TEAM_ID');
|
||||
const ENTITY = pick('SIGNING_ENTITY');
|
||||
const APP = pick('SIGNING_APP_BUNDLE_ID');
|
||||
return {
|
||||
TEAM, ENTITY, APP,
|
||||
EXT: `${APP}.PacketTunnel`,
|
||||
MAC_GROUP: `${TEAM}.${APP}`,
|
||||
IOS_GROUP: `group.${APP}`,
|
||||
};
|
||||
}
|
||||
|
||||
const V = readEnv();
|
||||
|
||||
// ── 每文件的结构化替换表(路径相对 client/)──────────────────────────────────
|
||||
// 每条 [正则, 替换串]。正则只描述「形状」,替换用派生值填入。
|
||||
const TABLE = {
|
||||
// ---- 通用:两个 pbxproj 的 Team + Bundle ID(按后缀区分 app/ext/tests)----
|
||||
'macos/Runner.xcodeproj/project.pbxproj': [
|
||||
[/DEVELOPMENT_TEAM = [A-Za-z0-9]+;/g, `DEVELOPMENT_TEAM = ${V.TEAM};`],
|
||||
[/PRODUCT_BUNDLE_IDENTIFIER = [\w.]+\.PacketTunnel;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${V.EXT};`],
|
||||
[/PRODUCT_NAME = [\w.]+\.PacketTunnel;/g, `PRODUCT_NAME = ${V.EXT};`],
|
||||
[/PRODUCT_BUNDLE_IDENTIFIER = [\w.]+\.RunnerTests;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${V.APP}.RunnerTests;`],
|
||||
],
|
||||
'ios/Runner.xcodeproj/project.pbxproj': [
|
||||
[/DEVELOPMENT_TEAM = [A-Za-z0-9]+;/g, `DEVELOPMENT_TEAM = ${V.TEAM};`],
|
||||
[/PRODUCT_BUNDLE_IDENTIFIER = [\w.]+\.PacketTunnel;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${V.EXT};`],
|
||||
// 主 app:不以 .PacketTunnel 结尾的那条(负向前瞻,避免误改扩展行)。
|
||||
[/PRODUCT_BUNDLE_IDENTIFIER = (?![\w.]*\.PacketTunnel;)[\w.]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${V.APP};`],
|
||||
],
|
||||
// ---- macOS xcconfig(主 app Bundle ID + 版权)----
|
||||
'macos/Runner/Configs/AppInfo.xcconfig': [
|
||||
[/^PRODUCT_BUNDLE_IDENTIFIER = .+$/m, `PRODUCT_BUNDLE_IDENTIFIER = ${V.APP}`],
|
||||
[/^PRODUCT_COPYRIGHT = .+$/m, `PRODUCT_COPYRIGHT = Copyright © 2026 ${V.ENTITY}. All rights reserved.`],
|
||||
],
|
||||
// ---- macOS entitlements:app-identifier 前缀 + native app group ----
|
||||
'macos/Runner/Release.entitlements': [
|
||||
[/\$\(AppIdentifierPrefix\)[\w.]+/g, `$(AppIdentifierPrefix)${V.APP}`],
|
||||
[/<string>[A-Z0-9]{10}\.[\w.]+<\/string>/g, `<string>${V.MAC_GROUP}</string>`],
|
||||
],
|
||||
'macos/Runner/DebugProfile.entitlements': [
|
||||
[/\$\(AppIdentifierPrefix\)[\w.]+/g, `$(AppIdentifierPrefix)${V.APP}`],
|
||||
[/<string>group\.[\w.]+<\/string>/g, `<string>${V.IOS_GROUP}</string>`],
|
||||
],
|
||||
'macos/PacketTunnel/PacketTunnel.entitlements': [
|
||||
[/<string>[A-Z0-9]{10}\.[\w.]+<\/string>/g, `<string>${V.MAC_GROUP}</string>`],
|
||||
],
|
||||
// ---- macOS PacketTunnel Info.plist:NEMachServiceName = app group + .PacketTunnel ----
|
||||
'macos/PacketTunnel/Info.plist': [
|
||||
[/<string>[A-Z0-9]{10}\.[\w.]+\.PacketTunnel<\/string>/g, `<string>${V.MAC_GROUP}.PacketTunnel</string>`],
|
||||
],
|
||||
// ---- macOS Swift / 登录助手脚本 ----
|
||||
'macos/PacketTunnel/PacketTunnelProvider.swift': [
|
||||
[/subsystem: "[\w.]+"/g, `subsystem: "${V.EXT}"`],
|
||||
[/appGroup = "[\w.]+"/g, `appGroup = "${V.MAC_GROUP}"`],
|
||||
],
|
||||
'macos/Runner/VpnChannel.swift': [
|
||||
[/subsystem: "[\w.]+"/g, `subsystem: "${V.APP}"`],
|
||||
[/tunnelBundleId = "[\w.]+"/g, `tunnelBundleId = "${V.EXT}"`],
|
||||
],
|
||||
'macos/Runner/MainFlutterWindow.swift': [
|
||||
[/loginHelperID = "[\w.]+"/g, `loginHelperID = "${V.APP}.LoginHelper"`],
|
||||
],
|
||||
'macos/build_login_helper.sh': [
|
||||
[/HELPER_ID="[\w.]+"/g, `HELPER_ID="${V.APP}.LoginHelper"`],
|
||||
],
|
||||
// ---- iOS entitlements(group. 前缀 app group)----
|
||||
'ios/Runner/Runner.entitlements': [
|
||||
[/<string>group\.[\w.]+<\/string>/g, `<string>${V.IOS_GROUP}</string>`],
|
||||
],
|
||||
'ios/PacketTunnel/PacketTunnel.entitlements': [
|
||||
[/<string>group\.[\w.]+<\/string>/g, `<string>${V.IOS_GROUP}</string>`],
|
||||
],
|
||||
// ---- iOS Swift ----
|
||||
'ios/Runner/VpnManager.swift': [
|
||||
[/extensionBundleId = "[\w.]+"/g, `extensionBundleId = "${V.EXT}"`],
|
||||
[/appGroup = "[\w.]+"/g, `appGroup = "${V.IOS_GROUP}"`],
|
||||
],
|
||||
'ios/PacketTunnel/PacketTunnelProvider.swift': [
|
||||
[/subsystem: "[\w.]+"/g, `subsystem: "${V.EXT}"`],
|
||||
[/appGroup = "[\w.]+"/g, `appGroup = "${V.IOS_GROUP}"`],
|
||||
],
|
||||
'ios/PacketTunnel/MemoryMonitor.swift': [
|
||||
[/subsystem: String = "[\w.]+"/g, `subsystem: String = "${V.EXT}"`],
|
||||
],
|
||||
};
|
||||
|
||||
// ── 执行 ─────────────────────────────────────────────────────────────────────
|
||||
const check = process.argv.includes('--check');
|
||||
let changed = 0;
|
||||
const changedFiles = [];
|
||||
for (const [rel, rules] of Object.entries(TABLE)) {
|
||||
const path = join(CLIENT, rel);
|
||||
const before = readFileSync(path, 'utf8');
|
||||
let after = before;
|
||||
for (const [re, repl] of rules) after = after.replace(re, repl);
|
||||
if (after !== before) {
|
||||
changed++;
|
||||
changedFiles.push(rel);
|
||||
if (!check) writeFileSync(path, after);
|
||||
}
|
||||
}
|
||||
|
||||
if (check) {
|
||||
if (changed) {
|
||||
console.error(`❌ signing codegen drift:${changed} 个文件与 signing.env 不一致:`);
|
||||
for (const f of changedFiles) console.error(' - ' + f);
|
||||
console.error(' 跑 `node scripts/gen-signing.mjs` 回填后重试。');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ signing codegen 无 drift(原生文件与 signing.env 一致)');
|
||||
} else {
|
||||
console.log(`signing codegen 完成:回填 ${changed} 个文件` + (changed ? ':' : '(已一致,无改动)'));
|
||||
for (const f of changedFiles) console.log(' - ' + f);
|
||||
console.log(` TEAM=${V.TEAM} APP=${V.APP} MAC_GROUP=${V.MAC_GROUP} IOS_GROUP=${V.IOS_GROUP}`);
|
||||
}
|
||||
+19
-14
@@ -23,11 +23,16 @@
|
||||
# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ...
|
||||
set -euo pipefail
|
||||
|
||||
# 签名身份单一真相源(Team ID / 实体名 / Bundle ID / App Group)——迁移账号只改 signing.env。
|
||||
_SIGN_ENV_DIR="${BASH_SOURCE[0]%/*}"; [ "$_SIGN_ENV_DIR" = "${BASH_SOURCE[0]}" ] && _SIGN_ENV_DIR="."
|
||||
. "${_SIGN_ENV_DIR}/signing.env"
|
||||
|
||||
# ─────────── 配置(按需改)───────────
|
||||
# 联调控制面。默认指向联调节点;要装生产地址的包用 env 覆盖:
|
||||
# API_URL=https://api.yanmeiai.com scripts/local_test.sh ipad
|
||||
API_URL="${API_URL:-http://103.119.13.48:8080}"
|
||||
SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
|
||||
# 控制面基址。默认生产(Cloudflare Tunnel);8080 已收口只绑 127.0.0.1,公网直连
|
||||
# 不再可用——联调自建控制面时用 env 显式覆盖:
|
||||
# API_URL=http://<联调地址>:8080 scripts/local_test.sh macos
|
||||
API_URL="${API_URL:-https://api.yanmeiai.com}"
|
||||
SIGN_ID="$SIGNING_DEVELOPER_ID" # 派生自 signing.env
|
||||
APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
|
||||
SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
|
||||
TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
|
||||
@@ -39,15 +44,15 @@ IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP
|
||||
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
|
||||
# app/sysext entitlements 与之对齐(见 write_entitlements)。
|
||||
|
||||
TEAM_ID="BYL4KQHMTN" # Yanmei (beijing) Technology Co., Ltd
|
||||
TEAM_ID="$SIGNING_TEAM_ID" # 派生自 signing.env
|
||||
# macOS App Group —— 必须是原生格式 <TeamID>.<name>,不是 iOS 的 group. 前缀(CLAUDE.md 铁律)。
|
||||
# 主 app 与 sysext 必须落在**同一个**;sysext 的 NEMachServiceName 必须以它为前缀。
|
||||
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
|
||||
APP_GROUP="$SIGNING_MACOS_APP_GROUP"
|
||||
|
||||
# ── iOS/iPad 真机分发(ad-hoc,**公司**证书)────────────────────────────────
|
||||
IOS_BUNDLE_ID="com.pangolin.pangolinVpn"
|
||||
IOS_BUNDLE_ID="$SIGNING_APP_BUNDLE_ID"
|
||||
# 期望的签名主体。装机前逐字核对,不符即中止 —— 防止悄悄退回个人开发证书。
|
||||
IOS_EXPECT_SIGNER="Apple Distribution: Yanmei (beijing) Technology Co., Ltd (${TEAM_ID})"
|
||||
IOS_EXPECT_SIGNER="$SIGNING_APPLE_DISTRIBUTION"
|
||||
|
||||
# ─────────── 路径推导 ───────────
|
||||
SRC="${BASH_SOURCE[0]}"
|
||||
@@ -56,9 +61,9 @@ cd "$DIR/.."; REPO_ROOT="$PWD"
|
||||
CLIENT="$REPO_ROOT/client"
|
||||
PBXPROJ="$CLIENT/macos/Runner.xcodeproj/project.pbxproj"
|
||||
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
|
||||
# sysext bundle 名 = 标识符(PRODUCT_NAME=com.pangolin.pangolin.PacketTunnel,见 CLAUDE.md),
|
||||
# sysext bundle 名 = 标识符(PRODUCT_NAME=com.yanmeiai.pangolin.PacketTunnel,见 CLAUDE.md),
|
||||
# 不是短名 PacketTunnel.systemextension。
|
||||
SE="$APP/Contents/Library/SystemExtensions/com.pangolin.pangolin.PacketTunnel.systemextension"
|
||||
SE="$APP/Contents/Library/SystemExtensions/${SIGNING_EXT_BUNDLE_ID}.systemextension"
|
||||
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
|
||||
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
|
||||
@@ -89,7 +94,7 @@ write_entitlements(){
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.application-identifier</key><string>${TEAM_ID}.com.pangolin.pangolin</string>
|
||||
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${SIGNING_APP_BUNDLE_ID}</string>
|
||||
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
|
||||
<key>com.apple.developer.system-extension.install</key><true/>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
@@ -100,14 +105,14 @@ write_entitlements(){
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array><string>${APP_GROUP}</string></array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array><string>${TEAM_ID}.com.pangolin.pangolin</string></array>
|
||||
<array><string>${TEAM_ID}.${SIGNING_APP_BUNDLE_ID}</string></array>
|
||||
</dict></plist>
|
||||
PLIST
|
||||
cat > "$WORK/sysext.entitlements" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.application-identifier</key><string>${TEAM_ID}.com.pangolin.pangolin.PacketTunnel</string>
|
||||
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${SIGNING_EXT_BUNDLE_ID}</string>
|
||||
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
|
||||
<key>com.apple.developer.networking.networkextension</key>
|
||||
<array><string>packet-tunnel-provider-systemextension</string></array>
|
||||
@@ -316,7 +321,7 @@ cmd_ks_status(){
|
||||
|
||||
# ─────────── iOS / iPad 真机安装 ───────────
|
||||
# 与 macOS 流程独立:iOS 用 Network Extension(非 System Extension),不需要 Developer ID
|
||||
# 重签 / 公证;flutter build ios --release 由 Xcode 工程配置签名(Team=BYL4KQHMTN,自动
|
||||
# 重签 / 公证;flutter build ios --release 由 Xcode 工程配置签名(Team=44WULXM6SV,自动
|
||||
# 签名会把已连接设备注册进描述文件),再 flutter install 走 USB 直接装。
|
||||
|
||||
# 列出已连接的物理 iOS 设备(输出 "id<TAB>name" 每行一台;模拟器/无线设备排除)。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# signing.env — Apple 签名身份的【单一真相源】(shell 侧)
|
||||
#
|
||||
# 【迁移账号 / 改 Bundle ID 时,改这一个文件就够】,然后跑一次:
|
||||
# node scripts/gen-signing.mjs
|
||||
# 它会把这里的值回填进所有原生文件(pbxproj/entitlements/plist/swift)的字面量
|
||||
# ——Apple 工具链要求这些值以字面量出现、无法 source 变量,故用 codegen 保持单源。
|
||||
#
|
||||
# 消费方:
|
||||
# - 三个签名脚本(local_test.sh / ci/compile-macos.sh / ci/compile-ios.sh)直接 source 本文件;
|
||||
# - 原生文件(Xcode/Swift)由 scripts/gen-signing.mjs 从本文件回填(勿手改那些标识符)。
|
||||
# drift 闸:ci/check-codegen-drift.sh 跑 `gen-signing.mjs --check`,防改了 env 没重生成 /
|
||||
# 手改了原生标识符。非机密(Team ID / 实体名 / Bundle ID 本就编进产物、公开),入 git。
|
||||
|
||||
# 用哪个 Apple 账号的 CI 密钥集(gitea 里 SIGN_<ACCT>_* 的 <ACCT>)。us|cn。
|
||||
# 这是 CI 多账号切换的唯一开关:改这行 = 换 CI 用的证书/ASC Key(见
|
||||
# docs/ci-multi-account-signing-design.html)。同时下面的 Team/Bundle 等身份也要
|
||||
# 一并改成该账号的并跑 gen-signing.mjs——账号密钥与身份必须匹配。
|
||||
SIGNING_ACCOUNT="us"
|
||||
|
||||
# 10 位 Team ID(developer.apple.com → Membership details)。
|
||||
SIGNING_TEAM_ID="44WULXM6SV"
|
||||
|
||||
# 法人实体名(= 证书主体 CN 内含;Developer ID / Apple Distribution 证书都用它)。
|
||||
SIGNING_ENTITY="Yanmei AI LLC"
|
||||
|
||||
# 主 app Bundle ID(macOS 与 iOS 现共用同串)。
|
||||
SIGNING_APP_BUNDLE_ID="com.yanmeiai.pangolin"
|
||||
|
||||
# PacketTunnel 扩展 Bundle ID(主 app + .PacketTunnel)。
|
||||
SIGNING_EXT_BUNDLE_ID="${SIGNING_APP_BUNDLE_ID}.PacketTunnel"
|
||||
|
||||
# App Group:macOS 用原生 <TeamID>.<bundle> 格式;iOS 用 group.<bundle> 前缀。
|
||||
SIGNING_MACOS_APP_GROUP="${SIGNING_TEAM_ID}.${SIGNING_APP_BUNDLE_ID}"
|
||||
SIGNING_IOS_APP_GROUP="group.${SIGNING_APP_BUNDLE_ID}"
|
||||
|
||||
# 派生的证书签名主体(codesign -s / 签名核验用)。
|
||||
SIGNING_DEVELOPER_ID="Developer ID Application: ${SIGNING_ENTITY} (${SIGNING_TEAM_ID})"
|
||||
SIGNING_APPLE_DISTRIBUTION="Apple Distribution: ${SIGNING_ENTITY} (${SIGNING_TEAM_ID})"
|
||||
@@ -48,14 +48,16 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
listenAddr := flag.String("addr", "", "HTTP listen address (default :8080, overridden by ADDR env)")
|
||||
listenAddr := flag.String("addr", "", "HTTP listen address (default 127.0.0.1:8080, overridden by ADDR env)")
|
||||
flag.Parse()
|
||||
|
||||
if *listenAddr == "" {
|
||||
if v := os.Getenv("ADDR"); v != "" {
|
||||
*listenAddr = v
|
||||
} else {
|
||||
*listenAddr = ":8080"
|
||||
// 默认只绑 loopback:控制面对外一律经 cloudflared 隧道(见 CLAUDE.md),
|
||||
// 源站不该裸监听公网。要跨主机监听须显式设 ADDR=:8080(明知代价)。
|
||||
*listenAddr = "127.0.0.1:8080"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +379,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
paySystem, paySecret, 5*time.Minute, 15*time.Minute)
|
||||
payWebhook.SetRewarder(rewardSvc)
|
||||
payWebhook.SetNoticer(noticesStore)
|
||||
// reconcile-on-read:查单发现 pay 网关已 paid 但本地未开通时,复用 webhook 的
|
||||
// 幂等 settle 就地开通,不干等 webhook 送达(webhook 仍作兜底)。
|
||||
payHandler.SetSettler(payWebhook)
|
||||
} else {
|
||||
log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载")
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
|
||||
const (
|
||||
// paidCredentialTTL is the default connect credential lifetime for paid users.
|
||||
paidCredentialTTL = 24 * time.Hour
|
||||
// Single source in the nodes package: ReportUsage 的「活跃即续期」用同一常量续期,
|
||||
// 两处 24h 不会漂移。
|
||||
paidCredentialTTL = nodes.PaidCredentialTTL
|
||||
// freeCredentialTTL is the per-minute TTL for free users (per remaining minutes).
|
||||
freeMinuteTTL = time.Minute
|
||||
// deviceStaleWindow: connect 设备上限 backstop 只数近此窗口活跃的设备(与 devices 侧一致)。
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package nodes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// RenewCredential is the DB half of "renew-on-activity" (the fix for a paid
|
||||
// session silently dropping ~1 day in — the connect credential's 24h TTL with
|
||||
// nothing refreshing it while a persistent macOS sysext tunnel outlives the GUI
|
||||
// app). Pure-Go modernc sqlite → runs in the default no-docker path alongside
|
||||
// entitlement_override_sqlite_test.go. Exercises the actual UPDATE SQL (column
|
||||
// names, the expires_at > now guard) the handler mock can't cover.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func openRenewTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedRenewNode inserts a provider + node (provider_id is a FK) and returns the
|
||||
// node id, on which credentials can be hung.
|
||||
func seedRenewNode(t *testing.T, db *sql.DB) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
res, err := db.ExecContext(ctx,
|
||||
`INSERT INTO providers (name, api_kind, regions, pool)
|
||||
VALUES ('renew-test', 'vultr', '["HK"]', 'consumable')`)
|
||||
if err != nil {
|
||||
t.Fatalf("seed provider: %v", err)
|
||||
}
|
||||
providerID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("provider id: %v", err)
|
||||
}
|
||||
res, err = db.ExecContext(ctx, `
|
||||
INSERT INTO nodes
|
||||
(uuid, region, name_zh, name_en, tier, endpoint, reality_pbk, reality_sni, provider_id, status)
|
||||
VALUES ('renew-node', 'HK', '香港', 'HK', 'pro', '1.2.3.4:443', 'pbk', 'sni.example.com', ?, 'up')
|
||||
`, providerID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
nodeID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("node id: %v", err)
|
||||
}
|
||||
return nodeID
|
||||
}
|
||||
|
||||
func insertRenewCred(t *testing.T, db *sql.DB, nodeID int64, dpUUID string, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
if _, err := db.ExecContext(context.Background(),
|
||||
`INSERT INTO connect_credentials (node_id, dp_uuid, protocol, flow, expires_at)
|
||||
VALUES (?, ?, 3, 'xtls-rprx-vision', ?)`,
|
||||
nodeID, dpUUID, expiresAt.UTC()); err != nil {
|
||||
t.Fatalf("insert credential %s: %v", dpUUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readRenewExpiry(t *testing.T, db *sql.DB, dpUUID string) time.Time {
|
||||
t.Helper()
|
||||
var exp time.Time
|
||||
if err := db.QueryRowContext(context.Background(),
|
||||
`SELECT expires_at FROM connect_credentials WHERE dp_uuid = ?`, dpUUID).Scan(&exp); err != nil {
|
||||
t.Fatalf("read expiry %s: %v", dpUUID, err)
|
||||
}
|
||||
return exp.UTC()
|
||||
}
|
||||
|
||||
// TestRenewCredential_BumpsActive: a still-active credential's expiry is pushed
|
||||
// forward to the requested time.
|
||||
func TestRenewCredential_BumpsActive(t *testing.T) {
|
||||
db := openRenewTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
nodeID := seedRenewNode(t, db)
|
||||
|
||||
now := time.Now().UTC()
|
||||
insertRenewCred(t, db, nodeID, "dp-active", now.Add(1*time.Hour)) // still active
|
||||
|
||||
newExp := now.Add(nodes.PaidCredentialTTL)
|
||||
if err := st.RenewCredential(context.Background(), "dp-active", newExp); err != nil {
|
||||
t.Fatalf("RenewCredential: %v", err)
|
||||
}
|
||||
|
||||
got := readRenewExpiry(t, db, "dp-active")
|
||||
if got.Sub(newExp).Abs() > time.Second {
|
||||
t.Errorf("expiry = %v, want ~%v (renewed)", got, newExp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenewCredential_LeavesExpired: an already-expired credential is NOT
|
||||
// resurrected (the expires_at > now guard) — a disconnected session must go
|
||||
// through a fresh /connect, not get silently revived by a late usage report.
|
||||
func TestRenewCredential_LeavesExpired(t *testing.T) {
|
||||
db := openRenewTestDB(t)
|
||||
st := nodes.NewSQLNodeStore(db)
|
||||
nodeID := seedRenewNode(t, db)
|
||||
|
||||
now := time.Now().UTC()
|
||||
expiredAt := now.Add(-1 * time.Hour)
|
||||
insertRenewCred(t, db, nodeID, "dp-expired", expiredAt) // already expired
|
||||
|
||||
if err := st.RenewCredential(context.Background(), "dp-expired", now.Add(nodes.PaidCredentialTTL)); err != nil {
|
||||
t.Fatalf("RenewCredential: %v", err)
|
||||
}
|
||||
|
||||
got := readRenewExpiry(t, db, "dp-expired")
|
||||
if got.After(now) {
|
||||
t.Errorf("expiry = %v, want unchanged (still expired, before %v)", got, now)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,15 @@ type mockNodeStore struct {
|
||||
devicesByDpUUID map[string][2]int64
|
||||
deviceUsageAccum []mockDeviceUsageEntry
|
||||
lastSeenTouched []int64
|
||||
// ent is returned by EntitlementForUser (nil = no subscription = free plan).
|
||||
ent *nodes.Entitlement
|
||||
// renewed logs RenewCredential calls (renew-on-activity assertions).
|
||||
renewed []mockRenewEntry
|
||||
}
|
||||
|
||||
type mockRenewEntry struct {
|
||||
DpUUID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type mockUsageEntry struct {
|
||||
@@ -99,13 +108,28 @@ func (m *mockNodeStore) ListUp(_ context.Context) ([]*nodes.NodeRow, error) {
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) EntitlementForUser(_ context.Context, _ int64) (*nodes.Entitlement, error) {
|
||||
return nil, nil
|
||||
return m.ent, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) RenewCredential(_ context.Context, dpUUID string, newExpiresAt time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.renewed = append(m.renewed, mockRenewEntry{dpUUID, newExpiresAt})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) renewLog() []mockRenewEntry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]mockRenewEntry, len(m.renewed))
|
||||
copy(out, m.renewed)
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1050,3 +1074,81 @@ func TestReportUsage_PerDevice(t *testing.T) {
|
||||
t.Errorf("device usage wrong: %+v", dev[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportUsage_RenewsPaidCredential verifies renew-on-activity: a PAID user's
|
||||
// usage report bumps its data-plane credential expiry ~PaidCredentialTTL into the
|
||||
// future — so a persistent session never hits the TTL wall mid-connection (the
|
||||
// root cause of "connected ~1 day then silently drops" on always-on macOS).
|
||||
func TestReportUsage_RenewsPaidCredential(t *testing.T) {
|
||||
const nodeUUID = "test-node-renew-paid"
|
||||
b := newTestServer(t, 1, nodeUUID)
|
||||
ctx := context.Background()
|
||||
b.store.devicesByDpUUID = map[string][2]int64{"dp-paid": {101, 55}}
|
||||
b.store.ent = &nodes.Entitlement{AdGate: false} // paid plan
|
||||
|
||||
_, _, conn := enrollNode(t, b, nodeUUID)
|
||||
client := agentv1.NewAgentServiceClient(conn)
|
||||
if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
before := time.Now().UTC()
|
||||
now := time.Now()
|
||||
if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{
|
||||
NodeUUID: nodeUUID,
|
||||
WindowStartUnix: now.Add(-time.Minute).Unix(),
|
||||
WindowEndUnix: now.Unix(),
|
||||
Entries: []*agentv1.UsageEntry{
|
||||
{DpUUID: "dp-paid", BytesUp: 100, BytesDown: 200, SessionMinutes: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportUsage: %v", err)
|
||||
}
|
||||
|
||||
renewed := b.store.renewLog()
|
||||
if len(renewed) != 1 {
|
||||
t.Fatalf("renew calls = %d, want 1 (paid session should renew)", len(renewed))
|
||||
}
|
||||
if renewed[0].DpUUID != "dp-paid" {
|
||||
t.Errorf("renewed dp_uuid = %q, want dp-paid", renewed[0].DpUUID)
|
||||
}
|
||||
// Expiry should land ~PaidCredentialTTL from now (allow the test's own runtime slack).
|
||||
wantMin := before.Add(nodes.PaidCredentialTTL)
|
||||
wantMax := time.Now().UTC().Add(nodes.PaidCredentialTTL + time.Minute)
|
||||
if renewed[0].ExpiresAt.Before(wantMin) || renewed[0].ExpiresAt.After(wantMax) {
|
||||
t.Errorf("renewed expiry %v out of [%v, %v]", renewed[0].ExpiresAt, wantMin, wantMax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportUsage_DoesNotRenewFreeCredential verifies free credentials are NEVER
|
||||
// renewed: their TTL encodes the daily-minute quota, and renewing would bypass the
|
||||
// data-plane hard cut-off that enforces it.
|
||||
func TestReportUsage_DoesNotRenewFreeCredential(t *testing.T) {
|
||||
const nodeUUID = "test-node-renew-free"
|
||||
b := newTestServer(t, 1, nodeUUID)
|
||||
ctx := context.Background()
|
||||
b.store.devicesByDpUUID = map[string][2]int64{"dp-free": {202, 66}}
|
||||
b.store.ent = &nodes.Entitlement{AdGate: true} // free plan (minute-quota-gated)
|
||||
|
||||
_, _, conn := enrollNode(t, b, nodeUUID)
|
||||
client := agentv1.NewAgentServiceClient(conn)
|
||||
if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{
|
||||
NodeUUID: nodeUUID,
|
||||
WindowStartUnix: now.Add(-time.Minute).Unix(),
|
||||
WindowEndUnix: now.Unix(),
|
||||
Entries: []*agentv1.UsageEntry{
|
||||
{DpUUID: "dp-free", BytesUp: 100, BytesDown: 200, SessionMinutes: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("ReportUsage: %v", err)
|
||||
}
|
||||
|
||||
if renewed := b.store.renewLog(); len(renewed) != 0 {
|
||||
t.Fatalf("renew calls = %d, want 0 (free credential must NOT be renewed)", len(renewed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,25 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
||||
type acctAgg struct{ bytesUp, bytesDown, minutes int64 }
|
||||
byUser := make(map[int64]*acctAgg)
|
||||
|
||||
// paidUser caches each user's paid/free status for this report so the
|
||||
// renew-on-activity path below does at most one entitlement lookup per user.
|
||||
paidUser := make(map[int64]bool)
|
||||
isPaid := func(userID int64) bool {
|
||||
if v, ok := paidUser[userID]; ok {
|
||||
return v
|
||||
}
|
||||
ent, err := h.store.EntitlementForUser(ctx, userID)
|
||||
if err != nil {
|
||||
slog.Warn("nodes.Handler.ReportUsage: entitlement lookup failed",
|
||||
"user_id", userID, "err", err)
|
||||
}
|
||||
paid := ent != nil && !ent.AdGate // AdGate = free plan (minute-quota-gated)
|
||||
paidUser[userID] = paid
|
||||
return paid
|
||||
}
|
||||
|
||||
renewedAt := time.Now().UTC().Add(PaidCredentialTTL)
|
||||
|
||||
for _, entry := range req.Entries {
|
||||
if entry.DpUUID == "" {
|
||||
continue
|
||||
@@ -310,6 +329,21 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
// Renew-on-activity: a live PAID session keeps sending usage reports, so
|
||||
// bump its data-plane credential expiry forward each window — it never hits
|
||||
// the PaidCredentialTTL wall mid-session. This is the fix for "connected ~1
|
||||
// day then silently drops": the client only re-issues a credential on an
|
||||
// in-foreground reconnect, which a persistent macOS sysext tunnel (GUI app
|
||||
// closed → no watchdog) never triggers. Server-side renewal is client-
|
||||
// lifecycle-independent, so it fixes all platforms at once. Free credentials
|
||||
// encode the daily-minute quota in their TTL — never renew them (would
|
||||
// bypass the hard cut-off), so this is gated to paid plans.
|
||||
if isPaid(userID) {
|
||||
if err := h.store.RenewCredential(ctx, entry.DpUUID, renewedAt); err != nil {
|
||||
slog.Warn("nodes.Handler.ReportUsage: renew credential failed",
|
||||
"dp_uuid", entry.DpUUID, "err", err)
|
||||
}
|
||||
}
|
||||
// Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重).
|
||||
a := byUser[userID]
|
||||
if a == nil {
|
||||
|
||||
@@ -16,6 +16,13 @@ import (
|
||||
// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。
|
||||
var ErrDeviceNotFound = errors.New("device not registered")
|
||||
|
||||
// PaidCredentialTTL is the付费连接凭证有效期(单一真相源)。httpapi 签发凭证与
|
||||
// ReportUsage 的「活跃即续期」都引用它,避免两处 24h 漂移。到期后 CredentialsForNode
|
||||
// 的 WHERE expires_at > now 会把它挡在 agent 快照外 → 下次 agent 重注册重渲染即踢下线。
|
||||
// 续期机制(见 RenewCredential)让活跃付费会话永不撞这道墙;免费凭证 TTL 编码日额度、
|
||||
// 不走此常量、也不续期。
|
||||
const PaidCredentialTTL = 24 * time.Hour
|
||||
|
||||
// NodeRow holds a node's essential fields from the nodes table.
|
||||
type NodeRow struct {
|
||||
ID int64
|
||||
@@ -72,6 +79,13 @@ type NodeStore interface {
|
||||
// PersistCredential upserts a credential row in connect_credentials.
|
||||
PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error
|
||||
|
||||
// RenewCredential 把某 dp_uuid 仍活跃(未过期)凭证的 expires_at 顶到 newExpiresAt
|
||||
// ——「活跃即续期」用:付费会话持续上报用量时把有效期不断往后推,让常驻隧道(尤其
|
||||
// macOS sysext,GUI app 关闭后客户端看门狗不运行、不会重连重签)不再在 24h 撞过期墙。
|
||||
// 只更新 expires_at > now 的行:绝不复活已过期凭证(那属于已断开会话,须重新连接)。
|
||||
// 调用方须只对付费凭证调用;免费凭证 TTL 编码日额度,续期会击穿日限。
|
||||
RenewCredential(ctx context.Context, dpUUID string, newExpiresAt time.Time) error
|
||||
|
||||
// DeleteCredential removes the credential for (nodeID, dpUUID).
|
||||
DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error
|
||||
|
||||
@@ -340,6 +354,18 @@ func (s *SQLNodeStore) PersistCredential(ctx context.Context, nodeID int64, cred
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewCredential extends the expiry of dpUUID's still-active credential(s) to
|
||||
// newExpiresAt. The `expires_at > ?` guard means an already-expired row is left
|
||||
// untouched (never resurrect a disconnected session). Portable SQL: bind
|
||||
// newExpiresAt then now, both UTC.
|
||||
func (s *SQLNodeStore) RenewCredential(ctx context.Context, dpUUID string, newExpiresAt time.Time) error {
|
||||
const q = `UPDATE connect_credentials SET expires_at = ? WHERE dp_uuid = ? AND expires_at > ?`
|
||||
if _, err := s.db.ExecContext(ctx, q, newExpiresAt.UTC(), dpUUID, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("nodes.SQLNodeStore.RenewCredential: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CredentialLocation identifies a node holding a given dp_uuid credential.
|
||||
type CredentialLocation struct {
|
||||
NodeID int64
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -15,18 +16,34 @@ import (
|
||||
"github.com/wangjia/pangolin/server/internal/auth"
|
||||
)
|
||||
|
||||
// orderSettler 抽象 webhook 的幂等开通入口(settle),供查单对账 reconcile-on-read
|
||||
// 复用。*WebhookHandler 已满足。放接口而非直接持有 *WebhookHandler,便于测试注入。
|
||||
type orderSettler interface {
|
||||
settle(ctx context.Context, ev *webhookEvent) error
|
||||
}
|
||||
|
||||
// payOrderPaid 是 pay 网关订单「已支付」状态词(= pay 侧 model.OrderStatusV2 "paid",
|
||||
// 经 client.GetOrder 原样透传)。查单见此即视为款已到、可开通。改这里前先对齐 pay 契约。
|
||||
const payOrderPaid = "paid"
|
||||
|
||||
// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧,
|
||||
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
|
||||
type Handler struct {
|
||||
client *Client
|
||||
store *Store
|
||||
db *sql.DB
|
||||
client *Client
|
||||
store *Store
|
||||
db *sql.DB
|
||||
settler orderSettler // 可选:reconcile-on-read 开通入口(见 SetSettler),nil 则退回等 webhook
|
||||
}
|
||||
|
||||
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
|
||||
return &Handler{client: client, store: store, db: db}
|
||||
}
|
||||
|
||||
// SetSettler 挂载对账开通入口(reconcile-on-read):装配后 GetOrder 在发现 pay 网关
|
||||
// 已 paid 但本地台账未开通时,就地幂等 settle 立即开通,不干等 webhook 送达
|
||||
// (webhook 仍作兜底)。为 nil 时退回旧行为(只认本地 row.Status)。
|
||||
func (h *Handler) SetSettler(s orderSettler) { h.settler = s }
|
||||
|
||||
// allowedMetadataKeys 与 pay gateway.go 白名单一致(is_mobile/render)。
|
||||
var allowedMetadataKeys = map[string]bool{"is_mobile": true, "render": true}
|
||||
|
||||
@@ -252,6 +269,30 @@ func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
|
||||
writePayErr(w, err)
|
||||
return
|
||||
}
|
||||
// reconcile-on-read:pay 网关已 paid 但本地台账还没开通(webhook 未到/漏投)→ 就地
|
||||
// 幂等 settle 立即开通,把「等 webhook 送达」那十几秒砍成「付完下一拍查单即开通」。
|
||||
// settle 已 paid 会短路(不会重复开通/发奖);失败仅记日志、退回旧展示,由下一拍轮询
|
||||
// 或 webhook 兜底。channel 查单不返回 → 回退 row.Method(仅影响渠道显示,不影响开通)。
|
||||
if h.settler != nil && st.Status == payOrderPaid && row.Status != "paid" {
|
||||
paidAt := ""
|
||||
if st.PaidAt != nil {
|
||||
paidAt = st.PaidAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if serr := h.settler.settle(ctx, &webhookEvent{
|
||||
EventType: "payment.succeeded",
|
||||
OutTradeNo: orderNo,
|
||||
BizRef: row.BizRef,
|
||||
ProductBizCode: row.SKU,
|
||||
AmountMinor: st.AmountMinor,
|
||||
Currency: st.Currency,
|
||||
Channel: row.Method,
|
||||
PaidAt: paidAt,
|
||||
}); serr != nil {
|
||||
slog.Warn("pay: reconcile-on-read 开通失败(webhook 仍兜底)", "order_no", orderNo, "err", serr)
|
||||
} else if r2, e2 := h.store.GetForUser(ctx, uid, orderNo); e2 == nil {
|
||||
row = r2 // 反映刚开通:本拍即回 activated=true,免再等一拍
|
||||
}
|
||||
}
|
||||
resp := orderStatusResponse{orderView: orderViewFromRow(row), PayStatus: st.Status, Activated: row.Status == "paid"}
|
||||
if row.SubID.Valid {
|
||||
if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// reconcileRig:假 pay(查单可配)+ sqlite 台账 + 真 WebhookHandler 作 settler。
|
||||
// 验证 GetOrder 的 reconcile-on-read:pay 网关已 paid 时就地幂等开通,不干等 webhook。
|
||||
func reconcileRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store, *sql.DB) {
|
||||
t.Helper()
|
||||
db := openMigratedSQLite(t)
|
||||
seedUser(t, db, 1, "uuid-1")
|
||||
srv := fakePay(t, payFn)
|
||||
st := NewStore(db)
|
||||
h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db)
|
||||
// 真 granter + webhook,settle 会真的开通订阅(与线上同一段逻辑)。
|
||||
codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
wh := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute)
|
||||
h.SetSettler(wh)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/pay/orders/{orderNo}", h.GetOrder)
|
||||
return r, st, db
|
||||
}
|
||||
|
||||
func getOrder(t *testing.T, router *chi.Mux, orderNo string, uid int64) (int, bool) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/"+orderNo, nil), uid))
|
||||
var resp struct {
|
||||
Activated bool `json:"activated"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
return w.Code, resp.Activated
|
||||
}
|
||||
|
||||
// 网关已 paid、本地还 created → 查单即就地开通(activated=true、台账翻 paid、订阅真授予);
|
||||
// 且重复轮询幂等:再查一次不二次开通(仍 1 条订阅)。
|
||||
func TestGetOrder_ReconcileOnRead_ActivatesWhenGatewayPaid(t *testing.T) {
|
||||
router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"paid",
|
||||
"subject":"Pro","amount_minor":2999,"currency":"CNY"}}`))
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
code, activated := getOrder(t, router, "pay001", 1)
|
||||
if code != http.StatusOK || !activated {
|
||||
t.Fatalf("首查应就地开通:code=%d activated=%v", code, activated)
|
||||
}
|
||||
|
||||
row, err := st.GetForUser(ctx, 1, "pay001")
|
||||
if err != nil || row.Status != "paid" {
|
||||
t.Fatalf("本地台账应已翻 paid: row=%+v err=%v", row, err)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应授予 1 条 pay 订阅,得 %d", n)
|
||||
}
|
||||
|
||||
// 幂等:再查一次(轮询会持续查),不得二次开通。
|
||||
if _, activated2 := getOrder(t, router, "pay001", 1); !activated2 {
|
||||
t.Fatal("二次查询仍应 activated")
|
||||
}
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1 AND source='pay'`).Scan(&n)
|
||||
if n != 1 {
|
||||
t.Fatalf("重复轮询不得二次开通,订阅数 = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 网关仍 pending → 不开通(activated=false、无订阅);证明只有 paid 才触发对账。
|
||||
func TestGetOrder_ReconcileOnRead_SkipsWhenGatewayPending(t *testing.T) {
|
||||
router, st, db := reconcileRig(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"CNY"}}`))
|
||||
})
|
||||
ctx := context.Background()
|
||||
if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code, activated := getOrder(t, router, "pay001", 1); code != http.StatusOK || activated {
|
||||
t.Fatalf("pending 不应开通:code=%d activated=%v", code, activated)
|
||||
}
|
||||
row, _ := st.GetForUser(ctx, 1, "pay001")
|
||||
if row.Status != "created" {
|
||||
t.Fatalf("pending 时本地台账应仍 created, got %q", row.Status)
|
||||
}
|
||||
var n int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id=1`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("pending 不得开通订阅,得 %d", n)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,17 @@ import { makeT } from '../../lib/i18n';
|
||||
import { getClient } from '../../lib/api/client';
|
||||
import { bilingual } from '../../lib/api/errors';
|
||||
|
||||
/** 站点 basePath(与 next.config.js 一致)。本页用的是 window.location 原生跳转,
|
||||
* Next 不会自动补 basePath,故绝对路径必须手动加前缀,否则会落到站点根(官网主页)
|
||||
* 而不是用户中心 —— 这正是「点用户中心却跳到主页、还要求重新登录」的根因。 */
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user';
|
||||
|
||||
/** 把本站相对路径补上 basePath(供 window.location 原生跳转用)。 */
|
||||
function withBase(path: string): string {
|
||||
const p = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${BASE_PATH}${p}`;
|
||||
}
|
||||
|
||||
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
|
||||
function safeRedirect(raw: string | null): string {
|
||||
if (!raw) return '/';
|
||||
@@ -45,14 +56,15 @@ export default function SsoPage() {
|
||||
const redirect = safeRedirect(params.get('redirect'));
|
||||
|
||||
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
|
||||
// 原生 history/location 不吃 Next 的 basePath,须手动补,否则地址与跳转都会漂到站点根。
|
||||
try {
|
||||
window.history.replaceState(null, '', '/sso/');
|
||||
window.history.replaceState(null, '', withBase('/sso/'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (!ticket) {
|
||||
window.location.replace('/');
|
||||
window.location.replace(withBase('/'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,14 +73,14 @@ export default function SsoPage() {
|
||||
.exchangeWebTicket(ticket)
|
||||
.then(() => {
|
||||
if (!alive) return;
|
||||
window.location.replace(redirect);
|
||||
window.location.replace(withBase(redirect));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setMsg(bilingual(e, lang));
|
||||
setFailed(true);
|
||||
setTimeout(() => {
|
||||
window.location.replace('/');
|
||||
window.location.replace(withBase('/'));
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user