Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3736fa43a | |||
| 6c6d9636bb | |||
| 3f54efa078 | |||
| 8f0c2ef81b | |||
| d094a53d7e | |||
| c152c2aadf | |||
| cf3b806311 | |||
| f1e527918f | |||
| 0a029dd222 | |||
| fea6f82299 | |||
| 5513377221 | |||
| acb9d8052b | |||
| 7be0eb4c08 | |||
| 3fb645ed40 | |||
| 6618929092 | |||
| baaf21df36 | |||
| 47c1e3329f | |||
| 4a76134ca9 | |||
| 55f6b1a19e | |||
| 721371d806 | |||
| 1cc52075e9 |
@@ -6,20 +6,14 @@ name: Deploy Client
|
|||||||
# intentionally DECOUPLED — see the "why build-macos/build-ios don't block"
|
# intentionally DECOUPLED — see the "why build-macos/build-ios don't block"
|
||||||
# note above the build-macos job below for the mechanism and rationale.
|
# note above the build-macos job below for the mechanism and rationale.
|
||||||
#
|
#
|
||||||
# TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin
|
# RUNNER 就绪(2026-09-07 起,过时 TODO 已删):用户级 mac runner(label `mac`)+
|
||||||
# currently has exactly ONE registered Gitea Actions runner
|
# windows runner(label `windows`)均已注册(单一真相源见 baize ci_runners)。
|
||||||
# ("mac-pangolin-2", label `nas:host`). Neither `runs-on: mac` nor
|
# runs-on: mac / windows 正常派工,不再无限排队。
|
||||||
# `runs-on: windows` below has any runner registered to pick it up yet — this
|
#
|
||||||
# workflow will queue forever until that's fixed. Options: (a) register
|
# WEB 已并入本工作流(deploy-web job,构建 usercenter+website → Cloudflare Pages)——
|
||||||
# mac-pangolin-2 with an additional `mac` label (it's already a mac host —
|
# 一个 `client-v*` tag 即发 **web + iOS/mac/Android/Windows**(对齐 jiu 的
|
||||||
# cheapest fix for build-android/release-deploy) and separately stand up +
|
# deploy-client)。仅发 web 仍可用独立的 site-v* / deploy-site.yml(逻辑同源,共用
|
||||||
# register an actual Windows host runner labeled `windows` for build-windows
|
# scripts/ci/{compile-site,compile-usercenter,combine-site,deploy-site}.sh)。
|
||||||
# (no such machine exists per docs/ci-runner.md), or (b) repoint both at
|
|
||||||
# `nas` and accept that Android/Windows builds then compete with the
|
|
||||||
# docker-in-domain nas jobs on the same single mac host. This mirrors the
|
|
||||||
# `runs-on: mac` / `runs-on: windows` split already planned in
|
|
||||||
# docs/superpowers/plans/2026-07-05-cicd.md Task 7/10 — written that way here
|
|
||||||
# for fidelity to that plan, NOT because the runners are confirmed to exist.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
@@ -102,6 +96,13 @@ jobs:
|
|||||||
# APPSTORE_API_* 等),macos/ios 正常应成功;continue-on-error 只是抖动兜底。
|
# APPSTORE_API_* 等),macos/ios 正常应成功;continue-on-error 只是抖动兜底。
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: mac
|
runs-on: mac
|
||||||
|
# 串行化 mac 三 job(android→macos→ios):家里只有一个 mac runner(capacity=1),
|
||||||
|
# 三个 runs-on:mac 并行会抢同一 runner → 抢不到的被取消(run#286 的 macos/ios 全步
|
||||||
|
# 骤 cancelled 即此)。用 needs 串成链让它们一个跑完再跑下一个;`if: !cancelled()`
|
||||||
|
# 保证即便上游(android/macos)失败,本 job 仍在其完成后照跑(不因上游 fail 被 skip),
|
||||||
|
# 与 continue-on-error 一起兜住「单平台抖动不拖累其他平台」。对齐 jiu 的 needs 链。
|
||||||
|
needs: [build-android]
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GOPROXY: https://goproxy.cn,direct
|
GOPROXY: https://goproxy.cn,direct
|
||||||
@@ -113,18 +114,26 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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)
|
- name: Compile (macOS System Extension app)
|
||||||
env:
|
env:
|
||||||
# 左=脚本期望的 env 名(勿改),右=实际密钥名。Apple 证书/公证密钥是
|
# 账号级密钥(Developer ID 证书 + ASC Key,跨该账号所有 app 共享):按
|
||||||
# 账号级、跨项目唯一 → 放【全局(用户级)密钥】用通用短名,pangolin/jiu 各自
|
# SIGNING_ACCOUNT 选——us→SIGN_US_*(用户级)、cn→旧通用名(用户级,不动、
|
||||||
# 在此映射到自己脚本的 env。Developer ID Application 证书=站外分发,一张签所有 app。
|
# 供其他 app 继续用)。描述文件是 app 级(绑 bundle id)→ 仓库级,与账号无关。
|
||||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12 }}
|
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: ${{ secrets.P12_PASSWORD }}
|
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_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }}
|
||||||
MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_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 }}
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: bash scripts/ci/compile-macos.sh "$REF_NAME"
|
run: bash scripts/ci/compile-macos.sh "$REF_NAME"
|
||||||
|
|
||||||
@@ -136,6 +145,9 @@ jobs:
|
|||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: mac
|
runs-on: mac
|
||||||
|
# 串行链末环:等 build-macos 完成再跑(单 mac runner 不争用,见 build-macos 注释)。
|
||||||
|
needs: [build-macos]
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
GOPROXY: https://goproxy.cn,direct
|
GOPROXY: https://goproxy.cn,direct
|
||||||
@@ -147,22 +159,52 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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)
|
- name: Compile & upload to TestFlight (iOS)
|
||||||
env:
|
env:
|
||||||
# 证书=账号级 →【全局】通用短名(Apple Distribution 证书,签 iOS App Store/TestFlight);
|
# 账号级(Apple Distribution 证书 + ASC Key)按 SIGNING_ACCOUNT 选;
|
||||||
# 描述文件=与 bundle id 绑定 →【项目级】pangolin 仓库密钥。
|
# 描述文件=与 bundle id 绑定 → 仓库级、与账号无关。
|
||||||
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_P12 }}
|
IOS_DIST_CERT_P12_BASE64: ${{ steps.acct.outputs.account == 'us' && secrets.SIGN_US_APPLE_DIST_P12 || secrets.IOS_DIST_P12 }}
|
||||||
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_PASSWORD }}
|
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_APP_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }}
|
||||||
IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PACKETTUNNEL_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 }}
|
REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: bash scripts/ci/compile-ios.sh "$REF_NAME"
|
run: bash scripts/ci/compile-ios.sh "$REF_NAME"
|
||||||
# No artifact upload — compile-ios.sh uploads straight to TestFlight via
|
# No artifact upload — compile-ios.sh uploads straight to TestFlight via
|
||||||
# altool (matches jiu); nothing is produced under dist/ for this job.
|
# altool (matches jiu); nothing is produced under dist/ for this job.
|
||||||
|
|
||||||
|
# WEB(用户中心 usercenter + 官网 website → Cloudflare Pages):并入本工作流,使
|
||||||
|
# 一个 client-v* tag 同时发 web + 四客户端平台(对齐 jiu 的 build-client-web)。
|
||||||
|
# 与 deploy-site.yml 同源(共用 scripts/ci/{compile-site,compile-usercenter,
|
||||||
|
# combine-site,deploy-site}.sh);web 直接自部署到 CF Pages,不产 dist/ 产物、
|
||||||
|
# 不进 release-deploy 的 artifact 下载。ubuntu-latest = 家里 nas act_runner。
|
||||||
|
deploy-web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Compile (Astro 官网)
|
||||||
|
env:
|
||||||
|
SITE_URL: https://pangolin.yanmeiai.com
|
||||||
|
run: bash scripts/ci/compile-site.sh
|
||||||
|
- name: Compile (用户中心 Next.js)
|
||||||
|
run: bash scripts/ci/compile-usercenter.sh
|
||||||
|
- name: Combine (官网 + 用户中心)
|
||||||
|
run: bash scripts/ci/combine-site.sh
|
||||||
|
- name: Deploy → Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
run: bash scripts/ci/deploy-site.sh
|
||||||
|
|
||||||
# release-deploy 发布策略:needs 全部四平台(等它们**完成**,不抢跑),但用
|
# release-deploy 发布策略:needs 全部四平台(等它们**完成**,不抢跑),但用
|
||||||
# `if` 让 mac/ios/windows 的失败不 skip 本 job —— 只要 android(稳定 floor)成功即发。
|
# `if` 让 mac/ios/windows 的失败不 skip 本 job —— 只要 android(稳定 floor)成功即发。
|
||||||
# 背景:build-macos/ios 必须跑在 mac runner,其 Checkout(git fetch git.51yanmei.com)
|
# 背景:build-macos/ios 必须跑在 mac runner,其 Checkout(git fetch git.51yanmei.com)
|
||||||
@@ -173,7 +215,7 @@ jobs:
|
|||||||
# · continue-on-error 在 gitea 里**不**让下游 needs 在失败时继续(会 skip),故必须
|
# · continue-on-error 在 gitea 里**不**让下游 needs 在失败时继续(会 skip),故必须
|
||||||
# 配 `if: always() && needs.build-android.result=='success'` 才能「等全部、失败不阻」。
|
# 配 `if: always() && needs.build-android.result=='success'` 才能「等全部、失败不阻」。
|
||||||
release-deploy:
|
release-deploy:
|
||||||
needs: [build-android, build-windows, build-macos, build-ios]
|
needs: [build-android, build-windows, build-macos, build-ios, deploy-web]
|
||||||
if: ${{ always() && needs.build-android.result == 'success' }}
|
if: ${{ always() && needs.build-android.result == 'success' }}
|
||||||
# ubuntu-latest = 家里 nas act_runner,与 gitea 同机/同网:Release → Forgejo 的
|
# ubuntu-latest = 家里 nas act_runner,与 gitea 同机/同网:Release → Forgejo 的
|
||||||
# API 调用走本地不过 frps 隧道(mac runner 走 git.51yanmei.com→frps→抖,curl 超时
|
# API 调用走本地不过 frps 隧道(mac runner 走 git.51yanmei.com→frps→抖,curl 超时
|
||||||
|
|||||||
@@ -183,9 +183,10 @@ cd web/website && npm run gen:tokens
|
|||||||
|
|
||||||
**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒)
|
**系统扩展能被 realize 的硬性要求**(缺一即 `code=4` / 静默拒)
|
||||||
- **自包含**:`PacketTunnel` target 设 `OTHER_LDFLAGS = ""`(切断继承项目级 CocoaPods 链接标志,否则会把 `flutter_secure_storage` 链进扩展);`Libbox.xcframework` **只 Link 不 Embed**(它是静态库)。验证:`otool -L` 扩展二进制应**零 `@rpath` 外部依赖**。
|
- **自包含**:`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 的不顶用)。
|
- 扩展 `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`。
|
- 沙箱扩展补 `network.client` / `network.server`;`get-task-allow=false` + 签名加 `--timestamp`。
|
||||||
|
|
||||||
**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意)
|
**libbox / NetworkExtension 集成铁律**(改 `PacketTunnelProvider.swift` 注意)
|
||||||
@@ -206,7 +207,7 @@ cd web/website && npm run gen:tokens
|
|||||||
|
|
||||||
同样内嵌 libbox,与 macOS 同「CommandServer 模型」,但形态/打包不同:
|
同样内嵌 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)。
|
- **Android**:`PangolinVpnService`(`client/android/.../PangolinVpnService.kt`),VpnService + libbox **同进程**;明文联调 API 需 manifest `usesCleartextTraffic`(Android 9+ 默认禁 http)。
|
||||||
- iOS/Android 与 macOS 同走 Dart `VpnNativeBridge`,所以 Dart 侧修复(状态/统计流)四端共享。
|
- 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 用前者。
|
- ⚠️ **Android libbox Java 包名是 `io.nekohasekai.libbox`**(不是 `libbox`),Kotlin import 用前者。
|
||||||
|
|
||||||
**真机装机(老忘——有现成脚本,别手搓 xcodebuild/签名)**:
|
**真机装机(老忘——有现成脚本,别手搓 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 接口,主仓那份兼容)。
|
- 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`)。
|
- 新设备首次装报 `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) ..."
|
echo "→ 校验原型 data-i18n key 全 resolve (check-proto-i18n.mjs) ..."
|
||||||
node design/prototype/tools/check-proto-i18n.mjs
|
node design/prototype/tools/check-proto-i18n.mjs
|
||||||
echo "✅ 原型 i18n 无漂移:I18N 块与单源一致、data-i18n 全 resolve。"
|
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 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 peakResidentBytes: UInt64 = 0
|
||||||
private(set) var minAvailableBytes: UInt64 = UInt64.max
|
private(set) var minAvailableBytes: UInt64 = UInt64.max
|
||||||
private(set) var sampleCount: Int = 0
|
private(set) var sampleCount: Int = 0
|
||||||
@@ -48,7 +48,7 @@ final class MemoryMonitor {
|
|||||||
init(
|
init(
|
||||||
interval: TimeInterval = 10,
|
interval: TimeInterval = 10,
|
||||||
warningThreshold: UInt64 = 5 * 1024 * 1024,
|
warningThreshold: UInt64 = 5 * 1024 * 1024,
|
||||||
subsystem: String = "com.pangolin.pangolinVpn.PacketTunnel"
|
subsystem: String = "com.yanmeiai.pangolin.PacketTunnel"
|
||||||
) {
|
) {
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self.warningThreshold = warningThreshold
|
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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<!--
|
<!--
|
||||||
PacketTunnel.entitlements — PacketTunnel Extension Target Entitlements
|
PacketTunnel.entitlements — PacketTunnel Extension Target Entitlements
|
||||||
Bundle ID: com.pangolin.pangolinVpn.PacketTunnel
|
Bundle ID: com.yanmeiai.pangolin.PacketTunnel
|
||||||
|
|
||||||
申请说明:
|
申请说明:
|
||||||
1. com.apple.developer.networking.networkextension
|
1. com.apple.developer.networking.networkextension
|
||||||
→ Extension Bundle ID 须单独在 Apple 开发者后台注册并开启:
|
→ 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(勾选)
|
→ Network Extensions → Packet Tunnel Provider(勾选)
|
||||||
2. com.apple.security.application-groups
|
2. com.apple.security.application-groups
|
||||||
→ 与主 App 使用相同 Group(group.com.pangolin.pangolinVpn)。
|
→ 与主 App 使用相同 Group(group.com.yanmeiai.pangolin)。
|
||||||
Extension 通过 UserDefaults(suiteName:) 写入最新 stats/status,
|
Extension 通过 UserDefaults(suiteName:) 写入最新 stats/status,
|
||||||
主 App 在下次启动时读取(异常崩溃恢复场景)。
|
主 App 在下次启动时读取(异常崩溃恢复场景)。
|
||||||
-->
|
-->
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<!-- App Group:与主 App 共享 UserDefaults / 文件容器 -->
|
<!-- App Group:与主 App 共享 UserDefaults / 文件容器 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.pangolin.pangolinVpn</string>
|
<string>group.com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import Network
|
|||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import os
|
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>. 前缀)。
|
// iOS App Group 用 group. 前缀(非 macOS 的 <TeamID>. 前缀)。
|
||||||
private let appGroup = "group.com.pangolin.pangolinVpn"
|
private let appGroup = "group.com.yanmeiai.pangolin"
|
||||||
|
|
||||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private var commandServer: LibboxCommandServer?
|
private var commandServer: LibboxCommandServer?
|
||||||
|
|||||||
@@ -265,7 +265,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastUpgradeCheck = 1510;
|
LastUpgradeCheck = 1510;
|
||||||
ORGANIZATIONNAME = com.pangolin;
|
ORGANIZATIONNAME = "Yanmei AI LLC";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
CreatedOnToolsVersion = 7.3.1;
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
@@ -481,14 +481,14 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -610,14 +610,14 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -634,14 +634,14 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -657,7 +657,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Frameworks",
|
"$(PROJECT_DIR)/Frameworks",
|
||||||
@@ -670,7 +670,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = PacketTunnel;
|
PRODUCT_NAME = PacketTunnel;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -689,7 +689,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Frameworks",
|
"$(PROJECT_DIR)/Frameworks",
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = PacketTunnel;
|
PRODUCT_NAME = PacketTunnel;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -721,7 +721,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Frameworks",
|
"$(PROJECT_DIR)/Frameworks",
|
||||||
@@ -734,7 +734,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolinVpn.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = PacketTunnel;
|
PRODUCT_NAME = PacketTunnel;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -51,5 +51,10 @@
|
|||||||
⚠️ 红线词规范:避免 VPN/翻墙 等词,使用「网络加速」口径 -->
|
⚠️ 红线词规范:避免 VPN/翻墙 等词,使用「网络加速」口径 -->
|
||||||
<key>NSVPNUsageDescription</key>
|
<key>NSVPNUsageDescription</key>
|
||||||
<string>Pangolin 使用网络加速通道为您的连接提供安全保护。</string>
|
<string>Pangolin 使用网络加速通道为您的连接提供安全保护。</string>
|
||||||
|
<!-- 出口加密合规:仅使用标准加密(TLS/REALITY 基于标准 crypto 原语,无自研加密)
|
||||||
|
→ 声明不含「非豁免加密」,免去 App Store Connect 每次上传的加密合规问答。
|
||||||
|
⚠️ 若后续法务判定需正式申报,改 true 并补 ITSEncryptionExportComplianceCode。 -->
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<!--
|
<!--
|
||||||
Runner.entitlements — 主 App Target Entitlements
|
Runner.entitlements — 主 App Target Entitlements
|
||||||
Bundle ID: com.pangolin.pangolinVpn
|
Bundle ID: com.yanmeiai.pangolin
|
||||||
|
|
||||||
申请说明:
|
申请说明:
|
||||||
1. com.apple.developer.networking.networkextension
|
1. com.apple.developer.networking.networkextension
|
||||||
→ "packet-tunnel-provider" 须在 Apple 开发者后台显式申请。
|
→ "packet-tunnel-provider" 须在 Apple 开发者后台显式申请。
|
||||||
申请路径:developer.apple.com → Certificates, IDs & Profiles
|
申请路径:developer.apple.com → Certificates, IDs & Profiles
|
||||||
→ Identifiers → com.pangolin.pangolinVpn → Capabilities
|
→ Identifiers → com.yanmeiai.pangolin → Capabilities
|
||||||
→ Network Extensions → Packet Tunnel Provider(勾选)
|
→ Network Extensions → Packet Tunnel Provider(勾选)
|
||||||
2. com.apple.security.application-groups
|
2. com.apple.security.application-groups
|
||||||
→ group.com.pangolin.pangolinVpn 须在 App Groups 注册。
|
→ group.com.yanmeiai.pangolin 须在 App Groups 注册。
|
||||||
主 App 与 PacketTunnel extension 共享同一 Group,
|
主 App 与 PacketTunnel extension 共享同一 Group,
|
||||||
用于 UserDefaults(suiteName:) / FileManager containerURL(forSecurityApplicationGroupIdentifier:)
|
用于 UserDefaults(suiteName:) / FileManager containerURL(forSecurityApplicationGroupIdentifier:)
|
||||||
传递少量共享状态(上次节点 tag、连接时间戳等)。
|
传递少量共享状态(上次节点 tag、连接时间戳等)。
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<!-- App Group:主 App ↔ PacketTunnel extension 共享容器 -->
|
<!-- App Group:主 App ↔ PacketTunnel extension 共享容器 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.pangolin.pangolinVpn</string>
|
<string>group.com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ class VpnManager: NSObject {
|
|||||||
private var tunnelManager: NETunnelProviderManager?
|
private var tunnelManager: NETunnelProviderManager?
|
||||||
|
|
||||||
// Bundle ID 与 PacketTunnel target PRODUCT_BUNDLE_IDENTIFIER 保持同步
|
// 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 容器
|
// App Group:与 PacketTunnel extension 共享 UserDefaults 容器
|
||||||
private let appGroup = "group.com.pangolin.pangolinVpn"
|
private let appGroup = "group.com.yanmeiai.pangolin"
|
||||||
|
|
||||||
private var statusObserver: NSObjectProtocol?
|
private var statusObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,9 @@ abstract class AppText {
|
|||||||
String get capOn;
|
String get capOn;
|
||||||
String get connectNow; // 连接键 off 态圆内文字
|
String get connectNow; // 连接键 off 态圆内文字
|
||||||
String get secure; // 连接键圆内「已加密」
|
String get secure; // 连接键圆内「已加密」
|
||||||
|
String get disconnectTitle; // 断开确认弹窗标题
|
||||||
|
String get disconnectBody; // 断开确认弹窗正文
|
||||||
|
String get disconnectAction; // 断开确认弹窗「断开」按钮
|
||||||
|
|
||||||
// ── 底部 / 侧栏导航 ──
|
// ── 底部 / 侧栏导航 ──
|
||||||
String get tabConnect;
|
String get tabConnect;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsEn extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => 'SECURE';
|
String get secure => 'SECURE';
|
||||||
@override
|
@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';
|
String get tabConnect => 'Connect';
|
||||||
@override
|
@override
|
||||||
String get tabServers => 'Servers';
|
String get tabServers => 'Servers';
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsEs extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => 'SEGURO';
|
String get secure => 'SEGURO';
|
||||||
@override
|
@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';
|
String get tabConnect => 'Conectar';
|
||||||
@override
|
@override
|
||||||
String get tabServers => 'Servidores';
|
String get tabServers => 'Servidores';
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsJa extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => 'セキュア';
|
String get secure => 'セキュア';
|
||||||
@override
|
@override
|
||||||
|
String get disconnectTitle => '切断しますか?';
|
||||||
|
@override
|
||||||
|
String get disconnectBody => '接続は暗号化されています。切断してもよろしいですか?';
|
||||||
|
@override
|
||||||
|
String get disconnectAction => '切断';
|
||||||
|
@override
|
||||||
String get tabConnect => '接続';
|
String get tabConnect => '接続';
|
||||||
@override
|
@override
|
||||||
String get tabServers => 'サーバー';
|
String get tabServers => 'サーバー';
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsKo extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => '보안';
|
String get secure => '보안';
|
||||||
@override
|
@override
|
||||||
|
String get disconnectTitle => '연결 해제하시겠습니까?';
|
||||||
|
@override
|
||||||
|
String get disconnectBody => '연결이 암호화되어 있습니다. 정말 연결을 해제하시겠습니까?';
|
||||||
|
@override
|
||||||
|
String get disconnectAction => '연결 해제';
|
||||||
|
@override
|
||||||
String get tabConnect => '연결';
|
String get tabConnect => '연결';
|
||||||
@override
|
@override
|
||||||
String get tabServers => '서버';
|
String get tabServers => '서버';
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsRu extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => 'ЗАЩИЩЕНО';
|
String get secure => 'ЗАЩИЩЕНО';
|
||||||
@override
|
@override
|
||||||
|
String get disconnectTitle => 'Отключиться?';
|
||||||
|
@override
|
||||||
|
String get disconnectBody => 'Ваше соединение зашифровано. Действительно отключиться?';
|
||||||
|
@override
|
||||||
|
String get disconnectAction => 'Отключить';
|
||||||
|
@override
|
||||||
String get tabConnect => 'Подключение';
|
String get tabConnect => 'Подключение';
|
||||||
@override
|
@override
|
||||||
String get tabServers => 'Серверы';
|
String get tabServers => 'Серверы';
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class StringsZh extends AppText {
|
|||||||
@override
|
@override
|
||||||
String get secure => '已加密';
|
String get secure => '已加密';
|
||||||
@override
|
@override
|
||||||
|
String get disconnectTitle => '断开连接?';
|
||||||
|
@override
|
||||||
|
String get disconnectBody => '当前连接已加密。确定要断开吗?';
|
||||||
|
@override
|
||||||
|
String get disconnectAction => '断开';
|
||||||
|
@override
|
||||||
String get tabConnect => '连接';
|
String get tabConnect => '连接';
|
||||||
@override
|
@override
|
||||||
String get tabServers => '节点';
|
String get tabServers => '节点';
|
||||||
|
|||||||
@@ -21,6 +21,47 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
final bool isWide;
|
final bool isWide;
|
||||||
final VoidCallback onOpenNodes;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final c = context.pangolin;
|
final c = context.pangolin;
|
||||||
@@ -53,7 +94,7 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
elapsed: conn.elapsed,
|
elapsed: conn.elapsed,
|
||||||
offLabel: t.connectNow,
|
offLabel: t.connectNow,
|
||||||
secureLabel: t.secure,
|
secureLabel: t.secure,
|
||||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
onTap: () => _handleToggle(context, ref, conn.phase, t),
|
||||||
);
|
);
|
||||||
|
|
||||||
final captionWidget = Column(mainAxisSize: MainAxisSize.min, children: [
|
final captionWidget = Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
@@ -94,7 +135,7 @@ class ConnectPage extends ConsumerWidget {
|
|||||||
offLabel: t.connectNow,
|
offLabel: t.connectNow,
|
||||||
secureLabel: t.secure,
|
secureLabel: t.secure,
|
||||||
size: 176,
|
size: 176,
|
||||||
onTap: () => ref.read(connectionProvider.notifier).toggle(),
|
onTap: () => _handleToggle(context, ref, conn.phase, t),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(caption,
|
Text(caption,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import '../state/payment_provider.dart';
|
|||||||
import '../widgets/page_body.dart';
|
import '../widgets/page_body.dart';
|
||||||
import '../widgets/pangolin_icons.dart';
|
import '../widgets/pangolin_icons.dart';
|
||||||
import '../widgets/sub_scaffold.dart';
|
import '../widgets/sub_scaffold.dart';
|
||||||
import '../widgets/pangolin_toast.dart';
|
|
||||||
import '../widgets/plan_card.dart';
|
import '../widgets/plan_card.dart';
|
||||||
import '../widgets/seg_switch.dart';
|
import '../widgets/seg_switch.dart';
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ class PurchaseScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
||||||
AppText get t => widget.t;
|
AppText get t => widget.t;
|
||||||
bool get _zh => t.lang == AppLang.zh;
|
|
||||||
|
|
||||||
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
|
// 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。
|
||||||
PayChannel _channel = PayChannel.usdt;
|
PayChannel _channel = PayChannel.usdt;
|
||||||
@@ -51,18 +49,22 @@ class _PurchaseScreenState extends ConsumerState<PurchaseScreen> {
|
|||||||
_ => sku,
|
_ => sku,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bool _buying = false; // 防连点:导航切走前的窗口内挡住第二次 tap,避免下双单
|
||||||
|
|
||||||
Future<void> _buy(PayCatalogItem item) async {
|
Future<void> _buy(PayCatalogItem item) async {
|
||||||
await ref.read(paymentFlowProvider.notifier).start(item, _channel.method);
|
if (_buying) return;
|
||||||
if (!mounted) return;
|
_buying = true;
|
||||||
final st = ref.read(paymentFlowProvider);
|
final notifier = ref.read(paymentFlowProvider.notifier);
|
||||||
if (st.phase == PaymentPhase.awaitingPayment) {
|
// start() 同步先把相位置 creating,随即导航到支付页 —— 用户点「立即购买」即刻
|
||||||
widget.onOrderCreated?.call();
|
// 看到页面切换 + 转圈,不再在购买页干等 createOrder 网络往返(原「点了卡一下」的
|
||||||
} else if (st.phase == PaymentPhase.failed) {
|
// 错觉根因)。下单结果(awaitingPayment/failed)由支付页自渲染(failed 带重试/
|
||||||
// 下单失败给可见 toast,别让用户以为「点了没反应」。
|
// 返回),这里不再回吐 toast。
|
||||||
showPangolinToast(
|
final fut = notifier.start(item, _channel.method);
|
||||||
context,
|
widget.onOrderCreated?.call();
|
||||||
(_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed,
|
try {
|
||||||
);
|
await fut;
|
||||||
|
} finally {
|
||||||
|
_buying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:async';
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart' show AppLifecycleListener;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../models/payment.dart';
|
import '../models/payment.dart';
|
||||||
@@ -63,19 +64,27 @@ class PaymentFlowState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PaymentFlowController extends StateNotifier<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
|
/// 测试专用 fixture:直接给定固定状态,`_ref` 留空、不发网络、不起 Timer、
|
||||||
/// (widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。[cancel] 对
|
/// 不挂生命周期监听(widget 测试用来渲染某一支付阶段的界面,不驱动状态机)。
|
||||||
/// `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
|
/// [cancel] 对 `_ref == null` 短路安全——支付页 dispose 时无条件可调,不炸 fixture。
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
PaymentFlowController.fixed(super.state) : _ref = null;
|
PaymentFlowController.fixed(super.state) : _ref = null;
|
||||||
|
|
||||||
final Ref? _ref;
|
final Ref? _ref;
|
||||||
Timer? _poll;
|
Timer? _poll;
|
||||||
|
AppLifecycleListener? _lifecycle;
|
||||||
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
|
bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍)
|
||||||
|
|
||||||
static const _pollInterval = Duration(seconds: 3);
|
static const _pollInterval = Duration(seconds: 2);
|
||||||
|
|
||||||
bool get _isMobile {
|
bool get _isMobile {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
@@ -229,6 +238,9 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
|||||||
|
|
||||||
void _startPolling() {
|
void _startPolling() {
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
|
// 立刻查一次:Timer.periodic 首拍要等满一个间隔才发,下单/续单后先补一拍,
|
||||||
|
// 把「已付款但还没轮到第一拍」的空窗抹掉。
|
||||||
|
unawaited(pollOnce());
|
||||||
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
|
_poll = Timer.periodic(_pollInterval, (_) => pollOnce());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +251,7 @@ class PaymentFlowController extends StateNotifier<PaymentFlowState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_lifecycle?.dispose();
|
||||||
_stopPolling();
|
_stopPolling();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
<key>NetworkExtension</key>
|
<key>NetworkExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<!-- NEMachServiceName 必须以扩展所属的某个 App Group 为前缀(Apple 规则)。
|
<!-- 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、
|
+ .PacketTunnel。参照可工作的 Tailscale:group=<Team>.io.tailscale.ipn.macsys、
|
||||||
mach=该 group + .network-extension。 -->
|
mach=该 group + .network-extension。 -->
|
||||||
<key>NEMachServiceName</key>
|
<key>NEMachServiceName</key>
|
||||||
<string>BYL4KQHMTN.com.pangolin.pangolin.PacketTunnel</string>
|
<string>44WULXM6SV.com.yanmeiai.pangolin.PacketTunnel</string>
|
||||||
<key>NEProviderClasses</key>
|
<key>NEProviderClasses</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.networkextension.packet-tunnel</key>
|
<key>com.apple.networkextension.packet-tunnel</key>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
非 iOS 的 group. 前缀——sysextd realize 前校验请求方/扩展时要求此格式。 -->
|
非 iOS 的 group. 前缀——sysextd realize 前校验请求方/扩展时要求此格式。 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
<string>44WULXM6SV.com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import Network
|
|||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import os
|
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. 前缀)。
|
// macOS 原生 App Group 格式 <TeamID>.<name>(非 iOS 的 group. 前缀)。
|
||||||
// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
|
// sysextd 校验请求方 app 时要求此格式;用 group. 式会在 realize 暂存前被拒。
|
||||||
private let appGroup = "BYL4KQHMTN.com.pangolin.pangolin"
|
private let appGroup = "44WULXM6SV.com.yanmeiai.pangolin"
|
||||||
|
|
||||||
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
final class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private var commandServer: LibboxCommandServer?
|
private var commandServer: LibboxCommandServer?
|
||||||
|
|||||||
@@ -662,7 +662,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
||||||
@@ -677,7 +677,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
||||||
@@ -692,7 +692,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pangolin_vpn.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pangolin_vpn";
|
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_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -893,7 +893,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -916,7 +916,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -961,7 +961,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -982,8 +982,8 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -1011,7 +1011,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Developer ID Application";
|
CODE_SIGN_IDENTITY = "Developer ID Application";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -1032,8 +1032,8 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_CODE_SIGN_FLAGS = "--timestamp";
|
OTHER_CODE_SIGN_FLAGS = "--timestamp";
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Pangolin PacketTunnel DevID";
|
PROVISIONING_PROFILE_SPECIFIER = "Pangolin PacketTunnel DevID";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -1060,7 +1060,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 55;
|
CURRENT_PROJECT_VERSION = 55;
|
||||||
DEVELOPMENT_TEAM = BYL4KQHMTN;
|
DEVELOPMENT_TEAM = 44WULXM6SV;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -1079,8 +1079,8 @@
|
|||||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PRODUCT_NAME = com.pangolin.pangolin.PacketTunnel;
|
PRODUCT_NAME = com.yanmeiai.pangolin.PacketTunnel;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
PRODUCT_NAME = pangolin_vpn
|
PRODUCT_NAME = pangolin_vpn
|
||||||
|
|
||||||
// The application's bundle identifier
|
// The application's bundle identifier
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin
|
PRODUCT_BUNDLE_IDENTIFIER = com.yanmeiai.pangolin
|
||||||
|
|
||||||
// The copyright displayed in application information
|
// 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 -->
|
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
<string>$(AppIdentifierPrefix)com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
|
<!-- P1 方案B:接 System Extension 时在此加(注册 App Group 后,见 p1-macos-system-extension.md §3):
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.pangolin.pangolin</string>
|
<string>group.com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
-->
|
-->
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ServiceManagement
|
|||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
// launch_at_startup 插件在 macOS 没自带实现,需宿主接 MethodChannel。学 Tailscale 用
|
// launch_at_startup 插件在 macOS 没自带实现,需宿主接 MethodChannel。学 Tailscale 用
|
||||||
// SMAppService.loginItem 注册内置的 PangolinLoginHelper(登录时它带 --autostart 拉起主 app)。
|
// SMAppService.loginItem 注册内置的 PangolinLoginHelper(登录时它带 --autostart 拉起主 app)。
|
||||||
private let loginHelperID = "com.pangolin.pangolin.LoginHelper"
|
private let loginHelperID = "com.yanmeiai.pangolin.LoginHelper"
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
let flutterViewController = FlutterViewController()
|
let flutterViewController = FlutterViewController()
|
||||||
|
|||||||
@@ -21,14 +21,14 @@
|
|||||||
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
<!-- flutter_secure_storage: Data Protection Keychain 需要此 entitlement,否则返回 -34018 -->
|
||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.pangolin.pangolin</string>
|
<string>$(AppIdentifierPrefix)com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
<!-- 与 PacketTunnel sysext 共享 App Group。必须用 macOS 原生格式 <TeamID>.<name>
|
<!-- 与 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)。 -->
|
暂存前校验请求方 app 时认定 app group 非法而拒绝(参照可工作的 Tailscale)。 -->
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>BYL4KQHMTN.com.pangolin.pangolin</string>
|
<string>44WULXM6SV.com.yanmeiai.pangolin</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import os.log
|
|||||||
|
|
||||||
// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被
|
// os_log + %{public} —— 让日志在 Console.app / `log show` 里可见(NSLog 的 %@ 参数会被
|
||||||
// 系统 redact 成 <private>,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用,
|
// 系统 redact 成 <private>,排障时看不到内容)。os_log(C API)自 macOS 10.12 起可用,
|
||||||
// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.pangolin.pangolin"'`。
|
// 兼容 Runner 的 10.15 部署目标。过滤:`log show --predicate 'subsystem == "com.yanmeiai.pangolin"'`。
|
||||||
private let vpnLogObj = OSLog(subsystem: "com.pangolin.pangolin", category: "vpn")
|
private let vpnLogObj = OSLog(subsystem: "com.yanmeiai.pangolin", category: "vpn")
|
||||||
private func vpnLog(_ message: String) {
|
private func vpnLog(_ message: String) {
|
||||||
os_log("%{public}@", log: vpnLogObj, type: .default, message)
|
os_log("%{public}@", log: vpnLogObj, type: .default, message)
|
||||||
NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到
|
NSLog("[pangolin/vpn] %@", message) // 同时进 stderr,flutter run 控制台也能看到
|
||||||
}
|
}
|
||||||
|
|
||||||
final class VpnChannel: NSObject {
|
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 statusSink: FlutterEventSink?
|
||||||
private var statsSink: FlutterEventSink?
|
private var statsSink: FlutterEventSink?
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HELPER_NAME="PangolinLoginHelper"
|
HELPER_NAME="PangolinLoginHelper"
|
||||||
HELPER_ID="com.pangolin.pangolin.LoginHelper"
|
HELPER_ID="com.yanmeiai.pangolin.LoginHelper"
|
||||||
SRC="${SRCROOT}/login_helper/main.swift"
|
SRC="${SRCROOT}/login_helper/main.swift"
|
||||||
DEST="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Library/LoginItems/${HELPER_NAME}.app"
|
DEST="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Library/LoginItems/${HELPER_NAME}.app"
|
||||||
WORK="${DERIVED_FILE_DIR}/login_helper"
|
WORK="${DERIVED_FILE_DIR}/login_helper"
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ void main() {
|
|||||||
expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded);
|
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 {
|
test('stopPolling() 只停 Timer,不远程 cancel、不重置 state', () async {
|
||||||
final api = _FakePaymentApi();
|
final api = _FakePaymentApi();
|
||||||
final c = _container(api);
|
final c = _container(api);
|
||||||
|
|||||||
@@ -65,6 +65,30 @@
|
|||||||
"ru": "ЗАЩИЩЕНО",
|
"ru": "ЗАЩИЩЕНО",
|
||||||
"es": "SEGURO"
|
"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": {
|
"tabConnect": {
|
||||||
"zh": "连接",
|
"zh": "连接",
|
||||||
"en": "Connect",
|
"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>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CI Runner 走域名注册 + 局域网/外网无感切换 实现方案</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.bad{border-left:3px solid var(--bad)}
|
||||||
|
.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)}
|
||||||
|
.diagram{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:20px;margin:18px 0;overflow-x:auto}
|
||||||
|
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>CI Runner 走域名注册 + 局域网/外网无感切换</h1>
|
||||||
|
<p class="sub">gitea Actions self-hosted runner · 注册地址统一 <code>https://git.51yanmei.com</code> · 复用 pangolin 私有分流(private-split)达成家/外无感 · 2026-09-07</p>
|
||||||
|
|
||||||
|
<div class="lead">
|
||||||
|
<strong>一句话:</strong>daily 代理已从 Shadowrocket 换成 <strong>pangolin(全局 TUN)</strong>——TUN 抓所有二进制、<strong>不再像 Shadowrocket 的按 App SOCKS 那样掐断 Go 二进制</strong>,所以 <code>act_runner</code> 现在能<strong>直接连域名</strong>,<span class="tag bad">relay 淘汰</span>。runner 一律注册到域名 <code>https://git.51yanmei.com</code>;<code>git.51yanmei.com</code> 早已在 pangolin 的 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 里,靠 <code>dns-system</code>(底层物理网络 DNS)解析 —— <strong>在家</strong>局域网 DNS 覆盖成 <code>192.168.3.200</code> → 命中「LAN 直连」规则直达 NAS;<strong>在外</strong>公网 DNS 返回 ali 锚点 <code>182.92.213.171</code> → 命中「私有域名走隧道」→ 经 pangolin1 出口 → ali frp → NAS。同一个域名、同一份配置,家/外自动切换。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card ok">
|
||||||
|
<h3>✅ 已就绪(本次实测确认,无需改动)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>pangolin 是当前活跃代理</b>:<code>com.pangolin.pangolin.PacketTunnel</code> 系统扩展 + <code>pangolin_vpn.app</code> 在跑;无 Shadowrocket。</li>
|
||||||
|
<li><b>域名已可直连</b>:从 runner mac 经 pangolin TUN,<code>GET https://git.51yanmei.com/</code> 与 <code>/api/v1/version</code> 均 <span class="ok-c">HTTP 200</span>(解析 <code>182.92.213.171</code>,~1.8s,走隧道→pangolin1→ali→frp→NAS)。<strong>relay 已不再必要。</strong></li>
|
||||||
|
<li><b>pangolin 配置已含 git</b>:pangolin1 <code>/etc/pangolin/server.env</code> 第 31 行 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS=nas,git,win,brain.51yanmei.com</code>。<span class="tag ok">零改动</span></li>
|
||||||
|
<li><b>ali 侧外网入口已就绪</b>:<code>/etc/nginx/conf.d/git.conf</code> = <code>git.51yanmei.com</code> 443(LE 证书) → <code>proxy_pass 127.0.0.1:3000</code>(frp→NAS),<strong>无 IP 白名单</strong>(不像 brain 限 <code>103.119.13.48</code>),任意来源可达。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bad">
|
||||||
|
<h3>❌ 差在「局域网直达」这条腿(本方案要补的)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>无家内分光 DNS</b>:全网(含在家)解析 <code>git.51yanmei.com</code> 都得 <code>182.92.213.171</code>;群晖 <b>DNS Server 套件未安装</b>(<code>/var/packages/DNSServer</code> 不存在)。baize <code>domains.yaml</code> 里 nas/win/git 的「家内覆盖 192.168.3.200 待做」正是此项。<b>可行性已判定→支持</b>(见 §6)。</li>
|
||||||
|
<li><b>NAS 443 尚无 git 的 TLS 反代条目</b>:NAS 监听 <code>0.0.0.0:443</code>(DSM nginx),需加 <code>git.51yanmei.com</code>→<code>gitea:3000</code> 的反代 + 证书。局域网直达 <code>https://git.51yanmei.com</code>(=192.168.3.200:443)必须它成立。<b>可行性已判定→支持,证书需 DNS-01/同步</b>(见 §6)。</li>
|
||||||
|
<li><b>runner 侧仍是旧的坏配置</b>:mac-pangolin-2(gitea_id=7)标签错(<code>nas</code>)、注册到直连公网域名(Shadowrocket 时代坏);jiu 的 mac-runner(id=3)仓库级、经 relay。均待收口成<strong>一个用户级 mac runner 走域名</strong>。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>0 · 目标架构(一张图)</h2>
|
||||||
|
<div class="diagram">
|
||||||
|
<svg viewBox="0 0 900 430" width="100%" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="ar" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5fb0c9"/></marker>
|
||||||
|
<marker id="arg" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#5ec27a"/></marker>
|
||||||
|
<marker id="ary" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"><path d="M0,0 L7,3 L0,6 Z" fill="#e0b84f"/></marker>
|
||||||
|
</defs>
|
||||||
|
<!-- runner mac -->
|
||||||
|
<rect x="20" y="170" width="150" height="90" rx="10" fill="#1d2129" stroke="#e0884f"/>
|
||||||
|
<text x="95" y="198" fill="#e6e8ee" font-size="13" font-weight="700" text-anchor="middle">runner mac</text>
|
||||||
|
<text x="95" y="218" fill="#a8afbd" font-size="11" text-anchor="middle">act_runner</text>
|
||||||
|
<text x="95" y="234" fill="#a8afbd" font-size="11" text-anchor="middle">pangolin TUN</text>
|
||||||
|
<text x="95" y="250" fill="#5fb0c9" font-size="10" text-anchor="middle">→ git.51yanmei.com</text>
|
||||||
|
|
||||||
|
<!-- DNS decision -->
|
||||||
|
<rect x="215" y="180" width="140" height="70" rx="10" fill="#171a22" stroke="#5fb0c9"/>
|
||||||
|
<text x="285" y="205" fill="#e6e8ee" font-size="12" font-weight="700" text-anchor="middle">私有分流判定</text>
|
||||||
|
<text x="285" y="222" fill="#a8afbd" font-size="10" text-anchor="middle">DNS 劫持 → dns-system</text>
|
||||||
|
<text x="285" y="237" fill="#a8afbd" font-size="10" text-anchor="middle">(底层物理网络 DNS)</text>
|
||||||
|
<line x1="170" y1="215" x2="212" y2="215" stroke="#5fb0c9" stroke-width="1.6" marker-end="url(#ar)"/>
|
||||||
|
|
||||||
|
<!-- LAN lane -->
|
||||||
|
<text x="430" y="60" fill="#5ec27a" font-size="12" font-weight="700">在家 · 局域网</text>
|
||||||
|
<rect x="410" y="75" width="170" height="66" rx="9" fill="#1d2129" stroke="#5ec27a"/>
|
||||||
|
<text x="495" y="99" fill="#e6e8ee" font-size="11.5" font-weight="700" text-anchor="middle">群晖 DNS Server</text>
|
||||||
|
<text x="495" y="116" fill="#5ec27a" font-size="10.5" text-anchor="middle">git → 192.168.3.200</text>
|
||||||
|
<text x="495" y="131" fill="#a8afbd" font-size="10" text-anchor="middle">命中「LAN 直连」规则</text>
|
||||||
|
<rect x="640" y="75" width="230" height="66" rx="9" fill="#171a22" stroke="#5ec27a"/>
|
||||||
|
<text x="755" y="99" fill="#e6e8ee" font-size="11.5" font-weight="700" text-anchor="middle">NAS 192.168.3.200</text>
|
||||||
|
<text x="755" y="116" fill="#a8afbd" font-size="10.5" text-anchor="middle">:443 反代(LE证书) → gitea:3000</text>
|
||||||
|
<text x="755" y="131" fill="#5ec27a" font-size="10" text-anchor="middle">直达,无隧道 · ~ms</text>
|
||||||
|
<path d="M300,180 C300,108 405,108 408,108" fill="none" stroke="#5ec27a" stroke-width="1.6" marker-end="url(#arg)"/>
|
||||||
|
<line x1="580" y1="108" x2="638" y2="108" stroke="#5ec27a" stroke-width="1.6" marker-end="url(#arg)"/>
|
||||||
|
|
||||||
|
<!-- WAN lane -->
|
||||||
|
<text x="430" y="300" fill="#e0b84f" font-size="12" font-weight="700">在外 · 公网</text>
|
||||||
|
<rect x="410" y="312" width="150" height="62" rx="9" fill="#1d2129" stroke="#e0b84f"/>
|
||||||
|
<text x="485" y="335" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">公网 DNS</text>
|
||||||
|
<text x="485" y="351" fill="#e0b84f" font-size="10.5" text-anchor="middle">git → 182.92.213.171</text>
|
||||||
|
<text x="485" y="366" fill="#a8afbd" font-size="9.5" text-anchor="middle">命中「私有域名走隧道」</text>
|
||||||
|
<rect x="600" y="306" width="120" height="74" rx="9" fill="#171a22" stroke="#e0884f"/>
|
||||||
|
<text x="660" y="330" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">pangolin1</text>
|
||||||
|
<text x="660" y="346" fill="#a8afbd" font-size="9.5" text-anchor="middle">隧道出口</text>
|
||||||
|
<text x="660" y="361" fill="#a8afbd" font-size="9.5" text-anchor="middle">103.119.13.48</text>
|
||||||
|
<rect x="760" y="306" width="120" height="74" rx="9" fill="#171a22" stroke="#e0b84f"/>
|
||||||
|
<text x="820" y="330" fill="#e6e8ee" font-size="11" font-weight="700" text-anchor="middle">ali nginx</text>
|
||||||
|
<text x="820" y="346" fill="#a8afbd" font-size="9.5" text-anchor="middle">:443 → frp</text>
|
||||||
|
<text x="820" y="361" fill="#5ec27a" font-size="9.5" text-anchor="middle">→ NAS:3000</text>
|
||||||
|
<path d="M300,250 C300,343 405,343 408,343" fill="none" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||||
|
<line x1="560" y1="343" x2="598" y2="343" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||||
|
<line x1="720" y1="343" x2="758" y2="343" stroke="#e0b84f" stroke-width="1.6" marker-end="url(#ary)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="small">两条腿只有 <b>DNS 解析结果不同</b>驱动分叉:家内被 <code>dns-system</code> 解成私网 IP → 先命中 route 里更靠前的「LAN 直连」;在外解成公网锚点 → 落到「私有域名 → auto(隧道)」。<b>runner 侧完全不感知</b>,永远只认 <code>https://git.51yanmei.com</code>。</p>
|
||||||
|
|
||||||
|
<h2>1 · 为什么无感切换成立(机制,已在代码里且有测试)</h2>
|
||||||
|
<p>核心在 <code>server/internal/httpapi/clientconfig.go</code>,<code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code> 非空时注入三处,<b>顺序即优先级</b>:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>位置</th><th>作用</th></tr>
|
||||||
|
<tr><td>①</td><td>DNS server <code>dns-system</code>(type=local,line 370-373)</td><td>私有域名不走公共 DNS(223.5.5.5/8.8.8.8),改用<strong>底层物理网络 DNS</strong>——在家=路由器/NAS 下发的局域网 DNS(含覆盖),在外=所在网络 DNS(公网记录)。</td></tr>
|
||||||
|
<tr><td>②</td><td>DNS rule <code>{domain: 私有域名, server: dns-system}</code>(line 383-385)</td><td>把私有域名的解析定向到 ①。排在 <code>geosite-cn</code> 之前,优先级最高。</td></tr>
|
||||||
|
<tr><td>③</td><td>route rule <code>{domain: 私有域名, outbound: auto}</code>(line 239-242)+ 前置 LAN 直连 <code>{ip_cidr:[10/8,172.16/12,192.168/16,127/8], outbound:direct}</code>(line 230-233)</td><td>在家:DNS 解出私网 IP → <strong>LAN 直连规则先命中</strong>(更靠前)→ 直达 NAS,<b>不进隧道</b>。在外:解出公网 IP → 落到私有域名规则 → <code>auto</code>(隧道) → pangolin1 出口。<code>dns.reverse_mapping=true</code> 补回域名元数据,使按 IP 发起的连接仍能命中该规则。</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="small">这正是 <a href="private-dest-acl-design.html">私有服务 ACL 设计</a> / <a href="private-dest-acl-plan.html">实现计划</a> 已落地的家庭内网穿透机制(nas/win/brain 同款)。git 只是复用它,<b>无新代码</b>。</p>
|
||||||
|
|
||||||
|
<h2>2 · 分项实现(workstreams)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>工作项</th><th>动谁</th><th>状态</th></tr>
|
||||||
|
<tr><td>W1</td><td>pangolin 把 git 纳入 private-split</td><td>pangolin1 <code>server.env</code></td><td><span class="tag ok">已完成</span></td></tr>
|
||||||
|
<tr><td>W2</td><td>ali 外网入口(git nginx 443→frp→NAS,开放)</td><td>ali</td><td><span class="tag ok">已就绪</span></td></tr>
|
||||||
|
<tr><td>W3</td><td>家内<b>分光 DNS</b>:git/nas/win → 192.168.3.200 / .88</td><td>NAS(群晖 DNS Server)+ <s>DHCP 下发</s>→仅 runner mac 手动 DNS</td><td><span class="tag ok">已完成 2026-09-07(改法,见 §8)</span></td></tr>
|
||||||
|
<tr><td>W4</td><td>NAS <b>443 TLS 反代</b> git → gitea:3000(+证书)</td><td>NAS(DSM 反向代理 + 导入 ali LE 证书)</td><td><span class="tag ok">已完成 2026-09-07</span></td></tr>
|
||||||
|
<tr><td>W5</td><td>runner 收口:1 个用户级 mac runner 走<b>域名</b>;退役 id=3;删 relay</td><td>dev mac(launchd)+ gitea</td><td><span class="tag ok">已完成 2026-09-07</span></td></tr>
|
||||||
|
<tr><td>W6</td><td>验证 + 回写 baize 台账</td><td>—</td><td><span class="tag warn">待做</span></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p><b>降级路径(若暂不做 W3/W4):</b>runner <b>现在</b>就能改注册到域名并删 relay(W5 单独可行)——在家会走「隧道→pangolin1→ali→NAS」的<b>回环</b>路径(实测 ~1.8s/请求,能用但慢)。W3+W4 是把在家这条腿从「回环」优化成「局域网直达(~ms)」,即你要的「局域网域名直接解析成局域网地址」。<b>建议:W5 先落地拿掉 relay,W3/W4 作为随后的直连优化。</b></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>W3 · 家内分光 DNS <span class="tag you">你确认/我执行</span></h3>
|
||||||
|
<p>目标:局域网内解析 <code>git/nas/win.51yanmei.com</code> → 私网 IP;其余照常公网。方案(二选一):</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>群晖 DNS Server(推荐,baize 既定方向)</b>:DSM 套件中心装 <b>DNS Server</b> → 建 Master Zone <code>51yanmei.com</code>(或用「解析区域 + A 记录覆盖」)→ 加 A:<code>git 192.168.3.200</code>、<code>nas 192.168.3.200</code>、<code>win 192.168.3.88</code>;开启 forwarders 把其余查询转公网(223.5.5.5)。<b>DHCP 下发 DNS 指向 NAS</b>——好消息:<b>NAS 本身就是 LAN 的 DHCP 服务器</b>(<code>DhcpServer</code> 套件已装、引擎 dnsmasq、段 .10–.230),在 DSM DHCP 设置里把下发 DNS 改成 <code>192.168.3.200</code> 即可,<b>无需碰路由器</b>。</li>
|
||||||
|
<li><b>ContainerManager 容器(备选,已装)</b>:跑 AdGuard Home / dnsmasq 容器做 <code>git→192.168.3.200</code> rewrite,DHCP 下发指向它。免装 DSM DNS Server 套件。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="small"><b>注意</b>:装 DNS Server / 改 DHCP 下发 = 机器与网络改动,按规矩<strong>先经你确认</strong>再动。<code>dns-system</code>(type=local)吃的是 OS resolver → DHCP 下发的 DNS,所以 DHCP 那一步不可省(NAS 既是 DHCP 服务器,这步在 DSM 内完成)。</p>
|
||||||
|
|
||||||
|
<h3>W4 · NAS 443 为 git 供 TLS 反代 <span class="tag you">你确认/我执行</span></h3>
|
||||||
|
<p>局域网直达 <code>https://git.51yanmei.com</code> 即打到 <code>192.168.3.200:443</code>,须 NAS 在 443 用 <b>git.51yanmei.com 有效证书</b>反代到 <code>gitea:3000</code>。步骤:</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>先<b>证实现状</b>(需 sudo,你在 NAS 上跑或授权我):看 DSM「控制面板 → 登录门户 → 高级 → 反向代理」是否已有 <code>git.51yanmei.com</code> 条目;<code>sudo ss -tlnp | grep :443</code> 看谁占 443。</li>
|
||||||
|
<li>若<b>无</b>:DSM 反向代理新建 <code>git.51yanmei.com:443</code> → <code>localhost:3000</code>,勾 HTTP/2、WebSocket;<code>client_max_body_size 0</code> 等价项在 DSM 里放开大包(git push)。</li>
|
||||||
|
<li><b>证书</b>:DSM「安全性 → 证书」为 <code>git.51yanmei.com</code> 配 LE 证书。家内无 80 端口暴露→用 <b>DNS-01</b>(阿里云 DNS,AK 在 Bitwarden「ali dev」),或从 ali <code>/etc/letsencrypt/live/git.51yanmei.com/</code> 定期同步导入。绑到反代条目。</li>
|
||||||
|
</ol>
|
||||||
|
<p class="small">gitea 的 <code>ROOT_URL</code> 已是 <code>https://git.51yanmei.com</code>(外网就这么跑),Host 头一致,反代到 3000 无需改 gitea。</p>
|
||||||
|
|
||||||
|
<h3>W5 · runner 收口到域名 + 删 relay <span class="tag me">我执行(经你确认)</span></h3>
|
||||||
|
<p>act_runner 的标签在<b>首次注册即固定</b>,改配置不重注册无效——所以必须重注册。目标态:<b>一个用户级 mac runner</b>。</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li><b>取用户级注册 token</b>:gitea 网页 头像 → Settings → Actions → Runners → <b>Create new Runner</b>(用户级 reg-token 的 API 端点本版本 404,只能网页取)。<span class="tag you">你提供</span></li>
|
||||||
|
<li><b>注册</b>新 runner:<code>instance = https://git.51yanmei.com</code>(<b>域名,不是 127.0.0.1:13000,不是 IP</b>),labels <code>mac,self-hosted,macos</code>,name <code>mac</code>。</li>
|
||||||
|
<li><b>launchd 收敛</b>:新建 <code>com.yanmei.act-runner</code>(用户级常驻,配置目录 <code>~/.act_runner_mac_config.yaml</code>);<b>卸载</b> <code>com.pangolin.act-runner</code>(mac-pangolin-2) 与 <code>com.jiu.act-runner</code>。</li>
|
||||||
|
<li><b>删 relay</b>:<code>com.jiu.act-runner</code> 的 start 脚本里附带拉起 <code>~/bin/tcp_relay.py</code>——runner 走域名后 relay 无用,随该 launchd 一并退休(脚本、plist、13000 监听全清)。</li>
|
||||||
|
<li><b>gitea 退役</b>旧 runner:删 mac-pangolin-2(id=7) 与 mac-runner(id=3)。jiu 仓的 job 由新用户级 mac runner 接管(标签 <code>mac</code>;如 jiu workflow 用 <code>runs-on: mac</code> 直接兼容,否则补标签)。</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="card warn">
|
||||||
|
<h3>⚠️ relay 到底还要不要(结论)</h3>
|
||||||
|
<p>relay(<code>tcp_relay.py</code> 127.0.0.1:13000 → NAS 192.168.3.200:3000)当初只为绕开 <b>Shadowrocket 拦 act_runner 这个 Go 二进制的直连</b>(Python 进程不被 Shadowrocket 拦)。现在 daily 代理是 pangolin 全局 TUN,<b>TUN 不做按-App 拦截、对 Go 二进制透明</b>,实测域名直连 200 通。<strong>relay 已淘汰,随 W5 删除。</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card ok">
|
||||||
|
<h3>✅ W5 执行记录(2026-09-07)</h3>
|
||||||
|
<p>比预想更简单——<b>无需重注册、无需注册 token</b>:mac-pangolin-2(id=7)<b>本就是用户级、start 脚本本就指向域名</b>,唯一 bug 是 label 错成 <code>nas</code>。根因是当年 Shadowrocket 掐断连接 → act_runner 的 <code>Declare(labels)</code> 从没上报成功。换 pangolin 后连通,做法:</p>
|
||||||
|
<ol>
|
||||||
|
<li>改 <code>~/.act_runner_pangolin2_config.yaml</code> 的 <code>labels: nas:host → mac:host</code>。</li>
|
||||||
|
<li><code>launchctl kickstart -k gui/501/com.pangolin.act-runner</code> 重启 → 日志 <code>declare successfully, labels:[mac]</code>,并<b>立即接走 pangolin 队列 task 2299</b>。</li>
|
||||||
|
<li>退役 jiu:<code>launchctl bootout</code> <code>com.jiu.act-runner</code>+watchdog(连带杀 relay)→ plist 改名 <code>.disabled</code> → repo API 删 id=3(<code>DELETE …/repos/wangjia/jiu/actions/runners/3</code> = 204)。</li>
|
||||||
|
<li>删 relay:<code>~/bin/tcp_relay.py</code> / jiu start 脚本 / jiu config 均 <code>.retired</code>;<code>:13000</code> 已释放。</li>
|
||||||
|
</ol>
|
||||||
|
<p class="small">现状:单一用户级 mac runner(id=7,<code>com.pangolin.act-runner</code>,label <code>mac</code>,域名直连、无 relay),服务全部 wangjia 仓。回写 baize <code>ci_runners</code>(commit 4b7ffe1)。<b>遗留(非阻塞)</b>:label 仍单 <code>mac</code>(可后加 self-hosted/macos);launchd 名仍带 pangolin(拟后续中性化 <code>com.yanmei.act-runner</code>)。在家目前走隧道回环(~1.8s),W3/W4 后转局域网直达。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>3 · 迁移顺序(依赖)</h2>
|
||||||
|
<ol class="steps">
|
||||||
|
<li><b>W5 先行</b>(可独立):runner 改注册到域名 + 删 relay。此刻在家走回环(能跑绿),先把「多头 + relay + 坏标签」这堆乱账清掉。</li>
|
||||||
|
<li><b>W4</b>:NAS 443 反代 + 证书(先证实、再补建)。</li>
|
||||||
|
<li><b>W3</b>:家内分光 DNS + DHCP 指向 NAS。W3 生效后,家内解析即翻私网 IP,runner/所有设备访问 git/nas/win 自动走局域网直达。</li>
|
||||||
|
<li><b>W6</b>:验证(见下)+ 回写 baize(DNS 覆盖落地、relay 退役、runner 台账更新)。</li>
|
||||||
|
</ol>
|
||||||
|
<p class="small">W3 与 W4 是一对:只做 W3 不做 W4,家内会打到 192.168.3.200:443 但无有效 TLS → 失败;两者同批上。</p>
|
||||||
|
|
||||||
|
<h2>4 · 验证清单(W6)</h2>
|
||||||
|
<pre><span class="c"># 在家(局域网)——应直达私网、无隧道回环</span>
|
||||||
|
dig +short git.51yanmei.com <span class="c"># 期望 192.168.3.200</span>
|
||||||
|
curl -sS -o /dev/null -w "%{remote_ip} %{time_total}\n" https://git.51yanmei.com/api/v1/version
|
||||||
|
<span class="c"># 期望 ip=192.168.3.200,time≈ms 级</span>
|
||||||
|
|
||||||
|
<span class="c"># 在外(手机热点/换网)——应回落公网锚点、经隧道</span>
|
||||||
|
dig +short git.51yanmei.com <span class="c"># 期望 182.92.213.171</span>
|
||||||
|
curl ... /api/v1/version <span class="c"># 200,ip=182.92.213.171</span>
|
||||||
|
|
||||||
|
<span class="c"># runner 状态</span>
|
||||||
|
launchctl list | grep act-runner <span class="c"># 只剩 com.yanmei.act-runner</span>
|
||||||
|
lsof -i :13000 <span class="c"># 空(relay 已死)</span>
|
||||||
|
</pre>
|
||||||
|
<p>gitea 网页 Runners:只余 <code>windows</code>(id=8) / <code>mac</code>(新,用户级) / <code>nas</code>(id=2,实例级不动)。触发一次 pangolin <code>build-macos</code> 确认被 <code>mac</code> runner 接走、跑绿。</p>
|
||||||
|
|
||||||
|
<h2>6 · NAS 可行性判定(read-only 实测 2026-09-07)</h2>
|
||||||
|
<p>「涉及 NAS 先判可行性,别做一半发现一般 nas 不支持」——判定结论:<b>这台 DS925+ 不属于「不支持」的那类</b>。它是 Plus x86 机(<code>x86_64</code> / DSM <code>7.3.2</code>),关键能力齐备。</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>能力</th><th>现状</th><th>判定</th></tr>
|
||||||
|
<tr><td>NAS 是否 LAN DHCP 服务器</td><td><code>DhcpServer</code> 套件<b>已装</b>,引擎正是 <b>dnsmasq</b>(<code>dnsmasq-2.x-virtual-dhcpserver</code>),租约段 <code>192.168.3.10–230</code></td><td class="ok-c">✅ DNS 下发掌握在自己手里,无需改路由器</td></tr>
|
||||||
|
<tr><td>DNS Server(分光 A 记录)</td><td>套件未装,但 DS925+/DSM7.3 catalog 支持;另 <code>ContainerManager</code> <b>已装</b>可跑 AdGuard/dnsmasq 容器</td><td class="ok-c">✅ 两条路径(官方套件 / 容器)任选</td></tr>
|
||||||
|
<tr><td>443 TLS 反代</td><td>443 由 DSM nginx 持有;<b>DSM Reverse Proxy 已在用</b>(<code>/usr/syno/etc/www/ReverseProxy.json</code> 有条目);gitea 为 3000 上 native 进程(非容器)</td><td class="ok-c">✅ 加一条 SNI vhost git→localhost:3000 即可</td></tr>
|
||||||
|
<tr><td>证书签发</td><td>DSM 内置 LE 仅 HTTP-01,家里 git 的 :80 不对公网→<b>一键签不了</b>;acme.sh 未装</td><td class="warn-c">⚠ 唯一非默认项:走 <b>acme.sh DNS-01</b>(阿里云 DNS,AK 在 Bitwarden「ali dev」) 或<b>同步 ali 已有 LE 证书</b>(cron scp+DSM 导入,最省)</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="card ok">
|
||||||
|
<p><b>判定:W3 + W4 均可行。</b>唯一 caveat 是证书需 DNS-01 或同步(非 DSM 一键 LE),但两条路都成熟、不阻塞。DHCP 已由 NAS(dnsmasq) 承担、ContainerManager 与 Reverse Proxy 都在——不存在「一般 nas 不支持」的情形。<br><span class="small">未证实项(需 sudo 才能读,不阻塞判定):ReverseProxy.json 具体条目、certificate 目录内容、DHCP 当前下发的 DNS 指向谁。落地时一并处理。</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>7 · 需要你拍板 / 提供的(机器改动,先确认再动)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>项</th><th>属于</th><th>需要你</th></tr>
|
||||||
|
<tr><td>用户级 runner 注册 token(网页 Create new Runner 取)</td><td>W5</td><td>提供 token</td></tr>
|
||||||
|
<tr><td>装群晖 DNS Server + 改路由器 DHCP DNS</td><td>W3</td><td>确认动手(装套件+改网络)</td></tr>
|
||||||
|
<tr><td>NAS DSM 反向代理 + 证书(DNS-01 或同步 ali 证书)</td><td>W4</td><td>确认动手 / 或授权 sudo 让我先证实现状</td></tr>
|
||||||
|
<tr><td>卸载 <code>com.jiu.act-runner</code> / <code>com.pangolin.act-runner</code>、删 relay</td><td>W5</td><td>确认(会短暂影响 jiu CI,直到新 runner 接管)</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="small">相关台账:<code>~/code/baize/data/hosts.yaml</code> <code>ci_runners</code> 段(relay_note、mac runner state)+ <code>domains.yaml</code>(nas/win/git「家内覆盖 待做」)。W3/W5 落地后回写。</p>
|
||||||
|
|
||||||
|
<h2>8 · W3/W4 执行记录(2026-09-07,实际落地与偏差)</h2>
|
||||||
|
<div class="card ok">
|
||||||
|
<h3>✅ 结果:git 在家从隧道回环 ~1.8s → <b>局域网直达 24ms</b></h3>
|
||||||
|
<p><code>curl https://git.51yanmei.com/api/v1/version</code> 从 runner mac 经 pangolin:<code>http=200 ip=192.168.3.200 time=0.025s</code>,证书受信。runner 重启后 <code>declare successfully</code>、无 <code>no such host</code>。</p>
|
||||||
|
</div>
|
||||||
|
<h3>W4(NAS 443 反代)— 按计划</h3>
|
||||||
|
<ul>
|
||||||
|
<li>DSM 反向代理原有 <code>git.51yanmei.com</code> 条目从 <b>HTTP:80 改 HTTPS:443</b>,目的地仍 <code>localhost:3000</code>。</li>
|
||||||
|
<li>证书:把 ali 上 <code>/etc/letsencrypt/live/git.51yanmei.com/</code> 的 LE 证书(cert/privkey/chain)导入 DSM,绑到该反代服务。(我这边拉私钥被安全闸拦,改由用户在自己终端拉取 + DSM GUI 导入。)</li>
|
||||||
|
</ul>
|
||||||
|
<h3>W3(家内分光 DNS)— <span class="tag warn">改了方案</span></h3>
|
||||||
|
<p>原计划「NAS 接管 DHCP、下发 .200 当 DNS」<b>失败</b>——根因:家里网关是 <b>华为 Q2S 路由器,带防私接 DHCP</b>,把 NAS(dnsmasq,绑 :67 正常)发出的 DHCP 应答全丢,客户端永远拿不到 NAS 租约(华为 DHCP 关掉后 mac 也 DISCOVER 不到 NAS→一度 No-IP,靠重开华为 DHCP 兜底恢复)。NAS 不是路由器、改不了这个。</p>
|
||||||
|
<p>改成的可行方案:</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>NAS 装 <b>Synology DNS Server</b>(<code>synopkg install_from_server DNSServer</code>):给 <code>git/nas/win.51yanmei.com</code> 各建<b>单主机主区</b>(只对这三名字权威→私网 IP)+ <code>named.options.user.conf</code> 加 <code>forwarders{223.5.5.5;119.29.29.29}</code> 转发其余。故 <code>pay/jiu/api</code> 等兄弟子域照常公网。</li>
|
||||||
|
<li><b>只给 runner mac 的家里 Wi-Fi 手动指 DNS=<code>192.168.3.200</code></b>(macOS 按网络记,出门用别的网 DNS、不受影响)。别的家里设备不动(它们不需要 git 覆盖)。NAS 的 DhcpServer 已停,华为继续当 DHCP。</li>
|
||||||
|
<li>⚠ 改 DNS 后 mDNSResponder 负缓存旧失败(<code>dig</code> 通但 <code>curl</code> no-such-host)→ <code>sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder</code>;runner 再 <code>launchctl kickstart -k</code> 重启。</li>
|
||||||
|
</ol>
|
||||||
|
<p class="small"><b>已知/待办</b>:① 只有 runner mac 走直达(本就够用,它才是 CI 需要的),非全网分光。② DNS=.200 仅设在家里 Wi-Fi;若 mac 换网上不了网就删这条。③ NAS DhcpServer 已 <code>stop</code>,如需永久别开机自启可在 DSM「DHCP Server」取消勾选。④ 台账真相源:baize <code>domains.yaml</code>『家内分光』note;NAS 详细運維归 <code>~/code/nas</code>。</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p class="small">关联:<a href="private-dest-acl-design.html">私有服务 ACL 设计</a> · <a href="private-dest-acl-plan.html">私有服务 ACL 实现计划</a> · <a href="control-plane-tls-tunnel.html">控制面 TLS(Tunnel 前置)</a> · <a href="index.html">← 文档索引</a></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -44,6 +44,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>设计方案 / Specs</h2>
|
<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">
|
<a class="doc" href="notifications-design.html">
|
||||||
<div class="t">系统通知机制(Spec ③)<span class="tag html">HTML</span></div>
|
<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>
|
<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>
|
||||||
@@ -141,6 +146,11 @@
|
|||||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
|
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-05-cicd.md</code>(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。</div>
|
||||||
<div class="path">docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md</div>
|
<div class="path">docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md</div>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="doc" href="ci-runner-domain-registration-plan.html">
|
||||||
|
<div class="t">CI Runner 走域名注册 + 局域网/外网无感切换 <span class="tag html">HTML</span></div>
|
||||||
|
<div class="d">daily 代理已从 Shadowrocket 换成 pangolin 全局 TUN(对 Go 二进制透明,不再掐 act_runner 直连)→ <b>relay 淘汰</b>,runner 一律注册到域名 <code>https://git.51yanmei.com</code>。git 早在 <code>PANGOLIN_PRIVATE_SPLIT_DOMAINS</code>,靠 <code>dns-system</code> 解析:在家局域网 DNS 覆盖成 192.168.3.200 → LAN 直连规则先命中直达 NAS;在外公网锚点 182.92.213.171 → 私有域名走隧道 → pangolin1 出口 → ali frp → NAS。已就绪(pangolin配置/ali入口/域名实测200);待补 W3 家内分光 DNS(群晖 DNS Server+DHCP)+W4 NAS 443 TLS 反代+W5 runner 收口成一个用户级 mac runner 并删 relay。机制复用私有服务 ACL。</div>
|
||||||
|
<div class="path">docs/ci-runner-domain-registration-plan.html</div>
|
||||||
|
</a>
|
||||||
<a class="doc" href="device-session-management-plan.html">
|
<a class="doc" href="device-session-management-plan.html">
|
||||||
<div class="t">设备 & 会话管理 + 每设备流量归因 实现计划(P1–P6)<span class="tag html">HTML</span></div>
|
<div class="t">设备 & 会话管理 + 每设备流量归因 实现计划(P1–P6)<span class="tag html">HTML</span></div>
|
||||||
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-29-device-session-management.md</code>(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。</div>
|
<div class="d">阅读版;执行真相源为 <code>docs/superpowers/plans/2026-06-29-device-session-management.md</code>(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。</div>
|
||||||
@@ -235,6 +245,11 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h2>排障 / Runbook</h2>
|
<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">
|
<a class="doc" href="connect-latency-urltest.html">
|
||||||
<div class="t">连接页延迟(urltest)· 跨端实现 + 排障 <span class="tag html">HTML</span></div>
|
<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>
|
<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,
|
# cert import, provisioning-profile install by UUID, ExportOptions.plist,
|
||||||
# `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin:
|
# `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin:
|
||||||
# - pangolin's iOS app ships a NEPacketTunnelProvider **app extension**
|
# - pangolin's iOS app ships a NEPacketTunnelProvider **app extension**
|
||||||
# (client/ios/PacketTunnel/, bundle com.pangolin.pangolinVpn.PacketTunnel)
|
# (client/ios/PacketTunnel/, bundle com.yanmeiai.pangolin.PacketTunnel)
|
||||||
# alongside the main app (com.pangolin.pangolinVpn) — jiu has no
|
# alongside the main app (com.yanmeiai.pangolin) — jiu has no
|
||||||
# extension at all, just the one app target. So this script needs TWO
|
# extension at all, just the one app target. So this script needs TWO
|
||||||
# distribution provisioning profiles (app + extension), not one, and the
|
# distribution provisioning profiles (app + extension), not one, and the
|
||||||
# ExportOptions.plist provisioningProfiles dict needs both bundle-id ->
|
# ExportOptions.plist provisioningProfiles dict needs both bundle-id ->
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
# embedded core. This script MUST run `scripts/build-libbox.sh apple ios`
|
# 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
|
# before `flutter build ipa`, or the IPA links no VPN kernel (mirrors the
|
||||||
# equivalent step in compile-android.sh / compile-macos.sh).
|
# 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
|
# secret (unlike jiu's IOS_TEAM_ID secret): it's already public inside
|
||||||
# this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj
|
# this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj
|
||||||
# DEVELOPMENT_TEAM, scripts/local_test.sh SIGN_ID) — not sensitive, no
|
# 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_P12_BASE64 Apple Distribution 证书(.p12)base64
|
||||||
# IOS_DIST_CERT_PASSWORD .p12 导出密码
|
# IOS_DIST_CERT_PASSWORD .p12 导出密码
|
||||||
# IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app,
|
# IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app,
|
||||||
# com.pangolin.pangolinVpn)base64
|
# com.yanmeiai.pangolin)base64
|
||||||
# IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(扩展,
|
# 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
|
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
|
||||||
# App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。
|
# App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。
|
||||||
#
|
#
|
||||||
@@ -83,9 +83,11 @@ fi
|
|||||||
|
|
||||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||||
TEAM_ID="BYL4KQHMTN"
|
# 签名身份单一真相源(../signing.env);迁移账号只改 signing.env。
|
||||||
APP_BUNDLE_ID="com.pangolin.pangolinVpn"
|
. "${BASH_SOURCE[0]%/*}/../signing.env"
|
||||||
EXT_BUNDLE_ID="com.pangolin.pangolinVpn.PacketTunnel"
|
TEAM_ID="$SIGNING_TEAM_ID"
|
||||||
|
APP_BUNDLE_ID="$SIGNING_APP_BUNDLE_ID"
|
||||||
|
EXT_BUNDLE_ID="$SIGNING_EXT_BUNDLE_ID"
|
||||||
|
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db"
|
KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain
|
# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain
|
||||||
# Developer ID import, inside-out codesign, notarytool submit --wait, staple,
|
# Developer ID import, inside-out codesign, notarytool submit --wait, staple,
|
||||||
# ditto zip), but pangolin's macOS app is heavier than jiu's plain window:
|
# 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/)
|
# `.systemextension` bundle under Contents/Library/SystemExtensions/)
|
||||||
# that needs ITS OWN Developer ID provisioning profile + entitlements,
|
# that needs ITS OWN Developer ID provisioning profile + entitlements,
|
||||||
# signed separately, inside-out, before the outer app is signed — see
|
# 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)"
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
|
||||||
|
# 签名身份单一真相源(../signing.env);迁移账号只改 signing.env。
|
||||||
|
. "${BASH_SOURCE[0]%/*}/../signing.env"
|
||||||
SIGN_ID_PREFIX="Developer ID Application"
|
SIGN_ID_PREFIX="Developer ID Application"
|
||||||
TEAM_ID="BYL4KQHMTN"
|
TEAM_ID="$SIGNING_TEAM_ID"
|
||||||
APP_BUNDLE_ID="com.pangolin.pangolin"
|
APP_BUNDLE_ID="$SIGNING_APP_BUNDLE_ID"
|
||||||
SYSEXT_BUNDLE_ID="com.pangolin.pangolin.PacketTunnel"
|
SYSEXT_BUNDLE_ID="$SIGNING_EXT_BUNDLE_ID"
|
||||||
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
|
APP_GROUP="$SIGNING_MACOS_APP_GROUP"
|
||||||
|
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db"
|
KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db"
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
+15
-11
@@ -23,12 +23,16 @@
|
|||||||
# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ...
|
# 用法: scripts/local_test.sh all | scripts/local_test.sh ipad ...
|
||||||
set -euo pipefail
|
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"
|
||||||
|
|
||||||
# ─────────── 配置(按需改)───────────
|
# ─────────── 配置(按需改)───────────
|
||||||
# 控制面基址。默认生产(Cloudflare Tunnel);8080 已收口只绑 127.0.0.1,公网直连
|
# 控制面基址。默认生产(Cloudflare Tunnel);8080 已收口只绑 127.0.0.1,公网直连
|
||||||
# 不再可用——联调自建控制面时用 env 显式覆盖:
|
# 不再可用——联调自建控制面时用 env 显式覆盖:
|
||||||
# API_URL=http://<联调地址>:8080 scripts/local_test.sh macos
|
# API_URL=http://<联调地址>:8080 scripts/local_test.sh macos
|
||||||
API_URL="${API_URL:-https://api.yanmeiai.com}"
|
API_URL="${API_URL:-https://api.yanmeiai.com}"
|
||||||
SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)"
|
SIGN_ID="$SIGNING_DEVELOPER_ID" # 派生自 signing.env
|
||||||
APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
|
APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名
|
||||||
SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
|
SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名
|
||||||
TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
|
TEST_EMAIL="wang880812@gmail.com" # 联调测试账号
|
||||||
@@ -40,15 +44,15 @@ IP_SVC="https://api.ipify.org" # 返回纯文本公网 IP
|
|||||||
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
|
# 注:DevID profile 已含 system-extension.install + NE(-systemextension 变体);
|
||||||
# app/sysext entitlements 与之对齐(见 write_entitlements)。
|
# 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 铁律)。
|
# macOS App Group —— 必须是原生格式 <TeamID>.<name>,不是 iOS 的 group. 前缀(CLAUDE.md 铁律)。
|
||||||
# 主 app 与 sysext 必须落在**同一个**;sysext 的 NEMachServiceName 必须以它为前缀。
|
# 主 app 与 sysext 必须落在**同一个**;sysext 的 NEMachServiceName 必须以它为前缀。
|
||||||
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
|
APP_GROUP="$SIGNING_MACOS_APP_GROUP"
|
||||||
|
|
||||||
# ── iOS/iPad 真机分发(ad-hoc,**公司**证书)────────────────────────────────
|
# ── 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]}"
|
SRC="${BASH_SOURCE[0]}"
|
||||||
@@ -57,9 +61,9 @@ cd "$DIR/.."; REPO_ROOT="$PWD"
|
|||||||
CLIENT="$REPO_ROOT/client"
|
CLIENT="$REPO_ROOT/client"
|
||||||
PBXPROJ="$CLIENT/macos/Runner.xcodeproj/project.pbxproj"
|
PBXPROJ="$CLIENT/macos/Runner.xcodeproj/project.pbxproj"
|
||||||
APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app"
|
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。
|
# 不是短名 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"
|
LIBFW="$SE/Contents/Frameworks/Libbox.framework"
|
||||||
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||||
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
|
WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK"
|
||||||
@@ -90,7 +94,7 @@ write_entitlements(){
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0"><dict>
|
<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.team-identifier</key><string>${TEAM_ID}</string>
|
||||||
<key>com.apple.developer.system-extension.install</key><true/>
|
<key>com.apple.developer.system-extension.install</key><true/>
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
@@ -101,14 +105,14 @@ write_entitlements(){
|
|||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array><string>${APP_GROUP}</string></array>
|
<array><string>${APP_GROUP}</string></array>
|
||||||
<key>keychain-access-groups</key>
|
<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>
|
</dict></plist>
|
||||||
PLIST
|
PLIST
|
||||||
cat > "$WORK/sysext.entitlements" <<PLIST
|
cat > "$WORK/sysext.entitlements" <<PLIST
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0"><dict>
|
<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.team-identifier</key><string>${TEAM_ID}</string>
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
<array><string>packet-tunnel-provider-systemextension</string></array>
|
<array><string>packet-tunnel-provider-systemextension</string></array>
|
||||||
@@ -317,7 +321,7 @@ cmd_ks_status(){
|
|||||||
|
|
||||||
# ─────────── iOS / iPad 真机安装 ───────────
|
# ─────────── iOS / iPad 真机安装 ───────────
|
||||||
# 与 macOS 流程独立:iOS 用 Network Extension(非 System Extension),不需要 Developer ID
|
# 与 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 直接装。
|
# 签名会把已连接设备注册进描述文件),再 flutter install 走 USB 直接装。
|
||||||
|
|
||||||
# 列出已连接的物理 iOS 设备(输出 "id<TAB>name" 每行一台;模拟器/无线设备排除)。
|
# 列出已连接的物理 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})"
|
||||||
@@ -379,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)
|
paySystem, paySecret, 5*time.Minute, 15*time.Minute)
|
||||||
payWebhook.SetRewarder(rewardSvc)
|
payWebhook.SetRewarder(rewardSvc)
|
||||||
payWebhook.SetNoticer(noticesStore)
|
payWebhook.SetNoticer(noticesStore)
|
||||||
|
// reconcile-on-read:查单发现 pay 网关已 paid 但本地未开通时,复用 webhook 的
|
||||||
|
// 幂等 settle 就地开通,不干等 webhook 送达(webhook 仍作兜底)。
|
||||||
|
payHandler.SetSettler(payWebhook)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载")
|
log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// paidCredentialTTL is the default connect credential lifetime for paid users.
|
// 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).
|
// freeCredentialTTL is the per-minute TTL for free users (per remaining minutes).
|
||||||
freeMinuteTTL = time.Minute
|
freeMinuteTTL = time.Minute
|
||||||
// deviceStaleWindow: connect 设备上限 backstop 只数近此窗口活跃的设备(与 devices 侧一致)。
|
// 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
|
devicesByDpUUID map[string][2]int64
|
||||||
deviceUsageAccum []mockDeviceUsageEntry
|
deviceUsageAccum []mockDeviceUsageEntry
|
||||||
lastSeenTouched []int64
|
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 {
|
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) {
|
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 {
|
func (m *mockNodeStore) PersistCredential(_ context.Context, _ int64, _ *agentv1.Credential, _ time.Time) error {
|
||||||
return nil
|
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 {
|
func (m *mockNodeStore) DeleteCredential(_ context.Context, _ int64, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1050,3 +1074,81 @@ func TestReportUsage_PerDevice(t *testing.T) {
|
|||||||
t.Errorf("device usage wrong: %+v", dev[0])
|
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 }
|
type acctAgg struct{ bytesUp, bytesDown, minutes int64 }
|
||||||
byUser := make(map[int64]*acctAgg)
|
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 {
|
for _, entry := range req.Entries {
|
||||||
if entry.DpUUID == "" {
|
if entry.DpUUID == "" {
|
||||||
continue
|
continue
|
||||||
@@ -310,6 +329,21 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a
|
|||||||
if !found {
|
if !found {
|
||||||
continue
|
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 = 墙上时钟去重).
|
// Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重).
|
||||||
a := byUser[userID]
|
a := byUser[userID]
|
||||||
if a == nil {
|
if a == nil {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ import (
|
|||||||
// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。
|
// connect 据此拒连并提示重新登录(重新注册设备),不再回退账户级 dp_uuid。
|
||||||
var ErrDeviceNotFound = errors.New("device not registered")
|
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.
|
// NodeRow holds a node's essential fields from the nodes table.
|
||||||
type NodeRow struct {
|
type NodeRow struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -72,6 +79,13 @@ type NodeStore interface {
|
|||||||
// PersistCredential upserts a credential row in connect_credentials.
|
// PersistCredential upserts a credential row in connect_credentials.
|
||||||
PersistCredential(ctx context.Context, nodeID int64, cred *agentv1.Credential, expiresAt time.Time) error
|
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 removes the credential for (nodeID, dpUUID).
|
||||||
DeleteCredential(ctx context.Context, nodeID int64, dpUUID string) error
|
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
|
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.
|
// CredentialLocation identifies a node holding a given dp_uuid credential.
|
||||||
type CredentialLocation struct {
|
type CredentialLocation struct {
|
||||||
NodeID int64
|
NodeID int64
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package pay
|
package pay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -15,18 +16,34 @@ import (
|
|||||||
"github.com/wangjia/pangolin/server/internal/auth"
|
"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 侧,
|
// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧,
|
||||||
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
|
// 客户端只传 sku+method+端型 metadata,永远不传金额)。
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
client *Client
|
client *Client
|
||||||
store *Store
|
store *Store
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
settler orderSettler // 可选:reconcile-on-read 开通入口(见 SetSettler),nil 则退回等 webhook
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
|
func NewHandler(client *Client, store *Store, db *sql.DB) *Handler {
|
||||||
return &Handler{client: client, store: store, db: db}
|
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)。
|
// allowedMetadataKeys 与 pay gateway.go 白名单一致(is_mobile/render)。
|
||||||
var allowedMetadataKeys = map[string]bool{"is_mobile": true, "render": true}
|
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)
|
writePayErr(w, err)
|
||||||
return
|
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"}
|
resp := orderStatusResponse{orderView: orderViewFromRow(row), PayStatus: st.Status, Activated: row.Status == "paid"}
|
||||||
if row.SubID.Valid {
|
if row.SubID.Valid {
|
||||||
if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user