Compare commits

..

19 Commits

Author SHA1 Message Date
wangjia b072cf448d fix(client/android): 强制 androidx.core/browser 兼容 AGP 8.6(url_launcher 拉入 1.17/1.9 要 AGP 8.9.1)
Deploy Client / build-android (push) Successful in 1m38s
Deploy Client / build-macos (push) Failing after 2s
Deploy Client / build-ios (push) Successful in 3s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy (push) Has been cancelled
client-v1.0.55 build-android 挂在 checkReleaseAarMetadata:url_launcher_android 拉入的
androidx.core:1.17 / androidx.browser:1.9 要求 AGP 8.9.1+,本项目 AGP 8.6。force 到
1.13.1/1.8.0(打开 URL 用不到新 API)。本地 apk 构建已验证通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:25:04 +08:00
wangjia 4e8d928350 feat(ci): macOS + iOS 客户端 CI 脚本(照 jiu + 系统扩展)+ 自动更新 manifest 推送接线
Deploy Client / build-android (push) Failing after 1m16s
Deploy Client / build-macos (push) Failing after 3s
Deploy Client / build-ios (push) Successful in 2s
Deploy Client / build-windows (push) Has been cancelled
Deploy Client / release-deploy (push) Has been cancelled
compile-macos.sh(Developer ID 签名+公证+staple, 2 描述文件+libbox 重建+CFBundleVersion 递增,
早期 fail-fast 缺 secret 不浪费构建)、compile-ios.sh(TestFlight, 缺 secret 优雅跳过)。
deploy-client.yml 加 build-macos/build-ios(runs-on mac, continue-on-error+release-deploy 不 needs
它们 → 不拖挂 android/windows)。release-client.sh 兼容自动更新 manifest 推送 + macos zip。
+ url_launcher 的 macos/windows 生成插件注册。代码就绪, 待 Apple secret 才能真跑 macOS/iOS。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:20:00 +08:00
wangjia 6277e86d0f feat(client): SSO 用户中心按钮 + 自动更新检查(镜像 jiu)
Deploy Server / deploy-server (push) Successful in 2m56s
SSO: web_launch.openWebUserCenter → POST /v1/auth/web-ticket → 打开 app.yanmeiai.com/sso?t=…;
设置页加「用户中心(网页)」入口。自动更新: update_provider 查 /version 语义比对 + update_dialog
「发现新版本」;设置页「检查更新」由空 onTap 接上。加 url_launcher(原缺)。flutter analyze 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:13:50 +08:00
wangjia 4baf24f6f8 feat(server): 自动更新 /version 清单端点(镜像 jiu)
GET /version(公开)读 /etc/pangolin/version.yaml(每请求重载,免重启);缺省回退。
download_urls 接 /downloads。deploy.sh 装清单(存在则不覆盖, 保住线上 bump)。含 4 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:12:46 +08:00
wangjia ec0942e1e7 feat(usercenter): SSO /sso 落地页(拿票兑会话→自动登录)
Deploy Server / deploy-server (push) Successful in 2m47s
App 打开 app.yanmeiai.com/sso?t=<ticket>&redirect=<路径>;本页 exchangeWebTicket→setSession
(与正常登录同路径)→跳白名单相对路径。镜像 jiu sso.njk。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-07 00:02:48 +08:00
wangjia 1e13f35219 feat(server): SSO 换票端点(App→Web 免登握手)
/v1/auth/web-ticket(需登录):签发一次性票据(crypto/rand 32B, Redis GETDEL 单用, 60s)。
/v1/auth/web-ticket/exchange(公开):票换与 /auth/login 同款 token pair。镜像 jiu。含 6 测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:54:15 +08:00
wangjia e4de308ba4 fix(usercenter): 首屏直接渲染登录页(修"空白")+ html suppressHydrationWarning
静态导出下 !ready 原来渲染空白背景 div,慢网络首屏看着是空的。改为 !ready||!authed
一律渲染 Login(静态无会话默认态),SSG 首屏即登录表单。html 加 suppressHydrationWarning
消除主题 data-theme 的 hydration 警告(#418)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:40:24 +08:00
wangjia 97caae95b8 feat(web): 官网 Windows 下载按钮上线(client CI 已产出 exe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 23:12:57 +08:00
wangjia 4561ee4cc3 fix(ci): fetch-desktop-bin curl 加固(windows 外网 GFW 抖动)
Deploy Client / build-android (push) Successful in 1m58s
Deploy Client / build-windows (push) Successful in 6m51s
Deploy Client / release-deploy (push) Successful in 1m13s
wintun.net / github 下载偶发超时(curl 28)。retry 3→8、加 --retry-all-errors
+ --retry-connrefused + --connect-timeout 20,拉长重试窗口以命中可达时刻。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:56:20 +08:00
wangjia a53bbe743e fix(ci): release-deploy 合并为单次 download-artifact + flatten
Deploy Client / build-android (push) Successful in 1m33s
Deploy Client / build-windows (push) Failing after 8m7s
Deploy Client / release-deploy (push) Has been skipped
首次 windows release-deploy 失败:两次 download-artifact 复用致 act 对只读缓存
action(pack idx 444)EACCES。改一次性下全部 artifact(dist-raw/)再 flatten 到 dist/。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:44:17 +08:00
wangjia 7b1f49c182 fix(ci): compile-windows 传 ISCC 用 Windows 路径(cygpath)
Deploy Client / build-android (push) Successful in 1m38s
Deploy Client / build-windows (push) Successful in 8m29s
Deploy Client / release-deploy (push) Failing after 6s
首个 Windows CI 失败:ISCC 'Unknown option: /c/pangolin-build/.../pangolin.iss'——
ISCC 是原生 Windows 程序,MSYS /c/... 路径被当成选项。用 cygpath 转 C:\... 再传。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:31:40 +08:00
wangjia 8c8486f9af fix(ci): deploy-client 用 env 传 REF_NAME(防 Actions 注入)+ job.status 同法
Deploy Client / build-android (push) Successful in 1m38s
Deploy Client / build-windows (push) Failing after 6m27s
Deploy Client / release-deploy (push) Has been skipped
安全审查:run: 里内联 ${{ gitea.ref_name }} 有注入风险(恶意 tag 名);改经 env REF_NAME
+ 引用 "$REF_NAME"(同 deploy-server.yml 的 TAG 做法)。notify 的 job.status 同法。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:22:00 +08:00
wangjia 7d0f4c8065 ci: 恢复 build-windows job(windows-runner 已提级 user 级)
Deploy Client / build-windows (push) Failing after 20s
Deploy Client / build-android (push) Successful in 1m40s
Deploy Client / release-deploy (push) Has been skipped
runs-on: windows;release-deploy needs [build-android, build-windows] + 下载 windows artifact。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 22:15:06 +08:00
wangjia 3d521973ab feat(server): CORS 中间件(Web 用户中心跨域调 /v1/*)
Deploy Server / deploy-server (push) Successful in 2m56s
usercenter(app.yanmeiai.com,浏览器)跨域调控制面 API 会被浏览器拦(无 CORS 头)。
加白名单 CORS 中间件(CORS_ORIGINS,缺省 app.yanmeiai.com + pages.dev),应答 OPTIONS
预检。原生端非浏览器不受影响。Bearer 认证故不需 Allow-Credentials。含 3 项测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 21:52:33 +08:00
wangjia cb8e4bddd8 feat(web): 官网恢复登录入口 → usercenter(app.yanmeiai.com)
usercenter(web/usercenter, Next 静态导出)已部署到 CF Pages(pangolin-usercenter,
自定义域 app.yanmeiai.com)。site.ts 加 usercenter 字段;Header 恢复 .linklogin(t.login)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 21:48:59 +08:00
wangjia 4cfe4d0ccb chore(ci): deploy-client 本轮 Android-only(暂无 windows runner, 移除 build-windows)
Deploy Client / build-android (push) Successful in 2m9s
Deploy Client / release-deploy (push) Successful in 51s
release-deploy needs 改 [build-android];windows job/下载步骤 git 历史 2698116 可恢复。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 20:51:18 +08:00
wangjia 366695ffd6 chore(web): Windows 下载按钮暂改「即将推出」(其 CI 未产出 exe;本轮仅 Android 真下载)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 19:58:18 +08:00
wangjia 2e631ed3b5 feat(server+web): 客户端下载闭环——pangolin-server /downloads + 官网下载链接
Deploy Server / deploy-server (push) Successful in 2m59s
- server: /downloads/* 公开静态服务(NewDownloadsHandler,DOWNLOADS_DIR 默认
  /var/lib/pangolin/downloads),防目录穿越/不列目录/缺目录不崩,Cache-Control:
  no-cache 保证 CI 覆盖后永远最新。挂在 /healthz 同级(免鉴权)。含 4 项测试。
- deploy.sh: install -d 下载目录 + server.env 注入 DOWNLOADS_DIR。
- web: site.ts 增 downloads.{android,windows}(→ https://api.yanmeiai.com/downloads/*);
  Download.astro 接上 Android/Windows 按钮,iOS/macOS/Linux 改「即将推出」禁用态。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 19:48:57 +08:00
wangjia 2698116696 feat(ci): 客户端 CI 脚手架(Android+Windows, 照 jiu)——起草+Android 本地验证
- compile-android.sh: 建 libbox.aar + split-per-abi 取 arm64-v8a(~82MB, 避 232MB
  universal) + release 签名(RELEASE_KEYSTORE/KEY_PASSWORD)。本地已验证构建通过、
  libbox.so 打进 APK。
- compile-windows.sh: 复用 client/windows/installer/pangolin.iss(Inno Setup),不签名。
- release-client.sh / deploy-client.sh: 复用 pangolin lib-forgejo/lib-ssh,传 Gitea
  release + 部署最新到 pangolin1:/var/lib/pangolin/downloads(仅留最新)。
- deploy-client.yml: client-v* tag → build-android(mac)+build-windows(windows) → release-deploy(mac)。
- build.gradle: 补 release 签名 config(读 key.properties, 缺则回退 debug)。
- 待确认: keystore alias/密码(见脚本 TODO);runner 需提到 user 级;服务器 /downloads + 官网链接下一步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-06 19:40:34 +08:00
48 changed files with 2715 additions and 17 deletions
+7 -1
View File
@@ -55,7 +55,13 @@ jobs:
/mnt/scripts/ci/release-server.sh \
/mnt/scripts/ci/deploy-server.sh \
/mnt/scripts/ci/test.sh \
/mnt/scripts/ci/backup-db.sh
/mnt/scripts/ci/backup-db.sh \
/mnt/scripts/ci/compile-android.sh \
/mnt/scripts/ci/compile-windows.sh \
/mnt/scripts/ci/compile-macos.sh \
/mnt/scripts/ci/compile-ios.sh \
/mnt/scripts/ci/release-client.sh \
/mnt/scripts/ci/deploy-client.sh
- name: shellcheck CI 脚本(ci/)
run: |
+202
View File
@@ -0,0 +1,202 @@
name: Deploy Client
# Mirrors ~/code/jiu/.gitea/workflows/deploy-client.yml's tag→build→release→
# deploy shape. Android + Windows are required (release-deploy `needs` them);
# macOS + iOS (Phase 3, see docs/superpowers/plans/2026-07-05-cicd.md) are
# intentionally DECOUPLED — see the "why build-macos/build-ios don't block"
# note above the build-macos job below for the mechanism and rationale.
#
# TODO(controller) — RUNNER AVAILABILITY: per docs/ci-runner.md, pangolin
# currently has exactly ONE registered Gitea Actions runner
# ("mac-pangolin-2", label `nas:host`). Neither `runs-on: mac` nor
# `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
# mac-pangolin-2 with an additional `mac` label (it's already a mac host —
# cheapest fix for build-android/release-deploy) and separately stand up +
# register an actual Windows host runner labeled `windows` for build-windows
# (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:
push:
tags:
- 'client-v[0-9]*.[0-9]*.[0-9]*'
workflow_dispatch:
concurrency:
group: deploy-client
cancel-in-progress: false
jobs:
build-android:
runs-on: mac
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compile (Android APK)
env:
RELEASE_KEYSTORE: ${{ secrets.RELEASE_KEYSTORE }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-android.sh "$REF_NAME"
- name: Upload android artifact
uses: actions/upload-artifact@v3
with:
name: android
path: dist/
build-windows:
runs-on: windows
env:
GOPROXY: https://goproxy.cn,direct
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compile (Windows installer)
shell: bash
env:
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-windows.sh "$REF_NAME"
- name: Upload windows artifact
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/
# Why build-macos/build-ios don't block the working android+windows pipeline
# when Apple secrets aren't configured yet (they aren't, as of this writing):
# 1. release-deploy's `needs:` below is [build-android, build-windows]
# ONLY — macOS/iOS are NOT dependencies, so release-deploy never waits
# on them and never fails because of them.
# 2. `continue-on-error: true` on both jobs keeps the overall workflow-run
# status green even while compile-macos.sh hard-fails (Apple Developer
# ID / notary secrets absent — see its fail-fast checks) — that failure
# is real signal ("go configure the secrets"), but it shouldn't read as
# "the release pipeline is broken" when android+windows shipped fine.
# 3. compile-macos.sh's own default behavior is to hard-fail (not skip)
# when its secrets are missing (macOS distribution must never ship
# unsigned/unnotarized — see its header comment); compile-ios.sh's
# default is to skip gracefully (exit 0) since an unconfigured iOS
# account is a normal "not set up yet" state, not a defect. Either way
# the job produces no dist/pangolin-macos-x64.zip, and
# release-deploy's "Download all artifacts" step (no `name:` filter)
# simply picks up whatever artifacts DO exist — an absent "macos"
# artifact is not an error there.
build-macos:
runs-on: mac
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compile (macOS System Extension app)
env:
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }}
MACOS_APP_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_APP_PROVISION_PROFILE_BASE64 }}
MACOS_SYSEXT_PROVISION_PROFILE_BASE64: ${{ secrets.MACOS_SYSEXT_PROVISION_PROFILE_BASE64 }}
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-macos.sh "$REF_NAME"
- name: Upload macos artifact
uses: actions/upload-artifact@v3
with:
name: macos
path: dist/
build-ios:
runs-on: mac
continue-on-error: true
env:
PUB_HOSTED_URL: https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compile & upload to TestFlight (iOS)
env:
IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
IOS_APP_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_PROVISIONING_PROFILE_BASE64 }}
IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 }}
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/compile-ios.sh "$REF_NAME"
# No artifact upload — compile-ios.sh uploads straight to TestFlight via
# altool (matches jiu); nothing is produced under dist/ for this job.
release-deploy:
needs: [build-android, build-windows]
runs-on: mac
steps:
- name: Checkout
uses: actions/checkout@v4
# 一次性下所有 artifact(不带 name),避免同 job 内两次复用 download-artifact
# action → act 对其只读缓存 git 仓库做二次操作时 EACCES(pack idx 444)。
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: dist-raw/
- name: Flatten artifacts into dist/
shell: bash
run: |
mkdir -p dist
find dist-raw -type f -exec cp {} dist/ \;
echo "dist/ 内容:"; ls -la dist/
- name: Release → Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
# Needed here (not just in the "Deploy" step below) because
# release-client.sh now also SSH-pushes the auto-update manifest
# (version.yaml) straight to pangolin1's /etc/pangolin/ — see the
# header comment in scripts/ci/release-client.sh.
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/release-client.sh "$REF_NAME"
- name: Deploy → pangolin1 (downloads/)
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
REF_NAME: ${{ gitea.ref_name }}
run: bash scripts/ci/deploy-client.sh "$REF_NAME"
- name: Notify
if: always()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
REF_NAME: ${{ gitea.ref_name }}
JOB_STATUS: ${{ job.status }}
run: |
. scripts/ci/notify.sh
if [ "$JOB_STATUS" = "success" ]; then
notify_ok "client $REF_NAME released + deployed"
else
notify_fail "client $REF_NAME pipeline failed"
fi
+8
View File
@@ -56,6 +56,14 @@ client/android/app/src/main/java/
client/ios/Flutter/ephemeral/
client/pubspec.lock
# Android release 签名材料(本地或 CI 落盘,绝不入库)
client/android/key.properties
client/android/*.jks
client/android/*.keystore
# CI 客户端产物暂存目录(scripts/ci/compile-{android,windows}.sh 输出,构建期生成)
/dist/
# Claude worktrees(临时工作目录,本地专用)
.claude/worktrees/
+2 -2
View File
@@ -140,7 +140,7 @@ fi
# ── 下载二进制压缩包 ──────────────────────────────────────────────────────────
printf '==> 下载 %s…\n' "${ARCHIVE_FILE}"
curl -fSL --retry 3 --retry-delay 2 \
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
-o "${ARCHIVE_CACHE}" \
"${ARCHIVE_URL}"
@@ -197,7 +197,7 @@ if [[ "${TARGET_OS}" == "windows" ]]; then
if [[ "${FORCE}" == false && -f "${WINTUN_OUT}" ]]; then
printf '✓ wintun.dll 已存在,跳过(传 --force 重新下载)\n'
else
curl -fSL --retry 3 --retry-delay 2 \
curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \
-o "${WINTUN_ZIP_CACHE}" \
"${WINTUN_URL}"
+36 -1
View File
@@ -22,6 +22,19 @@ if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
// ── release 签名(key.properties 不入库,由本地或 CI 生成)────────────────
// 缺失时回退 debug 签名,保证本地 `flutter run --release`/无密钥环境仍可构建。
// CI 侧由 scripts/ci/compile-android.sh 落盘:client/android/key.properties
// (storeFile/storePassword/keyAlias/keyPassword)。
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
def hasReleaseSigning = keystorePropertiesFile.exists()
if (hasReleaseSigning) {
keystorePropertiesFile.withReader('UTF-8') { reader ->
keystoreProperties.load(reader)
}
}
android {
namespace "com.pangolin.pangolin_vpn"
compileSdkVersion flutter.compileSdkVersion
@@ -49,9 +62,20 @@ android {
versionName flutterVersionName
}
signingConfigs {
if (hasReleaseSigning) {
release {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
release {
signingConfig signingConfigs.debug
signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug
}
}
}
@@ -60,6 +84,17 @@ flutter {
source '../..'
}
// url_launcher(6.3.x)的 android 实现拉入 androidx.core:1.17 / androidx.browser:1.9,
// 二者要求 AGP 8.9.1+,而本项目工具链是 AGP 8.6.0(升级 AGP 会牵动 libbox 原生构建,风险大)。
// 打开 URL 用不到这些新版 API → 强制降到兼容 AGP 8.6 的版本,构建通过、功能不受影响。
configurations.all {
resolutionStrategy {
force 'androidx.core:core:1.13.1'
force 'androidx.core:core-ktx:1.13.1'
force 'androidx.browser:browser:1.8.0'
}
}
dependencies {
// ── sing-box libboxgomobile AAR)──────────────────────────────
// 产物(gitignore,清掉/换 worktree 都要重建)二选一,均产 io.nekohasekai.libbox 包:
+8
View File
@@ -148,6 +148,14 @@ abstract class AppText {
String get killSwitch;
String get killSwitchSub;
String get checkUpdate;
String get webUserCenter; // 「用户中心(网页)」入口(App→Web SSO 免登)
// 更新检查(设置页「检查更新」手动触发)
String updateAvailableTitle(String version); // 「发现新版本 vX.Y.Z」
String get updateNotesFallback; // 无 release_notes 时的兜底文案
String get updateLater;
String get updateDownload;
String get updateUpToDate; // 检查后:已是最新版本
String get updateCheckFailed; // 检查失败(网络异常)
// ── 套餐选择 ──
String get choosePlan;
+14
View File
@@ -235,6 +235,20 @@ class StringsEn extends AppText {
String get killSwitchSub => 'Block traffic if the link drops';
@override
String get checkUpdate => 'Check for updates';
@override
String get webUserCenter => 'User Center (Web)';
@override
String updateAvailableTitle(String version) => 'New version v$version available';
@override
String get updateNotesFallback => 'This update includes fixes and stability improvements.';
@override
String get updateLater => 'Later';
@override
String get updateDownload => 'Download update';
@override
String get updateUpToDate => 'You are up to date';
@override
String get updateCheckFailed => 'Update check failed, try again later';
@override
String get choosePlan => 'Choose plan';
+14
View File
@@ -234,6 +234,20 @@ class StringsZh extends AppText {
String get killSwitchSub => '断线时阻断网络,防止泄露';
@override
String get checkUpdate => '检查更新';
@override
String get webUserCenter => '用户中心(网页)';
@override
String updateAvailableTitle(String version) => '发现新版本 v$version';
@override
String get updateNotesFallback => '本次更新修复了一些问题并提升了稳定性,建议尽快更新。';
@override
String get updateLater => '稍后';
@override
String get updateDownload => '下载更新';
@override
String get updateUpToDate => '已是最新版本';
@override
String get updateCheckFailed => '检查更新失败,请稍后重试';
@override
String get choosePlan => '选择套餐';
+22 -1
View File
@@ -9,9 +9,29 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../services/web_launch.dart';
import '../state/app_providers.dart';
import '../state/settings_provider.dart';
import '../state/update_provider.dart';
import '../widgets/pangolin_icons.dart';
import '../widgets/pangolin_toast.dart';
import '../widgets/update_dialog.dart';
/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示,
/// 有更新则弹 [showUpdateDialog]。
Future<void> _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async {
final info = await ref.read(updateCheckerProvider).check();
if (!context.mounted) return;
if (info == null) {
showPangolinToast(context, t.updateCheckFailed);
return;
}
if (!info.hasUpdate) {
showPangolinToast(context, t.updateUpToDate);
return;
}
await showUpdateDialog(context, t, info);
}
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@@ -59,7 +79,8 @@ class SettingsPage extends ConsumerWidget {
right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light),
),
_Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}),
_Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)),
_Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)),
_Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))),
]),
]),
+29
View File
@@ -0,0 +1,29 @@
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
//
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../state/account_providers.dart';
/// 用户中心(网页版)基址。
const String kWebUserCenterBaseUrl = 'https://app.yanmeiai.com';
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
try {
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
final ticket = resp['ticket'] as String?;
if (ticket != null && ticket.isNotEmpty) {
target = Uri.parse(
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
}
} catch (_) {
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
}
await launchUrl(target, mode: LaunchMode.externalApplication);
}
+105
View File
@@ -0,0 +1,105 @@
// update_provider.dart — 应用更新检查(轻量版,无后台轮询)。
//
// 手动触发(设置页「检查更新」)向控制面 GET $kApiBaseUrl/version 拉取最新版本信息,
// 与本地版本(package_info_plus)做语义比较;有更新则由调用方(settings_page)弹窗
// 展示(见 widgets/update_dialog.dart)。检查失败静默返回 null,不影响主流程。
//
// 对照 jiu client/lib/providers/update_provider.dart,去掉了定时轮询 Timer 与
// dismiss 状态——按需求这里只做「手动检查」,启动期自动检查留作后续 TODO。
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import '../services/api_config.dart';
/// 一次更新检查的结果。
class AppUpdateInfo {
const AppUpdateInfo({
required this.latestVersion,
required this.buildNumber,
required this.forceUpdate,
required this.releaseNotes,
required this.downloadUrls,
required this.hasUpdate,
});
final String latestVersion;
final int buildNumber;
final bool forceUpdate;
final String releaseNotes;
final Map<String, String> downloadUrls;
final bool hasUpdate;
}
/// 无状态更新检查服务,供设置页「检查更新」按钮直接调用。
final updateCheckerProvider = Provider<UpdateChecker>((ref) => const UpdateChecker());
class UpdateChecker {
const UpdateChecker();
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
Future<AppUpdateInfo?> check() async {
try {
final resp = await http
.get(Uri.parse('$kApiBaseUrl/version'))
.timeout(const Duration(seconds: 8));
if (resp.statusCode != 200) return null;
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final latestVersion = data['version'] as String? ?? '0.0.0';
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
final forceUpdate = data['force_update'] as bool? ?? false;
final releaseNotes = data['release_notes'] as String? ?? '';
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
final info = await PackageInfo.fromPlatform();
final hasUpdate = _isNewer(latestVersion, info.version);
return AppUpdateInfo(
latestVersion: latestVersion,
buildNumber: buildNumber,
forceUpdate: forceUpdate,
releaseNotes: releaseNotes,
downloadUrls: downloadUrls,
hasUpdate: hasUpdate,
);
} catch (_) {
return null;
}
}
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
bool _isNewer(String latest, String current) {
final l = _parse(latest);
final c = _parse(current);
for (var i = 0; i < 3; i++) {
if (l[i] > c[i]) return true;
if (l[i] < c[i]) return false;
}
return false;
}
List<int> _parse(String v) {
final parts = v.split('.').map((s) {
final m = RegExp(r'^\d+').firstMatch(s);
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
while (parts.length < 3) {
parts.add(0);
}
return parts;
}
}
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
if (Platform.isWindows) return downloadUrls['windows'];
if (Platform.isIOS) return downloadUrls['ios'];
if (Platform.isAndroid) return downloadUrls['android'];
return downloadUrls['web'];
}
+81
View File
@@ -0,0 +1,81 @@
// update_dialog.dart — 「发现新版本」更新提示弹窗。
//
// 只做「展示 + 引导下载」:点「下载更新」用 url_launcher 打开对应平台的下载直链
// (浏览器下载),不做应用内静默安装/自动重启。force_update=true 时不可关闭
// (无「稍后」按钮、点遮罩/返回也关不掉)。
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../l10n/app_text.dart';
import '../pangolin_theme.dart';
import '../state/update_provider.dart';
import 'pangolin_icons.dart';
/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。
Future<void> showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) {
return showDialog<void>(
context: context,
barrierDismissible: !info.forceUpdate,
builder: (ctx) => PopScope(
canPop: !info.forceUpdate,
child: _UpdateDialog(t: t, info: info),
),
);
}
class _UpdateDialog extends StatelessWidget {
const _UpdateDialog({required this.t, required this.info});
final AppText t;
final AppUpdateInfo info;
@override
Widget build(BuildContext context) {
final c = context.pangolin;
return AlertDialog(
backgroundColor: c.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)),
title: Row(children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(color: c.accentSubtle, shape: BoxShape.circle),
child: Icon(PangolinIcons.zap, size: 18, color: c.accent),
),
const SizedBox(width: 12),
Expanded(
child: Text(
t.updateAvailableTitle(info.latestVersion),
overflow: TextOverflow.ellipsis,
style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700),
),
),
]),
content: SingleChildScrollView(
child: Text(
info.releaseNotes.isEmpty ? t.updateNotesFallback : info.releaseNotes,
style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5),
),
),
actions: [
if (!info.forceUpdate)
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.updateLater, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)),
),
TextButton(
onPressed: () async {
final url = platformDownloadUrl(info.downloadUrls);
if (url != null && url.isNotEmpty) {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
if (context.mounted) Navigator.of(context).pop();
},
child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)),
),
],
);
}
}
@@ -11,6 +11,7 @@ import package_info_plus
import screen_retriever_macos
import shared_preferences_foundation
import tray_manager
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -20,5 +21,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}
+1
View File
@@ -20,6 +20,7 @@ dependencies:
flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化
shared_preferences: ^2.5.5
package_info_plus: ^9.0.1
url_launcher: ^6.3.0 # 打开外部链接(用户中心 SSO 免登 / 更新下载页)
device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报)
uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4)
launch_at_startup: ^0.5.1
@@ -9,6 +9,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <tray_manager/tray_manager_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
TrayManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("TrayManagerPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows
screen_retriever_windows
tray_manager
url_launcher_windows
window_manager
)
+20
View File
@@ -177,6 +177,8 @@ GRPC_CERT_PATH=$ETC/grpc.crt
GRPC_KEY_PATH=$ETC/grpc.key
PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com
PANGOLIN_RULES_DIR=$DATA_DIR/rules
DOWNLOADS_DIR=$DATA_DIR/downloads
VERSION_MANIFEST=$ETC/version.yaml
EOF
if [ -n "${SMTP_HOST:-}" ]; then
cat >> "$ETC/server.env" <<EOF
@@ -201,6 +203,24 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
https://github.com/SagerNet/sing-geosite/raw/rule-set/geosite-cn.srs \
|| log "WARN: geosite-cn.srs 拉取失败,国内分流将不可用"
# ── 6c. 客户端安装包下载目录 ──────────────────────────────────────────────────
# scripts/ci/deploy-client.sh 把最新 pangolin-android.apk /
# pangolin-windows-x64-setup.exe scp 到这里(每平台只保留最新一份),控制面
# GET /downloads/<file> 静态服务、官网下载按钮直链。这里先建目录占位(CI 首次
# 部署前跑本脚本也不会因目录缺失而 404 时找不到目录本身;DownloadsHandler 本身
# 即便目录不存在也能正常注册路由,只是请求会 404)。
DOWNLOADS_DIR="$DATA_DIR/downloads"
install -d -m 755 "$DOWNLOADS_DIR"
# ── 6d. 客户端自动更新版本清单 ─────────────────────────────────────────────────
# GET /version(公开、免鉴权)按 VERSION_MANIFEST(见上 server.env)读取该文件;
# scripts/ci/release-client.sh 每次 client-v* 发版都会以本仓库这份文件为模板,
# 改写 version/build_number 后 SSH 推到这个路径覆盖 —— 幂等重跑本脚本不应该把
# 已发布的最新版本号退回仓库里的默认值,所以文件已存在时不覆盖。
if [ ! -f "$ETC/version.yaml" ]; then
install -m 644 "$HERE/version.yaml" "$ETC/version.yaml"
fi
# ── 7. 迁移 + seed(SQLite)────────────────────────────────────────────────────
log "执行迁移(sqlite)..."
DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up
+22
View File
@@ -0,0 +1,22 @@
# version.yaml — 客户端自动更新版本清单(committed default)。
#
# deploy/single-node/deploy.sh 把这份文件安装到 /etc/pangolin/version.yaml
# (VERSION_MANIFEST 默认路径,见 server/internal/httpapi/version.go)。
# scripts/ci/release-client.sh 在每次 client-v* 发版时,以这份文件为模板改写
# version / build_number 字段,再原样(SSH)推到 pangolin1 的
# /etc/pangolin/version.yaml —— 控制面每次请求都重新读该文件,发版立即生效,
# 不需要重启/重新部署控制面。
#
# download_urls 目前是固定「仅保留最新一份」的稳定 URL(deploy-client.sh 每次
# 用同名文件覆盖),不是按版本变化的路径,因此这里不需要随发版改写;
# macos / ios 产物尚未产出,先留空字符串。
version: "1.0.48"
build_number: 10048
force_update: false
release_notes: ""
download_urls:
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
macos: ""
ios: ""
changelog: []
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# compile-android.sh <tag> — build+embed libbox.aar, build a signed Flutter
# Android APK, package into dist/.
#
# Mirrors ~/code/jiu/scripts/ci/compile-android.sh, adapted for pangolin:
# - version derived via pangolin's ver_from_tag (client-v* tag; _env.sh),
# not jiu's ad-hoc `${TAG#client-v}` strip.
# - pangolin embeds sing-box as a native library (io.nekohasekai.libbox,
# gomobile bind) — jiu's Flutter app has no embedded core at all. This
# script MUST build+deploy libbox.aar (scripts/build-libbox.sh android)
# before `flutter build apk`, or the APK links no VPN kernel at all.
# Needs JDK 17 (gomobile bind javac step) — see CLAUDE.md "client/ 移动端"
# and "已知坑" for why: JDK 21 is rejected outright, NDK 27 fails link.
# - signing secrets: RELEASE_KEYSTORE (base64) + KEY_PASSWORD are the
# secret names given for this task ("actual configured" names). jiu uses
# 4 separate secrets (ANDROID_KEYSTORE_BASE64/ANDROID_KEYSTORE_PASSWORD/
# ANDROID_KEY_ALIAS/ANDROID_KEY_PASSWORD), and pangolin's OWN prior CI
# design docs (docs/cicd-design.html, docs/superpowers/plans/2026-07-05-
# cicd.md, docs/superpowers/specs/2026-07-05-cicd-design.md) planned the
# same 4-secret jiu-style naming. That is a real conflict with this
# task's brief — see the TODO(controller) block below and the draft
# report. This script follows the 2-secret brief as instructed, assuming
# storePassword == keyPassword == KEY_PASSWORD and a derivable keyAlias
# (default "pangolin", overridable via optional RELEASE_KEY_ALIAS).
# - dart-define is PANGOLIN_API_URL (client/lib/services/api_config.dart),
# not jiu's BASE_URL/PUBLIC_URL/APP_VERSION trio.
# - build.gradle previously signed release builds with signingConfigs.debug
# (no release signing config existed at all) — this task's draft also
# wires client/android/app/build.gradle to add a `release` signingConfig
# that reads client/android/key.properties (falls back to debug when
# key.properties is absent, so local/no-secret builds still work). See
# report for the exact diff.
# - universal APK (`flutter build apk --release`, no --split-per-abi) — see
# report for the size-vs-single-stable-URL tradeoff discussion; this
# deviates from docs/superpowers/plans/2026-07-05-cicd.md Task 7 which
# had planned --split-per-abi.
# - output dist/pangolin-android.apk (stable name; deploy-client.sh keeps
# only the latest one under this name on pangolin1).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
TAG="${1:?usage: compile-android.sh <tag>}"
# No $() anywhere (repo convention): ver_from_tag prints to stdout, captured
# via a temp file + `read`, same pattern as release-server.sh.
ver_file="/tmp/compile_android_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
# `|| true`: ver_from_tag uses printf '%s' (no trailing newline); `read`
# hitting EOF without a newline returns 1 even though VER was assigned —
# under `set -e` that would abort here. Same rationale as release-server.sh.
read -r VER < "$ver_file" || true
rm -f "$ver_file"
# versionCode 必须单调递增,否则 Android 无法覆盖升级安装(同 jiu 注释)。
# 由版本号推导:major*10000 + minor*100 + patch(如 1.0.48 -> 10048)。
# Pure parameter expansion (no `cut`/`$()`), and strip any `-suffix` off the
# patch component so the arithmetic below stays numeric (e.g. "48-rc1"->48).
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-android: tag=${TAG} version=${VER} build=${BUILD}"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
# ── [1/3] build+embed libbox.aar (io.nekohasekai.libbox) ────────────────────
# Must run BEFORE `flutter build apk` — build.gradle's dependency block reads
# app/kernel/dist/android/libbox.aar and just logs a warning (does not fail
# the build!) if it's missing, which would silently ship an APK with no VPN
# kernel. Fail loudly here instead.
JAVA_HOME_17="${JAVA_HOME_17:-/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home}"
if [ ! -d "$JAVA_HOME_17" ]; then
echo "ERROR: JDK 17 not found at ${JAVA_HOME_17} (required for gomobile Android bind)." >&2
echo " See CLAUDE.md 'libbox 构建' — install via 'brew install openjdk@17' or set JAVA_HOME_17." >&2
exit 1
fi
echo "==> compile-android: building libbox.aar (JAVA_HOME=${JAVA_HOME_17}, NDK auto-detected >=28)"
JAVA_HOME="$JAVA_HOME_17" bash scripts/build-libbox.sh android
LIBBOX_AAR="app/kernel/dist/android/libbox.aar"
if [ ! -f "$LIBBOX_AAR" ]; then
echo "ERROR: ${LIBBOX_AAR} not found after build-libbox.sh — aborting." >&2
exit 1
fi
# ── [2/3] sync pubspec version + release signing ────────────────────────────
# BSD sed (macOS runner — mac-pangolin-2, see docs/ci-runner.md), same as jiu.
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" client/pubspec.yaml
# 必须 release 签名。缺任一签名 secret 直接失败,绝不回退 debug:debug 包用公开
# 调试密钥签名,不能正式分发,也无法与正式版互相覆盖升级(同 jiu 注释)。
: "${RELEASE_KEYSTORE:?缺少 RELEASE_KEYSTORE(base64 编码的 release keystore):未配置签名,构建中止(不回退 debug)}"
: "${KEY_PASSWORD:?缺少 KEY_PASSWORD:未配置签名,构建中止}"
# TODO(controller): confirm this 2-secret assumption before relying on it.
# - Assumes storePassword == keyPassword == KEY_PASSWORD (true only if the
# keystore was generated with the same password for both).
# - keyAlias is NOT among the secrets this task named, so it defaults to
# "pangolin" below — override with RELEASE_KEY_ALIAS if the real alias
# differs (e.g. keytool default "androiddebugkey" is NOT this — that
# alias belongs to the debug keystore, never use it for release signing).
# - pangolin's OWN prior CI design docs (docs/cicd-design.html §secrets,
# docs/superpowers/plans/2026-07-05-cicd.md Task 6/7,
# docs/superpowers/specs/2026-07-05-cicd-design.md) planned 4 separate
# secrets instead: ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD /
# ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD. If THAT naming is what is
# actually configured in Forgejo (not RELEASE_KEYSTORE/KEY_PASSWORD),
# this script needs updating before first real run.
RELEASE_KEY_ALIAS="${RELEASE_KEY_ALIAS:-pangolin}"
echo "==> compile-android: configuring release signing (keyAlias=${RELEASE_KEY_ALIAS})"
KEYSTORE_PATH="${PWD}/client/android/pangolin-release.jks"
printf '%s' "$RELEASE_KEYSTORE" | base64 --decode > "$KEYSTORE_PATH"
cat > client/android/key.properties <<EOF
storeFile=${KEYSTORE_PATH}
storePassword=${KEY_PASSWORD}
keyAlias=${RELEASE_KEY_ALIAS}
keyPassword=${KEY_PASSWORD}
EOF
# ── [3/3] flutter build apk (--split-per-abi) ───────────────────────────────
# split-per-abi 而非 universal:嵌入的 libbox.so(gomobile 编的 sing-box)每 ABI
# ~60MB,universal 三合一直接 232MB,做下载链接太大。拆分后取 **arm64-v8a**(覆盖
# 现代绝大多数 Android 机)作为唯一下载,约 ~80MB;32 位老机(armeabi-v7a)/模拟器
# (x86_64)本轮不分发(需要再加)。这样官网 + deploy-client 仍是单一稳定 URL。
cd client
flutter build apk --release --split-per-abi \
"--dart-define=PANGOLIN_API_URL=${API_URL}"
cd ..
# Locate the arm64-v8a APK (path differs across Flutter versions) and copy to dist/
mkdir -p dist
APK=""
for cand in \
client/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk \
client/build/app/outputs/apk/release/app-arm64-v8a-release.apk; do
if [ -f "$cand" ]; then APK="$cand"; break; fi
done
if [ -z "$APK" ]; then
echo "ERROR: built arm64-v8a APK not found" >&2
exit 1
fi
cp "$APK" dist/pangolin-android.apk
# Clean up signing material so it never lingers on the runner workspace.
rm -f client/android/key.properties "$KEYSTORE_PATH" 2>/dev/null || true
echo "==> compile-android: done — dist/ contents:"
ls -lh dist/
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# compile-ios.sh <tag> — build a signed iOS IPA (app + PacketTunnel Network
# Extension) and upload it to TestFlight (App Store Connect).
#
# Mirrors ~/code/jiu/scripts/ci/compile-ios.sh's shape (temp-keychain dist
# cert import, provisioning-profile install by UUID, ExportOptions.plist,
# `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin:
# - pangolin's iOS app ships a NEPacketTunnelProvider **app extension**
# (client/ios/PacketTunnel/, bundle com.pangolin.pangolinVpn.PacketTunnel)
# alongside the main app (com.pangolin.pangolinVpn) — jiu has no
# extension at all, just the one app target. So this script needs TWO
# distribution provisioning profiles (app + extension), not one, and the
# ExportOptions.plist provisioningProfiles dict needs both bundle-id ->
# profile-name mappings.
# - pangolin embeds sing-box as a native core (gomobile-built
# Libbox.xcframework, io.nekohasekai.libbox) — jiu's Flutter app has no
# embedded core. This script MUST run `scripts/build-libbox.sh apple ios`
# before `flutter build ipa`, or the IPA links no VPN kernel (mirrors the
# equivalent step in compile-android.sh / compile-macos.sh).
# - Team ID BYL4KQHMTN is hardcoded as a script constant rather than a
# secret (unlike jiu's IOS_TEAM_ID secret): it's already public inside
# this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj
# DEVELOPMENT_TEAM, scripts/local_test.sh SIGN_ID) — not sensitive, no
# reason to add another secret for it.
# - APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
# are shared with compile-macos.sh's notarization step (same App Store
# Connect API key serves both notarization and TestFlight upload).
#
# Required CI secrets (missing ANY of them => SKIP gracefully, exit 0 — does
# NOT fail the pipeline; Apple iOS distribution needs an enrolled account +
# certs that may not exist yet, unlike macOS Developer ID which is a hard
# fail-fast in compile-macos.sh):
# IOS_DIST_CERT_P12_BASE64 Apple Distribution 证书(.p12)base64
# IOS_DIST_CERT_PASSWORD .p12 导出密码
# IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app,
# com.pangolin.pangolinVpn)base64
# IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(扩展,
# com.pangolin.pangolinVpn.PacketTunnel)base64
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
# App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。
#
# No dist/ artifact is produced (matches jiu) — the IPA goes straight to
# TestFlight via altool, nothing is uploaded to the Forgejo release.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
TAG="${1:?usage: compile-ios.sh <tag>}"
ver_file="/tmp/compile_ios_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
# CFBundleVersion(两个 target 均为 "$(FLUTTER_BUILD_NUMBER)",见
# client/ios/Runner.xcodeproj/project.pbxproj —— 与 macOS 的 PacketTunnel 硬编码
# CURRENT_PROJECT_VERSION 不同,这里走 flutter build --build-number 走标准路径)。
# 必须单调递增,否则 TestFlight 拒绝重复上传(同 jiu 注释),公式与
# compile-android.sh / compile-macos.sh 一致。
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-ios: tag=${TAG} version=${VER} build=${BUILD}"
# ── 凭证缺失则优雅跳过(不阻塞流水线;见头部说明)─────────────────────────
if [ -z "${IOS_DIST_CERT_P12_BASE64:-}" ] || \
[ -z "${IOS_DIST_CERT_PASSWORD:-}" ] || \
[ -z "${IOS_APP_PROVISIONING_PROFILE_BASE64:-}" ] || \
[ -z "${IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64:-}" ] || \
[ -z "${APPSTORE_API_KEY_ID:-}" ] || \
[ -z "${APPSTORE_API_ISSUER_ID:-}" ] || \
[ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then
echo "==> compile-ios: SKIP — iOS signing secrets not fully configured yet (not a failure)"
exit 0
fi
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
TEAM_ID="BYL4KQHMTN"
APP_BUNDLE_ID="com.pangolin.pangolinVpn"
EXT_BUNDLE_ID="com.pangolin.pangolinVpn.PacketTunnel"
WORK="$(mktemp -d)"
KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db"
PROFILES_DIR="${HOME}/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_DIR"
cleanup_ios() {
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
security list-keychains -d user -s login.keychain-db 2>/dev/null || true
[ -n "${APP_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision" 2>/dev/null || true
[ -n "${EXT_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup_ios EXIT
# ── [1/6] 重建 libbox.xcframework(apple ios;gitignore 产物,每次都要重建) ──
echo "==> compile-ios: building Libbox.xcframework (apple ios)"
bash "${REPO_ROOT}/scripts/build-libbox.sh" apple ios
LIBBOX_FW="${REPO_ROOT}/client/ios/Frameworks/Libbox.xcframework"
if [ ! -d "$LIBBOX_FW" ]; then
echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2
exit 1
fi
# ── [2/6] 临时 keychain 导入 Apple Distribution 证书 ─────────────────────────
KEYCHAIN_PWD="ci-temp-$$"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
printf '%s' "$IOS_DIST_CERT_P12_BASE64" | base64 --decode > "${WORK}/dist.p12"
security import "${WORK}/dist.p12" -k "$KEYCHAIN" \
-P "$IOS_DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
# ── [3/6] 安装 2 个 App Store 类型描述文件(app + PacketTunnel 扩展) ─────────
printf '%s' "$IOS_APP_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/app.mobileprovision"
security cms -D -i "${WORK}/app.mobileprovision" > "${WORK}/app_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/app_profile.plist" > "${WORK}/app_name.txt"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt"
APP_PROFILE_NAME=""
read -r APP_PROFILE_NAME < "${WORK}/app_name.txt" || true
APP_PROFILE_UUID=""
read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true
cp "${WORK}/app.mobileprovision" "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision"
echo "==> compile-ios: app profile '${APP_PROFILE_NAME}' (${APP_PROFILE_UUID})"
printf '%s' "$IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/ext.mobileprovision"
security cms -D -i "${WORK}/ext.mobileprovision" > "${WORK}/ext_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/ext_profile.plist" > "${WORK}/ext_name.txt"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/ext_profile.plist" > "${WORK}/ext_uuid.txt"
EXT_PROFILE_NAME=""
read -r EXT_PROFILE_NAME < "${WORK}/ext_name.txt" || true
EXT_PROFILE_UUID=""
read -r EXT_PROFILE_UUID < "${WORK}/ext_uuid.txt" || true
cp "${WORK}/ext.mobileprovision" "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision"
echo "==> compile-ios: PacketTunnel profile '${EXT_PROFILE_NAME}' (${EXT_PROFILE_UUID})"
# ── [4/6] App Store Connect API Key(供 altool 上传使用)──────────────────────
API_KEYS_DIR="${HOME}/.appstoreconnect/private_keys"
mkdir -p "$API_KEYS_DIR"
printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${API_KEYS_DIR}/AuthKey_${APPSTORE_API_KEY_ID}.p8"
# ── [5/6] 同步版本号 + 生成 ExportOptions.plist(manual 签名,两个 bundle 都映射)──
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml"
cat > "${WORK}/ExportOptions.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store</string>
<key>teamID</key><string>${TEAM_ID}</string>
<key>signingStyle</key><string>manual</string>
<key>uploadBitcode</key><false/>
<key>uploadSymbols</key><true/>
<key>provisioningProfiles</key>
<dict>
<key>${APP_BUNDLE_ID}</key><string>${APP_PROFILE_NAME}</string>
<key>${EXT_BUNDLE_ID}</key><string>${EXT_PROFILE_NAME}</string>
</dict>
</dict>
</plist>
EOF
# ── [6/6] flutter build ipa + 上传 TestFlight ───────────────────────────────
cd "${REPO_ROOT}/client"
flutter build ipa --release \
--build-name="${VER}" \
--build-number="${BUILD}" \
--export-options-plist="${WORK}/ExportOptions.plist" \
"--dart-define=PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
IPA=""
for cand in "${REPO_ROOT}"/client/build/ios/ipa/*.ipa; do
if [ -f "$cand" ]; then IPA="$cand"; break; fi
done
if [ -z "$IPA" ]; then
echo "ERROR: IPA not found under client/build/ios/ipa/" >&2
exit 1
fi
echo "==> compile-ios: built ${IPA}"
echo "==> compile-ios: uploading to TestFlight"
xcrun altool --upload-app -f "$IPA" -t ios \
--apiKey "$APPSTORE_API_KEY_ID" \
--apiIssuer "$APPSTORE_API_ISSUER_ID"
echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env bash
# compile-macos.sh <tag> — build the Flutter macOS app (with its embedded
# PacketTunnel System Extension), Developer ID sign + notarize + staple,
# package into dist/.
#
# Mirrors ~/code/jiu/scripts/ci/compile-macos.sh's shape (temp-keychain
# Developer ID import, inside-out codesign, notarytool submit --wait, staple,
# ditto zip), but pangolin's macOS app is heavier than jiu's plain window:
# - it embeds a System Extension (com.pangolin.pangolin.PacketTunnel,
# `.systemextension` bundle under Contents/Library/SystemExtensions/)
# that needs ITS OWN Developer ID provisioning profile + entitlements,
# signed separately, inside-out, before the outer app is signed — see
# CLAUDE.md "client/ macOS 原生隧道" and
# docs/macos-sysext-realize-troubleshooting.html for why every one of
# these steps is load-bearing (wrong order / missing entitlement /
# unsigned nested item => sysextd silently refuses to load it).
# - the Xcode project (client/macos/Runner.xcodeproj) is ALREADY configured
# CODE_SIGN_STYLE=Manual with CODE_SIGN_IDENTITY="Developer ID
# Application" + PROVISIONING_PROFILE_SPECIFIER set per target (unlike
# jiu, whose Release config has CODE_SIGN_IDENTITY="-", i.e. unsigned at
# build time, signed entirely by hand afterward). So `flutter build macos
# --release` here should already produce a signed .app *if* the identity
# is in a searched keychain and the profiles are in place. This script
# still re-runs the explicit inside-out codesign pass below (mirroring
# scripts/local_test.sh's now-legacy `cmd_sign`) as a defensive,
# idempotent safety net — resigning with the same identity is harmless,
# and it removes any dependency on Xcode's automatic nested-item signing
# behaving identically on a CI runner vs. the maintainer's dev machine.
# - CFBundleVersion for the PacketTunnel extension is NOT derived from
# FLUTTER_BUILD_NUMBER (unlike the main Runner target, and unlike iOS
# where every target uses "$(FLUTTER_BUILD_NUMBER)") — it's a literal
# CURRENT_PROJECT_VERSION build setting hardcoded in project.pbxproj
# (currently 53, see CLAUDE.md: "每次构建必递增 CFBundleVersion
# (CURRENT_PROJECT_VERSION)——否则 sysextd 视为同版本不更新"). This script
# computes a monotonic build number from the tag version (same
# major*10000+minor*100+patch formula as compile-android.sh /
# compile-ios.sh) and sed's every CURRENT_PROJECT_VERSION occurrence in
# the pbxproj (RunnerTests + PacketTunnel Debug/Release/Profile, 6 total)
# before building. This edit is NOT committed back to git — each CI run
# starts from the repo's checked-in value and recomputes deterministically
# from the tag, so it stays monotonic across releases as long as the
# version itself increases.
#
# Required CI secrets (fail-fast — ANY missing => abort, never ship unsigned):
# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书(.p12)base64
# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码
# MACOS_APP_PROVISION_PROFILE_BASE64 "Pangolin App DevID" 描述文件 base64
# MACOS_SYSEXT_PROVISION_PROFILE_BASE64 "Pangolin PacketTunnel DevID" 描述文件 base64
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64
# App Store Connect API Key(公证用,notarytool --key/--key-id/--issuer
# 不同于 scripts/local_test.sh 本机用的 --keychain-profile pangolin-notary,
# CI 无法预置 keychain profile,必须走显式 API key 三件套)。
#
# Output: dist/pangolin-macos-x64.zip (stable name — deploy-client.sh /
# release-client.sh key off this exact filename).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
TAG="${1:?usage: compile-macos.sh <tag>}"
ver_file="/tmp/compile_macos_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
# 单调递增 build 号:major*10000 + minor*100 + patch(同 compile-android.sh /
# compile-ios.sh 公式)。纯参数展开,不用 cut/$();剥掉 patch 段的 `-suffix`
# (如预发布 tag "48-rc1")以保证后面的算术展开是纯数字。
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "==> compile-macos: tag=${TAG} version=${VER} build=${BUILD}"
# ── [0/7] fail-fast:签名/公证所需 secret 一个不少 ──────────────────────────
# 比 jiu 的 mac 脚本(只查 2 个)更严格:任一缺失都直接报错中止,不产出未签名/
# 未公证包。用 `${VAR:?msg}` 而非 if-empty,出错信息更精确定位到具体哪个 secret。
: "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:?缺少 MACOS_DEVELOPER_ID_CERT_P12_BASE64:未配置 Developer ID 证书,构建中止(不产出未签名包)}"
: "${MACOS_DEVELOPER_ID_CERT_PASSWORD:?缺少 MACOS_DEVELOPER_ID_CERT_PASSWORD}"
: "${MACOS_APP_PROVISION_PROFILE_BASE64:?缺少 MACOS_APP_PROVISION_PROFILE_BASE64('Pangolin App DevID' 描述文件)}"
: "${MACOS_SYSEXT_PROVISION_PROFILE_BASE64:?缺少 MACOS_SYSEXT_PROVISION_PROFILE_BASE64('Pangolin PacketTunnel DevID' 描述文件)}"
: "${APPSTORE_API_KEY_ID:?缺少 APPSTORE_API_KEY_ID(公证用 App Store Connect API Key}"
: "${APPSTORE_API_ISSUER_ID:?缺少 APPSTORE_API_ISSUER_ID}"
: "${APPSTORE_API_KEY_P8_BASE64:?缺少 APPSTORE_API_KEY_P8_BASE64}"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
SIGN_ID_PREFIX="Developer ID Application"
TEAM_ID="BYL4KQHMTN"
APP_BUNDLE_ID="com.pangolin.pangolin"
SYSEXT_BUNDLE_ID="com.pangolin.pangolin.PacketTunnel"
APP_GROUP="${TEAM_ID}.com.pangolin.pangolin"
WORK="$(mktemp -d)"
KEYCHAIN="${WORK}/pangolin-mac-ci.keychain-db"
# 与 scripts/local_test.sh 用的描述文件目录保持一致(Xcode UserData 目录,
# 而非经典的 ~/Library/MobileDevice/Provisioning Profiles/)——CI 与本机联调
# 复用同一处,两边的 "Pangolin App DevID"/"Pangolin PacketTunnel DevID" 描述
# 文件本就应该是同一份,CI 覆盖写入是幂等的。故清理阶段【不删除】这两个描述
# 文件(只清理临时 keychain/证书),避免影响维护者在同一台 mac runner 上继续
# 用 local_test.sh 手动联调。
PROF_DIR="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"
mkdir -p "$PROF_DIR"
cleanup_mac() {
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
# 把 keychain 搜索列表还原成默认单项,不把临时 keychain 的痕迹留在这台常驻
# runner 上(mac-pangolin-2 同时也被人手动用来跑 local_test.sh)。
security list-keychains -d user -s login.keychain-db 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup_mac EXIT
# ── [1/7] 重建 libbox.xcframeworkgitignore 产物,每次换 worktree/CI 都要重建)─
echo "==> compile-macos: building Libbox.xcframework (apple macos)"
bash "${REPO_ROOT}/scripts/build-libbox.sh" apple macos
LIBBOX_FW="${REPO_ROOT}/client/macos/Frameworks/Libbox.xcframework"
if [ ! -d "$LIBBOX_FW" ]; then
echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2
exit 1
fi
# ── [2/7] 递增 CURRENT_PROJECT_VERSIONsysext CFBundleVersion,见头部说明)──
PBXPROJ="${REPO_ROOT}/client/macos/Runner.xcodeproj/project.pbxproj"
echo "==> compile-macos: bumping CURRENT_PROJECT_VERSION -> ${BUILD} in project.pbxproj"
sed -i '' -E "s/CURRENT_PROJECT_VERSION = [0-9]+;/CURRENT_PROJECT_VERSION = ${BUILD};/g" "$PBXPROJ"
# 同步 pubspec.yaml(主 app 自身的 FLUTTER_BUILD_NUMBER,和 android/windows 脚本一致)。
sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml"
# ── [3/7] 解码 2 个 Developer ID 描述文件到 local_test.sh 同款目录 ──────────
printf '%s' "$MACOS_APP_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/app.provisionprofile"
printf '%s' "$MACOS_SYSEXT_PROVISION_PROFILE_BASE64" | base64 --decode > "${WORK}/sysext.provisionprofile"
# 提取 UUID 用来落盘为规范文件名(Xcode/xcodebuild 靠内容匹配,文件名本身不
# 强制要求,但用 UUID 命名是 Xcode 自身导入时的惯例,避免与其他描述文件重名冲突)。
security cms -D -i "${WORK}/app.provisionprofile" > "${WORK}/app_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt"
APP_PROFILE_UUID=""
read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true
APP_PROFILE="${PROF_DIR}/${APP_PROFILE_UUID}.provisionprofile"
cp "${WORK}/app.provisionprofile" "$APP_PROFILE"
security cms -D -i "${WORK}/sysext.provisionprofile" > "${WORK}/sysext_profile.plist"
/usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/sysext_profile.plist" > "${WORK}/sysext_uuid.txt"
SYSEXT_PROFILE_UUID=""
read -r SYSEXT_PROFILE_UUID < "${WORK}/sysext_uuid.txt" || true
SYSEXT_PROFILE="${PROF_DIR}/${SYSEXT_PROFILE_UUID}.provisionprofile"
cp "${WORK}/sysext.provisionprofile" "$SYSEXT_PROFILE"
echo "==> compile-macos: profiles installed to '${PROF_DIR}' (app=${APP_PROFILE_UUID}, sysext=${SYSEXT_PROFILE_UUID})"
# ── [4/7] 临时 keychain 导入 Developer ID Application 证书 ──────────────────
KEYCHAIN_PWD="ci-temp-$$"
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
printf '%s' "$MACOS_DEVELOPER_ID_CERT_P12_BASE64" | base64 --decode > "${WORK}/devid.p12"
security import "${WORK}/devid.p12" -k "$KEYCHAIN" \
-P "$MACOS_DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
security find-identity -v -p codesigning "$KEYCHAIN" > "${WORK}/identities.txt"
grep "$SIGN_ID_PREFIX" "${WORK}/identities.txt" | head -1 | sed -E 's/.*"(.*)"/\1/' > "${WORK}/identity.txt"
IDENTITY=""
read -r IDENTITY < "${WORK}/identity.txt" || true
if [ -z "$IDENTITY" ]; then
echo "ERROR: '${SIGN_ID_PREFIX}' identity not found in imported keychain" >&2
exit 1
fi
echo "==> compile-macos: signing identity '${IDENTITY}'"
# ── [5/7] flutter build macos --release ─────────────────────────────────────
# 项目已配 CODE_SIGN_STYLE=Manual + PROVISIONING_PROFILE_SPECIFIER(见头部说
# 明),本步应已产出 Developer ID 签名 + hardened runtime 的 .app;下一步的
# inside-out 重签是幂等的安全网,不依赖这一步是否已经"恰好签对"。
cd "${REPO_ROOT}/client"
flutter build macos --release --dart-define="PANGOLIN_API_URL=${API_URL}"
cd "$REPO_ROOT"
APP="${REPO_ROOT}/client/build/macos/Build/Products/Release/pangolin_vpn.app"
SE="${APP}/Contents/Library/SystemExtensions/${SYSEXT_BUNDLE_ID}.systemextension"
[ -d "$APP" ] || { echo "ERROR: build product not found: ${APP}" >&2; exit 1; }
[ -d "$SE" ] || { echo "ERROR: sysext bundle not found: ${SE}" >&2; exit 1; }
# ── [6/7] inside-out 重签(sysext → app frameworks → app),同 local_test.sh cmd_sign ──
cat > "${WORK}/app.entitlements" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${APP_BUNDLE_ID}</string>
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
<key>com.apple.developer.system-extension.install</key><true/>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>keychain-access-groups</key>
<array><string>${APP_GROUP}</string></array>
</dict></plist>
PLIST
cat > "${WORK}/sysext.entitlements" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.application-identifier</key><string>${TEAM_ID}.${SYSEXT_BUNDLE_ID}</string>
<key>com.apple.developer.team-identifier</key><string>${TEAM_ID}</string>
<key>com.apple.developer.networking.networkextension</key>
<array><string>packet-tunnel-provider-systemextension</string></array>
<key>com.apple.security.app-sandbox</key><true/>
<key>com.apple.security.application-groups</key>
<array><string>group.com.pangolin.pangolin</string></array>
</dict></plist>
PLIST
echo "==> compile-macos: inside-out codesign (sysext -> frameworks -> app)"
xattr -cr "$APP"
cp "$SYSEXT_PROFILE" "${SE}/Contents/embedded.provisionprofile"
cp "$APP_PROFILE" "${APP}/Contents/embedded.provisionprofile"
# sysext first — libbox is linked (not embedded) into the sysext binary
# itself (see CLAUDE.md), so signing the sysext bundle re-covers its statically
# linked libbox; no separate Libbox.framework to sign inside it.
codesign --force --options runtime --timestamp \
--entitlements "${WORK}/sysext.entitlements" --sign "$IDENTITY" "$SE"
if [ -d "${APP}/Contents/Frameworks" ]; then
for item in "${APP}/Contents/Frameworks/"*; do
[ -e "$item" ] && codesign --force --options runtime --timestamp --sign "$IDENTITY" "$item"
done
fi
codesign --force --options runtime --timestamp \
--entitlements "${WORK}/app.entitlements" --sign "$IDENTITY" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "==> compile-macos: signed + verified (${IDENTITY})"
# ── [7/7] 公证(notarytoolAPI key 三件套) + staple + 打包 ──────────────────
echo "==> compile-macos: notarizing (notarytool submit --wait, ~1-5min)"
NOTARIZE_ZIP="${WORK}/notarize.zip"
ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP"
printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${WORK}/AuthKey.p8"
xcrun notarytool submit "$NOTARIZE_ZIP" \
--key "${WORK}/AuthKey.p8" \
--key-id "$APPSTORE_API_KEY_ID" \
--issuer "$APPSTORE_API_ISSUER_ID" \
--wait
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl -a -vvv -t install "$APP" || true # 期望 source=Notarized Developer ID
echo "==> compile-macos: notarized + stapled"
mkdir -p "${REPO_ROOT}/dist"
ditto -c -k --keepParent "$APP" "${REPO_ROOT}/dist/pangolin-macos-x64.zip"
echo "==> compile-macos: done — dist/ contents:"
ls -lh "${REPO_ROOT}/dist/"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# compile-windows.sh <tag> — build Flutter Windows desktop app, package into
# dist/ via the existing Inno Setup script (client/windows/installer/pangolin.iss).
#
# Mirrors ~/code/jiu/scripts/ci/compile-windows.sh's short-path fix, adapted:
# - version derived via pangolin's ver_from_tag, not jiu's ad-hoc strip.
# - pangolin's Windows client does NOT embed libbox — it bundles a
# downloaded sing-box.exe + wintun.dll as a subprocess (see
# client/windows/README.md, CLAUDE.md "client/ 移动端"'s sibling desktop
# note). Those are fetched by app/kernel/fetch-desktop-bin.sh, NOT built
# by scripts/build-libbox.sh — no JDK/NDK/gomobile needed on Windows.
# - pangolin already has a working local packaging path:
# client/windows/build.ps1 + client/windows/installer/pangolin.iss. This
# script re-implements that flow in bash (matching the repo's
# jiu-mirrored bash-script-per-platform convention) rather than shelling
# out to build.ps1, so it can apply jiu's short-path fix below.
# - jiu's short-path fix: the Forgejo Windows runner checks out under
# C:\Windows\System32\config\systemprofile\.cache\act\...\hostexecutor,
# which triggers WOW64 filesystem redirection that breaks CMake/Flutter
# native paths. jiu copies client/ to C:\jiu-build\client and builds
# there. pangolin's windows/CMakeLists.txt additionally resolves the
# kernel binaries via a path RELATIVE to CMAKE_SOURCE_DIR
# (client/windows/../../app/kernel/dist/desktop/windows-* — see
# client/windows/CMakeLists.txt "Pangolin sing-box kernel" section), so
# this script must copy app/kernel/dist alongside client/ preserving the
# SAME relative layout (BUILD_ROOT/client + BUILD_ROOT/app/kernel/dist),
# not just client/ alone like jiu does — jiu has no such cross-directory
# kernel reference.
# - dart-define is PANGOLIN_API_URL, not jiu's BASE_URL/PUBLIC_URL/APP_VERSION.
# - pangolin.iss hardcodes `#define MyAppVersion "1.0.47"` (no #ifndef guard
# like jiu's jiu-installer.iss), so ISCC's /D command-line override would
# be silently ignored. This script `sed`s the version into the copied
# .iss file instead of passing /DAppVer.
# - output dist/pangolin-windows-x64-setup.exe (stable name — the .iss's
# own OutputBaseFilename is version-suffixed "pangolin-setup-<ver>.exe",
# so this script copies+renames it to the stable name deploy-client.sh
# and release-client.sh expect).
# - unsigned (like jiu) — first install shows a SmartScreen warning; no
# code-signing cert configured (see report).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
TAG="${1:?usage: compile-windows.sh <tag>}"
ver_file="/tmp/compile_windows_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释
rm -f "$ver_file"
echo "==> compile-windows: tag=${TAG} version=${VER}"
API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}"
# ── [1/4] fetch sing-box.exe + wintun.dll into the ORIGINAL checkout ───────
# Must run before the short-path copy below (step 2), so app/kernel/dist/
# exists to be copied alongside client/.
echo "==> compile-windows: fetching sing-box.exe + wintun.dll"
bash app/kernel/fetch-desktop-bin.sh windows amd64
# ── [2/4] copy client/ + app/kernel/dist to a short path ───────────────────
BUILD_ROOT="/c/pangolin-build"
BUILD_CLIENT="${BUILD_ROOT}/client"
echo "==> copying client/ + app/kernel/dist to ${BUILD_ROOT}"
rm -rf "$BUILD_ROOT"
mkdir -p "${BUILD_ROOT}/app/kernel"
cp -r client "$BUILD_CLIENT"
cp -r app/kernel/dist "${BUILD_ROOT}/app/kernel/dist"
# `cp -r` turns Flutter's plugin symlinks (windows/flutter/ephemeral/.plugin_symlinks/*)
# into real directories. flutter's createPluginSymlinks only cleans up *symlinks*, so it
# then fails to create a symlink over the copied real dir (errno 183 / PathExists).
# Drop all regenerable artifacts so the build below recreates them cleanly (same fix as jiu).
rm -rf "${BUILD_CLIENT}/windows/flutter/ephemeral" \
"${BUILD_CLIENT}/.dart_tool" \
"${BUILD_CLIENT}/build"
# ── [3/4] build ──────────────────────────────────────────────────────────────
# NOTE(controller): `flutter create` on an existing project only fills in
# scaffolding it manages (ephemeral/, generated_plugins.cmake, ...) and should
# not overwrite files that already exist — jiu relies on exactly this. But
# pangolin's windows/ tree is more heavily customized than jiu's (CMakeLists
# kernel-bundling block, runner.exe.manifest requireAdministrator + the
# /MANIFESTUAC:NO link flag — see client/windows/README.md "已知坑"). Verify
# on first real CI run that none of those get clobbered.
pushd "$BUILD_CLIENT" > /dev/null
flutter create --platforms=windows . --project-name pangolin_vpn
flutter build windows --release "--dart-define=PANGOLIN_API_URL=${API_URL}"
popd > /dev/null
# ── [4/4] package with Inno Setup (existing client/windows/installer/pangolin.iss) ──
ISCC=""
for c in "/c/Program Files (x86)/Inno Setup 6/ISCC.exe" "/c/Program Files/Inno Setup 6/ISCC.exe"; do
if [ -f "$c" ]; then ISCC="$c"; break; fi
done
if [ -z "$ISCC" ]; then
echo "ERROR: Inno Setup (ISCC) not found. Install Inno Setup 6 on this runner first." >&2
exit 1
fi
ISS="${BUILD_CLIENT}/windows/installer/pangolin.iss"
# pangolin.iss hardcodes MyAppVersion (no #ifndef guard) — sed it to match the
# release tag rather than relying on an ISCC /D override (which would be
# shadowed by the unconditional #define — see header note above).
sed -i "s/^#define MyAppVersion .*/#define MyAppVersion \"${VER}\"/" "$ISS"
echo "==> building installer with Inno Setup (version ${VER})"
# ISCC 是原生 Windows 程序,只认 Windows 路径(C:\...);传 MSYS 风格 /c/... 会被它
# 当成选项 → "Unknown option: /c/...pangolin.iss"。故先用 cygpath 把 .iss 转成
# Windows 路径再传(jiu 是直接写死 C:\ 字面量;这里用 cygpath 更稳)。
# 不用 $() 命令替换(仓库约定):cygpath 输出落临时文件、read 读回。
cygpath -w "$ISS" > /tmp/pangolin_iss_win.$$
ISS_WIN=""
read -r ISS_WIN < /tmp/pangolin_iss_win.$$
rm -f /tmp/pangolin_iss_win.$$
# MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL 关掉 Git Bash 对 /-开头参数的自动路径转换
# (否则会把 .iss 里将来可能的 /D 定义也改坏)。ISS_WIN 已是 Windows 路径,原样传即可。
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' "$ISCC" "$ISS_WIN"
# pangolin.iss has no explicit OutputDir -> Inno defaults to {src}\Output
# (relative to the .iss file), filename OutputBaseFilename=pangolin-setup-<ver>.
mkdir -p dist
SETUP_SRC="C:\\pangolin-build\\client\\windows\\installer\\Output\\pangolin-setup-${VER}.exe"
echo "==> copying installer -> dist/pangolin-windows-x64-setup.exe"
powershell -NoProfile -Command \
"Copy-Item '${SETUP_SRC}' 'dist\\pangolin-windows-x64-setup.exe' -Force"
echo "==> compile-windows: done — dist/ contents:"
ls -lh dist/
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# deploy-client.sh <tag> — publish the latest Android/Windows client
# installers to pangolin1's public downloads directory. Keeps only the single
# latest file per platform (jiu-style "only latest" — see jiu's
# deploy-client.sh /opt/jiu/downloads handling), overwriting whatever a
# previous release published under the same stable filename.
#
# Mirrors ~/code/jiu/scripts/ci/deploy-client.sh's "only latest" downloads
# publish, but uses pangolin's own lib-ssh.sh API (setup_ssh / $SSH /
# $RSYNC_SSH, DEPLOY_HOST defaulting to pangolin1's IP — different from
# jiu's lib-forgejo.sh-embedded setup_ssh/EC2_HOST convention) and drops
# jiu's Flutter-web + version.yaml swap entirely: pangolin's client has no
# web build in this pipeline, and no version.yaml self-update manifest.
#
# TODO(controller): this script assumes pangolin-server (or nginx/whatever
# fronts pangolin1's public HTTP) serves ${DOWNLOADS_DIR} at /downloads/ —
# that vhost/route wiring is a SEPARATE, not-yet-done task (same status as
# docs/superpowers/plans/2026-07-05-cicd.md Task 8's "官网下载链接接
# Android release", which is also not part of this draft).
#
# Usage: scripts/ci/deploy-client.sh <tag> (e.g. client-v1.0.49)
# Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh). Assumes compile-android.sh /
# compile-windows.sh (or a prior download-artifact step) have already
# produced dist/pangolin-android.apk and/or dist/pangolin-windows-x64-setup.exe.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/ci/lib-ssh.sh
. "${SCRIPT_DIR}/lib-ssh.sh"
# TODO(controller): confirm this path. Proposed to sit alongside the existing
# /var/lib/pangolin/ tree (pangolin.db lives at /var/lib/pangolin/pangolin.db
# per deploy-server.sh) rather than under nginx's webroot directly, so the
# web server config just needs one `location /downloads/ { alias ...; }`
# (or equivalent) added — no file ownership overlap with the control-plane
# SQLite DB / systemd units.
DOWNLOADS_DIR=/var/lib/pangolin/downloads
TAG="${1:?usage: deploy-client.sh <tag>}"
# Refuse anything that isn't a strict client-vX.Y.Z[-suffix] tag before it can
# reach the remote commands below (command-injection guard, mirroring
# deploy-server.sh's rationale: an anchored regex, not a `case` glob, since a
# trailing `*` would match shell metacharacters too).
if ! [[ "$TAG" =~ ^client-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then
echo "deploy-client: refusing unexpected tag '$TAG'" >&2
exit 1
fi
if [ ! -f dist/pangolin-android.apk ] && [ ! -f dist/pangolin-windows-x64-setup.exe ] && [ ! -f dist/pangolin-macos-x64.zip ]; then
echo "deploy-client: no artifacts found in dist/ — nothing to deploy" >&2
exit 1
fi
setup_ssh
SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
echo "==> deploy-client: tag=${TAG} host=${DEPLOY_HOST} downloads_dir=${DOWNLOADS_DIR}"
$SSH "root@${DEPLOY_HOST}" "mkdir -p ${DOWNLOADS_DIR}"
if [ -f dist/pangolin-android.apk ]; then
echo "==> deploy-client: uploading pangolin-android.apk"
$SCP dist/pangolin-android.apk "root@${DEPLOY_HOST}:/tmp/pangolin-android.apk.new"
$SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-android.apk && mv /tmp/pangolin-android.apk.new ${DOWNLOADS_DIR}/pangolin-android.apk && chmod 644 ${DOWNLOADS_DIR}/pangolin-android.apk"
fi
if [ -f dist/pangolin-windows-x64-setup.exe ]; then
echo "==> deploy-client: uploading pangolin-windows-x64-setup.exe"
$SCP dist/pangolin-windows-x64-setup.exe "root@${DEPLOY_HOST}:/tmp/pangolin-windows-x64-setup.exe.new"
$SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && mv /tmp/pangolin-windows-x64-setup.exe.new ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe && chmod 644 ${DOWNLOADS_DIR}/pangolin-windows-x64-setup.exe"
fi
if [ -f dist/pangolin-macos-x64.zip ]; then
echo "==> deploy-client: uploading pangolin-macos-x64.zip"
$SCP dist/pangolin-macos-x64.zip "root@${DEPLOY_HOST}:/tmp/pangolin-macos-x64.zip.new"
$SSH "root@${DEPLOY_HOST}" "rm -f ${DOWNLOADS_DIR}/pangolin-macos-x64.zip && mv /tmp/pangolin-macos-x64.zip.new ${DOWNLOADS_DIR}/pangolin-macos-x64.zip && chmod 644 ${DOWNLOADS_DIR}/pangolin-macos-x64.zip"
fi
# iOS has no dist/ artifact to deploy here — compile-ios.sh uploads straight
# to TestFlight via altool (matches jiu).
echo "==> deploy-client: done"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# release-client.sh <tag> — ensure the Forgejo release for a client-v* tag
# exists, upload whichever client artifacts are present in dist/, and roll
# the auto-update manifest (deploy/single-node/version.yaml) forward.
#
# Mirrors ~/code/jiu/scripts/ci/release-client.sh's asset-guarding pattern,
# but uses pangolin's own lib-forgejo.sh API (forgejo_release_ensure /
# forgejo_upload_asset — different names/signature than jiu's create_release/
# upload_asset).
#
# Manifest handling differs from jiu on purpose: jiu's backend reads
# backend/config/version.yaml straight out of its own working directory, so
# rewriting that file in the repo checkout is enough. Pangolin's server reads
# VERSION_MANIFEST from /etc/pangolin (see server/internal/httpapi/version.go
# + deploy/single-node/deploy.sh), a path that lives on pangolin1, not in any
# git checkout — so getting the new version/build_number onto the live host
# needs an SSH push, the same DEPLOY_SSH_KEY / lib-ssh.sh path
# deploy-client.sh already uses for the downloads/ dir (see
# .gitea/workflows/deploy-client.yml, which now passes DEPLOY_SSH_KEY into
# this step too). Also unlike jiu, download_urls are fixed "latest" stable
# URLs (deploy-client.sh keeps only one file per platform) and changelog[]
# isn't populated yet (no CHANGELOG-client.md in this repo) — so only
# version/build_number are rewritten here, everything else in the manifest
# template is passed through unchanged.
#
# Multiple platform build jobs (android/windows/...) all upload into the SAME
# release, guarded by `[ -f ... ]` since a given CI run may only have built a
# subset (e.g. a partial manual re-dispatch, or before macOS/iOS land).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=scripts/ci/_env.sh
. "${SCRIPT_DIR}/_env.sh"
# shellcheck source=scripts/ci/lib-forgejo.sh
. "${SCRIPT_DIR}/lib-forgejo.sh"
# shellcheck source=scripts/ci/lib-ssh.sh
. "${SCRIPT_DIR}/lib-ssh.sh"
TAG="${1:?usage: release-client.sh <tag>}"
ver_file="/tmp/release_client_ver.$$"
ver_from_tag client "$TAG" > "$ver_file"
VER=""
read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 release-server.sh 同注释
rm -f "$ver_file"
echo "==> release-client: tag=${TAG} ver=${VER}"
forgejo_release_ensure "$TAG" "client ${VER}"
if [ -f dist/pangolin-android.apk ]; then
forgejo_upload_asset "$TAG" dist/pangolin-android.apk
fi
if [ -f dist/pangolin-windows-x64-setup.exe ]; then
forgejo_upload_asset "$TAG" dist/pangolin-windows-x64-setup.exe
fi
if [ -f dist/pangolin-macos-x64.zip ]; then
forgejo_upload_asset "$TAG" dist/pangolin-macos-x64.zip
fi
# iOS has no dist/ artifact — compile-ios.sh uploads straight to TestFlight
# via altool (matches jiu), nothing to attach to the Forgejo release here.
# ── Auto-update manifest: bump version/build_number, stage + push to pangolin1 ──
# BUILD is derived the exact same way compile-android.sh derives the APK's
# Android versionCode (major*10000 + minor*100 + patch) so the manifest's
# build_number stays consistent with the shipped APK for a given tag, rather
# than being an independently-incremented counter like jiu's.
MAJOR="${VER%%.*}"
_REST="${VER#*.}"
MINOR="${_REST%%.*}"
PATCH="${_REST#*.}"
PATCH="${PATCH%%-*}"
BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
MANIFEST_TMPL="${REPO_ROOT}/deploy/single-node/version.yaml"
MANIFEST_OUT="/tmp/pangolin_version_manifest.$$.yaml"
if [ -f "$MANIFEST_TMPL" ]; then
: > "$MANIFEST_OUT"
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
version:*) printf 'version: "%s"\n' "$VER" >> "$MANIFEST_OUT" ;;
build_number:*) printf 'build_number: %d\n' "$BUILD" >> "$MANIFEST_OUT" ;;
*) printf '%s\n' "$line" >> "$MANIFEST_OUT" ;;
esac
done < "$MANIFEST_TMPL"
mkdir -p dist
cp "$MANIFEST_OUT" dist/version.yaml
# Stage as a release asset too, so a manual rollback can re-fetch the exact
# manifest that shipped alongside this tag (mirrors jiu's rationale).
forgejo_upload_asset "$TAG" dist/version.yaml
if [ -n "${DEPLOY_SSH_KEY:-}" ]; then
echo "==> release-client: pushing version.yaml (version=${VER} build_number=${BUILD}) to pangolin1"
setup_ssh
SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}"
$SSH "root@${DEPLOY_HOST}" "mkdir -p /etc/pangolin"
$SCP "$MANIFEST_OUT" "root@${DEPLOY_HOST}:/tmp/pangolin_version.yaml.new"
$SSH "root@${DEPLOY_HOST}" "mv /tmp/pangolin_version.yaml.new /etc/pangolin/version.yaml && chown pangolin:pangolin /etc/pangolin/version.yaml && chmod 644 /etc/pangolin/version.yaml"
echo "==> release-client: version.yaml deployed to pangolin1 (/etc/pangolin/version.yaml)"
else
echo "==> release-client: DEPLOY_SSH_KEY not set — skipping live manifest push (dist/version.yaml still staged + uploaded as a release asset)"
fi
rm -f "$MANIFEST_OUT"
else
echo "==> release-client: WARN manifest template ${MANIFEST_TMPL} not found — skipping version.yaml update" >&2
fi
echo "==> release-client: done — Release ${TAG} updated. dist/ contents:"
ls -lh dist/ 2>/dev/null || true
+22
View File
@@ -123,6 +123,8 @@ func main() {
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(apierr.Middleware)
// CORS:Web 用户中心(app.yanmeiai.com)跨域调 /v1/*;原生端不受影响。
r.Use(httpapi.NewCORS())
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -130,6 +132,19 @@ func main() {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Public (no auth): 客户端安装包下载(官网下载按钮直链)。CI
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到 DOWNLOADS_DIR,按平台固定
// 文件名覆盖;目录不存在也不影响启动,只是请求 404(见 DownloadsHandler 注释)。
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
r.Get("/downloads/*", downloadsHandler.Serve)
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
r.Get("/version", versionHandler.Serve)
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
// (below) when both are enabled, so they share one Redis-backed store.
var sharedProbeStore *probe.Store
@@ -370,6 +385,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
v1.Post("/auth/login", authHandler.Login)
v1.Post("/auth/refresh", authHandler.Refresh)
v1.Post("/auth/logout", authHandler.Logout)
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
// 受保护分组里的签票端 /auth/web-ticket)。
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
if totpHandler != nil {
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
}
@@ -397,6 +415,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
}
})
protected.Post("/redeem", redeemHandler.ServeHTTP)
if authHandler != nil {
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
protected.Post("/auth/web-ticket", authHandler.WebTicket)
}
protected.Get("/usage", usageHandler.ServeHTTP)
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
+9
View File
@@ -83,4 +83,13 @@ var (
MessageZH: "服务器内部错误,请稍后重试",
MessageEn: "Internal server error, please try again later",
}
// ErrTicketInvalid — the web-ticket (App→网页免登录一次性票据) is missing,
// already used, or expired. Intentionally generic (mirrors ErrCodeInvalid) so
// the three cases can't be distinguished from the response.
ErrTicketInvalid = &apierr.Error{
Code: "auth.ticket_invalid",
MessageZH: "登录票据无效或已过期,请重新从 App 打开",
MessageEn: "Login ticket is invalid or expired, please reopen from the app",
}
)
+70 -1
View File
@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/auth/login", h.Login)
r.Post("/auth/refresh", h.Refresh)
r.Post("/auth/logout", h.Logout)
r.Post("/auth/web-ticket/exchange", h.WebTicketExchange)
}
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
@@ -158,6 +159,74 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
writeTokenPair(w, pair)
}
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
// webTicketRequest / webTicketResponse — POST /v1/auth/web-ticket (auth-required).
type webTicketResponse struct {
Ticket string `json:"ticket"`
ExpiresIn int `json:"expires_in"`
}
// WebTicket handles POST /v1/auth/web-ticket (auth-required, mounted in the
// RequireAuth group — see main.go). Mints a one-time ticket for the currently
// authenticated user so the app can open the web user-center pre-authenticated
// (`https://<host>/sso?t=<ticket>`). Rate-limited to 1/sec/user.
func (h *Handler) WebTicket(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
writeAPIErr(w, ErrUnauthorized, 0)
return
}
uuid, ok := UserUUIDFromContext(r.Context())
if !ok {
writeAPIErr(w, ErrUnauthorized, 0)
return
}
ticket, ttl, retryAfter, apiErr := h.svc.IssueWebTicket(r.Context(), uid, uuid)
if apiErr != nil {
writeAPIErr(w, apiErr, retryAfter)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(webTicketResponse{Ticket: ticket, ExpiresIn: ttl})
}
// webTicketExchangeRequest is the public exchange body.
type webTicketExchangeRequest struct {
Ticket string `json:"ticket"`
Device deviceBody `json:"device"`
}
// WebTicketExchange handles POST /v1/auth/web-ticket/exchange (public, no
// bearer auth — the ticket itself is the credential). Validates+consumes the
// one-time ticket and issues the SAME token-pair shape as a normal login for
// the ticket's user, registering the device like Login does. Invalid, expired,
// or already-used tickets all map to a generic 401.
func (h *Handler) WebTicketExchange(w http.ResponseWriter, r *http.Request) {
var req webTicketExchangeRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Ticket == "" {
writeAPIErr(w, ErrInvalidRequest, 0)
return
}
pair, deviceLimit, apiErr := h.svc.LoginWithWebTicket(r.Context(), req.Ticket, clientIP(r), req.Device.toMeta())
if apiErr != nil {
writeAPIErr(w, apiErr, 0)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(tokenPairResponse{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
DeviceLimit: deviceLimit,
})
}
// ---- helpers ----
// decodeJSON decodes the request body, writing a 400 on malformed input.
@@ -206,7 +275,7 @@ func statusFor(e *apierr.Error) int {
return http.StatusConflict
case ErrRateLimited.Code, ErrAccountLocked.Code:
return http.StatusTooManyRequests
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code, ErrTicketInvalid.Code:
return http.StatusUnauthorized
case ErrAccountBanned.Code:
return http.StatusForbidden
+130
View File
@@ -0,0 +1,130 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
//
// The app (already holding a valid JWT) mints a one-time ticket via
// IssueWebTicket and opens the system browser at .../sso?t=<ticket>. The web
// user-center exchanges the ticket via LoginWithWebTicket for a normal token
// pair — same shape as /auth/login — without ever seeing the app's own tokens.
//
// Storage: Redis only (no DB row). Tickets are single-instance, ephemeral
// (60s TTL) credentials — a DB table would need its own cleanup job for what
// Redis already gives for free via key expiry. Consumption uses GETDEL, which
// is atomic server-side: a replayed/concurrent second exchange always loses
// the race and gets redis.Nil, so single-use is enforced without a CAS dance.
const (
// webTicketTTL is the ticket lifetime: long enough for the system browser
// to open and hit the exchange endpoint, short enough to bound replay risk.
webTicketTTL = 60 * time.Second
// webTicketPrefix namespaces ticket keys in Redis.
webTicketPrefix = "auth:webticket:"
// scopeWebTicket is the rate-limit scope for ticket issuance (1/sec/user).
scopeWebTicket = "webticket"
)
func webTicketKey(ticket string) string { return webTicketPrefix + ticket }
// genWebTicket returns a fresh, cryptographically random one-time ticket. 32
// bytes (256 bits) of entropy makes guessing infeasible within the 60s TTL.
func genWebTicket() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("auth: gen web ticket: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// IssueWebTicket mints a one-time ticket for an already-authenticated user
// (userID/userUUID come from the caller's validated JWT claims — see
// Handler.WebTicket). Rate-limited to 1/sec/user as a rebuff-abuse backstop
// (the endpoint already requires login). The ticket value itself never
// carries a real token — only enough to look the user up on exchange.
func (s *Service) IssueWebTicket(ctx context.Context, userID int64, userUUID string) (ticket string, ttlSeconds int, retryAfter time.Duration, apiErr *apierr.Error) {
ok, ra, err := s.rl.Allow(ctx, scopeWebTicket, strconv.FormatInt(userID, 10), 1, time.Second)
if err != nil {
return "", 0, 0, ErrInternal
}
if !ok {
return "", 0, ra, ErrRateLimited
}
tk, err := genWebTicket()
if err != nil {
return "", 0, 0, ErrInternal
}
val := userID2UUID(userID, userUUID)
if err := s.rdb.Set(ctx, webTicketKey(tk), val, webTicketTTL).Err(); err != nil {
return "", 0, 0, ErrInternal
}
return tk, int(webTicketTTL.Seconds()), 0, nil
}
// LoginWithWebTicket validates+atomically-consumes a one-time ticket and
// issues a fresh token pair for its user, registering the device exactly like
// a normal Login (best-effort — see recordLogin). Any invalid/expired/
// already-used ticket collapses to ErrTicketInvalid (no distinguishing info
// leaked).
func (s *Service) LoginWithWebTicket(ctx context.Context, ticket, ip string, device DeviceMeta) (*TokenPair, *DeviceLimit, *apierr.Error) {
if ticket == "" {
return nil, nil, ErrTicketInvalid
}
// GETDEL is atomic: concurrent/replayed exchanges of the same ticket race
// on a single Redis command, so exactly one caller ever sees the value.
val, err := s.rdb.GetDel(ctx, webTicketKey(ticket)).Result()
if errors.Is(err, redis.Nil) {
return nil, nil, ErrTicketInvalid
}
if err != nil {
return nil, nil, ErrInternal
}
userID, userUUID, ok := splitUserID2UUID(val)
if !ok {
// Our own format, corrupt only via a Redis-level anomaly — never surface
// internals to the (unauthenticated) caller.
return nil, nil, ErrInternal
}
pair, jti, err := s.tokens.IssueWithJTI(ctx, userID, userUUID)
if err != nil {
return nil, nil, ErrInternal
}
dl := s.recordLogin(ctx, userID, jti, ip, device)
return pair, dl, nil
}
// userID2UUID / splitUserID2UUID encode the ticket's Redis value as
// "<userID>:<userUUID>". UUIDs are hyphenated hex (no ':'), so a single
// SplitN(2) round-trips unambiguously.
func userID2UUID(userID int64, userUUID string) string {
return strconv.FormatInt(userID, 10) + ":" + userUUID
}
func splitUserID2UUID(val string) (userID int64, userUUID string, ok bool) {
parts := strings.SplitN(val, ":", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, "", false
}
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id == 0 {
return 0, "", false
}
return id, parts[1], true
}
+186
View File
@@ -0,0 +1,186 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// newWebTicketHandler wires a Handler with BOTH the public routes (via
// RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how
// main.go mounts them in two separate groups (public vs RequireAuth).
func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
t.Helper()
svc, _, _ := newService(t, cfg)
h := NewHandler(svc)
r := chi.NewRouter()
h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange
r.Group(func(protected chi.Router) {
protected.Use(RequireAuth(svc.tokens))
protected.Post("/auth/web-ticket", h.WebTicket)
})
return svc, r
}
// issueAccessToken mints a real, valid access token for a fresh user id/uuid
// pair — bypassing full register/login, since the ticket flow only cares that
// RequireAuth's middleware injects a valid uid/uuid into the request context.
func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) {
t.Helper()
userID = 42
userUUID = uuid.NewString()
pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID)
if err != nil {
t.Fatalf("issue access token: %v", err)
}
return pair.AccessToken, userID, userUUID
}
func doAuthed(t *testing.T, h http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.RemoteAddr = "203.0.113.5:1234"
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// TestWebTicket_IssueAndExchange covers the full happy path: an authed app
// mints a ticket, the (unauthenticated) web side exchanges it for a token
// pair shaped exactly like a normal /auth/login response.
func TestWebTicket_IssueAndExchange(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, wantUID, wantUUID := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body)
}
var issued webTicketResponse
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
t.Fatalf("decode issue response: %v", err)
}
if issued.Ticket == "" {
t.Fatal("empty ticket")
}
if issued.ExpiresIn != 60 {
t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}})
if rec.Code != http.StatusOK {
t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body)
}
var pair tokenPairResponse
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
t.Fatalf("decode exchange response: %v", err)
}
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
t.Fatalf("bad token pair: %+v", pair)
}
// The exchanged access token must authenticate as the SAME user the app
// ticket was issued for.
claims, err := svc.tokens.ParseAccess(pair.AccessToken)
if err != nil {
t.Fatalf("parse exchanged access token: %v", err)
}
if claims.UID != wantUID || claims.Subject != wantUUID {
t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s",
claims.UID, claims.Subject, wantUID, wantUUID)
}
}
// TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same
// ticket (replay) must fail 401, even though the first succeeded.
func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
var issued webTicketResponse
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusOK {
t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of
// this package does (service_test.go TestService_CodeExpired): delete the
// Redis key directly rather than fast-forwarding a clock.
func TestWebTicket_Expired_Rejected(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
var issued webTicketResponse
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket))
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": issued.Ticket})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued.
func TestWebTicket_Bogus_Rejected(t *testing.T) {
_, h := newWebTicketHandler(t, ServiceConfig{})
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
map[string]any{"ticket": "not-a-real-ticket"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
}
}
// TestWebTicket_RequiresAuth: minting a ticket without a bearer token is
// rejected by RequireAuth before it ever reaches the handler.
func TestWebTicket_RequiresAuth(t *testing.T) {
_, h := newWebTicketHandler(t, ServiceConfig{})
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code)
}
}
// TestWebTicket_RateLimited: a second ticket request within the same second
// for the same user is rejected 429 (rebuff-abuse backstop; the endpoint
// already requires login).
func TestWebTicket_RateLimited(t *testing.T) {
svc, h := newWebTicketHandler(t, ServiceConfig{})
token, _, _ := issueAccessToken(t, svc)
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body)
}
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body)
}
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"os"
"strings"
)
// NewCORS 返回一个 CORS 中间件:为白名单 Origin(精确匹配)补 CORS 响应头,并直接
// 应答 OPTIONS 预检。Web 用户中心(app.yanmeiai.com,浏览器)跨域调用 /v1/*,浏览器
// 会先发预检、并校验 Access-Control-Allow-Origin;原生移动/桌面客户端不是浏览器、
// 不受 CORS 约束,故此前无需 CORS。
//
// Origin 白名单来自 CORS_ORIGINS(逗号分隔),缺省含 usercenter 的正式域与 pages.dev。
// 认证走 Authorization: Bearer(非 cookie),所以不需要 Allow-Credentials。
func NewCORS() func(http.Handler) http.Handler {
raw := os.Getenv("CORS_ORIGINS")
if raw == "" {
raw = "https://app.yanmeiai.com,https://pangolin-usercenter.pages.dev"
}
allowed := map[string]bool{}
for _, o := range strings.Split(raw, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed[o] = true
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && allowed[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
h.Set("Access-Control-Max-Age", "600")
}
// 预检请求直接 204(不落到业务路由,避免 /v1/... 的 OPTIONS 404)。
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestCORS(t *testing.T) {
t.Setenv("CORS_ORIGINS", "https://app.yanmeiai.com")
mw := NewCORS()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
h := mw(next)
// 允许的 Origin:补 Allow-Origin,普通请求继续。
r := httptest.NewRequest("POST", "/v1/auth/login", nil)
r.Header.Set("Origin", "https://app.yanmeiai.com")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.yanmeiai.com" {
t.Fatalf("allowed origin: want header, got %q", got)
}
if w.Code != 200 {
t.Fatalf("non-preflight should reach next, got %d", w.Code)
}
// OPTIONS 预检:204,不落到业务。
r = httptest.NewRequest("OPTIONS", "/v1/auth/login", nil)
r.Header.Set("Origin", "https://app.yanmeiai.com")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNoContent {
t.Fatalf("preflight: want 204, got %d", w.Code)
}
if w.Header().Get("Access-Control-Allow-Methods") == "" {
t.Fatalf("preflight missing Allow-Methods")
}
// 未白名单 Origin:不补 Allow-Origin。
r = httptest.NewRequest("POST", "/v1/auth/login", nil)
r.Header.Set("Origin", "https://evil.example.com")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("disallowed origin should get no header, got %q", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package httpapi
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
)
// DownloadsHandler 静态服务客户端安装包(pangolin-android.apk /
// pangolin-windows-x64-setup.exe 等),供官网下载按钮直链。CI
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到该目录、按平台固定文件名覆盖。
//
// 无需鉴权(匿名下载),但:
// - 不暴露目录列表(裸目录 / 不存在的文件一律 404,不用 http.FileServer 的默认
// 目录浏览行为)
// - 防目录穿越(拒绝任何解析后逃出 dir 的路径,而不仅仅是字符串里含 ".." 就拒,
// 这样能正确处理 "foo/../bar" 这类仍落在 dir 内的写法,同时挡住真正逃逸的路径)
// - DOWNLOADS_DIR 在启动时可以不存在(比如还没跑过一次 CI 部署),路由仍要能
// 注册,只是请求会 404,不能让进程直接崩溃
type DownloadsHandler struct {
dir string
}
// NewDownloadsHandler 指定安装包所在目录(DOWNLOADS_DIR,默认 /var/lib/pangolin/downloads)。
func NewDownloadsHandler(dir string) *DownloadsHandler {
if dir == "" {
dir = "/var/lib/pangolin/downloads"
}
return &DownloadsHandler{dir: dir}
}
// Serve 处理 GET /downloads/{file}(chi 通配 "*",支持任意文件名,不限定白名单,
// 因为 CI 产出的安装包文件名随平台/版本命名策略变化,这里只做路径安全校验)。
func (h *DownloadsHandler) Serve(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "*")
if name == "" {
http.NotFound(w, r)
return
}
// 拒绝任何路径分隔符之外的穿越:先按 "/" 做 Clean,再校验结果既不是绝对路径、
// 也没有以 ".." 开头(即没有逃出 dir),最后拒绝空/根路径。这比单纯 strings.Contains(name, "..")
// 更准确 —— 例如 "sub/../file.apk" 清洗后是 "file.apk",本来就没有逃逸,不该被误杀;
// 而 "../etc/passwd" 清洗后以 ".." 开头,必须拒绝。
cleaned := filepath.Clean("/" + name) // 前置 "/" 后 Clean,任何 ".." 都无法越过根
cleaned = strings.TrimPrefix(cleaned, "/")
if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") {
http.NotFound(w, r)
return
}
full := filepath.Join(h.dir, cleaned)
// 双重保险:确认最终路径确实在 dir 之下(处理 dir 本身含 ".." 或符号链接等边角情况)。
relDir, err := filepath.Abs(h.dir)
if err != nil {
http.NotFound(w, r)
return
}
absFull, err := filepath.Abs(full)
if err != nil || (absFull != relDir && !strings.HasPrefix(absFull, relDir+string(filepath.Separator))) {
http.NotFound(w, r)
return
}
f, err := os.Open(full)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(cleaned)+"\"")
// no-cache:CI「仅留最新」在同一稳定 URL 覆盖文件,不能让 CF/浏览器返回旧包。
// no-cache = 可缓存但每次须回源校验;ServeContent 带 Last-Modified,未变则 304
// (便宜),变了才传新字节 —— 既保证永远最新,又避免每次全量 82MB 回源。
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, filepath.Base(cleaned), st.ModTime(), f)
}
+117
View File
@@ -0,0 +1,117 @@
package httpapi
import (
"context"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
)
// buildDownloadsRouter mounts DownloadsHandler on a real chi router (mirrors
// how main.go mounts it under "/downloads/*") so the wildcard param behaves
// exactly as it does in production.
func buildDownloadsRouter(dir string) chi.Router {
h := NewDownloadsHandler(dir)
r := chi.NewRouter()
r.Get("/downloads/*", h.Serve)
return r
}
func TestDownloadsHandler_ServesExistingFile(t *testing.T) {
dir := t.TempDir()
content := []byte("hello pangolin apk bytes")
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), content, 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/pangolin-android.apk", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if got := rec.Body.String(); got != string(content) {
t.Errorf("body = %q, want %q", got, content)
}
}
func TestDownloadsHandler_MissingFile404(t *testing.T) {
dir := t.TempDir()
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/nope.exe", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestDownloadsHandler_BareDirNotListed(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("x"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
req := httptest.NewRequest("GET", "/downloads/", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("bare dir status = %d, want 404 (no directory listing)", rec.Code)
}
}
func TestDownloadsHandler_PathTraversalBlocked(t *testing.T) {
outerDir := t.TempDir()
secretPath := filepath.Join(outerDir, "secret.txt")
if err := os.WriteFile(secretPath, []byte("top secret"), 0o600); err != nil {
t.Fatalf("write secret: %v", err)
}
dir := filepath.Join(outerDir, "downloads")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatalf("mkdir downloads: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("apk"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildDownloadsRouter(dir)
// net/http's ServeMux/chi normalize ".." segments in the URL path before
// routing, so we exercise the handler directly with a raw URLParam to
// simulate any escape attempt that might otherwise reach it, in addition
// to the router-level request below.
h := NewDownloadsHandler(dir)
req := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("*", "../secret.txt")
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
h.Serve(rec, req)
if rec.Code != 404 {
t.Fatalf("direct traversal status = %d, want 404 (must not escape dir)", rec.Code)
}
if rec.Body.String() == "top secret" {
t.Fatalf("traversal leaked secret file contents")
}
// Router-level request: most HTTP clients/servers collapse ".." during URL
// normalization, but confirm the end-to-end path also can't reach the file
// outside dir and doesn't 200 with the secret's contents.
req2 := httptest.NewRequest("GET", "/downloads/../secret.txt", nil)
rec2 := httptest.NewRecorder()
r.ServeHTTP(rec2, req2)
if rec2.Body.String() == "top secret" {
t.Fatalf("router-level traversal leaked secret file contents (status=%d)", rec2.Code)
}
}
+137
View File
@@ -0,0 +1,137 @@
package httpapi
import (
"encoding/json"
"net/http"
"os"
"gopkg.in/yaml.v3"
)
// defaultVersionManifestPath is VERSION_MANIFEST's default when unset — mirrors
// how server.env wires DOWNLOADS_DIR alongside DownloadsHandler's own built-in
// default (see downloads.go / deploy/single-node/deploy.sh).
const defaultVersionManifestPath = "/etc/pangolin/version.yaml"
// Fallback values used ONLY when the manifest file itself is missing (fresh
// box that hasn't run deploy/single-node/deploy.sh's manifest-install step
// yet, or a local dev server). These are hand-set, not auto-derived — once a
// box is deployed, the real source of truth is the on-disk manifest that
// scripts/ci/release-client.sh overwrites on every client-v* release.
const (
defaultManifestVersion = "1.0.48"
defaultManifestBuildNumber = 10048
)
// changelogSection / changelogEntry mirror jiu's backend/config/version.yaml
// shape (~/code/jiu/backend/internal/handler/version.go) so any future shared
// tooling (e.g. a website changelog widget) can treat both services'
// /version responses identically. Pangolin's release pipeline doesn't
// populate Changelog yet (no CHANGELOG-client.md parsing wired in
// scripts/ci/release-client.sh) — the field exists for shape-compatibility
// and always serializes as [] rather than null.
type changelogSection struct {
Type string `yaml:"type" json:"type"`
Items []string `yaml:"items" json:"items"`
}
type changelogEntry struct {
Version string `yaml:"version" json:"version"`
Date string `yaml:"date" json:"date"`
Intro string `yaml:"intro" json:"intro"`
Sections []changelogSection `yaml:"sections" json:"sections"`
}
// versionManifest is the on-disk (and wire) shape of the auto-update
// manifest. download_urls keys in practice: macos, windows, ios, android
// (web is intentionally not used — pangolin's client is native-only).
type versionManifest struct {
Version string `yaml:"version" json:"version"`
BuildNumber int `yaml:"build_number" json:"build_number"`
ForceUpdate bool `yaml:"force_update" json:"force_update"`
ReleaseNotes string `yaml:"release_notes" json:"release_notes"`
DownloadURLs map[string]string `yaml:"download_urls" json:"download_urls"`
Changelog []changelogEntry `yaml:"changelog" json:"changelog"`
}
// VersionHandler serves GET /version (public, no auth — mounted directly in
// main.go next to /healthz and /downloads/*). Unlike most handlers in this
// package it re-reads its manifest file from disk on EVERY request rather
// than caching it in memory: scripts/ci/release-client.sh rewrites
// /etc/pangolin/version.yaml's version/build_number on each client-v*
// release, and that must take effect immediately without a control-plane
// restart or redeploy (mirrors jiu's loadVersionConfig()-per-request).
type VersionHandler struct {
path string
}
// NewVersionHandler builds a VersionHandler reading the manifest at path.
// path=="" falls back to defaultVersionManifestPath (in production this is
// overridden via the VERSION_MANIFEST env var — see main.go).
func NewVersionHandler(path string) *VersionHandler {
if path == "" {
path = defaultVersionManifestPath
}
return &VersionHandler{path: path}
}
// defaultManifest is returned when the manifest file doesn't exist yet, so
// GET /version still answers usefully (client update-checks shouldn't hard
// fail just because deploy/single-node/deploy.sh hasn't run on this box).
func defaultManifest() versionManifest {
return versionManifest{
Version: defaultManifestVersion,
BuildNumber: defaultManifestBuildNumber,
ForceUpdate: false,
ReleaseNotes: "",
DownloadURLs: map[string]string{
"android": "https://api.yanmeiai.com/downloads/pangolin-android.apk",
"windows": "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe",
"macos": "",
"ios": "",
},
Changelog: []changelogEntry{},
}
}
// loadManifest reads+parses h.path. A missing file is NOT an error (falls
// back to defaultManifest()); any other read/parse failure IS, so ServeHTTP
// can 500 instead of silently masking a corrupt manifest written by a bad
// release-client.sh run.
func (h *VersionHandler) loadManifest() (versionManifest, error) {
data, err := os.ReadFile(h.path)
if err != nil {
if os.IsNotExist(err) {
return defaultManifest(), nil
}
return versionManifest{}, err
}
var m versionManifest
if err := yaml.Unmarshal(data, &m); err != nil {
return versionManifest{}, err
}
// Keep the JSON response shape stable (empty object/array, never null)
// regardless of what the manifest on disk happens to omit.
if m.DownloadURLs == nil {
m.DownloadURLs = map[string]string{}
}
if m.Changelog == nil {
m.Changelog = []changelogEntry{}
}
return m, nil
}
// Serve handles GET /version. Named Serve (not ServeHTTP) to match
// DownloadsHandler's convention in this package (see downloads.go).
func (h *VersionHandler) Serve(w http.ResponseWriter, r *http.Request) {
m, err := h.loadManifest()
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "version manifest unavailable"})
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(m)
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
)
// buildVersionRouter mounts VersionHandler on a real chi router (mirrors how
// main.go mounts GET /version) so routing behaves exactly as in production.
func buildVersionRouter(path string) chi.Router {
h := NewVersionHandler(path)
r := chi.NewRouter()
r.Get("/version", h.Serve)
return r
}
func TestVersionHandler_ServesManifestFromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "version.yaml")
yamlContent := `version: "1.2.3"
build_number: 10203
force_update: true
release_notes: "测试发布说明"
download_urls:
android: "https://api.yanmeiai.com/downloads/pangolin-android.apk"
windows: "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe"
macos: ""
ios: ""
changelog:
- version: "1.2.3"
date: "2026-07-06"
intro: "小版本更新"
sections:
- type: "新增"
items:
- "示例条目"
`
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got versionManifest
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
}
if got.Version != "1.2.3" {
t.Errorf("version = %q, want %q", got.Version, "1.2.3")
}
if got.BuildNumber != 10203 {
t.Errorf("build_number = %d, want %d", got.BuildNumber, 10203)
}
if !got.ForceUpdate {
t.Errorf("force_update = false, want true")
}
if got.ReleaseNotes != "测试发布说明" {
t.Errorf("release_notes = %q, want %q", got.ReleaseNotes, "测试发布说明")
}
if got.DownloadURLs["android"] != "https://api.yanmeiai.com/downloads/pangolin-android.apk" {
t.Errorf("download_urls.android = %q", got.DownloadURLs["android"])
}
if got.DownloadURLs["windows"] != "https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe" {
t.Errorf("download_urls.windows = %q", got.DownloadURLs["windows"])
}
if got.DownloadURLs["macos"] != "" || got.DownloadURLs["ios"] != "" {
t.Errorf("expected empty macos/ios download URLs, got macos=%q ios=%q", got.DownloadURLs["macos"], got.DownloadURLs["ios"])
}
if len(got.Changelog) != 1 || got.Changelog[0].Version != "1.2.3" {
t.Errorf("changelog = %+v, want 1 entry for version 1.2.3", got.Changelog)
}
}
func TestVersionHandler_MissingFileReturnsDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "does-not-exist.yaml")
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200 (missing file falls back to default manifest); body=%s", rec.Code, rec.Body.String())
}
var got versionManifest
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v; body=%s", err, rec.Body.String())
}
if got.Version == "" {
t.Errorf("default manifest: version should not be empty")
}
if got.DownloadURLs["android"] == "" {
t.Errorf("default manifest: download_urls.android should not be empty")
}
if got.DownloadURLs["windows"] == "" {
t.Errorf("default manifest: download_urls.windows should not be empty")
}
if got.Changelog == nil {
t.Errorf("default manifest: changelog should be [] not null")
}
}
func TestVersionHandler_JSONShape(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "version.yaml")
if err := os.WriteFile(path, []byte(`version: "1.0.0"
build_number: 10000
force_update: false
release_notes: ""
download_urls:
android: "https://example.com/a.apk"
`), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
r := buildVersionRouter(path)
req := httptest.NewRequest("GET", "/version", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var raw map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatalf("unmarshal raw response: %v", err)
}
for _, key := range []string{"version", "build_number", "force_update", "release_notes", "download_urls", "changelog"} {
if _, ok := raw[key]; !ok {
t.Errorf("response missing expected top-level key %q; body=%s", key, rec.Body.String())
}
}
// changelog must serialize as an array even when the manifest omits it —
// front-ends should be able to blindly .map()/range over it.
if string(raw["changelog"]) != "[]" {
t.Errorf("changelog = %s, want []", raw["changelog"])
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
}
func TestVersionHandler_DefaultPathFallback(t *testing.T) {
h := NewVersionHandler("")
if h.path != defaultVersionManifestPath {
t.Errorf("NewVersionHandler(\"\").path = %q, want %q", h.path, defaultVersionManifestPath)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" data-theme="light">
<html lang="zh" data-theme="light" suppressHydrationWarning>
<head>
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
+102
View File
@@ -0,0 +1,102 @@
'use client';
// app/sso/page.tsx — App→网页免登录落地页(镜像 jiu 的 web/sso.njk)。
//
// 流程:App 已持正常登录态,先 POST /v1/auth/web-ticket 签一次性票据(60s TTL、
// 单次可用),再打开系统浏览器到 https://<usercenter>/sso/?t=<ticket>&redirect=<本站路径>。
// 本页凭票 POST /v1/auth/web-ticket/exchange(公开端点)兑换与 login() 同形状的
// token pair,经与登录页相同的 setSession(见 lib/api/http.ts::exchangeWebTicket)
// 落地会话,再跳 redirect —— 其余页面(UserCenter.tsx)据此把它当作一次正常登录。
//
// 因 next.config.js 是 output:'export' 的纯静态导出,这里没有服务端 searchParams
// 可用,票据与 redirect 目标都在挂载后从 window.location.search 读取。
//
// 安全要点(与 jiu 的 sso.njk 一致)
// - URL 只携带一次性票据,从不携带真正的 access/refresh token;
// - redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
// 路径,其余一律回退到 '/'(登录页),防 open redirect;
// - 票据用后立即从地址栏抹掉(history.replaceState),不留浏览器历史;
// - 兑换失败(票据无效/过期/已用)一律落回登录页('/'),不泄露失败原因细节。
import React, { useEffect, useState } from 'react';
import { Mark } from '../../components/icons';
import { ErrorLine } from '../../components/Login';
import { card } from '../../components/shared';
import { useUI } from '../../lib/theme';
import { makeT } from '../../lib/i18n';
import { getClient } from '../../lib/api/client';
import { bilingual } from '../../lib/api/errors';
/** redirect 白名单:仅本站相对路径(单个 '/' 开头),其余一律回 '/'。 */
function safeRedirect(raw: string | null): string {
if (!raw) return '/';
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return '/';
return raw;
}
export default function SsoPage() {
const { lang } = useUI();
const t = makeT(lang);
const [failed, setFailed] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
let alive = true;
const params = new URLSearchParams(window.location.search);
const ticket = params.get('t');
const redirect = safeRedirect(params.get('redirect'));
// 票据只用一次,立刻从地址栏抹掉,不留浏览器历史(与 jiu 一致)。
try {
window.history.replaceState(null, '', '/sso/');
} catch {
/* ignore */
}
if (!ticket) {
window.location.replace('/');
return;
}
const api = getClient();
api
.exchangeWebTicket(ticket)
.then(() => {
if (!alive) return;
window.location.replace(redirect);
})
.catch((e) => {
if (!alive) return;
setMsg(bilingual(e, lang));
setFailed(true);
setTimeout(() => {
window.location.replace('/');
}, 1500);
});
return () => {
alive = false;
};
// 仅挂载时读取一次 URL 并发起兑换;lang 变化不应重跑网络请求。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
<div style={{ ...card, width: 420, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box', textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 18 }}>
<Mark size={32} />
</div>
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>
{failed ? t('ssoFailTitle') : t('ssoTitle')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '8px 0 0' }}>
{failed ? t('ssoFailHint') : t('ssoMsg')}
</div>
{failed && msg && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<ErrorLine text={msg} />
</div>
)}
</div>
</div>
);
}
+3 -4
View File
@@ -100,10 +100,9 @@ export default function UserCenter() {
setView('overview');
}
if (!ready) {
return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />;
}
if (!authed) {
// 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白
// 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。
if (!ready || !authed) {
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
}
+1
View File
@@ -18,6 +18,7 @@ export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
device_not_found: { zh: '设备不存在', en: 'Device not found' },
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app' },
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
};
+15
View File
@@ -142,6 +142,21 @@ export class HttpClient implements ApiClient {
return session;
}
// App→Web 免登录:POST /v1/auth/web-ticket/exchange(公开端点,无 Authorization 头)。
// 服务端消费一次性票据后返回与 /v1/auth/login 相同的扁平 TokenPair(不会走 TOTP 分支——
// 票据本身已代表 App 内已完成的完整登录),故直接映射 session 并 setSession,与
// login()/loginTotp() 落地同一份会话状态。
async exchangeWebTicket(ticket: string): Promise<Session> {
const r = await this.request<RawTokenPair>('/v1/auth/web-ticket/exchange', {
method: 'POST',
body: { ticket },
auth: false,
});
const session = mapSession(r);
setSession(session);
return session;
}
async refresh(): Promise<Session> {
const rt = getRefreshToken();
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
+15
View File
@@ -64,6 +64,21 @@ export class MockClient implements ApiClient {
return s;
}
// 演示态:任意非 'invalid' 票据都兑换成功;'invalid' 用于验收失败态文案。
async exchangeWebTicket(ticket: string): Promise<Session> {
await delay(300);
if (ticket === 'invalid') {
throw new ApiError({
code: 'auth.ticket_invalid',
message_zh: '登录票据无效或已过期,请重新从 App 打开',
message_en: 'Login ticket is invalid or expired, please reopen from the app',
});
}
const s = makeSession();
setSession(s);
return s;
}
async refresh(): Promise<Session> {
await delay(150);
const s = makeSession();
+2
View File
@@ -82,6 +82,8 @@ export interface ApiClient {
login(email: string, password: string): Promise<LoginResult>;
/** 登录二段式:提交 TOTP 动态码换取 session */
loginTotp(pendingToken: string, code: string): Promise<Session>;
/** App→Web 免登录:凭一次性票据兑换与 login() 同形状的会话(公开端点,无需 TOTP) */
exchangeWebTicket(ticket: string): Promise<Session>;
refresh(): Promise<Session>;
getMe(): Promise<Me>;
getSubscription(): Promise<SubscriptionInfo>;
+6
View File
@@ -27,6 +27,12 @@ export const STRINGS: Record<string, Entry> = {
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
/* sso(App→网页免登录落地页) */
ssoTitle: { zh: '正在登录…', en: 'Signing in…' },
ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app' },
ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed' },
ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually' },
/* overview */
greeting: { zh: '欢迎回来', en: 'Welcome back' },
curPlan: { zh: '当前套餐', en: 'Current plan' },
+11 -4
View File
@@ -1,15 +1,18 @@
---
import Icon from './Icon.astro';
import type { T } from '../i18n/strings';
import { SITE } from '../config/site';
interface Props { t: T }
const { t } = Astro.props;
const plats = [
// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。
// Android + Windows 已由客户端 CI 产出真实产物并部署到 /downloads。
const plats: { icon: string; name: string; ver: string; href?: string }[] = [
{ icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' },
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+' },
{ icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android },
{ icon: 'laptop', name: 'macOS', ver: 'macOS 12+' },
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11' },
{ icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows },
{ icon: 'terminal', name: 'Linux', ver: 'deb / rpm' },
];
---
@@ -26,7 +29,11 @@ const plats = [
<div class="ico"><Icon name={p.icon} /></div>
<div class="pn">{p.name}</div>
<div class="pv">{p.ver}</div>
<a class="gb"><Icon name="download" /><span>{t('dl.get')}</span></a>
{p.href ? (
<a class="gb" href={p.href}><Icon name="download" /><span>{t('dl.get')}</span></a>
) : (
<span class="gb disabled" aria-disabled="true"><Icon name="download" /><span>{t('dl.soon')}</span></span>
)}
</div>
))}
</div>
+2 -2
View File
@@ -5,6 +5,7 @@
*/
import { useEffect, useState } from 'react';
import { Download, Menu } from 'lucide-react';
import { SITE } from '../config/site';
function Mark() {
return (
@@ -60,8 +61,7 @@ export default function Header({ lang = 'zh', t = {} }) {
<a data-lang="zh" class={lang === 'zh' ? 'on' : undefined} href="/">中文</a>
<a data-lang="en" class={lang === 'en' ? 'on' : undefined} href="/en/">EN</a>
</div>
{/* 「登录」暂移除:Web 用户中心(web/usercenter)未部署、无 URL;待 #30 CI/CD
部署后接上 usercenter 登录地址再恢复。 */}
<a class="linklogin" href={SITE.usercenter}>{t.login}</a>
<a class="btn btn-primary" href="#download">
<Download />
<span>{t.get}</span>
+14
View File
@@ -19,4 +19,18 @@ export const SITE = {
/** 自助发卡商店 —— 占位待定(#24)。 */
store: { label: 'shop.pangolin.vpn' },
/** Web 用户中心(web/usercenterNext.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */
usercenter: 'https://app.yanmeiai.com',
/**
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
* /downloads/<file>origin = 127.0.0.1:8080),文件由
* scripts/ci/deploy-client.sh 每次构建 scp 覆盖到 pangolin1:/var/lib/pangolin/downloads/
* 每平台固定文件名、只保留最新一份。iOS / macOS / Linux 本轮未接入下载按钮。
*/
downloads: {
android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk',
windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe',
},
} as const;
+1
View File
@@ -128,6 +128,7 @@ export const STRINGS: Record<string, [string, string]> = {
'dl.h': ['全平台,随处可用', 'Every platform, everywhere'],
'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'],
'dl.get': ['下载', 'Download'],
'dl.soon': ['敬请期待', 'Coming soon'],
'docs.eyebrow': ['文档', 'Docs'],
'docs.h': ['需要帮助?都在这里', 'Need help? Its all here'],
+2
View File
@@ -189,6 +189,8 @@ section{padding:88px 0}
.dl .pv{font-size:12px;color:var(--fg3);margin:3px 0 14px;font-family:var(--font-mono)}
.dl .gb{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--accent)}
.dl .gb svg{width:14px;height:14px}
.dl .gb.disabled{color:var(--fg3);cursor:not-allowed;pointer-events:none}
.dl .gb.disabled svg{opacity:.5}
/* ---------- docs ---------- */
.docs-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-top:44px}