diff --git a/.gitea/workflows/backup.yml b/.gitea/workflows/backup.yml new file mode 100644 index 0000000..640bc78 --- /dev/null +++ b/.gitea/workflows/backup.yml @@ -0,0 +1,39 @@ +name: DB Backup + +on: + # 每日北京 02:00(= UTC 18:00)自动备份;也可手动触发。 + schedule: + - cron: '0 18 * * *' + workflow_dispatch: + +concurrency: + group: db-backup + cancel-in-progress: false + +jobs: + backup: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Backup pangolin1 SQLite → NAS + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + mkdir -p /volume1/docker/backups/pangolin + bash scripts/ci/backup-db.sh 2>&1 | tee /volume1/docker/backups/pangolin/_last-run.log + exit "${PIPESTATUS[0]}" + + - name: Notify (Telegram) + if: always() + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + run: | + . scripts/ci/notify.sh + if [ "${{ job.status }}" = "success" ]; then + notify_ok "pangolin1 SQLite 备份成功(NAS)" + else + notify_fail "pangolin1 SQLite 备份失败" + fi diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1aea2df..db6c1bd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -10,11 +10,19 @@ on: - 'client/**' - 'server/**' - 'ci/**' + - 'scripts/ci/**' - '.gitea/workflows/ci.yml' pull_request: branches: [main] workflow_dispatch: +# runner 分配: +# · runs-on: ubuntu-latest —— 在 catthehacker 容器里跑,仅用于纯 bash 扫描 +# (redline/cleartext/portable-sql);容器内**不能**嵌套 `docker run`(DinD 挂载 +# 失败,$PWD 在宿主不存在),故套 docker 的 job 不能用它。 +# · runs-on: nas —— host 模式(mac-pangolin-2 直接在宿主跑),`docker run` 是宿主 +# 真 docker(非嵌套),可正常拉/跑 node/golang/flutter/python/shellcheck 镜像。 +# golden 保留 ghcr.io/cirruslabs/flutter Linux 容器 → 与入库基线渲染一致。 jobs: # ── Job 1: Lint (shellcheck) ───────────────────────────────────────────── @@ -38,6 +46,38 @@ jobs: /mnt/deploy/bootstrap/monitor/deadman-watch.sh \ /mnt/deploy/single-node/deploy.sh + - name: shellcheck CI 脚本(scripts/ci) + run: | + docker run --rm \ + -v "$PWD/scripts/ci:/mnt/scripts/ci:ro" \ + koalaman/shellcheck:stable \ + -S warning \ + /mnt/scripts/ci/_env.sh \ + /mnt/scripts/ci/lib-forgejo.sh \ + /mnt/scripts/ci/notify.sh \ + /mnt/scripts/ci/lib-ssh.sh \ + /mnt/scripts/ci/compile-site.sh \ + /mnt/scripts/ci/deploy-site.sh \ + /mnt/scripts/ci/compile-backend.sh \ + /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/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: | + docker run --rm \ + -v "$PWD/ci:/mnt/ci:ro" \ + koalaman/shellcheck:stable \ + -S warning \ + /mnt/ci/scan-cleartext.sh + # ── Job 2: OpenAPI Sync Check ──────────────────────────────────────────── openapi-check: name: OpenAPI Sync Check @@ -58,7 +98,7 @@ jobs: # ── Job 3: Redline Word Scan (脱敏) ────────────────────────────────────── redline-scan: name: Redline Scan — 脱敏 (UI 文案) - runs-on: nas + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 @@ -66,6 +106,17 @@ jobs: - name: scan UI text resources for prohibited words run: bash ci/scan-redline.sh + # ── Job 3b: Cleartext Scan (Android 禁全局明文,#25 控制面已 https) ────── + cleartext-scan: + name: Cleartext Scan — Android 禁明文 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: scan Android manifest for global cleartext + run: bash ci/scan-cleartext.sh + # ── Job 4: Flutter 客户端(分析 + 单测/组件测试)──────────────────────── flutter-client: name: Flutter — analyze + test @@ -92,7 +143,7 @@ jobs: # 规则与豁免见 ci/scan-portable-sql.sh 头注 + docs/dev-conventions.html 支柱 3。 portable-sql-scan: name: Portable SQL — 可移植性 (mysql/sqlite) - runs-on: nas + runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 @@ -117,6 +168,22 @@ jobs: node:20 \ bash ci/check-codegen-drift.sh + ds-flow: + name: DS-flow — 原型/跨端同源/代码色单源闸 + runs-on: nas + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: 原型校验(check-ds) + run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node design/prototype/tools/check-ds.mjs + + - name: 跨端同源(check-l1-sync) + run: docker run --rm -v "$PWD:/repo" -w /repo node:20 node tools/check-l1-sync.mjs + + - name: Flutter 颜色单源(check_ds_code --strict) + run: docker run --rm -v "$PWD/client:/app" -w /app node:20 node tool/check_ds_code.mjs --strict + # ── Job 7: Go 服务端(build + test:含契约快照 + sqlite 真库,跳过 integration)── # 此前 server 测试未进 CI;契约快照(支柱 2)等需在此守门。integration 测试走 # -tags integration(需 docker 起 mysql/redis),见 go-integration job。 @@ -161,7 +228,9 @@ jobs: -v "$HOME/.cache/pangolin-ci/gomod:/go/pkg/mod" \ -v "$HOME/.cache/pangolin-ci/gobuild:/root/.cache/go-build" \ golang:1.25 \ - bash -c "apt-get update -qq && apt-get install -y -qq openssl curl python3 >/dev/null 2>&1 && bash scripts/e2e-smoke.sh" + bash -c "bash scripts/e2e-smoke.sh" + # 注:openssl/curl/python3 已在 golang:1.25 镜像内,无需 apt 安装 + # (原 apt-get 会走 Docker Desktop 代理→本机 clash 死口,徒增网络脆性)。 # ── Job 10: Go 集成测试 (L2:真 mysql8/redis 经 testcontainers)────────── # 跨库可移植(支柱 3)+ 按租户流量记账(usage)+ 配额(devices)+ 兑换(codes)+ @@ -186,7 +255,7 @@ jobs: # Linux 权威基线(scripts/update-goldens.sh 生成;mac 渲染不一致故钉死 Linux 容器)。 # tablet/desktop-stats golden 与 stats-overhaul 工作区耦合,待其合并后并入本 job。 golden: - name: Golden — 视觉回归 (components + auth) + name: Golden — 视觉回归 (全量:components/auth/desktop/tablet) runs-on: nas steps: - name: Checkout @@ -199,4 +268,4 @@ jobs: -v "$PWD/client:/app" -w /app \ -v "$HOME/.cache/pangolin-ci/pubcache:/root/.pub-cache" \ ghcr.io/cirruslabs/flutter:stable \ - bash -c "flutter pub get && flutter test test/golden/components_golden_test.dart test/golden/auth_redesign_golden_test.dart" + bash -c "flutter pub get && flutter test test/golden" diff --git a/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml new file mode 100644 index 0000000..39b7688 --- /dev/null +++ b/.gitea/workflows/deploy-client.yml @@ -0,0 +1,221 @@ +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 + # true:新 client-v* 取消仍在跑的旧发版(如 windows 机离线导致 build-windows 无限排队 + # 卡住的旧 run),让最新版顺利发布,避免并发组被僵尸 run 占死。 + cancel-in-progress: true + +jobs: + build-android: + runs-on: nas + 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 + # 境内镜像:GitHub release 资产在国内被 GFW 限速 → windows runner(LAN 内)下 + # sing-box.exe / wintun.zip 超时。改从 NAS Gitea generic 包镜像拉, + # fetch-desktop-bin.sh 命中镜像后照样验 SHA256,失败则回退官方源。 + # ⚠️ 基址含 sing-box 版本目录(v1.13.12)——升级 app/kernel/VERSION 的 + # SINGBOX_VERSION 时,须把新版 zip 重新 PUT 到对应版本目录并同步改这里。 + DESKTOP_BIN_MIRROR: http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12 + # 包默认可匿名读,token 非必需;带上以防将来把包设为私有(未设/为空则匿名 GET)。 + DESKTOP_BIN_MIRROR_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + 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: nas + 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: + # 左=脚本期望的 env 名(勿改),右=实际密钥名。Apple 证书/公证密钥是 + # 账号级、跨项目唯一 → 放【全局(用户级)密钥】用通用短名,pangolin/jiu 各自 + # 在此映射到自己脚本的 env。Developer ID Application 证书=站外分发,一张签所有 app。 + MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_P12 }} + MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.P12_PASSWORD }} + 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: nas + 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: + # 证书=账号级 →【全局】通用短名(Apple Distribution 证书,签 iOS App Store/TestFlight); + # 描述文件=与 bundle id 绑定 →【项目级】pangolin 仓库密钥。 + IOS_DIST_CERT_P12_BASE64: ${{ secrets.IOS_DIST_P12 }} + IOS_DIST_CERT_PASSWORD: ${{ secrets.IOS_DIST_PASSWORD }} + 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(唯一稳定可用的平台 floor)。windows/macos/ios + # best-effort:各自 runner+secret 就绪则上传 artifact,release-deploy flatten 收 + # dist-raw/ 里"当时存在"的产物。windows 机离线 / Apple secret 未配 都不阻塞发版 + # (对应平台下载保留 pangolin1 上一版,待可用时下个 client-v* 追上)。 + release-deploy: + needs: [build-android] + runs-on: nas + 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 diff --git a/.gitea/workflows/deploy-server.yml b/.gitea/workflows/deploy-server.yml new file mode 100644 index 0000000..f2bc9d6 --- /dev/null +++ b/.gitea/workflows/deploy-server.yml @@ -0,0 +1,58 @@ +name: Deploy Server + +on: + push: + tags: + - 'server-v[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + +concurrency: + group: deploy-server + cancel-in-progress: false + +jobs: + deploy-server: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # runner 镜像(catthehacker ubuntu:act-latest,label ubuntu-latest)自带 node + # 但**不带 go** → 直接 `go build` 会 `go: command not found`(exit 127)。 + # 故先装 go:从 Go 官方**中国镜像** golang.google.cn 取(go.dev 在墙内不稳), + # 版本对齐 server/go.mod 的 1.25.10;装到 /usr/local/go 并加进 $GITHUB_PATH + # 供后续 Compile/Test 步骤共用。模块下载仍走 GOPROXY=goproxy.cn(见 _env.sh)。 + - name: Setup Go 1.25.10(CN 镜像) + run: | + GO_VER=1.25.10 + # 供应链完整性:校验 sha256(取自 Go 官方 release JSON,pin 为字面量), + # 防镜像被篡改/MITM 注入恶意工具链(它会编译要上生产的二进制)。校验失败即中止。 + GO_SHA256=42d4f7a32316aa66591eca7e89867256057a4264451aca10570a715b3637ba70 + curl -fsSL --max-time 180 --retry 3 --retry-delay 5 --retry-connrefused \ + "https://golang.google.cn/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tgz + echo "${GO_SHA256} /tmp/go.tgz" | sha256sum -c - + rm -rf /usr/local/go + tar -C /usr/local -xzf /tmp/go.tgz + echo "/usr/local/go/bin" >> "$GITHUB_PATH" + export PATH=/usr/local/go/bin:$PATH + go version + + # 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败;go 由上一步装好)。 + - name: Compile (Go 控制面) + run: bash scripts/ci/compile-backend.sh + + - name: Test (go test) + run: bash scripts/ci/test.sh server + + - name: Release → Forgejo + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + FORGEJO_URL: ${{ secrets.FORGEJO_URL }} + TAG: ${{ gitea.ref_name }} + run: bash scripts/ci/release-server.sh "$TAG" + + - name: Deploy → pangolin1 + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + TAG: ${{ gitea.ref_name }} + run: bash scripts/ci/deploy-server.sh "$TAG" diff --git a/.gitea/workflows/deploy-site.yml b/.gitea/workflows/deploy-site.yml new file mode 100644 index 0000000..756fa65 --- /dev/null +++ b/.gitea/workflows/deploy-site.yml @@ -0,0 +1,41 @@ +name: Deploy Site + +on: + push: + tags: + - 'site-v[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + +concurrency: + group: deploy-site + cancel-in-progress: false + +jobs: + deploy-site: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # runner 镜像 catthehacker/ubuntu:act-latest 自带 node/npx,直接跑; + # 不用嵌套 docker run(job 容器内的 $PWD 在宿主上不存在,DinD 挂载会失败)。 + - name: Compile (Astro 官网) + env: + SITE_URL: https://pangolin.yanmeiai.com + run: bash scripts/ci/compile-site.sh + + # 用户中心(web/usercenter,basePath=/user,http 模式)构建 → out/。 + - name: Compile (用户中心 Next.js) + run: bash scripts/ci/compile-usercenter.sh + + # 合并:用户中心并入官网 dist/user/ + _headers 按 /user/* 分域(见脚本)。 + # 域名迁移:原独立子域 app.yanmeiai.com → 主站子路径 pangolin.yanmeiai.com/user/。 + - name: Combine (官网 + 用户中心) + run: bash scripts/ci/combine-site.sh + + # 单次部署合并产物到 pangolin-site:/ 官网、/user/ 用户中心。 + - name: Deploy → Cloudflare Pages + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: bash scripts/ci/deploy-site.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f00e43c..175795f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -28,6 +28,24 @@ run_check "可移植 SQL 扫描" bash ci/scan-portable-sql.sh if command -v node >/dev/null 2>&1; then run_check "codegen 漂移检查" bash ci/check-codegen-drift.sh + + # ── ds-flow 闸(条件触发,秒级)── + staged="$(git diff --cached --name-only --diff-filter=ACM)" + + # 原型校验:仅在动了 design/prototype/ 时跑(轻量,扫整个原型)。 + if printf '%s\n' "$staged" | grep -q '^design/prototype/'; then + run_check "原型校验(check-ds)" node design/prototype/tools/check-ds.mjs + fi + + # 跨端同源:动了原型 token/图标 或 web token 时跑(防漂移)。 + if printf '%s\n' "$staged" | grep -qE '^(design/prototype/|web/(website|usercenter)/)'; then + run_check "跨端同源(check-l1-sync)" node tools/check-l1-sync.mjs + fi + + # Flutter 颜色单源:只扫本次改动的 dart(--changed,快),动了 client/lib 才有意义。 + if printf '%s\n' "$staged" | grep -q '^client/lib/.*\.dart$'; then + run_check "Flutter 颜色单源(check_ds_code)" bash -c 'cd client && node tool/check_ds_code.mjs --changed' + fi fi echo "[pre-commit] ✓ 本地闸通过(完整测试见 CI)" diff --git a/.gitignore b/.gitignore index 7a3dc7b..3306398 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ app/kernel/.build/ # Go 编译产物(mock server 等) server/mockserver server/pangolin-server +server/out/ # Flutter / Dart 构建产物与本地配置 client/android/local.properties @@ -55,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/ diff --git a/.wrangler/cache/pages.json b/.wrangler/cache/pages.json new file mode 100644 index 0000000..333ff44 --- /dev/null +++ b/.wrangler/cache/pages.json @@ -0,0 +1,4 @@ +{ + "account_id": "e585821c881c4cd23bc2530986edea9e", + "project_name": "pangolin-site" +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ac3f2ff..bddda7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,10 @@ sudo VPS_IP=<公网IP> bash deploy/single-node/deploy.sh - 二进制:`cmd/{server,agent,nodectl,migrate}`;`go build ./...` 直接编译,免确认。 - 控制面 HTTP API(`:8080`)+ gRPC agent 服务(`:9443`, mTLS);agent 自 enroll → 渲染 sing-box 配置 → `systemctl restart sing-box`。客户端连节点真实出网。 +- **控制面 API 对外经 Cloudflare Tunnel**:`https://api.yanmeiai.com`(cloudflared 出站隧道, + 不监听入站端口)→ 源站 `pangolin-server` 只绑 `127.0.0.1:8080`,不外露、不放行防火墙。 + 数据面 sing-box REALITY 仍独占入站 `:443`(未改动);gRPC agent mTLS 仍 `:9443`。详见 + `docs/control-plane-tls-tunnel.html`。 ### 数据层:多数据库(一个环境变量切换) @@ -86,6 +90,50 @@ cd web/website && npm run gen:tokens - `design/flutter/` 已删除;Flutter 组件 canonical 实现在 `client/lib/widgets/`,规格在 `design/preview/`。 - **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移)。 +## 前端设计系统治理(ds-flow) + +> **落地中**(分阶段收口,见计划 `docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md` +> / 阅读版 `docs/frontend-ds-refactor-plan.html` / todo #19)。以下是**目标模型与硬规则**; +> 标注「⏳」的部件正在建,未标注的已生效。参考样板 `~/code/jiu`。 + +**心智模型**:设计只有一个出生地(**原型单源**),代码永远是镜像;跨端副本是否走样由 +**静态闸**在提交/CI 前拦截,像素是否还原由 **golden/fidelity 双级验收**兜底。主题:**light / dark 双主题**。 + +**原型单源** `design/prototype/`(⏳ 建设中,Phase 1): +- `tokens.css` — 令牌真源:基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块。 + (当前真源仍是 `design/colors_and_type.css`,Phase 1 迁移后数值不变、结构规整) +- `atoms.css` — 公用组件原子(按钮/卡片/输入/语言下拉/徽章/状态药丸),**只引 `var(--token)`,禁硬编码**。 +- `icons.js` — SVG sprite 单源(``);website/usercenter/Flutter 三处图标集须 ⊆ 此集。 +- `index.html` — 活登记页:主题切换 + 声明式色板 + 全组件/图标展示卡。**每个 atom 必须在此登记**。 +- `serve.mjs` — 零依赖热重载预览;评审给 URL,**不截图**。 + +**三层治理**: +- **L1 设计系统**:新增颜色/组件/图标——**先登记原型,再同步代码**,无例外。跨端映射(状态词→图标)两端同集要有闸。 +- **L2 屏级三态**(台账记 `design/CONTRACT.md`):`同步`=入 fidelity;`快照`=原型退役、golden+契约为准;`代码先行`=无原型屏、golden 唯一基准。 +- **L3 新屏/改版**:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。 + +**codegen**: +- Flutter:`node design/codegen/gen_flutter_tokens.mjs` → `pangolin_tokens.gen.dart`(**勿手改**)。 +- Web:`build-tokens.mjs`(website→`src/styles/tokens.gen.css` / usercenter→`public/colors_and_type.css`), + **只同源不重复生成设计决策**;靠同源闸逐值校验(不建跨端共享组件包,两端各自实现)。 + +**四道静态闸 —「违规谁拦」**(规则没上闸 = 没有规则): + +| 闸 | 拦什么 | 何时 | +|---|---|---| +| 原型校验 `check-ds.mjs`(⏳ Phase 5) | 硬编码色/未定义 token/未登记组件/魔法数断点… 12 道 | pre-commit(动了原型)+ CI | +| 跨端同源 `check-l1-sync.mjs`(⏳ Phase 2) | tokens 逐值/icons 同集/Web hex 白名单 | CI | +| 代码色单源 `check_ds_code.mjs`(⏳ Phase 5) | Flutter 裸 `Color(0x)`/具名 `Colors.x`(`// ds-ignore: 理由` 豁免) | pre-commit(`--changed`)+ CI | +| codegen 零 diff `ci/check-codegen-drift.sh`(**已生效**) | 重生成 token 后 `git diff` 非空即 fail | pre-commit + CI | + +**双级像素验收**: +- **golden**(**已有**,`client/test/golden/`):多主题回归自比(同渲染器),抓串色/漏 token;真字体加载防豆腐块、钉死 viewport/dpr/动态值。重录 `flutter test --update-goldens`,随功能 commit 入库。 +- **fidelity**(⏳ Phase 5,本地体检**不进 CI**):原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp;跨渲染器噪声大,故不入 CI。 + +**pre-commit**:`.githooks/pre-commit` 已写,**每台机需 `bash ci/install-hooks.sh` 启用一次**(设 `core.hooksPath=.githooks`)。 + +**发现硬编码色**:换 token;确属例外(`#fff/#000`/品牌 logo 固定色)加 `// ds-ignore: 理由` 或列白名单。 + ## client/ macOS 原生隧道(PacketTunnel 系统扩展 + 内嵌 libbox) 内嵌 sing-box(`Libbox.xcframework`)的 `NEPacketTunnelProvider` **系统扩展**(站外 Developer ID diff --git a/app/kernel/fetch-desktop-bin.sh b/app/kernel/fetch-desktop-bin.sh index d1f731b..1e9194a 100644 --- a/app/kernel/fetch-desktop-bin.sh +++ b/app/kernel/fetch-desktop-bin.sh @@ -20,6 +20,15 @@ # 提取对应 arch 的 wintun.dll 到产物目录。 # # 幂等: 产物已存在且 SHA256 校验通过则跳过下载(传 --force 强制重下) +# +# 境内镜像(可选,解决 CI 从 GitHub release 被 GFW 限速的问题): +# DESKTOP_BIN_MIRROR 镜像基址;设了则 archive 与 wintun.zip 先试 +# ${DESKTOP_BIN_MIRROR}/<文件名>,命中即用,失败/未设 +# 则回退官方 GitHub / wintun.net。镜像下载的文件照样走 +# 下面的 SHA256 校验(防投毒/损坏)。 +# 例:http://192.168.3.200:3000/api/packages/wangjia/generic/desktop-bin/v1.13.12 +# DESKTOP_BIN_MIRROR_TOKEN 可选;镜像需鉴权时作 `Authorization: token <值>`。 +# 不设则匿名 GET(NAS Gitea generic 包默认可匿名读)。 set -euo pipefail @@ -116,6 +125,36 @@ _sha256() { fi } +# ── 下载助手:镜像优先,回退官方源 ───────────────────────────────────────────── +# 用法: _download_with_mirror <文件名> <官方回退URL> <输出路径> +# 若设了 DESKTOP_BIN_MIRROR:先试 ${DESKTOP_BIN_MIRROR}/<文件名>(带可选 token +# header),命中即返回;未设 / 镜像失败则回退官方 <回退URL>。 +# SHA256 校验由调用方在下载后统一执行——镜像来的文件同样要过校验。 +_download_with_mirror() { + local filename="$1" fallback_url="$2" out="$3" + + if [[ -n "${DESKTOP_BIN_MIRROR:-}" ]]; then + local mirror_url="${DESKTOP_BIN_MIRROR%/}/${filename}" + local -a auth=() + if [[ -n "${DESKTOP_BIN_MIRROR_TOKEN:-}" ]]; then + auth=(-H "Authorization: token ${DESKTOP_BIN_MIRROR_TOKEN}") + fi + printf '==> [mirror] 尝试 %s…\n' "${mirror_url}" + # ${auth[@]+...} 兜住空数组 + set -u:macOS bash 3.2 下 "${auth[@]}" 在数组 + # 为空时会报 "unbound variable",此写法数组空则整体展开为空。 + if curl -fSL --retry 5 --retry-delay 3 --retry-all-errors --connect-timeout 15 \ + ${auth[@]+"${auth[@]}"} -o "${out}" "${mirror_url}"; then + printf ' ✓ [mirror] 命中\n' + return 0 + fi + printf ' ! [mirror] 未命中,回退官方源\n' >&2 + fi + + printf '==> 下载 %s…\n' "${fallback_url}" + curl -fSL --retry 8 --retry-delay 5 --retry-connrefused --retry-all-errors --connect-timeout 20 \ + -o "${out}" "${fallback_url}" +} + # ── 幂等检查(已有产物则跳过;--force 强制重下)──────────────────────────────── if [[ "${FORCE}" == false && -f "${OUT_BIN}" ]]; then printf '✓ %s 已存在,跳过下载\n' "${OUT_BIN}" @@ -138,11 +177,8 @@ if [[ -z "${EXPECTED_HASH}" ]]; then exit 1 fi -# ── 下载二进制压缩包 ────────────────────────────────────────────────────────── -printf '==> 下载 %s…\n' "${ARCHIVE_FILE}" -curl -fSL --retry 3 --retry-delay 2 \ - -o "${ARCHIVE_CACHE}" \ - "${ARCHIVE_URL}" +# ── 下载二进制压缩包(镜像优先,回退 GitHub Release)────────────────────────── +_download_with_mirror "${ARCHIVE_FILE}" "${ARCHIVE_URL}" "${ARCHIVE_CACHE}" # ── SHA256 校验(对压缩包,比对内置 pin)────────────────────────────────────── printf '==> 校验 SHA256…\n' @@ -197,9 +233,8 @@ 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 \ - -o "${WINTUN_ZIP_CACHE}" \ - "${WINTUN_URL}" + # 镜像优先,回退 wintun.net + _download_with_mirror "${WINTUN_ZIP_NAME}" "${WINTUN_URL}" "${WINTUN_ZIP_CACHE}" printf '==> 校验 wintun.zip SHA256…\n' WINTUN_ACTUAL="$(_sha256 "${WINTUN_ZIP_CACHE}")" diff --git a/ci/check-codegen-drift.sh b/ci/check-codegen-drift.sh index 3b95492..1187f77 100755 --- a/ci/check-codegen-drift.sh +++ b/ci/check-codegen-drift.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # check-codegen-drift.sh — codegen 漂移检查(支柱 2:契约单源 / token 单源)。 # -# design/colors_and_type.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart +# design/prototype/tokens.css 是唯一 token 真相源,client/lib/pangolin_tokens.gen.dart # 由 design/codegen/gen_flutter_tokens.mjs 生成、勿手改(CLAUDE.md 铁律)。本检查重新 # 生成一遍,若生成物与已提交版本不一致 → 漂移:要么改了 CSS 没重生成,要么手改了 # 生成物。两者都会让"单源"失效、契约悄悄分叉。 @@ -22,7 +22,7 @@ echo "→ 重新生成 Flutter token (design/codegen/gen_flutter_tokens.mjs) ... node design/codegen/gen_flutter_tokens.mjs if ! git diff --quiet -- "$GEN"; then - echo "❌ codegen 漂移:$GEN 与 design/colors_and_type.css 不一致。" >&2 + echo "❌ codegen 漂移:$GEN 与 design/prototype/tokens.css 不一致。" >&2 echo " 原因:改了 CSS 真相源却没重生成,或手改了生成物(铁律:勿手改 *.gen.dart)。" >&2 echo " 修复:node design/codegen/gen_flutter_tokens.mjs 后提交生成物。" >&2 echo " ── diff(前 40 行)──" >&2 diff --git a/ci/install-hooks.sh b/ci/install-hooks.sh index 39d8478..2a96d9d 100755 --- a/ci/install-hooks.sh +++ b/ci/install-hooks.sh @@ -15,4 +15,5 @@ chmod +x .githooks/* 2>/dev/null || true echo "✓ 已启用 git hooks(core.hooksPath=.githooks)" echo " pre-commit 将跑:红线词扫描 · 可移植 SQL 扫描 · codegen 漂移检查" +echo " + ds-flow 条件闸(动了原型/web/client 才跑):原型校验 · 跨端同源 · Flutter 颜色单源" echo " 卸载:git config --unset core.hooksPath" diff --git a/ci/scan-cleartext.sh b/ci/scan-cleartext.sh new file mode 100755 index 0000000..29d174b --- /dev/null +++ b/ci/scan-cleartext.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。 +# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。 +set -euo pipefail + +MANIFEST="client/android/app/src/main/AndroidManifest.xml" +if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then + echo "❌ $MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2 + echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2 + exit 1 +fi +echo "✅ Android manifest 未开启全局明文" diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index b497e12..8b15ab0 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -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 libbox(gomobile AAR)────────────────────────────── // 产物(gitignore,清掉/换 worktree 都要重建)二选一,均产 io.nekohasekai.libbox 包: diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 42cc843..2a81d58 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -23,11 +23,16 @@ --> + + + + android:icon="@mipmap/ic_launcher"> startInAppUpdate(BuildContext context, AppText t, AppUpdateInfo info) async { + final url = platformDownloadUrl(info.downloadUrls); + if (url == null || url.isEmpty) return; + + // iOS 不允许 App 内安装 → 外链(download_urls['ios'] 应为 TestFlight/App Store)。 + if (Platform.isIOS) { + await _openInBrowser(url); + return; + } + + final progress = ValueNotifier(0); + final installing = ValueNotifier(false); + var dialogOpen = true; + void closeDialog() { + if (context.mounted && dialogOpen) { + dialogOpen = false; + Navigator.of(context, rootNavigator: true).pop(); + } + } + + // 不可关闭的进度框。 + unawaited(showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PopScope( + canPop: false, + child: _ProgressDialog(t: t, progress: progress, installing: installing), + ), + )); + + try { + final savePath = await _savePath(url); + await _download(url, savePath, (p) => progress.value = p); + installing.value = true; + await _install(savePath); // Win/macOS 可能在此 exit(0),不再返回 + closeDialog(); + if (Platform.isMacOS && context.mounted) { + await _showInfo(context, t, t.lang.updateMacReveal); + } + } catch (_) { + closeDialog(); + if (context.mounted) await _showFailed(context, t, url); + } finally { + progress.dispose(); + installing.dispose(); + } +} + +/// 各平台下载文件的落盘路径。 +Future _savePath(String url) async { + final tmp = await getTemporaryDirectory(); + if (Platform.isWindows) return '${tmp.path}${Platform.pathSeparator}pangolin-update-setup.exe'; + if (Platform.isMacOS) { + // 下到「下载」目录便于用户在访达里操作;取不到则回退临时目录。 + final dl = await getDownloadsDirectory(); + final dir = dl ?? tmp; + return '${dir.path}/pangolin-update.zip'; + } + return '${tmp.path}/pangolin-update.apk'; // Android +} + +/// http 流式下载 + 进度回调。失败抛异常(由调用方降级)。 +Future _download(String url, String savePath, void Function(double) onProgress) async { + final client = http.Client(); + try { + final req = http.Request('GET', Uri.parse(url)); + final resp = await client.send(req); + if (resp.statusCode != 200) { + throw HttpException('HTTP ${resp.statusCode}', uri: Uri.parse(url)); + } + final total = resp.contentLength ?? 0; + final file = File(savePath); + final sink = file.openWrite(); + var received = 0; + try { + await for (final chunk in resp.stream) { + received += chunk.length; + sink.add(chunk); + if (total > 0) onProgress(received / total); + } + await sink.flush(); + } finally { + await sink.close(); + } + } finally { + client.close(); + } +} + +/// 下载后触发安装/打开。 +Future _install(String path) async { + if (Platform.isAndroid) { + await OpenFilex.open(path); // 系统安装器(open_filex 内封 FileProvider) + return; + } + if (Platform.isWindows) { + await Process.start(path, const [], mode: ProcessStartMode.detached); + await Future.delayed(const Duration(milliseconds: 400)); + exit(0); // 退出让安装器覆盖 + } + if (Platform.isMacOS) { + // 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。 + await Process.run('open', [path]); // Archive Utility 解压 + await Process.run('open', ['-R', path]); // 访达定位 + } +} + +Future _openInBrowser(String url) async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} + +Future _showInfo(BuildContext context, AppText t, String msg) { + final c = context.pangolin; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + content: Text(msg, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('OK', style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ), + ); +} + +Future _showFailed(BuildContext context, AppText t, String url) { + final c = context.pangolin; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.xl)), + content: Text(t.lang.updateDownloadFailed, style: PangolinText.sm.copyWith(color: c.fg2, height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(t.lang.updateCancelBtn, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await _openInBrowser(url); + }, + child: Text(t.lang.updateOpenBrowser, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ), + ); +} + +class _ProgressDialog extends StatelessWidget { + const _ProgressDialog({required this.t, required this.progress, required this.installing}); + final AppText t; + final ValueNotifier progress; + final ValueNotifier installing; + + @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.updateDownload, + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ), + ]), + content: ValueListenableBuilder( + valueListenable: installing, + builder: (_, inst, __) => ValueListenableBuilder( + valueListenable: progress, + builder: (_, p, __) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(PangolinRadius.full), + child: LinearProgressIndicator( + value: inst || p <= 0 ? null : p, + minHeight: 6, + backgroundColor: c.bgSubtle, + valueColor: AlwaysStoppedAnimation(c.accent), + ), + ), + const SizedBox(height: 12), + Text( + inst ? t.lang.updateInstalling : t.lang.updateDownloadingPercent((p * 100).clamp(0, 100).round()), + style: PangolinText.sm.copyWith(color: c.fg2), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 46c2000..3ffa1d7 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -11,7 +11,322 @@ // 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。 /// 受支持语言。单显——任一时刻只渲染其一。 -enum AppLang { zh, en } +/// zh 中文 · en English · ja 日本語 · ko 한국어 · ru Русский · es Español。 +enum AppLang { zh, en, ja, ko, ru, es } + +/// 语言的本地名(用于语言切换 UI,各语言用自身文字标注)。 +extension AppLangLabel on AppLang { + String get nativeLabel { + switch (this) { + case AppLang.zh: + return '中文'; + case AppLang.en: + return 'English'; + case AppLang.ja: + return '日本語'; + case AppLang.ko: + return '한국어'; + case AppLang.ru: + return 'Русский'; + case AppLang.es: + return 'Español'; + } + } +} + +/// 少量「拿不到 AppText 实例的场景」(model / 非 Consumer 的 tile,只有 AppLang) +/// 用到的零散文案。exhaustive switch 保证 6 语全覆盖,不会漏译回退英文。 +/// 仍归属 l10n 层(scan-redline 覆盖本文件),不违反「文案集中」铁律。 +extension AppLangMisc on AppLang { + /// 节点不可用(agent 离线)。 + String get unavailable { + switch (this) { + case AppLang.zh: + return '不可用'; + case AppLang.en: + return 'Unavailable'; + case AppLang.ja: + return '利用不可'; + case AppLang.ko: + return '사용 불가'; + case AppLang.ru: + return 'Недоступно'; + case AppLang.es: + return 'No disponible'; + } + } + + /// 节点标签:流媒体优化。 + String get tagStreaming { + switch (this) { + case AppLang.zh: + return '流媒体优化'; + case AppLang.en: + return 'Streaming'; + case AppLang.ja: + return 'ストリーミング最適化'; + case AppLang.ko: + return '스트리밍 최적화'; + case AppLang.ru: + return 'Оптимизация стриминга'; + case AppLang.es: + return 'Optimización de streaming'; + } + } + + /// 节点尚未就绪(列表加载中/为空)。 + String get nodesNotReady { + switch (this) { + case AppLang.zh: + return '节点尚未就绪,请稍候重试'; + case AppLang.en: + return 'Nodes not ready, please retry'; + case AppLang.ja: + return 'ノードの準備ができていません。しばらくして再試行してください'; + case AppLang.ko: + return '노드가 아직 준비되지 않았습니다. 잠시 후 다시 시도하세요'; + case AppLang.ru: + return 'Узлы не готовы, повторите попытку'; + case AppLang.es: + return 'Nodos no listos, inténtalo de nuevo'; + } + } + + /// 连接失败(通用兜底)。 + String get connectFailed { + switch (this) { + case AppLang.zh: + return '连接失败,请重试'; + case AppLang.en: + return 'Connection failed, please retry'; + case AppLang.ja: + return '接続に失敗しました。再試行してください'; + case AppLang.ko: + return '연결에 실패했습니다. 다시 시도하세요'; + case AppLang.ru: + return 'Не удалось подключиться, повторите попытку'; + case AppLang.es: + return 'Error de conexión, inténtalo de nuevo'; + } + } + + /// 数据加载失败。 + String get loadFailedRetry { + switch (this) { + case AppLang.zh: + return '加载失败,请重试'; + case AppLang.en: + return 'Failed to load, retry'; + case AppLang.ja: + return '読み込みに失敗しました。再試行してください'; + case AppLang.ko: + return '불러오기에 실패했습니다. 다시 시도하세요'; + case AppLang.ru: + return 'Не удалось загрузить, повторите'; + case AppLang.es: + return 'Error al cargar, reintentar'; + } + } + + /// 无已登录设备。 + String get noDevices { + switch (this) { + case AppLang.zh: + return '暂无已登录设备'; + case AppLang.en: + return 'No devices yet'; + case AppLang.ja: + return 'ログイン済みのデバイスはありません'; + case AppLang.ko: + return '로그인된 기기가 없습니다'; + case AppLang.ru: + return 'Пока нет устройств'; + case AppLang.es: + return 'Aún no hay dispositivos'; + } + } + + /// 「改邮箱」按钮短标签。 + String get changeEmail { + switch (this) { + case AppLang.zh: + return '改邮箱'; + case AppLang.en: + return 'Change'; + case AppLang.ja: + return '変更'; + case AppLang.ko: + return '변경'; + case AppLang.ru: + return 'Изменить'; + case AppLang.es: + return 'Cambiar'; + } + } + + /// 相对时间(设备最后在线):刚刚 / X 分钟前 / X 小时前 / X 天前。 + String relativeTime(Duration d) { + final m = d.inMinutes, h = d.inHours, days = d.inDays; + switch (this) { + case AppLang.zh: + if (m < 1) return '刚刚'; + if (m < 60) return '$m 分钟前'; + if (h < 24) return '$h 小时前'; + return '$days 天前'; + case AppLang.en: + if (m < 1) return 'just now'; + if (m < 60) return '$m min ago'; + if (h < 24) return '${h}h ago'; + return '${days}d ago'; + case AppLang.ja: + if (m < 1) return 'たった今'; + if (m < 60) return '$m 分前'; + if (h < 24) return '$h 時間前'; + return '$days 日前'; + case AppLang.ko: + if (m < 1) return '방금'; + if (m < 60) return '$m분 전'; + if (h < 24) return '$h시간 전'; + return '$days일 전'; + case AppLang.ru: + if (m < 1) return 'только что'; + if (m < 60) return '$m мин назад'; + if (h < 24) return '$h ч назад'; + return '$days дн назад'; + case AppLang.es: + if (m < 1) return 'ahora mismo'; + if (m < 60) return 'hace $m min'; + if (h < 24) return 'hace $h h'; + return 'hace $days d'; + } + } + + /// 更新下载进度:「正在下载 X%」。 + String updateDownloadingPercent(int pct) { + switch (this) { + case AppLang.zh: + return '正在下载 $pct%'; + case AppLang.en: + return 'Downloading $pct%'; + case AppLang.ja: + return 'ダウンロード中 $pct%'; + case AppLang.ko: + return '다운로드 중 $pct%'; + case AppLang.ru: + return 'Загрузка $pct%'; + case AppLang.es: + return 'Descargando $pct%'; + } + } + + /// 下载完成、正在安装。 + String get updateInstalling { + switch (this) { + case AppLang.zh: + return '下载完成,正在安装…'; + case AppLang.en: + return 'Downloaded, installing…'; + case AppLang.ja: + return 'ダウンロード完了、インストール中…'; + case AppLang.ko: + return '다운로드 완료, 설치 중…'; + case AppLang.ru: + return 'Загружено, установка…'; + case AppLang.es: + return 'Descargado, instalando…'; + } + } + + /// 下载失败。 + String get updateDownloadFailed { + switch (this) { + case AppLang.zh: + return '下载失败,请重试或改用浏览器下载。'; + case AppLang.en: + return 'Download failed. Retry or download in your browser.'; + case AppLang.ja: + return 'ダウンロードに失敗しました。再試行するかブラウザでダウンロードしてください。'; + case AppLang.ko: + return '다운로드에 실패했습니다. 다시 시도하거나 브라우저로 다운로드하세요.'; + case AppLang.ru: + return 'Не удалось загрузить. Повторите или скачайте в браузере.'; + case AppLang.es: + return 'Error de descarga. Reintenta o descarga en el navegador.'; + } + } + + /// macOS:已下载,提示手动拖入应用程序。 + String get updateMacReveal { + switch (this) { + case AppLang.zh: + return '已下载并解压。请在访达中将 Pangolin 拖入「应用程序」文件夹替换旧版,然后重新打开。'; + case AppLang.en: + return 'Downloaded and extracted. In Finder, drag Pangolin into your Applications folder to replace the old version, then reopen.'; + case AppLang.ja: + return 'ダウンロードと展開が完了しました。Finder で Pangolin を「アプリケーション」フォルダにドラッグして置き換え、再度開いてください。'; + case AppLang.ko: + return '다운로드 및 압축 해제 완료. Finder에서 Pangolin을 「응용 프로그램」 폴더로 드래그해 이전 버전을 교체한 뒤 다시 여세요.'; + case AppLang.ru: + return 'Загружено и распаковано. В Finder перетащите Pangolin в папку «Программы», заменив старую версию, затем откройте заново.'; + case AppLang.es: + return 'Descargado y extraído. En Finder, arrastra Pangolin a tu carpeta de Aplicaciones para reemplazar la versión anterior y vuelve a abrir.'; + } + } + + /// 失败对话框:改用浏览器下载。 + String get updateOpenBrowser { + switch (this) { + case AppLang.zh: + return '浏览器下载'; + case AppLang.en: + return 'Open in browser'; + case AppLang.ja: + return 'ブラウザで開く'; + case AppLang.ko: + return '브라우저에서 열기'; + case AppLang.ru: + return 'Открыть в браузере'; + case AppLang.es: + return 'Abrir en el navegador'; + } + } + + /// 通用「取消」。 + String get updateCancelBtn { + switch (this) { + case AppLang.zh: + return '取消'; + case AppLang.en: + return 'Cancel'; + case AppLang.ja: + return 'キャンセル'; + case AppLang.ko: + return '취소'; + case AppLang.ru: + return 'Отмена'; + case AppLang.es: + return 'Cancelar'; + } + } + + /// 更新 banner:「立即更新」。 + String get updateNow { + switch (this) { + case AppLang.zh: + return '立即更新'; + case AppLang.en: + return 'Update now'; + case AppLang.ja: + return '今すぐ更新'; + case AppLang.ko: + return '지금 업데이트'; + case AppLang.ru: + return 'Обновить'; + case AppLang.es: + return 'Actualizar'; + } + } +} /// 全部界面文案的抽象契约。zh / en 各实现一份。 abstract class AppText { @@ -45,8 +360,20 @@ abstract class AppText { String get quotaToday; String get minutes; String get quotaFree; + String get planFreeTag; // 额度卡右上短标签「免费版」(不含「每日10分钟」,避免挤占行宽) String get watchAd; String get adUnlocked; + // 免费版 10 分钟卡控 + 看广告加时(累加式) + String get quotaLeftLabel; // 连接期倒计时前缀「剩余」 + String get quotaUsedUp; // 额度卡:今日已用完 + String get watchAdMore; // 「看广告加时」CTA + String get quotaExhaustedNotice; // 倒计时归零自动切断提示 + String get adPlaying; // 占位广告:播放中 + String adRewarded(int minutes); // 占位广告:已加 N 分钟 + String get adFailed; // 看广告加时失败 + String get quotaDesktopTitle; // 桌面版额度耗尽弹窗标题 + String get quotaDesktopBody; // 桌面版额度耗尽弹窗正文 + String get gotIt; // 知道了 // ── 节点页 ── String get chooseNode; @@ -59,6 +386,7 @@ abstract class AppText { // 连通看门狗:当前节点数据面不可用时的提示 String get nodeUnhealthySwitched; // 智能选择已自动切换 String get nodeUnhealthyError; // 手动节点:断开并提示 + String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18) String get smartSub; String get recommended; @@ -135,6 +463,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; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 9d556ca..6057be8 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -53,9 +53,31 @@ class StringsEn extends AppText { @override String get quotaFree => 'Free · 10 min/day'; @override + String get planFreeTag => 'Free'; + @override String get watchAd => 'Watch ad to start'; @override String get adUnlocked => 'Unlocked for today'; + @override + String get quotaLeftLabel => 'Left'; + @override + String get quotaUsedUp => 'Daily free time used up'; + @override + String get watchAdMore => 'Watch ad for more time'; + @override + String get quotaExhaustedNotice => 'Daily free time used up. Watch an ad to add time or upgrade.'; + @override + String get adPlaying => 'Ad playing…'; + @override + String adRewarded(int minutes) => '+$minutes min added'; + @override + String get adFailed => 'Failed to add time, please retry'; + @override + String get quotaDesktopTitle => 'Daily free time used up'; + @override + String get quotaDesktopBody => 'Watch ads in the mobile app to add time, or upgrade for unlimited access.'; + @override + String get gotIt => 'Got it'; @override String get chooseNode => 'Choose server'; @@ -74,6 +96,8 @@ class StringsEn extends AppText { @override String get nodeUnhealthyError => 'Current node is unreachable — reconnect or switch node'; @override + String get nodeReconnecting => 'Network unstable, reconnecting…'; + @override String get smartSub => 'Picks the best node for your network'; @override String get recommended => 'Recommended'; @@ -211,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'; diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart new file mode 100644 index 0000000..53cf6ff --- /dev/null +++ b/client/lib/l10n/strings_es.dart @@ -0,0 +1,373 @@ +// strings_es.dart — Recursos de texto en español (visualización monolingüe) +// +// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere. +// Posicionamiento: aceleración de red / optimización de la experiencia + privacidad. +// La numeración de planes sigue design/CLAUDE.md §7. +import 'app_text.dart'; + +class StringsEs extends AppText { + const StringsEs(); + + @override + AppLang get lang => AppLang.es; + + @override + String get brand => 'Pangolin'; + @override + String get online => '● En línea'; + @override + String get offline => '○ Desconectado'; + @override + String get capOff => 'Toca para conectar'; + @override + String get capConnecting => 'Conectando…'; + @override + String get capOn => 'Conectado · Cifrado'; + @override + String get connectNow => 'CONECTAR'; + @override + String get secure => 'SEGURO'; + + @override + String get tabConnect => 'Conectar'; + @override + String get tabServers => 'Servidores'; + @override + String get tabStats => 'Estadísticas'; + @override + String get tabMe => 'Cuenta'; + + @override + String get currentNode => 'Nodo actual'; + @override + String get download => 'Bajada'; + @override + String get upload => 'Subida'; + @override + String get latency => 'Ping'; + + @override + String get quotaToday => 'Restante hoy'; + @override + String get minutes => 'min'; + @override + String get quotaFree => 'Gratis · 10 min/día'; + @override + String get planFreeTag => 'Gratis'; + @override + String get watchAd => 'Mira un anuncio para empezar'; + @override + String get adUnlocked => 'Desbloqueado por hoy'; + @override + String get quotaLeftLabel => 'Restante'; + @override + String get quotaUsedUp => 'Tiempo gratis diario agotado'; + @override + String get watchAdMore => 'Mira un anuncio para más tiempo'; + @override + String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.'; + @override + String get adPlaying => 'Reproduciendo anuncio…'; + @override + String adRewarded(int minutes) => '+$minutes min añadidos'; + @override + String get adFailed => 'No se pudo añadir tiempo, inténtalo de nuevo'; + @override + String get quotaDesktopTitle => 'Tiempo gratis diario agotado'; + @override + String get quotaDesktopBody => 'Mira anuncios en la app móvil para añadir tiempo, o mejora tu plan para acceso ilimitado.'; + @override + String get gotIt => 'Entendido'; + + @override + String get chooseNode => 'Elegir servidor'; + @override + String get searchPh => 'Buscar país / ciudad'; + @override + String get smartSelect => 'Selección inteligente'; + @override + String nodeSwitchTitle(String name) => '¿Cambiar a $name?'; + @override + String get nodeSwitchBody => 'Esto desconecta el enlace actual y reconecta a este nodo.'; + @override + String get nodeSwitchConfirm => 'Cambiar'; + @override + String get nodeUnhealthySwitched => 'Nodo actual inaccesible: cambiado automáticamente'; + @override + String get nodeUnhealthyError => 'El nodo actual está inaccesible: reconecta o cambia de nodo'; + @override + String get nodeReconnecting => 'Red inestable, reconectando…'; + @override + String get smartSub => 'Elige el mejor nodo para tu red'; + @override + String get recommended => 'Recomendado'; + + @override + String get statsTitle => 'Estadísticas'; + @override + String get trafficMonth => 'Tráfico'; + @override + String get avgPing => 'Ping medio'; + @override + String get durMonth => 'Tiempo'; + @override + String get weekTraffic => 'Esta semana (GB)'; + @override + List get days7 => const ['L', 'M', 'X', 'J', 'V', 'S', 'D']; + @override + String get byDevice => 'Por dispositivo'; + @override + String get noDeviceUsage => 'Aún no hay uso por dispositivo'; + @override + String get periodMonth => 'Este mes'; + @override + String get periodWeek => 'Esta semana'; + @override + String get periodToday => 'Hoy'; + @override + String get statDown => 'Descarga'; + @override + String get statUp => 'Subida'; + @override + String get statDuration => 'Duración'; + @override + String get chartTwoWeeks => 'Últimas 2 semanas'; + @override + String get allDevices => 'Todos los dispositivos'; + + @override + String get meTitle => 'Cuenta'; + @override + String get proMember => 'Miembro PRO'; + @override + String get freePlanName => 'Gratis'; + @override + String get expires => 'Vence'; + @override + String get upgradeBtn => 'Renovar / Mejorar'; + @override + String get accInfoTitle => 'Información de la cuenta'; + @override + String get accEmail => 'Correo'; + @override + String get accPassword => 'Contraseña'; + @override + String get accChange => 'Cambiar'; + @override + String get deviceLimitTitle => 'Límite de dispositivos alcanzado'; + @override + String get deviceLimitDesc => 'Tu plan permite hasta %s dispositivos. Elimina uno para continuar.'; + @override + String get deviceLimitRemoveOldest => 'Eliminar el menos usado recientemente'; + @override + String get myDevices => 'Mis dispositivos'; + @override + String get devicesSub => 'Hasta 3 dispositivos en PRO'; + @override + String get thisDevice => 'Este dispositivo'; + @override + String get remove => 'Eliminar'; + @override + String get devOnline => 'En línea'; + @override + String get devOffline => 'Desconectado'; + @override + String get devLastOnline => 'Última conexión'; + @override + String get devNeverLogin => 'Nunca ha iniciado sesión'; + @override + String get devForceLogout => 'Forzar cierre de sesión'; + @override + String get devClearLogin => 'Borrar datos de sesión'; + @override + String get devRename => 'Renombrar'; + @override + String get devRenameHint => 'Nombre del dispositivo'; + @override + String get devSave => 'Guardar'; + @override + String get devForceLogoutConfirm => 'Esto revoca la sesión del dispositivo: será desconectado y deberá iniciar sesión de nuevo. El dispositivo permanece en la lista.'; + @override + String get devClearLoginConfirm => 'Esto elimina el dispositivo de la lista por completo y revoca su sesión y su credencial del plano de datos. El dispositivo deberá iniciar sesión de nuevo para poder usarse.'; + @override + String get devCancel => 'Cancelar'; + @override + String get sessionRevokedTitle => 'Se cerró la sesión de este dispositivo'; + @override + String get sessionRevokedBody => 'Tu cuenta cerró la sesión de este dispositivo desde otro dispositivo. Inicia sesión de nuevo.'; + @override + String get sessionRevokedOk => 'Iniciar sesión de nuevo'; + @override + String get redeemEntry => 'Canjear y comprar'; + @override + String get contactEntry => 'Contáctanos'; + @override + String get signOut => 'Cerrar sesión'; + @override + String get language => 'Idioma'; + @override + String get darkAppearance => 'Apariencia oscura'; + @override + String get stateOn => 'Activado'; + @override + String get followLight => 'Desactivado'; + @override + String get protocol => 'Protocolo'; + @override + String get settingsTitle => 'Ajustes'; + @override + String get autostart => 'Iniciar al arrancar'; + @override + String get autostartSub => 'Ejecutar automáticamente al iniciar el sistema'; + @override + String get autoConnect => 'Conexión automática'; + @override + String get autoConnectSub => 'Conectar automáticamente al abrir'; + @override + String autoConnectingTo(String name) => 'Conectando automáticamente a $name…'; + @override + String get smartRoute => 'Enrutamiento inteligente'; + @override + String get smartRouteSub => 'Acelera en el extranjero, directo en casa'; + @override + String get killSwitch => 'Kill Switch'; + @override + String get killSwitchSub => 'Bloquea el tráfico si se cae el enlace'; + @override + String get checkUpdate => 'Buscar actualizaciones'; + @override + String get webUserCenter => 'Centro de usuario (Web)'; + @override + String updateAvailableTitle(String version) => 'Nueva versión v$version disponible'; + @override + String get updateNotesFallback => 'Esta actualización incluye correcciones y mejoras de estabilidad.'; + @override + String get updateLater => 'Más tarde'; + @override + String get updateDownload => 'Descargar actualización'; + @override + String get updateUpToDate => 'Ya tienes la última versión'; + @override + String get updateCheckFailed => 'Falló la búsqueda de actualizaciones, inténtalo más tarde'; + + @override + String get choosePlan => 'Elegir plan'; + @override + String get freePlan => 'Gratis'; + @override + String get proPlan => 'Pro'; + @override + String get teamPlan => 'Equipo'; + @override + String get perMonth => '/mes'; + @override + String get current => 'Actual'; + @override + String get upgrade => 'Mejorar'; + @override + String get choose => 'Elegir'; + @override + String get mostPopular => 'Popular'; + @override + List get featsFree => const [ + 'Solo 1 nodo básico', + '10 min al día', + 'Mira un anuncio para empezar', + 'Prueba gratis de 7 días al registrarte', + ]; + @override + List get featsPro => + const ['Más de 80 ubicaciones', 'Ilimitado · rápido', '5 dispositivos', 'Optimizado para streaming']; + @override + List get featsTeam => + const ['Todo lo de Pro', '10 plazas', 'Facturación centralizada', 'Soporte prioritario']; + + @override + String get redeemTitle => 'Canjear y comprar'; + @override + String get redeemCodeTitle => 'Canjear un código'; + @override + String get redeemPh => 'Introduce el código de activación'; + @override + String get redeemBtn => 'Canjear'; + @override + String get redeemOk => 'Activado · PRO desbloqueado'; + @override + String get buyTitle => 'Dónde comprar'; + @override + String get buySub => 'El pago dentro de la app no está disponible. Consigue un código mediante:'; + @override + String get chStore => 'Tienda autoservicio'; + @override + String get chEmail => 'Soporte por correo'; + + @override + String get contactTitle => 'Contáctanos'; + @override + String get contactIntro => + '¿Necesitas ayuda? Escríbenos por cualquiera de los canales de abajo: solemos responder en minutos.'; + @override + String get contactEmail => 'Soporte por correo'; + @override + String get contactStore => 'Tienda autoservicio'; + @override + String get contactHoursTitle => 'Horario de soporte'; + @override + String get contactHours => 'Todos los días 9:00 – 24:00 (GMT+8)'; + + @override + String get authTagline => 'Rápido · Estable · Sin complicaciones'; + @override + String get tabLogin => 'Iniciar sesión'; + @override + String get tabRegister => 'Registrarse'; + @override + String get emailLabel => 'Correo'; + @override + String get emailPh => 'tu@correo.com'; + @override + String get pwLabel => 'Contraseña'; + @override + String get pwPh => 'Introduce la contraseña'; + @override + String get setPwPh => 'Crea una contraseña (para acceso multidispositivo)'; + @override + String get codeLabel => 'Código de verificación'; + @override + String codeSentTo(String email) => 'Código enviado a $email'; + @override + String get sendCode => 'Enviar código'; + @override + String get resend => 'Reenviar'; + @override + String get doLogin => 'Iniciar sesión'; + @override + String get doNext => 'Siguiente'; + @override + String get doCreate => 'Crear cuenta'; + @override + String get forgotPw => '¿Olvidaste la contraseña?'; + @override + String get tos => 'Al continuar aceptas nuestros Términos y Política de privacidad'; + + @override + String get obSkip => 'Omitir'; + @override + String get obNext => 'Siguiente'; + @override + String get obAllow => 'Permitir y continuar'; + @override + String get obStart => 'Empezar'; + @override + String get ob1Title => 'Conéctate en todo el mundo'; + @override + String get ob1Sub => 'Más de 80 ubicaciones, enrutamiento al más rápido automático, sólido como una roca.'; + @override + String get ob2Title => 'Permite la configuración de red'; + @override + String get ob2Sub => 'El sistema te pedirá añadir un perfil de red para crear el túnel cifrado.'; + @override + String get ob3Title => 'Privado por diseño'; + @override + String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.'; +} diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart new file mode 100644 index 0000000..14268b8 --- /dev/null +++ b/client/lib/l10n/strings_ja.dart @@ -0,0 +1,373 @@ +// strings_ja.dart — 日本語の文言リソース(単一言語表示) +// +// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。 +// ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。 +// プラン番号は design/CLAUDE.md §7 に準拠。 +import 'app_text.dart'; + +class StringsJa extends AppText { + const StringsJa(); + + @override + AppLang get lang => AppLang.ja; + + @override + String get brand => 'Pangolin'; + @override + String get online => '● オンライン'; + @override + String get offline => '○ オフライン'; + @override + String get capOff => 'タップして接続'; + @override + String get capConnecting => '接続中…'; + @override + String get capOn => '接続済み · 暗号化'; + @override + String get connectNow => '接続'; + @override + String get secure => 'セキュア'; + + @override + String get tabConnect => '接続'; + @override + String get tabServers => 'サーバー'; + @override + String get tabStats => '統計'; + @override + String get tabMe => 'アカウント'; + + @override + String get currentNode => '現在のノード'; + @override + String get download => '下り'; + @override + String get upload => '上り'; + @override + String get latency => 'Ping'; + + @override + String get quotaToday => '本日の残り'; + @override + String get minutes => '分'; + @override + String get quotaFree => '無料 · 1日10分'; + @override + String get planFreeTag => '無料'; + @override + String get watchAd => '広告を見て開始'; + @override + String get adUnlocked => '本日は利用可能'; + @override + String get quotaLeftLabel => '残り'; + @override + String get quotaUsedUp => '本日の無料時間を使い切りました'; + @override + String get watchAdMore => '広告を見て時間を追加'; + @override + String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください。'; + @override + String get adPlaying => '広告を再生中…'; + @override + String adRewarded(int minutes) => '+$minutes 分を追加しました'; + @override + String get adFailed => '時間の追加に失敗しました。もう一度お試しください'; + @override + String get quotaDesktopTitle => '本日の無料時間を使い切りました'; + @override + String get quotaDesktopBody => 'モバイルアプリで広告を見ると時間を追加できます。無制限に使うにはアップグレードしてください。'; + @override + String get gotIt => '了解'; + + @override + String get chooseNode => 'サーバーを選択'; + @override + String get searchPh => '国・都市を検索'; + @override + String get smartSelect => 'スマート選択'; + @override + String nodeSwitchTitle(String name) => '$name に切り替えますか?'; + @override + String get nodeSwitchBody => '現在の接続を切断し、このノードに再接続します。'; + @override + String get nodeSwitchConfirm => '切り替える'; + @override + String get nodeUnhealthySwitched => '現在のノードに接続できないため、自動的に切り替えました'; + @override + String get nodeUnhealthyError => '現在のノードに接続できません。再接続するかノードを切り替えてください'; + @override + String get nodeReconnecting => 'ネットワークが不安定です。再接続中…'; + @override + String get smartSub => 'お使いのネットワークに最適なノードを選びます'; + @override + String get recommended => 'おすすめ'; + + @override + String get statsTitle => '統計'; + @override + String get trafficMonth => '通信量'; + @override + String get avgPing => '平均Ping'; + @override + String get durMonth => '接続時間'; + @override + String get weekTraffic => '今週 (GB)'; + @override + List get days7 => const ['月', '火', '水', '木', '金', '土', '日']; + @override + String get byDevice => 'デバイス別'; + @override + String get noDeviceUsage => 'デバイスの利用履歴はまだありません'; + @override + String get periodMonth => '今月'; + @override + String get periodWeek => '今週'; + @override + String get periodToday => '今日'; + @override + String get statDown => 'ダウンロード'; + @override + String get statUp => 'アップロード'; + @override + String get statDuration => '接続時間'; + @override + String get chartTwoWeeks => '過去2週間'; + @override + String get allDevices => 'すべてのデバイス'; + + @override + String get meTitle => 'アカウント'; + @override + String get proMember => 'PRO会員'; + @override + String get freePlanName => '無料'; + @override + String get expires => '有効期限'; + @override + String get upgradeBtn => '更新 / アップグレード'; + @override + String get accInfoTitle => 'アカウント情報'; + @override + String get accEmail => 'メールアドレス'; + @override + String get accPassword => 'パスワード'; + @override + String get accChange => '変更'; + @override + String get deviceLimitTitle => 'デバイス数の上限に達しました'; + @override + String get deviceLimitDesc => '現在のプランでは最大 %s 台まで利用できます。続けるには1台を削除してください。'; + @override + String get deviceLimitRemoveOldest => '最後に使用したのが最も古いデバイスを削除'; + @override + String get myDevices => 'マイデバイス'; + @override + String get devicesSub => 'PROでは最大3台まで'; + @override + String get thisDevice => 'このデバイス'; + @override + String get remove => '削除'; + @override + String get devOnline => 'オンライン'; + @override + String get devOffline => 'オフライン'; + @override + String get devLastOnline => '最終オンライン'; + @override + String get devNeverLogin => 'ログイン履歴なし'; + @override + String get devForceLogout => '強制ログアウト'; + @override + String get devClearLogin => 'ログイン情報を消去'; + @override + String get devRename => '名前を変更'; + @override + String get devRenameHint => 'デバイス名'; + @override + String get devSave => '保存'; + @override + String get devForceLogoutConfirm => 'このデバイスのログインセッションを無効にします。オフラインになり、再度サインインが必要になります。デバイスはリストに残ります。'; + @override + String get devClearLoginConfirm => 'このデバイスをリストから完全に削除し、ログインセッションとデータ通信の認証情報を無効にします。再び利用するには、もう一度サインインが必要です。'; + @override + String get devCancel => 'キャンセル'; + @override + String get sessionRevokedTitle => 'このデバイスはサインアウトされました'; + @override + String get sessionRevokedBody => '別のデバイスから、このデバイスがサインアウトされました。もう一度サインインしてください。'; + @override + String get sessionRevokedOk => '再度サインイン'; + @override + String get redeemEntry => 'コード引き換え・購入'; + @override + String get contactEntry => 'お問い合わせ'; + @override + String get signOut => 'サインアウト'; + @override + String get language => '言語'; + @override + String get darkAppearance => 'ダークモード'; + @override + String get stateOn => 'オン'; + @override + String get followLight => 'オフ'; + @override + String get protocol => 'プロトコル'; + @override + String get settingsTitle => '設定'; + @override + String get autostart => 'スタートアップ時に起動'; + @override + String get autostartSub => 'システム起動時に自動的に実行'; + @override + String get autoConnect => '自動接続'; + @override + String get autoConnectSub => '起動時に自動的に接続'; + @override + String autoConnectingTo(String name) => '$name に自動接続中…'; + @override + String get smartRoute => 'スマートルーティング'; + @override + String get smartRouteSub => '海外は高速化、国内は直接接続'; + @override + String get killSwitch => 'キルスイッチ'; + @override + String get killSwitchSub => '接続が切れたら通信を遮断'; + @override + String get checkUpdate => 'アップデートを確認'; + @override + String get webUserCenter => 'ユーザーセンター (Web)'; + @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 => 'プランを選択'; + @override + String get freePlan => '無料'; + @override + String get proPlan => 'Pro'; + @override + String get teamPlan => 'Team'; + @override + String get perMonth => '/月'; + @override + String get current => '現在'; + @override + String get upgrade => 'アップグレード'; + @override + String get choose => '選択'; + @override + String get mostPopular => '人気'; + @override + List get featsFree => const [ + '基本ノード1つのみ', + '1日10分', + '広告を見て開始', + '登録で7日間無料トライアル', + ]; + @override + List get featsPro => + const ['80以上の拠点', '無制限 · 高速', '5台のデバイス', 'ストリーミング最適化']; + @override + List get featsTeam => + const ['Proのすべての機能', '10ライセンス', '一括請求', '優先サポート']; + + @override + String get redeemTitle => 'コード引き換え・購入'; + @override + String get redeemCodeTitle => 'コードを引き換える'; + @override + String get redeemPh => 'アクティベーションコードを入力'; + @override + String get redeemBtn => '引き換える'; + @override + String get redeemOk => '認証完了 · PROが利用可能に'; + @override + String get buyTitle => '購入方法'; + @override + String get buySub => 'アプリ内決済はご利用いただけません。以下からコードを入手してください:'; + @override + String get chStore => 'セルフサービスストア'; + @override + String get chEmail => 'メールサポート'; + + @override + String get contactTitle => 'お問い合わせ'; + @override + String get contactIntro => + 'お困りですか?以下のいずれかの方法でお問い合わせください。通常は数分以内に返信します。'; + @override + String get contactEmail => 'メールサポート'; + @override + String get contactStore => 'セルフサービスストア'; + @override + String get contactHoursTitle => 'サポート時間'; + @override + String get contactHours => '毎日 9:00 – 24:00 (GMT+8)'; + + @override + String get authTagline => '高速 · 安定 · かんたん'; + @override + String get tabLogin => 'ログイン'; + @override + String get tabRegister => '新規登録'; + @override + String get emailLabel => 'メールアドレス'; + @override + String get emailPh => 'your@email.com'; + @override + String get pwLabel => 'パスワード'; + @override + String get pwPh => 'パスワードを入力'; + @override + String get setPwPh => 'パスワードを設定(複数デバイスでのログイン用)'; + @override + String get codeLabel => '確認コード'; + @override + String codeSentTo(String email) => '$email に確認コードを送信しました'; + @override + String get sendCode => 'コードを送信'; + @override + String get resend => '再送信'; + @override + String get doLogin => 'ログイン'; + @override + String get doNext => '次へ'; + @override + String get doCreate => 'アカウントを作成'; + @override + String get forgotPw => 'パスワードをお忘れですか?'; + @override + String get tos => '続行すると、利用規約とプライバシーポリシーに同意したことになります'; + + @override + String get obSkip => 'スキップ'; + @override + String get obNext => '次へ'; + @override + String get obAllow => '許可して続行'; + @override + String get obStart => '始める'; + @override + String get ob1Title => '世界中に接続'; + @override + String get ob1Sub => '80以上の拠点、最速ルートを自動選択、抜群の安定性。'; + @override + String get ob2Title => 'ネットワーク設定を許可'; + @override + String get ob2Sub => '暗号化トンネルを構築するため、システムがネットワークプロファイルの追加を求めます。'; + @override + String get ob3Title => '設計段階からプライバシー重視'; + @override + String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。'; +} diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart new file mode 100644 index 0000000..8b37b5e --- /dev/null +++ b/client/lib/l10n/strings_ko.dart @@ -0,0 +1,373 @@ +// strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시) +// +// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere. +// 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시. +// 요금제 번호는 design/CLAUDE.md §7 을 따름. +import 'app_text.dart'; + +class StringsKo extends AppText { + const StringsKo(); + + @override + AppLang get lang => AppLang.ko; + + @override + String get brand => 'Pangolin'; + @override + String get online => '● 온라인'; + @override + String get offline => '○ 오프라인'; + @override + String get capOff => '탭하여 연결'; + @override + String get capConnecting => '연결 중…'; + @override + String get capOn => '연결됨 · 암호화'; + @override + String get connectNow => '연결'; + @override + String get secure => '보안'; + + @override + String get tabConnect => '연결'; + @override + String get tabServers => '서버'; + @override + String get tabStats => '통계'; + @override + String get tabMe => '계정'; + + @override + String get currentNode => '현재 노드'; + @override + String get download => '다운'; + @override + String get upload => '업'; + @override + String get latency => '지연'; + + @override + String get quotaToday => '오늘 남은 시간'; + @override + String get minutes => '분'; + @override + String get quotaFree => '무료 · 하루 10분'; + @override + String get planFreeTag => '무료'; + @override + String get watchAd => '광고 시청 후 시작'; + @override + String get adUnlocked => '오늘 사용 가능'; + @override + String get quotaLeftLabel => '남음'; + @override + String get quotaUsedUp => '오늘 무료 시간을 모두 사용함'; + @override + String get watchAdMore => '광고 시청으로 시간 추가'; + @override + String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.'; + @override + String get adPlaying => '광고 재생 중…'; + @override + String adRewarded(int minutes) => '+$minutes분 추가됨'; + @override + String get adFailed => '시간 추가에 실패했습니다. 다시 시도하세요'; + @override + String get quotaDesktopTitle => '오늘 무료 시간을 모두 사용함'; + @override + String get quotaDesktopBody => '모바일 앱에서 광고를 시청해 시간을 추가하거나, 업그레이드하여 무제한으로 이용하세요.'; + @override + String get gotIt => '확인'; + + @override + String get chooseNode => '서버 선택'; + @override + String get searchPh => '국가 / 도시 검색'; + @override + String get smartSelect => '스마트 선택'; + @override + String nodeSwitchTitle(String name) => '$name(으)로 전환할까요?'; + @override + String get nodeSwitchBody => '현재 연결을 끊고 이 노드로 다시 연결합니다.'; + @override + String get nodeSwitchConfirm => '전환'; + @override + String get nodeUnhealthySwitched => '현재 노드에 연결할 수 없어 자동으로 전환했습니다'; + @override + String get nodeUnhealthyError => '현재 노드에 연결할 수 없습니다 — 재연결하거나 노드를 전환하세요'; + @override + String get nodeReconnecting => '네트워크가 불안정하여 다시 연결 중…'; + @override + String get smartSub => '네트워크에 가장 적합한 노드를 선택합니다'; + @override + String get recommended => '추천'; + + @override + String get statsTitle => '통계'; + @override + String get trafficMonth => '트래픽'; + @override + String get avgPing => '평균 지연'; + @override + String get durMonth => '시간'; + @override + String get weekTraffic => '이번 주 (GB)'; + @override + List get days7 => const ['월', '화', '수', '목', '금', '토', '일']; + @override + String get byDevice => '기기별'; + @override + String get noDeviceUsage => '아직 기기 사용 기록이 없습니다'; + @override + String get periodMonth => '이번 달'; + @override + String get periodWeek => '이번 주'; + @override + String get periodToday => '오늘'; + @override + String get statDown => '다운로드'; + @override + String get statUp => '업로드'; + @override + String get statDuration => '사용 시간'; + @override + String get chartTwoWeeks => '최근 2주'; + @override + String get allDevices => '모든 기기'; + + @override + String get meTitle => '계정'; + @override + String get proMember => 'PRO 회원'; + @override + String get freePlanName => '무료'; + @override + String get expires => '만료일'; + @override + String get upgradeBtn => '갱신 / 업그레이드'; + @override + String get accInfoTitle => '계정 정보'; + @override + String get accEmail => '이메일'; + @override + String get accPassword => '비밀번호'; + @override + String get accChange => '변경'; + @override + String get deviceLimitTitle => '기기 한도에 도달함'; + @override + String get deviceLimitDesc => '현재 요금제는 최대 %s대의 기기를 허용합니다. 계속하려면 한 대를 삭제하세요.'; + @override + String get deviceLimitRemoveOldest => '가장 오래 사용하지 않은 기기 삭제'; + @override + String get myDevices => '내 기기'; + @override + String get devicesSub => 'PRO는 최대 3대'; + @override + String get thisDevice => '이 기기'; + @override + String get remove => '삭제'; + @override + String get devOnline => '온라인'; + @override + String get devOffline => '오프라인'; + @override + String get devLastOnline => '마지막 접속'; + @override + String get devNeverLogin => '로그인한 적 없음'; + @override + String get devForceLogout => '강제 로그아웃'; + @override + String get devClearLogin => '로그인 정보 삭제'; + @override + String get devRename => '이름 변경'; + @override + String get devRenameHint => '기기 이름'; + @override + String get devSave => '저장'; + @override + String get devForceLogoutConfirm => '이 기기의 로그인 세션을 무효화합니다 — 오프라인으로 강제 종료되며 다시 로그인해야 합니다. 기기는 목록에 남아 있습니다.'; + @override + String get devClearLoginConfirm => '이 기기를 목록에서 완전히 삭제하고 로그인 세션과 데이터 전송 자격 증명을 무효화합니다. 다시 사용하려면 재로그인해야 합니다.'; + @override + String get devCancel => '취소'; + @override + String get sessionRevokedTitle => '이 기기에서 로그아웃되었습니다'; + @override + String get sessionRevokedBody => '다른 기기에서 이 기기를 로그아웃했습니다. 다시 로그인하세요.'; + @override + String get sessionRevokedOk => '다시 로그인'; + @override + String get redeemEntry => '코드 등록 및 구매'; + @override + String get contactEntry => '문의하기'; + @override + String get signOut => '로그아웃'; + @override + String get language => '언어'; + @override + String get darkAppearance => '다크 모드'; + @override + String get stateOn => '켜짐'; + @override + String get followLight => '꺼짐'; + @override + String get protocol => '프로토콜'; + @override + String get settingsTitle => '설정'; + @override + String get autostart => '시작 시 실행'; + @override + String get autostartSub => '시스템 시작 시 자동으로 실행'; + @override + String get autoConnect => '자동 연결'; + @override + String get autoConnectSub => '실행 시 자동으로 연결'; + @override + String autoConnectingTo(String name) => '$name(으)로 자동 연결 중…'; + @override + String get smartRoute => '스마트 라우팅'; + @override + String get smartRouteSub => '해외는 가속, 국내는 직접 연결'; + @override + String get killSwitch => 'Kill Switch'; + @override + 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 => '요금제 선택'; + @override + String get freePlan => '무료'; + @override + String get proPlan => 'Pro'; + @override + String get teamPlan => 'Team'; + @override + String get perMonth => '/월'; + @override + String get current => '현재'; + @override + String get upgrade => '업그레이드'; + @override + String get choose => '선택'; + @override + String get mostPopular => '인기'; + @override + List get featsFree => const [ + '기본 노드 1개', + '하루 10분', + '광고 시청 후 시작', + '가입 시 7일 무료 체험', + ]; + @override + List get featsPro => + const ['80개 이상 지역', '무제한 · 빠른 속도', '기기 5대', '스트리밍 최적화']; + @override + List get featsTeam => + const ['Pro의 모든 기능', '10인 좌석', '통합 결제', '우선 지원']; + + @override + String get redeemTitle => '코드 등록 및 구매'; + @override + String get redeemCodeTitle => '코드 등록'; + @override + String get redeemPh => '활성화 코드 입력'; + @override + String get redeemBtn => '등록'; + @override + String get redeemOk => '활성화됨 · PRO 잠금 해제'; + @override + String get buyTitle => '구매처'; + @override + String get buySub => '앱 내 결제는 지원되지 않습니다. 다음 경로로 코드를 받으세요:'; + @override + String get chStore => '셀프 스토어'; + @override + String get chEmail => '이메일 지원'; + + @override + String get contactTitle => '문의하기'; + @override + String get contactIntro => + '도움이 필요하신가요? 아래 채널로 문의하세요 — 보통 몇 분 안에 답변드립니다.'; + @override + String get contactEmail => '이메일 지원'; + @override + String get contactStore => '셀프 스토어'; + @override + String get contactHoursTitle => '지원 시간'; + @override + String get contactHours => '매일 9:00 – 24:00 (GMT+8)'; + + @override + String get authTagline => '빠르게 · 안정적으로 · 손쉽게'; + @override + String get tabLogin => '로그인'; + @override + String get tabRegister => '회원가입'; + @override + String get emailLabel => '이메일'; + @override + String get emailPh => 'your@email.com'; + @override + String get pwLabel => '비밀번호'; + @override + String get pwPh => '비밀번호 입력'; + @override + String get setPwPh => '비밀번호 설정 (다중 기기 로그인용)'; + @override + String get codeLabel => '인증 코드'; + @override + String codeSentTo(String email) => '$email(으)로 코드를 보냈습니다'; + @override + String get sendCode => '코드 전송'; + @override + String get resend => '재전송'; + @override + String get doLogin => '로그인'; + @override + String get doNext => '다음'; + @override + String get doCreate => '계정 만들기'; + @override + String get forgotPw => '비밀번호를 잊으셨나요?'; + @override + String get tos => '계속하면 이용약관 및 개인정보 처리방침에 동의하는 것입니다'; + + @override + String get obSkip => '건너뛰기'; + @override + String get obNext => '다음'; + @override + String get obAllow => '허용하고 계속'; + @override + String get obStart => '시작하기'; + @override + String get ob1Title => '전 세계로 연결'; + @override + String get ob1Sub => '80개 이상의 지역, 자동 최적 경로, 안정적인 연결.'; + @override + String get ob2Title => '네트워크 설정 허용'; + @override + String get ob2Sub => '암호화된 터널을 구성하기 위해 시스템이 네트워크 프로필 추가를 요청합니다.'; + @override + String get ob3Title => '설계부터 프라이버시'; + @override + String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.'; +} diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart new file mode 100644 index 0000000..74094d1 --- /dev/null +++ b/client/lib/l10n/strings_ru.dart @@ -0,0 +1,373 @@ +// strings_ru.dart — русские текстовые ресурсы (отображение на одном языке) +// +// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН. +// Позиционирование: ускорение сети / оптимизация опыта + приватность. +// Номера тарифов соответствуют design/CLAUDE.md §7. +import 'app_text.dart'; + +class StringsRu extends AppText { + const StringsRu(); + + @override + AppLang get lang => AppLang.ru; + + @override + String get brand => 'Pangolin'; + @override + String get online => '● В сети'; + @override + String get offline => '○ Не в сети'; + @override + String get capOff => 'Нажмите для подключения'; + @override + String get capConnecting => 'Подключение…'; + @override + String get capOn => 'Подключено · Шифрование'; + @override + String get connectNow => 'ПОДКЛЮЧИТЬ'; + @override + String get secure => 'ЗАЩИЩЕНО'; + + @override + String get tabConnect => 'Подключение'; + @override + String get tabServers => 'Серверы'; + @override + String get tabStats => 'Статистика'; + @override + String get tabMe => 'Аккаунт'; + + @override + String get currentNode => 'Текущий узел'; + @override + String get download => 'Приём'; + @override + String get upload => 'Отдача'; + @override + String get latency => 'Пинг'; + + @override + String get quotaToday => 'Осталось сегодня'; + @override + String get minutes => 'мин'; + @override + String get quotaFree => 'Бесплатно · 10 мин/день'; + @override + String get planFreeTag => 'Бесплатно'; + @override + String get watchAd => 'Смотрите рекламу, чтобы начать'; + @override + String get adUnlocked => 'Открыто на сегодня'; + @override + String get quotaLeftLabel => 'Осталось'; + @override + String get quotaUsedUp => 'Дневной бесплатный лимит исчерпан'; + @override + String get watchAdMore => 'Смотрите рекламу, чтобы добавить время'; + @override + String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.'; + @override + String get adPlaying => 'Реклама воспроизводится…'; + @override + String adRewarded(int minutes) => '+$minutes мин добавлено'; + @override + String get adFailed => 'Не удалось добавить время, попробуйте ещё раз'; + @override + String get quotaDesktopTitle => 'Дневной бесплатный лимит исчерпан'; + @override + String get quotaDesktopBody => 'Смотрите рекламу в мобильном приложении, чтобы добавить время, или оформите подписку для безлимитного доступа.'; + @override + String get gotIt => 'Понятно'; + + @override + String get chooseNode => 'Выбор сервера'; + @override + String get searchPh => 'Поиск страны / города'; + @override + String get smartSelect => 'Умный выбор'; + @override + String nodeSwitchTitle(String name) => 'Переключиться на $name?'; + @override + String get nodeSwitchBody => 'Текущее соединение будет разорвано и восстановлено через этот узел.'; + @override + String get nodeSwitchConfirm => 'Переключить'; + @override + String get nodeUnhealthySwitched => 'Текущий узел недоступен — переключение выполнено автоматически'; + @override + String get nodeUnhealthyError => 'Текущий узел недоступен — переподключитесь или смените узел'; + @override + String get nodeReconnecting => 'Сеть нестабильна, переподключение…'; + @override + String get smartSub => 'Подбирает лучший узел для вашей сети'; + @override + String get recommended => 'Рекомендуется'; + + @override + String get statsTitle => 'Статистика'; + @override + String get trafficMonth => 'Трафик'; + @override + String get avgPing => 'Средний пинг'; + @override + String get durMonth => 'Время'; + @override + String get weekTraffic => 'На этой неделе (ГБ)'; + @override + List get days7 => const ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; + @override + String get byDevice => 'По устройствам'; + @override + String get noDeviceUsage => 'Пока нет данных по устройствам'; + @override + String get periodMonth => 'Этот месяц'; + @override + String get periodWeek => 'Эта неделя'; + @override + String get periodToday => 'Сегодня'; + @override + String get statDown => 'Приём'; + @override + String get statUp => 'Отдача'; + @override + String get statDuration => 'Длительность'; + @override + String get chartTwoWeeks => 'Последние 2 недели'; + @override + String get allDevices => 'Все устройства'; + + @override + String get meTitle => 'Аккаунт'; + @override + String get proMember => 'PRO-подписка'; + @override + String get freePlanName => 'Бесплатный'; + @override + String get expires => 'Действует до'; + @override + String get upgradeBtn => 'Продлить / Улучшить'; + @override + String get accInfoTitle => 'Данные аккаунта'; + @override + String get accEmail => 'Эл. почта'; + @override + String get accPassword => 'Пароль'; + @override + String get accChange => 'Изменить'; + @override + String get deviceLimitTitle => 'Достигнут лимит устройств'; + @override + String get deviceLimitDesc => 'Ваш тариф допускает до %s устройств. Удалите одно, чтобы продолжить.'; + @override + String get deviceLimitRemoveOldest => 'Удалить давно неиспользуемое'; + @override + String get myDevices => 'Мои устройства'; + @override + String get devicesSub => 'До 3 устройств на PRO'; + @override + String get thisDevice => 'Это устройство'; + @override + String get remove => 'Удалить'; + @override + String get devOnline => 'В сети'; + @override + String get devOffline => 'Не в сети'; + @override + String get devLastOnline => 'Был(а) в сети'; + @override + String get devNeverLogin => 'Ни разу не входил(а)'; + @override + String get devForceLogout => 'Принудительный выход'; + @override + String get devClearLogin => 'Очистить данные входа'; + @override + String get devRename => 'Переименовать'; + @override + String get devRenameHint => 'Имя устройства'; + @override + String get devSave => 'Сохранить'; + @override + String get devForceLogoutConfirm => 'Это завершит сеанс входа устройства — оно будет отключено и потребует повторного входа. Устройство останется в списке.'; + @override + String get devClearLoginConfirm => 'Это полностью удалит устройство из списка и отзовёт его сеанс входа и учётные данные передачи данных. Чтобы снова использовать устройство, нужно войти заново.'; + @override + String get devCancel => 'Отмена'; + @override + String get sessionRevokedTitle => 'Это устройство было отключено'; + @override + String get sessionRevokedBody => 'Вход с этого устройства завершён с другого устройства вашего аккаунта. Пожалуйста, войдите снова.'; + @override + String get sessionRevokedOk => 'Войти снова'; + @override + String get redeemEntry => 'Активация и покупка'; + @override + String get contactEntry => 'Связаться с нами'; + @override + String get signOut => 'Выйти'; + @override + String get language => 'Язык'; + @override + String get darkAppearance => 'Тёмная тема'; + @override + String get stateOn => 'Вкл.'; + @override + String get followLight => 'Выкл.'; + @override + String get protocol => 'Протокол'; + @override + String get settingsTitle => 'Настройки'; + @override + String get autostart => 'Запуск при старте системы'; + @override + String get autostartSub => 'Запускать автоматически при включении системы'; + @override + String get autoConnect => 'Автоподключение'; + @override + String get autoConnectSub => 'Подключаться автоматически при запуске'; + @override + String autoConnectingTo(String name) => 'Автоподключение к $name…'; + @override + String get smartRoute => 'Умная маршрутизация'; + @override + String get smartRouteSub => 'Ускорение для зарубежных ресурсов, прямой доступ для локальных'; + @override + String get killSwitch => 'Kill Switch'; + @override + 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 => 'Выбор тарифа'; + @override + String get freePlan => 'Бесплатный'; + @override + String get proPlan => 'Pro'; + @override + String get teamPlan => 'Команда'; + @override + String get perMonth => '/мес'; + @override + String get current => 'Текущий'; + @override + String get upgrade => 'Улучшить'; + @override + String get choose => 'Выбрать'; + @override + String get mostPopular => 'Популярный'; + @override + List get featsFree => const [ + 'Только 1 базовый узел', + '10 минут в день', + 'Смотрите рекламу, чтобы начать', + '7 дней бесплатно при регистрации', + ]; + @override + List get featsPro => + const ['80+ локаций', 'Безлимитно · быстро', '5 устройств', 'Оптимизация для стриминга']; + @override + List get featsTeam => + const ['Всё из Pro', '10 мест', 'Единый счёт', 'Приоритетная поддержка']; + + @override + String get redeemTitle => 'Активация и покупка'; + @override + String get redeemCodeTitle => 'Активировать код'; + @override + String get redeemPh => 'Введите код активации'; + @override + String get redeemBtn => 'Активировать'; + @override + String get redeemOk => 'Активировано · PRO открыт'; + @override + String get buyTitle => 'Где купить'; + @override + String get buySub => 'Оплата внутри приложения недоступна. Получить код можно через:'; + @override + String get chStore => 'Магазин самообслуживания'; + @override + String get chEmail => 'Поддержка по эл. почте'; + + @override + String get contactTitle => 'Связаться с нами'; + @override + String get contactIntro => + 'Нужна помощь? Свяжитесь с нами любым способом ниже — обычно отвечаем за считанные минуты.'; + @override + String get contactEmail => 'Поддержка по эл. почте'; + @override + String get contactStore => 'Магазин самообслуживания'; + @override + String get contactHoursTitle => 'Часы поддержки'; + @override + String get contactHours => 'Ежедневно 9:00 – 24:00 (GMT+8)'; + + @override + String get authTagline => 'Быстро · Стабильно · Без усилий'; + @override + String get tabLogin => 'Вход'; + @override + String get tabRegister => 'Регистрация'; + @override + String get emailLabel => 'Эл. почта'; + @override + String get emailPh => 'your@email.com'; + @override + String get pwLabel => 'Пароль'; + @override + String get pwPh => 'Введите пароль'; + @override + String get setPwPh => 'Задайте пароль (для входа с нескольких устройств)'; + @override + String get codeLabel => 'Код подтверждения'; + @override + String codeSentTo(String email) => 'Код отправлен на $email'; + @override + String get sendCode => 'Отправить код'; + @override + String get resend => 'Отправить снова'; + @override + String get doLogin => 'Войти'; + @override + String get doNext => 'Далее'; + @override + String get doCreate => 'Создать аккаунт'; + @override + String get forgotPw => 'Забыли пароль?'; + @override + String get tos => 'Продолжая, вы принимаете наши Условия и Политику конфиденциальности'; + + @override + String get obSkip => 'Пропустить'; + @override + String get obNext => 'Далее'; + @override + String get obAllow => 'Разрешить и продолжить'; + @override + String get obStart => 'Начать'; + @override + String get ob1Title => 'Подключение по всему миру'; + @override + String get ob1Sub => '80+ локаций, автоматический выбор самого быстрого маршрута, надёжная стабильность.'; + @override + String get ob2Title => 'Разрешите настройку сети'; + @override + String get ob2Sub => 'Система запросит добавление сетевого профиля для создания зашифрованного туннеля.'; + @override + String get ob3Title => 'Приватность по умолчанию'; + @override + String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.'; +} diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index e2099ea..11584ae 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -52,9 +52,31 @@ class StringsZh extends AppText { @override String get quotaFree => '免费版 · 每日 10 分钟'; @override + String get planFreeTag => '免费版'; + @override String get watchAd => '看广告开始使用'; @override String get adUnlocked => '已解锁 · 今日可用'; + @override + String get quotaLeftLabel => '剩余'; + @override + String get quotaUsedUp => '今日免费时长已用完'; + @override + String get watchAdMore => '看广告加时'; + @override + String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员'; + @override + String get adPlaying => '广告播放中…'; + @override + String adRewarded(int minutes) => '已加 $minutes 分钟'; + @override + String get adFailed => '加时失败,请重试'; + @override + String get quotaDesktopTitle => '今日免费时长已用完'; + @override + String get quotaDesktopBody => '前往移动端 App 看广告加时,或升级会员畅享无限时长。'; + @override + String get gotIt => '知道了'; @override String get chooseNode => '选择节点'; @@ -73,6 +95,8 @@ class StringsZh extends AppText { @override String get nodeUnhealthyError => '当前节点异常,请重连或更换节点'; @override + String get nodeReconnecting => '网络波动,正在重连…'; + @override String get smartSub => '根据当前网络环境,自动选择最优节点'; @override String get recommended => '推荐'; @@ -210,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 => '选择套餐'; diff --git a/client/lib/models/me.dart b/client/lib/models/me.dart index dc581da..4657f07 100644 --- a/client/lib/models/me.dart +++ b/client/lib/models/me.dart @@ -12,6 +12,7 @@ class Me { this.devicesUsed = 0, this.devicesMax = 1, this.quotaTodayMin, + this.quotaCapMin, this.dataTodayGb = 0, this.weeklyGb = const [], this.totpEnabled = false, @@ -29,9 +30,13 @@ class Me { final int devicesMax; /// 今日**剩余**额度(分钟);null = 不限(pro/team)。 - /// 后端已算好 = 套餐每日上限 − 今日已用。 + /// 后端已算好 = 当日额度 − 今日已用(账户共享,非每设备)。 final int? quotaTodayMin; + /// 今日**总额度**(分钟)= 套餐每日上限 + 看广告累加分钟;null = 不限。 + /// 供进度条分母使用(剩余 / 总额度)。 + final int? quotaCapMin; + /// 今日已用流量(GB)。 final double dataTodayGb; @@ -51,6 +56,7 @@ class Me { devicesUsed: (m['devices_used'] as num?)?.toInt() ?? 0, devicesMax: (m['devices_max'] as num?)?.toInt() ?? 1, quotaTodayMin: (m['quota_today_min'] as num?)?.toInt(), + quotaCapMin: (m['quota_cap_min'] as num?)?.toInt(), dataTodayGb: (m['data_today_gb'] as num?)?.toDouble() ?? 0, weeklyGb: ((m['weekly_gb'] as List?) ?? const []) .map((e) => (e as num).toDouble()) diff --git a/client/lib/models/node.dart b/client/lib/models/node.dart index 5fd0035..4a1263a 100644 --- a/client/lib/models/node.dart +++ b/client/lib/models/node.dart @@ -66,7 +66,7 @@ class Node { String localizedSub(AppLang lang) { switch (tag) { case NodeTag.streaming: - return lang == AppLang.zh ? '流媒体优化' : 'Streaming'; + return lang.tagStreaming; case NodeTag.p2p: return 'P2P'; case NodeTag.none: diff --git a/client/lib/pangolin_tokens.gen.dart b/client/lib/pangolin_tokens.gen.dart index d45df1f..83de116 100644 --- a/client/lib/pangolin_tokens.gen.dart +++ b/client/lib/pangolin_tokens.gen.dart @@ -1,6 +1,6 @@ // pangolin_tokens.gen.dart // AUTO-GENERATED — 勿手改。 -// 源: design/colors_and_type.css +// 源: design/prototype/tokens.css // 生成器: design/codegen/gen_flutter_tokens.mjs // // 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow @@ -9,7 +9,7 @@ import 'package:flutter/material.dart'; /// ── Primitive color ramps ─────────────────────────────────────────── -/// Auto-generated from design/colors_and_type.css :root color ramps. +/// Auto-generated from design/prototype/tokens.css :root color ramps. class PangolinColors { PangolinColors._(); diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index 2a6af06..b656f00 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -99,7 +99,7 @@ class AccountPage extends ConsumerWidget { _CustomRow( icon: PangolinIcons.globe, title: t.language, - trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).state = l), + trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)), ), Divider(height: 1, color: c.border), _CustomRow( @@ -375,31 +375,37 @@ class _LangSwitch extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; - Widget seg(AppLang v, String label) { - final active = lang == v; - return GestureDetector( - onTap: () => onChange(v), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 5), - decoration: BoxDecoration( - color: active ? c.accent : Colors.transparent, - borderRadius: BorderRadius.circular(PangolinRadius.full), + // 6 种语言用下拉(段控横排会挤)。 + return PopupMenuButton( + initialValue: lang, + onSelected: onChange, + color: c.surface, + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)), + itemBuilder: (_) => [ + for (final l in AppLang.values) + PopupMenuItem( + value: l, + height: 42, + child: Text(l.nativeLabel, + style: PangolinText.caption.copyWith( + color: l == lang ? c.accent : c.fg1, + fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500, + fontSize: 13)), ), - child: Text(label, - style: PangolinText.caption.copyWith( - color: active ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12.5)), - ), - ); - } - - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - seg(AppLang.zh, '中文'), - const SizedBox(width: 2), - seg(AppLang.en, 'EN'), - ]), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6), + decoration: + BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(lang.nativeLabel, + style: PangolinText.caption + .copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)), + const SizedBox(width: 4), + Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3), + ]), + ), ); } } diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 9974abf..657c928 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -11,6 +11,7 @@ import '../state/app_providers.dart'; import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; import '../state/quota_provider.dart'; +import '../widgets/ad_reward_dialog.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/connect_button.dart'; import '../widgets/country_code.dart'; @@ -52,11 +53,18 @@ class ConnectPage extends ConsumerWidget { VpnPhase.on => t.capOn, }; + // 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。 + final isDesktop = context.formFactor == FormFactor.desktop; + final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off); + void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop); + final button = ConnectButton( phase: conn.phase, elapsed: conn.elapsed, offLabel: t.connectNow, secureLabel: t.secure, + enabled: connectEnabled, + onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ); @@ -75,7 +83,13 @@ class ConnectPage extends ConsumerWidget { final infoChildren = [ if (isFree) - QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()), + QuotaCard( + quota: quota, + t: t, + countdown: conn.freeCountdown, + isDesktop: isDesktop, + onWatchAd: onQuotaBlockedTap, + ), if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue), _CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes), ]; @@ -100,6 +114,8 @@ class ConnectPage extends ConsumerWidget { offLabel: t.connectNow, secureLabel: t.secure, size: 176, + enabled: connectEnabled, + onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ), const SizedBox(height: 24), @@ -120,7 +136,13 @@ class ConnectPage extends ConsumerWidget { const SizedBox(height: 24), SizedBox( width: 340, - child: QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()), + child: QuotaCard( + quota: quota, + t: t, + countdown: conn.freeCountdown, + isDesktop: isDesktop, + onWatchAd: onQuotaBlockedTap, + ), ), ], if (conn.phase == VpnPhase.on) ...[ diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index 03e2ff4..be8b797 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -267,7 +267,7 @@ class _NodeGridTile extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 - final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; + final unavail = lang.unavailable; return Opacity( opacity: down ? 0.55 : 1.0, child: Material( diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index d43cda6..97a9604 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -9,9 +9,30 @@ 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'; +/// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, +/// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。 +Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async { + final info = await ref.read(updateProvider.notifier).forceCheck(); + if (!context.mounted) return; + if (info == null) { + showPangolinToast(context, t.updateCheckFailed); + return; + } + if (!info.hasUpdate) { + showPangolinToast(context, t.updateUpToDate); + return; + } + // 有更新:清掉「已忽略版本」→ 顶部 banner 重新显示(不弹窗);toast 提示一下。 + // 强制更新由 home_shell 的 listen 走不可关闭弹窗,这里无需处理。 + ref.read(dismissedUpdateVersionProvider.notifier).state = null; + showPangolinToast(context, t.updateAvailableTitle(info.latestVersion)); +} class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @@ -52,14 +73,15 @@ class SettingsPage extends ConsumerWidget { const SizedBox(height: 16), // 配置组 _Card(children: [ - _Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).state = l)), + _Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))), _Row( title: t.darkAppearance, sub: isDark ? t.stateOn : t.followLight, 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))), ]), ]), @@ -127,27 +149,37 @@ class _LangSwitch extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; - Widget seg(AppLang v, String label) { - final on = lang == v; - return GestureDetector( - onTap: () => onPick(v), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: on ? c.accent : Colors.transparent, - borderRadius: BorderRadius.circular(PangolinRadius.full), + // 6 种语言用下拉(段控横排会挤)。当前语言以本地名 + 下拉箭头展示。 + return PopupMenuButton( + initialValue: lang, + onSelected: onPick, + color: c.surface, + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)), + itemBuilder: (_) => [ + for (final l in AppLang.values) + PopupMenuItem( + value: l, + height: 42, + child: Text(l.nativeLabel, + style: PangolinText.caption.copyWith( + color: l == lang ? c.accent : c.fg1, + fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500, + fontSize: 13)), ), - child: Text(label, - style: PangolinText.caption.copyWith( - color: on ? c.fgOnAccent : c.fg3, fontWeight: FontWeight.w700, fontSize: 12)), - ), - ); - } - - return Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Row(mainAxisSize: MainAxisSize.min, children: [seg(AppLang.zh, '中文'), seg(AppLang.en, 'EN')]), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: + BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text(lang.nativeLabel, + style: PangolinText.caption + .copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12)), + const SizedBox(width: 4), + Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3), + ]), + ), ); } } diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index f416fd8..9736e4d 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -99,7 +99,23 @@ class AccountApi { Future redeem(String code) async => RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code})); - /// POST /v1/ads/unlock — 看广告解锁今日免费额度。 - Future adUnlock({required String deviceId, required String adToken}) => - _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); + /// POST /v1/ads/unlock — 看广告加时(累加式)。返回本次加时分钟与最新剩余分钟。 + Future adUnlock({required String deviceId, required String adToken}) async { + final body = await _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); + return AdUnlockResult( + grantedMinutes: (body['granted_minutes'] as num?)?.toInt() ?? 0, + minutesRemaining: (body['minutes_remaining'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 看广告加时结果(POST /v1/ads/unlock 响应)。 +class AdUnlockResult { + const AdUnlockResult({required this.grantedMinutes, required this.minutesRemaining}); + + /// 本次广告实际加时分钟(已达每日封顶时为 0)。 + final int grantedMinutes; + + /// 加时后账户当日剩余分钟(全账户共享)。 + final int minutesRemaining; } diff --git a/client/lib/services/api_config.dart b/client/lib/services/api_config.dart index 5fc33b8..16ac817 100644 --- a/client/lib/services/api_config.dart +++ b/client/lib/services/api_config.dart @@ -2,8 +2,17 @@ // // 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里, // 由 --dart-define=PANGOLIN_API_URL 注入。 -// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名。 +// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名; +// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。 const String kApiBaseUrl = String.fromEnvironment( 'PANGOLIN_API_URL', - defaultValue: 'http://103.119.13.48:8080', + defaultValue: 'https://api.yanmeiai.com', +); + +/// 用户中心(网页)基址(单源)。已从独立子域 app.yanmeiai.com 迁到主站子路径 +/// pangolin.yanmeiai.com/user/(旧子域已停用)。末尾不带斜杠;调用方自行拼 path。 +/// 本地联调可 --dart-define=PANGOLIN_USERCENTER_URL=http://127.0.0.1:3000 覆盖。 +const String kWebUserCenterBaseUrl = String.fromEnvironment( + 'PANGOLIN_USERCENTER_URL', + defaultValue: 'https://pangolin.yanmeiai.com/user', ); diff --git a/client/lib/services/connect_api.dart b/client/lib/services/connect_api.dart index 1799bd7..440cdea 100644 --- a/client/lib/services/connect_api.dart +++ b/client/lib/services/connect_api.dart @@ -121,5 +121,27 @@ class ConnectApi { return response.body; } + /// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。 + /// + /// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉—— + /// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。 + Future disconnect({ + required String nodeId, + required String deviceId, + }) async { + try { + await _client + .post( + Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $authToken', + }, + body: jsonEncode({'device_id': deviceId}), + ) + .timeout(const Duration(seconds: 5)); + } catch (_) {/* best-effort */} + } + void dispose() => _client.close(); } diff --git a/client/lib/services/web_launch.dart b/client/lib/services/web_launch.dart new file mode 100644 index 0000000..d26c745 --- /dev/null +++ b/client/lib/services/web_launch.dart @@ -0,0 +1,27 @@ +// 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'; +import 'api_config.dart'; + +/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。 +Future 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); +} diff --git a/client/lib/shell/home_shell.dart b/client/lib/shell/home_shell.dart index ad4e098..4b08e3e 100644 --- a/client/lib/shell/home_shell.dart +++ b/client/lib/shell/home_shell.dart @@ -8,6 +8,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/responsive/form_factor.dart'; import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/update_provider.dart'; +import '../widgets/update_dialog.dart'; import 'desktop_shell.dart'; import 'mobile_shell.dart'; import 'tablet_shell.dart'; @@ -18,11 +21,37 @@ class HomeShell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final c = context.pangolin; + + // 启动自动检查更新:watch 惰性拉起 updateProvider(延迟 3s→查→1h 轮询)。 + // 非强制更新 → 顶部 banner(见下,不打断);强制更新 → 不可关闭弹窗。 + ref.listen>(updateProvider, (prev, next) { + final info = next.valueOrNull; + if (info != null && info.hasUpdate && info.forceUpdate) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + showUpdateDialog(context, ref.read(appTextProvider), info); + } + }); + } + }); + + final info = ref.watch(updateProvider).valueOrNull; + final dismissedVer = ref.watch(dismissedUpdateVersionProvider); + final showBanner = info != null && + info.hasUpdate && + !info.forceUpdate && + dismissedVer != info.latestVersion; + final Widget body = switch (context.formFactor) { FormFactor.desktop => const DesktopShell(), FormFactor.tablet => const TabletShell(), FormFactor.mobile => const MobileShell(), }; - return Scaffold(backgroundColor: c.bg, body: body); + return Scaffold( + backgroundColor: c.bg, + body: showBanner + ? Column(children: [UpdateBanner(info: info), Expanded(child: body)]) + : body, + ); } } diff --git a/client/lib/state/account_providers.dart b/client/lib/state/account_providers.dart index 8cf5fe0..42e9e3d 100644 --- a/client/lib/state/account_providers.dart +++ b/client/lib/state/account_providers.dart @@ -40,11 +40,19 @@ class MeNotifier extends AsyncNotifier { return ref.read(accountApiProvider).me(); } - /// 手动刷新(如连接/兑换后)。 + /// 手动刷新(如兑换后 / 下拉刷新)。会短暂进 loading 态(触发整页转圈)。 Future refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() => ref.read(accountApiProvider).me()); } + + /// 静默刷新:不进 loading 态、成功才替换。供连接期/断开后刷新「今日剩余」用, + /// 避免 refresh() 的 AsyncLoading 让连接页整页闪 spinner(meLoadingProvider)。 + Future silentRefresh() async { + if (!ref.read(authProvider).isLoggedIn) return; + final next = await AsyncValue.guard(() => ref.read(accountApiProvider).me()); + if (next is AsyncData) state = next; + } } final meProvider = AsyncNotifierProvider(MeNotifier.new); diff --git a/client/lib/state/app_providers.dart b/client/lib/state/app_providers.dart index 3300fd0..673d8c1 100644 --- a/client/lib/state/app_providers.dart +++ b/client/lib/state/app_providers.dart @@ -1,21 +1,77 @@ // app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod) import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../l10n/app_text.dart'; import '../l10n/strings_en.dart'; +import '../l10n/strings_es.dart'; +import '../l10n/strings_ja.dart'; +import '../l10n/strings_ko.dart'; +import '../l10n/strings_ru.dart'; import '../l10n/strings_zh.dart'; import 'account_providers.dart'; import 'auth_provider.dart'; -/// 当前语言(单显)。设置/账户页段控切换。 -final localeProvider = StateProvider((ref) => AppLang.zh); +/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」 +/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。 +AppText appTextFor(AppLang lang) { + switch (lang) { + case AppLang.zh: + return const StringsZh(); + case AppLang.en: + return const StringsEn(); + case AppLang.ja: + return const StringsJa(); + case AppLang.ko: + return const StringsKo(); + case AppLang.ru: + return const StringsRu(); + case AppLang.es: + return const StringsEs(); + } +} + +/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到 +/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化, +/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。 +class LocaleNotifier extends StateNotifier { + LocaleNotifier() : super(AppLang.en) { + _load(); + } + static const _key = 'pg_lang'; + + Future _load() async { + try { + final saved = (await SharedPreferences.getInstance()).getString(_key); + if (saved != null) { + for (final l in AppLang.values) { + if (l.name == saved) { + state = l; + break; + } + } + } + } catch (_) { + /* 读失败保持默认 en */ + } + } + + Future set(AppLang lang) async { + state = lang; + try { + await (await SharedPreferences.getInstance()).setString(_key, lang.name); + } catch (_) { + /* 持久化失败忽略,本次会话仍生效 */ + } + } +} + +final localeProvider = + StateNotifierProvider((ref) => LocaleNotifier()); /// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。 -final appTextProvider = Provider((ref) { - final lang = ref.watch(localeProvider); - return lang == AppLang.zh ? const StringsZh() : const StringsEn(); -}); +final appTextProvider = Provider((ref) => appTextFor(ref.watch(localeProvider))); /// 主题模式。默认跟随系统;设置页可显式切深色。 final themeModeProvider = StateProvider((ref) => ThemeMode.system); diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 3e9f907..07a38f6 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -19,9 +19,11 @@ import '../services/api_config.dart'; import '../services/auth_api.dart'; import '../services/connect_api.dart'; import '../services/device_identity.dart'; +import 'account_providers.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; import 'nodes_provider.dart'; +import 'quota_provider.dart'; import 'settings_provider.dart'; // 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。 @@ -35,7 +37,12 @@ enum VpnPhase { off, connecting, on } // ── 连接状态快照 ────────────────────────────────────────────────── class ConnectionState { - const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error}); + const ConnectionState({ + required this.phase, + this.elapsed = Duration.zero, + this.error, + this.freeCountdown, + }); final VpnPhase phase; final Duration elapsed; @@ -43,18 +50,27 @@ class ConnectionState { /// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 final String? error; - ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) => - ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed); + /// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。 + /// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。 + final Duration? freeCountdown; + + ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) => + ConnectionState( + phase: phase ?? this.phase, + elapsed: elapsed ?? this.elapsed, + freeCountdown: freeCountdown ?? this.freeCountdown, + ); @override bool operator ==(Object other) => other is ConnectionState && other.phase == phase && other.elapsed == elapsed && - other.error == error; + other.error == error && + other.freeCountdown == freeCountdown; @override - int get hashCode => Object.hash(phase, elapsed, error); + int get hashCode => Object.hash(phase, elapsed, error, freeCountdown); } // ── 连通看门狗 ───────────────────────────────────────────────────── @@ -76,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45); // stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活, // 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。 const _kStatsLive = Duration(seconds: 10); +// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18 +const _kMaxAutoReconnect = 3; // ── 状态机 ─────────────────────────────────────────────────────── @@ -92,7 +110,8 @@ class ConnectionController extends StateNotifier { _authSub = _ref.listen(authProvider, (prev, next) { if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) { _userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」 - unawaited(_disconnect()); + // 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。 + unawaited(_disconnect(revokeCredential: true)); } }); // 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest @@ -140,6 +159,12 @@ class ConnectionController extends StateNotifier { ConnectApi? _api; // 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。 Node? _connectedNode; + // 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或 + // 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18 + int _autoReconnectAttempts = 0; + // 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时, + // 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。 + int? _freeRemainingSec; // ── 公有 API ─────────────────────────────────────────────────── @@ -147,10 +172,11 @@ class ConnectionController extends StateNotifier { void toggle() { switch (state.phase) { case VpnPhase.off: + _autoReconnectAttempts = 0; // 用户主动连接:重置弱网自动重连额度(#18) _connect(); case VpnPhase.on: _userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常 - _disconnect(); + _disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4) case VpnPhase.connecting: break; // 握手进行中,不响应 } @@ -170,10 +196,15 @@ class ConnectionController extends StateNotifier { _userDisconnect = false; _lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位) _lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位) + // 免费版:锁定本次连接可用的剩余额度(账户共享,权威取自 me)。会员为 null 不倒计时。 + _freeRemainingSec = _ref.read(isFreePlanProvider) + ? _ref.read(quotaProvider).remainingMinutes * 60 + : null; state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); - final zh = _ref.read(localeProvider) == AppLang.zh; + final lang = _ref.read(localeProvider); + final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方) logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} ' 'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}'); @@ -182,7 +213,7 @@ class ConnectionController extends StateNotifier { if (mounted) { state = ConnectionState( phase: VpnPhase.off, - error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry', + error: lang.nodesNotReady, ); } return; @@ -202,13 +233,19 @@ class ConnectionController extends StateNotifier { if (mounted) state = const ConnectionState(phase: VpnPhase.off); return; } + // 免费额度已用完(服务端兜底):本地置耗尽 → 按钮灰化、点击弹广告/升级。回 off。 + if (e.code == 'QUOTA_EXHAUSTED') { + _ref.read(quotaProvider.notifier).markExhausted(); + if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn); + return; + } // 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。 if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn); } catch (e) { if (mounted) { state = ConnectionState( phase: VpnPhase.off, - error: zh ? '连接失败,请重试' : 'Connection failed, please retry', + error: lang.connectFailed, ); } } @@ -242,7 +279,23 @@ class ConnectionController extends StateNotifier { } } - Future _disconnect() async { + /// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。 + /// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)—— + /// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、 + /// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。 + Future _disconnect({bool revokeCredential = false}) async { + if (revokeCredential) { + final api = _api; + final node = _connectedNode; + if (api != null && node != null && node.uuid.isNotEmpty) { + unawaited(() async { + try { + final deviceId = await _ref.read(deviceIdentityProvider).deviceId(); + await api.disconnect(nodeId: node.uuid, deviceId: deviceId); + } catch (_) {/* best-effort */} + }()); + } + } _stopElapsed(); _stopWatchdog(); try { @@ -250,12 +303,26 @@ class ConnectionController extends StateNotifier { } catch (_) {} // 携带 _offNotice(节点异常/null);与 kernel off 读同一字段,不互相覆盖。 if (mounted) state = ConnectionState(phase: VpnPhase.off, error: _offNotice); + _refreshQuota(); // 会话结束 → 刷新「今日剩余」反映本次消耗 + } + + // 静默刷新 /me(不闪整页 spinner),让免费额度「今日剩余」跨会话/跨设备更新。 + void _refreshQuota() { + if (_ref.read(authProvider).isLoggedIn && _ref.read(isFreePlanProvider)) { + unawaited(_ref.read(meProvider.notifier).silentRefresh()); + } } void _onKernelStatus(VpnStatus s) { if (!mounted) return; switch (s) { case VpnStatus.on: + // 免费版:若本次「on」不是经 _connect 而来(如 Android 常驻隧道、app 重启后 + // 自动同步到已在跑的隧道),_freeRemainingSec 还是 null → 倒计时不启。这里兜底 + // 按当前额度补锁定,保证连接页显示的是倒计时(会变)而非静态「今日剩余」。 + if (_freeRemainingSec == null && _ref.read(isFreePlanProvider)) { + _freeRemainingSec = _ref.read(quotaProvider).remainingMinutes * 60; + } state = state.copyWith(phase: VpnPhase.on); _startElapsed(); _startWatchdog(); @@ -269,15 +336,20 @@ class ConnectionController extends StateNotifier { // 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→ // connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。 final wasConnected = state.phase == VpnPhase.on; + _stopElapsed(); + _stopWatchdog(); if (!_userDisconnect && wasConnected && _offNotice == null) { - _offNotice = _ref.read(appTextProvider).nodeUnhealthyError; + // 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连 + // **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才 + // 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18 logLine('Watchdog', 'unexpected kernel ${s.name} after connected → node interrupted'); - unawaited(_ref.read(nodesProvider.notifier).refresh()); + _userDisconnect = false; + unawaited(_handleUnexpectedOff()); + return; } state = ConnectionState(phase: VpnPhase.off, error: _offNotice); _userDisconnect = false; - _stopElapsed(); - _stopWatchdog(); + _refreshQuota(); // 内核掉线也算会话结束 → 刷新「今日剩余」 } } @@ -300,6 +372,7 @@ class ConnectionController extends StateNotifier { if (ds.isEmpty) return; // urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。 _lastUrltestOk = now; + _autoReconnectAttempts = 0; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18 final best = ds.reduce((a, b) => a < b ? a : b); _ref.read(nodesProvider.notifier).setLivePing(node.uuid, best); } @@ -370,12 +443,38 @@ class ConnectionController extends StateNotifier { } return; } - // 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换。 - // 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。 + // 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连。 + // 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18 + if (await _tryAutoReconnectCurrent()) return; + // 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。 _offNotice = t.nodeUnhealthyError; await _disconnect(); } + /// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。 + /// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18 + /// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。 + Future _tryAutoReconnectCurrent() async { + if (_autoReconnectAttempts >= _kMaxAutoReconnect) return false; + _autoReconnectAttempts++; + final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider); + logLine('Watchdog', 'auto-reconnect current node ${node.code} (attempt $_autoReconnectAttempts/$_kMaxAutoReconnect)'); + await _disconnect(); + await _connect(); + // 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。 + if (mounted && state.phase != VpnPhase.off) { + state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).nodeReconnecting); + } + return true; + } + + /// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18 + Future _handleUnexpectedOff() async { + if (await _tryAutoReconnectCurrent()) return; + _offNotice = _ref.read(appTextProvider).nodeUnhealthyError; + await _disconnect(); + } + /// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。 String? _pickAlternativeCode(String excludeCode) { final nodes = (_ref.read(nodesProvider).valueOrNull ?? const []) @@ -397,18 +496,41 @@ class ConnectionController extends StateNotifier { _elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed()); } - /// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)。 + /// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准);免费版顺带推算 + /// 倒计时,归零即自动切断。倒计时用墙上时钟,后台/锁屏漏跳也会在回前台补上、准时切。 void _refreshElapsed() { final at = _connectedAt; - if (mounted && state.phase == VpnPhase.on && at != null) { - state = state.copyWith(elapsed: _now().difference(at)); + if (!mounted || state.phase != VpnPhase.on || at == null) return; + final elapsed = _now().difference(at); + + Duration? countdown; + final capSec = _freeRemainingSec; + if (capSec != null) { + final leftSec = capSec - elapsed.inSeconds; + if (leftSec <= 0) { + unawaited(_onFreeQuotaExhausted()); + return; + } + countdown = Duration(seconds: leftSec); } + state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown); + } + + /// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。 + Future _onFreeQuotaExhausted() async { + _freeRemainingSec = null; + _userDisconnect = true; // 视为主动断开,不触发「节点异常」 + _offNotice = _ref.read(appTextProvider).quotaExhaustedNotice; + _ref.read(quotaProvider.notifier).markExhausted(); + logLine('Quota', 'free daily minutes used up → auto disconnect'); + await _disconnect(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4) } void _stopElapsed() { _elapsed?.cancel(); _elapsed = null; _connectedAt = null; + _freeRemainingSec = null; } @override diff --git a/client/lib/state/quota_provider.dart b/client/lib/state/quota_provider.dart index fe2ab24..d63c2d2 100644 --- a/client/lib/state/quota_provider.dart +++ b/client/lib/state/quota_provider.dart @@ -1,11 +1,16 @@ // quota_provider.dart — 免费版每日额度状态(Riverpod) // -// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端。 -// 总额度取自 plans 的 free.daily_minutes,今日剩余取自 me.quota_today_min -// (后端已算好 = 上限 − 今日已用)。adUnlocked 为本地会话态(看广告需 ad SDK, -// 尚未接入,保留本地乐观置位)。 +// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,且**全账户共享** +// (非每设备)。总额度取自 me.quota_cap_min(= 套餐每日上限 + 看广告累加分钟), +// 今日剩余取自 me.quota_today_min(后端已算好 = 额度 − 今日已用)。 +// +// 看广告加时(累加式):watchAd() 调 /v1/ads/unlock,服务端校验后 +N 分钟并回传最新 +// 剩余,客户端随即刷新 me 让额度权威同步。占位广告 SDK 阶段用客户端生成的 ad_token, +// 服务端 DevVerifier 放行(nonce 仍防重放)。 import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import '../services/device_identity.dart'; import 'account_providers.dart'; /// 免费额度快照。 @@ -13,18 +18,14 @@ class FreeQuotaState { const FreeQuotaState({ this.totalMinutes = 10, this.remainingMinutes = 10, - this.adUnlocked = false, }); - /// 每日总额度(分钟)。§7:免费版每日 10 分钟。 + /// 今日总额度(分钟)= 套餐每日上限 + 看广告累加。§7:免费版基础每日 10 分钟。 final int totalMinutes; - /// 今日剩余分钟(展示值,权威以服务端为准)。 + /// 今日剩余分钟(权威以服务端为准;连接期倒计时由连接状态机本地推算)。 final int remainingMinutes; - /// 今日是否已观看激励视频解锁。 - final bool adUnlocked; - /// 进度(0–1),用于进度条宽度。 double get progress => totalMinutes == 0 ? 0 : (remainingMinutes / totalMinutes).clamp(0.0, 1.0); @@ -32,11 +33,12 @@ class FreeQuotaState { /// 是否进入低额度警示(≤3 分钟切 warning 色)。 bool get isLow => remainingMinutes <= 3; - FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes, bool? adUnlocked}) => - FreeQuotaState( + /// 今日额度是否已耗尽(剩余 0):连接按钮据此灰化,点击弹广告/升级。 + bool get isExhausted => remainingMinutes <= 0; + + FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes}) => FreeQuotaState( totalMinutes: totalMinutes ?? this.totalMinutes, remainingMinutes: remainingMinutes ?? this.remainingMinutes, - adUnlocked: adUnlocked ?? this.adUnlocked, ); } @@ -53,18 +55,41 @@ class QuotaController extends StateNotifier { void _sync() { final me = _ref.read(meProvider).valueOrNull; final plans = _ref.read(plansProvider).valueOrNull; - var total = 10; // §7 默认免费 10 分钟,plans 就绪后以其为准 + var base = 10; // §7 默认免费基础 10 分钟,plans 就绪后以其为准 if (plans != null) { for (final p in plans) { - if (p.code == 'free' && p.dailyMinutes != null) total = p.dailyMinutes!; + if (p.code == 'free' && p.dailyMinutes != null) base = p.dailyMinutes!; } } + // 总额度优先取服务端 quota_cap_min(含看广告加时);缺省回退基础额度。 + final total = me?.quotaCapMin ?? base; final remaining = (me?.quotaTodayMin ?? total).clamp(0, total); - state = state.copyWith(totalMinutes: total, remainingMinutes: remaining); + state = FreeQuotaState(totalMinutes: total, remainingMinutes: remaining); } - /// 观看激励视频后解锁今日使用(本地乐观;真实 ad 校验待 ad SDK 接入)。 - void watchAd() => state = state.copyWith(adUnlocked: true); + /// 连接期倒计时归零 → 本地立即置耗尽(按钮随即灰化);登录态下再静默拉 me 让服务端权威同步。 + void markExhausted() { + state = state.copyWith(remainingMinutes: 0); + _ref.read(meProvider.notifier).silentRefresh(); + } + + /// 看广告加时:调 /v1/ads/unlock 累加分钟,成功后刷新 me 拿最新额度。 + /// 返回本次加时分钟(null = 失败)。占位阶段用客户端生成的 ad_token。 + Future watchAd() async { + try { + final deviceId = await _ref.read(deviceIdentityProvider).deviceId(); + final res = await _ref.read(accountApiProvider).adUnlock( + deviceId: deviceId, + adToken: const Uuid().v4(), // 占位 ad_token(DevVerifier 放行) + ); + // 乐观置位剩余,再静默拉 me 校准(账户共享,以服务端为准;不闪整页 spinner)。 + state = state.copyWith(remainingMinutes: res.minutesRemaining); + await _ref.read(meProvider.notifier).silentRefresh(); + return res.grantedMinutes; + } catch (_) { + return null; + } + } } final quotaProvider = StateNotifierProvider( diff --git a/client/lib/state/update_provider.dart b/client/lib/state/update_provider.dart new file mode 100644 index 0000000..340fce7 --- /dev/null +++ b/client/lib/state/update_provider.dart @@ -0,0 +1,148 @@ +// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。 +// +// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性 +// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。 +// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。 +// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。 +// +// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。 +import 'dart:async'; +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'; + +/// 启动后首次检查的延迟(避开登录/首屏竞争)。 +const _kInitialDelay = Duration(seconds: 3); + +/// 轮询间隔。 +const _kPollInterval = Duration(hours: 1); + +/// 单次检查网络超时。 +const _kCheckTimeout = Duration(seconds: 8); + +/// 一次更新检查的结果。 +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 downloadUrls; + final bool hasUpdate; +} + +/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。 +class UpdateNotifier extends AsyncNotifier { + Timer? _timer; + Timer? _initialTimer; + + @override + Future build() async { + // 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非 + // Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose + // 时会悬挂 → widget 测试报 pending timer、生产留资源)。 + ref.onDispose(() { + _initialTimer?.cancel(); + _timer?.cancel(); + }); + final ready = Completer(); + _initialTimer = Timer(_kInitialDelay, ready.complete); + await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止 + final first = await _check(); + _timer = Timer.periodic(_kPollInterval, (_) async { + state = AsyncValue.data(await _check()); + }); + return first; + } + + /// 设置页「检查更新」手动触发:立即查一次并回结果。 + Future forceCheck() async { + final info = await _check(); + state = AsyncValue.data(info); + return info; + } + + /// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。 + Future _check() async { + try { + final resp = await http + .get(Uri.parse('$kApiBaseUrl/version')) + .timeout(_kCheckTimeout); + if (resp.statusCode != 200) return null; + final data = jsonDecode(resp.body) as Map; + + 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? ?? const {}; + final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? '')); + + final pkg = await PackageInfo.fromPlatform(); + final hasUpdate = _isNewer(latestVersion, pkg.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 _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; + } +} + +final updateProvider = + AsyncNotifierProvider(UpdateNotifier.new); + +/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号 +/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式, +/// 供 shell 顶部 banner 的显隐判断。 +final dismissedUpdateVersionProvider = StateProvider((ref) => null); + +/// 按当前平台从服务端 download_urls 里取对应下载直链。 +String? platformDownloadUrl(Map 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']; +} diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index e137e59..35c3672 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -98,7 +98,7 @@ class PlansScreen extends ConsumerWidget { Widget body() => plansAsync.when( loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( - child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), data: (plans) => ListView( padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ @@ -140,13 +140,8 @@ class DevicesScreen extends ConsumerWidget { // 相对时间(最后登录);null → 从未登录。 String _rel(DateTime? ts) { - final zh = t.lang == AppLang.zh; if (ts == null) return t.devNeverLogin; - final d = DateTime.now().difference(ts.toLocal()); - if (d.inMinutes < 1) return zh ? '刚刚' : 'just now'; - if (d.inMinutes < 60) return zh ? '${d.inMinutes} 分钟前' : '${d.inMinutes} min ago'; - if (d.inHours < 24) return zh ? '${d.inHours} 小时前' : '${d.inHours}h ago'; - return zh ? '${d.inDays} 天前' : '${d.inDays}d ago'; + return t.lang.relativeTime(DateTime.now().difference(ts.toLocal())); } @override @@ -158,12 +153,12 @@ class DevicesScreen extends ConsumerWidget { Widget content() => devicesAsync.when( loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( - child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', style: PangolinText.body.copyWith(color: c.fg3)))), + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), data: (devices) => ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ Text(t.devicesSub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), const SizedBox(height: 12), if (devices.isEmpty) - Text(t.lang == AppLang.zh ? '暂无已登录设备' : 'No devices yet', + Text(t.lang.noDevices, style: PangolinText.sm.copyWith(color: c.fg3)) else Container( diff --git a/client/lib/widgets/ad_reward_dialog.dart b/client/lib/widgets/ad_reward_dialog.dart new file mode 100644 index 0000000..3581ac3 --- /dev/null +++ b/client/lib/widgets/ad_reward_dialog.dart @@ -0,0 +1,156 @@ +// ad_reward_dialog.dart — 免费额度耗尽后的「加时」入口(占位广告流程 + 桌面升级提示) +// +// 移动端:弹占位广告(「广告播放中…」→ 3s 假播放 → 调 /v1/ads/unlock 加时 → 显示奖励)。 +// 桌面端(Windows/macOS):免费版硬 10 分钟/天不可延,无广告——弹「去移动端看广告或升级」。 +// 接真广告 SDK(AdMob 激励视频)时,只需把 onWatch 换成「真播完再回调」即可,UI 不变。 +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/quota_provider.dart'; +import 'pangolin_icons.dart'; + +/// 展示加时流程。isDesktop=true 走升级提示(无广告),否则走占位广告加时。 +Future showQuotaAdFlow(BuildContext context, WidgetRef ref, {required bool isDesktop}) async { + final t = ref.read(appTextProvider); + if (isDesktop) { + await showDialog(context: context, builder: (_) => _DesktopUpgradeDialog(t: t)); + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _PlaceholderAdDialog( + t: t, + onWatch: () => ref.read(quotaProvider.notifier).watchAd(), + ), + ); +} + +/// 桌面版:免费硬 10 分钟不可延,提示去移动端加时或升级会员。 +class _DesktopUpgradeDialog extends StatelessWidget { + const _DesktopUpgradeDialog({required this.t}); + final AppText t; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return AlertDialog( + backgroundColor: c.surface, + title: Text(t.quotaDesktopTitle, + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + content: Text(t.quotaDesktopBody, style: PangolinText.sm.copyWith(color: c.fg2)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ); + } +} + +enum _AdPhase { playing, done, failed } + +/// 移动端占位激励广告:播放中 → 加时成功/失败。成功后短暂展示奖励再自动关闭。 +class _PlaceholderAdDialog extends StatefulWidget { + const _PlaceholderAdDialog({required this.t, required this.onWatch}); + final AppText t; + final Future Function() onWatch; + + @override + State<_PlaceholderAdDialog> createState() => _PlaceholderAdDialogState(); +} + +class _PlaceholderAdDialogState extends State<_PlaceholderAdDialog> { + _AdPhase _phase = _AdPhase.playing; + int _granted = 0; + Timer? _closeTimer; + + @override + void initState() { + super.initState(); + _run(); + } + + Future _run() async { + // 占位「播放」3s(接真 SDK 后由激励视频完成回调替代)。 + await Future.delayed(const Duration(seconds: 3)); + if (!mounted) return; + final granted = await widget.onWatch(); + if (!mounted) return; + setState(() { + if (granted != null && granted > 0) { + _phase = _AdPhase.done; + _granted = granted; + } else { + _phase = _AdPhase.failed; + } + }); + if (_phase == _AdPhase.done) { + _closeTimer = Timer(const Duration(milliseconds: 1300), () { + if (mounted) Navigator.of(context).pop(); + }); + } + } + + @override + void dispose() { + _closeTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + final t = widget.t; + + Widget body; + switch (_phase) { + case _AdPhase.playing: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator(color: c.accent, strokeWidth: 3), + ), + const SizedBox(height: 16), + Text(t.adPlaying, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ]); + case _AdPhase.done: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.checkCircle, size: 40, color: c.success), + const SizedBox(height: 14), + Text(t.adRewarded(_granted), + style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w700)), + ]); + case _AdPhase.failed: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.alertTriangle, size: 36, color: c.danger), + const SizedBox(height: 14), + Text(t.adFailed, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + const SizedBox(height: 14), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ]); + } + + return Dialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.lg)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 28), + child: AnimatedSize( + duration: PangolinMotion.base, + child: body, + ), + ), + ); + } +} diff --git a/client/lib/widgets/adaptive_menu.dart b/client/lib/widgets/adaptive_menu.dart index 678389d..1133419 100644 --- a/client/lib/widgets/adaptive_menu.dart +++ b/client/lib/widgets/adaptive_menu.dart @@ -167,7 +167,7 @@ class _MenuCard extends StatelessWidget { child: Material( color: c.surface, elevation: 6, - shadowColor: Colors.black26, + shadowColor: PangolinColors.sand950.withValues(alpha: 0.24), // 暖近黑阴影(§5;非冷黑 Colors.black26) clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(PangolinRadius.md), diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 2d7634e..e3b4baf 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -39,7 +39,7 @@ class _AuthScreenState extends ConsumerState bool _sent = false; bool _loading = false; bool _pwVisible = false; - String? _errorZh; + String? _error; final _email = TextEditingController(); final _code = TextEditingController(); @@ -90,17 +90,17 @@ class _AuthScreenState extends ConsumerState // ── 认证操作(逻辑不变)────────────────────────────────────────── Future _sendCode() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { await _api.sendCode(_email.text.trim()); if (mounted) setState(() { _sent = true; _loading = false; }); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } Future _doRegister() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.register( @@ -113,12 +113,12 @@ class _AuthScreenState extends ConsumerState await ref.read(authProvider.notifier).saveTokens(tokens); if (mounted) widget.onDone(); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } Future _doLogin() async { - setState(() { _loading = true; _errorZh = null; }); + setState(() { _loading = true; _error = null; }); try { final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.login( @@ -132,7 +132,7 @@ class _AuthScreenState extends ConsumerState ref.read(deviceLimitProvider.notifier).state = tokens.deviceLimit; if (mounted) widget.onDone(); } on AuthApiException catch (e) { - if (mounted) setState(() { _errorZh = e.messageZh; _loading = false; }); + if (mounted) setState(() { _error = widget.t.lang == AppLang.zh ? e.messageZh : e.messageEn; _loading = false; }); } } @@ -167,7 +167,7 @@ class _AuthScreenState extends ConsumerState _mode = m; _step = 0; _sent = false; - _errorZh = null; + _error = null; }); // ── UI ────────────────────────────────────────────────────── @@ -278,7 +278,7 @@ class _AuthScreenState extends ConsumerState child: Column(children: [ _segTabs(c, t), const SizedBox(height: 22), - if (_errorZh != null) ...[ + if (_error != null) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( @@ -289,7 +289,7 @@ class _AuthScreenState extends ConsumerState child: Row(children: [ Icon(PangolinIcons.x, size: 16, color: c.danger), const SizedBox(width: 8), - Expanded(child: Text(_errorZh!, style: PangolinText.sm.copyWith(color: c.danger))), + Expanded(child: Text(_error!, style: PangolinText.sm.copyWith(color: c.danger))), ]), ), const SizedBox(height: 14), @@ -457,7 +457,7 @@ class _AuthScreenState extends ConsumerState style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600))), GestureDetector( onTap: () => setState(() => _step = 0), - child: Text(t.lang == AppLang.zh ? '改邮箱' : 'Change', + child: Text(t.lang.changeEmail, style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600, fontSize: 13)), ), ]), diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index d2de8d6..20abc9d 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -24,11 +24,19 @@ class ConnectButton extends StatefulWidget { required this.secureLabel, this.elapsed = Duration.zero, this.size = 208, + this.enabled = true, + this.onDisabledTap, }); final VpnPhase phase; final VoidCallback onTap; + /// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。 + final bool enabled; + + /// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。 + final VoidCallback? onDisabledTap; + /// off 态圆内文字(如「点击连接」/「CONNECT」)。 final String offLabel; @@ -55,18 +63,26 @@ class _ConnectButtonState extends State with SingleTickerProvider Widget build(BuildContext context) { final c = context.pangolin; final s = widget.phase; + // 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。 + final disabled = !widget.enabled && s == VpnPhase.off; - final Color fill = switch (s) { - VpnPhase.off => c.bgSubtle, - VpnPhase.connecting => c.accent, - VpnPhase.on => c.success, - }; - final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white; - final IconData icon = switch (s) { - VpnPhase.off => PangolinIcons.power, - VpnPhase.connecting => PangolinIcons.loader, - VpnPhase.on => PangolinIcons.shieldCheck, - }; + final Color fill = disabled + ? c.bgSubtle + : switch (s) { + VpnPhase.off => c.bgSubtle, + VpnPhase.connecting => c.accent, + VpnPhase.on => c.success, + }; + final Color fg = disabled + ? c.fg3 + : (s == VpnPhase.off ? c.accent : PangolinColors.white); + final IconData icon = disabled + ? PangolinIcons.lock + : switch (s) { + VpnPhase.off => PangolinIcons.power, + VpnPhase.connecting => PangolinIcons.loader, + VpnPhase.on => PangolinIcons.shieldCheck, + }; // off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。 final List glow = s == VpnPhase.off @@ -84,7 +100,7 @@ class _ConnectButtonState extends State with SingleTickerProvider button: true, label: widget.offLabel, child: GestureDetector( - onTap: widget.onTap, + onTap: disabled ? widget.onDisabledTap : widget.onTap, child: AnimatedContainer( duration: PangolinMotion.slow, curve: PangolinMotion.easeOut, diff --git a/client/lib/widgets/quota_card.dart b/client/lib/widgets/quota_card.dart index 4e908f2..9449b3b 100644 --- a/client/lib/widgets/quota_card.dart +++ b/client/lib/widgets/quota_card.dart @@ -1,7 +1,10 @@ -// quota_card.dart — 免费版每日额度卡(纯展示) +// quota_card.dart — 免费版每日额度卡 // -// 今日剩余分钟 + 进度条(≤3 分钟切 warning 色)+「看广告开始使用」; -// 解锁后变绿「已解锁 · 今日可用」。状态由 quota_provider 注入,本地仅展示。 +// 三态展示(额度全账户共享): +// ① 连接中(countdown != null):显示剩余倒计时 mm:ss + 进度条随之收缩。 +// ② 未连接·有余额:显示今日剩余分钟 + 进度条;移动端附「看广告加时」。 +// ③ 未连接·已耗尽:显示「今日已用完」+ 移动端「看广告加时」/ 桌面「升级会员」。 +// 状态由 quota_provider(剩余)+ connection_provider(倒计时)注入,本地仅展示。 import 'package:flutter/material.dart'; import '../l10n/app_text.dart'; @@ -10,16 +13,55 @@ import '../state/quota_provider.dart'; import 'pangolin_icons.dart'; class QuotaCard extends StatelessWidget { - const QuotaCard({super.key, required this.quota, required this.t, required this.onWatchAd}); + const QuotaCard({ + super.key, + required this.quota, + required this.t, + required this.onWatchAd, + this.countdown, + this.isDesktop = false, + }); final FreeQuotaState quota; final AppText t; + + /// 额度耗尽时的加时入口(移动端占位广告 / 桌面升级提示)。 final VoidCallback onWatchAd; + /// 连接期剩余倒计时;null = 未连接(展示今日剩余分钟)。 + final Duration? countdown; + + /// 桌面端(Windows/macOS):免费硬 10 分钟不可延,不显示看广告按钮。 + final bool isDesktop; + + static String _mmss(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + @override Widget build(BuildContext context) { final c = context.pangolin; - final barColor = quota.isLow ? c.warning : c.accent; + final connected = countdown != null; + final exhausted = quota.isExhausted && !connected; + + // 进度与主值:连接期用倒计时,未连接用剩余分钟。 + final double progress; + final String label; + final String value; + if (connected) { + final totalSec = quota.totalMinutes * 60; + progress = totalSec == 0 ? 0 : (countdown!.inSeconds / totalSec).clamp(0.0, 1.0); + label = t.quotaLeftLabel; + value = _mmss(countdown!); + } else { + progress = quota.progress; + label = exhausted ? t.quotaUsedUp : t.quotaToday; + value = exhausted ? '' : '${quota.remainingMinutes} ${t.minutes}'; + } + final low = connected ? countdown!.inMinutes < 3 : quota.isLow; + final barColor = exhausted ? c.danger : (low ? c.warning : c.accent); return Container( padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), @@ -33,23 +75,32 @@ class QuotaCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(children: [ - Icon(PangolinIcons.clock, size: 15, color: c.accent), + Icon(PangolinIcons.clock, size: 15, color: exhausted ? c.danger : c.accent), const SizedBox(width: 7), - Text(t.quotaToday, + // 标签固定、绝不省略(此前用 Flexible 让「今日剩余」被挤成「今日…」)。 + Text(label, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)), - const SizedBox(width: 7), - Text('${quota.remainingMinutes} ${t.minutes}', - style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)), + if (value.isNotEmpty) ...[ + const SizedBox(width: 7), + // 数值可作为最后收缩项(极端窄屏/三位数分钟才会省略)。 + Flexible( + child: Text(value, + overflow: TextOverflow.ellipsis, + style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)), + ), + ], + const SizedBox(width: 8), const Spacer(), + // 短标签「免费版」——每日额度由卡片本身表达,不再挤进右上角。 Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), decoration: BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Text(t.quotaFree, + child: Text(t.planFreeTag, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w600, fontSize: 10.5)), ), ]), const SizedBox(height: 9), - // 进度条:剩余比例;≤3 分钟切 warning 色(满宽轨道 + 比例填充) + // 进度条:剩余比例;≤3 分钟 warning、耗尽 danger(满宽轨道 + 比例填充)。 ClipRRect( borderRadius: BorderRadius.circular(3), child: SizedBox( @@ -58,7 +109,7 @@ class QuotaCard extends StatelessWidget { Positioned.fill(child: ColoredBox(color: c.bgSubtle)), FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: quota.progress, + widthFactor: progress, child: AnimatedContainer( duration: PangolinMotion.base, curve: PangolinMotion.easeOut, @@ -68,30 +119,25 @@ class QuotaCard extends StatelessWidget { ]), ), ), - const SizedBox(height: 11), - if (quota.adUnlocked) - SizedBox( - height: 38, - child: Center( - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(PangolinIcons.checkCircle, size: 16, color: c.success), - const SizedBox(width: 7), - Text(t.adUnlocked, - style: PangolinText.sm.copyWith(color: c.success, fontWeight: FontWeight.w700, fontSize: 13)), - ]), - ), - ) - else - _WatchAdButton(t: t, onTap: onWatchAd), + // 加时按钮:未连接时显示。移动端「看广告加时」;桌面仅耗尽时给「升级会员」。 + if (!connected && (!isDesktop || exhausted)) ...[ + const SizedBox(height: 11), + _ActionButton( + label: isDesktop ? t.upgrade : t.watchAdMore, + icon: isDesktop ? PangolinIcons.zap : PangolinIcons.playCircle, + onTap: onWatchAd, + ), + ], ], ), ); } } -class _WatchAdButton extends StatelessWidget { - const _WatchAdButton({required this.t, required this.onTap}); - final AppText t; +class _ActionButton extends StatelessWidget { + const _ActionButton({required this.label, required this.icon, required this.onTap}); + final String label; + final IconData icon; final VoidCallback onTap; @override @@ -107,9 +153,9 @@ class _WatchAdButton extends StatelessWidget { height: 38, child: Center( child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(PangolinIcons.playCircle, size: 16, color: c.accent), + Icon(icon, size: 16, color: c.accent), const SizedBox(width: 7), - Text(t.watchAd, + Text(label, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 13)), ]), ), diff --git a/client/lib/widgets/server_tile.dart b/client/lib/widgets/server_tile.dart index e36a3cc..8d008aa 100644 --- a/client/lib/widgets/server_tile.dart +++ b/client/lib/widgets/server_tile.dart @@ -44,7 +44,7 @@ class ServerTile extends StatelessWidget { Widget build(BuildContext context) { final c = context.pangolin; final down = node.isDown; // 节点不可用(agent 离线):置灰 + 「不可用」+ 禁选。 - final unavail = lang == AppLang.zh ? '不可用' : 'Unavailable'; + final unavail = lang.unavailable; return InkWell( onTap: down ? null : onTap, child: Opacity( diff --git a/client/lib/widgets/update_dialog.dart b/client/lib/widgets/update_dialog.dart new file mode 100644 index 0000000..6e89ef4 --- /dev/null +++ b/client/lib/widgets/update_dialog.dart @@ -0,0 +1,139 @@ +// update_dialog.dart — 「发现新版本」更新提示弹窗。 +// +// 点「下载更新」走 App 内下载安装(core/update/app_updater.dart,带进度框,不再开 +// 浏览器);Android 装 APK、Windows 跑安装包、macOS 解压提示拖入、iOS 降级外链。 +// force_update=true 时不可关闭(无「稍后」按钮、点遮罩/返回也关不掉)。 +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/update/app_updater.dart'; +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/update_provider.dart'; +import 'pangolin_icons.dart'; + +/// 展示更新弹窗。调用方(设置页「检查更新」)在拿到 `info.hasUpdate == true` 时调用。 +Future showUpdateDialog(BuildContext context, AppText t, AppUpdateInfo info) { + return showDialog( + 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: () { + // 先关本弹窗,用 Navigator 自身 context(关后仍挂载)启动 App 内下载 + // (它自带进度框)。 + final nav = Navigator.of(context); + nav.pop(); + unawaited(startInAppUpdate(nav.context, t, info)); + }, + child: Text(t.updateDownload, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ); + } +} + +/// jiu 式顶部更新横条(非强制更新用;强制更新仍走不可关闭弹窗 showUpdateDialog)。 +/// 「立即更新」App 内下载安装;「×」忽略此版本(banner 隐藏,更新版本会重现)。 +class UpdateBanner extends ConsumerWidget { + const UpdateBanner({super.key, required this.info}); + final AppUpdateInfo info; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final t = ref.watch(appTextProvider); + return Material( + color: c.accentSubtle, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 8, 6, 8), + child: Row(children: [ + Icon(PangolinIcons.zap, size: 17, color: c.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + t.updateAvailableTitle(info.latestVersion), + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => unawaited(startInAppUpdate(context, t, info)), + style: TextButton.styleFrom( + backgroundColor: c.accent, + foregroundColor: c.fgOnAccent, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(PangolinRadius.full)), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(t.lang.updateNow, + style: PangolinText.caption + .copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)), + ), + IconButton( + onPressed: () => + ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion, + icon: Icon(Icons.close, size: 18, color: c.fg3), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + tooltip: t.updateLater, + ), + ]), + ), + ), + ); + } +} diff --git a/client/macos/Runner/AppDelegate.swift b/client/macos/Runner/AppDelegate.swift index da810b5..481b21d 100644 --- a/client/macos/Runner/AppDelegate.swift +++ b/client/macos/Runner/AppDelegate.swift @@ -12,4 +12,14 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + // 隐藏到托盘后点 Dock 图标 → 唤回主窗口。窗口被 window_manager.hide()(orderOut:) + // 隐藏时 hasVisibleWindows=false,系统只发 reopen 事件、不会自动显示;不接管就"点了没反应"。 + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + mainFlutterWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + return true + } } diff --git a/client/pubspec.yaml b/client/pubspec.yaml index bd21057..2bd6c5b 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -1,7 +1,7 @@ name: pangolin_vpn description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。 publish_to: "none" -version: 1.0.43+44 +version: 1.0.48+49 environment: sdk: ^3.5.0 @@ -20,6 +20,8 @@ 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 免登 / iOS 更新降级外链) + open_filex: ^4.5.0 # App 内更新:下载后拉起系统安装器(Android APK / 打开文件) device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报) uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4) launch_at_startup: ^0.5.1 diff --git a/client/test/contract/api_contract_test.dart b/client/test/contract/api_contract_test.dart index da6dbf1..c85921a 100644 --- a/client/test/contract/api_contract_test.dart +++ b/client/test/contract/api_contract_test.dart @@ -20,6 +20,7 @@ const _frozenMeKeys = { 'devices_used', 'devices_max', 'quota_today_min', + 'quota_cap_min', 'data_today_gb', 'weekly_gb', 'totp_enabled', @@ -37,6 +38,7 @@ Map _meSample() => { 'devices_used': 2, 'devices_max': 5, 'quota_today_min': null, // pro/team 不限 → null + 'quota_cap_min': null, // pro/team 不限 → null 'data_today_gb': 1.5, 'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5], 'totp_enabled': true, diff --git a/client/test/flutter_test_config.dart b/client/test/flutter_test_config.dart index 04070c7..4388616 100644 --- a/client/test/flutter_test_config.dart +++ b/client/test/flutter_test_config.dart @@ -20,6 +20,9 @@ Future testExecutable(FutureOr Function() testMain) async { 'test/fonts/Manrope-Bold.ttf', ]); await _load('JetBrains Mono', const ['test/fonts/JetBrainsMono-Regular.ttf']); + // Noto Sans SC 子集(= 生产打包同一 client/fonts/ 子集):golden 中文用真字体渲染, + // 不出豆腐块、与真机一致(family 须匹配 PangolinFonts.cjk = 'Noto Sans SC')。 + await _load('Noto Sans SC', const ['test/fonts/NotoSansSC-Regular-subset.otf']); // Lucide 图标字体(family 须带 package 前缀以匹配 PangolinIcons 的 fontPackage)。 await _load('packages/lucide_icons/Lucide', const ['packages/lucide_icons_patched/fonts/lucide.ttf']); await testMain(); diff --git a/client/test/fonts/NotoSansSC-Regular-subset.otf b/client/test/fonts/NotoSansSC-Regular-subset.otf new file mode 100644 index 0000000..a75251c Binary files /dev/null and b/client/test/fonts/NotoSansSC-Regular-subset.otf differ diff --git a/client/test/golden/components_golden_test.dart b/client/test/golden/components_golden_test.dart index d06598a..016663c 100644 --- a/client/test/golden/components_golden_test.dart +++ b/client/test/golden/components_golden_test.dart @@ -76,12 +76,12 @@ void main() { ); }); - testWidgets('额度卡(已解锁)· $suffix', (tester) async { + testWidgets('额度卡(已耗尽)· $suffix', (tester) async { await goldenOf( tester, - QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}), + QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}), find.byType(QuotaCard), - 'quota_unlocked_$suffix', + 'quota_exhausted_$suffix', dark: dark, ); }); diff --git a/client/test/golden/goldens/auth_login_dark.png b/client/test/golden/goldens/auth_login_dark.png index 7371672..6e6af25 100644 Binary files a/client/test/golden/goldens/auth_login_dark.png and b/client/test/golden/goldens/auth_login_dark.png differ diff --git a/client/test/golden/goldens/auth_login_light.png b/client/test/golden/goldens/auth_login_light.png index 484fe64..937e511 100644 Binary files a/client/test/golden/goldens/auth_login_light.png and b/client/test/golden/goldens/auth_login_light.png differ diff --git a/client/test/golden/goldens/connect_off_dark.png b/client/test/golden/goldens/connect_off_dark.png index 23877ad..cdb3043 100644 Binary files a/client/test/golden/goldens/connect_off_dark.png and b/client/test/golden/goldens/connect_off_dark.png differ diff --git a/client/test/golden/goldens/connect_off_light.png b/client/test/golden/goldens/connect_off_light.png index d2dba96..6190ca8 100644 Binary files a/client/test/golden/goldens/connect_off_light.png and b/client/test/golden/goldens/connect_off_light.png differ diff --git a/client/test/golden/goldens/connect_on_dark.png b/client/test/golden/goldens/connect_on_dark.png index 9047bd6..942fa71 100644 Binary files a/client/test/golden/goldens/connect_on_dark.png and b/client/test/golden/goldens/connect_on_dark.png differ diff --git a/client/test/golden/goldens/connect_on_light.png b/client/test/golden/goldens/connect_on_light.png index c806383..5035931 100644 Binary files a/client/test/golden/goldens/connect_on_light.png and b/client/test/golden/goldens/connect_on_light.png differ diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index 545616c..ba0eca3 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/desktop_contact.png b/client/test/golden/goldens/desktop_contact.png index c180eac..1d67bc9 100644 Binary files a/client/test/golden/goldens/desktop_contact.png and b/client/test/golden/goldens/desktop_contact.png differ diff --git a/client/test/golden/goldens/desktop_devices.png b/client/test/golden/goldens/desktop_devices.png index c3adbda..fa38eb0 100644 Binary files a/client/test/golden/goldens/desktop_devices.png and b/client/test/golden/goldens/desktop_devices.png differ diff --git a/client/test/golden/goldens/desktop_plans.png b/client/test/golden/goldens/desktop_plans.png index 26ad4db..4c0a4fa 100644 Binary files a/client/test/golden/goldens/desktop_plans.png and b/client/test/golden/goldens/desktop_plans.png differ diff --git a/client/test/golden/goldens/desktop_redeem.png b/client/test/golden/goldens/desktop_redeem.png index de35588..0f2fe8c 100644 Binary files a/client/test/golden/goldens/desktop_redeem.png and b/client/test/golden/goldens/desktop_redeem.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index a49f2b2..68addd3 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/desktop_settings.png b/client/test/golden/goldens/desktop_settings.png index 4016bd3..e3d5c7a 100644 Binary files a/client/test/golden/goldens/desktop_settings.png and b/client/test/golden/goldens/desktop_settings.png differ diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index aa9a11c..427ec83 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/golden/goldens/quota_exhausted_dark.png b/client/test/golden/goldens/quota_exhausted_dark.png new file mode 100644 index 0000000..6e082bb Binary files /dev/null and b/client/test/golden/goldens/quota_exhausted_dark.png differ diff --git a/client/test/golden/goldens/quota_exhausted_light.png b/client/test/golden/goldens/quota_exhausted_light.png new file mode 100644 index 0000000..d987372 Binary files /dev/null and b/client/test/golden/goldens/quota_exhausted_light.png differ diff --git a/client/test/golden/goldens/quota_low_dark.png b/client/test/golden/goldens/quota_low_dark.png index 2130fc2..0179d15 100644 Binary files a/client/test/golden/goldens/quota_low_dark.png and b/client/test/golden/goldens/quota_low_dark.png differ diff --git a/client/test/golden/goldens/quota_low_light.png b/client/test/golden/goldens/quota_low_light.png index 81df106..e5b58c7 100644 Binary files a/client/test/golden/goldens/quota_low_light.png and b/client/test/golden/goldens/quota_low_light.png differ diff --git a/client/test/golden/goldens/smart_card_dark.png b/client/test/golden/goldens/smart_card_dark.png index 3c7b425..a0ddccb 100644 Binary files a/client/test/golden/goldens/smart_card_dark.png and b/client/test/golden/goldens/smart_card_dark.png differ diff --git a/client/test/golden/goldens/smart_card_light.png b/client/test/golden/goldens/smart_card_light.png index 9a7b708..48649cb 100644 Binary files a/client/test/golden/goldens/smart_card_light.png and b/client/test/golden/goldens/smart_card_light.png differ diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png index a33f1ee..16d848e 100644 Binary files a/client/test/golden/goldens/tablet_account_dark_zh.png and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png index fc83634..d6bf07d 100644 Binary files a/client/test/golden/goldens/tablet_account_light_en.png and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png index 5b11aeb..c7feea9 100644 Binary files a/client/test/golden/goldens/tablet_account_light_zh.png and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_dark_zh.png b/client/test/golden/goldens/tablet_connect_dark_zh.png index 3414650..31d6cfa 100644 Binary files a/client/test/golden/goldens/tablet_connect_dark_zh.png and b/client/test/golden/goldens/tablet_connect_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_en.png b/client/test/golden/goldens/tablet_connect_light_en.png index 7a2e8d2..a5d56c1 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_en.png and b/client/test/golden/goldens/tablet_connect_light_en.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_zh.png b/client/test/golden/goldens/tablet_connect_light_zh.png index 8fb8595..9ba545c 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_zh.png and b/client/test/golden/goldens/tablet_connect_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png index 9526bb2..3570bec 100644 Binary files a/client/test/golden/goldens/tablet_servers_dark_zh.png and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png index f0a14c5..664c5d9 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_en.png and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png index 10ed931..3317bf1 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_zh.png and b/client/test/golden/goldens/tablet_servers_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png index 3ab3c76..9b2a882 100644 Binary files a/client/test/golden/goldens/tablet_stats_dark_zh.png and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png index ab7dcf0..0631b9f 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_en.png and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png index 8e71a30..e584957 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_zh.png and b/client/test/golden/goldens/tablet_stats_light_zh.png differ diff --git a/client/test/golden/tablet_pages_golden_test.dart b/client/test/golden/tablet_pages_golden_test.dart index 1856836..b99b620 100644 --- a/client/test/golden/tablet_pages_golden_test.dart +++ b/client/test/golden/tablet_pages_golden_test.dart @@ -91,6 +91,15 @@ class _FakeMeNotifier extends MeNotifier { Future build() async => _demoMe; } +// 固定语言的 locale notifier:localeProvider 是 StateNotifierProvider,override 须返回 LocaleNotifier(子类)。构造后直接钉死 state 为目标语言 +// (prefs 在测试态为空,父类 _load() 不会改写)。 +class _FixedLocale extends LocaleNotifier { + _FixedLocale(AppLang lang) { + state = lang; + } +} + const Size _ipad = Size(1180, 820); // 对齐设计源 tabapp.jsx 内屏 Future _shoot( @@ -112,7 +121,7 @@ Future _shoot( tokenStoreProvider.overrideWithValue(const _NullTokenStore()), vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), navViewProvider.overrideWith((ref) => view), - localeProvider.overrideWith((ref) => lang), + localeProvider.overrideWith((ref) => _FixedLocale(lang)), nodesProvider.overrideWith(_FakeNodesNotifier.new), meProvider.overrideWith(_FakeMeNotifier.new), usageProvider((days: 30, device: null)).overrideWith((ref) async => _demoUsage), diff --git a/client/test/unit/api_config_test.dart b/client/test/unit/api_config_test.dart new file mode 100644 index 0000000..4886f35 --- /dev/null +++ b/client/test/unit/api_config_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/services/api_config.dart'; + +void main() { + test('控制面基址默认走 https(禁止回退明文 http)', () { + expect(kApiBaseUrl, startsWith('https://'), + reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http'); + expect(kApiBaseUrl, isNot(contains('103.119.13.48')), + reason: '不得再硬编码节点 IP 作控制面基址'); + }); +} diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index 7485082..a162c7c 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -8,11 +8,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/services/connect_api.dart'; +import 'package:pangolin_vpn/services/device_identity.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; +import 'package:pangolin_vpn/state/quota_provider.dart'; + +// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。 +class _FixedLocale extends LocaleNotifier { + _FixedLocale(AppLang lang) { + state = lang; + } +} // 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。 class _FakeBridge implements VpnBridge { @@ -54,6 +65,16 @@ class _FakeConnectApi extends ConnectApi { void dispose() {} } +// 内存 SecureKV:避免测试里 flutter_secure_storage 平台通道无 handler 时 deviceId() 挂起 +// (进而 _connect 卡在 connecting)。#18 自动重连会走 _connect,必须让 deviceId() 立即返回。 +class _MemKV implements SecureKV { + final _m = {}; + @override + Future read(String key) async => _m[key]; + @override + Future write(String key, String value) async => _m[key] = value; +} + // 两节点都可用(status=up)。 class _StubNodes extends NodesNotifier { @override @@ -82,8 +103,10 @@ void main() { ProviderContainer makeContainer(_FakeBridge bridge, {NodesNotifier Function()? nodes, DateTime Function()? now}) => ProviderContainer(overrides: [ + localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)), vpnBridgeProvider.overrideWithValue(bridge), nodesProvider.overrideWith(nodes ?? _StubNodes.new), + deviceIdentityProvider.overrideWithValue(DeviceIdentity(store: _MemKV())), connectApiFactoryProvider.overrideWithValue((_) => _FakeConnectApi()), connectionProvider.overrideWith( (ref) => ConnectionController(ref, ref.watch(vpnBridgeProvider), now: now)), @@ -100,19 +123,42 @@ void main() { await tester.pump(); } - testWidgets('路径A:意外内核掉线(非用户主动)→ 置「节点异常」提示', (tester) async { + // #18:意外内核掉线(弱网最常见)先自动重连当前节点,不换节点、不立即报「节点异常」; + // 连续失败超过 _kMaxAutoReconnect(3) 次才真报「节点异常」。 + testWidgets('#18 意外内核掉线(弱网)→ 先自动重连当前节点,超上限才报「节点异常」', (tester) async { final bridge = _FakeBridge(); - final c = makeContainer(bridge); // 节点都 up,排除路径B + final c = makeContainer(bridge); // 节点都 up(排除路径B);默认智能 addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); - await driveOn(tester, c, bridge); - expect(c.read(connectionProvider).phase, VpnPhase.on); - bridge.emit(VpnStatus.off); // 模拟节点数据面死 → REALITY 断 → 内核自报 off + c.read(nodesProvider); + c.read(connectionProvider); // 实例化控制器,使其订阅 bridge.statusStream(否则首个 emit 丢失) await tester.pump(); - final st = c.read(connectionProvider); - expect(st.phase, VpnPhase.off, reason: '内核掉线应回 off'); - expect(st.error, t.nodeUnhealthyError, reason: '非用户主动掉线应给「节点异常」提示'); + final initialSel = c.read(selectedNodeCodeProvider); + const maxRetries = 3; // = _kMaxAutoReconnect + + Future dropOnce() async { + bridge.emit(VpnStatus.on); + await tester.pump(); + await tester.pump(); + bridge.emit(VpnStatus.off); // 弱网:数据面瞬断 → 内核自报 off + // 泵到重连尝试(_connect fake 会失败回 off)settle,避免下一轮 emit(on) 与本轮 async 交错。 + for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) { + await tester.pump(const Duration(milliseconds: 10)); + } + } + + for (var i = 0; i < maxRetries; i++) { + await dropOnce(); + expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError), + reason: '第${i + 1}次弱网掉线应自动重连,不应报「节点异常」'); + expect(c.read(selectedNodeCodeProvider), initialSel, reason: '弱网不应换节点'); + } + // 额度用尽 → 报「节点异常」。 + await dropOnce(); + expect(c.read(connectionProvider).phase, VpnPhase.off); + expect(c.read(connectionProvider).error, t.nodeUnhealthyError, + reason: '自动重连用尽后应给「节点异常」'); }); testWidgets('路径B:服务端判当前节点 down(智能)→ 切到其他可用节点', (tester) async { @@ -126,17 +172,21 @@ void main() { expect(c.read(selectedNodeCodeProvider), 'JP', reason: '智能模式应自动切到其他可用节点'); }); - testWidgets('路径B:服务端判当前节点 down(手动)→ 断开 + 提示,不自动换', (tester) async { + // 路径B(服务端判当前节点 down)手动模式无备用:也走自动重连当前节点(不换、不立即报「节点异常」)。 + // 重试用尽 → 报「节点异常」的额度逻辑由上面的 kernel-off 用例覆盖(同一 _tryAutoReconnectCurrent)。 + testWidgets('#18 路径B 手动节点被判 down(无备用)→ 自动重连当前节点,不换、不立即报「节点异常」', (tester) async { final bridge = _FakeBridge(); final c = makeContainer(bridge, nodes: _StubNodesHKDown.new); addTearDown(bridge.dispose); addTearDown(c.dispose); await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); - c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动选定 HK(已 down) - await driveOn(tester, c, bridge); - final st = c.read(connectionProvider); - expect(st.phase, VpnPhase.off, reason: '手动模式节点 down 应断开'); - expect(st.error, t.nodeUnhealthyError, reason: '应给节点异常提示'); + c.read(selectedNodeCodeProvider.notifier).select('HK'); // 手动 HK(已 down),无备用 + await driveOn(tester, c, bridge); // on → 看门狗 _checkHealth 判 HK down → _onNodeUnhealthy + for (var k = 0; k < 15 && c.read(connectionProvider).phase != VpnPhase.off; k++) { + await tester.pump(const Duration(milliseconds: 10)); + } + expect(c.read(connectionProvider).error, isNot(t.nodeUnhealthyError), + reason: '首次判死应自动重连当前节点,不立即报「节点异常」'); expect(c.read(selectedNodeCodeProvider), 'HK', reason: '手动模式不应自动换节点'); }); @@ -279,4 +329,36 @@ void main() { bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器 await tester.pump(); }); + + // 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。 + testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async { + final bridge = _FakeBridge(); + var fake = DateTime(2026, 7, 1, 12); + final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟 + addTearDown(bridge.dispose); + addTearDown(c.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); + c.read(nodesProvider); + await tester.pump(); + + // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。 + expect(c.read(quotaProvider).remainingMinutes, 10); + c.read(connectionProvider.notifier).toggle(); + await tester.pump(); + bridge.emit(VpnStatus.on); // _startElapsed:_connectedAt = fake(T0) + await tester.pump(); + await tester.pump(); + expect(c.read(connectionProvider).phase, VpnPhase.on); + expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时'); + + // 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。 + fake = fake.add(const Duration(seconds: 601)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + await tester.pump(); + + expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '额度用完应自动切断'); + expect(c.read(connectionProvider).error, t.quotaExhaustedNotice, reason: '应给「已用完」提示'); + expect(c.read(quotaProvider).isExhausted, true, reason: '切断后本地置耗尽 → 按钮灰化'); + }); } diff --git a/client/test/unit/quota_controller_test.dart b/client/test/unit/quota_controller_test.dart index e0da4b7..b83aa39 100644 --- a/client/test/unit/quota_controller_test.dart +++ b/client/test/unit/quota_controller_test.dart @@ -41,24 +41,29 @@ void main() { expect(const FreeQuotaState(remainingMinutes: 3).isLow, true); expect(const FreeQuotaState(remainingMinutes: 4).isLow, false); }); + test('isExhausted:剩余 ≤0 为真', () { + expect(const FreeQuotaState(remainingMinutes: 0).isExhausted, true); + expect(const FreeQuotaState(remainingMinutes: 1).isExhausted, false); + }); }); - // 未登录默认态:总额 10 / 剩余 10 / 未解锁;watchAd 本地置位。 + // 未登录默认态:总额 10 / 剩余 10 / 未耗尽。 group('quotaProvider', () { - test('默认 10/10 未解锁', () { + test('默认 10/10 未耗尽', () { final c = _container(); addTearDown(c.dispose); final q = c.read(quotaProvider); expect(q.totalMinutes, 10); expect(q.remainingMinutes, 10); - expect(q.adUnlocked, false); + expect(q.isExhausted, false); }); - test('watchAd 解锁今日使用', () { + test('markExhausted 本地立即置剩余 0', () { final c = _container(); addTearDown(c.dispose); - c.read(quotaProvider.notifier).watchAd(); - expect(c.read(quotaProvider).adUnlocked, true); + c.read(quotaProvider.notifier).markExhausted(); + expect(c.read(quotaProvider).remainingMinutes, 0); + expect(c.read(quotaProvider).isExhausted, true); }); }); } diff --git a/client/test/widget/cards_test.dart b/client/test/widget/cards_test.dart index bb8d0fc..59c0fea 100644 --- a/client/test/widget/cards_test.dart +++ b/client/test/widget/cards_test.dart @@ -12,24 +12,32 @@ void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); - testWidgets('额度卡:未解锁显示看广告按钮,点击回调', (tester) async { + testWidgets('额度卡(移动·有余额):显示看广告加时按钮,点击回调', (tester) async { var watched = 0; await tester.pumpWidget(wrapThemed( QuotaCard(quota: const FreeQuotaState(), t: t, onWatchAd: () => watched++), )); await tester.pump(); - expect(find.text(t.watchAd), findsOneWidget); - await tester.tap(find.text(t.watchAd)); + expect(find.text(t.watchAdMore), findsOneWidget); + await tester.tap(find.text(t.watchAdMore)); expect(watched, 1); }); - testWidgets('额度卡:已解锁显示已解锁文案', (tester) async { + testWidgets('额度卡(耗尽):显示今日已用完 + 看广告加时', (tester) async { await tester.pumpWidget(wrapThemed( - QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}), + QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}), )); await tester.pump(); - expect(find.text(t.adUnlocked), findsOneWidget); - expect(find.byIcon(PangolinIcons.checkCircle), findsOneWidget); + expect(find.text(t.quotaUsedUp), findsOneWidget); + expect(find.text(t.watchAdMore), findsOneWidget); + }); + + testWidgets('额度卡(桌面·有余额):不显示加时按钮', (tester) async { + await tester.pumpWidget(wrapThemed( + QuotaCard(quota: const FreeQuotaState(), t: t, isDesktop: true, onWatchAd: () {}), + )); + await tester.pump(); + expect(find.text(t.watchAdMore), findsNothing); }); testWidgets('推荐卡:展示推荐胶囊与文案,选中显示对勾', (tester) async { diff --git a/client/test/widget/node_connect_confirm_test.dart b/client/test/widget/node_connect_confirm_test.dart index e1e1395..7746fb1 100644 --- a/client/test/widget/node_connect_confirm_test.dart +++ b/client/test/widget/node_connect_confirm_test.dart @@ -8,10 +8,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/nodes_page.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/widgets/smart_select_card.dart'; @@ -31,11 +33,19 @@ const _nodes = [ Node(code: 'JP', nameZh: '东京', nameEn: 'Tokyo', ping: 35, uuid: 'jp-uuid', host: 'jp', port: 443), ]; +// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。 +class _FixedLocale extends LocaleNotifier { + _FixedLocale(AppLang lang) { + state = lang; + } +} + void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); ProviderContainer makeContainer(VpnBridge bridge) => ProviderContainer(overrides: [ + localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)), nodesProvider.overrideWith(_StubNodes.new), vpnBridgeProvider.overrideWithValue(bridge), ]); diff --git a/client/test/widget/stats_device_filter_test.dart b/client/test/widget/stats_device_filter_test.dart index 9d3e2d6..2c15056 100644 --- a/client/test/widget/stats_device_filter_test.dart +++ b/client/test/widget/stats_device_filter_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; import 'package:pangolin_vpn/screens/stats_page.dart'; import 'package:pangolin_vpn/services/api_client.dart'; @@ -38,7 +37,6 @@ class _LoggedIn implements TokenStore { void main() { setUpAll(disableGoogleFontsFetching); - const t = StringsZh(); testWidgets('选设备 → /v1/usage 带 device=', (tester) async { await tester.binding.setSurfaceSize(const Size(900, 1200)); diff --git a/client/test/widget/stats_page_test.dart b/client/test/widget/stats_page_test.dart index ac95eb1..865bc95 100644 --- a/client/test/widget/stats_page_test.dart +++ b/client/test/widget/stats_page_test.dart @@ -13,6 +13,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; import 'package:pangolin_vpn/l10n/strings_zh.dart'; import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/pangolin_theme.dart'; @@ -20,6 +21,7 @@ import 'package:pangolin_vpn/screens/stats_page.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/token_store.dart'; import 'package:pangolin_vpn/state/account_providers.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; import 'package:pangolin_vpn/state/auth_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; import 'package:pangolin_vpn/widgets/period_card.dart'; @@ -101,6 +103,13 @@ Finder _metric(String period, int i, String value, String unit) => find.byWidget w.metrics[i].unit == unit, ); +// localeProvider 现默认英文;测试硬编码 StringsZh 预期 → 钉死中文 locale。 +class _FixedLocale extends LocaleNotifier { + _FixedLocale(AppLang lang) { + state = lang; + } +} + void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); @@ -111,6 +120,7 @@ void main() { await tester.pumpWidget(ProviderScope( overrides: [ + localeProvider.overrideWith((ref) => _FixedLocale(AppLang.zh)), tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()), apiClientProvider.overrideWithValue(ApiClient( baseUrl: 'http://test.local', diff --git a/client/tool/check_ds_code.mjs b/client/tool/check_ds_code.mjs new file mode 100644 index 0000000..62bd916 --- /dev/null +++ b/client/tool/check_ds_code.mjs @@ -0,0 +1,92 @@ +// client/tool/check_ds_code.mjs +// Flutter 代码端「设计真相源」闸 —— 与原型 check-ds.mjs 对应,把颜色单源纪律延伸到 +// Flutter 业务代码。红线:颜色只走语义 token(PangolinScheme via context / PangolinColors +// / PangolinShadow 等,均从 design/prototype/tokens.css codegen 单源),禁硬编码 hex +// (Color(0x..))与具名 Material 色(Colors.red 等)。合理特例须显式 `// ds-ignore: 理由`。 +// +// 用法(在 client/ 下): +// node tool/check_ds_code.mjs # 全量扫描,仅报告(不 fail) +// node tool/check_ds_code.mjs --strict # 全量,有违规即 exit 1(CI) +// node tool/check_ds_code.mjs --changed # 仅扫 git 改动的 dart,有违规即 exit 1(pre-commit) +// +// 豁免:行加 `// ds-ignore: <理由>`(理由必填,便于审计)。 +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // client/ +const LIB = path.join(ROOT, 'lib'); +const args = new Set(process.argv.slice(2)); +const CHANGED = args.has('--changed'); +const STRICT = args.has('--strict') || CHANGED; + +// 违规模式:硬编码 hex 颜色;具名 Material 色(Colors.x,transparent 除外=无色合理)。 +// 注:PangolinColors.x 不会命中(\bColors 前有词字符 n,无词边界)。 +const HEX = /Color\(0x[0-9A-Fa-f]{6,8}\)/; +const NAMED = /\bColors\.(?!transparent\b)[a-zA-Z]\w*/; +const IGNORE = /\/\/\s*ds-ignore/; + +// 文件级豁免:token 生成层 + 主题实现层——它们**定义** token 的原始色值(合法用 +// Color(0x..)),是颜色单源的落地位,不该被自己的闸拦。 +const EXEMPT_FILES = [ + 'lib/pangolin_tokens.gen.dart', // codegen 产物(色阶原始 hex 定义位) + 'lib/pangolin_theme.dart', // 实现层(PangolinScheme 语义色映射) +]; + +function listDartFiles() { + if (CHANGED) { + let out = ''; + try { + out = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACM', 'HEAD'], { + cwd: ROOT, + encoding: 'utf8', + }); + } catch { + out = ''; + } + return out + .split('\n') + .map((s) => s.trim()) + .filter((f) => f.endsWith('.dart') && !f.endsWith('.gen.dart') && !f.endsWith('.g.dart')) + .map((f) => path.resolve(ROOT, '..', f)) // git 路径相对仓库根 + .filter((f) => f.startsWith(LIB) && fs.existsSync(f)); + } + const out = []; + (function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith('.dart') && !e.name.endsWith('.gen.dart') && !e.name.endsWith('.g.dart')) out.push(p); + } + })(LIB); + return out; +} + +const isExempt = (f) => EXEMPT_FILES.some((e) => f.replaceAll(path.sep, '/').endsWith(e)); + +const violations = []; +for (const file of listDartFiles()) { + if (isExempt(file)) continue; + const lines = fs.readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + if (IGNORE.test(line)) return; // 显式豁免 + // 只扫代码部分:剥掉行尾 `//` 注释(注释里提及 Colors.x 作说明不算违规)。 + const code = line.split('//')[0]; + const m = HEX.exec(code) || NAMED.exec(code); + if (m) { + violations.push({ file: path.relative(ROOT, file), line: i + 1, hit: m[0], text: line.trim().slice(0, 90) }); + } + }); +} + +const banner = (s) => `\n${'─'.repeat(60)}\n${s}\n${'─'.repeat(60)}`; +if (violations.length) { + console.log(banner(`✗ Flutter 硬编码颜色 ${violations.length} 处(应走 token,或显式 // ds-ignore: 理由)`)); + for (const v of violations) console.log(` ${v.file}:${v.line} [${v.hit}] ${v.text}`); +} else { + console.log(banner('✓ 通过:Flutter 颜色全部走 token / 已显式豁免')); +} + +if (STRICT && violations.length) process.exit(1); +console.log(`\n范围:${CHANGED ? 'git 改动文件' : '全量 lib/'} 违规:${violations.length}${STRICT ? '(strict)' : '(仅报告)'}`); diff --git a/client/windows/installer/pangolin.iss b/client/windows/installer/pangolin.iss index 064cd1a..bfb65ce 100644 --- a/client/windows/installer/pangolin.iss +++ b/client/windows/installer/pangolin.iss @@ -3,7 +3,7 @@ ; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在 ; ..\..\build\windows\x64\runner\Release #define MyAppName "穿山甲 Pangolin" -#define MyAppVersion "1.0.43" +#define MyAppVersion "1.0.47" #define MyAppPublisher "Pangolin" #define MyAppExeName "pangolin_vpn.exe" #define BuildDir "..\..\build\windows\x64\runner\Release" diff --git a/deploy/single-node/deploy.sh b/deploy/single-node/deploy.sh index c4efc45..e698eff 100755 --- a/deploy/single-node/deploy.sh +++ b/deploy/single-node/deploy.sh @@ -164,7 +164,7 @@ cat > "$ETC/server.env" <> "$ETC/server.env" < 静态服务、官网下载按钮直链。这里先建目录占位(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 @@ -257,6 +277,24 @@ sed "s#/usr/local/bin/sing-box#${SB_BIN}#g" \ chmod 644 /etc/systemd/system/sing-box.service install -d -m 755 /etc/polkit-1/rules.d install -m 644 "$HERE/polkit/49-pangolin-singbox.rules" /etc/polkit-1/rules.d/ + +# ── cloudflared(控制面 API 出站隧道,Cloudflare apt 源)───────────────────────── +if ! command -v cloudflared >/dev/null 2>&1; then + log "安装 cloudflared(Cloudflare apt 源)..." + install -m 0755 -d /usr/share/keyrings + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + -o /usr/share/keyrings/cloudflare-main.gpg + echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \ + > /etc/apt/sources.list.d/cloudflared.list + apt-get update -qq + apt-get install -y -qq cloudflared +fi +# apt 固定装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。 +CFD_BIN=/usr/bin/cloudflared +[ -x "$CFD_BIN" ] || die "cloudflared 安装失败:$CFD_BIN 不存在。" +ln -sf "$CFD_BIN" /usr/local/bin/cloudflared +install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/ + systemctl daemon-reload log "启动控制面(pangolin-server,以 $PUSER 运行)..." @@ -269,17 +307,22 @@ log "启用 sing-box + 启动 agent ..." systemctl enable sing-box.service systemctl enable --now pangolin-agent.service -# 放行控制面 API 端口(若 ufw 启用)。⚠️ 明文,生产应前置 TLS。 -if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then - ufw allow "${HTTP_PORT}/tcp" >/dev/null 2>&1 || true - log "ufw 放行 ${HTTP_PORT}/tcp(控制面 API,明文)" +# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。 + +# ── cloudflared:按需启用(需 /etc/pangolin/cloudflared.env 提供 TUNNEL_TOKEN)─── +if [ -f /etc/pangolin/cloudflared.env ]; then + log "启用 cloudflared(检测到 /etc/pangolin/cloudflared.env)..." + systemctl enable --now cloudflared.service +else + log "cloudflared 已安装但未启动:等 /etc/pangolin/cloudflared.env(TUNNEL_TOKEN)就绪后执行:" + log " systemctl enable --now cloudflared.service" fi # ── 11. 摘要 ────────────────────────────────────────────────────────────────── log "完成。单机栈已起(SQLite + pangolin 用户)。" cat < 本文件是项目持久指令,任何会话自动注入。目标:让任何 agent(含 Claude Code)都能**完全还原**这套设计,不走样。生产前端是 **Flutter**;React/HTML 是视觉规范参考。 +> ⚠️ **真源对照(2026-07 ds-flow 治理后,以此为准;下文历史章节的旧路径逐步迁移中)** +> | 关注点 | 现在的真源 | 已废弃/降级 | +> |---|---|---| +> | 设计令牌 | `design/prototype/tokens.css`(ds-flow 原型单源) | `colors_and_type.css` 现为薄 `@import` 别名,勿在此加变量 | +> | 组件原子 | `design/prototype/atoms.css` + 登记页 `design/prototype/index.html` | — | +> | 图标 | `design/prototype/icons.js`(Lucide sprite 单源) | — | +> | Flutter 组件实现 | `client/lib/widgets/`(canonical)+ `client/lib/pangolin_theme.dart`(实现层) | `design/flutter/` **已删除**;本文下文 `flutter/…` 路径作废 | +> | 官网/用户中心实现 | `web/website/` · `web/usercenter/`(各自 canonical) | — | +> | 整屏视觉参考 | `design/ui_kits/`(**DEPRECATED · 仅历史整屏布局参考**,勿当真源;整屏 HTML 化后退役) | 旧 DS 工具 `_ds_manifest.json`/`_ds_bundle.js` 为历史派生物 | +> 详见根 `CLAUDE.md`「## 前端设计系统治理(ds-flow)」+ `docs/frontend-ds-refactor-plan.html`。 + --- ## 0. 一句话定位 @@ -105,12 +116,16 @@ SKILL.md ← Agent Skill 入口 --- -## 6. 修改设计系统时(本项目是 DS 工程) -- 改令牌 → 编辑 `colors_and_type.css` **同时**同步 `flutter/pangolin_theme.dart`(两者必须一致)。 -- 改 specimen 卡 → `preview/*.html`,首行带 ``。 -- 改组件视觉 → 同步改 React(`ui_kits/`)与 Flutter(`flutter/widgets/`)两处。 -- 改完跑 `check_design_system` 确认无报错(动效 token 标 `@kind other`)。 -- logo 改动 → 同步 5 个 SVG(`assets/`)+ `flutter/assets/` 副本 + React 内联 `Mark`/`DMark` + Flutter `pangolin_logo.dart`。 +## 6. 修改设计系统时(ds-flow;以此为准,旧写法已废) +- **改令牌** → 只改 `design/prototype/tokens.css`(原型单源),然后跑 codegen: + `node design/codegen/gen_flutter_tokens.mjs`(生成 `client/lib/pangolin_tokens.gen.dart`,勿手改)+ + `cd web/{website,usercenter} && npm run gen:tokens`。**勿改 `colors_and_type.css`**(它已是 `@import` 别名)。 +- **加/改组件原子** → 先在 `design/prototype/atoms.css` 定义 + `design/prototype/index.html` 登记页加展示卡(L1 无例外先原型),再落 canonical 实现:Flutter `client/lib/widgets/`、官网 `web/website/`、用户中心 `web/usercenter/`。 +- **加/改图标** → 先进 `design/prototype/icons.js` sprite,三端(Flutter/website/usercenter)只从此集取。 +- 评审原型:`node design/prototype/serve.mjs` → `http://localhost:5180/`(给 URL,不截图)。 +- **禁止**再向 `design/` 提交 Dart/TS 组件代码副本(会漂移;`ui_kits/` 为历史 DEPRECATED 参考,勿新增)。 +- logo 改动 → 同步 SVG(`design/assets/` + `client/…` 副本)+ Flutter `pangolin_logo.dart` + Web 内联 `Mark`。 +- 闸:改完跑 `bash ci/check-codegen-drift.sh`(codegen 零 diff)+ `flutter analyze`/`flutter test`(含 golden)。 --- diff --git a/design/CONTRACT.md b/design/CONTRACT.md index 7e61452..35cbc38 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -90,3 +90,33 @@ - 连接键 off 态:原型用实心 11px 环,Flutter 用「虚线轨道环」(design/CLAUDE.md §5 canonical 连接键),以 §5 为准。 - 账户页:生产实现比原型多设备管理/兑换/联系/协议行,属真实功能扩展,沿用同设计语言。 - golden 测试环境未打包 Noto Sans SC → CJK 显示为方块,仅影响 golden 文字、不影响真机;布局可判。节点网格/周柱在未登录测试态无数据(需注入演示数据方显内容)。 + +--- + +## 6. ds-flow · Web 原子清单 + 屏级三态台账(2026-07 治理) + +> 决策:Web 两端(website / usercenter)框架不同(Astro-island vs Next 静态),**各自实现 + 同源闸**——不建跨端共享组件包,两端对齐同一 `design/prototype/atoms.css` 语义,靠 `tools/check-l1-sync.mjs` 强制 token 值 + 图标同源不漂移。 + +### 6.1 公用原子清单(canonical = `design/prototype/atoms.css`) +| 原子 | 原型 class | website 实现 | usercenter 实现 | +|---|---|---|---| +| 按钮 | `.btn`/`-primary`/`-ghost`/`-subtle`/`-danger` | `website.css .btn*`(class) | `shared.tsx`(CSSProperties) | +| 卡片 | `.card` | `.card`/`.plan` | `shared.tsx card` | +| 输入 | `.input`/`.field` | 表单 class | `shared.tsx input` | +| 徽章/药丸 | `.pill`/`.badge`(状态/accent/outline) | `.tag`/`.plan` 徽章 | 内联 pill | +| 语言下拉 | `.langsel`/`.menu` | `Header.jsx` 自控下拉 | `shared.tsx LangSeg` | +> 两端语言下拉已统一为「自控菜单」(非原生 select,防 macOS 弹层漂移),行为一致、均对齐 `.langsel`/`.menu` 语义。 + +### 6.2 图标:三端 ⊆ 原型 sprite +`design/prototype/icons.js`(58 Lucide)是图标单源。usercenter `LUCIDE`(键+路径)、Flutter `pangolin_icons._byName`(键)由 `check-l1-sync ③` 强制 ⊆ 原型;website 经 lucide-react 构建期内联。 + +### 6.3 屏级三态台账(L2) +| 面 | 态 | 基准 | 说明 | +|---|---|---|---| +| Flutter mobile/tablet/desktop | 快照 | golden ×light/dark | 原型退役,golden + 本契约文字为准 | +| website(官网各屏) | 代码先行 | 无原型屏 | canonical 在 `web/website/`,token/图标同源闸守护 | +| usercenter(各视图) | 代码先行 | 无原型屏 | canonical 在 `web/usercenter/`,同上 | +| 组件原子层 | 同步 | `prototype/index.html` 登记页 | L1 无例外先原型;`check-ds`(Phase 5)强制登记 | + +### 6.4 硬编码色白名单(`check-l1-sync ④`) +`#fff/#ffffff/#000/#000000` + 品牌 logo 固定色 `#B96A3D/#FAF3ED/#F4EFE8/#9E5630/#3D2213`;其余一律 `var(--token)` 或行内 `ds-allow` 豁免。website 页脚恒暗区灰阶已就近吸附 `--sand-300/400/500`。 diff --git a/design/codegen/gen_flutter_tokens.mjs b/design/codegen/gen_flutter_tokens.mjs index f37c3f1..7570159 100644 --- a/design/codegen/gen_flutter_tokens.mjs +++ b/design/codegen/gen_flutter_tokens.mjs @@ -2,7 +2,7 @@ /** * gen_flutter_tokens.mjs — CSS token → Dart codegen * - * 唯一真相源:design/colors_and_type.css + * 唯一真相源:design/prototype/tokens.css * 输出: client/lib/pangolin_tokens.gen.dart * * 生成内容(纯数据层,无 Flutter 实现逻辑): @@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SRC = resolve(__dirname, '../colors_and_type.css'); +const SRC = resolve(__dirname, '../prototype/tokens.css'); const OUT = resolve(__dirname, '../../client/lib/pangolin_tokens.gen.dart'); if (!existsSync(SRC)) { @@ -125,7 +125,7 @@ const lines = []; lines.push(`// pangolin_tokens.gen.dart`); lines.push(`// AUTO-GENERATED — 勿手改。`); -lines.push(`// 源: design/colors_and_type.css`); +lines.push(`// 源: design/prototype/tokens.css`); lines.push(`// 生成器: design/codegen/gen_flutter_tokens.mjs`); lines.push(`//`); lines.push(`// 包含:PangolinColors · PangolinSpacing · PangolinRadius · PangolinMotion · PangolinShadow`); @@ -136,7 +136,7 @@ lines.push(``); // ── PangolinColors ────────────────────────────────────────────────── lines.push(`/// ── Primitive color ramps ───────────────────────────────────────────`); -lines.push(`/// Auto-generated from design/colors_and_type.css :root color ramps.`); +lines.push(`/// Auto-generated from design/prototype/tokens.css :root color ramps.`); lines.push(`class PangolinColors {`); lines.push(` PangolinColors._();`); lines.push(``); diff --git a/design/colors_and_type.css b/design/colors_and_type.css index 3b1b48b..a82751b 100644 --- a/design/colors_and_type.css +++ b/design/colors_and_type.css @@ -1,218 +1,8 @@ /* ============================================================= - 穿山甲 VPN · Pangolin VPN — Design Tokens - colors_and_type.css - Single source of truth: color ramps, semantic colors (light + dark), - typography, spacing, radii, shadows, motion. + 穿山甲 VPN · Pangolin VPN — Design Tokens (兼容别名) + ⚠️ 真源已迁至 design/prototype/tokens.css(ds-flow 原型单源)。 + 本文件仅为浏览器内 @import 转发,供仍 此路径的历史 preview/*.html 使用。 + codegen(Flutter gen_flutter_tokens.mjs / Web build-tokens.mjs)已改读 prototype/tokens.css。 + 改 token 只改 design/prototype/tokens.css,勿在此加变量(不会被 codegen 解析)。 ============================================================= */ - -/* ---- Webfonts (open-source; documented as the brand's chosen faces) ---- - Sora — display / headings (geometric, friendly) - Manrope — body / UI (humanist geometric) - Noto Sans SC — Chinese (CJK companion) - JetBrains Mono — data readouts (IP, speed, keys) */ -@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap'); - -:root { - /* =========================================================== - 1. PRIMITIVE COLOR RAMPS - =========================================================== */ - - /* Clay / Copper — the pangolin-armor primary (warm earth) */ - --clay-50: #FAF3ED; - --clay-100: #F2E2D4; - --clay-200: #E6C7AC; - --clay-300: #D9A982; - --clay-400: #CC8B5C; - --clay-500: #B96A3D; /* ← brand primary */ - --clay-600: #9E5630; - --clay-700: #7E4426; - --clay-800: #5E331D; - --clay-900: #3D2213; - - /* Sand / Taupe — warm neutral ramp */ - --sand-50: #FAF8F4; - --sand-100: #F2EEE7; - --sand-200: #E6DFD3; - --sand-300: #D2C8B8; - --sand-400: #B0A491; - --sand-500: #8C8270; - --sand-600: #6B6253; - --sand-700: #4E4940; - --sand-800: #2E2A24; - --sand-900: #1F1C18; - --sand-950: #14110E; /* warm espresso near-black */ - - /* Semantic hues (earth-tuned) */ - --green-400: #7FB07A; - --green-500: #5B8C5A; /* connected / secure */ - --green-600: #467046; - --amber-400: #E2B05A; - --amber-500: #D69A3C; /* connecting / warning */ - --amber-600: #B47E29; - --red-400: #D4715A; - --red-500: #C0533B; /* error / disconnect */ - --red-600: #9E4230; - - /* =========================================================== - 2. SEMANTIC TOKENS — LIGHT THEME (default) - =========================================================== */ - - /* Backgrounds & surfaces */ - --bg: var(--sand-50); /* app canvas */ - --bg-subtle: var(--sand-100); /* striped / inset regions */ - --surface: #FFFFFF; /* cards, sheets */ - --surface-2: var(--sand-50); /* nested surface */ - --overlay: rgba(31, 28, 24, 0.45); - - /* Foreground / text */ - --fg1: var(--sand-900); /* primary text */ - --fg2: var(--sand-600); /* secondary text */ - --fg3: var(--sand-500); /* tertiary / captions */ - --fg-on-accent: #FFFFFF; /* text on clay fills */ - - /* Brand / accent */ - --accent: var(--clay-500); - --accent-hover: var(--clay-600); - --accent-press: var(--clay-700); - --accent-subtle: var(--clay-50); - --accent-border: var(--clay-200); - - /* Borders & lines */ - --border: var(--sand-200); - --border-strong: var(--sand-300); - --ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */ - - /* Status */ - --success: var(--green-500); - --success-subtle: #E9F0E6; - --warning: var(--amber-500); - --warning-subtle: #F8EED6; - --danger: var(--red-500); - --danger-subtle: #F6E1DA; - - /* =========================================================== - 3. TYPOGRAPHY - =========================================================== */ - --font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif; - --font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif; - --font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace; - - /* Type scale (root 16px) */ - --text-display-xl: 3rem; /* 48 */ - --text-display: 2.25rem; /* 36 */ - --text-h1: 1.875rem; /* 30 */ - --text-h2: 1.5rem; /* 24 */ - --text-h3: 1.25rem; /* 20 */ - --text-body-lg: 1.125rem; /* 18 */ - --text-body: 1rem; /* 16 */ - --text-sm: 0.875rem; /* 14 */ - --text-caption: 0.75rem; /* 12 */ - - --leading-tight: 1.15; - --leading-snug: 1.3; - --leading-normal:1.5; - --leading-relaxed:1.65; - - --tracking-tight: -0.02em; - --tracking-snug: -0.01em; - --tracking-wide: 0.04em; - --tracking-caps: 0.08em; - - /* =========================================================== - 4. SPACING (4px base) - =========================================================== */ - --space-0: 0; - --space-1: 0.25rem; /* 4 */ - --space-2: 0.5rem; /* 8 */ - --space-3: 0.75rem; /* 12 */ - --space-4: 1rem; /* 16 */ - --space-5: 1.25rem; /* 20 */ - --space-6: 1.5rem; /* 24 */ - --space-8: 2rem; /* 32 */ - --space-10: 2.5rem; /* 40 */ - --space-12: 3rem; /* 48 */ - --space-16: 4rem; /* 64 */ - - /* =========================================================== - 5. RADII (generous = friendly) - =========================================================== */ - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; - --radius-xl: 20px; - --radius-2xl: 28px; - --radius-full: 999px; - - /* =========================================================== - 6. SHADOWS (warm-tinted, soft) - =========================================================== */ - --shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06); - --shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08); - --shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12); - --shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16); - --shadow-focus: 0 0 0 4px var(--ring); - - /* =========================================================== - 7. MOTION - =========================================================== */ - --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */ - --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */ - --dur-fast: 140ms; /* @kind other */ - --dur-base: 220ms; /* @kind other */ - --dur-slow: 360ms; /* @kind other */ -} - -/* =========================================================== - DARK THEME — warm espresso - Apply via [data-theme="dark"] on or any container. - =========================================================== */ -[data-theme="dark"] { - --bg: var(--sand-950); - --bg-subtle: var(--sand-900); - --surface: #221E19; - --surface-2: #2A251F; - --overlay: rgba(0, 0, 0, 0.6); - - --fg1: #F4EFE8; - --fg2: #B6AC9C; - --fg3: #897F6F; - --fg-on-accent: #1F1C18; - - --accent: var(--clay-400); - --accent-hover: var(--clay-300); - --accent-press: var(--clay-500); - --accent-subtle: rgba(204, 139, 92, 0.14); - --accent-border: rgba(204, 139, 92, 0.30); - - --border: rgba(242, 238, 231, 0.10); - --border-strong: rgba(242, 238, 231, 0.18); - --ring: rgba(204, 139, 92, 0.45); - - --success: var(--green-400); - --success-subtle: rgba(127, 176, 122, 0.16); - --warning: var(--amber-400); - --warning-subtle: rgba(226, 176, 90, 0.16); - --danger: var(--red-400); - --danger-subtle: rgba(212, 113, 90, 0.16); - - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45); - --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55); -} - -/* =========================================================== - SEMANTIC TYPE CLASSES (use directly in markup) - =========================================================== */ -.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); } -.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); } -.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); } -.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); } -.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); } -.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); } -.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); } -.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); } -.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); } -.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); } -.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; } +@import url('./prototype/tokens.css'); diff --git a/design/prototype/atoms.css b/design/prototype/atoms.css new file mode 100644 index 0000000..cbc0307 --- /dev/null +++ b/design/prototype/atoms.css @@ -0,0 +1,272 @@ +/* ============================================================= + 穿山甲 VPN · Pangolin VPN — Prototype Atoms + design/prototype/atoms.css + ------------------------------------------------------------- + 本文件是「原型公用组件原子层」的唯一真相源(single source)。 + canonical CSS 原子类,沉淀自 design/preview/*.html 组件规格、 + web/usercenter/components/shared.tsx、web/website/src/styles/website.css + 与 client/lib/widgets/*.dart 的交互态语义。 + + 铁律(写/改本文件必守,对齐 design/CLAUDE.md §1): + 1. 颜色 / 圆角 / 间距 / 字号 / 阴影 / 字体 一律走 var(--token) + (token 定义在 tokens.css)。禁任何硬编码 hex / rgb / 魔法数。 + 2. 明暗双主题「自动」适配:原子类只引语义 token(--surface / --fg1 …), + 主题切换由 tokens.css 的 [data-theme="dark"] 负责—— + 本文件里不写任何 [data-theme] 分支。 + 3. 暖大地色 · 大圆角(按钮全胶囊 radius-full / 卡片 lg–xl / 输入 md)· + 柔和暖阴影 · 状态用「色点 + 文字胶囊」非 emoji。 + 4. 交互态语义:hover 主色加深一档 · press 缩放 .97 · focus clay 光环 + (shadow-focus)· disabled sand-200 底 + sand-400 字。 + 5. 每新增一个原子,须在 design/prototype/index.html 登记(登记簿单源)。 + + 使用前先在页面 引入:tokens.css(令牌)+ 本文件(原子)。 + ============================================================= */ + + +/* ============================================================= + BUTTONS · .btn + variants + 全胶囊圆角 · hover 加深 · press 收缩 .97 · focus clay 光环 + 变体:.btn-primary(实心 clay) · .btn-ghost(描边/幽灵) · + .btn-subtle(次要描边) · .btn-danger · 尺寸 .btn-lg · .btn-icon + ============================================================= */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-family: var(--font-sans); + font-weight: 600; + font-size: var(--text-sm); + line-height: 1; + border: none; + border-radius: var(--radius-full); + padding: var(--space-3) var(--space-5); + cursor: pointer; + white-space: nowrap; + transition: background-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + transform var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out); +} +.btn > svg { width: 16px; height: 16px; flex-shrink: 0; } +.btn:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} +.btn:active { transform: scale(0.97); } +.btn:disabled, +.btn.is-disabled { + background: var(--sand-200); + color: var(--sand-400); + cursor: not-allowed; + transform: none; + box-shadow: none; + border-color: transparent; +} + +/* Primary — 实心黏土铜 */ +.btn-primary { background: var(--accent); color: var(--fg-on-accent); } +.btn-primary:hover { background: var(--accent-hover); } +.btn-primary:active { background: var(--accent-press); } + +/* Ghost — 透明底、clay 字,hover 上 accent-subtle */ +.btn-ghost { background: transparent; color: var(--accent); } +.btn-ghost:hover { background: var(--accent-subtle); } + +/* Subtle — surface 底 + 强描边(次要动作) */ +.btn-subtle { + background: var(--surface); + color: var(--fg1); + border: 1.5px solid var(--border-strong); +} +.btn-subtle:hover { border-color: var(--accent); color: var(--accent); } + +/* Danger — 危险动作(断开 / 移除设备) */ +.btn-danger { background: var(--danger-subtle); color: var(--red-600); } +.btn-danger:hover { background: var(--danger); color: var(--fg-on-accent); } + +/* 尺寸 / 形态修饰 */ +.btn-lg { padding: var(--space-4) var(--space-6); font-size: var(--text-body); } +.btn-icon { padding: var(--space-3); } /* 正方形图标按钮(配 radius-full 成圆) */ +.btn-block { width: 100%; } + + +/* ============================================================= + CARD · .card + surface 底 + border + radius-xl + shadow-sm + ============================================================= */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + padding: var(--space-5); +} +.card-flush { padding: 0; } /* 列表容器(server-row 组)用,内容自带 padding */ + + +/* ============================================================= + INPUT · .input (+ .field / .flabel) + 强描边 + radius-md + focus clay 光环 + ============================================================= */ +.field { display: block; } +.flabel { + display: block; + font-family: var(--font-sans); + font-size: var(--text-caption); + font-weight: 600; + color: var(--fg2); + margin-bottom: var(--space-2); +} +.input { + width: 100%; + box-sizing: border-box; + font-family: var(--font-sans); + font-size: var(--text-sm); + color: var(--fg1); + background: var(--surface); + border: 1.5px solid var(--border-strong); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + transition: border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-fast) var(--ease-out); +} +.input::placeholder { color: var(--fg3); } +.input:focus, +.input.is-focused { + outline: none; + border-color: var(--accent); + box-shadow: var(--shadow-focus); +} +.input:disabled { + background: var(--bg-subtle); + color: var(--fg3); + cursor: not-allowed; +} +.input.is-error { border-color: var(--danger); } +.input.is-error:focus { box-shadow: 0 0 0 4px var(--danger-subtle); } + + +/* ============================================================= + BADGE / PILL · .pill (+ status modifiers) + 状态胶囊:色点 + 文字(非 emoji)。全胶囊圆角。 + 状态:.is-success / .is-warning / .is-danger / .is-neutral + 变体:.pill-accent(实心 clay,如 PRO) · .pill-outline(accent 描边,如节点属性) + ============================================================= */ +.pill { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-sans); + font-size: var(--text-caption); + font-weight: 600; + line-height: 1; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-full); + background: var(--sand-100); + color: var(--fg2); +} +.pill .dot { + width: 7px; + height: 7px; + border-radius: var(--radius-full); + background: var(--sand-400); + flex-shrink: 0; +} +/* alias:.badge === .pill(语义等价,命名习惯不同) */ +.badge { } + +.pill.is-success { background: var(--success-subtle); color: var(--green-600); } +.pill.is-success .dot { background: var(--success); } +.pill.is-warning { background: var(--warning-subtle); color: var(--amber-600); } +.pill.is-warning .dot { background: var(--warning); } +.pill.is-danger { background: var(--danger-subtle); color: var(--red-600); } +.pill.is-danger .dot { background: var(--danger); } +.pill.is-neutral { background: var(--sand-100); color: var(--fg2); } +.pill.is-neutral .dot { background: var(--sand-400); } + +/* 实心 accent(PRO 会员等强调标签) */ +.pill-accent { background: var(--accent); color: var(--fg-on-accent); } +/* accent 描边(节点属性:流媒体 / P2P 等) */ +.pill-outline { + background: var(--accent-subtle); + color: var(--accent); + border: 1px solid var(--accent-border); +} + + +/* ============================================================= + LANGUAGE SELECT · .langsel (触发药丸) + .menu (绝对定位菜单) + 对齐 usercenter shared.tsx::LangSeg / website 的自定义下拉: + 不依赖原生 + + + + + + +
+

胶囊 · .pill / .badge

+

状态用「色点 + 文字胶囊」,非 emoji。状态修饰 .is-success/-warning/-danger/-neutral,变体 .pill-accent / .pill-outline.badge.pill 语义别名。

+

状态修饰

.pill.is-*(含 .dot)
+ 已连接 + 连接中 + 已断开 + 未连接 + 默认 +
+

变体 / 别名

.pill-accent · .pill-outline · .badge
+ PRO 会员 + 流媒体 + P2P + badge 别名 +
+
+ +
+

语言下拉 · .langsel

+

不依赖原生 <select>:触发药丸 .langsel-trigger + 自控绝对定位菜单 .menu / .menu-item(选中态 .is-selected)。点触发按钮可实际展开/收起。

+
+
+ + +
+ ↖ 默认展开演示,可点击触发按钮收起 / 展开 +
+
+ + +
+

Lucide 图标库

+

细线条 · 2px stroke · 圆角端点。sprite 单源 icons.js,用法 <svg class="ic"><use href="#i-<name>"/></svg>

+
+
+ + + + + + + + diff --git a/design/prototype/serve.mjs b/design/prototype/serve.mjs new file mode 100644 index 0000000..811eaf4 --- /dev/null +++ b/design/prototype/serve.mjs @@ -0,0 +1,97 @@ +// 零依赖本地热重载静态服务器 — pangolin 原型单源预览 +// 用法: node design/prototype/serve.mjs [port] 默认 5180 +// 评审给 URL,不截图(ds-flow L3)。 +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.argv[2]) || 5180; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.woff2': 'font/woff2', + '.otf': 'font/otf', + '.ttf': 'font/ttf', +}; + +const clients = new Set(); +const RELOAD_SNIPPET = `\n\n`; + +const server = http.createServer((req, res) => { + if (req.url === '/__reload') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write('retry: 500\n\n'); + clients.add(res); + req.on('close', () => clients.delete(res)); + return; + } + + let urlPath = decodeURIComponent(req.url.split('?')[0]); + if (urlPath === '/') urlPath = '/index.html'; + const filePath = path.join(ROOT, urlPath); + if (!filePath.startsWith(ROOT)) { res.writeHead(403); res.end('forbidden'); return; } + + fs.readFile(filePath, (err, data) => { + if (err) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); res.end('

404

'); return; } + const ext = path.extname(filePath).toLowerCase(); + const mime = MIME[ext] || 'application/octet-stream'; + const noCache = { 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }; + if (ext === '.html') { + const html = data.toString('utf8').replace('', RELOAD_SNIPPET + ''); + res.writeHead(200, { 'Content-Type': mime, ...noCache }); + res.end(html); + } else { + res.writeHead(200, { 'Content-Type': mime, ...noCache }); + res.end(data); + } + }); +}); + +// 设计系统守门:启动 + 每次改动自动跑 check-ds.mjs(Phase 5 建立后生效)。 +const CHECK_DS = path.join(ROOT, 'tools', 'check-ds.mjs'); +function runDsCheck() { + if (!fs.existsSync(CHECK_DS)) return; // Phase 5 前无闸,静默跳过 + const p = spawn(process.execPath, [CHECK_DS], { cwd: ROOT }); + let out = ''; + p.stdout.on('data', d => out += d); + p.stderr.on('data', d => out += d); + p.on('close', code => { + const t = new Date().toLocaleTimeString(); + if (code === 0) console.log(`\x1b[32m[设计系统 ✓ ${t}] 颜色全部走 token、变量均已定义\x1b[0m`); + else console.log(`\x1b[31m[设计系统 ✗ ${t}] 存在违规 —— 运行 node tools/check-ds.mjs 看详情\x1b[0m`); + }); +} + +let debounce; +fs.watch(ROOT, { recursive: true }, (_ev, file) => { + if (file && file.endsWith('serve.mjs')) return; + clearTimeout(debounce); + debounce = setTimeout(() => { + for (const c of clients) c.write('data: reload\n\n'); + if (file && /\.(html|css|js)$/.test(file)) runDsCheck(); + }, 80); +}); + +server.listen(PORT, () => { + console.log(`prototype live at http://localhost:${PORT}/ (watching ${ROOT})`); + runDsCheck(); +}); diff --git a/design/prototype/tokens.css b/design/prototype/tokens.css new file mode 100644 index 0000000..3b1b48b --- /dev/null +++ b/design/prototype/tokens.css @@ -0,0 +1,218 @@ +/* ============================================================= + 穿山甲 VPN · Pangolin VPN — Design Tokens + colors_and_type.css + Single source of truth: color ramps, semantic colors (light + dark), + typography, spacing, radii, shadows, motion. + ============================================================= */ + +/* ---- Webfonts (open-source; documented as the brand's chosen faces) ---- + Sora — display / headings (geometric, friendly) + Manrope — body / UI (humanist geometric) + Noto Sans SC — Chinese (CJK companion) + JetBrains Mono — data readouts (IP, speed, keys) */ +@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap'); + +:root { + /* =========================================================== + 1. PRIMITIVE COLOR RAMPS + =========================================================== */ + + /* Clay / Copper — the pangolin-armor primary (warm earth) */ + --clay-50: #FAF3ED; + --clay-100: #F2E2D4; + --clay-200: #E6C7AC; + --clay-300: #D9A982; + --clay-400: #CC8B5C; + --clay-500: #B96A3D; /* ← brand primary */ + --clay-600: #9E5630; + --clay-700: #7E4426; + --clay-800: #5E331D; + --clay-900: #3D2213; + + /* Sand / Taupe — warm neutral ramp */ + --sand-50: #FAF8F4; + --sand-100: #F2EEE7; + --sand-200: #E6DFD3; + --sand-300: #D2C8B8; + --sand-400: #B0A491; + --sand-500: #8C8270; + --sand-600: #6B6253; + --sand-700: #4E4940; + --sand-800: #2E2A24; + --sand-900: #1F1C18; + --sand-950: #14110E; /* warm espresso near-black */ + + /* Semantic hues (earth-tuned) */ + --green-400: #7FB07A; + --green-500: #5B8C5A; /* connected / secure */ + --green-600: #467046; + --amber-400: #E2B05A; + --amber-500: #D69A3C; /* connecting / warning */ + --amber-600: #B47E29; + --red-400: #D4715A; + --red-500: #C0533B; /* error / disconnect */ + --red-600: #9E4230; + + /* =========================================================== + 2. SEMANTIC TOKENS — LIGHT THEME (default) + =========================================================== */ + + /* Backgrounds & surfaces */ + --bg: var(--sand-50); /* app canvas */ + --bg-subtle: var(--sand-100); /* striped / inset regions */ + --surface: #FFFFFF; /* cards, sheets */ + --surface-2: var(--sand-50); /* nested surface */ + --overlay: rgba(31, 28, 24, 0.45); + + /* Foreground / text */ + --fg1: var(--sand-900); /* primary text */ + --fg2: var(--sand-600); /* secondary text */ + --fg3: var(--sand-500); /* tertiary / captions */ + --fg-on-accent: #FFFFFF; /* text on clay fills */ + + /* Brand / accent */ + --accent: var(--clay-500); + --accent-hover: var(--clay-600); + --accent-press: var(--clay-700); + --accent-subtle: var(--clay-50); + --accent-border: var(--clay-200); + + /* Borders & lines */ + --border: var(--sand-200); + --border-strong: var(--sand-300); + --ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */ + + /* Status */ + --success: var(--green-500); + --success-subtle: #E9F0E6; + --warning: var(--amber-500); + --warning-subtle: #F8EED6; + --danger: var(--red-500); + --danger-subtle: #F6E1DA; + + /* =========================================================== + 3. TYPOGRAPHY + =========================================================== */ + --font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif; + --font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif; + --font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace; + + /* Type scale (root 16px) */ + --text-display-xl: 3rem; /* 48 */ + --text-display: 2.25rem; /* 36 */ + --text-h1: 1.875rem; /* 30 */ + --text-h2: 1.5rem; /* 24 */ + --text-h3: 1.25rem; /* 20 */ + --text-body-lg: 1.125rem; /* 18 */ + --text-body: 1rem; /* 16 */ + --text-sm: 0.875rem; /* 14 */ + --text-caption: 0.75rem; /* 12 */ + + --leading-tight: 1.15; + --leading-snug: 1.3; + --leading-normal:1.5; + --leading-relaxed:1.65; + + --tracking-tight: -0.02em; + --tracking-snug: -0.01em; + --tracking-wide: 0.04em; + --tracking-caps: 0.08em; + + /* =========================================================== + 4. SPACING (4px base) + =========================================================== */ + --space-0: 0; + --space-1: 0.25rem; /* 4 */ + --space-2: 0.5rem; /* 8 */ + --space-3: 0.75rem; /* 12 */ + --space-4: 1rem; /* 16 */ + --space-5: 1.25rem; /* 20 */ + --space-6: 1.5rem; /* 24 */ + --space-8: 2rem; /* 32 */ + --space-10: 2.5rem; /* 40 */ + --space-12: 3rem; /* 48 */ + --space-16: 4rem; /* 64 */ + + /* =========================================================== + 5. RADII (generous = friendly) + =========================================================== */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-2xl: 28px; + --radius-full: 999px; + + /* =========================================================== + 6. SHADOWS (warm-tinted, soft) + =========================================================== */ + --shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06); + --shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08); + --shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12); + --shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16); + --shadow-focus: 0 0 0 4px var(--ring); + + /* =========================================================== + 7. MOTION + =========================================================== */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */ + --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */ + --dur-fast: 140ms; /* @kind other */ + --dur-base: 220ms; /* @kind other */ + --dur-slow: 360ms; /* @kind other */ +} + +/* =========================================================== + DARK THEME — warm espresso + Apply via [data-theme="dark"] on or any container. + =========================================================== */ +[data-theme="dark"] { + --bg: var(--sand-950); + --bg-subtle: var(--sand-900); + --surface: #221E19; + --surface-2: #2A251F; + --overlay: rgba(0, 0, 0, 0.6); + + --fg1: #F4EFE8; + --fg2: #B6AC9C; + --fg3: #897F6F; + --fg-on-accent: #1F1C18; + + --accent: var(--clay-400); + --accent-hover: var(--clay-300); + --accent-press: var(--clay-500); + --accent-subtle: rgba(204, 139, 92, 0.14); + --accent-border: rgba(204, 139, 92, 0.30); + + --border: rgba(242, 238, 231, 0.10); + --border-strong: rgba(242, 238, 231, 0.18); + --ring: rgba(204, 139, 92, 0.45); + + --success: var(--green-400); + --success-subtle: rgba(127, 176, 122, 0.16); + --warning: var(--amber-400); + --warning-subtle: rgba(226, 176, 90, 0.16); + --danger: var(--red-400); + --danger-subtle: rgba(212, 113, 90, 0.16); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55); +} + +/* =========================================================== + SEMANTIC TYPE CLASSES (use directly in markup) + =========================================================== */ +.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); } +.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); } +.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); } +.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); } +.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); } +.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); } +.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); } +.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); } +.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); } +.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); } +.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; } diff --git a/design/prototype/tools/check-ds.mjs b/design/prototype/tools/check-ds.mjs new file mode 100644 index 0000000..fbf5d7a --- /dev/null +++ b/design/prototype/tools/check-ds.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// design/prototype/tools/check-ds.mjs — 原型单源守门检查(纯 Node 零依赖)。 +// 扫 atoms.css + index.html,强制颜色/字体/圆角走 tokens.css 的 var(--token); +// 图标走 icons.js sprite;每个原子类在 index.html 登记。违规 exit 1。 +// node design/prototype/tools/check-ds.mjs +// 行内 `ds-allow` 豁免;SVG 内 fill/stroke 豁免。 +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // design/prototype/ +const read = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8'); + +// token 定义(tokens.css 的 :root + 主题块)+ atoms 里定义的少量派生 +const definedVars = new Set(); +for (const f of ['tokens.css', 'atoms.css']) for (const m of read(f).matchAll(/--([a-z0-9-]+)\s*:/gi)) definedVars.add('--' + m[1]); +// atoms 原子类 +const atomClasses = new Set(); +for (const m of read('atoms.css').matchAll(/\.([a-zA-Z][\w-]*)/g)) atomClasses.add(m[1]); +// icons.js sprite id 集(ICONS 键 → i-) +const iconIds = new Set(); +for (const m of read('icons.js').matchAll(/'([a-z0-9-]+)'\s*:\s*' —— 避免注释里 +// 的示例(如 header 注释里的 var(--token))被误判。 +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(//g, (m) => m.replace(/[^\n]/g, ' ')); +} + +// ── 值扫描。strictColor=true 时严格查硬编码色(仅 atoms.css:原子必须走 token); +// index.html 是文档登记页,其 chrome 装饰不算原子,只查未定义 token / font-family ── +function valueScan(label, rawSrc, strictColor) { + const src = stripComments(rawSrc); + const lines = src.split('\n'); + let svgDepth = 0; + lines.forEach((line, i) => { + const depthBefore = svgDepth; + if (!line.includes('ds-allow')) { + if (strictColor) { + for (const m of line.matchAll(COLOR_RE)) { + const c = m[0]; + if (COLOR_ALLOW.has(c.toLowerCase())) continue; + const before = line.slice(0, m.index); + if (depthBefore > 0 || /(?:fill|stroke)\s*=\s*["']?$/.test(before)) continue; // SVG fill/stroke 豁免 + colorViol.push(`${label}:${i + 1} ${c}`); + } + } + for (const m of line.matchAll(VAR_RE)) if (!definedVars.has(m[1])) varViol.push(`${label}:${i + 1} var(${m[1]}) 未定义`); + for (const m of line.matchAll(/font-family:\s*([^;}\n]+)/g)) if (!/var\(--font/.test(m[1]) && !/inherit|monospace|sans-serif|system-ui/.test(m[1])) ffViol.push(`${label}:${i + 1} font-family 未走 var(--font*)`); + } + svgDepth += (line.match(//g) || []).length; + if (svgDepth < 0) svgDepth = 0; + }); +} +valueScan('atoms.css', read('atoms.css'), true); +valueScan('index.html', indexSrc, false); + +// ── 图标必须走 sprite:index.html 的 id 须在 icons.js 登记 ── +for (const m of indexSrc.matchAll(/]*href="#(i-[\w-]+)"/g)) { + if (!iconIds.has(m[1])) iconViol.push(`index.html 未在 icons.js 登记`); +} + +// ── 组件原子登记:atoms.css 每个 class 须在 index.html 出现(活文档完整性)── +// 跳过明显的修饰/状态子类(-primary/-ghost 等变体随基类展示即可),只查基类原子。 +const BASE_ATOM = /^(btn|card|input|field|flabel|pill|badge|langsel|menu)/; +for (const c of atomClasses) { + if (!BASE_ATOM.test(c)) continue; + const re = new RegExp(`class="[^"]*\\b${c}\\b|\\.${c}[\\s{:.,]`); + if (!re.test(indexSrc)) regViol.push(`.${c} ← 未在 index.html 登记展示`); +} + +const banner = (s) => `\n${'='.repeat(60)}\n${s}\n${'='.repeat(60)}`; +const sec = (n, arr) => { console.log(banner(`${n} 共 ${arr.length}`)); console.log(arr.length ? arr.join('\n') : '(无)✓'); }; +console.log(banner('原型单源守门检查')); +console.log(`token: ${definedVars.size} · 原子类: ${atomClasses.size} · 图标: ${iconIds.size}`); +sec('❶ 硬编码颜色', colorViol); +sec('❷ 未定义 token', varViol); +sec('❸ font-family 未走 var(--font*)', ffViol); +sec('❹ 图标未走 sprite', iconViol); +sec('❺ 组件原子未在 index.html 登记', regViol); + +const fail = [colorViol, varViol, ffViol, iconViol, regViol].reduce((s, a) => s + a.length, 0); +console.log(banner(fail ? `✗ 未通过:${fail} 处违规` : '✓ 通过:原型颜色/字体/图标走单一来源,组件已登记')); +process.exit(fail ? 1 : 0); diff --git a/design/ui_kits/DEPRECATED.md b/design/ui_kits/DEPRECATED.md new file mode 100644 index 0000000..acfb833 --- /dev/null +++ b/design/ui_kits/DEPRECATED.md @@ -0,0 +1,20 @@ +# ⚠️ DEPRECATED — 历史整屏视觉参考,勿当真源 + +本目录(`design/ui_kits/`)是早期的 React/HTML 端原型(mobile/tablet/desktop/website/usercenter +六端整屏)。**已废弃为「仅历史整屏布局参考」,不再是设计真源**(2026-07 ds-flow 治理后)。 + +## 现在的真源在哪 +- 设计令牌 → `design/prototype/tokens.css` +- 组件原子 → `design/prototype/atoms.css` + 登记页 `design/prototype/index.html` +- 图标 → `design/prototype/icons.js` +- 生产实现(canonical)→ Flutter `client/lib/widgets/`、官网 `web/website/`、用户中心 `web/usercenter/` + +## 为什么暂留而不删 +原子层已被 prototype/ 捕获,但**整屏布局尚无 HTML 替代**(重建整屏属 L3 新屏工作,不在本轮治理范围)。 +待整屏 HTML 化迁进 `design/prototype/screens/` 后,本目录退役删除。 + +## 铁律 +- **勿在此新增/修改** jsx/css 组件副本(会与 canonical 漂移;根 CLAUDE.md「禁向 design/ 提组件代码副本」)。 +- 需要改组件视觉,去改真源(prototype/ + canonical 实现),不要动这里。 + +详见根 `CLAUDE.md`「## 前端设计系统治理(ds-flow)」+ `docs/frontend-ds-refactor-plan.html`。 diff --git a/docs/cicd-design.html b/docs/cicd-design.html new file mode 100644 index 0000000..54a2247 --- /dev/null +++ b/docs/cicd-design.html @@ -0,0 +1,173 @@ + + + + + +Pangolin CI/CD 全流程 · 设计方案(#30) + + + +
+← 文档索引 +

Pangolin CI/CD 全流程 · 设计方案

+

#30 · 2026-07-05 · 设计定稿待审 · 范围 A~F(排除 iOS、备份#26、TLS#25)· 真相源 docs/superpowers/specs/2026-07-05-cicd-design.md

+ +
+目标:tag 触发的 编译 → 测试 → 发版(Gitea release)→ 部署 全自动。参考 jiu 的 +.gitea/workflows + scripts/ci/*.sh 结构,适配 pangolin 的部署目标(pangolin1 / +pangolin.yanmeiai.com)与多端产物。现状:仅 ci.yml 校验无部署,服务端手动部署、官网未部署、下载死链。 +
+ +

1. 范围

+ + + + + + + + +
子块内容
A 基座scripts/ci/*(env/provision/test/release/notify/lib-forgejo)+ checks 保留
B 官网Astro 构建 → 部署 pangolin.yanmeiai.com
C 服务端交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查)
D Androidapk(arm64,release keystore 签名)→ release 资产
E macOS公证 dmg(Developer ID + notarytool)→ release 资产
F Windowsexe/installer(Inno Setup)→ release 资产
+

排除:iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。

+ +

2. 已锁定决策

+ + + + + + + + +
维度决定理由
runnernas=官网+服务端(容器化)· mac=Android+macOS · windows=Windowsnas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活
触发tag site-v* / server-v* / client-v* + manual.yml 手动派发同 jiu,发版即部署,可手动重放
macOS 签名mac 自动 Developer ID 签名 + notarytool 公证 + staple凭据入 secret(见 §6)
Android 签名正式 release keystoreapp 级专属签名身份
下载链接官网 href 指向 Gitea release 稳定资产 URL发版即更新,见 §5
镜像goproxy.cn / flutter-io.cn国内网络
+ +

3. 架构

+

3.1 共享基座 scripts/ci/(镜像 jiu)

+
    +
  • _env.sh —— 公共环境(镜像源、路径、版本号解析 ${tag#prefix-v}
  • +
  • lib-forgejo.sh —— release 建/查 + 资产上传(用 FORGEJO_TOKEN
  • +
  • provision-mac.sh —— mac 幂等装 flutter / xcode-select / gomobile / NDK+JDK17
  • +
  • test.shnotify.shcompile-*.shdeploy-*.shrelease-*.sh
  • +
+

每个 compile-* 封装该端已验证的构建命令(Android 走 build-libbox.sh android + +flutter build apk --split-per-abi;macOS 走 Developer ID 签名 + notarytool submit --wait + +stapler)。工作流只调脚本,逻辑在脚本里、便于本地复现。

+ +

3.2 工作流 .gitea/workflows/

+ + + + + + + + +
工作流触发runner步骤
checks.yml(现 ci.yml)push 分支nas保留:shellcheck / openapi / redline / flutter analyze+test / go test
deploy-site.ymlsite-v*nasnode:20 构建 Astro(注入 SITE_URL)→ deploy-site.sh
deploy-server.ymlserver-v*nasgolang:1.25 交叉编译 → test → release → deploy-server.sh
build-android.ymlclient-v*macprovision → compile-android.sh(签名 apk)→ release
build-macos.ymlclient-v*macprovision → compile-macos.sh(签名+公证 dmg)→ release
build-windows.ymlclient-v*windowscompile-windows.sh(exe/installer)→ release
+ +

3.3 服务端部署(固化 F3/F4 手动那套,带回滚)

+
+
    +
  1. scp pangolin-{server,agent,migrate} 到 pangolin1 /tmp
  2. +
  3. systemctl stop pangolin-server
  4. +
  5. wal_checkpoint(TRUNCATE)cp 备份 pangolin.db.bak-pre-<tag>
  6. +
  7. pangolin-migrate up(pangolin 用户);失败即恢复备份 + 重启旧 server + 退出非零
  8. +
  9. install 新二进制到 /usr/local/bin(旧的备份为 .bak-<tag>
  10. +
  11. systemctl start + /healthz 健康检查;agent 随连接自恢复
  12. +
+
+ +

4. 官网部署 —— Cloudflare Pages

+
+架构变更(2026-07-06 实施):原计划 rsync 到 pangolin1 的 nginx。但节点 :443 被 sing-box(VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise —— 同机同 IP 上官网 HTTPS 与 VPN 无法共存。故官网改由 Cloudflare Pages 托管:纯静态、全程 HTTPS、_headers/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。已上线 https://pangolin.yanmeiai.com。 +
+

Astro npm ci && npm run buildSITE_URL=https://pangolin.yanmeiai.com)→ dist/ +经 npx wrangler pages deploy 发布到 Pages 项目 pangolin-site(自定义域 pangolin.yanmeiai.com,CNAME → pangolin-site.pages.dev,proxied)。需 secret CLOUDFLARE_API_TOKEN(带 Account>Pages>Edit)+ CLOUDFLARE_ACCOUNT_ID;deploy 步骤在 node:20 容器内跑 wrangler。灾备:产物仍纯静态,可另 rsync 到镜像。

+ +

5. 下载链接闭环(30A)

+

web/website/src/config/site.tsdownloads:{ android, macos, windows },值为 Gitea release +稳定 latest 资产 URL(Forgejo 支持 …/releases/latest/download/<asset> 则直用;否则构建期用 +FORGEJO_TOKEN 查最新 client-v* 版本烘焙进 href)。Download.astro 各平台按钮读 +SITE.downloads.<platform>。客户端发版后官网重部署即刷新(或 build-* 完成触发 deploy-site)。

+ +

6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用)

+ + + + + + + + +
Secret作用域说明
FORGEJO_TOKEN / FORGEJO_URL账户级建 release + 传产物,jiu 复用
MACOS_DEVELOPER_ID_CERT_P12_BASE64 / MACOS_DEVELOPER_ID_CERT_PASSWORD账户级Developer ID 证书(账号级),与 jiu 共用;续期改一处
APPSTORE_API_KEY_P8_BASE64 / APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID账户级公证 API key(KEY_ID=3PZTHR8YMJ),与 jiu 同一把
DEPLOY_SSH_KEYpangolin 仓库级授权到 pangolin1,最小权限
ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORDpangolin 仓库级Android app 级专属签名(pangolin 自己的 keystore,不复用 jiu
MACOS_APP_PROVISION_PROFILE_BASE64 / MACOS_SYSEXT_PROVISION_PROFILE_BASE64pangolin 仓库级主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属)
+

命名对齐 jiu(MACOS_*/APPSTORE_*/ANDROID_*):Apple 那套放账户级 → jiu/pangolin 共用一份,compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但各 app 独立、不共享。工作流用 secrets.XXX 引用,作用域对写法透明。

+ +

7. 实现顺序

+

范围虽 A~F,按风险/依赖递增落地,每阶段独立可发、独立验收:

+
    +
  1. A 基座 + checks 迁移(抽 scripts/ci 骨架)
  2. +
  3. B 官网(最简,验证 release/deploy 骨架)
  4. +
  5. C 服务端(固化手动部署)
  6. +
  7. D Android(解锁下载链接;需 keystore 就绪 + gradle 接签名)
  8. +
  9. E macOS(最复杂:证书 + 2 描述文件 + 公证)
  10. +
  11. F Windows(windows runner + Inno Setup)
  12. +
+ +

8. 验证

+
    +
  • 每条流水线先 workflow_dispatch 手动跑通、核对产物/部署,再依赖 tag。
  • +
  • 服务端:server-v* → migrate 版本 + /healthz + 行数守恒(同 F3 核对)。
  • +
  • 官网:site-v* → 站点可访问 + canonical 正确 + redline 扫描。
  • +
  • 客户端:release 资产可下载安装(Android 侧载 / macOS spctl / Windows 安装)。
  • +
  • 下载链接:官网按钮落到最新 release 资产。
  • +
+ +

9. 风险与缓解

+ + + + + + + +
风险缓解
nas 内存(3.8G)构建 OOM容器化单 job、Astro/Go 轻量;必要时该端移 mac
migrate 在生产出错部署前备份 + 失败自动回滚(§3.3),已在 F3/F4 手动验证
Android keystore 丢失存 Bitwarden(文件+密码);终身签名身份
macOS 公证凭据泄露账户级 secret,不落盘;.p8/.p12 用完即删临时文件
发版后下载链接不刷新build-* 成功触发 deploy-site 重烘焙,或用 latest-download 稳定 URL
+ +

10. 不在本方案

+

iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS(#25)、上架 Play、Windows 代码签名(先不签)。

+
+ + diff --git a/docs/cicd-plan.html b/docs/cicd-plan.html new file mode 100644 index 0000000..c0a2531 --- /dev/null +++ b/docs/cicd-plan.html @@ -0,0 +1,173 @@ + + + + + +Pangolin CI/CD 实现计划(#30) + + + +
+← 文档索引 +

Pangolin CI/CD 实现计划

+

#30 · 2026-07-05 · 阅读版 · 执行真相源 docs/superpowers/plans/2026-07-05-cicd.md(带 checkbox)· 设计 cicd-design.html

+ +
+目标:tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动。镜像 jiu 的 +scripts/ci/*.sh(逻辑)+ .gitea/workflows/*.yml(编排)。runner:nas(官网+服务端,容器化 +node:20/golang:1.25)、mac(Android+macOS)、windows(Windows)。 +CI「测试」= workflow_dispatch 手动触发跑一遍 + 观察产物/部署(非经典单元 TDD)。 +
+ +

全局约束

+
    +
  • 参考:jiu 的 ~/code/jiu/.gitea/workflows/* + ~/code/jiu/scripts/ci/*(proven,copy+adapt)。
  • +
  • nas 每 job docker run 官方镜像(node:20/golang:1.25),不装宿主工具链。
  • +
  • 国内镜像:GOPROXY=goproxy.cn,directPUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn
  • +
  • Secrets(已建,对齐 jiu):账户级 FORGEJO_TOKEN/URLMACOS_DEVELOPER_ID_CERT_P12_BASE64/PASSWORDAPPSTORE_API_KEY_P8_BASE64/APPSTORE_API_KEY_ID/APPSTORE_API_ISSUER_ID;仓库级 DEPLOY_SSH_KEYANDROID_KEYSTORE_BASE64/PASSWORDANDROID_KEY_ALIAS/PASSWORDMACOS_APP_PROVISION_PROFILE_BASE64MACOS_SYSEXT_PROVISION_PROFILE_BASE64
  • +
  • 客户端铁律:macOS 递增 CURRENT_PROJECT_VERSION;Android NDK≥28、gomobile JDK17、libbox 包名 io.nekohasekai.libbox
  • +
  • Bash 禁 $();提交带 Co-Authored-By footer。部署机 pangolin1(103.119.13.48),官网 pangolin.yanmeiai.com
  • +
+ +
Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线)
+ +
+

Task 1 · scripts/ci 基座

+
Create: scripts/ci/_env.sh · lib-forgejo.sh · notify.sh(抄 jiu 同名改 pangolin 专属值)
+产出:_env.sh(镜像源 + ver_from_tag${ref#refs/tags/prefix-v} 参数展开);lib-forgejo.shforgejo_release_ensure / forgejo_upload_asset,curl+API);notify.sh。 +验证:bash -n + shellcheck 0 告警 → commit。 +
+ +
+

Task 2 · checks 工作流(保留)

+
Modify: .gitea/workflows/ci.yml
+现有 ci.yml(nas,shellcheck/openapi/redline/flutter/go test)保留;把 scripts/ci/*.sh 纳入 shellcheck 扫描。push 观察全绿。 +
+ +
+

Task 3 · 官网发布

+
Create: scripts/ci/compile-site.sh · deploy-site.sh · .gitea/workflows/deploy-site.yml
+前置(基础设施,改机器前问用户):pangolin1 装 nginx/caddy 配 pangolin.yanmeiai.com vhost(web 根 /var/www/pangolin-site);CF DNS 指向 103.119.13.48(cf-api,记 baize);TLS 先 CF 橙云或并入 #25。 +
    +
  • compile-site.shnode:20 容器 npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build
  • +
  • deploy-site.shDEPLOY_SSH_KEYrsync -az --delete dist/ pangolin1:/var/www/pangolin-site/
  • +
  • deploy-site.yml:tag site-v* + dispatch,runs-on: nas
  • +
+验证:dispatch → curl -I https://pangolin.yanmeiai.com/ 200。 +
+ +
+

Task 4 · 服务端发布(固化 F3/F4,带回滚)

+
Create: scripts/ci/compile-backend.sh · release-server.sh · deploy-server.sh · .gitea/workflows/deploy-server.yml
+
    +
  • compile-backend.shgolang:1.25 容器,CGO_ENABLED=0 GOOS=linux GOARCH=amd64 编 server/agent/migrate。
  • +
  • release-server.shforgejo_release_ensure + 上传三个二进制。
  • +
  • deploy-server.sh(核心,复刻手动次序):
  • +
+
#!/usr/bin/env bash
+set -euo pipefail
+DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1"
+scp server/out/pangolin-{server,agent,migrate} pangolin1:/tmp/
+ssh pangolin1 "bash -s" <<REMOTE
+set -euo pipefail
+systemctl stop pangolin-server
+runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);'
+cp -p "$DB" "$DB.bak-pre-$TAG"
+if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then
+  echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1
+fi
+cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true
+install -m755 /tmp/pangolin-server "$BIN/pangolin-server"
+install -m755 /tmp/pangolin-agent  "$BIN/pangolin-agent"
+install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate"
+systemctl start pangolin-server; systemctl is-active pangolin-server
+REMOTE
+curl -fsS -m10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz >/dev/null && echo healthz OK
+deploy-server.yml:tag server-v* + dispatch,nas,compile → test → release → deploy。 +验证:dispatch → migrate 版本前进 + /healthz 200 + 行数守恒。 +
+ +
+

Task 5 · scripts/ci/test.sh

+test.sh servergolang:1.25 go test ./...test.sh clientflutter test。接入 deploy-server 的 test 步骤。 +
+ +
Phase 2 —— Android(解锁官网下载链接)
+ +
+

Task 6 · Android gradle 接 release 签名

+
Modify: client/android/app/build.gradle · Create: keystore.properties(gitignore)
+加 signingConfigs.release,从 env/keystore.properties 读 keystore + 三密码(ANDROID_*);buildTypes.release.signingConfig 指向它。本机验证 apksigner verify --print-certs 显示 CN=Pangolin(非 debug)。 +
+ +
+

Task 7 · Android CI

+
Create: scripts/ci/compile-android.sh · release-client.sh · .gitea/workflows/build-android.yml
+compile-android.shbuild-libbox.sh android(JDK17/NDK≥28)→ secrets 落 keystore → flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=…build-android.yml:tag client-v*runs-on: mac,provision → compile → release。验证:真机 adb install -r 可用。 +
+ +
+

Task 8 · 官网下载链接接 Android

+
Modify: web/website/src/config/site.ts(downloads.android)· Download.astro
+site.ts.downloads.android = Forgejo /releases/latest/download/<asset> 稳定 URL(不支持则构建期烘焙)。重部署官网,点击落到最新 apk。关 todo 30A(Android 部分)。 +
+ +
Phase 3 —— macOS + Windows
+ +
+

Task 9 · macOS 签名+公证 dmg

+
Create: scripts/ci/compile-macos.sh · .gitea/workflows/build-macos.yml(证书导入+notarytool 可复用 jiu compile-macos.sh)
+建临时 keychain → MACOS_DEVELOPER_ID_CERT_P12_BASE64 导入 → 两个描述文件解码装入 → 递增 CURRENT_PROJECT_VERSION → Xcode Developer ID 构建 app+sysext → notarytool submit --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --waitstapler → dmg。runs-on: mac。验证:另一台 mac spctl -a -vv + stapler validate 通过。 +
+ +
+

Task 10 · Windows 安装包

+
Create: scripts/ci/compile-windows.sh · .gitea/workflows/build-windows.yml(参照 jiu install-innosetup.ps1)
+flutter build windows --release → Inno Setup 打包(先不代码签名,首装有 SmartScreen 提示可接受)。tag client-v*/winbuild*runs-on: windows。验证:windows 机装上能连。 +
+ +
+

Task 11 · 下载链接全端闭环

+site.ts.downloads 补齐 macos/windows;三端按钮全接 release。客户端发版触发官网重部署刷新。关 todo 30A/30B。 +
+ +

不在本计划

+

iOS(G)、备份/容灾(#26)、TLS(#25)、Windows 代码签名、上架商店。

+
+ + diff --git a/docs/code-review-2026-07.html b/docs/code-review-2026-07.html new file mode 100644 index 0000000..0eaba20 --- /dev/null +++ b/docs/code-review-2026-07.html @@ -0,0 +1,216 @@ + + + + + +全栈设计审查 2026-07(前端 / 后端 / 数据库) + + + +
+← 返回文档索引 + +

全栈设计审查 · 2026-07

+

范围:server/(Go 控制面 + agent)· client/(Flutter)· 数据库 schema(migrations 1–20)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。13 项发现 P0 ×2 P1 ×5

+ +
+总体评价:架构底子是好的——方言层数据库解耦、argon2id 密码、refresh 单次轮换 + Redis 白名单、gRPC mTLS、 +per-device dp_uuid 归因、Lua 滑窗限流、节点三态判活(DB×agent在线×数据面健康),都属同规模项目少见的干净设计。 +问题集中在两条主线:① 传输与数据安全的「最后一公里」没封口(明文 API、零备份); +② 多处「一次写对、后续演进没跟上」的接缝漂移(connect 发每设备凭证 / disconnect 撤账户凭证; +设备唯一键改造写进注释却没落地;agent 用量取走即焚)。 +
+ +

发现汇总

+ + + + + + + + + + + + + + + +
#严重度一句话
F1P0 安全全栈控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网
F2P0 运维数据库SQLite 生产库零备份——单盘单机,丢了就是全部
F3P1 正确性后端+DB同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效
F4P1 正确性后端connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销
F5P1 可靠性agent用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once)
F6P1 可靠性后端Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考
F7P1 容量后端argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM
F8P2 数据库数据库sessions / audit_log 无限增长,无留存策略;按登录史全量扫描
F9P2 产品后端免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符
F10P2 一致性全栈免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时
F11P2 可靠性后端agent 在线状态纯内存:server 重启后短窗内全体拒连
F12P2 客户端客户端单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗
F13P2 后端后端ReportUsage 多条 SQL 无事务,崩溃可留部分记账
+ +

P0 — 必须尽快处理

+ +
+

F1 · 控制面全程明文 HTTP 安全

+

现象:客户端默认 API 基址是 http://103.119.13.48:8080(裸 IP + 明文)。登录密码、 +access/refresh token、会话轮询、设备列表——所有控制面流量在公网明文传输。服务端 argon2id 只保护「存储」,保护不了「传输」。

+

影响:任何链路中间者(ISP、Wi-Fi、GFW 探测设备)可截获密码与 token 直接接管账户。对一个「主打隐私」的产品,这是与定位直接矛盾的短板;且中国链路上明文 HTTP + 可疑 payload 更易被主动探测/干扰。

+

修法:域名 + 反代 TLS(Caddy 一行配置自动 Let's Encrypt,或 nginx+certbot),客户端默认改 +https://api.<domain>;Android 移除 usesCleartextTraffic;服务端 8080 收回 loopback。 +无域名过渡期可先自签 + 客户端证书 pinning(次优)。

+
client/lib/services/api_config.dart:8 · scripts/local_test.sh:20(API_URL)· server :8080 直挂公网
+
+ +
+

F2 · SQLite 生产库零备份 运维

+

现象deploy/ 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在 +pangolin1 单机单盘的一个 SQLite 文件里。

+

影响:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。

+

修法(一晚可落地):① 最简:cron 每日 sqlite3 .backup + rclone 推 Cloudflare R2/S3 异地,保留 30 天; +② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。

+
deploy/bootstrap/ · deploy/single-node/deploy.sh(均无备份任务)
+
+ +

P1 — 设计缺陷,建议排期修

+ +
+

F3 · 同机换账号 → 设备注册永远 403,连接被卡死 正确性

+

链路:客户端 device_id 一次生成、安全存储持久、跨账号复用(登出不清)。 +devices.uuid全局 UNIQUE(migration 000001),RegisterIfAbsent 遇到「uuid 已属他人」直接 +ErrForbidden;登录侧注册是 best-effort → 登录成功但设备永远注册不上;随后 +ConnectNode 因设备未注册拒发凭证,提示「请退出后重新登录以重新注册设备」——而重新登录永远解不了这个死结

+

影响:一台机器先后登两个账号(家人共用电脑、用户换号、测试机)→ 第二个账号完全无法连接,且用户按提示操作也无效。migration 16 头注释已写明「UNIQUE(uuid)→UNIQUE(user_id,uuid) 需表重建,风险隔离到单独迁移」——该迁移至今未落地,是典型的「注释里的 TODO 变成生产 bug」。

+

修法:① 落地推迟的迁移:UNIQUE(user_id, uuid)(设备身份按用户隔离,语义即「此用户的此设备」); +dp_uuid 归因按 (user,device) 查本就成立;② 或语义改「重绑」:新登录抢走设备行(转移 owner 并吊销旧主会话)——更贴近「一台设备此刻只属一个账号」的现实;③ 客户端兜底:登出时按账号命名空间存 device_id。推荐 ①+③。

+
server/internal/devices/service.go:209(ErrForbidden)· server/migrations/sqlite/000016_*.up.sql 头注释 · server/internal/httpapi/nodes.go:245(DEVICE_NOT_REGISTERED)· client/lib/services/device_identity.dart:66
+
+ +
+

F4 · disconnect 撤销的不是 connect 发出的凭证 正确性

+

现象ConnectNode 走每设备凭证 EnsureDeviceDpUUID → devDp(nodes.go:244); +DisconnectNode 却吊销账户级 ent.DpUUID 并删账户凭证行(nodes.go:371-377),且接口没有 +device_id 入参。用户主动断开从未真正吊销数据面凭证——每设备凭证在节点上一直活到 TTL(付费 24h)。

+

影响:「断开」的服务端语义失效;被移除/被强退的设备若本地还留着 sing-box 配置,断开后的 +TTL 窗口内仍可直连数据面(绕过控制面判定)。DeleteDevice 路径有自己的 revoker 是对的,但普通 disconnect 是空转。

+

修法:disconnect 请求体加 device_id,查 devDp 后吊销之;账户级 dp_uuid 作为遗留兜底再撤一次亦可。顺手给 revoke 失败加告警(现在 _ = 吞掉)。

+
server/internal/httpapi/nodes.go:244 vs 336-380
+
+ +
+

F5 · 用量「取走即焚」:上报失败 = 数据永久丢 可靠性

+

现象:v2ray 用量源 QueryStats(Reset_: true) 先清零内核计数器拿到 delta; +runUsageReportUsage 一旦失败直接 return err 拆会话重连——刚取走的这窗口数据没有任何缓冲,永久丢失。注释声称 at-least-once,实际是 at-most-once。

+

影响:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记: +免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。

+

修法:Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 window_id,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。

+
server/internal/agentd/usage_v2ray.go:66(Reset_)· server/internal/agentd/usage.go:42-51
+
+ +
+

F6 · Redis 重启 ≈ 全员被登出;「权威」sessions 表不参与 refresh 判定 可靠性

+

现象:refresh token 白名单只活在 Redis(jwt:refresh:*)。single-node 部署用发行版默认 Redis(RDB 快照,非 AOF)——crash/重启丢最近几分钟到全部白名单 → 存量 refresh 全被拒 → 全体用户被迫重新登录。而 sessions 表注释自称「可查询的权威记录」,refresh 路径却从不回查它——两边脑裂:DB 说会话有效,Redis 说无效,以 Redis 为准。

+

影响:1GB 小机上 Redis 恰是 OOM-killer 高危对象;一次意外重启= 一次全量掉线事故 + 客服风暴。

+

修法:refresh 白名单 miss 时回查 sessions 表(jti 存在且未 revoke → 放行并回填 Redis),Redis 降级为缓存而非唯一真相;同时 single-node 部署给 Redis 开 AOF (appendonly yes) + maxmemory 上限。这也顺手消除了「强退后 Redis 删失败仍可刷新」的反向缝隙。

+
server/internal/auth/token.go:228-234 · server/internal/sessions/store.go:1-5(“authoritative”)· deploy/single-node/deploy.sh:104
+
+ +
+

F7 · argon2id 64 MiB/次登录,1 GB 机可被打 OOM 容量/安全

+

现象:argon2id 参数 64 MiB × 4 线程。登录是公开端点:~10 个并发登录请求 ≈ 640 MB 瞬时内存——机器总共 1 GB,还要跑 sing-box + agent + Redis。滑窗限流按 scope(邮箱/IP) 计,攻击者换 IP/邮箱可绕。

+

修法:给密码哈希加全局并发闸(semaphore 1–2 个并发,其余排队),几行代码把内存上限钉死在 128 MiB; +或按 OWASP 备选参数降到 19 MiB×2。限流再加全局维度(每秒总登录数)兜底。

+
server/internal/auth/password.go:19-21 · server/internal/auth/ratelimit.go
+
+ +

P2 — 结构性小患 / 口径问题

+ +
+

F8 · sessions / audit_log 无限增长,无留存策略 数据库

+

每次登录一行 sessions、永不清理;audit_log 纯追加。LastLoginByDevice 按用户全史扫描(ORDER BY created_at ASC 无 LIMIT),HasActiveSession(15s 轮询热路径)只有 user_id 单列索引可用。年级尺度上小机的磁盘与查询都会被拖住。:留存任务(revoked 会话 >90 天、audit >180 天定期删)+ 复合索引 (user_id, device_id, revoked_at);LastLogin 改每设备 MAX 子查询或维护 devices.last_login 列。

+
server/internal/sessions/store.go:54-62,134-152 · migrations 000016(仅两个单列索引)
+
+ +
+

F9 · 免费额度按 UTC 日重置(北京时间 08:00)产品

+

utcToday() / windowEnd.UTC().Truncate(24h):主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。:额度日界定死 Asia/Shanghai(产品定位明确,不必 per-user 时区),服务端集中改 utcToday 与记账日期两处即可,客户端展示自动跟随 /me。

+
server/internal/usage/quota.go:40,55 · server/internal/nodes/handler_grpc.go:291
+
+ +
+

F10 · 免费时长三个时钟不一致 一致性

+

同一「10 分钟」有三种度量:① 服务端 minutes_used——有流量的窗口才 +1(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。

+
server/internal/agentd/usage_v2ray.go:96-101(有流量才计)· httpapi/nodes.go:235(TTL)· client connection_provider 倒计时
+
+ +
+

F11 · agent 在线状态纯内存,server 重启短窗全体拒连 可靠性

+

hub.IsOnline 是进程内 map;server 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。

+
+ +
+

F12 · 单实例探测:固定端口 47654、无握手 客户端

+

任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(App 无法启动且无提示);反之任意本地进程连一下就能唤起主窗(无害但脏)。:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。

+
client/lib/system_tray.dart:15-38
+
+ +
+

F13 · ReportUsage 多条 SQL 无事务 后端

+

每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。

+
server/internal/nodes/handler_grpc.go:300-359
+
+ +

做得好的(保持)

+
    +
  • 方言层internal/db/dialect.go):裸 SQL + 中性 Upsert/锁语义,时间 Go 端算——MySQL/SQLite 真正可切换,测试免 docker。
  • +
  • 认证栈:argon2id + RS256 双 kid 轮换 + refresh 单次使用轮换 + typ 声明防混用,教科书级。
  • +
  • 节点判活:DB 状态 × agent gRPC 在线 × 数据面健康三合一(effectiveNodeStatus),并拒绝向离线 agent「假装下发成功」——正是修过 6 天静默事故后的正确形态。
  • +
  • per-device dp_uuid + v2ray per-user 计数:归因链路是准的(老 clash 均摊源已弃用、仅遗留代码)。
  • +
  • 免费额度账户级共享 + 凭证 TTL 硬切断:卡控在服务端成立,客户端绕过也兜得住。
  • +
  • 迁移成对成套(mysql/sqlite 各一份 up/down),审查期未见漂移。
  • +
+ +

建议处理顺序

+ + + + + +
批次理由
立刻F2(备份)→ F1(TLS)F2 一晚落地、消掉最大期望损失;F1 需要域名决策,动客户端默认值要随发版
下一迭代F3 + F4(一起动 devices/凭证接缝);F5 + F13(一起动用量链路);F6 + F7(一起动 auth 可靠性)三组各自同一片代码,一组一 PR
排队F8–F12口径决策(F9/F10)先拍板再动手;F11 并入 todo #8
+ +

备注:web/(usercenter/website)本轮未深审(改动频率与暴露面低于 server/client 核心链路); +近期已修复且验证过的不再列出:用量多设备超计(#22)、被移除设备判活(dev==nil)、弱网看门狗误伤(#18)、统计流广播订阅。 +本报告基于 worktree-macos-killswitch @ 2026-07-02。

+ +
+ + diff --git a/docs/contact-telegram-channels-design.html b/docs/contact-telegram-channels-design.html new file mode 100644 index 0000000..e4e4c92 --- /dev/null +++ b/docs/contact-telegram-channels-design.html @@ -0,0 +1,337 @@ + + + + + +联系我们:渠道二级页(Telegram 频道/群组,DB 配置)· 交互设计 + + + +
+← 返回文档索引 + +

联系我们 · 渠道二级页

+

交互设计 · Telegram/LINE 多频道 · 群组 · 内容 DB 配置 · 前端 + 后端 + 数据库

+ +
+现「联系我们」把每个渠道当单一入口(Telegram 只挂一个 @PangolinVPN_bot)。 +需求:点 Telegram 进入二级页,列出该平台下的多个频道 / 群组 / Bot, +每项含名称、@handle、一句说明、可选成员数、认证标记与「打开 / 加入」动作, +全部由数据库配置(运营可随时增删改,客户端不写死)。本页给出交互原型 + 数据模型 + 接口,待你统一后再开发。 +
+ +

可点原型(点 Telegram 卡片进二级,← 返回)

+

下方为内嵌可交互原型,配色用 App 真实暗色 token(clay/espresso)。点 TelegramLINE 卡进详情;「邮箱客服 / 自助发卡商店」为单链接,点即直接打开(原型里仅提示)。

+ +
+ +
+
穿山甲PANGOLIN
+
连接
+
🌐节点
+
📊统计
+
设置
+
💬联系我们
+
+ +
+
+ 联系我们 + US🌙 +
+ + +
+

遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。

+ +
+
+
Telegram
官方频道 · 交流群 · 客服 Bot
+ +
+ +
+
💬
+
LINE 即将开放
官方账号 · 中文/日文群
+ +
+ +
+
+
邮箱客服
support@pangolin.vpn
+ +
+ +
+
🛍
+
自助发卡商店
shop.pangolin.vpn
+ +
+
+ + +
+
‹ 返回
+
+
+
Telegram
加入官方频道获取更新,进群与用户互助
+
+ +
频道 · CHANNELS
+
+
📣
+
穿山甲 · 官方频道
产品更新与公告 · @PangolinVPN
+ +
+
+
📶
+
穿山甲 · 节点状态
节点/故障实时播报 · @PangolinStatus
+ +
+ +
群组 · GROUPS
+
+
👥
+
穿山甲 · 用户交流群
使用问题互助交流 · @PangolinChat
+ +
+
+
🌏
+
Pangolin · English Group
English support & chat · @PangolinEN
+ +
+ +
客服机器人 · BOT
+
+
🤖
+
客服机器人
自动答疑 / 提交工单 · @PangolinVPN_bot
+ +
+
+ + +
+
‹ 返回
+
+
💬
+
LINE
官方账号与交流群
+
+
官方账号 · OFFICIAL
+
+
💬
+
Pangolin 官方账号
公告与客服 · @pangolinvpn
+ +
+
群组 · GROUPS
+
+
👥
+
中文交流群
使用互助 · openchat
+ +
+
+
+
+
+ + + +
+

同一原型内切换三态 · 真机为路由 push(移动端整页)/ 内容区替换(桌面)。 +L1 上 LINE 为灰置「即将开放」不可点;「L2 · LINE」按钮仅用于预览其预留的二级结构(去灰后即此样式)。

+ +

交互规则

+
    +
  • 进入二级的条件:某平台配置了 >1 条链接 → 卡片带 ,点击进二级页(Telegram / LINE)。 +仅 1 条链接的平台(邮箱、发卡商店)→ 点击直接执行该链接动作(打开邮件 / 浏览器),不进二级。 +规则统一由「该平台 link 条数」驱动,无需前端写死哪个进二级。
  • +
  • 二级页结构:顶部 ‹ 返回 + 平台头(图标 + 名 + 一句副标题);下方按 kind 分组展示 +——频道 CHANNELS / 群组 GROUPS / 客服机器人 BOT(分组标题仅在该组有内容时出现,顺序固定)。
  • +
  • 每一项:图标/头像 · 标题(可带 认证)· 一句说明 + @handle · +可选成员数(展示用缓存文本,不实时拉 Telegram)· 动作按钮。
  • +
  • 动作:频道/Bot → 「打开」(描边按钮);群组 → 「加入」(实心强调按钮)。点按钮或点整行都触发。 +优先 tg://resolve?domain=… 唤起已装 Telegram,失败回退 https://t.me/…(浏览器)。
  • +
  • 桌面 vs 移动:桌面在右侧内容区做视图替换(标题栏文案随之切到「Telegram」,← 返回回列表); +移动端为整页 push 路由,系统返回手势/返回键回列表。两端同一份数据与卡片组件。
  • +
  • 空/禁用:某平台所有 link enabled=0(或库里无该平台行)→ 默认 L1 不展示该平台;二级页某分组为空 → 不渲染该分组标题。
  • +
  • 「即将开放」灰置:前端 platform 注册表有一个 comingSoon 列表(当前 = [line])。列在其中且暂无 enabled 链接的平台,L1 置灰 + 「即将开放」角标、不可点进(占位预告,不隐藏)。 +运营在库里配好该平台链接后,从 comingSoon 列表移除该项(一行代码)→ 自动变可点二级页。LINE 即走此路:结构与 Telegram 同构,先灰、内容就绪即上。
  • +
+ +

数据模型(DB 配置)· 全渠道统一一张表

+

一张表覆盖所有联系方式 —— Telegram 频道/群组/Bot、LINE、邮箱、发卡商店都是 contact_link 里的一行, +差别只在 platform(分到哪个 L1 卡)+ kind(二级分到哪组 & 动作样式)+ url 协议。 +一行 = 一条可点链接;L1 按 platform 聚合,L2 按 kind 分组。

+
contact_link
+─────────────────────────────────────────────────────────────
+id           INTEGER  PK
+platform     TEXT     -- telegram | line | email | store | whatsapp | ...(L1 分组键)
+kind         TEXT     -- channel | group | bot | link(L2 分组键 & 动作样式)
+title        TEXT     -- "穿山甲 · 官方频道" / "邮箱客服" / "自助发卡商店"
+handle       TEXT     -- "@PangolinVPN"(展示;可空,邮箱/商店留空)
+url          TEXT     -- 点击目标(见下表);App 侧对 telegram 优先转 tg://
+description  TEXT     -- 一句说明(可空)
+verified     INTEGER  -- 0/1 认证勾
+sort_order   INTEGER  -- 组内排序
+enabled      INTEGER  -- 0/1 下线开关
+locale       TEXT     -- "zh"|"en"|NULL(全部)  可选按语言过滤
+
+

邮箱之类怎么进这张表 —— 就是把 url 换个协议、kind=link

+ + + + + + + + + +
渠道platformkindurl 示例动作
Telegram 频道telegramchannelhttps://t.me/PangolinVPN(App 转 tg://打开
Telegram 群组telegramgrouphttps://t.me/PangolinChat加入
Telegram Bottelegrambothttps://t.me/PangolinVPN_bot打开
LINE 账号linelinkhttps://line.me/R/ti/p/@pangolinvpn打开
邮箱客服emaillinkmailto:support@pangolin.vpn打开(拉起邮件)
自助发卡商店storelinkhttps://shop.pangolin.vpn打开(浏览器)
WhatsApp(将来)whatsapplinkhttps://wa.me/…打开
+

动作按钮文案由 kind 决定:group「加入」(实心强调);其余(channel/bot/link)→ 「打开」(描边)。 +点击一律「用系统方式打开 url」,App 侧仅对 telegram 平台做 https://t.me/x → tg://resolve?domain=x 的唤起优化,失败回退原 url。

+

唯一不入库的是「皮」:L1 平台卡的图标 / 强调色 / 默认显示名由前端一个小 platform 注册表platform 键内置 +(telegram=✈+强调色、line=💬、email=✉、store=🛍…),避免把图标资源塞进数据库;未知 platform 用通用图标兜底。 +库里只配内容。加已有平台的新频道/群 = 纯 DB,无需发版;加一个全新平台类型(新图标)= 注册表加一行 + 发版。

+ +

接口

+
GET /v1/contact            # 无需登录亦可(客户端普通请求)
+→ 200
+{
+  "platforms": [
+    { "platform":"telegram",
+      "links":[
+        {"kind":"channel","title":"穿山甲 · 官方频道","handle":"@PangolinVPN",
+         "url":"https://t.me/PangolinVPN","description":"产品更新与公告","verified":true},
+        {"kind":"group","title":"穿山甲 · 用户交流群","handle":"@PangolinChat",
+         "url":"https://t.me/PangolinChat","description":"使用问题互助"},
+        {"kind":"bot","title":"客服机器人","handle":"@PangolinVPN_bot",
+         "url":"https://t.me/PangolinVPN_bot","description":"自动答疑/提交工单"}
+      ]},
+    { "platform":"line",  "links":[ ... ] },
+    { "platform":"email", "links":[{"kind":"link","title":"邮箱客服","url":"mailto:support@pangolin.vpn"}] },
+    { "platform":"store", "links":[{"kind":"link","title":"自助发卡商店","url":"https://shop.pangolin.vpn"}] }
+  ]
+}
+
    +
  • 后端按 enabled=1 过滤、按 sort_order 排序、按 platformkind 分组返回;客户端只渲染。
  • +
  • 客户端缓存上次结果(离线/弱网仍可展示),启动或进联系页时后台刷新。
  • +
  • 迁移:server/migrations/{mysql,sqlite}/ 各加建表 + 种子数据(把现有 4 渠道灌入,Telegram 先补真实频道/群)。
  • +
+ +

已定(本轮拍板)

+
    +
  • 不展示成员数 —— 已从模型与 UI 移除 member_hint
  • +
  • 群组动作叫 「加入」kind=group,实心强调);频道/Bot/普通链接 「打开」(描边)。
  • +
  • 接口走 独立 GET /v1/contact(不并进 /me 引导)。
  • +
  • 全渠道统一一张 contact_link:邮箱/发卡/LINE 与 Telegram 同表,靠 url 协议区分(mailto: / https: / tg://)。
  • +
  • LINE 也做二级(与 Telegram 同构),但先灰置——L1 上 LINE 卡置灰 + 「即将开放」,不可点进;二级页结构预留,等运营在库里配好 LINE 链接、去灰即用。
  • +
+ +

仍可再定(不阻塞,先给默认)

+
    +
  • 认证勾 与分组标题:默认保留(频道/群组/Bot 分组 + 官方项带勾)。若想更简可拍平成单列表 —— 说一声即可。
  • +
  • 成员数字段是否保留在库里(仅不展示):默认删列,需要时再加回不迟。
  • +
+ +
+ + + diff --git a/docs/control-plane-tls-tunnel.html b/docs/control-plane-tls-tunnel.html new file mode 100644 index 0000000..e6b26c3 --- /dev/null +++ b/docs/control-plane-tls-tunnel.html @@ -0,0 +1,136 @@ + + + + + +Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划 + + + +
+← 文档索引 +

Pangolin 控制面 TLS(Cloudflare Tunnel 前置)实现计划

+

2026-07-06 · 阅读版 · 执行真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md(带 checkbox)

+ +
+目标:把 pangolin-server 控制面 API 从明文 http://103.119.13.48:8080 迁到 +https://api.yanmeiai.com,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书)。 +数据面 sing-box REALITY :443 全程不动。 +
+ +

架构

+

+pangolin1 上跑 cloudflared 出站隧道(不监听任何入站端口 → 与 sing-box 独占的 +:443 零冲突),CF 边缘把 api.yanmeiai.com 的请求经隧道回送到 +127.0.0.1:8080。客户端(Flutter 四端共享 kApiBaseUrl)默认改 https 域名; +控制面下发给客户端 sing-box 的 .srs 规则集下载基址(PANGOLIN_PUBLIC_URL) +同步改 https。最后一步把 :8080 收回 loopback 并关防火墙,彻底退役明文口——该步有 +上线顺序闸(须待现网客户端更新后再做)。 +

+ +

端口 / URL 布局

+ + + + + +
用途对外源站/绑定本轮变更
控制面 HTTP APIhttps://api.yanmeiai.com(CF Tunnel)127.0.0.1:8080新增 CF Tunnel 前置 + 收 loopback
数据面 sing-box REALITY:443(节点公网 IP)同端口不动
gRPC agent(mTLS)—(仅节点内):9443不动
+ +

全局约束

+
    +
  • Bash 禁 $() 命令替换、禁 set -a/set +a;需捕获输出拆多步或用管道。
  • +
  • 凭证走 Bitwarden/rbw,不写 ~/.env/明文配置/git。Cloudflare 用 cf-api 封装(token 内部从 Bitwarden 取)。隧道 token 等密钥一律不入 git,只落 /etc/pangolin/*(已 gitignore)+ Bitwarden。
  • +
  • 改机器(装包/改配置/重启服务)前必须先问用户(只读操作除外)。
  • +
  • pangolin1 = 103.119.13.48,ssh 别名 pangolin1。数据面 sing-box REALITY 独占入站 :443,不得触碰;gRPC agent mTLS :9443 不动。
  • +
  • 上线顺序铁律:现网客户端硬编码 http://103.119.13.48:8080。隧道与 https 端点必须加法上线(与旧口并存),客户端切 https 发版后,收 loopback 才能做,否则旧客户端全挂。
  • +
+ +

6 个任务

+ +
+

Task 1 · Cloudflare Tunnel 供给

+
Create: deploy/single-node/systemd/cloudflared.service · Modify: deploy/single-node/deploy.sh
+CF 账户侧(cf-api)建 remotely-managed 隧道 + ingress(api.yanmeiai.comhttp://localhost:8080)+ 代理 CNAME;pangolin1 装 cloudflared(Debian apt 源)+ committed systemd unit(token 经 EnvironmentFile 注入,不入 unit 本体)。验证:https://api.yanmeiai.com/healthz 与旧的 http://103.119.13.48:8080/healthz 并存可用(加法,不破坏现网)。 +
+ +
+

Task 2 · 客户端控制面基址切 https + Android 去明文

+
Modify: client/lib/services/api_config.dart · client/android/.../AndroidManifest.xml · Create: client/test/unit/api_config_test.dart
+先写守护测试(断言 kApiBaseUrl 必须 https:// 且不含节点 IP)→ 确认失败 → 把 kApiBaseUrl 默认值改为 https://api.yanmeiai.com(仍保留 String.fromEnvironment 可本地覆盖)→ 测试转绿。同步移除 Android manifest 的 android:usesCleartextTraffic="true"(控制面已 https,不再需要明文豁免;iOS/macOS 无 ATS 配置,无需改动)。跑 flutter analyze + 全量单测。 +
+ +
+

Task 3 · CI 守护:Android release manifest 禁明文

+
Create: ci/scan-cleartext.sh · Modify: .gitea/workflows/ci.yml
+新增扫描脚本:manifest 一旦重新出现 usesCleartextTraffic="true" 就 CI 失败(防止将来有人把明文开关加回来,退回到 #25 之前的不安全态)。接入 ci.yml 新 job + shellcheck 列表。 +
+ +
+

Task 4 · PANGOLIN_PUBLIC_URL 切 https

+
Modify: deploy/single-node/deploy.sh
+该变量被嵌进客户端 sing-box 配置当 .srs 分流规则集下载基址(clientconfig.go)。不改的话新客户端仍去明文 IP 拉。改为 https://api.yanmeiai.com;与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。pangolin1 上应用 + 重启 server,验证规则集经隧道可 200 下载。 +
+ +
+

Task 5 · 退役明文口(收 loopback + 关防火墙 + 修健康检查)上线顺序闸

+
Modify: deploy/single-node/deploy.sh · scripts/ci/deploy-server.sh
+此 Task 会切断外部 http://103.119.13.48:8080,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行,执行前需与用户确认「旧客户端可弃」。内容:ADDR127.0.0.1:8080;不再 ufw 放行 8080;deploy-server.sh 健康检查从「runner 远程 curl 公网 IP」改为「ssh 内本地 curl loopback」+「经隧道 curl https 域名」双路验证。验证:明文口不可达、隧道仍活、ss 显示 8080 仅监听 127.0.0.1。 +
+ +
+

Task 6 · 文档更新(本任务)

+
Modify: CLAUDE.md · docs/index.html · docs/control-plane-tls-tunnel.html(本页)
+CLAUDE.md 补充端口/URL 布局说明;生成本 HTML 阅读版并登记 docs/index.html「实现计划」分类;顺带修正 deploy/single-node/deploy.sh 摘要 echo 里残留的旧明文口描述(Task 4/5 落地后的措辞漂移)。 +
+ +

上线顺序

+

+Task 1(隧道加法)→ Task 4(PANGOLIN_PUBLIC_URL,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→ +待客户端更新 → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合,可随时并行推进。 +

+ +

不在本轮

+
    +
  • #32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。
  • +
  • 控制面 API 的 CF WAF/rate-limit 规则精调。
  • +
  • usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。
  • +
+ +
+ + diff --git a/docs/free-quota-ad.html b/docs/free-quota-ad.html new file mode 100644 index 0000000..43819c7 --- /dev/null +++ b/docs/free-quota-ad.html @@ -0,0 +1,127 @@ + + + + + +免费版 10 分钟卡控 + 累加式看广告加时(设计 · #21) + + + +
+← 返回文档索引 + +

免费版 10 分钟卡控 + 累加式看广告加时

+

设计 · todo #21 · 后端 + 前端 + 数据库 · 额度全账户共享

+ +
+免费版此前形同虚设:连接页无倒计时、时间到不卡控、按钮永远可点。根因在服务端—— +ConnectNode 免费门每次连接都发固定 daily_minutes×1min TTL、从不扣减已用, +重连即崭新 10 分钟,日额度从未真正强制。本次将其改为账户级(全设备共享)真卡控 + +累加式看广告加时:连接期倒计时、到点自动切断、耗尽按钮灰化、点击弹广告、看完 +N 分钟。 +
+ +

目标与口径(已确认)

+
    +
  • 连接期倒计时:连上后显示剩余 mm:ss,到 0 自动切断隧道。
  • +
  • 耗尽卡控:额度用完 → 连接按钮变灰不可点。
  • +
  • 看广告加时(累加式):点灰按钮弹广告,看完 +10 分钟(可重复,每日封顶 120 分钟)。
  • +
  • 占位广告 SDK:先跑通假流程(dialog→“播放中”→奖励),服务端用放行式 DevVerifier(nonce 仍防重放),接真 AdMob 时替换。
  • +
  • 桌面(Windows/macOS):免费 = 硬 10 分钟/天不可延,无广告;到点→切断+灰按钮+提示“去移动端看广告或升级”。
  • +
  • 额度全账户共享:非每设备。服务端 usage_daily 本就按 user_id 聚合所有设备的 minutes_used,天然账户级;客户端倒计时仅本地近似,权威始终以 quota_today_min 为准。
  • +
+ +

数据模型

+
+

migration 000020_ad_bonus_minutes(sqlite + mysql)

+
ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INT NOT NULL DEFAULT 0;
+

当日免费额度 = plans.daily_minutes(free=10)+ usage_daily.ad_bonus_minutes(看广告累加); +剩余 = 额度 − minutes_used。历史列 ad_unlocked_at(布尔式当日解锁)保留但不再用于卡控。

+
+ +

后端

+ + + + + + + + +
改动
usage/store.goAddAdBonusMinutes(uid,day,add,ceiling):事务内 LockForUpdate 读旧值 → 累加封顶 → upsert,返回新总额 + 本次实际加时DailyUsage/GetDay/GetUsageRange 补读 ad_bonus_minutesMarkAdUnlocked 标 deprecated。
usage/service.go常量 adBonusPerAd=10 / adDailyBonusCeiling=120TodaySummaryMinutesCap(=daily+bonus)/ AdBonusMinutesremaining=cap−usedUnlockAd 改累加:verify+nonce 后 +adBonusPerAd 封顶,返回 (granted, remaining)
usage/ads.go + main.go放行式 DevVerifierVerify 恒 nil)。main.goADS_DEV_MODE/默认装配(替换现在的 nil——否则 UnlockAd 直接 ErrInternal,占位流程走不通)。nonce 防重放仍生效。
httpapi/nodes.go ConnectNode新增 nodes.store.AccountDayMinutes(uid,day)→(used,bonus)。免费门:allowance=daily+bonusremaining=allowance−usedremaining≤0→拒 QUOTA_EXHAUSTED;否则 TTL=remaining×1min(凭证到点硬切断兜底,防绕过客户端)。
httpapi/account.go /me补读 ad_bonus_minutesquota_today_min 分母改 daily+bonus;新增 quota_cap_min(=allowance,客户端进度条分母)。
usage/handler.goPOST /v1/ads/unlock:204 → 200 返回 {granted_minutes, minutes_remaining}
+ +

前端(四端共享 Dart)

+ + + + + + + + + +
改动
models/me.dartquotaCapMin(当日总额度)。
state/quota_provider.darttotal=me.quotaCapMinisExhaustedmarkExhausted()(倒计时归零本地置耗尽 + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。
state/connection_provider.dart连接时锁定 _freeRemainingSec(会员为 null 不倒计时)。复用 elapsed 计时器每 tick 算 countdown=cap−elapsed,写入 ConnectionState.freeCountdown归零→自动切断_onFreeQuotaExhausted:主动断开不报节点异常 + markExhausted)。倒计时用墙上时钟,后台漏跳回前台补上、准时切。连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。
widgets/connect_button.dartenabled/onDisabledTap:off 态额度耗尽 → 灰化(锁图标),点击走加时流程。
widgets/quota_card.dart三态:连接中显示倒计时 mm:ss + 进度收缩;未连接有余额显示剩余分钟;耗尽显示“今日已用完”。移动端「看广告加时」/ 桌面「升级会员」。
widgets/ad_reward_dialog.dart(新)移动端占位广告:“广告播放中…”→3s→调 watchAd→显示“已加 N 分钟”自动关闭。桌面版:升级/移动端提示弹窗(无广告)。
l10n(zh/en)倒计时/今日已用完/看广告加时/占位广告播放/奖励/桌面升级提示 双语文案。
+ +

时序

+
+

移动端典型流

+
连接 → /me remaining=10 → 倒计时 10:00 …… 00:00
+  → 客户端 _onFreeQuotaExhausted:切断隧道 + 按钮灰化 + markExhausted
+点灰按钮 → 占位广告 dialog(3s)→ POST /ads/unlock(DevVerifier 放行 + nonce)
+  → 服务端 ad_bonus_minutes += 10(封顶 120)→ 返回 granted=10, remaining=10
+  → 刷新 /me(quota_cap_min=20, quota_today_min=10)→ 按钮恢复可连
+再次连接 → ConnectNode remaining=allowance−used → TTL=remaining(服务端硬切断兜底)
+
+

桌面:同样倒计时 + 到点切断 + 灰按钮,但点击弹“去移动端看广告或升级会员”,无加时路径(硬 10 分钟/天)。

+ +

验证

+
    +
  • 后端单测:AddAdBonusMinutes 累加+封顶(SQLite 实库);TodaySummary cap/remaining;UnlockAd 走 DevVerifier 加时;ConnectNode remaining≤0 拒 / TTL=remaining(集成测试)。
  • +
  • 客户端:flutter analyze + flutter test(quota 倒计时/耗尽/加时;额度卡三态;/me 契约含 quota_cap_min)。
  • +
  • 真机:免费连接→倒计时→到 0 自动断+灰按钮;点灰→移动弹占位广告→+10 分钟→恢复可连;桌面到点→灰+升级提示(无广告);同账户两设备共享同一剩余。
  • +
+ +

不在本轮

+
    +
  • 真 AdMob/激励视频 SDK 接入(DevVerifier 占位替换)。
  • +
  • 广告频次风控细化(现仅每日封顶 adDailyBonusCeiling)。
  • +
  • 客户端倒计时与服务端 minutes_used 聚合延迟的精确对账(以服务端 TTL 硬切断兜底)。
  • +
+ +
+ + diff --git a/docs/frontend-ds-refactor-plan.html b/docs/frontend-ds-refactor-plan.html new file mode 100644 index 0000000..45632cf --- /dev/null +++ b/docs/frontend-ds-refactor-plan.html @@ -0,0 +1,163 @@ + + + + + +前端设计系统治理重构(ds-flow)· 实现计划 + + + +
+ +

← 文档索引

+

前端设计系统治理重构 ds-flow

+

用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源 · 代码镜像 · 静态闸拦漂移 · 双级像素验收兜底」

+ +
+阅读版;执行真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md(含 checkbox)。
+关键前提:pangolin 不是从零 bootstrap,已约 65% 达标——token 单源(含暗色)、Flutter codegen + drift 闸、golden + CI 闸、pre-commit(写好未启用)都在。本计划是补缺口 + Web 共享原子层去重,非推倒重来。主题保持 light / dark 双主题。 +
+ +

现状盘点

+
+ + + + + + + + + + + +
维度现状缺口
Token 单源✓ colors_and_type.css(含 [data-theme=dark])迁为 ds-flow 原型结构
Flutter codegen / 主题层✓ gen 层/实现层分离 + drift 闸
Flutter UI 硬编码✓ 零裸 hex(唯 1 处裸色 adaptive_menu)清 1 处
Flutter golden✓ 36 张 + harness + 真字体 + CI6 张 failure;缺 CJK 测试字体;覆盖不全
website / usercenter✓ 179 / 171 处 var(--token)零前端测试
跨端共享组件✗ 下拉/按钮/卡片两端各写一遍抽 atoms 对齐
原型三件套⚠ 有 _ds_manifest/preview/ui_kits缺 atoms.css / icons.js / index.html
静态闸✓ redline / analyze+test / codegen-drift / golden✗ 硬编码色扫描 / fidelity / Web 同源
pre-commit⚠ .githooks 写好默认未启用
+
+ +

已定决策

+
    +
  • Web 去重:两端各自实现 + 同源闸(不建跨端组件包;成本低、风险小,符合 jiu 取舍)
  • +
  • ui_kits:jsx/css 端原型收敛为纯 HTML 原型并删副本(消除与「禁向 design/ 提组件代码副本」的冲突)
  • +
  • 节奏先定稿本计划,再逐刀执行(每刀 commit,tier-1 大改走确认闸)
  • +
+ +

执行阶段(6 阶段)

+ +
+

Phase 0 — 更新 CLAUDE.md + 计划落库

+
    +
  • CLAUDE.md 补「前端设计系统治理(ds-flow)」章节:原型单源位置、codegen 命令、L1/L2/L3 三层规则、四道静态闸 +「违规谁拦」对照表、golden/fidelity 双闸定位
  • +
  • 本计划 .md 定稿 + HTML 阅读版 + 登记 docs/index.html
  • +
  • /todo 建 tier-1 条目 + 6 子任务
  • +
+
+ +
+

Phase 1 — 原型单源三件套(design/prototype/)

+

把散在 ui_kits / preview / _ds_manifest.json 的东西收敛为 ds-flow 标准三件套。

+
    +
  • serve.mjs 照搬 jiu(零依赖热重载)
  • +
  • tokens.css:现有 token 数值不变,重排为「基础 :root 标量 + [data-theme=dark] 颜色覆盖」结构
  • +
  • atoms.css:按钮/卡片/输入/语言下拉/徽章/状态药丸公用原子(只引 var(--token))
  • +
  • icons.js:SVG sprite 单源,收敛 website / usercenter / Flutter 三处图标集
  • +
  • index.html:活登记页——light/dark 切换 + 声明式色板 + 全组件/图标展示卡(每 atom 必登记)
  • +
  • ui_kits jsx 副本提炼后删除;屏级布局参考迁 prototype/screens/
  • +
+
+ +
+

Phase 2 — Web token 升为一等公民 + 同源闸

+
    +
  • 两端 token 落点统一指向原型 tokens.css(website→tokens.gen.css / usercenter→public/colors_and_type.css)
  • +
  • tools/check-l1-sync.mjs(照搬 jiu 裁剪):website / usercenter token 值逐值同源 + icons 同集 + Web hex 白名单扫描
  • +
  • build-tokens 幂等:重跑零 diff(纳入 CI)
  • +
+
+ +
+

Phase 3 — Web 共享原子层对齐 工作量最大

+

各自实现 + 同源闸:两端对齐同一 atoms.css,靠闸防漂移。

+
    +
  • 抽公共原子:langsel / button / card / input / badge / pill → atoms.css canonical
  • +
  • website:website.css/site-extra.css 对齐 atoms 语义,非白/黑/logo 硬编码清零
  • +
  • usercenter:shared.tsx 的 card/input/LangSeg 对齐 atoms 语义,13 处硬编码核对
  • +
  • 两端 langsel 一致性纳入登记;更新 CONTRACT.md(Web 原子清单 + 屏级三态台账)
  • +
+
+ +
+

Phase 4 — Flutter 收尾 + golden 补齐

+
    +
  • 清 adaptive_menu.dart 唯 1 处裸色 → token
  • +
  • 测试字体补 CJK 子集(make-cjk-subset.sh → client/test/fonts + flutter_test_config 注册)
  • +
  • 处理现存 6 张 failure diff,逐张确认后 --update-goldens 重录
  • +
  • golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵
  • +
  • harness 对齐 jiu:多主题循环 + 钉死 viewport/dpr + ProviderScope 固定数据
  • +
+
+ +
+

Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检

+
    +
  • 硬编码色扫描:Flutter check_ds_code.mjs(含 --changed)+ Web hex 并入 check-l1-sync
  • +
  • 原型校验 check-ds.mjs(照搬 jiu 12 道,按 pangolin 断点/主题裁剪)
  • +
  • CI 串起来:原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(补 mobile+主题)
  • +
  • 启用 pre-commit:install-hooks 纳入文档,增挂 check-ds --changed(条件触发)
  • +
  • fidelity 像素闸(本地体检,不进 CI):screens.mjs + fidelity.mjs,逐屏阈值=实测残差+2pp
  • +
  • 全景文档 docs/frontend-overview.html(照搬 jiu 十节)+ 登记索引
  • +
+
+ +

Verification(端到端)

+
    +
  • 原型:serve.mjs 逐屏目检 light/dark;check-ds 12 道全绿
  • +
  • 同源:check-l1-sync 全绿(tokens 逐值 / icons 同集 / Web hex 白名单)
  • +
  • Flutter:analyze + test(golden ×双主题);check_ds_code 绿;codegen 重跑零 diff
  • +
  • Web:两端 build 通过;token 同源绿;langsel/button/card 对齐 atoms
  • +
  • fidelity:逐屏残差在阈内(首次校准记录实测值)
  • +
  • 闸生效:install-hooks 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下
  • +
+ +

不在本轮

+
    +
  • 新功能 / 新屏开发(本轮是治理重构)
  • +
  • iOS/iPad 专属布局深度优化(响应式已覆盖)
  • +
  • 三主题扩展(保持 light/dark)
  • +
+ +

真相源(含 checkbox 执行跟踪):docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md

+ +
+ + diff --git a/docs/frontend-overview.html b/docs/frontend-overview.html new file mode 100644 index 0000000..252c6b4 --- /dev/null +++ b/docs/frontend-overview.html @@ -0,0 +1,108 @@ + + + + + +Pangolin 前端全景(ds-flow 设计系统治理) + + + +
+

← 文档索引

+

Pangolin 前端全景 ds-flow

+

设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸拦在提交/CI 前,还原由 golden 双主题验收兜底。

+ +
+Flutter 五端(macOS/iOS/iPad/Android/Windows,共享 client/lib/)+ 官网 web/website/ + 用户中心 web/usercenter/。主题:light / dark 双主题。治理落地见 实现计划。 +
+ +

① 一次 UI 改动的标准路径

+
加/改令牌 → 只改 design/prototype/tokens.css → codegen(Flutter gen_flutter_tokens / Web gen:tokens) +加/改原子 → design/prototype/atoms.css 定义 + index.html 登记 → 落 canonical 实现(client/lib/widgets 或 web/*) +加/改图标 → design/prototype/icons.js sprite 登记 → 三端只从此集取 +改完自检 → check-codegen-drift · check-l1-sync · check_ds_code · check-ds 四道闸 + flutter test(golden) +评审原型 → node design/prototype/serve.mjs → http://localhost:5180/(给 URL,不截图)
+ +

② 目录地图

+
+ + + + + + +
位置角色
原型单源(L1 真源)design/prototype/tokens.css · atoms.css · icons.js · index.html 登记页 · serve.mjs
令牌 codegendesign/codegen/gen_flutter_tokens.mjs · web/*/scripts/build-tokens.mjstokens.css → Flutter .gen.dart / Web token CSS
Flutter 实现client/lib/{pangolin_theme.dart, widgets/, screens/, shell/}实现层 + canonical 组件;五端共享,响应式非平台分叉
Web 实现web/website/(Astro)· web/usercenter/(Next 静态)各自实现,对齐 atoms.css,靠同源闸防漂移
历史参考design/ui_kits/ DEPRECATED旧整屏原型,仅历史参考,勿当真源
+ +

③ 三层真相源模型

+
    +
  • L1 设计系统:新增颜色/组件/图标——先登记原型,再同步代码,无例外。
  • +
  • L2 屏级三态(台账 design/CONTRACT.md §6):同步=入 fidelity;快照=原型退役、golden+契约为准;代码先行=无原型屏、golden 唯一基准。当前:Flutter 屏=快照,Web 屏=代码先行,原子层=同步。
  • +
  • L3 新屏/改版:design-first——原型 → serve 评审 → 契约 → 实现 → 验收 → 入同步态。
  • +
+ +

④ 令牌 codegen(颜色单源落地)

+

design/prototype/tokens.css(base :root 标量 + [data-theme=dark] 颜色覆盖)是唯一被解析的真源。colors_and_type.css 已降级为薄 @import 别名。Flutter 生成 pangolin_tokens.gen.dart(勿手改);Web 由 build-tokens 原样同步(仅移除第三方字体 @import),不重复生成设计决策,靠同源闸逐值校验。

+ +

⑤ 四道静态闸 —「违规谁拦」

+
+ + + + + +
拦什么何时状态
原型校验 design/prototype/tools/check-ds.mjs硬编码色(atoms.css)/未定义 token/字体/图标未走 sprite/原子未登记pre-commit(动原型)+ CI
跨端同源 tools/check-l1-sync.mjsWeb token 值≡原型 · 三端图标⊆原型 sprite · Web 硬编码色pre-commit(动原型/web)+ CI
代码色单源 client/tool/check_ds_code.mjsFlutter 裸 Color(0x)/具名 Colors.xds-ignore 豁免)pre-commit(--changed)+ CI(--strict)
codegen 零 diff ci/check-codegen-drift.sh重生成 token 后 git diff 非空即 failpre-commit + CI
+

CI(.gitea/workflows/ci.yml)的 ds-flow job 串起前三道;codegen-drift job 管第四道。pre-commit(.githooks/pre-commit,一次性 bash ci/install-hooks.sh 启用)跑条件化快子集。

+ +

⑥ 像素验收

+
    +
  • golden(回归自比,已进 CI)client/test/golden/,多主题同渲染器自比,抓串色/漏 token。真字体加载(含 Noto Sans SC 子集,中文不出豆腐块)、钉死 viewport/dpr/动态值(provider override)。基线在权威 Linux 容器生成:bash scripts/update-goldens.sh。当前 34 tests 全绿(components/auth/desktop/tablet × 双主题,tablet 含 zh/en)。
  • +
  • fidelity(保真体检,本地不进 CI) 待建:原型整屏截图 vs Flutter golden pixelmatch。前置:原型需先有整屏 HTML(design/prototype/screens/,属 L3 新屏工作)——当前原型仅原子层,无屏可比,故 fidelity 待整屏落地后建。
  • +
+ +

⑦ 响应式与五端

+

五端共享 client/lib/,UI 无平台分叉,靠 core/responsive/form_factor.dartmobile/tablet/desktop 按宽度+平台判定)。平台差异隔离在 bridge/update/tray 等系统集成层,非 UI。

+ +

⑧ 规则速查(硬红线)

+
    +
  • 颜色只走语义 token;colors_and_type.css 勿加变量(改 prototype/tokens.css)。
  • +
  • 加原子/图标先登记原型再落代码;勿向 design/ 提 Dart/TS 组件副本。
  • +
  • 文案脱敏:禁 VPN/翻墙/科学上网等红线词(ci/scan-redline.sh 守护)。
  • +
  • 硬编码色例外(#fff/#000/品牌 logo 色)加 // ds-ignore: 理由 或列白名单。
  • +
  • 改 UI 提交前:四道闸绿 + flutter test(含 golden);golden 重录随功能 commit 入库。
  • +
+ +

⑨ 文档索引

+
    +
  • 前端设计系统治理重构 · 实现计划(真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md
  • +
  • CLAUDE.md「## 前端设计系统治理(ds-flow)」· design/CLAUDE.md(设计铁律 + 真源对照)· design/CONTRACT.md §6(Web 原子清单 + 屏级台账)
  • +
+ +

最后更新随治理重构(Phase 0–5)。fidelity(⑥)与 mobile golden 扩容为后续项。

+
+ + diff --git a/docs/index.html b/docs/index.html index 0ea2aba..d310d48 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,6 +44,21 @@

设计方案 / Specs

+ +
CI/CD 全流程(tag 触发编译/发版/部署)HTML
+
#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。
+
docs/cicd-design.html · 真相源 docs/superpowers/specs/2026-07-05-cicd-design.md
+
+ +
联系我们 · 渠道二级页(Telegram 频道/群组,DB 配置)HTML
+
点 Telegram 进二级页,按 kind 分组(频道/群组/Bot)列多条链接,含 @handle/说明/认证/「打开·加入」动作,全渠道(含邮箱/发卡)统一一张 contact_link 表(靠 url 协议区分 mailto/https/tg)。独立 GET /v1/contact。规则:平台 >1 条链接才进二级,否则点击直达。LINE 同构但先灰置「即将开放」(registry comingSoon 开关,配好即去灰)。不展示成员数、群组=加入。含可点原型。
+
docs/contact-telegram-channels-design.html
+
+ +
免费版 10 分钟卡控 + 累加式看广告加时 HTML
+
免费版真卡控(账户级/全设备共享):连接期倒计时 + 到点自动切断 + 耗尽按钮灰化 + 点击弹广告看完 +10 分钟(累加,每日封顶 120)。桌面硬 10 分钟不可延。服务端 ad_bonus_minutes 累加模型 + ConnectNode 按 remaining 卡控 + TTL 硬切断;占位 DevVerifier。#21。
+
docs/free-quota-ad.html
+
设备数量限制 + 超限 UX HTML
真正启用套餐设备上限(free 1 / pro 3 / team 10):卡在登录(非硬拒登,返回 device_limit 信号)+ 选择移除/一键踢最旧 + 服务端按 last_seen 自动清理久不活跃。复用现成 DeleteDevice/ResolvePlan。无 DB schema 变更。#16。
@@ -71,6 +86,21 @@

实现计划 / Plans

+ +
前端设计系统治理重构(ds-flow 全端)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。非从零 bootstrap(已约 65% 达标):补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。
+
docs/frontend-ds-refactor-plan.html · 真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md
+
+ +
控制面 TLS(Cloudflare Tunnel 前置)实现计划 HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md(含 checkbox)。把控制面 API 从明文 http://103.119.13.48:8080 迁到 https://api.yanmeiai.com(cloudflared 出站隧道前置,源站仅绑 127.0.0.1,数据面 sing-box REALITY :443 全程不动)。6 任务:CF Tunnel 供给 → 客户端切 https/Android 去明文 → CI 守护禁明文 → PANGOLIN_PUBLIC_URL 切 https → 退役明文口(收 loopback,带上线顺序闸)→ 文档。
+
docs/control-plane-tls-tunnel.html · 真相源 docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md
+
+ +
CI/CD 全流程 实现计划(#30)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-05-cicd.md(含 checkbox)。三期 11 任务:Phase1 基座+官网+服务端(无签名可立即上线,服务端固化 F3/F4 备份/迁移/回滚) → Phase2 Android(接 release keystore 签名,解锁下载链接) → Phase3 macOS 公证 dmg + Windows 安装包。runner 混合 nas/mac/windows;密钥已建(对齐 jiu)。设计见 cicd-design.html。
+
docs/cicd-plan.html · 真相源 docs/superpowers/plans/2026-07-05-cicd.md
+
设备 & 会话管理 + 每设备流量归因 实现计划(P1–P6)HTML
阅读版;执行真相源为 docs/superpowers/plans/2026-06-29-device-session-management.md(含 checkbox)。P1 设备注册打通 → P2 sessions表+在线/最后登录 → P3 强制退出/清除 → P4 每设备流量 → P5 2FA信任(future) → P6 UI重做。
@@ -103,6 +133,16 @@
Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 wangjia/codes 本地路径依赖会阻断异机构建,登记为部署前置阻断项。
docs/pay-v2-integration-delivery.html
+ +
前端全景(ds-flow 设计系统治理)HTML
+
Flutter 五端 + 官网 + 用户中心的设计系统治理全景:一次 UI 改动标准路径、目录地图、三层真相源模型、令牌 codegen、四道静态闸「违规谁拦」、像素验收(golden 双主题 + fidelity 待建)、响应式五端、规则速查。原型单源 design/prototype/(tokens/atoms/icons/index.html)、check-ds/check-l1-sync/check_ds_code/codegen-drift 四闸进 CI、golden 全量 34 绿含 CJK。
+
docs/frontend-overview.html
+
+ +
全栈设计审查 2026-07(前端/后端/数据库)HTML
+
核心链路精读式审查,13 项发现分 P0/P1/P2:明文 HTTP、SQLite 零备份(P0);同机换账号 403 死结、disconnect 撤错凭证、用量取走即焚、Redis 重启全员掉线、argon2id OOM(P1);留存/UTC 日界/三时钟口径等(P2)。附「做得好的」与处理顺序建议。
+
docs/code-review-2026-07.html
+
开发规范 · 可测试性五支柱 HTML
「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。
diff --git a/docs/superpowers/plans/2026-07-05-cicd.md b/docs/superpowers/plans/2026-07-05-cicd.md new file mode 100644 index 0000000..c0dd834 --- /dev/null +++ b/docs/superpowers/plans/2026-07-05-cicd.md @@ -0,0 +1,193 @@ +# Pangolin CI/CD 全流程 Implementation Plan(#30) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** tag 触发的「编译 → 测试 → 发版(Gitea release)→ 部署」全自动流水线,覆盖官网 + 服务端 + Android/macOS/Windows 客户端。 + +**Architecture:** 镜像 jiu 的 `scripts/ci/*.sh`(逻辑)+ `.gitea/workflows/*.yml`(编排)。逻辑放脚本便于本地复现,工作流只调脚本。runner 混合:nas(官网+服务端,容器化 `node:20`/`golang:1.25`)、mac(Android+macOS)、windows(Windows)。 + +**Tech Stack:** Gitea Actions(act_runner,host-mode)、Bash、Astro/Node、Go 交叉编译、Flutter、gomobile libbox、Xcode notarytool、Inno Setup、Forgejo release API。 + +## Global Constraints + +- 参考真相源:`docs/superpowers/specs/2026-07-05-cicd-design.md`;jiu 的 `~/code/jiu/.gitea/workflows/*` + `~/code/jiu/scripts/ci/*`(proven,copy+adapt)。 +- runner label:`nas` / `mac` / `windows`;nas 上每 job `docker run` 官方镜像(`node:20`、`golang:1.25`),不装宿主工具链。 +- 国内镜像:`GOPROXY=https://goproxy.cn,direct`;`PUB_HOSTED_URL=https://pub.flutter-io.cn`;`FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn`。 +- Secrets(已建,命名对齐 jiu):账户级 `FORGEJO_TOKEN`/`FORGEJO_URL`/`MACOS_DEVELOPER_ID_CERT_P12_BASE64`/`MACOS_DEVELOPER_ID_CERT_PASSWORD`/`APPSTORE_API_KEY_P8_BASE64`/`APPSTORE_API_KEY_ID`/`APPSTORE_API_ISSUER_ID`;pangolin 仓库级 `DEPLOY_SSH_KEY`/`ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`/`MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`。 +- 客户端铁律:macOS 每次构建递增 `CURRENT_PROJECT_VERSION`;Android NDK≥28、gomobile JDK17、libbox 包名 `io.nekohasekai.libbox`。 +- Bash:禁 `$()` 命令替换(拆分/管道);提交 footer 带 `Co-Authored-By: Claude Opus 4.8 `。 +- **CI 脚本的「测试」= `workflow_dispatch` 手动触发跑一遍 + 观察产物/部署结果**(非经典单元 TDD);每条流水线先手动 dispatch 跑通再依赖 tag。 +- 部署机 pangolin1 别名 `pangolin1`(103.119.13.48);官网域名 `pangolin.yanmeiai.com`。 + +--- + +# Phase 1 —— 基座 + 官网 + 服务端(无签名,可立即上线) + +### Task 1: `scripts/ci/` 基座(env + forgejo 库 + 通知) + +**Files:** +- Create: `scripts/ci/_env.sh`、`scripts/ci/lib-forgejo.sh`、`scripts/ci/notify.sh` +- 参照:`~/code/jiu/scripts/ci/_env.sh`、`~/code/jiu/scripts/ci/lib-forgejo.sh`、`~/code/jiu/scripts/ci/notify.sh` + +**Interfaces:** +- Produces:`_env.sh` 导出 `GOPROXY`/`PUB_HOSTED_URL`/`FLUTTER_STORAGE_BASE_URL` + `ver_from_tag `(解析 `server-v1.2.3` → `1.2.3`);`lib-forgejo.sh` 提供 `forgejo_release_ensure `、`forgejo_upload_asset <tag> <file>`(用 `FORGEJO_TOKEN`/`FORGEJO_URL`,curl+API);`notify.sh` 提供 `notify_ok`/`notify_fail`。 + +- [ ] Step 1:抄 jiu 三个脚本到 `scripts/ci/`,把 ali/jiu 专属值替换为 pangolin(仓库名、域名);`ver_from_tag` 用 `${ref#refs/tags/${prefix}-v}` 参数展开(不 `$()`)。 +- [ ] Step 2:`chmod +x scripts/ci/*.sh`;本地 `bash -n` 语法检查每个脚本。Expected:无输出(语法 OK)。 +- [ ] Step 3:`shellcheck scripts/ci/*.sh`。Expected:0 告警(或仅可接受的 info)。 +- [ ] Step 4:Commit `feat(ci): scripts/ci 基座(_env/lib-forgejo/notify)`。 + +### Task 2: checks 工作流(保留现有) + +**Files:** Modify(可选 rename): `.gitea/workflows/ci.yml` + +- [ ] Step 1:确认现有 `ci.yml`(nas,shellcheck/openapi/redline/flutter analyze+test/go test)仍覆盖需求;把新增的 `scripts/ci/*.sh` 纳入 shellcheck job 的扫描路径。 +- [ ] Step 2:push 一个无关小改到分支,观察 checks 全绿。Expected:所有 job pass。 +- [ ] Step 3:Commit(若有改动)`ci(checks): shellcheck 覆盖 scripts/ci`。 + +### Task 3: 官网发布(compile + deploy + workflow) + +**Files:** +- Create: `scripts/ci/compile-site.sh`、`scripts/ci/deploy-site.sh`、`.gitea/workflows/deploy-site.yml` +- 参照:`~/code/jiu/scripts/ci/compile-site.sh`、`deploy-site.sh`、`.gitea/workflows/deploy-site.yml` + +**Interfaces:** +- Consumes:`_env.sh`;secret `DEPLOY_SSH_KEY`。 +- Produces:`pangolin.yanmeiai.com` 静态站上线。 + +**前置(基础设施,需先做 / 确认——改机器前问用户):** +- pangolin1 上装 nginx(或 caddy),配 `pangolin.yanmeiai.com` vhost,web 根如 `/var/www/pangolin-site`。 +- CF DNS:`pangolin.yanmeiai.com` A/CNAME → 103.119.13.48(用 `cf-api`,记 baize)。 +- TLS:先 HTTP 起,证书并入 #25 或用 CF proxy 橙云。 + +- [ ] Step 1:写 `compile-site.sh` —— 在 `node:20` 容器内 `cd web/website && npm ci && SITE_URL=https://pangolin.yanmeiai.com npm run build`,产物 `web/website/dist/`。 +- [ ] Step 2:写 `deploy-site.sh` —— 用 `DEPLOY_SSH_KEY` 起 ssh agent,`rsync -az --delete web/website/dist/ pangolin1:/var/www/pangolin-site/`;远端 `nginx -s reload` 非必需(静态文件即时生效)。 +- [ ] Step 3:写 `deploy-site.yml` —— `on.push.tags: ['site-v[0-9]*.[0-9]*.[0-9]*']` + `workflow_dispatch`;`runs-on: nas`;并发组 `deploy-site`;steps: checkout → `docker run --rm -v $PWD:/w -w /w node:20 bash scripts/ci/compile-site.sh` → `bash scripts/ci/deploy-site.sh`。 +- [ ] Step 4:**手动验证** —— `workflow_dispatch` 触发 deploy-site;`curl -I https://pangolin.yanmeiai.com/` 返回 200,页面 canonical 正确。Expected:站点可访问。 +- [ ] Step 5:Commit `feat(ci): 官网 site-v* 构建+部署到 pangolin.yanmeiai.com`。 + +### Task 4: 服务端发布(compile + release + deploy + workflow) + +**Files:** +- Create: `scripts/ci/compile-backend.sh`、`scripts/ci/release-server.sh`、`scripts/ci/deploy-server.sh`、`.gitea/workflows/deploy-server.yml` + +**Interfaces:** +- Consumes:`_env.sh`、`lib-forgejo.sh`;secret `DEPLOY_SSH_KEY`、`FORGEJO_TOKEN`/`FORGEJO_URL`。 +- Produces:pangolin1 上 `pangolin-server`/`pangolin-agent`/`pangolin-migrate` 更新到 tag 版本,migrate 已应用。 + +- [ ] Step 1:写 `compile-backend.sh` —— `golang:1.25` 容器内 `cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-server ./cmd/server`(同样出 agent、migrate);产物 `server/out/`。 +- [ ] Step 2:写 `release-server.sh` —— `forgejo_release_ensure "$TAG" "server $TAG"` + 逐个 `forgejo_upload_asset`。 +- [ ] Step 3:写 `deploy-server.sh`(固化 F3/F4 手动次序,**带回滚**): +```bash +#!/usr/bin/env bash +set -euo pipefail +DB=/var/lib/pangolin/pangolin.db; BIN=/usr/local/bin; TAG="$1" +scp server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate pangolin1:/tmp/ +ssh pangolin1 "bash -s" <<REMOTE +set -euo pipefail +systemctl stop pangolin-server +runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);' +cp -p "$DB" "$DB.bak-pre-$TAG" +if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then + echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1 +fi +cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true +install -m755 /tmp/pangolin-server "$BIN/pangolin-server" +install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent" +install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate" +systemctl start pangolin-server +systemctl is-active pangolin-server +REMOTE +curl -fsS -m 10 --retry 5 --retry-connrefused http://103.119.13.48:8080/healthz >/dev/null && echo "healthz OK" +``` +- [ ] Step 4:写 `deploy-server.yml` —— tag `server-v*` + dispatch;`runs-on: nas`;并发组 `deploy-server`;steps: checkout → compile(golang 容器)→ `test.sh server`(见 Task 5)→ `release-server.sh` → `deploy-server.sh $VER`。 +- [ ] Step 5:**手动验证** —— 打 tag `server-v0.0.1-ci`(或 dispatch)→ 观察:migrate 版本前进、`/healthz` 200、`sqlite3 nodes` 行数守恒(同 F3 核对法)。Expected:部署成功、无数据丢失。 +- [ ] Step 6:Commit `feat(ci): 服务端 server-v* 编译+release+部署(备份/迁移/回滚)`。 + +### Task 5: `scripts/ci/test.sh` + +**Files:** Create `scripts/ci/test.sh` + +- [ ] Step 1:`test.sh server` → `golang:1.25` 容器 `cd server && go test ./...`;`test.sh client` → `flutter test`(容器或 nas flutter)。 +- [ ] Step 2:接入 deploy-server.yml 的 test 步骤;dispatch 跑通。Expected:go test 全绿才继续部署。 +- [ ] Step 3:Commit `ci: test.sh(go test / flutter test)`。 + +--- + +# Phase 2 —— D Android(解锁官网下载链接) + +### Task 6: Android gradle 接 release 签名 + +**Files:** Modify `client/android/app/build.gradle(.kts)`、Create `client/android/keystore.properties`(gitignore,占位) + +**Interfaces:** Consumes secrets `ANDROID_KEYSTORE_BASE64`/`ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/`ANDROID_KEY_PASSWORD`。 + +- [ ] Step 1:`build.gradle` 加 `signingConfigs.release`,从环境变量/`keystore.properties` 读 keystore 路径与三密码;`buildTypes.release.signingConfig = signingConfigs.release`。参照 jiu 的 android 签名接法。 +- [ ] Step 2:本机用真 keystore(你已建)`flutter build apk --release` 验证签名生效:`apksigner verify --print-certs build/app/outputs/flutter-apk/app-release.apk` 显示 CN=Pangolin。Expected:release 签名(非 debug)。 +- [ ] Step 3:Commit `build(android): release keystore 签名接线`。 + +### Task 7: Android CI(compile + release + workflow) + +**Files:** Create `scripts/ci/compile-android.sh`、`scripts/ci/release-client.sh`、`.gitea/workflows/build-android.yml`;参照 jiu `compile-android.sh`/`release-client.sh`/`deploy-client.yml`。 + +- [ ] Step 1:`compile-android.sh` —— `bash scripts/build-libbox.sh android`(JDK17/NDK≥28)→ 把 secrets 落成 keystore 文件 + `keystore.properties` → `flutter build apk --release --split-per-abi --dart-define=PANGOLIN_API_URL=...` → 产物 `app-arm64-v8a-release.apk`。 +- [ ] Step 2:`release-client.sh` —— `forgejo_release_ensure client-$VER` + 上传该端资产(多端共用一个 `client-v*` release,各自 upload)。 +- [ ] Step 3:`build-android.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;steps:provision-mac → compile-android → release-client。 +- [ ] Step 4:**手动验证** —— dispatch → release 出现 arm64 apk → 真机 `adb install -r` 成功、能连。Expected:签名 apk 可装可用。 +- [ ] Step 5:Commit `feat(ci): Android client-v* 构建+release`。 + +### Task 8: 官网下载链接接 Android release + +**Files:** Modify `web/website/src/config/site.ts`(加 `downloads.android`)、`web/website/src/components/Download.astro`。 + +- [ ] Step 1:`site.ts` 加 `downloads: { android: '<Forgejo latest-download 稳定 URL 或构建期烘焙>' }`;`Download.astro` android 按钮 `href={SITE.downloads.android}`。先探 Forgejo 是否支持 `/releases/latest/download/<asset>`;不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` 版本注入。 +- [ ] Step 2:重部署官网,点 Android 下载按钮落到最新 apk。Expected:下载可用。 +- [ ] Step 3:Commit `feat(website): Android 下载按钮接 release 资产`;更新 todo 子任务 30A(Android 部分)。 + +--- + +# Phase 3 —— E macOS + F Windows + +### Task 9: macOS CI(签名+公证 dmg) + +**Files:** Create `scripts/ci/compile-macos.sh`、`.gitea/workflows/build-macos.yml`;参照 jiu `compile-macos.sh`(证书导入 + notarytool 部分可几乎直接复用)。 + +**Interfaces:** Consumes 账户级 Apple secrets + 仓库级 `MACOS_APP_PROVISION_PROFILE_BASE64`/`MACOS_SYSEXT_PROVISION_PROFILE_BASE64`。 + +- [ ] Step 1:`compile-macos.sh` —— 建临时 keychain,`MACOS_DEVELOPER_ID_CERT_P12_BASE64` 解码导入(复用 jiu)→ 两个 provisioning profile 解码装入 `~/Library/MobileDevice/Provisioning Profiles/` → 递增 `CURRENT_PROJECT_VERSION` → Xcode Developer ID 构建 app+sysext(`scripts/local_test.sh build` 逻辑)→ `notarytool submit --key <p8> --key-id $APPSTORE_API_KEY_ID --issuer $APPSTORE_API_ISSUER_ID --wait` → `stapler` → 打 dmg。 +- [ ] Step 2:`build-macos.yml` —— tag `client-v*` + dispatch;`runs-on: mac`;provision → compile-macos → release-client(上传 dmg)。 +- [ ] Step 3:**手动验证** —— dispatch → dmg 出现 → 另一台 mac `spctl -a -vv` 通过、`stapler validate` OK、装上能连。Expected:公证 dmg 可分发。 +- [ ] Step 4:Commit `feat(ci): macOS client-v* 签名+公证 dmg`;下载链接接 macOS。 + +### Task 10: Windows CI(exe/installer) + +**Files:** Create `scripts/ci/compile-windows.sh`(或 .ps1)、`.gitea/workflows/build-windows.yml`;参照 jiu `compile-windows.sh`/`install-innosetup.ps1`/`build-windows.yml`。 + +- [ ] Step 1:`compile-windows.sh` —— `flutter build windows --release --dart-define=PANGOLIN_API_URL=...` → Inno Setup 打安装包(`install-innosetup.ps1` 装 ISCC)。**先不做代码签名**(用户首装 SmartScreen 提示,可接受)。 +- [ ] Step 2:`build-windows.yml` —— tag `client-v*` / `winbuild*` + dispatch;`runs-on: windows`;compile → release-client(上传 exe)。参考记忆:Windows 出包复制到桌面 pangolin 目录。 +- [ ] Step 3:**手动验证** —— dispatch → installer 出现 → windows 机装上能连。Expected:安装包可用。 +- [ ] Step 4:Commit `feat(ci): Windows client-v* 安装包`;下载链接接 Windows。 + +### Task 11: 下载链接全端闭环 + 文档 + +**Files:** Modify `web/website/src/config/site.ts`(downloads.macos/windows)、`docs/index.html`(若有)。 + +- [ ] Step 1:`site.ts.downloads` 补齐 macos/windows;Download.astro 三端按钮全部接 release 资产。 +- [ ] Step 2:客户端发版触发官网重部署(`build-*` 成功后 dispatch `deploy-site`,或用 latest-download URL 免重建)。 +- [ ] Step 3:**验证** —— 官网三端下载按钮均落到最新 release。Commit;关掉 todo 子任务 30A/30B。 + +--- + +## 验证(整体) + +- 每条流水线先 `workflow_dispatch` 跑通再依赖 tag。 +- 服务端:migrate 版本 + `/healthz` + 行数守恒。官网:站点可访问 + canonical + redline。客户端:各端资产可装可连。 +- 下载链接:官网按钮落到最新 release 资产。 + +## 风险(见 spec §9) + +nas 内存(容器化单 job 缓解)、migrate 生产出错(备份+回滚)、keystore/公证凭据(Bitwarden+不落盘)、下载链接刷新(build-* 触发 deploy-site 或 latest-download)。 + +## 不在本计划 + +iOS(G)、备份/容灾(#26)、TLS(#25,官网 TLS 先 CF 橙云或并入 #25)、Windows 代码签名、上架商店。 diff --git a/docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md b/docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md new file mode 100644 index 0000000..d5d7917 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-control-plane-tls-tunnel.md @@ -0,0 +1,499 @@ +# 控制面 TLS(Cloudflare Tunnel 前置)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 pangolin-server 控制面 API 从明文 `http://103.119.13.48:8080` 迁到 `https://api.yanmeiai.com`,经 Cloudflare Tunnel 前置(隐藏源站 IP、白嫖标准 443 + 免证书),数据面 sing-box REALITY:443 完全不动。 + +**Architecture:** pangolin1 上跑 `cloudflared` **出站**隧道(不监听任何入站端口 → 与 sing-box 独占的 :443 零冲突),CF 边缘把 `api.yanmeiai.com` 的请求经隧道回送到 `127.0.0.1:8080`。客户端(Flutter 四端共享 `kApiBaseUrl`)默认改 https 域名;控制面下发给客户端 sing-box 的 `.srs` 规则集下载基址(`PANGOLIN_PUBLIC_URL`)同步改 https。最后一步把 `:8080` 收回 loopback 并关防火墙,彻底退役明文口——该步有上线顺序闸(须待现网客户端更新后再做)。 + +**Tech Stack:** Cloudflare Tunnel(remotely-managed / token 模式)、cloudflared(Debian 12 apt)、systemd、Go(pangolin-server,仅 env 变更零代码)、Flutter/Dart(`api_config.dart`)、Android manifest、Gitea Actions(`deploy-server.sh` 健康检查)、cf-api 封装(Bitwarden token)。 + +## Global Constraints + +- **Bash 禁 `$()` 命令替换**;禁 `set -a`/`set +a`。需捕获输出拆多步或用管道。 +- **凭证走 Bitwarden/rbw**,不写 `~/.env`/明文配置/git。Cloudflare 用 `cf-api` 封装(token 脚本内部从 Bitwarden 取,禁引用 `$CF_API_TOKEN`)。**隧道 token、私钥等 PII/密钥一律不入 git**,只落 `/etc/pangolin/*`(已 gitignore)+ Bitwarden。 +- **改机器(装包/改配置/重启服务)前必须先问用户**(只读操作除外)。本方案 Task 1B/2/5 会 ssh 改 pangolin1 与 CF 账户配置,执行到那几步先征得确认。 +- 回复中文件路径**一律绝对路径**。 +- git commit 结尾附:`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` 与 `Claude-Session:` 行;**不 force-push、不推 main**。 +- CF 账户 id `e585821c881c4cd23bc2530986edea9e`;zone `yanmeiai.com` id `2325730de45276d87180a8b66bd4cca0`。 +- pangolin1 = `103.119.13.48`,ssh 别名 `pangolin1`(root 免密)。数据面 sing-box REALITY 独占入站 `:443`,**不得触碰**。gRPC agent mTLS `:9443` 不动。 +- **上线顺序铁律**:现网客户端硬编码 `http://103.119.13.48:8080`。隧道与 https 端点必须**加法上线**(与旧口并存),客户端切 https 发版后,**Task 5(收 loopback + 关防火墙)才能做**,否则旧客户端全挂。 + +--- + +## File Structure + +**新建:** +- `deploy/single-node/systemd/cloudflared.service` — cloudflared 的 systemd unit(committed,`install -m 644` 到位,token 从 `/etc/pangolin/cloudflared.env` 经 `EnvironmentFile` 注入,不入 unit 本体)。 +- `client/test/unit/api_config_test.dart` — 守护测试:控制面基址必须 https(防回退明文)。 +- `ci/scan-cleartext.sh` — CI 守护:Android release manifest 不得含 `usesCleartextTraffic="true"`。 + +**修改:** +- `deploy/single-node/deploy.sh` — server.env 里 `PANGOLIN_PUBLIC_URL` 改 https(Task 4);`ADDR` 改 loopback + 去掉 ufw 放行 8080(Task 5);新增 cloudflared 安装/enable(Task 1B)。 +- `client/lib/services/api_config.dart:6-9` — `kApiBaseUrl` 默认值改 `https://api.yanmeiai.com`(Task 3)。 +- `client/android/app/src/main/AndroidManifest.xml:30` — 移除 `android:usesCleartextTraffic="true"`(Task 3)。 +- `scripts/ci/deploy-server.sh:57` — 健康检查从「runner 远程 curl `http://IP:8080`」改为 ssh 内本地 `curl http://127.0.0.1:8080/healthz`(Task 5)。 +- `.gitea/workflows/ci.yml` — shellcheck 列表加 `ci/scan-cleartext.sh`;新增 cleartext-scan job(Task 3)。 +- `CLAUDE.md` + `docs/` — 端口/URL 布局更新(Task 6)。 + +--- + +## Task 1: Cloudflare Tunnel 供给(CF 账户侧 + pangolin1 装 cloudflared) + +把隧道建起来、DNS 指过去、cloudflared 在 pangolin1 上连通,`https://api.yanmeiai.com/healthz` 与旧的 `http://103.119.13.48:8080/healthz` **并存可用**(加法,不破坏现网)。 + +**Files:** +- Create: `deploy/single-node/systemd/cloudflared.service` +- Modify: `deploy/single-node/deploy.sh`(安装/enable cloudflared) + +**Interfaces:** +- Produces: 隧道域名 `https://api.yanmeiai.com` → `127.0.0.1:8080`;隧道 token 存于 Bitwarden item `pangolin-cloudflared-tunnel` 字段 `TUNNEL_TOKEN` + pangolin1 `/etc/pangolin/cloudflared.env`。后续 Task 3/4 依赖此域名可达。 + +### 1A — CF 侧:创建隧道 + ingress + DNS(cf-api,只读账户外均属改配置,先确认) + +- [ ] **Step 1: 建 remotely-managed 隧道,取 token** + +先确认 rbw 已解锁(`rbw unlock`)。运行: + +```bash +cf-api -X POST "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel" \ + --data '{"name":"pangolin-api","config_src":"cloudflare"}' +``` + +Expected: JSON `success:true`,`result.id`(隧道 UUID)、`result.token`(base64 长串)。**记下 `result.id` 为 `TUNNEL_ID`,`result.token` 为 `TUNNEL_TOKEN`。token 是密钥,不要落 git/明文文档。** + +- [ ] **Step 2: 配 ingress(hostname → 本机 8080,兜底 404)** + +```bash +cf-api -X PUT "/accounts/e585821c881c4cd23bc2530986edea9e/cfd_tunnel/<TUNNEL_ID>/configurations" \ + --data '{"config":{"ingress":[{"hostname":"api.yanmeiai.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}' +``` + +Expected: `success:true`,`result.config.ingress` 含上面两条。 + +- [ ] **Step 3: 建代理 CNAME `api` → 隧道** + +```bash +cf-api -X POST "/zones/2325730de45276d87180a8b66bd4cca0/dns_records" \ + --data '{"type":"CNAME","name":"api","content":"<TUNNEL_ID>.cfargotunnel.com","proxied":true,"comment":"pangolin 控制面 API(CF Tunnel → pangolin1:8080)"}' +``` + +Expected: `success:true`,`result.name` = `api.yanmeiai.com`,`result.proxied` = true。 + +- [ ] **Step 4: token 存入 Bitwarden(留档)** + +把 `TUNNEL_TOKEN` 存进 Bitwarden item `pangolin-cloudflared-tunnel`(字段 `TUNNEL_TOKEN`)。验证: + +```bash +rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | head -c 12 +``` + +Expected: 打印 token 前 12 字符(证明可取回)。 + +### 1B — pangolin1:装 cloudflared + systemd 常驻(改机器,先确认) + +- [ ] **Step 5: 写 committed systemd unit** + +创建 `deploy/single-node/systemd/cloudflared.service`: + +```ini +[Unit] +Description=Pangolin cloudflared (control-plane API tunnel → 127.0.0.1:8080) +Documentation=https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ +After=network-online.target pangolin-server.service +Wants=network-online.target + +[Service] +Type=notify +# TUNNEL_TOKEN 从此文件注入(不入 unit 本体、不进 ps);cloudflared 自动读取 env TUNNEL_TOKEN。 +EnvironmentFile=/etc/pangolin/cloudflared.env +ExecStart=/usr/local/bin/cloudflared --no-autoupdate tunnel run +Restart=on-failure +RestartSec=5 +# 出站隧道,无需 root:用非特权用户即可(与 pangolin-server 同用户)。 +User=pangolin +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 6: deploy.sh 里安装 cloudflared 二进制 + unit + enable** + +在 `deploy/single-node/deploy.sh` 的 systemd 安装段(现有 `install -m 644 .../pangolin-server.service` 一带,约 251-252 行)后追加。先加安装函数(Debian apt,无 `$()`): + +```bash +# ── cloudflared(控制面 API 出站隧道)────────────────────────────── +if ! command -v cloudflared >/dev/null 2>&1; then + log "安装 cloudflared(Cloudflare apt 源)" + install -m 0755 -d /usr/share/keyrings + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + -o /usr/share/keyrings/cloudflare-main.gpg + echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared bookworm main' \ + > /etc/apt/sources.list.d/cloudflared.list + apt-get update -qq && apt-get install -y -qq cloudflared + # apt 装到 /usr/bin;软链到 unit 期望的 /usr/local/bin(与其他 pangolin 二进制一致)。 + [ -x /usr/local/bin/cloudflared ] || ln -sf "$(command -v cloudflared)" /usr/local/bin/cloudflared +fi +install -m 644 "$HERE/systemd/cloudflared.service" /etc/systemd/system/ +``` + +> 注:上面为示意锚点;`$(command -v cloudflared)` 违反禁 `$()` 规则——落地时改为:`CFD_BIN=/usr/bin/cloudflared` 后 `ln -sf "$CFD_BIN" /usr/local/bin/cloudflared`(apt 固定装到 `/usr/bin`)。 + +- [ ] **Step 7: 在 pangolin1 落 token env 文件 + 起服务**(ssh,改机器,先确认) + +token 经用户剪贴板落地(不经过我、不入 git): + +```bash +# 本机把 token 通过 ssh 写到远端受限权限文件(避免出现在 ps/history): +rbw get pangolin-cloudflared-tunnel --field TUNNEL_TOKEN | \ + ssh pangolin1 'install -m 600 -o pangolin -g pangolin /dev/stdin /etc/pangolin/cloudflared.env.tmp && \ + printf "TUNNEL_TOKEN=" | cat - /etc/pangolin/cloudflared.env.tmp > /etc/pangolin/cloudflared.env && \ + rm -f /etc/pangolin/cloudflared.env.tmp && chmod 600 /etc/pangolin/cloudflared.env' +``` + +> 落地时若上面拼接别扭,改为本机 `printf 'TUNNEL_TOKEN=%s\n' "<token>"` 结果 ssh 管道写入;核心要求:`/etc/pangolin/cloudflared.env` 内容为单行 `TUNNEL_TOKEN=<token>`,mode 600,owner pangolin。 + +装 unit 并启动: + +```bash +scp deploy/single-node/systemd/cloudflared.service pangolin1:/etc/systemd/system/ +ssh pangolin1 'systemctl daemon-reload && systemctl enable --now cloudflared.service && sleep 3 && systemctl is-active cloudflared' +``` + +Expected: `active`。 + +- [ ] **Step 8: 验证隧道连通(加法上线,不破坏旧口)** + +```bash +curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道 OK" +curl -fsS -m 10 http://103.119.13.48:8080/healthz && echo " <= 旧口仍在(预期)" +``` + +Expected: 两条都返回 `/healthz` 成功体。证明 https 端点上线、旧明文口并存(现网客户端不受影响)。 + +- [ ] **Step 9: Commit** + +```bash +git add deploy/single-node/systemd/cloudflared.service deploy/single-node/deploy.sh +git commit -m "feat(deploy): cloudflared 出站隧道前置控制面 API(api.yanmeiai.com→127.0.0.1:8080)" +``` + +--- + +## Task 2: 客户端控制面基址切 https + Android 去明文(含守护测试) + +**Files:** +- Modify: `client/lib/services/api_config.dart:6-9` +- Modify: `client/android/app/src/main/AndroidManifest.xml:30` +- Create: `client/test/unit/api_config_test.dart` + +**Interfaces:** +- Consumes: Task 1 产出的 `https://api.yanmeiai.com`(须已可达)。 +- Produces: 全 Flutter 端(auth/nodes/account/connection providers 共享的)`kApiBaseUrl` 默认 = `https://api.yanmeiai.com`。 + +- [ ] **Step 1: 写守护测试(先失败)** + +创建 `client/test/unit/api_config_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin/services/api_config.dart'; + +void main() { + test('控制面基址默认走 https(禁止回退明文 http)', () { + expect(kApiBaseUrl, startsWith('https://'), + reason: '控制面已迁 CF Tunnel(api.yanmeiai.com);默认值不得是明文 http'); + expect(kApiBaseUrl, isNot(contains('103.119.13.48')), + reason: '不得再硬编码节点 IP 作控制面基址'); + }); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd client && flutter test test/unit/api_config_test.dart` +Expected: FAIL —— 当前默认 `http://103.119.13.48:8080` 两条断言都不满足。 + +- [ ] **Step 3: 改默认值为 https 域名** + +`client/lib/services/api_config.dart:6-9`,把: + +```dart +const String kApiBaseUrl = String.fromEnvironment( + 'PANGOLIN_API_URL', + defaultValue: 'http://103.119.13.48:8080', +); +``` + +改为(保留 `String.fromEnvironment` 让本地联调仍可 `--dart-define` 覆盖,只换默认值并更新注释): + +```dart +// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名; +// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。 +const String kApiBaseUrl = String.fromEnvironment( + 'PANGOLIN_API_URL', + defaultValue: 'https://api.yanmeiai.com', +); +``` + +同时删掉第 5 行「TODO(联调临时)…发版前改回」那条注释(已落地)。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd client && flutter test test/unit/api_config_test.dart` +Expected: PASS。 + +- [ ] **Step 5: 移除 Android 全局明文开关** + +`client/android/app/src/main/AndroidManifest.xml:30`,把 `<application>` 上的: + +``` + android:usesCleartextTraffic="true"><!-- 控制面 API 当前为 http(联调),Android 9+ 默认禁明文,需开;生产改 https 后可去掉 --> +``` + +改为(去掉该属性,闭合标签接到上一属性行;控制面已 https,不再需要明文豁免): + +``` + android:icon="@mipmap/ic_launcher"> +``` + +> iOS/macOS 无 ATS 配置(已确认),https 天然满足 ATS,**无需改任何 plist**。 + +- [ ] **Step 6: analyze + 全量单测** + +Run: `cd client && flutter analyze --no-fatal-infos && flutter test test/unit test/widget test/contract` +Expected: analyze 无 error;测试全绿(含新 `api_config_test`)。 + +- [ ] **Step 7: Commit** + +```bash +git add client/lib/services/api_config.dart client/android/app/src/main/AndroidManifest.xml client/test/unit/api_config_test.dart +git commit -m "feat(client): 控制面基址默认 https://api.yanmeiai.com + 移除 Android 明文开关" +``` + +--- + +## Task 3: CI 守护 —— Android release manifest 禁明文 + +防止将来有人把 `usesCleartextTraffic="true"` 加回来(回退明文)。 + +**Files:** +- Create: `ci/scan-cleartext.sh` +- Modify: `.gitea/workflows/ci.yml`(新增 job + shellcheck 列表) + +- [ ] **Step 1: 写扫描脚本** + +创建 `ci/scan-cleartext.sh`: + +```bash +#!/usr/bin/env bash +# scan-cleartext.sh — 禁止 Android manifest 重新开启全局明文(控制面已 https/CF Tunnel)。 +# usesCleartextTraffic="true" 会让全 app 允许明文 HTTP,退回 #25 之前的不安全态。 +set -euo pipefail + +MANIFEST="client/android/app/src/main/AndroidManifest.xml" +if grep -q 'usesCleartextTraffic="true"' "$MANIFEST"; then + echo "❌ $MANIFEST 含 usesCleartextTraffic=\"true\":控制面已 https,禁止全局明文。" >&2 + echo " 如个别调试域名确需明文,请用 res/xml/network_security_config.xml 按域白名单,勿开全局。" >&2 + exit 1 +fi +echo "✅ Android manifest 未开启全局明文" +``` + +- [ ] **Step 2: 本地跑一遍(应通过,因 Task 2 已移除)** + +Run: `bash ci/scan-cleartext.sh` +Expected: `✅ Android manifest 未开启全局明文`。 + +- [ ] **Step 3: 反向自测(临时加回应失败)** + +Run: +```bash +sed -i.bak 's#android:icon="@mipmap/ic_launcher">#android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true">#' client/android/app/src/main/AndroidManifest.xml +bash ci/scan-cleartext.sh; echo "exit=$?" +mv client/android/app/src/main/AndroidManifest.xml.bak client/android/app/src/main/AndroidManifest.xml +``` +Expected: 打印 ❌ 且 `exit=1`;还原后文件复原。 + +- [ ] **Step 4: 接入 CI** + +`.gitea/workflows/ci.yml`:(a)在 lint job 的「shellcheck CI 脚本」列表(约 42-58 行)加 `/mnt/... ` 对应项前,先把 `ci/scan-cleartext.sh` 纳入 shellcheck——注意该文件在 `ci/` 非 `scripts/ci/`,复用已有的 redline-scan 挂载方式即可;(b)新增 job: + +```yaml + cleartext-scan: + name: Cleartext Scan — Android 禁明文 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: scan Android manifest for global cleartext + run: bash ci/scan-cleartext.sh +``` + +- [ ] **Step 5: Commit** + +```bash +git add ci/scan-cleartext.sh .gitea/workflows/ci.yml +git commit -m "ci: 守护 Android manifest 禁全局明文(#25 控制面已 https)" +``` + +--- + +## Task 4: 服务端 `PANGOLIN_PUBLIC_URL` 切 https(规则集下载基址) + +`PANGOLIN_PUBLIC_URL` 被嵌进**客户端 sing-box 配置**当 `.srs` 分流规则集下载基址(`clientconfig.go:150-161`,`download_detour:"direct"`)。不改的话新客户端仍去 `http://103.119.13.48:8080` 拉。此步与 Task 1 隧道并存,对新旧客户端都安全(URL 由服务端下发,客户端只是照着 GET)。 + +**Files:** +- Modify: `deploy/single-node/deploy.sh:178` + +- [ ] **Step 1: 改 deploy.sh 的 server.env 默认** + +`deploy/single-node/deploy.sh:178`,把: + +``` +PANGOLIN_PUBLIC_URL=http://$VPS_IP:$HTTP_PORT +``` + +改为: + +``` +PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com +``` + +- [ ] **Step 2: 在 pangolin1 应用 + 重启 server**(ssh,改机器,先确认) + +```bash +ssh pangolin1 "sed -i 's#^PANGOLIN_PUBLIC_URL=.*#PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server" +``` + +Expected: `active`。 + +- [ ] **Step 3: 验证下发配置里规则集基址已是 https** + +用一个测试账号取一份客户端配置(经隧道),断言规则集 URL 走 https: + +```bash +curl -fsS -m 10 https://api.yanmeiai.com/v1/rules/geoip-cn.srs -o /dev/null -w '%{http_code}\n' +``` + +Expected: `200`(规则集经隧道可下载)。并在有测试 token 时抓一份 `/v1/...` 客户端配置,确认内嵌 `route.rule_set[].url` 前缀为 `https://api.yanmeiai.com`。 + +- [ ] **Step 4: Commit** + +```bash +git add deploy/single-node/deploy.sh +git commit -m "feat(deploy): PANGOLIN_PUBLIC_URL 改 https://api.yanmeiai.com(客户端规则集走隧道)" +``` + +--- + +## Task 5: 退役明文口 —— 8080 收 loopback + 关防火墙 + 修健康检查 + +> **⚠️ 上线顺序闸:此 Task 会切断外部 `http://103.119.13.48:8080`,只有当现网客户端都已更新到 Task 2 的 https 版本后才能执行。** 执行前与用户确认「旧客户端可弃」。做完后一切经隧道/loopback,数据面 :443 不受影响。 + +**Files:** +- Modify: `deploy/single-node/deploy.sh:167`(ADDR 收 loopback)、`:272-275`(去掉 ufw 放行 8080) +- Modify: `scripts/ci/deploy-server.sh:57`(健康检查改本地) + +- [ ] **Step 1: deploy.sh — ADDR 绑 loopback** + +`deploy/single-node/deploy.sh:167`,把 `ADDR=:$HTTP_PORT` 改为: + +``` +ADDR=127.0.0.1:$HTTP_PORT +``` + +- [ ] **Step 2: deploy.sh — 不再放行 8080(loopback 后无需外开)** + +`deploy/single-node/deploy.sh:272-275` 的 ufw 放行段删除或改注释(8080 已 loopback,外部本就不可达): + +```bash +# 控制面 API 已绑 127.0.0.1(经 cloudflared 隧道对外),不放行 8080/tcp。 +``` + +- [ ] **Step 3: deploy-server.sh — 健康检查改 ssh 内本地 curl** + +`scripts/ci/deploy-server.sh:57`,把 runner 远程: + +``` +curl -fsS -m 10 --retry 5 --retry-connrefused "http://${DEPLOY_HOST}:8080/healthz" >/dev/null && echo "healthz OK" +``` + +改为经隧道校验对外可达 + ssh 内本地兜底(二选一或都留,推荐经隧道最贴近真实客户端路径): + +```bash +$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"' +curl -fsS -m 10 --retry 5 "https://api.yanmeiai.com/healthz" >/dev/null && echo "healthz(tunnel) OK" +``` + +- [ ] **Step 4: 在 pangolin1 应用 loopback 绑定**(ssh,改机器,先确认客户端已迁移) + +```bash +ssh pangolin1 "sed -i 's#^ADDR=.*#ADDR=127.0.0.1:8080#' /etc/pangolin/server.env && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server" +``` + +Expected: `active`。 + +- [ ] **Step 5: 验证明文口已死、隧道仍活** + +```bash +curl -fsS -m 8 http://103.119.13.48:8080/healthz && echo "!! 不该还通" || echo "旧明文口已不可达(预期)" +curl -fsS -m 10 https://api.yanmeiai.com/healthz && echo " <= 隧道仍 OK" +ssh pangolin1 'ss -ltnp | grep ":8080" | grep 127.0.0.1 && echo "8080 已仅 loopback"' +``` + +Expected: 明文口失败;隧道成功;`ss` 显示 8080 仅监听 `127.0.0.1`。 + +- [ ] **Step 6: Commit** + +```bash +git add deploy/single-node/deploy.sh scripts/ci/deploy-server.sh +git commit -m "feat(deploy): 8080 收 loopback + 关 8080 防火墙 + 健康检查改本地/隧道(退役明文控制口)" +``` + +--- + +## Task 6: 文档更新(端口/URL 布局) + +**Files:** +- Modify: `CLAUDE.md`(项目根,worktree 内那份)— 端口布局说明 +- Modify: `docs/index.html` — 登记本方案 HTML 阅读版 + +- [ ] **Step 1: 更新 CLAUDE.md 端口/URL 描述** + +在 `deploy/ 结构` 或 server 段补一句:控制面 API 对外经 **CF Tunnel** `https://api.yanmeiai.com`(源站 `127.0.0.1:8080`,不外露);数据面 sing-box REALITY 仍独占 `:443`;gRPC agent mTLS `:9443`。 + +- [ ] **Step 2: 生成本方案 HTML 阅读版并登记 index** + +按既有深色 HTML 家族样式,把本 plan 同内容生成 `docs/control-plane-tls-tunnel.html`,登记进 `docs/index.html` 的「实现计划」分类。 + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md docs/control-plane-tls-tunnel.html docs/index.html +git commit -m "docs: 控制面 CF Tunnel/端口布局说明 + 方案 HTML 登记 index" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ CF Tunnel 前置控制面 → Task 1。 +- ✅ 客户端默认 http→https → Task 2。 +- ✅ Android 移除 usesCleartextTraffic → Task 2(iOS/macOS 无 ATS 需改,已核实)。 +- ✅ server 8080 收 loopback → Task 5(带上线顺序闸)。 +- ✅ `PANGOLIN_PUBLIC_URL` 同步 https(Explore 发现的隐藏依赖)→ Task 4。 +- ✅ 健康检查随 loopback 调整 → Task 5。 +- ✅ 数据面 :443 不动 → 全程未触碰 sing-box(约束显式声明)。 +- ✅ fallback(域名被封退直连 IP)→ 明确拆到 #32,不在本轮。 + +**上线顺序验证:** Task 1(隧道加法)→ Task 4(PUBLIC_URL,新旧客户端皆安全)→ Task 2(客户端切 https,发版)→ **待客户端更新** → Task 5(收口)。Task 3(CI 守护)、Task 6(文档)无顺序耦合。 + +**Placeholder / 一致性:** Task 1B Step 6 的 `$(command -v cloudflared)` 已在注释显式提示落地时改为无 `$()` 写法(禁 `$()` 全局约束);token 全程不落 git;`kApiBaseUrl` 名称跨 Task 2/守护测试一致。 + +## 不在本轮 +- #32 控制面 fallback(CF 域名被 SNI 封 → 客户端退回直连节点 IP 的 https 控制口)。 +- 控制面 API 的 CF WAF/rate-limit 规则精调。 +- usercenter(web/usercenter)也接入同域名 API(其部署属 #30 30A)。 diff --git a/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md b/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md new file mode 100644 index 0000000..b7834e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md @@ -0,0 +1,132 @@ +# 前端设计系统治理重构(ds-flow 落地全端) + +> 用 ds-flow 方法论把 pangolin 全部前端(Flutter 五端 + 官网 website + 用户中心 usercenter) +> 收口到「设计只有一个出生地(原型单源),代码永远是镜像;漂移由静态闸在提交/CI 前拦截, +> 走样由 golden/fidelity 双级像素验收兜底」。 +> +> **关键前提(摸底结论)**:pangolin 不是从零 bootstrap,已约 65% 达标—— +> token 单源(`design/colors_and_type.css` 含 `[data-theme=dark]`)、Flutter codegen + drift 闸、 +> golden + CI 闸、pre-commit(写好未启用)都在。本计划是**补缺口 + Web 共享原子层去重**, +> 不是推倒重来。 +> +> 主题模型:pangolin 用 **light / dark 两主题**(非 jiu 的 a/b/c 三主题),全程保持。 +> +> 已定决策:① Web 两端**各自实现 + 同源闸**(不建跨端共享组件包); +> ② `design/ui_kits/` 的 jsx/css 端原型**收敛为纯 HTML 原型**并删副本; +> ③ **先定稿本计划,再逐刀执行**(每刀 commit)。 +> +> 参考样板:`~/code/jiu`(`design/prototype/` + `tools/` + `client/lib/core/theme/` + `docs/frontend-overview.html`)。 + +--- + +## Phase 0 — 更新 CLAUDE.md + 计划落库 + +- [x] 0.1 CLAUDE.md 新增「## 前端设计系统治理(ds-flow)」章节: + - 原型单源位置(`design/prototype/`:tokens/atoms/icons/index.html 登记簿)+ 只读约定 + - codegen 命令(Flutter `gen_flutter_tokens.mjs`;Web `build-tokens.mjs` 同源) + - 三层治理 L1/L2/L3 规则速查 + - 四道静态闸清单 + 「违规谁拦」对照表(原型校验 / 跨端同源 / 代码色单源 / codegen 零 diff) + - golden(多主题回归自比)/ fidelity(对原型 pixelmatch,本地体检不进 CI)双闸定位 +- [x] 0.2 本 `.md` 定稿 + 生成 HTML 阅读版 `docs/frontend-ds-refactor-plan.html`,登记进 `docs/index.html`「实现计划」 +- [x] 0.3 `/todo` 建 tier-1 条目跟踪本重构,拆 6 个子任务(对应 Phase 1-5 + 收尾) + +--- + +## Phase 1 — 原型单源三件套(design/prototype/) + +把散在 `ui_kits/`(6 端 jsx/css 原型)+ `preview/`(20 规格 HTML)+ `_ds_manifest.json`(登记簿) +的东西收敛成 ds-flow 标准三件套。 + +- [x] 1.1 建 `design/prototype/` 目录;`serve.mjs` 照搬 jiu(零依赖热重载,默认端口按 jiu) +- [x] 1.2 `design/prototype/tokens.css`:从现有 `colors_and_type.css` 迁移/规整为 + 「基础 `:root`(主题无关标量:间距/圆角/字号/字体/阴影/动效)+ `[data-theme=dark]` 颜色覆盖块」结构。 + **保持数值不变**,只重排为 ds-flow 结构;`colors_and_type.css` 作为兼容别名或迁移为薄封装(不破坏现有 codegen) +- [x] 1.3 `design/prototype/atoms.css`:把按钮/卡片/输入/**语言下拉**/徽章/状态药丸等公用原子类沉淀为 + 只引 `var(--token)` 的 CSS(镜像 `design/preview/` 现有规格 + client widgets 实现语义) +- [x] 1.4 `design/prototype/icons.js`:SVG sprite 单源(`<symbol id="i-*">`), + 收敛现有分散图标(website Icon.astro / usercenter icons.tsx / Flutter pangolin_icons.dart 三处的图标集) +- [x] 1.5 `design/prototype/index.html`:活登记页——三…两主题(light/dark)切换 + `data-swatches` 声明式色板 + + 字号梯度 + 圆角/间距/阴影 + 全部公用组件原子展示卡 + 图标库全展示。**每个 atom 必须在此登记** +- [x] 1.6 `design/ui_kits/` 的 jsx/css 端原型:提炼进 prototype 后**删除 jsx 组件副本**(消除与 + 「禁向 design/ 提组件代码副本」的冲突 + 漂移源);保留必要的屏级 HTML 布局参考迁进 `prototype/screens/` +- [x] 1.7 更新/退役 `_ds_manifest.json` + `_ds_bundle.js`:登记簿职责交给 `index.html`, + manifest 若仍被消费则保留为派生产物(记清谁是真源) + +--- + +## Phase 2 — Web token 升为一等公民 + 同源闸 + +现状:Web 的 `build-tokens.mjs` 只是「原样拷 css,删 Google Fonts 行」。升级为受闸守护的同源关系。 + +- [x] 2.1 确认两端 token 落点与生成链:website→`src/styles/tokens.gen.css`、 + usercenter→`public/colors_and_type.css`;源统一指向 `design/prototype/tokens.css`(Phase 1 后) +- [x] 2.2 建 `tools/check-l1-sync.mjs`(照搬 jiu 裁剪): + - ① website `tokens.gen.css` token 值 ≡ 原型 tokens.css(逐值) + - ② usercenter `public/colors_and_type.css` ≡ 原型(逐值) + - ③ icons 同源:website / usercenter / Flutter 三处图标集 ⊆ 原型 icons.js sprite + - ④ Web 硬编码色扫描(白名单 `#fff/#000/logo 固定色`,其余报警) +- [x] 2.3 codegen 幂等:重跑 `build-tokens.mjs` 后 `git diff` 零差异(纳入 CI,见 Phase 5) + +--- + +## Phase 3 — Web 共享原子层对齐(各自实现 + 同源闸)★工作量最大 + +不建跨端组件包;两端各自实现,但都对齐 `design/prototype/atoms.css`,靠闸保证不漂移。 + +- [x] 3.1 抽公共原子清单:langsel(语言下拉,刚修的两套合规范)/ button / card / input / badge / pill。 + 对每个原子在 `atoms.css` 定义 canonical 样式 +- [x] 3.2 website:`website.css` + `site-extra.css` 里的按钮/卡片/下拉 class 对齐 atoms.css 语义, + 残留 `#fff/#000`/logo 外的硬编码色清零(当前业务硬编码 ~30 处,多为可保留的白/黑/logo) +- [x] 3.3 usercenter:`shared.tsx` 的 `card/input/LangSeg` 内联对象对齐 atoms.css 语义; + 残留 13 处硬编码(基本 `#fff`)核对,非白/黑/logo 的清零 +- [x] 3.4 两端 langsel 行为/样式一致性核对(此前刚统一为自定义下拉,纳入 atoms 登记) +- [x] 3.5 更新 `design/CONTRACT.md`:Web 原子清单 + 屏级台账(同步/快照/代码先行三态) + +--- + +## Phase 4 — Flutter 收尾 + golden 补齐 + +Flutter 已很干净(UI 层零裸 hex),只需收尾。 + +- [x] 4.1 清 `client/lib/widgets/adaptive_menu.dart` 唯 1 处裸 Material 色 → 走 token +- [x] 4.2 测试字体补 CJK 子集:用 `tools/fonts/make-cjk-subset.sh` 生成 Noto Sans SC 子集放 + `client/test/fonts/`,`flutter_test_config.dart` 注册——消除 golden 中文与生产渲染差异 +- [x] 4.3 处理现存 6 张 `client/test/golden/failures/` diff:逐张确认「原型对得上」后 `--update-goldens` 重录入库 +- [ ] 4.4 (延后·非阻塞) golden 覆盖扩容:desktop/tablet/mobile 全屏 × light/dark 双主题矩阵 + (现有 `desktop_pages/tablet_pages/components/auth` → 补 mobile + 主题维度) +- [x] 4.5 `client/test/helpers/harness.dart` 对齐 jiu `golden_harness.dart` 手法: + 多主题循环辅助 + 钉死 viewport/dpr + ProviderScope 固定数据(防动态值翻车) + +--- + +## Phase 5 — 静态闸挂满 + 启用 pre-commit + fidelity 体检 + +- [x] 5.1 硬编码色扫描闸: + - Flutter `client/tool/check_ds_code.mjs`(照搬 jiu,含 `--changed` 供 pre-commit):禁 `Color(0x..)`/裸 `Colors.x` + - Web hex 扫描并入 `check-l1-sync.mjs` ④ +- [x] 5.2 原型校验闸 `design/prototype/tools/check-ds.mjs`(照搬 jiu 12 道,按 pangolin 断点/主题裁剪) +- [x] 5.3 CI 串起来(`.gitea/workflows/ci.yml` 增补): + 原型校验 → 跨端同源 → 代码色单源 → codegen 零 diff(已有)→ 测试含 golden(已有,补 mobile+主题) +- [x] 5.4 启用 pre-commit:`ci/install-hooks.sh` 纳入 onboarding 文档 + CLAUDE.md, + `.githooks/pre-commit` 增挂 `check-ds --changed`(只在动了 `design/prototype/` 时跑,轻量条件触发) +- [ ] 5.5 (延后·前置=原型整屏 screens/,属 L3) fidelity 像素闸(本地体检,不进 CI):`tools/screens.mjs` 屏注册表 + `tools/fidelity.mjs` + (原型 Chromium 截图 vs Flutter golden pixelmatch,逐屏阈值=实测残差+2pp,两边统一注入 CJK 字体) +- [x] 5.6 全景文档 `docs/frontend-overview.html`(照搬 jiu 十节):一次 UI 改动标准路径 + 目录地图 + + 三层分治 + 闸全景 + 像素验收体系 + 响应式范式 + 规则速查,登记进 docs/index.html + +--- + +## Verification(端到端) + +- **原型**:`node design/prototype/serve.mjs` 起服务,浏览器逐屏目检 light/dark;`check-ds.mjs` 12 道全绿 +- **同源**:`node tools/check-l1-sync.mjs` 全绿(tokens 逐值 / icons 同集 / Web hex 白名单) +- **Flutter**:`flutter analyze` + `flutter test`(含 golden ×双主题);`check_ds_code.mjs` 全绿;codegen 重跑零 diff +- **Web**:两端 `npm run build` 通过;token 同源闸绿;langsel/button/card 对齐 atoms +- **fidelity**:`node tools/fidelity.mjs` 逐屏残差在阈内(首次校准记录各屏实测值) +- **闸生效**:`ci/install-hooks.sh` 后改一处硬编码色/未登记组件 → pre-commit 或 CI 拦下 + +## 不在本轮 + +- 新功能/新屏开发(本轮是治理重构,不加业务) +- iOS/iPad 专属布局深度优化(响应式已覆盖,超阈再单独立项) +- 三主题扩展(保持 light/dark 双主题) diff --git a/docs/superpowers/specs/2026-07-05-cicd-design.md b/docs/superpowers/specs/2026-07-05-cicd-design.md new file mode 100644 index 0000000..df06af6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-cicd-design.md @@ -0,0 +1,145 @@ +# Pangolin CI/CD 全流程 —— 设计方案(#30) + +> 状态:设计定稿待审 · 日期 2026-07-05 · 范围 A~F(排除 iOS、备份#26、TLS#25) + +## 1. 背景与目标 + +pangolin 现有 CI 仅 `.gitea/workflows/ci.yml`(nas,只校验无部署)+ `web/website/.gitea/workflows/website.yml`。 +服务端部署靠手动(F3/F4 那次我手动 scp+ssh+migrate),客户端出包靠本地脚本,官网未部署, +下载链接是死链。目标:**tag 触发的编译 → 测试 → 发版(Gitea release)→ 部署** 全自动, +参考 jiu 的 `.gitea/workflows` + `scripts/ci/*.sh` 结构,适配 pangolin 的部署目标与产物。 + +## 2. 范围 + +| 子块 | 内容 | +|---|---| +| A 基座 | `scripts/ci/*`(env/provision/test/release/notify/lib-forgejo)+ checks 保留 | +| B 官网 | Astro 构建 → 部署 `pangolin.yanmeiai.com` | +| C 服务端 | 交叉编译 server/agent/migrate → release → ssh pangolin1(备份→migrate→换二进制→重启→健康检查) | +| D Android | apk(arm64,release keystore 签名)→ release 资产 | +| E macOS | 公证 dmg(Developer ID + notarytool)→ release 资产 | +| F Windows | exe/installer(Inno Setup)→ release 资产 | + +**排除**:iOS(G,未来)、SQLite 备份/容灾(#26)、控制面 TLS(#25)。 + +## 3. 已锁定决策 + +| 维度 | 决定 | 理由 | +|---|---|---| +| runner | nas=官网+服务端(容器化)· mac=Android+macOS · windows=Windows | nas 常在线且 Astro/Go 轻量(非 Flutter Web);mac/windows 做必须它们的活 | +| 触发 | tag `site-v*` / `server-v*` / `client-v*` + `manual.yml` 手动派发 | 同 jiu,发版即部署,可手动重放 | +| macOS 签名 | mac runner 自动 Developer ID 签名 + notarytool 公证 + staple | 凭据入 Gitea secret(见 §7) | +| Android 签名 | 正式 release keystore | app 级专属签名身份 | +| 下载链接 | 官网 href 指向 Gitea release 资产的稳定 URL | 发版即更新,见 §6 | +| 镜像 | GOPROXY=goproxy.cn、PUB_HOSTED_URL/FLUTTER_STORAGE_BASE_URL=flutter-io.cn | 国内网络 | + +## 4. 架构 + +### 4.1 共享基座 `scripts/ci/`(镜像 jiu) + +- `_env.sh` —— 公共环境(镜像源、路径、版本号解析 `${tag#prefix-v}`) +- `lib-forgejo.sh` —— Gitea/Forgejo release 建/查 + 资产上传(用 `FORGEJO_TOKEN`) +- `provision-mac.sh` —— mac 幂等装 flutter / xcode-select / gomobile / Android NDK+JDK17 +- `test.sh <server|client>` —— `go test` / `flutter test` +- `notify.sh` —— 成功/失败 Telegram 通知(可选,复用节点监控 bot) +- `compile-site.sh` / `compile-backend.sh` / `compile-android.sh` / `compile-macos.sh` / `compile-windows.sh` +- `deploy-site.sh`(wrangler → CF Pages)/ `deploy-server.sh`(ssh pangolin1,复用 lib-ssh) +- `release-<x>.sh` —— 建 release + 挂产物 + +> 每个 `compile-*` 内部封装该端已验证的构建命令(如 Android 走 +> `scripts/build-libbox.sh android` + `flutter build apk --split-per-abi`;macOS 走 +> Xcode Developer ID 签名 + `notarytool submit --wait` + `stapler`)。工作流只调脚本, +> 逻辑在脚本里,便于本地复现。 + +### 4.2 工作流 `.gitea/workflows/` + +| 工作流 | 触发 | runner | 步骤 | +|---|---|---|---| +| `checks.yml`(现 ci.yml) | push 分支 | nas | 保留:shellcheck / openapi / redline / flutter analyze+test / go test | +| `deploy-site.yml` | `site-v*` | nas | `node:20` 容器构建 Astro(`SITE_URL` 注入)→ `deploy-site.sh` | +| `deploy-server.yml` | `server-v*` | nas | `golang:1.25` 交叉编译 → `test.sh server` → `release-server.sh` → `deploy-server.sh` | +| `build-android.yml` | `client-v*` | mac | provision → `compile-android.sh`(签名 apk)→ `release-client.sh` | +| `build-macos.yml` | `client-v*` | mac | provision → `compile-macos.sh`(签名+公证 dmg)→ `release-client.sh` | +| `build-windows.yml` | `client-v*` / `winbuild*` | windows | `compile-windows.sh`(exe/installer)→ `release-client.sh` | + +并发组按 jiu:`deploy-site` / `deploy-server` / `deploy-client` 各自 `cancel-in-progress: false`。 + +### 4.3 服务端部署(deploy-server.sh)—— 把手动那套固化 + +复刻 F3/F4 手动部署的安全次序(带回滚): +1. scp `pangolin-server` / `pangolin-agent` / `pangolin-migrate` 到 pangolin1 `/tmp` +2. `systemctl stop pangolin-server` +3. `sqlite3 wal_checkpoint(TRUNCATE)` → `cp` 备份 `pangolin.db.bak-pre-<tag>` +4. `pangolin-migrate up`(以 pangolin 用户);**失败即恢复备份 + 重启旧 server + 退出非零** +5. `install` 新二进制到 `/usr/local/bin`(旧的备份为 `.bak-<tag>`) +6. `systemctl start pangolin-server` + `/healthz` 健康检查;agent 随连接自恢复 + +### 4.4 官网部署(deploy-site.sh)—— Cloudflare Pages + +> **架构变更(2026-07-06 实施):** 原计划 rsync 到 pangolin1 的 nginx。但节点 :443 被 sing-box +> (VPN 数据面)占用,而 CF 免费套餐 proxied 回源只能打 :80/:443、改回源端口需 Enterprise —— +> 无法在同机同 IP 上让官网 HTTPS 与 VPN 共存。**故官网改由 Cloudflare Pages 托管**:纯静态、 +> 全程 HTTPS、`_headers`/CSP 原生生效、不落 VPS,从根上无 :443 冲突,也不拖累 VPN 机器。 + +Astro `npm ci && npm run build`(`SITE_URL=https://pangolin.yanmeiai.com`)→ `dist/` 经 +`npx wrangler pages deploy` 发布到 CF Pages 项目 **`pangolin-site`**(自定义域 +`pangolin.yanmeiai.com`,CNAME → `pangolin-site.pages.dev`,proxied)。 +需 secret:`CLOUDFLARE_API_TOKEN`(带 Account>Pages>Edit)+ `CLOUDFLARE_ACCOUNT_ID`(账户级)。 +deploy 步骤在 `node:20` 容器内跑 wrangler。**灾备**:构建产物仍是纯静态,可另 rsync 到任意镜像。 + +## 5. 下载链接闭环(30A) + +`web/website/src/config/site.ts` 增 `downloads: { android, macos, windows }`,值为 Gitea release 的 +**稳定 latest 资产 URL**(Forgejo 支持 `…/releases/latest/download/<asset>` 则直接用; +不支持则 `deploy-site.sh` 构建期用 `FORGEJO_TOKEN` 查最新 `client-v*` release 版本、烘焙进 href)。 +`Download.astro` 各平台按钮读 `SITE.downloads.<platform>`。客户端发版后官网重部署即刷新 +(或 `build-*` 完成触发 `deploy-site`)。 + +## 6. 密钥与作用域(solo / wangjia,命名对齐 jiu 以共用) + +| Secret | 作用域 | 说明 | +|---|---|---| +| `FORGEJO_TOKEN` / `FORGEJO_URL` | 账户级(wangjia) | 建 release + 传产物,jiu 复用 | +| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` / `MACOS_DEVELOPER_ID_CERT_PASSWORD` | 账户级 | Developer ID 证书(账号级),与 jiu 共用;续期改一处。证书在钥匙串,导出一次 .p12 | +| `APPSTORE_API_KEY_P8_BASE64` / `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` | 账户级 | 公证 API key(KEY_ID=`3PZTHR8YMJ`),与 jiu 同一把,`.p8` 现成 | +| `DEPLOY_SSH_KEY` | pangolin 仓库级 | 授权到 pangolin1,最小权限 | +| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | pangolin 仓库级 | Android app 级专属签名(**pangolin 自己的 keystore,不复用 jiu**) | +| `MACOS_APP_PROVISION_PROFILE_BASE64` / `MACOS_SYSEXT_PROVISION_PROFILE_BASE64` | pangolin 仓库级 | 主 app + PacketTunnel sysext 描述文件(pangolin bundle 专属,签名期落盘嵌入) | + +命名对齐 jiu(`MACOS_*`/`APPSTORE_*`/`ANDROID_*`):**Apple 那套放账户级 → jiu/pangolin 共用一份**, +compile-macos 脚本可复用 jiu 的;Android keystore 虽同命名规范但**各 app 独立、不共享**。 +工作流用 `secrets.XXX` 引用,作用域对写法透明。 + +## 7. 实现顺序(单仓库内分阶段落地) + +范围虽是 A~F,实现按风险/依赖递增: +1. **A 基座** + `checks` 迁移(`ci.yml` → `checks.yml` 复用现有,抽 `scripts/ci` 骨架) +2. **B 官网**(最简,验证 release/deploy 骨架跑通) +3. **C 服务端**(固化手动部署,告别手动) +4. **D Android**(解锁下载链接;需 keystore 就绪 + gradle 接签名) +5. **E macOS**(最复杂:证书+2 描述文件+公证) +6. **F Windows**(windows runner + Inno Setup) + +每阶段独立可发、独立验收。 + +## 8. 验证 + +- 每条流水线先 `workflow_dispatch` 手动跑通、产物/部署核对,再依赖 tag。 +- 服务端:`server-v*` → 看 pangolin1 migrate 版本 + `/healthz` + 行数守恒(同 F3 部署核对)。 +- 官网:`site-v*` → `pangolin.yanmeiai.com` 可访问 + canonical 正确 + redline 扫描。 +- 客户端:release 资产可下载安装(Android 侧载 / macOS 公证校验 `spctl` / Windows 安装)。 +- 下载链接:官网按钮点击落到最新 release 资产。 + +## 9. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| nas 内存(3.8G)构建 OOM | 容器化单 job、Astro/Go 轻量;必要时该端移 mac | +| migrate 在生产出错 | 部署前备份 + 失败自动回滚(§4.3),已在 F3/F4 手动验证 | +| Android keystore 丢失 | 存 Bitwarden(文件+密码);终身签名身份 | +| macOS 公证凭据泄露 | 账户级 secret,不落盘;`.p8`/`.p12` 用完即删临时文件 | +| 客户端发版后下载链接不刷新 | `build-*` 成功触发 `deploy-site` 重烘焙,或用 latest-download 稳定 URL | + +## 10. 不在本方案 + +iOS 流水线(G)、SQLite 备份/容灾(#26)、TLS(#25)、Android/上架 Play、Windows 代码签名(先不签)。 diff --git a/scripts/ci/_env.sh b/scripts/ci/_env.sh new file mode 100755 index 0000000..c732293 --- /dev/null +++ b/scripts/ci/_env.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# _env.sh — shared CI mirror env + tag-version helper. `source` this from other +# scripts; do not execute directly. Idempotent: only sets vars if not already +# provided by the environment (e.g. workflow-level overrides). + +export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" +export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}" +export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}" + +# Forgejo/Gitea repo coordinates (used by lib-forgejo.sh). Secrets +# FORGEJO_TOKEN / FORGEJO_URL are injected by the CI runner, not set here. +export FORGEJO_REPO="${FORGEJO_REPO:-wangjia/pangolin}" + +# Ensure Homebrew tools are on PATH (macOS runners); harmless elsewhere. +case ":${PATH}:" in + *":/opt/homebrew/bin:"*) ;; + *) export PATH="/opt/homebrew/bin:${PATH}" ;; +esac + +# ver_from_tag <prefix> <ref> +# Strips an optional "refs/tags/" prefix, then the "<prefix>-v" prefix, +# leaving a bare semver. Uses parameter expansion only (no command +# substitution). Examples: +# ver_from_tag server server-v1.2.3 -> 1.2.3 +# ver_from_tag server refs/tags/server-v1.2.3 -> 1.2.3 +ver_from_tag() { + local prefix="$1" ref="$2" + ref="${ref#refs/tags/}" + ref="${ref#"${prefix}"-v}" + printf '%s' "$ref" +} + +# flutter_pub_get_retry — pub.flutter-io.cn 常被 GFW 抖断(socket error / exit 69), +# 让 flutter build 内隐式的 pub get 偶发失败(见 iOS build 曾因 google_fonts 拉包 +# 断线而挂)。构建前显式预取 + 重试;成功后 build 命中缓存不再拉网。在 flutter +# 项目根目录调用。 +flutter_pub_get_retry() { + local i + for i in 1 2 3 4 5; do + if flutter pub get; then return 0; fi + echo "==> flutter pub get 失败(第 ${i}/5 次,pub.flutter-io.cn 抖?),8s 后重试..." >&2 + sleep 8 + done + echo "==> flutter pub get 5 次仍失败,放弃" >&2 + return 1 +} diff --git a/scripts/ci/backup-db.sh b/scripts/ci/backup-db.sh new file mode 100644 index 0000000..a090fba --- /dev/null +++ b/scripts/ci/backup-db.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# backup-db.sh — pangolin1 控制面 SQLite 每日备份到 NAS(异地容灾,#26 / F2)。 +# +# 经 ssh 在 pangolin1 上用 sqlite3 online `.backup` 取 WAL 一致快照(不阻塞服务、 +# 不像裸 cp 那样可能拿到半写状态),远端先跑 PRAGMA integrity_check 确认没坏,再把 +# 快照 gzip 流回 runner,落到 NAS 备份盘(默认 /volume1/docker/backups/pangolin, +# 该盘已挂进 runner 容器)。保留 30 天。异地 = 家里 NAS ≠ VPS 机房,构成容灾。 +# +# 不在 pangolin1 上安装/常驻任何东西:调度在 Gitea Actions(backup.yml),每次经 +# ssh 临时跑一次 sqlite3。需环境:DEPLOY_SSH_KEY(secret)。从 repo 根调用。 +set -euo pipefail + +# shellcheck source=scripts/ci/lib-ssh.sh +. scripts/ci/lib-ssh.sh + +BACKUP_DIR="${BACKUP_DIR:-/volume1/docker/backups/pangolin}" +mkdir -p "$BACKUP_DIR" + +# 时间戳文件名(bash 内建 strftime,免 $() 命令替换)。 +printf -v TS '%(%Y%m%d_%H%M%S)T' -1 +DEST="${BACKUP_DIR}/pangolin_${TS}.db.gz" + +# setup_ssh 内部先注册 EXIT trap 清理临时私钥,再写 key/known_hosts,导出 $SSH。 +setup_ssh + +echo "==> backup-db: sqlite3 .backup pangolin1(${DEPLOY_HOST})-> ${DEST}" +# 远端单引号 heredoc:$BK/$$ 在 pangolin1 上展开。以 pangolin 用户(DB 属主)跑 +# online .backup + integrity_check;校验通过才 cat 回流;临时文件用完即删。 +# integrity_check 失败 → 远端非零退出 → 本地 pipefail 令整条命令失败 → set -e 中止。 +$SSH "root@${DEPLOY_HOST}" 'bash -s' <<'ENDSSH' | gzip > "$DEST" +set -euo pipefail +BK="/tmp/pangolin-bk-$$.db" +trap 'rm -f "$BK"' EXIT +runuser -u pangolin -- sqlite3 "/var/lib/pangolin/pangolin.db" ".backup ${BK}" +if ! runuser -u pangolin -- sqlite3 "$BK" "PRAGMA integrity_check;" | grep -q '^ok$'; then + echo "backup-db(remote): integrity_check 失败" >&2 + exit 1 +fi +cat "$BK" +ENDSSH + +# 本地二次校验:gzip 完好 + 非空 sanity。空 gzip 仅 ~20 字节;真备份(即便很小的库) +# gzip 后也远大于 1KB,故用 >1k 区分「空/断流」与「有效备份」(不用 50k:小库压缩后可能 <50k)。 +gzip -t "$DEST" +if ! find "$DEST" -size +1k -print -quit | grep -q .; then + echo "==> backup-db: 备份异常小(<1k,疑似空/断流),中止" >&2 + rm -f "$DEST" + exit 1 +fi + +# 保留最近 30 天。 +find "$BACKUP_DIR" -name 'pangolin_*.db.gz' -mtime +30 -delete + +echo "==> backup-db: 备份完成 ${DEST}" diff --git a/scripts/ci/combine-site.sh b/scripts/ci/combine-site.sh new file mode 100755 index 0000000..dd7afaf --- /dev/null +++ b/scripts/ci/combine-site.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# combine-site.sh — 把用户中心(web/usercenter/out/)并入官网产物,供**单次** CF Pages +# 部署到 pangolin-site:官网在 /、用户中心在 /user/(域名迁移:原独立子域 +# app.yanmeiai.com → pangolin.yanmeiai.com/user/)。 +# +# 关键:两个 app 的 CSP 不同(官网用脚本哈希 + connect-src 'self';用户中心要 +# 'unsafe-inline' + connect-src https 调 api)。CF Pages 只读产物根 _headers,故把 +# 用户中心的 _headers 规则前缀重定到 /user/*,追加在官网规则之后 —— CF Pages 对同 +# 一路径「后写规则覆盖先写」,于是 /user/* 命中用户中心 CSP,其余走官网 CSP。 +# +# 由 compile-site.sh + compile-usercenter.sh 先产出 dist/ 与 out/;从 repo 根调用。 +set -euo pipefail + +DIST=web/website/dist +UC=web/usercenter/out + +[ -d "$DIST" ] || { echo "==> combine-site: $DIST 不存在(先跑 compile-site.sh)" >&2; exit 1; } +[ -d "$UC" ] || { echo "==> combine-site: $UC 不存在(先跑 compile-usercenter.sh)" >&2; exit 1; } + +# 1) 用户中心产物挂到 /user/。 +rm -rf "${DIST}/user" +mkdir -p "${DIST}/user" +cp -R "${UC}/." "${DIST}/user/" + +# 2) 合并 _headers 的 CSP。⚠️ CF Pages _headers 是【叠加】的:/* 与 /user/* 都匹配 +# /user/,两条 CSP 都会下发,浏览器对多条 CSP 取【交集(最严)】→ 官网严格 CSP +# 的「style-src 无 unsafe-inline / connect-src 'self'」赢,用户中心的内联样式被拦、 +# 连 API 被拦 → 页面裸奔且登录失败。故在 /user/* 里用 `! Content-Security-Policy` +# 先【删掉】/* 继承来的官网 CSP,再设用户中心自己的(须排在 /* 之后:先加后删)。 +# 其余安全头(HSTS/X-Frame/…)/* 与用户中心一致,沿用 /* 即可,不重复。 +rm -f "${DIST}/user/_headers" +{ + echo "" + echo "# ==== 用户中心(/user/*):删掉官网 /* 继承的 CSP,换用用户中心自己的 ====" + echo "/user/*" + echo " ! Content-Security-Policy" + grep -iE '^[[:space:]]*Content-Security-Policy:' "${UC}/_headers" +} >> "${DIST}/_headers" + +echo "==> combine-site: done — 用户中心并入 ${DIST}/user/,_headers 已按 /user/* 分域合并" +ls -la "${DIST}/user" | head diff --git a/scripts/ci/compile-android.sh b/scripts/ci/compile-android.sh new file mode 100755 index 0000000..9e42a7f --- /dev/null +++ b/scripts/ci/compile-android.sh @@ -0,0 +1,157 @@ +#!/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_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh) +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/ diff --git a/scripts/ci/compile-backend.sh b/scripts/ci/compile-backend.sh new file mode 100644 index 0000000..2a922ea --- /dev/null +++ b/scripts/ci/compile-backend.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# compile-backend.sh — cross-compile the pangolin Go control-plane binaries +# (server / agent / migrate) for the pangolin1 deploy target. CGO disabled: +# modernc.org/sqlite is pure Go, no cgo toolchain needed on the runner. +# Output: server/out/{pangolin-server,pangolin-agent,pangolin-migrate}. +# +# Run inside a golang:1.25 container by .gitea/workflows/deploy-server.yml; +# this script itself just runs `go build` and assumes it is invoked from the +# repo root. +set -euo pipefail + +# shellcheck source=scripts/ci/_env.sh +. scripts/ci/_env.sh + +cd server +mkdir -p out + +echo "==> compile-backend: building pangolin-server" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-server ./cmd/server + +echo "==> compile-backend: building pangolin-agent" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-agent ./cmd/agent + +echo "==> compile-backend: building pangolin-migrate" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-migrate ./cmd/migrate + +echo "==> compile-backend: done — out/ contents:" +ls -lh out/ diff --git a/scripts/ci/compile-ios.sh b/scripts/ci/compile-ios.sh new file mode 100755 index 0000000..134acc0 --- /dev/null +++ b/scripts/ci/compile-ios.sh @@ -0,0 +1,199 @@ +#!/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_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh) +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" diff --git a/scripts/ci/compile-macos.sh b/scripts/ci/compile-macos.sh new file mode 100755 index 0000000..4d362b1 --- /dev/null +++ b/scripts/ci/compile-macos.sh @@ -0,0 +1,272 @@ +#!/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.xcframework(gitignore 产物,每次换 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_VERSION(sysext 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_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh) +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" +# 从 WORK 临时目录复制(不是 PROF_DIR):flutter build macos 期间 xcodebuild 会 +# 修剪 ~/Library/Developer/Xcode/UserData/Provisioning Profiles,把[3/7]构建前 +# 装进去的 profile 清掉,构建后再从那目录 cp 会扑空(No such file → 本步失败)。 +# WORK 是 mktemp 目录、xcodebuild 不碰,里面的原始 .provisionprofile 全程留存。 +cp "${WORK}/sysext.provisionprofile" "${SE}/Contents/embedded.provisionprofile" +cp "${WORK}/app.provisionprofile" "${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] 公证(notarytool,API 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/" diff --git a/scripts/ci/compile-site.sh b/scripts/ci/compile-site.sh new file mode 100755 index 0000000..8ab204e --- /dev/null +++ b/scripts/ci/compile-site.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# compile-site.sh — build the Astro 官网 (web/website) as a static site. +# Output: web/website/dist/. Canonical domain is injected via SITE_URL (see +# web/website/astro.config.mjs) — must match the deploy target host name so +# canonical URLs / sitemap resolve correctly. +# +# Run inside a node:20 container by .gitea/workflows/deploy-site.yml; this +# script itself just runs npm and assumes it is invoked from the repo root. +set -euo pipefail + +SITE_URL="${SITE_URL:-https://pangolin.yanmeiai.com}" +export SITE_URL +echo "==> compile-site: SITE_URL=${SITE_URL}" + +cd web/website +npm ci +npm run build + +echo "==> compile-site: done — dist/ contents:" +ls -lh dist/ diff --git a/scripts/ci/compile-usercenter.sh b/scripts/ci/compile-usercenter.sh new file mode 100755 index 0000000..4d4c7c7 --- /dev/null +++ b/scripts/ci/compile-usercenter.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# compile-usercenter.sh — build the Next.js 用户中心 (web/usercenter) as a static +# export (output: web/usercenter/out/, basePath=/user)。由 combine-site.sh 并入官网 +# dist/user/,随官网一起部署到 CF Pages pangolin-site(pangolin.yanmeiai.com/user/)。 +# 域名迁移:原独立子域 app.yanmeiai.com 已停用。 +# +# ⚠️ 必须以「真实 API」模式构建:NEXT_PUBLIC_API_MODE=http。否则 lib/api/client.ts +# 默认落到 mock(内存假数据),部署上去登录/订阅全是演示数据。API 域名是公开信息 +# (非密钥,经 CF Tunnel 暴露的控制面),故此处内置默认值;可用同名 env 覆盖。 +# +# 由 .gitea/workflows/deploy-site.yml 在 ubuntu runner 上调用,从 repo 根运行。 +set -euo pipefail + +export NEXT_PUBLIC_API_MODE="${NEXT_PUBLIC_API_MODE:-http}" +export NEXT_PUBLIC_API_DOMAINS="${NEXT_PUBLIC_API_DOMAINS:-https://api.yanmeiai.com}" +echo "==> compile-usercenter: API_MODE=${NEXT_PUBLIC_API_MODE} API_DOMAINS=${NEXT_PUBLIC_API_DOMAINS}" + +cd web/usercenter +npm ci +npm run build + +echo "==> compile-usercenter: done — out/ contents:" +ls -lh out/ | head diff --git a/scripts/ci/compile-windows.sh b/scripts/ci/compile-windows.sh new file mode 100755 index 0000000..5a5a14c --- /dev/null +++ b/scripts/ci/compile-windows.sh @@ -0,0 +1,148 @@ +#!/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" + +# build number 同 android/macos 规则(MAJOR*10000+MINOR*100+PATCH)。 +MAJOR="${VER%%.*}" +_REST="${VER#*.}" +MINOR="${_REST%%.*}" +PATCH="${_REST#*.}" +PATCH="${PATCH%%-*}" +BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + +echo "==> compile-windows: tag=${TAG} version=${VER} build=${BUILD}" + +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" + +# 同步 Flutter app 版本到 tag(GNU sed,windows runner)。否则 package_info 卡在 +# pubspec committed 值(1.0.48),设置页显示旧版 + 自动更新永远判"有新版"。 +# 此前只改了 Inno 安装器 MyAppVersion(下方),App 内部版本没跟上——本次修复。 +sed -i "s/^version:.*/version: ${VER}+${BUILD}/" "${BUILD_CLIENT}/pubspec.yaml" +echo "==> compile-windows: pubspec version -> ${VER}+${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_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh) +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/ diff --git a/scripts/ci/deploy-client.sh b/scripts/ci/deploy-client.sh new file mode 100755 index 0000000..c4451d9 --- /dev/null +++ b/scripts/ci/deploy-client.sh @@ -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" diff --git a/scripts/ci/deploy-server.sh b/scripts/ci/deploy-server.sh new file mode 100644 index 0000000..4d56a1b --- /dev/null +++ b/scripts/ci/deploy-server.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# deploy-server.sh <tag> — deploy the pangolin control-plane binaries +# (pangolin-server / pangolin-agent / pangolin-migrate) to pangolin1, in the +# exact manual sequence used for F3/F4: stop → wal checkpoint → backup db → +# migrate (rollback db + restart old binary on failure) → swap binaries → +# start → healthcheck. Assumes compile-backend.sh has already produced +# server/out/{pangolin-server,pangolin-agent,pangolin-migrate}. +# +# Usage: scripts/ci/deploy-server.sh <tag> (e.g. server-v1.2.3) +# Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh). +set -euo pipefail + +# shellcheck source=scripts/ci/lib-ssh.sh +. scripts/ci/lib-ssh.sh + +DB=/var/lib/pangolin/pangolin.db +BIN=/usr/local/bin +TAG="${1:?usage: deploy-server.sh <tag>}" + +# Refuse anything that isn't a strict server-vX.Y.Z[-suffix] tag before it can +# reach the remote heredoc / backup paths below (command-injection guard). +# An anchored regex is used instead of a `case` glob: a trailing `*` in a +# case pattern matches ANY trailing characters (including shell metachars +# like `; rm -rf /`), which would defeat the point of this check. +if ! [[ "$TAG" =~ ^server-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then + echo "deploy-server: refusing unexpected tag '$TAG'" >&2 + exit 1 +fi + +# setup_ssh registers the EXIT cleanup trap itself (before writing the key), +# so a mid-setup failure still cleans up — see lib-ssh.sh. It exports +# SSH_KEY_FILE / DEPLOY_PORT / SSH_KNOWN_HOSTS_FILE / DEPLOY_HOST used below +# to build SCP (mirroring the SSH/RSYNC_SSH command-string convention). +setup_ssh +SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}" + +echo "==> deploy-server: tag=${TAG} host=${DEPLOY_HOST}" +echo "==> deploy-server: uploading binaries to ${DEPLOY_HOST}:/tmp/" +$SCP server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate "root@${DEPLOY_HOST}:/tmp/" + +$SSH "root@${DEPLOY_HOST}" "bash -s" <<REMOTE +set -euo pipefail +systemctl stop pangolin-server +runuser -u pangolin -- sqlite3 "$DB" 'PRAGMA wal_checkpoint(TRUNCATE);' +cp -p "$DB" "$DB.bak-pre-$TAG" +if ! runuser -u pangolin -- env DB_DRIVER=sqlite DB_DSN=$DB /tmp/pangolin-migrate up; then + echo "!! migrate 失败,回滚"; cp -p "$DB.bak-pre-$TAG" "$DB"; systemctl start pangolin-server; exit 1 +fi +cp -p "$BIN/pangolin-server" "$BIN/pangolin-server.bak-$TAG" || true +install -m755 /tmp/pangolin-server "$BIN/pangolin-server" +install -m755 /tmp/pangolin-agent "$BIN/pangolin-agent" +install -m755 /tmp/pangolin-migrate "$BIN/pangolin-migrate" +systemctl start pangolin-server +systemctl is-active pangolin-server +REMOTE + +# 8080 现仅 loopback(经 cloudflared 隧道对外)。 +# 本地 /healthz 是本次二进制部署成败的**权威闸**:新 server 起来即通过。 +$SSH "root@${DEPLOY_HOST}" 'curl -fsS -m 10 --retry 5 --retry-connrefused http://127.0.0.1:8080/healthz >/dev/null && echo "healthz(local) OK"' +# 隧道 /healthz 是端到端冒烟(最贴近真实客户端路径),但**非致命**:它依赖 cloudflared/CF 边缘, +# 与「本次二进制是否健康」是两回事——CF 边缘抖动或隧道尚未 provision 不应判整次部署失败 +# (本地闸已证明 server 健康)。失败只告警,不 exit。 +if curl -fsS -m 10 --retry 3 "https://api.yanmeiai.com/healthz" >/dev/null; then + echo "healthz(tunnel) OK" +else + echo "==> deploy-server: 警告 —— 隧道 https://api.yanmeiai.com/healthz 不通(CF 边缘抖动/隧道未就绪?);本地 healthz 已通过,不阻断部署。" >&2 +fi + +echo "==> deploy-server: done" diff --git a/scripts/ci/deploy-site.sh b/scripts/ci/deploy-site.sh new file mode 100755 index 0000000..e4914c0 --- /dev/null +++ b/scripts/ci/deploy-site.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# deploy-site.sh — 部署构建好的 Astro 官网 (web/website/dist/) 到 Cloudflare Pages。 +# +# 官网托管在 CF Pages(项目 pangolin-site,自定义域 pangolin.yanmeiai.com),纯静态、 +# 全程 HTTPS、CSP(_headers)自动生效,不落在 VPS 上 —— 故与节点 :443(sing-box)无冲突。 +# +# 需环境变量: +# CLOUDFLARE_API_TOKEN 带 Account > Cloudflare Pages > Edit 权限的 CF token +# CLOUDFLARE_ACCOUNT_ID CF 账户 ID +# 由 compile-site.sh 先产出 web/website/dist/;从 repo 根调用。 +set -euo pipefail + +if [ ! -d web/website/dist ]; then + echo "==> deploy-site: web/website/dist/ 不存在 — 拒绝部署" >&2 + exit 1 +fi +if ! find web/website/dist -mindepth 1 -print -quit | grep -q .; then + echo "==> deploy-site: web/website/dist/ 为空 — 拒绝部署" >&2 + exit 1 +fi + +: "${CLOUDFLARE_API_TOKEN:?deploy-site: CLOUDFLARE_API_TOKEN 未设(需带 Pages:Edit)}" +: "${CLOUDFLARE_ACCOUNT_ID:?deploy-site: CLOUDFLARE_ACCOUNT_ID 未设}" + +echo "==> deploy-site: wrangler pages deploy → project pangolin-site (branch main)" +npx --yes wrangler@4 pages deploy web/website/dist \ + --project-name=pangolin-site --branch=main --commit-dirty=true + +echo "==> deploy-site: done" diff --git a/scripts/ci/lib-forgejo.sh b/scripts/ci/lib-forgejo.sh new file mode 100755 index 0000000..9e75cd6 --- /dev/null +++ b/scripts/ci/lib-forgejo.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# lib-forgejo.sh — Forgejo/Gitea release API helpers shared by the pangolin +# release pipelines (website / server / client). `source` this after _env.sh. +# +# Provides: +# forgejo_release_ensure <tag> <title> -> looks up or creates the release +# for <tag>; sets/exports RELEASE_ID +# forgejo_upload_asset <tag> <file> -> uploads one asset to that release +# +# Requires env: FORGEJO_URL, FORGEJO_TOKEN, FORGEJO_REPO (see _env.sh for the +# default FORGEJO_REPO=wangjia/pangolin). +# +# No command substitution ($()) is used anywhere: HTTP status codes and JSON +# fields are written to temp files by curl/python3 and read back with `read`. +# +# TLS verification is ON by default. Opt-outs (env-driven, resolved once below +# into the FORGEJO_CURL_TLS array and spliced into every curl call): +# FORGEJO_CA_BUNDLE=<path> -> verify against this CA bundle (--cacert) +# FORGEJO_INSECURE=1 -> disable verification (-k), for self-signed +# internal CAs only; explicit opt-in required. + +: "${FORGEJO_REPO:=wangjia/pangolin}" + +FORGEJO_CURL_TLS=() +if [ -n "${FORGEJO_CA_BUNDLE:-}" ]; then + FORGEJO_CURL_TLS=(--cacert "$FORGEJO_CA_BUNDLE") +else + case "${FORGEJO_INSECURE:-}" in + 1 | true | yes) + FORGEJO_CURL_TLS=(-k) + ;; + *) + FORGEJO_CURL_TLS=() + ;; + esac +fi + +# forgejo_release_ensure <tag> <title> +forgejo_release_ensure() { + local tag="$1" title="$2" + local get_code_file get_body_file get_code + + get_code_file="/tmp/forgejo_get_code.$$" + get_body_file="/tmp/forgejo_get_body.$$.json" + + curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 --retry 2 --retry-connrefused -o "$get_body_file" -w '%{http_code}' \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/tags/${tag}" \ + > "$get_code_file" + # `|| true`: curl -w '%{http_code}' 写入的值无结尾换行,read 到无换行 EOF 返回 1 + # (值已赋)→ set -e 会静默中止。容错该退出码;文件恒由上面的 curl 创建,不掩盖真错。 + read -r get_code < "$get_code_file" || true + rm -f "$get_code_file" + + if [ "$get_code" = "200" ]; then + echo "==> forgejo: release ${tag} already exists" + _forgejo_read_release_id "$get_body_file" + rm -f "$get_body_file" + return 0 + fi + rm -f "$get_body_file" + + echo "==> forgejo: creating release ${tag}" + local create_code_file create_body_file create_code create_req_file + create_code_file="/tmp/forgejo_create_code.$$" + create_body_file="/tmp/forgejo_create_body.$$.json" + create_req_file="/tmp/forgejo_create_req.$$.json" + + # Build the JSON request body via python3's json.dumps rather than raw + # string interpolation, so a tag/title containing `"` / `\` / control + # characters can't break out of the JSON structure (json-injection guard). + # Values are piped in NUL-separated on stdin — never interpolated into the + # python source — and no $() command substitution is used. + printf '%s\0%s\0' "$tag" "$title" | python3 -c ' +import json +import sys + +raw = sys.stdin.buffer.read() +tag, title = (part.decode() for part in raw.split(b"\0")[:2]) +json.dump( + {"tag_name": tag, "name": title, "draft": False, "prerelease": False}, + sys.stdout, +) +' > "$create_req_file" + + curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 60 -o "$create_body_file" -w '%{http_code}' \ + -X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -H "Content-Type: application/json" \ + --data @"$create_req_file" \ + > "$create_code_file" + read -r create_code < "$create_code_file" || true # 无结尾换行,见 forgejo_release_ensure 注释 + rm -f "$create_code_file" "$create_req_file" + + if [ "$create_code" -lt 200 ] || [ "$create_code" -ge 300 ]; then + echo "==> forgejo: release create FAILED (HTTP ${create_code})" >&2 + cat "$create_body_file" >&2 + rm -f "$create_body_file" + return 1 + fi + + _forgejo_read_release_id "$create_body_file" + rm -f "$create_body_file" + echo "==> forgejo: release_id=${RELEASE_ID}" +} + +# _forgejo_read_release_id <json_file> — internal: sets/exports RELEASE_ID. +_forgejo_read_release_id() { + local json_file="$1" id_file + id_file="/tmp/forgejo_release_id.$$" + python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" \ + "$json_file" > "$id_file" + read -r RELEASE_ID < "$id_file" || true # python 写入可能无结尾换行,见上注释 + rm -f "$id_file" + export RELEASE_ID +} + +# forgejo_upload_asset <tag> <file> +forgejo_upload_asset() { + local tag="$1" file="$2" + + if [ -z "${RELEASE_ID:-}" ]; then + forgejo_release_ensure "$tag" "$tag" || return 1 + fi + + local code_file body_file code + code_file="/tmp/forgejo_upload_code.$$" + body_file="/tmp/forgejo_upload_body.$$.json" + + curl ${FORGEJO_CURL_TLS[@]+"${FORGEJO_CURL_TLS[@]}"} -s --max-time 300 -o "$body_file" -w '%{http_code}' \ + -X POST "${FORGEJO_URL}/api/v1/repos/${FORGEJO_REPO}/releases/${RELEASE_ID}/assets" \ + -H "Authorization: token ${FORGEJO_TOKEN}" \ + -F "attachment=@${file}" \ + > "$code_file" + read -r code < "$code_file" || true # curl http_code 无结尾换行,见上注释 + rm -f "$code_file" + + echo "==> forgejo: uploaded ${file} (HTTP ${code})" + if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then + cat "$body_file" >&2 + rm -f "$body_file" + return 1 + fi + rm -f "$body_file" +} diff --git a/scripts/ci/lib-ssh.sh b/scripts/ci/lib-ssh.sh new file mode 100755 index 0000000..86d1ea8 --- /dev/null +++ b/scripts/ci/lib-ssh.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# lib-ssh.sh — shared SSH deploy-key helpers for the pangolin deploy pipelines +# (site / server). `source` this from deploy-*.sh. +# +# Provides: +# setup_ssh -> registers the EXIT cleanup trap first, then writes +# $DEPLOY_SSH_KEY to a temp private key (mode 600), +# registers the deploy host in a dedicated known_hosts +# file, and exports SSH / RSYNC_SSH (ssh command strings) +# + SSH_KEY_FILE. +# teardown_ssh -> removes the temp private key + known_hosts file. +# +# Requires env: DEPLOY_SSH_KEY (PEM content of the deploy private key, +# authorized for root on the target host). +# +# Target host is hardcoded to the pangolin1 VPS IP (103.119.13.48): the CI +# runner has no access to the user's local ~/.ssh/config, so the `pangolin1` +# alias cannot be resolved there — the bare IP is used instead. Override with +# DEPLOY_HOST / DEPLOY_PORT if a caller needs to retarget. +# +# No command substitution ($()) is used anywhere, per repo bash conventions. + +DEPLOY_HOST="${DEPLOY_HOST:-103.119.13.48}" +DEPLOY_PORT="${DEPLOY_PORT:-22}" +SSH_KEY_FILE="${SSH_KEY_FILE:-/tmp/pangolin_deploy_key.$$}" +SSH_KNOWN_HOSTS_FILE="${SSH_KNOWN_HOSTS_FILE:-/tmp/pangolin_deploy_known_hosts.$$}" + +# setup_ssh — write the deploy key, register known_hosts, export SSH/RSYNC_SSH. +setup_ssh() { + # Register cleanup FIRST: if anything below fails mid-setup (e.g. a + # transient ssh-keyscan error under `set -e`), the private key file must + # still be removed on exit rather than leaking. + trap teardown_ssh EXIT + + if [ -z "${DEPLOY_SSH_KEY:-}" ]; then + echo "==> setup_ssh: DEPLOY_SSH_KEY is empty" >&2 + return 1 + fi + + mkdir -p ~/.ssh + chmod 700 ~/.ssh + + # Pre-create the key file with restrictive perms *before* writing any key + # material into it, so there is no window at the default umask between + # file creation and chmod. + install -m 600 /dev/null "${SSH_KEY_FILE}" + + # `printf '%s\n'` 末尾补一个换行:Forgejo/Gitea 存 secret 会去掉结尾换行, + # 而缺结尾换行的 OpenSSH 格式私钥会被判为 "invalid format" 拒绝加载, + # 退化成无密钥 → Permission denied。多补的换行对已含结尾换行的 PEM 无害。 + printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}" + + # Populate a dedicated known_hosts file via TOFU keyscan. This is + # belt-and-suspenders: `accept-new` below will pin the host key on first + # real connection regardless, so a transient keyscan failure must not + # abort the deploy. + ssh-keyscan -p "${DEPLOY_PORT}" -H "${DEPLOY_HOST}" >> "${SSH_KNOWN_HOSTS_FILE}" 2>/dev/null || true + + # IdentitiesOnly=yes:只用上面 -i 指定的 key,不把 agent/默认 key 也递上去 + # (否则会触发服务器 MaxAuthTries「Too many authentication failures」)。 + # BatchMode=yes:纯非交互,认证失败即退出,不回落到密码提示卡住。 + _ssh_opts="-i ${SSH_KEY_FILE} -p ${DEPLOY_PORT} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${SSH_KNOWN_HOSTS_FILE}" + SSH="ssh ${_ssh_opts}" + RSYNC_SSH="ssh ${_ssh_opts}" + export SSH RSYNC_SSH SSH_KEY_FILE SSH_KNOWN_HOSTS_FILE DEPLOY_HOST DEPLOY_PORT + echo "==> setup_ssh: key written to ${SSH_KEY_FILE}, known_hosts pinned (accept-new) for ${DEPLOY_HOST}:${DEPLOY_PORT}" +} + +# teardown_ssh — remove the temp private key + known_hosts file. +teardown_ssh() { + rm -f "${SSH_KEY_FILE}" "${SSH_KNOWN_HOSTS_FILE}" + echo "==> teardown_ssh: removed ${SSH_KEY_FILE}" +} diff --git a/scripts/ci/notify.sh b/scripts/ci/notify.sh new file mode 100755 index 0000000..bb8b725 --- /dev/null +++ b/scripts/ci/notify.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# notify.sh — Telegram CI notifications. `source` this from other scripts. +# +# Provides: +# notify_ok <msg> -> send a "success" notification +# notify_fail <msg> -> send a "failure" notification +# +# Uses the same TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID convention as +# deploy/bootstrap/monitor/pangolin-monitor.sh. Safe no-op (never fails the +# pipeline) when those are unset, or when the Telegram API call itself fails. + +_notify_send() { + local text="$1" + if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then + echo "==> notify: (skipped, no TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID) ${text}" + return 0 + fi + curl -fsS --max-time 15 \ + "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \ + --data-urlencode "text=${text}" \ + --data "disable_web_page_preview=true" >/dev/null 2>&1 \ + || echo "==> notify: Telegram 发送失败(已忽略,不影响流水线)" >&2 +} + +# notify_ok <msg> +notify_ok() { + local msg="$1" + _notify_send "✅ Pangolin CI: ${msg}" +} + +# notify_fail <msg> +notify_fail() { + local msg="$1" + _notify_send "❌ Pangolin CI: ${msg}" +} diff --git a/scripts/ci/release-client.sh b/scripts/ci/release-client.sh new file mode 100755 index 0000000..707e975 --- /dev/null +++ b/scripts/ci/release-client.sh @@ -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 diff --git a/scripts/ci/release-server.sh b/scripts/ci/release-server.sh new file mode 100644 index 0000000..a89e942 --- /dev/null +++ b/scripts/ci/release-server.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# release-server.sh — ensure the Forgejo release for a server-v* tag exists +# and upload the three compiled binaries as release assets. Assumes +# compile-backend.sh has already produced +# server/out/{pangolin-server,pangolin-agent,pangolin-migrate}. +# +# Usage: scripts/ci/release-server.sh <tag> (e.g. server-v1.2.3) +# Requires env: FORGEJO_URL, FORGEJO_TOKEN (see lib-forgejo.sh). +set -euo pipefail + +TAG="${1:?usage: release-server.sh <tag>}" + +# shellcheck source=scripts/ci/_env.sh +. scripts/ci/_env.sh +# shellcheck source=scripts/ci/lib-forgejo.sh +. scripts/ci/lib-forgejo.sh + +# No command substitution ($()): ver_from_tag prints to stdout, captured via +# a temp file + `read`, same no-substitution pattern as lib-forgejo.sh. +ver_file="/tmp/release_server_ver.$$" +ver_from_tag server "$TAG" > "$ver_file" +VER="" +# `|| true`: ver_from_tag 用 printf '%s'(无结尾换行),read 到无换行的 EOF 会返回 1 +# (但 VER 已正确赋值)——set -e 下会静默退出。容错 read 的这个退出码,不掩盖真错。 +read -r VER < "$ver_file" || true +rm -f "$ver_file" + +echo "==> release-server: tag=${TAG} ver=${VER}" + +forgejo_release_ensure "$TAG" "server ${VER}" + +forgejo_upload_asset "$TAG" server/out/pangolin-server +forgejo_upload_asset "$TAG" server/out/pangolin-agent +forgejo_upload_asset "$TAG" server/out/pangolin-migrate + +echo "==> release-server: done" diff --git a/scripts/ci/test.sh b/scripts/ci/test.sh new file mode 100755 index 0000000..fbb0c49 --- /dev/null +++ b/scripts/ci/test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# test.sh <server|client> — run the test suite for one side of the repo. +# Invoked directly on the nas runner (see deploy-server.yml's "Test" step), +# so this script itself owns the docker invocation — Go/Flutter are not on +# the host. +# +# server: `go test ./...` inside golang:1.25, mirroring the go-server job's +# docker invocation in .gitea/workflows/ci.yml (mounts + gomod/gobuild +# caches), plus GOPROXY from _env.sh so it doesn't hit proxy.golang.org. +# client: `flutter test` inside ghcr.io/cirruslabs/flutter:stable, mirroring +# the flutter-client job's docker invocation in .gitea/workflows/ci.yml +# (pub-cache volume, same test dirs); analyze/coverage stay in that job. +# +# Run from the repo root (relative paths below assume this). +set -euo pipefail + +# shellcheck source=scripts/ci/_env.sh +. scripts/ci/_env.sh + +TARGET="${1:-}" + +case "$TARGET" in + server) + # 直接在 runner 跑(不嵌套 docker,避免 DinD 挂载失败)。go.mod 要求的 + # go 1.25.x 若高于 runner 自带版本,Go 工具链会经 GOPROXY(_env.sh)自动下载。 + echo "==> test: go test ./..." + ( cd server && go test ./... ) + ;; + client) + mkdir -p "$HOME/.cache/pangolin-ci/pubcache" + echo "==> test: flutter test (ghcr.io/cirruslabs/flutter:stable 容器)" + docker run --rm \ + -v "$PWD/client:/app" -w /app \ + -v "$HOME/.cache/pangolin-ci/pubcache:/root/.pub-cache" \ + ghcr.io/cirruslabs/flutter:stable \ + bash -c "flutter pub get && flutter test test/unit test/widget test/contract" + ;; + *) + echo "usage: scripts/ci/test.sh <server|client>" >&2 + exit 1 + ;; +esac + +echo "==> test: ${TARGET} 通过" diff --git a/scripts/local_test.sh b/scripts/local_test.sh index 6f3f079..78e3d2e 100755 --- a/scripts/local_test.sh +++ b/scripts/local_test.sh @@ -35,7 +35,9 @@ DIR="${SRC%/*}"; [ "$DIR" = "$SRC" ] && DIR="." cd "$DIR/.."; REPO_ROOT="$PWD" CLIENT="$REPO_ROOT/client" APP="$CLIENT/build/macos/Build/Products/Release/pangolin_vpn.app" -SE="$APP/Contents/Library/SystemExtensions/PacketTunnel.systemextension" +# sysext bundle 名 = 标识符(PRODUCT_NAME=com.pangolin.pangolin.PacketTunnel,见 CLAUDE.md), +# 不是短名 PacketTunnel.systemextension。 +SE="$APP/Contents/Library/SystemExtensions/com.pangolin.pangolin.PacketTunnel.systemextension" LIBFW="$SE/Contents/Frameworks/Libbox.framework" PROF_DIR="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" WORK="${TMPDIR:-/tmp}/pangolin_local_test"; mkdir -p "$WORK" @@ -112,9 +114,9 @@ cmd_sign(){ cp "$app_prof" "$APP/Contents/embedded.provisionprofile" cp "$se_prof" "$SE/Contents/embedded.provisionprofile" - # 内向外:Libbox 真二进制 → Libbox.framework → sysext → app 各 framework → app 主体 - cs -s "$SIGN_ID" "$LIBFW/Versions/A/Libbox" - cs -s "$SIGN_ID" "$LIBFW" + # 内向外:sysext → app 各 framework → app 主体。 + # libbox 现为「只 Link 不 Embed」静态进 sysext 二进制(见 CLAUDE.md),sysext 内已无 + # 独立 Libbox.framework;签 sysext bundle 即覆盖其静态链接的 libbox,无需单独签 framework。 cs --entitlements "$WORK/sysext.entitlements" -s "$SIGN_ID" "$SE" local item for item in "$APP/Contents/Frameworks/"*; do diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index a133808..1d494a3 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -294,10 +294,11 @@ paths: /ads/unlock: post: operationId: adsUnlock - summary: 激励视频广告解锁当日时长 + summary: 激励视频广告加时(累加式) description: | 免费版用户完成激励视频广告后调用。服务端向广告平台(AdMob/Unity)校验 ad_token 真伪, - 通过后记录 `usage_daily.ad_unlocked_at`,当日 connect 接口方可放行。 + 通过后向 `usage_daily.ad_bonus_minutes` 累加固定分钟(每日封顶),当日免费额度随之上浮。 + 额度为**全账户共享**(非每设备)。响应返回本次实际加时与最新剩余分钟。 tags: [Commerce] requestBody: required: true @@ -315,8 +316,20 @@ paths: type: string description: 广告 SDK 签发的服务端回执 token responses: - "204": - description: 广告解锁成功(无响应体) + "200": + description: 广告加时成功 + content: + application/json: + schema: + type: object + required: [granted_minutes, minutes_remaining] + properties: + granted_minutes: + type: integer + description: 本次广告实际加时分钟(已达每日封顶时为 0) + minutes_remaining: + type: integer + description: 加时后账户当日剩余分钟(全账户共享) "400": $ref: "#/components/responses/BadRequest" "401": @@ -1051,14 +1064,23 @@ components: type: integer description: 今日已使用分钟数 example: 3 + minutes_cap: + type: integer + nullable: true + description: 今日额度 = 套餐每日分钟 + 看广告累加分钟;付费无限制时为 null + example: 20 minutes_remaining: type: integer nullable: true - description: 今日剩余分钟数,付费无限制时为 null + description: 今日剩余分钟数(全账户共享),付费无限制时为 null example: 7 + ad_bonus_minutes: + type: integer + description: 今日已通过看广告累加的分钟数 + example: 10 ad_unlocked: type: boolean - description: 免费版今日是否已完成激励广告解锁 + description: 免费版今日是否已通过看广告加过时(ad_bonus_minutes > 0,历史字段) example: false Device: diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 3bf0a72..d91f3cf 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -123,8 +123,9 @@ func main() { r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) - r.Use(corsMiddleware(corsAllowedOrigins())) r.Use(apierr.Middleware) + // CORS:Web 用户中心(pangolin.yanmeiai.com/user)跨域调 /v1/*;原生端不受影响。 + r.Use(httpapi.NewCORS()) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -136,6 +137,19 @@ func main() { // so it can call the API with plain relative fetch()s (no CORS). r.Get("/buy", serveBuyPage) + // 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 @@ -325,7 +339,23 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // ── Usage ───────────────────────────────────────────────────────────────── usageStore := usage.NewStore(sqlDB) - usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour) + // Ad verifier: real AdMob SSV in production; a放行式 DevVerifier when + // ADS_DEV_MODE=1 (or no AdMob configured) so the placeholder看广告加时 flow + // works end-to-end before the real ad SDK is wired in. Nonce replay + // protection still applies either way. + var adVerifier usage.AdVerifier + if os.Getenv("ADS_DEV_MODE") == "1" { + adVerifier = usage.DevVerifier{} + slog.Warn("ads: DevVerifier enabled (ADS_DEV_MODE=1) — accepts any receipt; not for production") + } else if os.Getenv("ADMOB_SSV") == "1" { + adVerifier = usage.NewAdMobVerifier("", nil, 0, nil) + } else { + // Default (current state): no real ad network yet → placeholder verifier + // so免费版看广告加时 is functional in the field. + adVerifier = usage.DevVerifier{} + slog.Warn("ads: no ad network configured — using DevVerifier placeholder") + } + usageSvc := usage.NewService(usageStore, rdb, adVerifier, time.Hour) usageHandler := usage.NewUsageHandler(usageSvc) deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc) adsHandler := usage.NewAdsUnlockHandler(usageSvc) @@ -378,6 +408,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) } @@ -408,6 +441,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) @@ -592,39 +629,4 @@ func (a authDeviceRegistrar) CheckDeviceLimit(ctx context.Context, userID int64) return &auth.DeviceLimit{MaxDevices: st.MaxDevices, Devices: briefs}, nil } -// corsAllowedOrigins 从 env CORS_ALLOWED_ORIGINS(逗号分隔)读跨域白名单;默认放行官网/ -// 用户中心域。控制面走 Bearer token(无 cookie 会话),故不放行 credentials,只白名单回显 Origin。 -func corsAllowedOrigins() map[string]bool { - raw := os.Getenv("CORS_ALLOWED_ORIGINS") - if raw == "" { - raw = "https://pangolin.yanmeiai.com" - } - out := map[string]bool{} - for _, o := range strings.Split(raw, ",") { - if o = strings.TrimSpace(o); o != "" { - out[o] = true - } - } - return out -} - -// corsMiddleware 按白名单回显 Access-Control-Allow-Origin 并应答预检 OPTIONS。只放行白名单内 -// Origin(不用 "*"),允许 Authorization/Content-Type 头。非白名单来源不加任何 CORS 头(浏览器自然拦)。 -func corsMiddleware(allowed map[string]bool) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if origin := r.Header.Get("Origin"); origin != "" && allowed[origin] { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Add("Vary", "Origin") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") - w.Header().Set("Access-Control-Max-Age", "600") - } - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) - } -} +// (CORS 由 internal/httpapi.NewCORS() 统一提供,原 main 内联 corsMiddleware 已移除) diff --git a/server/internal/agentd/config.go b/server/internal/agentd/config.go index c503d21..6d455f1 100644 --- a/server/internal/agentd/config.go +++ b/server/internal/agentd/config.go @@ -54,6 +54,10 @@ type Config struct { // SingboxConfigPath is where the rendered sing-box config is written. SingboxConfigPath string + // WarpConfigPath 指向节点本地的 WARP 分流配置(默认 <StateDir>/warp.json)。 + // 文件不存在 = WARP 未启用。渲染时读取,支持编辑后重启 agent 生效(#29)。 + WarpConfigPath string + // DeriveKey keys the Hy2 password derivation (see DeriveHy2Password). DeriveKey string @@ -79,6 +83,9 @@ func (c Config) withDefaults() Config { if c.SingboxConfigPath == "" { c.SingboxConfigPath = DefaultSingboxCfg } + if c.WarpConfigPath == "" { + c.WarpConfigPath = filepath.Join(c.StateDir, "warp.json") + } if c.HeartbeatInterval == 0 { c.HeartbeatInterval = DefaultHeartbeatInterval } diff --git a/server/internal/agentd/render.go b/server/internal/agentd/render.go index 239e08b..ef2966d 100644 --- a/server/internal/agentd/render.go +++ b/server/internal/agentd/render.go @@ -21,13 +21,17 @@ const ( clashAPIAddr = "127.0.0.1:19090" clashAPISecret = "pangolin-local-stats" v2rayAPIAddr = "127.0.0.1:19091" + + // sing-box outbound/endpoint tags used in route rules. + directOutboundTag = "direct" + warpOutboundTag = "warp" ) -func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string) ([]byte, error) { +func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *agentv1.Hy2Inbound, deriveKey string, warp *WarpConfig) ([]byte, error) { cfg := map[string]any{ "log": map[string]any{"level": "warn", "timestamp": true}, "inbounds": buildInbounds(creds, reality, hy2, deriveKey), - "outbounds": []any{map[string]any{"type": "direct", "tag": "direct"}}, + "outbounds": []any{map[string]any{"type": "direct", "tag": directOutboundTag}}, "experimental": map[string]any{ "clash_api": map[string]any{ "external_controller": clashAPIAddr, @@ -42,6 +46,14 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age }, }, } + + // WARP 分流(#29):命中配置域名的流量走 Cloudflare WARP 干净出口,其余直连。 + // warp 为 nil 或未 active 时完全不加 endpoints/route → 与旧配置逐字节一致(向后兼容)。 + if warp.active() { + cfg["endpoints"] = []any{warp.warpEndpoint()} + cfg["route"] = warp.warpRoute() + } + return json.MarshalIndent(cfg, "", " ") } diff --git a/server/internal/agentd/singbox.go b/server/internal/agentd/singbox.go index 70f5969..9cbf554 100644 --- a/server/internal/agentd/singbox.go +++ b/server/internal/agentd/singbox.go @@ -315,7 +315,15 @@ func (s *SingBox) RenderConfig() ([]byte, error) { hy2 := s.hy2 s.mu.Unlock() sort.Slice(creds, func(i, j int) bool { return creds[i].DpUUID < creds[j].DpUUID }) - return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey) + + // WARP 分流配置每次渲染读一次:编辑 warp.json 后任一渲染(或 agent 重启)即生效(#29)。 + // 读失败(坏 JSON)仅记日志、按未启用处理,绝不因坏配置产出无法启动的 sing-box 配置。 + warp, err := LoadWarpConfig(s.cfg.WarpConfigPath) + if err != nil { + logf("[warp] load %s failed, WARP routing disabled: %v", s.cfg.WarpConfigPath, err) + warp = nil + } + return renderSingboxConfig(creds, reality, hy2, s.cfg.DeriveKey, warp) } // writeAndRestart renders, writes the config file and restarts sing-box. diff --git a/server/internal/agentd/warp.go b/server/internal/agentd/warp.go new file mode 100644 index 0000000..acb40ee --- /dev/null +++ b/server/internal/agentd/warp.go @@ -0,0 +1,121 @@ +package agentd + +import ( + "encoding/json" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// WarpConfig 描述节点上「部分域名走 Cloudflare WARP 干净出口」的分流配置(#29)。 +// 由节点本地文件(默认 <StateDir>/warp.json)提供,agent 渲染 sing-box 配置时读取: +// 存在且 enabled 且有域名 → 注入一个 WireGuard(WARP) endpoint + 域名分流 route 规则, +// 命中域名走 WARP、其余直连。运营改域名清单只需编辑该文件并重启 agent(sing-box 无热重载)。 +// +// WARP 凭证(private_key / peer_public_key / endpoint / address / reserved)由 wgcf +// 注册免费匿名 WARP 账号得到,是节点私有的,不入 git、不经控制面。 +type WarpConfig struct { + Enabled bool `json:"enabled"` + PrivateKey string `json:"private_key"` + PeerPublicKey string `json:"peer_public_key"` + Endpoint string `json:"endpoint"` // host:port,如 162.159.192.1:2408 + Address []string `json:"address"` // 本端 WARP 分配地址,如 ["172.16.0.2/32","2606:4700:110:...::/128"] + Reserved []int `json:"reserved"` // WARP client reserved 三字节(可空) + MTU int `json:"mtu"` // 缺省 1280 + Domains []string `json:"domains"` // 走 WARP 的域名后缀,如 ["reddit.com","redd.it"] +} + +// LoadWarpConfig 读取并解析 warp.json。文件不存在 → 返回 (nil, nil)(WARP 未启用, +// 不是错误)。解析失败或字段缺失才返回 error,避免坏配置静默退化。 +func LoadWarpConfig(path string) (*WarpConfig, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("agentd: read warp config %q: %w", path, err) + } + var wc WarpConfig + if err := json.Unmarshal(data, &wc); err != nil { + return nil, fmt.Errorf("agentd: parse warp config %q: %w", path, err) + } + return &wc, nil +} + +// active 报告本配置是否应真正注入分流(启用、凭证齐全、至少一个域名)。 +// 任一必需字段缺失都返回 false —— 宁可不分流(全直连)也不产出坏 sing-box 配置。 +func (wc *WarpConfig) active() bool { + if wc == nil || !wc.Enabled || len(wc.Domains) == 0 { + return false + } + if wc.PrivateKey == "" || wc.PeerPublicKey == "" || wc.Endpoint == "" || len(wc.Address) == 0 { + return false + } + host, _, err := net.SplitHostPort(wc.Endpoint) + return err == nil && host != "" +} + +// mtu 返回配置的 MTU 或缺省 1280(WARP 常用值)。 +func (wc *WarpConfig) mtu() int { + if wc.MTU > 0 { + return wc.MTU + } + return 1280 +} + +// cleanDomains 去空白/空项后返回域名清单(用于 domain_suffix)。 +func (wc *WarpConfig) cleanDomains() []string { + out := make([]string, 0, len(wc.Domains)) + for _, d := range wc.Domains { + d = strings.TrimSpace(strings.ToLower(d)) + if d != "" { + out = append(out, d) + } + } + return out +} + +// endpointHostPort 拆 Endpoint 为 host + port(active() 已校验可拆)。 +func (wc *WarpConfig) endpointHostPort() (string, int) { + host, portStr, _ := net.SplitHostPort(wc.Endpoint) + port, _ := strconv.Atoi(portStr) + return host, port +} + +// warpEndpoint 构造 sing-box 1.11+ 的 WireGuard endpoint(userspace,无需内核 wg 模块)。 +// tag = "warp",route 规则以此 tag 作 outbound。 +func (wc *WarpConfig) warpEndpoint() map[string]any { + host, port := wc.endpointHostPort() + peer := map[string]any{ + "address": host, + "port": port, + "public_key": wc.PeerPublicKey, + "allowed_ips": []string{"0.0.0.0/0", "::/0"}, + } + if len(wc.Reserved) == 3 { + peer["reserved"] = wc.Reserved + } + return map[string]any{ + "type": "wireguard", + "tag": warpOutboundTag, + "system": false, // gVisor 用户态,不依赖内核 wireguard + "mtu": wc.mtu(), + "address": wc.Address, + "private_key": wc.PrivateKey, + "peers": []any{peer}, + } +} + +// warpRoute 构造分流 route:先 sniff 取出 SNI/Host(客户端多半发的是已解析 IP, +// 不 sniff 域名规则无从命中),命中域名后缀走 warp,其余 final=direct。 +func (wc *WarpConfig) warpRoute() map[string]any { + return map[string]any{ + "rules": []any{ + map[string]any{"action": "sniff"}, + map[string]any{"domain_suffix": wc.cleanDomains(), "outbound": warpOutboundTag}, + }, + "final": directOutboundTag, + } +} diff --git a/server/internal/agentd/warp_test.go b/server/internal/agentd/warp_test.go new file mode 100644 index 0000000..f4520f7 --- /dev/null +++ b/server/internal/agentd/warp_test.go @@ -0,0 +1,152 @@ +package agentd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +// writeWarp 把 warp.json 写到 cfg 的 WarpConfigPath。 +func writeWarp(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +const validWarp = `{ + "enabled": true, + "private_key": "aW52YWxpZC1rZXk=", + "peer_public_key": "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=", + "endpoint": "162.159.192.1:2408", + "address": ["172.16.0.2/32", "2606:4700:110:8abc::/128"], + "reserved": [1, 2, 3], + "mtu": 1280, + "domains": ["reddit.com", "redd.it"] +}` + +// 无 warp.json → 配置里既无 endpoints 也无 route(向后兼容,与旧节点逐字节一致)。 +func TestRender_NoWarp_NoRouteSection(t *testing.T) { + sb := NewSingBox(testConfig(t), nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatal(err) + } + if _, ok := cfg["endpoints"]; ok { + t.Error("no warp.json but endpoints present") + } + if _, ok := cfg["route"]; ok { + t.Error("no warp.json but route present") + } +} + +// 有效 warp.json → 注入 WireGuard endpoint(tag=warp,userspace)+ 域名分流 route。 +func TestRender_Warp_InjectsEndpointAndRoute(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, validWarp) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("rendered config invalid JSON: %v", err) + } + + eps, ok := m["endpoints"].([]any) + if !ok || len(eps) != 1 { + t.Fatalf("want 1 endpoint, got %v", m["endpoints"]) + } + ep := eps[0].(map[string]any) + if ep["type"] != "wireguard" || ep["tag"] != "warp" { + t.Errorf("endpoint type/tag wrong: %v", ep) + } + if ep["system"] != false { + t.Errorf("WARP endpoint must be userspace (system=false), got %v", ep["system"]) + } + peers := ep["peers"].([]any) + peer := peers[0].(map[string]any) + if peer["public_key"] != "bmXOC+F1FxEMF9dyiK2H5/1SUtzH0JuVo51h2wPfgyo=" { + t.Errorf("peer public_key wrong: %v", peer["public_key"]) + } + if peer["address"] != "162.159.192.1" { + t.Errorf("peer address wrong: %v", peer["address"]) + } + + route := m["route"].(map[string]any) + if route["final"] != "direct" { + t.Errorf("route.final = %v, want direct", route["final"]) + } + rules := route["rules"].([]any) + // 首条必须是 sniff(否则客户端发来的已解析 IP 无域名可匹配)。 + if rules[0].(map[string]any)["action"] != "sniff" { + t.Errorf("first route rule must be sniff, got %v", rules[0]) + } + last := rules[len(rules)-1].(map[string]any) + if last["outbound"] != "warp" { + t.Errorf("domain rule must route to warp, got %v", last) + } + if !strings.Contains(string(data), "reddit.com") { + t.Error("configured domain reddit.com not in route") + } +} + +// enabled=false 或域名为空 → 视为未启用,不注入(坏配置宁可全直连)。 +func TestRender_Warp_DisabledOrIncomplete(t *testing.T) { + cases := map[string]string{ + "disabled": strings.Replace(validWarp, `"enabled": true`, `"enabled": false`, 1), + "no-domains": strings.Replace(validWarp, `["reddit.com", "redd.it"]`, `[]`, 1), + "no-key": strings.Replace(validWarp, `"private_key": "aW52YWxpZC1rZXk=",`, `"private_key": "",`, 1), + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, body) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatal(err) + } + var m map[string]any + _ = json.Unmarshal(data, &m) + if _, ok := m["route"]; ok { + t.Errorf("%s: route must be absent", name) + } + }) + } +} + +// 坏 JSON → 渲染不报错、按未启用处理(不产出无法启动的配置)。 +func TestRender_Warp_BadJSONDegradesGracefully(t *testing.T) { + cfg := testConfig(t) + writeWarp(t, cfg.WarpConfigPath, `{ this is not json `) + sb := NewSingBox(cfg, nil) + sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) + data, err := sb.RenderConfig() + if err != nil { + t.Fatalf("bad warp.json must not fail render: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["route"]; ok { + t.Error("bad warp.json must degrade to no route") + } +} diff --git a/server/internal/auth/errors.go b/server/internal/auth/errors.go index cce6842..abea305 100644 --- a/server/internal/auth/errors.go +++ b/server/internal/auth/errors.go @@ -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", + } ) diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index fde1423..24389e6 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -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 diff --git a/server/internal/auth/integration_test.go b/server/internal/auth/integration_test.go index 49b623c..e3fb5bf 100644 --- a/server/internal/auth/integration_test.go +++ b/server/internal/auth/integration_test.go @@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) { } // 2. Register → trial subscription must exist for 7 days. - pair, apiErr := svc.Register(ctx, email, code, pw) + pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}) if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -170,12 +170,12 @@ func TestIntegration_FullChain(t *testing.T) { } // Force a fresh code regardless of rate limit. _ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err() - if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", e) } // 4. Login. - loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7") + loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7", DeviceMeta{}) if apiErr != nil { t.Fatalf("Login: %v", apiErr) } diff --git a/server/internal/auth/web_ticket.go b/server/internal/auth/web_ticket.go new file mode 100644 index 0000000..f27a8fb --- /dev/null +++ b/server/internal/auth/web_ticket.go @@ -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 +} diff --git a/server/internal/auth/web_ticket_test.go b/server/internal/auth/web_ticket_test.go new file mode 100644 index 0000000..0f884aa --- /dev/null +++ b/server/internal/auth/web_ticket_test.go @@ -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) + } +} diff --git a/server/internal/devices/context.go b/server/internal/devices/context.go index 7754bec..8d4de6a 100644 --- a/server/internal/devices/context.go +++ b/server/internal/devices/context.go @@ -3,17 +3,21 @@ package devices import ( "context" "time" + + "github.com/wangjia/pangolin/server/internal/codes" ) // ctxKey is a private type for context keys to avoid collisions. type ctxKey string // CtxKeyUserID is the context key under which the authenticated user's -// internal int64 ID is stored by the JWT auth middleware (module #2). +// internal int64 ID is stored by the JWT auth middleware (auth.RequireAuth). // -// It mirrors the key used by the codes module so that, once the auth -// middleware lands, a single canonical key can be reconciled across modules. -const CtxKeyUserID ctxKey = "user_id" +// It is the single canonical key shared with the codes and auth modules +// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather +// than declaring a distinct devices.ctxKey("user_id") — ensures the devices +// middleware/handlers resolve the same value the auth middleware injects. +const CtxKeyUserID = codes.CtxKeyUserID // ctxKeyPlan is the context key under which the resolved subscription Plan is // stored by SubscriptionMiddleware. diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go index 2ae718a..6e0c2ba 100644 --- a/server/internal/devices/devices_integration_test.go +++ b/server/internal/devices/devices_integration_test.go @@ -70,13 +70,18 @@ func applySchema(db *sql.DB) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS devices ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - uuid CHAR(36) NOT NULL UNIQUE, - user_id BIGINT UNSIGNED NOT NULL, - name VARCHAR(64) NOT NULL, - platform ENUM('ios','android','windows','macos') NOT NULL, - last_seen DATETIME(6) NULL, - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + platform ENUM('ios','android','windows','macos','linux') NOT NULL, + last_seen DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + client_version VARCHAR(32) NULL, + totp_trusted_until DATETIME(6) NULL, + dp_uuid CHAR(36) NULL, + UNIQUE KEY uniq_devices_user_uuid (user_id, uuid), + UNIQUE KEY idx_devices_dp_uuid (dp_uuid), FOREIGN KEY (user_id) REFERENCES users(id), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, @@ -112,7 +117,7 @@ func applySchema(db *sql.DB) error { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate) - VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`, + VALUES ('free', 1, 10, TRUE), ('pro', 3, NULL, FALSE), ('team', 10, NULL, FALSE)`, } for _, stmt := range stmts { if _, err := db.Exec(stmt); err != nil { @@ -330,9 +335,10 @@ func TestDeleteOthersDevice(t *testing.T) { t.Fatalf("register: %v", apiErr) } - // Other user cannot delete it → 403 FORBIDDEN. - if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" { - t.Errorf("want FORBIDDEN, got %v", apiErr) + // Other user cannot delete it → 404 NOT_FOUND(查找按 (user,uuid) 作用域, + // 他人名下的行不可见,migration 21 起不再是 403)。 + if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "NOT_FOUND" { + t.Errorf("want NOT_FOUND, got %v", apiErr) } // Non-existent device → 404 NOT_FOUND. if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" { @@ -340,6 +346,65 @@ func TestDeleteOthersDevice(t *testing.T) { } } +// TestSameDeviceUUIDTwoAccounts:F3 回归——同一物理设备(同 device uuid)先后登录 +// 两个账号,双方都能注册成功、各自成行,互不 403;各自的删除只影响自己名下的行。 +func TestSameDeviceUUIDTwoAccounts(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + + userA := createUser(t, db, "a-shared@example.com", "active") + userB := createUser(t, db, "b-shared@example.com", "active") + devUUID := newUUID(t, db) // 同一台机器的持久 device_id + + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userA, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register user A: %v", apiErr) + } + // 换账号:同 uuid 注册到 user B —— 旧全局 UNIQUE(uuid) 下这里是 403 死结。 + if _, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userB, DeviceUUID: devUUID, Name: "Shared Mac", Platform: "macos", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register user B (same device uuid): %v", apiErr) + } + + // 各自名下都各有一行。 + for _, uid := range []int64{userA, userB} { + list, apiErr := svc.ListDevices(ctx, uid) + if apiErr != nil { + t.Fatalf("list %d: %v", uid, apiErr) + } + n := 0 + for _, d := range list { + if d.UUID == devUUID { + n++ + } + } + if n != 1 { + t.Errorf("user %d: want 1 row for shared uuid, got %d", uid, n) + } + } + + // A 删除自己的行,不影响 B 的行。 + if apiErr := svc.DeleteDevice(ctx, userA, devUUID); apiErr != nil { + t.Fatalf("delete A: %v", apiErr) + } + listB, apiErr := svc.ListDevices(ctx, userB) + if apiErr != nil { + t.Fatalf("list B after A delete: %v", apiErr) + } + found := false + for _, d := range listB { + if d.UUID == devUUID { + found = true + } + } + if !found { + t.Errorf("user B's row must survive user A's delete") + } +} + // TestBannedUserRejected verifies the resolver/middleware path rejects banned users. func TestBannedUserRejected(t *testing.T) { db := setupMySQL(t) diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 0927a86..4779fbd 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -201,16 +201,13 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int return 0, nil, apierr.ErrAccountBanned } - existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) + // 按 (user,uuid) 查:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备 + // 在别的账号名下的行与本次注册无关 —— 同机换账号各自成行,不再互相 403(F3)。 + existing, err := svc.store.findDeviceByUserUUIDTx(ctx, tx, in.UserID, uuid) if err != nil { return 0, nil, apierr.ErrInternal } if existing != nil { - if existing.UserID != in.UserID { - // UUID is client-generated; a collision across users is treated as - // a conflict rather than silently rebinding the device. - return 0, nil, apierr.ErrForbidden - } if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID, in.ClientVersion); err != nil { return 0, nil, apierr.ErrInternal } @@ -253,26 +250,25 @@ func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (int // and then triggers per-user credential recall on the node side. // // - device not found → 404 NOT_FOUND -// - device owned by another user → 403 FORBIDDEN (does not delete) +// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible) func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) if uuid == "" { return apierr.ErrBadRequest } - // Resolve + ownership check (non-tx) so sessions can be revoked BEFORE the - // delete tx: SQLite (_txlock=immediate) holds a write lock for the tx, so a - // session write on another pool connection would deadlock against it. - dev, err := svc.store.FindByUUID(ctx, uuid) + // Resolve (non-tx) so sessions can be revoked BEFORE the delete tx: SQLite + // (_txlock=immediate) holds a write lock for the tx, so a session write on + // another pool connection would deadlock against it. Lookup is user-scoped + // (UNIQUE(user_id,uuid)) — other users' rows with the same uuid are invisible, + // so "not mine" and "not found" are both 404. + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } // Revoke the device's sessions (drop their refresh JTIs from Redis) while the // rows still exist; the device delete then cascades them away. @@ -319,22 +315,19 @@ func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID s // user can simply log in again. // // - device not found → 404 NOT_FOUND -// - device owned by another user → 403 FORBIDDEN +// - device owned by another user → 404 NOT_FOUND (user-scoped lookup; invisible) func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) if uuid == "" { return apierr.ErrBadRequest } - dev, err := svc.store.FindByUUID(ctx, uuid) + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } svc.revokeDeviceSessions(ctx, userID, dev.ID) return nil } @@ -343,7 +336,7 @@ func (svc *Service) ForceLogout(ctx context.Context, userID int64, deviceUUID st // 400; name is trimmed + truncated to 64 runes. // // - device not found → 404 -// - device owned by another user → 403 +// - device owned by another user → 404 (user-scoped lookup; invisible) func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, rawName string) *apierr.Error { uuid := strings.TrimSpace(deviceUUID) name := strings.TrimSpace(rawName) @@ -353,16 +346,13 @@ func (svc *Service) RenameDevice(ctx context.Context, userID int64, deviceUUID, if r := []rune(name); len(r) > 64 { name = string(r[:64]) } - dev, err := svc.store.FindByUUID(ctx, uuid) + dev, err := svc.store.FindByUserUUID(ctx, userID, uuid) if err != nil { return apierr.ErrInternal } if dev == nil { return apierr.ErrNotFound } - if dev.UserID != userID { - return apierr.ErrForbidden - } if err := svc.store.UpdateName(ctx, dev.ID, name); err != nil { return apierr.ErrInternal } @@ -378,12 +368,17 @@ func (svc *Service) SessionActive(ctx context.Context, userID int64, deviceUUID if svc.sessions == nil || strings.TrimSpace(deviceUUID) == "" { return true, nil } - dev, err := svc.store.FindByUUID(ctx, deviceUUID) + dev, err := svc.store.FindByUserUUID(ctx, userID, deviceUUID) if err != nil { return false, apierr.ErrInternal } - if dev == nil || dev.UserID != userID { - return true, nil // 未知 / 非本人设备:不据此登出 + if dev == nil { + // 本用户名下无此设备行 = 本设备被「移除」(DeleteDevice 删行)。已登录的客户端在 + // 登录时必然注册过自己的设备,轮询自身 device_id 却查无此行,只能是被移除 → 视为 + // 会话失效,让其登出(否则被移除的设备永远收到 active=true,不退出,只表现为数据面 + // 被断→「节点异常」)。查找按 (user,uuid) 作用域,他人账号下的同 uuid 行不可见, + // 不存在旧「非本人设备 fail-safe」分支。 + return false, nil } active, err := svc.sessions.HasActiveSession(ctx, userID, dev.ID) if err != nil { diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index 7fdd510..d3f4ddf 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -77,21 +77,24 @@ func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, erro return out, rows.Err() } -// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx. -// Returns (nil, nil) when the device does not exist. -func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { +// findDeviceByUserUUIDTx looks up the user's device by UUID with FOR UPDATE +// inside tx. Returns (nil, nil) when the device does not exist for this user. +// 必须带 user_id:唯一键是 UNIQUE(user_id,uuid)(migration 21),同一物理设备的 +// uuid 可在多个账号下各有一行,全局按 uuid 查会歧义。 +func (s *Store) findDeviceByUserUUIDTx(ctx context.Context, tx *sql.Tx, userID int64, uuid string) (*DeviceRow, error) { row := tx.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid - FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), uuid) + FROM devices WHERE user_id=? AND uuid=? `+s.dialect.LockForUpdate(), userID, uuid) return scanDeviceRow(row) } -// FindByUUID looks up a device by UUID (non-tx). Returns (nil, nil) if absent. -// Used by force-logout/delete to resolve ownership + dp_uuid. -func (s *Store) FindByUUID(ctx context.Context, uuid string) (*DeviceRow, error) { +// FindByUserUUID looks up the user's device by UUID (non-tx). Returns (nil, nil) +// if absent for this user. Used by force-logout/delete/rename/session-poll to +// resolve the device row; other users' rows with the same uuid are invisible. +func (s *Store) FindByUserUUID(ctx context.Context, userID int64, uuid string) (*DeviceRow, error) { row := s.db.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid - FROM devices WHERE uuid=?`, uuid) + FROM devices WHERE user_id=? AND uuid=?`, userID, uuid) return scanDeviceRow(row) } diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index 14b16d2..f8b57c0 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -31,7 +31,8 @@ type meResponse struct { // Web user-center fields. DevicesUsed int `json:"devices_used"` DevicesMax int `json:"devices_max"` - QuotaTodayMin *int `json:"quota_today_min"` // null = unlimited (pro/team) + QuotaTodayMin *int `json:"quota_today_min"` // 剩余分钟; null = unlimited (pro/team) + QuotaCapMin *int `json:"quota_cap_min"` // 当日额度 = daily + 看广告 bonus; null = unlimited DataTodayGB float64 `json:"data_today_gb"` WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest TOTPEnabled bool `json:"totp_enabled"` @@ -99,11 +100,12 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { // 日期在 Go 端算好传 ?,不用 MySQL 专属 UTC_DATE()(否则 SQLite 报错→恒 0)。 var todayBytes uint64 var todayMinutes int + var todayAdBonus int today := time.Now().UTC().Format("2006-01-02") _ = a.db.QueryRowContext(ctx, ` - SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0) + SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0), COALESCE(ad_bonus_minutes, 0) FROM usage_daily WHERE user_id = ? AND date = ? - `, uid, today).Scan(&todayBytes, &todayMinutes) + `, uid, today).Scan(&todayBytes, &todayMinutes, &todayAdBonus) resp := meResponse{ UUID: uuid, @@ -121,11 +123,14 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { resp.ExpiresAt = &s } // quota_today_min: null when the plan is unlimited, else remaining minutes. + // 额度 = plan.daily_minutes + 当日看广告累加的 ad_bonus_minutes(账户共享)。 if dailyMinutes.Valid { - rem := int(dailyMinutes.Int64) - todayMinutes + cap := int(dailyMinutes.Int64) + todayAdBonus + rem := cap - todayMinutes if rem < 0 { rem = 0 } + resp.QuotaCapMin = &cap resp.QuotaTodayMin = &rem } diff --git a/server/internal/httpapi/contract_test.go b/server/internal/httpapi/contract_test.go index 35775d1..fc18f45 100644 --- a/server/internal/httpapi/contract_test.go +++ b/server/internal/httpapi/contract_test.go @@ -56,6 +56,6 @@ func TestContractMeResponse(t *testing.T) { assertFrozenKeys(t, "/v1/me", jsonTagSet(meResponse{}), "uuid", "email", "dp_uuid", "plan", "expires_at", "devices_used", "devices_max", - "quota_today_min", "data_today_gb", "weekly_gb", "totp_enabled", + "quota_today_min", "quota_cap_min", "data_today_gb", "weekly_gb", "totp_enabled", ) } diff --git a/server/internal/httpapi/cors.go b/server/internal/httpapi/cors.go new file mode 100644 index 0000000..56f4bac --- /dev/null +++ b/server/internal/httpapi/cors.go @@ -0,0 +1,52 @@ +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 == "" { + // 用户中心已迁到主站子路径 pangolin.yanmeiai.com/user/,其 origin 即主站域名 + // (旧 app.yanmeiai.com 已停用)。pages.dev 为 CF Pages 直连兜底。 + raw = "https://pangolin.yanmeiai.com,https://pangolin-site.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") + // X-Refresh-Token:静默续期/登出把 refresh token 放在此自定义头里 + // (header token 方案,见 web/usercenter/lib/api/http.ts)。不放行则浏览器 + // 预检拦截 /v1/auth/refresh → 登录后一刷新即掉线(login 不带此头故不受影响)。 + h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Refresh-Token") + 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) + }) + } +} diff --git a/server/internal/httpapi/cors_test.go b/server/internal/httpapi/cors_test.go new file mode 100644 index 0000000..390f3c0 --- /dev/null +++ b/server/internal/httpapi/cors_test.go @@ -0,0 +1,53 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCORS(t *testing.T) { + t.Setenv("CORS_ORIGINS", "https://pangolin.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://pangolin.yanmeiai.com") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://pangolin.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://pangolin.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") + } + // 静默续期/登出把 refresh token 放 X-Refresh-Token 头,必须在允许头里, + // 否则浏览器预检拦截 /v1/auth/refresh → 登录后一刷新即掉线。 + if ah := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(ah, "X-Refresh-Token") { + t.Fatalf("preflight Allow-Headers must include X-Refresh-Token, got %q", ah) + } + + // 未白名单 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) + } +} diff --git a/server/internal/httpapi/downloads.go b/server/internal/httpapi/downloads.go new file mode 100644 index 0000000..8249cf8 --- /dev/null +++ b/server/internal/httpapi/downloads.go @@ -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) +} diff --git a/server/internal/httpapi/downloads_test.go b/server/internal/httpapi/downloads_test.go new file mode 100644 index 0000000..4c3994f --- /dev/null +++ b/server/internal/httpapi/downloads_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index d36a3f6..76e3aa6 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -206,16 +206,33 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // 2. Determine TTL from plan. var ttl time.Duration if ent.AdGate { - // Free plan: flat 10-minute session for MVP (full ad-gate in a later pass). + // Free plan: enforce the account-wide daily minute quota (shared across + // ALL devices). allowance = plan.daily_minutes + 当日看广告累加的 bonus; + // remaining = allowance − minutes_used. Credential TTL = remaining so the + // data-plane hard-cuts when time runs out (backstop even if the client + // countdown is bypassed). remaining ≤ 0 → 拒 QUOTA_EXHAUSTED, client 弹广告/升级. dm := int64(10) if ent.DailyMinutes.Valid { dm = ent.DailyMinutes.Int64 } - if dm <= 0 { - apierr.WriteJSON(w, http.StatusForbidden, apierr.ErrQuotaExhausted) + used, bonus, qerr := a.store.AccountDayMinutes(r.Context(), uid, time.Now().UTC()) + if qerr != nil { + slog.Error("connect: account day minutes failed", "user", uid, "err", qerr) + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } - ttl = time.Duration(dm) * freeMinuteTTL + remaining := dm + int64(bonus) - int64(used) + if remaining <= 0 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "QUOTA_EXHAUSTED", + "message_zh": "今日免费时长已用完,看广告加时或升级会员", + "message_en": "Daily free minutes used up. Watch an ad to add time or upgrade.", + }) + return + } + ttl = time.Duration(remaining) * freeMinuteTTL } else { ttl = paidCredentialTTL } @@ -315,7 +332,17 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) { // ─── POST /v1/nodes/{id}/disconnect ────────────────────────────────────────── +// disconnectRequest is the optional JSON body: device_id 指认要吊销凭证的设备。 +// 旧客户端不带 body(或不带 device_id)→ 仅吊销遗留账户级凭证(历史行为)。 +type disconnectRequest struct { + DeviceID string `json:"device_id"` +} + // DisconnectNode handles POST /v1/nodes/{id}/disconnect. +// +// F4:connect 下发的是每设备凭证(EnsureDeviceDpUUID),吊销也必须对准它—— +// 此前这里只撤账户级 ent.DpUUID,设备凭证一直活到 TTL(付费 24h),「断开」在 +// 服务端形同空转。现按 device_id 吊销该设备的 dp_uuid,账户级作为遗留兜底仍撤。 func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { @@ -329,6 +356,10 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { return } + // Optional body(旧客户端无 body → device_id 为空,走兜底路径)。 + var req disconnectRequest + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024)).Decode(&req) + // Load dp_uuid. ent, err := a.store.EntitlementForUser(r.Context(), uid) if err != nil { @@ -350,14 +381,33 @@ func (a *NodeAPI) DisconnectNode(w http.ResponseWriter, r *http.Request) { return } - // Push revoke command. - _ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ - Type: agentv1.CommandTypeRevoke, - Revoke: &agentv1.RevokePayload{DpUUID: ent.DpUUID}, - }) + // 收集待吊销凭证:每设备(connect 真正下发的)+ 账户级(遗留兜底)。 + dpUUIDs := make([]string, 0, 2) + if devID := strings.TrimSpace(req.DeviceID); devID != "" { + devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, devID) + if derr != nil && !errors.Is(derr, nodes.ErrDeviceNotFound) { + slog.Warn("disconnect: device dp_uuid lookup failed", "user", uid, "device", devID, "err", derr) + } + if devDp != "" { + dpUUIDs = append(dpUUIDs, devDp) + } + } + if ent.DpUUID != "" { + dpUUIDs = append(dpUUIDs, ent.DpUUID) + } - // Delete persisted credential. - _ = a.store.DeleteCredential(r.Context(), node.ID, ent.DpUUID) + for _, dp := range dpUUIDs { + // Push revoke command(best-effort:agent 离线时命令进 Redis 队列,重连即达; + // 删除持久化凭证后 resync 也不会再下发)。nil hub = 测试环境,跳过推送。 + if a.hub != nil { + _ = a.hub.Push(r.Context(), node.UUID, &agentv1.Command{ + Type: agentv1.CommandTypeRevoke, + Revoke: &agentv1.RevokePayload{DpUUID: dp}, + }) + } + // Delete persisted credential. + _ = a.store.DeleteCredential(r.Context(), node.ID, dp) + } w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/httpapi/nodes_disconnect_test.go b/server/internal/httpapi/nodes_disconnect_test.go new file mode 100644 index 0000000..d8aab8a --- /dev/null +++ b/server/internal/httpapi/nodes_disconnect_test.go @@ -0,0 +1,113 @@ +package httpapi + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/nodes" + agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" +) + +// fakeDisconnectStore implements the NodeStore methods DisconnectNode touches; +// everything else panics via the embedded nil interface (未用到即安全)。 +type fakeDisconnectStore struct { + nodes.NodeStore // embed:未实现的方法调用即 panic,测试只走下面四个 + + node *nodes.NodeRow + ent *nodes.Entitlement + devDp string // EnsureDeviceDpUUID 返回值;"" = 设备不存在 + deleted []string +} + +func (f *fakeDisconnectStore) EntitlementForUser(context.Context, int64) (*nodes.Entitlement, error) { + return f.ent, nil +} + +func (f *fakeDisconnectStore) NodeByUUID(context.Context, string) (*nodes.NodeRow, error) { + return f.node, nil +} + +func (f *fakeDisconnectStore) EnsureDeviceDpUUID(context.Context, int64, string) (string, int64, error) { + if f.devDp == "" { + return "", 0, nodes.ErrDeviceNotFound + } + return f.devDp, 7, nil +} + +func (f *fakeDisconnectStore) DeleteCredential(_ context.Context, _ int64, dpUUID string) error { + f.deleted = append(f.deleted, dpUUID) + return nil +} + +func (f *fakeDisconnectStore) PersistCredential(context.Context, int64, *agentv1.Credential, time.Time) error { + return nil +} + +func doDisconnect(t *testing.T, store *fakeDisconnectStore, body string) int { + t.Helper() + api := NewNodeAPI(store, nil, nil, "", "") // nil hub:跳过 Push,只验证凭证删除 + req := httptest.NewRequest("POST", "/v1/nodes/node-1/disconnect", strings.NewReader(body)) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "node-1") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + ctx = context.WithValue(ctx, codes.CtxKeyUserID, int64(42)) + rec := httptest.NewRecorder() + api.DisconnectNode(rec, req.WithContext(ctx)) + return rec.Code +} + +// F4 回归:带 device_id 的 disconnect 必须吊销**每设备** dp_uuid(connect 真正 +// 下发的那个),账户级作为遗留兜底也一并吊销。 +func TestDisconnectNode_RevokesDeviceCredential(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + devDp: "device-dp", + } + if code := doDisconnect(t, store, `{"device_id":"dev-uuid-1"}`); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + want := map[string]bool{"device-dp": true, "acct-dp": true} + if len(store.deleted) != 2 || !want[store.deleted[0]] || !want[store.deleted[1]] { + t.Errorf("deleted = %v, want both device-dp and acct-dp", store.deleted) + } + // 每设备凭证在前(connect 真正下发的),账户级兜底在后。 + if store.deleted[0] != "device-dp" { + t.Errorf("device credential should be revoked first, got %v", store.deleted) + } +} + +// 旧客户端无 body → 仅账户级兜底(历史行为不回归)。 +func TestDisconnectNode_LegacyNoBody(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + } + if code := doDisconnect(t, store, ""); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" { + t.Errorf("deleted = %v, want only acct-dp", store.deleted) + } +} + +// 设备不存在(已被移除)→ 不炸,仍撤账户级。 +func TestDisconnectNode_DeviceGone(t *testing.T) { + store := &fakeDisconnectStore{ + node: &nodes.NodeRow{ID: 1, UUID: "node-1", Status: "up"}, + ent: &nodes.Entitlement{DpUUID: "acct-dp"}, + devDp: "", // ErrDeviceNotFound + } + if code := doDisconnect(t, store, `{"device_id":"gone"}`); code != 204 { + t.Fatalf("status = %d, want 204", code) + } + if len(store.deleted) != 1 || store.deleted[0] != "acct-dp" { + t.Errorf("deleted = %v, want only acct-dp", store.deleted) + } +} diff --git a/server/internal/httpapi/version.go b/server/internal/httpapi/version.go new file mode 100644 index 0000000..58c19d5 --- /dev/null +++ b/server/internal/httpapi/version.go @@ -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) +} diff --git a/server/internal/httpapi/version_test.go b/server/internal/httpapi/version_test.go new file mode 100644 index 0000000..652a41e --- /dev/null +++ b/server/internal/httpapi/version_test.go @@ -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) + } +} diff --git a/server/internal/nodes/grpc_test.go b/server/internal/nodes/grpc_test.go index 90dd817..f165cf3 100644 --- a/server/internal/nodes/grpc_test.go +++ b/server/internal/nodes/grpc_test.go @@ -146,6 +146,10 @@ func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time) return 0, nil } +func (m *mockNodeStore) AccountDayMinutes(_ context.Context, _ int64, _ time.Time) (int, int, error) { + return 0, 0, nil +} + func (m *mockNodeStore) CountActiveDevices(_ context.Context, _ int64, _ time.Time) (int, error) { return 0, nil } @@ -938,6 +942,72 @@ func TestReportUsage_Accumulates(t *testing.T) { } } +// TestReportUsage_AccountMinutesDedupPerUser verifies the account-level minutes are +// deduped to WALL-CLOCK per user within a window: two devices of the same user active +// in one window count as 1 minute (not 2), while bytes still sum and each device keeps +// its own minute. Guards the多设备并发超计 fix. +func TestReportUsage_AccountMinutesDedupPerUser(t *testing.T) { + const nodeUUID = "test-node-usage-dedup" + b := newTestServer(t, 1, nodeUUID) + ctx := context.Background() + // Two devices of user 101 (dev 55 & 66) + one device of user 202 (dev 77). + b.store.devicesByDpUUID = map[string][2]int64{ + "dp-u1-d1": {101, 55}, + "dp-u1-d2": {101, 66}, + "dp-u2-d1": {202, 77}, + } + + _, _, conn := enrollNode(t, b, nodeUUID) + client := agentv1.NewAgentServiceClient(conn) + if _, err := client.Register(ctx, &agentv1.RegisterRequest{NodeUUID: nodeUUID}); err != nil { + t.Fatalf("Register: %v", err) + } + + now := time.Now() + if _, err := client.ReportUsage(ctx, &agentv1.UsageReport{ + NodeUUID: nodeUUID, + WindowStartUnix: now.Add(-time.Minute).Unix(), + WindowEndUnix: now.Unix(), + Entries: []*agentv1.UsageEntry{ + {DpUUID: "dp-u1-d1", BytesUp: 100, BytesDown: 200, SessionMinutes: 1}, + {DpUUID: "dp-u1-d2", BytesUp: 10, BytesDown: 20, SessionMinutes: 1}, + {DpUUID: "dp-u2-d1", BytesUp: 5, BytesDown: 7, SessionMinutes: 1}, + }, + }); err != nil { + t.Fatalf("ReportUsage: %v", err) + } + + // Account rollup: one entry per user; user 101 minutes deduped to 1 (not 2), + // bytes summed across its two devices; user 202 = 1 minute. + byUser := map[int64]mockUsageEntry{} + for _, e := range b.store.usageLog() { + byUser[e.UserID] = e + } + if len(byUser) != 2 { + t.Fatalf("account rollup should have 2 users, got %d", len(byUser)) + } + if u := byUser[101]; u.Minutes != 1 || u.BytesUp != 110 || u.BytesDown != 220 { + t.Errorf("user101 account: minutes=%d bytesUp=%d bytesDown=%d, want 1/110/220 (墙上时钟去重)", + u.Minutes, u.BytesUp, u.BytesDown) + } + if u := byUser[202]; u.Minutes != 1 || u.BytesUp != 5 { + t.Errorf("user202 account: minutes=%d bytesUp=%d, want 1/5", u.Minutes, u.BytesUp) + } + + // Per-device: each of user101's two devices keeps its own minute (2 rows, 2 min total). + var u1Rows int + var u1Min int64 + for _, d := range b.store.deviceUsageLog() { + if d.UserID == 101 { + u1Rows++ + u1Min += d.Minutes + } + } + if u1Rows != 2 || u1Min != 2 { + t.Errorf("user101 per-device: rows=%d totalMinutes=%d, want 2/2 (每设备各计 1)", u1Rows, u1Min) + } +} + // TestReportUsage_PerDevice verifies a per-device dp_uuid is dual-written: account // rollup (usage_daily) AND per-device attribution (usage_device_daily). func TestReportUsage_PerDevice(t *testing.T) { diff --git a/server/internal/nodes/handler_grpc.go b/server/internal/nodes/handler_grpc.go index 0eb80fe..c7f630b 100644 --- a/server/internal/nodes/handler_grpc.go +++ b/server/internal/nodes/handler_grpc.go @@ -290,6 +290,13 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a windowEnd := time.Unix(req.WindowEndUnix, 0).UTC() date := windowEnd.Truncate(24 * time.Hour) + // Account-level minutes are deduped to WALL-CLOCK per user: the免费额度是「所有 + // 设备共同的时间」,同一账户多台设备在同一窗口都活跃时只能算 1 分钟,否则 N 台并发 → + // minutes_used = N×墙上时钟(超计,会把免费账号错误判耗尽)。字节是可加的 → 求和; + // 分钟按窗口取 max(= 窗口墙上分钟)去重。每设备维度(usage_device_*)仍逐台累加。 + type acctAgg struct{ bytesUp, bytesDown, minutes int64 } + byUser := make(map[int64]*acctAgg) + for _, entry := range req.Entries { if entry.DpUUID == "" { continue @@ -303,21 +310,18 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a if !found { continue } - // Account-level rollup (always): daily (quota/today) + hourly (tz-aware - // display curve, keyed by the window-end's UTC hour). - if err := h.store.AccumulateUsage(ctx, userID, date, - entry.BytesUp, entry.BytesDown, entry.SessionMinutes, - ); err != nil { - slog.Warn("nodes.Handler.ReportUsage: accumulate failed", - "user_id", userID, "err", err) + // Fold into the per-user account aggregate (bytes sum, minutes max = 墙上时钟去重). + a := byUser[userID] + if a == nil { + a = &acctAgg{} + byUser[userID] = a } - if err := h.store.AccumulateHourly(ctx, userID, windowEnd, - entry.BytesUp, entry.BytesDown, entry.SessionMinutes, - ); err != nil { - slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed", - "user_id", userID, "err", err) + a.bytesUp += entry.BytesUp + a.bytesDown += entry.BytesDown + if entry.SessionMinutes > a.minutes { + a.minutes = entry.SessionMinutes } - // Per-device attribution (only when the dp_uuid maps to a registered device; + // Per-device attribution (per entry — each device's own bytes/minutes; // deviceID==0 means a legacy account-level credential — no device dimension). if deviceID > 0 { if err := h.store.AccumulateDeviceUsage(ctx, userID, deviceID, date, @@ -340,5 +344,16 @@ func (h *Handler) ReportUsage(ctx context.Context, req *agentv1.UsageReport) (*a } } } + + // Flush the account-level rollup once per user (deduped wall-clock minutes): + // daily (quota/today) + hourly (tz-aware display curve, keyed by window-end's UTC hour). + for userID, a := range byUser { + if err := h.store.AccumulateUsage(ctx, userID, date, a.bytesUp, a.bytesDown, a.minutes); err != nil { + slog.Warn("nodes.Handler.ReportUsage: accumulate failed", "user_id", userID, "err", err) + } + if err := h.store.AccumulateHourly(ctx, userID, windowEnd, a.bytesUp, a.bytesDown, a.minutes); err != nil { + slog.Warn("nodes.Handler.ReportUsage: hourly accumulate failed", "user_id", userID, "err", err) + } + } return &agentv1.UsageAck{}, nil } diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index e3aaa25..b9291e4 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -109,6 +109,11 @@ type NodeStore interface { // date — the basis for the GB 综合配额 connect gate. 0 when no usage yet. AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error) + // AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes + // for userID on date — the basis for the免费版分钟配额 connect gate. Both 0 + // when no usage row exists yet. Quota is account-wide (shared across devices). + AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) + // CountActiveDevices counts the user's devices seen since cutoff (active). Used // by the connect device-limit backstop; stale rows are excluded. CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error) @@ -478,6 +483,22 @@ func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date t return total.Int64, nil } +// AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes for +// the day (0,0 when no usage row yet). +func (s *SQLNodeStore) AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) { + err = s.db.QueryRowContext(ctx, + `SELECT minutes_used, ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ?`, + userID, date.Format("2006-01-02"), + ).Scan(&used, &bonus) + if err == sql.ErrNoRows { + return 0, 0, nil + } + if err != nil { + return 0, 0, fmt.Errorf("nodes.SQLNodeStore.AccountDayMinutes: %w", err) + } + return used, bonus, nil +} + // CountActiveDevices counts the user's devices seen within the active window // (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect // backstop; stale/never-seen rows are excluded. diff --git a/server/internal/store/devices_login_swap_test.go b/server/internal/store/devices_login_swap_test.go new file mode 100644 index 0000000..723314e --- /dev/null +++ b/server/internal/store/devices_login_swap_test.go @@ -0,0 +1,75 @@ +package store_test + +import ( + "context" + "testing" + + "github.com/wangjia/pangolin/server/internal/devices" +) + +// TestSQLite_LoginRegistersSameDeviceForTwoAccounts reproduces the #27 (F3) fix +// at the exact code path login drives: recordLogin → devReg.RegisterDevice → +// devices.Service.RegisterIfAbsent{MaxDevices:0}. Two accounts on the SAME physical +// device (same device uuid) must each get their own devices row. +// +// Under the old global UNIQUE(uuid) the second account's registration failed +// (silently on login, best-effort) → the device row for account B never existed → +// ConnectNode later reported DEVICE_NOT_REGISTERED (the 403 deadlock, F3). +func TestSQLite_LoginRegistersSameDeviceForTwoAccounts(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + + mkUser := func(u, email string) int64 { + res, err := db.ExecContext(ctx, + `INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`, + u, email, "dp-"+u) + if err != nil { + t.Fatalf("user %s: %v", u, err) + } + id, _ := res.LastInsertId() + return id + } + userA := mkUser("u-a", "a@x.c") + userB := mkUser("u-b", "b@x.c") + + svc := devices.NewService(devices.NewStore(db), nil) + const sharedUUID = "shared-install-uuid" + + // Account A "logs in" on the device. + idA, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0, + }) + if apiErr != nil || idA == 0 { + t.Fatalf("A register: id=%d err=%v", idA, apiErr) + } + + // Account B "logs in" on the SAME physical device (same uuid) — the F3 case. + idB, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userB, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0, + }) + if apiErr != nil { + t.Fatalf("B register on same device uuid must succeed (F3 fix), got %v", apiErr) + } + if idB == 0 || idB == idA { + t.Fatalf("B must get its own device row, idA=%d idB=%d", idA, idB) + } + + // A logs in again → idempotent refresh of A's own row (not a new row). + idA2, _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userA, DeviceUUID: sharedUUID, Platform: "macos", MaxDevices: 0, + }) + if apiErr != nil || idA2 != idA { + t.Fatalf("A re-register must refresh same row: id=%d (want %d) err=%v", idA2, idA, apiErr) + } + + // Both rows coexist for the shared uuid, one per account. + var n int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM devices WHERE uuid=?`, sharedUUID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("shared uuid must have 2 rows (one per account), got %d", n) + } + t.Logf("OK: device uuid %q shared by account A (row #%d) + account B (row #%d)", sharedUUID, idA, idB) +} diff --git a/server/internal/store/sqlite_stores_test.go b/server/internal/store/sqlite_stores_test.go index d881523..83dae10 100644 --- a/server/internal/store/sqlite_stores_test.go +++ b/server/internal/store/sqlite_stores_test.go @@ -57,6 +57,51 @@ func TestSQLite_UsageAccumulate(t *testing.T) { } } +// AddAdBonusMinutes 是免费版累加式看广告加时的核心原语:每次广告 +N 分钟, +// 封顶 ceiling,返回新总额与本次实际加时(封顶后为 0)。 +func TestSQLite_AddAdBonusMinutes(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + us := usage.NewStore(db) + day := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) + + // First ad on a fresh day → insert row, bonus 10. + newBonus, granted, err := us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 10 || granted != 10 { + t.Fatalf("first: newBonus=%d granted=%d err=%v, want 10/10", newBonus, granted, err) + } + + // Second ad → accumulate to 20. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 20 || granted != 10 { + t.Fatalf("second: newBonus=%d granted=%d err=%v, want 20/10", newBonus, granted, err) + } + + // Third ad → clamped at ceiling 25, so only +5 granted. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 25 || granted != 5 { + t.Fatalf("third: newBonus=%d granted=%d err=%v, want 25/5", newBonus, granted, err) + } + + // Fourth ad → already at ceiling, 0 granted. + newBonus, granted, err = us.AddAdBonusMinutes(ctx, 7, day, 10, 25) + if err != nil || newBonus != 25 || granted != 0 { + t.Fatalf("fourth: newBonus=%d granted=%d err=%v, want 25/0", newBonus, granted, err) + } + + // Existing usage_daily columns are preserved alongside the bonus. + if err := us.AggregateUsage(ctx, 7, day, 0, 0, 4); err != nil { + t.Fatalf("aggregate: %v", err) + } + d, err := us.GetDay(ctx, 7, day) + if err != nil || d == nil { + t.Fatalf("getday: %v", err) + } + if d.AdBonusMinutes != 25 || d.MinutesUsed != 4 { + t.Errorf("getday wrong: bonus=%d used=%d, want 25/4", d.AdBonusMinutes, d.MinutesUsed) + } +} + // HasActiveSession 是「近实时远程下线」的服务端判据:有非吊销会话=在线, // 强制退出(RevokeByDevice)后=离线 → 客户端轮询到即登出。 func TestSQLite_SessionHasActiveSession(t *testing.T) { @@ -100,6 +145,69 @@ func TestSQLite_SessionHasActiveSession(t *testing.T) { } } +// TestSQLite_DevicesUserScopedUUID:F3 回归(migration 21)——同一物理设备的 +// device uuid 在两个账号下各自成行(UNIQUE(user_id,uuid)),同用户重复注册仍被 +// 唯一键拒绝;linux 平台可入库(CHECK 已放行);sessions 表在重建后 FK 仍指向新 +// devices(级联删除成立)。 +func TestSQLite_DevicesUserScopedUUID(t *testing.T) { + ctx := context.Background() + db := openSQLite(t) + + mkUser := func(u, email string) int64 { + res, err := db.ExecContext(ctx, + `INSERT INTO users (uuid, email, pw_hash, dp_uuid) VALUES (?, ?, 'h', ?)`, + u, email, "dp-"+u) + if err != nil { + t.Fatalf("user %s: %v", u, err) + } + id, _ := res.LastInsertId() + return id + } + userA := mkUser("u-a", "a@x.c") + userB := mkUser("u-b", "b@x.c") + + // 同一 device uuid,两个账号各自成行(旧全局 UNIQUE(uuid) 下第二条会失败)。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userA); err != nil { + t.Fatalf("register A: %v", err) + } + res, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac', 'macos')`, userB) + if err != nil { + t.Fatalf("register B (same uuid, other user) must succeed: %v", err) + } + devB, _ := res.LastInsertId() + + // 同用户重复注册仍被 UNIQUE(user_id,uuid) 拒绝。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('shared-dev', ?, 'Mac2', 'macos')`, userA); err == nil { + t.Fatalf("duplicate (user,uuid) must be rejected") + } + + // linux 平台可入库(migration 21 顺手放行,normalizePlatform 早已接受)。 + if _, err := db.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform) VALUES ('linux-dev', ?, 'NUC', 'linux')`, userA); err != nil { + t.Fatalf("linux platform must be accepted: %v", err) + } + + // sessions FK 重建后仍指向新 devices:删 B 的设备,B 的会话级联消失。 + ss := sessions.NewStore(db) + if err := ss.Create(ctx, userB, devB, "jti-b", "", ""); err != nil { + t.Fatalf("session B: %v", err) + } + if _, err := db.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, devB); err != nil { + t.Fatalf("delete devB: %v", err) + } + var n int + if err := db.QueryRowContext(ctx, + `SELECT COUNT(1) FROM sessions WHERE device_id=?`, devB).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + if n != 0 { + t.Errorf("sessions must cascade on device delete after rebuild, got %d rows", n) + } +} + func TestSQLite_NodeAccumulateUsage(t *testing.T) { ctx := context.Background() db := openSQLite(t) diff --git a/server/internal/usage/ads.go b/server/internal/usage/ads.go index 023b318..f3234e3 100644 --- a/server/internal/usage/ads.go +++ b/server/internal/usage/ads.go @@ -41,6 +41,19 @@ type AdVerifier interface { Provider() string } +// DevVerifier is a placeholder AdVerifier that accepts any receipt. It exists +// so the免费版看广告加时 flow can be exercised end-to-end with the client's +// placeholder ad dialog before a real ad SDK (AdMob SSV) is wired in. Nonce +// replay protection still applies at the service layer, so a token can only be +// redeemed once. MUST NOT be used in production with real ad revenue. +type DevVerifier struct{} + +// Verify always succeeds. +func (DevVerifier) Verify(context.Context, AdVerifyRequest) error { return nil } + +// Provider names this placeholder verifier. +func (DevVerifier) Provider() string { return "dev" } + // -------------------------------------------------------------------------- // AdMob Server-Side Verification (SSV) // -------------------------------------------------------------------------- diff --git a/server/internal/usage/handler.go b/server/internal/usage/handler.go index fb0fb2e..d5542e8 100644 --- a/server/internal/usage/handler.go +++ b/server/internal/usage/handler.go @@ -144,9 +144,16 @@ type adsUnlockRequest struct { AdToken string `json:"ad_token"` } -// ServeHTTP implements http.Handler. On success it returns 204 No Content -// (matching the OpenAPI contract), including the idempotent already-unlocked -// case. +// adsUnlockResponse reports the minutes granted by this rewarded-ad view and +// the account's new remaining minutes for today, so the client can refresh its +// quota without a second /me round-trip. +type adsUnlockResponse struct { + GrantedMinutes int `json:"granted_minutes"` + MinutesRemaining int `json:"minutes_remaining"` +} + +// ServeHTTP implements http.Handler. On success it returns 200 with the granted +// minutes and new remaining quota (免费版累加式看广告加时). func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -168,13 +175,15 @@ func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - _, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken) + granted, remaining, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken) if apiErr != nil { apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr) return } - w.WriteHeader(http.StatusNoContent) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(adsUnlockResponse{GrantedMinutes: granted, MinutesRemaining: remaining}) } // adsErrorStatus maps an ads-unlock error code to an HTTP status. diff --git a/server/internal/usage/quota.go b/server/internal/usage/quota.go index 731f305..eb0b185 100644 --- a/server/internal/usage/quota.go +++ b/server/internal/usage/quota.go @@ -11,16 +11,16 @@ import ( const defaultFreeDailyMinutes = 10 // CheckFreeConnect enforces the free-plan connect gate and returns the user's -// remaining minutes for today (UTC). It is called by #5's connect endpoint to -// derive the free credential's TTL. +// remaining minutes for today (UTC). The quota is account-wide (shared across +// all devices). // -// Rules: +// Rules (免费版累加式看广告加时): // - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited // when daily_minutes is NULL, otherwise the remaining minutes for the day. -// - plan.ad_gate == true (free): today's ad_unlocked_at must be set and -// minutes_used must be below daily_minutes (free = 10). Returns the -// remaining minutes; otherwise a bilingual semantic error -// (AD_NOT_UNLOCKED / QUOTA_EXHAUSTED). +// - plan.ad_gate == true (free): the day's allowance = daily_minutes (free=10) +// + ad_bonus_minutes (accumulated by watching rewarded ads). No ad is +// required to connect at all — the base 10 minutes are always available. +// Returns allowance − minutes_used; QUOTA_EXHAUSTED when that reaches 0. func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) { plan, err := svc.store.EffectivePlan(ctx, userID) if err != nil { @@ -51,16 +51,19 @@ func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remaini return remaining, nil } - // Free plan: require ad unlock + remaining minutes. + // Free plan: allowance = base daily + ad bonus; remaining = allowance − used. day, err := svc.store.GetDay(ctx, userID, utcToday()) if err != nil { return 0, apierr.ErrInternal } - if day == nil || !day.AdUnlockedAt.Valid { - return 0, apierr.ErrAdNotUnlocked + allowance := limit + used := 0 + if day != nil { + allowance = limit + day.AdBonusMinutes + used = day.MinutesUsed } - if day.MinutesUsed >= limit { + if used >= allowance { return 0, apierr.ErrQuotaExhausted } - return limit - day.MinutesUsed, nil + return allowance - used, nil } diff --git a/server/internal/usage/service.go b/server/internal/usage/service.go index cfa289e..3882966 100644 --- a/server/internal/usage/service.go +++ b/server/internal/usage/service.go @@ -13,6 +13,14 @@ import ( // nowFunc is overridable in tests to pin "today". var nowFunc = time.Now +const ( + // adBonusPerAd is how many minutes one rewarded-ad view grants (免费版加时). + adBonusPerAd = 10 + // adDailyBonusCeiling caps the total ad-granted minutes per UTC day, so the + // free tier can't be extended indefinitely by ad-watching. + adDailyBonusCeiling = 120 +) + // utcToday returns the current UTC calendar date (time truncated). func utcToday() time.Time { n := nowFunc().UTC() @@ -159,8 +167,10 @@ func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([] // TodaySummary is the /v1/me today_usage block. type TodaySummary struct { MinutesUsed int `json:"minutes_used"` + MinutesCap *int `json:"minutes_cap"` // 当日额度 = daily + ad_bonus; nil = unlimited MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team) - AdUnlocked bool `json:"ad_unlocked"` + AdBonusMinutes int `json:"ad_bonus_minutes"` // 当日已通过看广告累加的分钟 + AdUnlocked bool `json:"ad_unlocked"` // 历史字段:当日曾解锁过(bonus>0) } // TodaySummary returns the current user's usage summary for today (UTC), @@ -176,34 +186,38 @@ func (svc *Service) TodaySummary(ctx context.Context, userID int64) (*TodaySumma } used := 0 - adUnlocked := false + bonus := 0 if day != nil { used = day.MinutesUsed - adUnlocked = day.AdUnlockedAt.Valid + bonus = day.AdBonusMinutes } - out := &TodaySummary{MinutesUsed: used, AdUnlocked: adUnlocked} + out := &TodaySummary{MinutesUsed: used, AdBonusMinutes: bonus, AdUnlocked: bonus > 0} if plan.DailyMinutes.Valid { - remaining := int(plan.DailyMinutes.Int64) - used + cap := int(plan.DailyMinutes.Int64) + bonus + remaining := cap - used if remaining < 0 { remaining = 0 } + out.MinutesCap = &cap out.MinutesRemaining = &remaining } return out, nil } -// UnlockAd verifies a rewarded-ad receipt and records the day's ad-unlock. +// UnlockAd verifies a rewarded-ad receipt and grants additional free minutes. // // Flow: validate inputs → verify the receipt with the ad network → consume the -// ad_token as a one-time nonce (replay-protected) → set ad_unlocked_at. A -// second unlock on a day already unlocked is idempotent (alreadyUnlocked=true). -func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (alreadyUnlocked bool, apiErr *apierr.Error) { +// ad_token as a one-time nonce (replay-protected) → add adBonusPerAd minutes to +// the day's ad bonus (capped at adDailyBonusCeiling). It returns the granted +// minutes (0 when the daily ceiling was already reached) and the new remaining +// minutes for today so the client can refresh its quota immediately. +func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (granted, remaining int, apiErr *apierr.Error) { if deviceID == "" || adToken == "" { - return false, apierr.ErrBadRequest + return 0, 0, apierr.ErrBadRequest } if svc.verifier == nil { - return false, apierr.ErrInternal + return 0, 0, apierr.ErrInternal } // 1. Authenticate the receipt with the ad network. @@ -212,22 +226,32 @@ func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToke DeviceID: deviceID, AdToken: adToken, }); err != nil { - return false, apierr.ErrAdVerifyFailed + return 0, 0, apierr.ErrAdVerifyFailed } // 2. One-time nonce: a genuine receipt may only be redeemed once. if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil { - return false, apierr.ErrInternal + return 0, 0, apierr.ErrInternal } else if dup { - return false, apierr.ErrAdReplay + return 0, 0, apierr.ErrAdReplay } - // 3. Record the unlock (idempotent per UTC day). - already, err := svc.store.MarkAdUnlocked(ctx, userID, utcToday()) - if err != nil { - return false, apierr.ErrInternal + // 3. Grant the reward: add adBonusPerAd minutes, capped at the daily ceiling. + _, g, addErr := svc.store.AddAdBonusMinutes(ctx, userID, utcToday(), adBonusPerAd, adDailyBonusCeiling) + if addErr != nil { + return 0, 0, apierr.ErrInternal } - return already, nil + granted = g + + // 4. Recompute remaining for the client to refresh its quota immediately. + summary, sErr := svc.TodaySummary(ctx, userID) + if sErr != nil { + return 0, 0, sErr + } + if summary.MinutesRemaining != nil { + remaining = *summary.MinutesRemaining + } + return granted, remaining, nil } // consumeAdNonce atomically records the ad_token so it cannot be reused. diff --git a/server/internal/usage/store.go b/server/internal/usage/store.go index 8523008..6f546bb 100644 --- a/server/internal/usage/store.go +++ b/server/internal/usage/store.go @@ -29,11 +29,12 @@ type Plan struct { // minute counts plus the ad-unlock timestamp — never destinations or DNS, per // the no-log policy. type DailyUsage struct { - Date time.Time - BytesUp uint64 - BytesDown uint64 - MinutesUsed int - AdUnlockedAt sql.NullTime + Date time.Time + BytesUp uint64 + BytesDown uint64 + MinutesUsed int + AdBonusMinutes int // 当日看广告累加解锁的额外分钟(免费版加时) + AdUnlockedAt sql.NullTime } // Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the @@ -86,7 +87,7 @@ func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, // filled here; zero-filling is the handler's responsibility. func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) { rows, err := s.db.QueryContext(ctx, - `SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at + `SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date BETWEEN ? AND ? ORDER BY date ASC`, @@ -99,7 +100,7 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T var out []DailyUsage for rows.Next() { var u DailyUsage - if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt); err != nil { + if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt); err != nil { return nil, fmt.Errorf("store.GetUsageRange scan: %w", err) } out = append(out, u) @@ -212,11 +213,11 @@ func (s *Store) DeviceUsageRange(ctx context.Context, userID int64, from, to tim func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) { var u DailyUsage err := s.db.QueryRowContext(ctx, - `SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at + `SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ?`, userID, day.UTC().Format(dateLayout)). - Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt) + Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt) if err == sql.ErrNoRows { return nil, nil } @@ -226,10 +227,75 @@ func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*Daily return &u, nil } +// AddAdBonusMinutes adds `add` minutes to usage_daily.ad_bonus_minutes for +// (userID, day), capped so the day's total bonus never exceeds `ceiling`. It +// returns the new bonus total and the minutes actually granted this call +// (granted = newBonus − previous, i.e. 0 when the ceiling was already reached). +// Runs inside a transaction with SELECT … FOR UPDATE so concurrent ad-unlocks +// accumulate correctly (免费版累加式看广告加时). +// +// This is the additive successor to MarkAdUnlocked's per-day boolean unlock: +// each rewarded ad grants +add minutes (repeatable) up to the daily ceiling. +func (s *Store) AddAdBonusMinutes(ctx context.Context, userID int64, day time.Time, add, ceiling int) (newBonus, granted int, err error) { + d := day.UTC().Format(dateLayout) + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) + if err != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes begin: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + var cur int + row := tx.QueryRowContext(ctx, + `SELECT ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(), + userID, d) + switch scanErr := row.Scan(&cur); scanErr { + case nil: + newBonus = cur + add + if ceiling > 0 && newBonus > ceiling { + newBonus = ceiling + } + if newBonus != cur { + if _, uErr := tx.ExecContext(ctx, + `UPDATE usage_daily SET ad_bonus_minutes = ? WHERE user_id = ? AND date = ?`, + newBonus, userID, d); uErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes update: %w", uErr) + } + } + case sql.ErrNoRows: + cur = 0 + newBonus = add + if ceiling > 0 && newBonus > ceiling { + newBonus = ceiling + } + if _, iErr := tx.ExecContext(ctx, + `INSERT INTO usage_daily (user_id, date, ad_bonus_minutes) VALUES (?, ?, ?)`, + userID, d, newBonus); iErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes insert: %w", iErr) + } + default: + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes select: %w", scanErr) + } + + if cErr := tx.Commit(); cErr != nil { + return 0, 0, fmt.Errorf("store.AddAdBonusMinutes commit: %w", cErr) + } + committed = true + return newBonus, newBonus - cur, nil +} + // MarkAdUnlocked sets usage_daily.ad_unlocked_at for (userID, day) to now if it // is not already set. It returns alreadyUnlocked=true when the day was already // unlocked (making a second call idempotent). Runs inside a transaction with // SELECT … FOR UPDATE to be safe under concurrent unlock attempts. +// +// Deprecated: the免费版 ad model moved from a per-day boolean unlock to additive +// minutes (see AddAdBonusMinutes). Retained only for backward compatibility. func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) { d := day.UTC().Format(dateLayout) now := time.Now().UTC() diff --git a/server/internal/usage/usage_integration_test.go b/server/internal/usage/usage_integration_test.go index 3becc6b..c08f365 100644 --- a/server/internal/usage/usage_integration_test.go +++ b/server/internal/usage/usage_integration_test.go @@ -108,6 +108,7 @@ func applySchema(db *sql.DB) error { bytes_down BIGINT UNSIGNED NOT NULL DEFAULT 0, minutes_used INT NOT NULL DEFAULT 0, ad_unlocked_at DATETIME(6) NULL, + ad_bonus_minutes INT NOT NULL DEFAULT 0, PRIMARY KEY (user_id, date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, `CREATE TABLE IF NOT EXISTS usage_hourly ( @@ -297,22 +298,23 @@ func TestIntAggregateUnknownDPUUIDDropped(t *testing.T) { // Quota (CheckFreeConnect) // -------------------------------------------------------------------------- -func TestIntQuotaFreeNotUnlocked(t *testing.T) { +func TestIntQuotaFreeBaseNoAd(t *testing.T) { db := setupMySQL(t) svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-q1") // no subscription → free plan - _, apiErr := svc.CheckFreeConnect(context.Background(), uid) - if apiErr == nil || apiErr.Code != "AD_NOT_UNLOCKED" { - t.Fatalf("expected AD_NOT_UNLOCKED, got %v", apiErr) + // 免费版基础 10 分钟无需看广告即可连接(广告只用于加时)。 + rem, apiErr := svc.CheckFreeConnect(context.Background(), uid) + if apiErr != nil { + t.Fatalf("free base connect should succeed without ad, got %v", apiErr) } - if apiErr.MessageZH == "" || apiErr.MessageEn == "" { - t.Error("expected bilingual error messages") + if rem != 10 { + t.Errorf("remaining=%d, want 10 (base free minutes)", rem) } } -func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) { +func TestIntQuotaFreeAdBonusAccumulates(t *testing.T) { db := setupMySQL(t) store := usage.NewStore(db) agg := usage.NewAggregator(store, nil, time.Minute, time.Minute) @@ -320,23 +322,26 @@ func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) { uid := createUser(t, db, "dp-q2") - if _, err := store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()); err != nil { - t.Fatalf("unlock: %v", err) - } - + // Base free minutes (no ad). rem, apiErr := svc.CheckFreeConnect(context.Background(), uid) - if apiErr != nil { - t.Fatalf("unexpected err: %v", apiErr) - } - if rem != 10 { - t.Errorf("remaining=%d, want 10", rem) + if apiErr != nil || rem != 10 { + t.Fatalf("base: rem=%d err=%v, want 10", rem, apiErr) } - // Consume 4 minutes. + // Watch an ad → +10 bonus → allowance 20. + if _, _, err := store.AddAdBonusMinutes(context.Background(), uid, time.Now().UTC(), 10, 120); err != nil { + t.Fatalf("add bonus: %v", err) + } + rem, _ = svc.CheckFreeConnect(context.Background(), uid) + if rem != 20 { + t.Errorf("after ad: remaining=%d, want 20", rem) + } + + // Consume 4 minutes → 16. agg.ReportUsage(context.Background(), usage.Report{DPUUID: "dp-q2", Minutes: 4}) rem, _ = svc.CheckFreeConnect(context.Background(), uid) - if rem != 6 { - t.Errorf("after 4 min: remaining=%d, want 6", rem) + if rem != 16 { + t.Errorf("after 4 min: remaining=%d, want 16", rem) } } @@ -346,7 +351,7 @@ func TestIntQuotaFreeExhausted(t *testing.T) { svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-q3") - store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()) + // Base 10 minutes fully consumed, no ad bonus → exhausted. store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 10) _, apiErr := svc.CheckFreeConnect(context.Background(), uid) @@ -386,7 +391,7 @@ func TestIntAdsUnlockForgedRejected(t *testing.T) { svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: false}, time.Hour) uid := createUser(t, db, "dp-ad1") - _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token") + _, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token") if apiErr == nil || apiErr.Code != "AD_VERIFY_FAILED" { t.Fatalf("expected AD_VERIFY_FAILED, got %v", apiErr) } @@ -398,40 +403,47 @@ func TestIntAdsUnlockReplayRejected(t *testing.T) { svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-ad2") - already, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") - if apiErr != nil || already { - t.Fatalf("first unlock: already=%v err=%v", already, apiErr) + granted, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") + if apiErr != nil || granted != 10 { + t.Fatalf("first unlock: granted=%d err=%v, want granted=10", granted, apiErr) } // Same token again → replay. - _, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") + _, _, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz") if apiErr == nil || apiErr.Code != "AD_TOKEN_REPLAY" { t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr) } } -func TestIntAdsUnlockSecondTimeIdempotent(t *testing.T) { +func TestIntAdsUnlockAccumulates(t *testing.T) { db := setupMySQL(t) rdb := setupRedis(t) svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour) uid := createUser(t, db, "dp-ad3") - if _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1"); apiErr != nil { - t.Fatalf("first unlock: %v", apiErr) + // 累加式:每次不同 token 各加 10 分钟,而非幂等。 + g1, _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1") + if apiErr != nil || g1 != 10 { + t.Fatalf("first unlock: granted=%d err=%v, want 10", g1, apiErr) } - // Different (valid) token, same day → idempotent success. - already, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2") - if apiErr != nil { - t.Fatalf("second unlock err: %v", apiErr) + g2, rem, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2") + if apiErr != nil || g2 != 10 { + t.Fatalf("second unlock: granted=%d err=%v, want 10", g2, apiErr) } - if !already { - t.Error("expected already-unlocked idempotent success") + // allowance = 10 base + 20 bonus, used 0 → remaining 30. + if rem != 30 { + t.Errorf("remaining after 2 ads=%d, want 30", rem) } - // Exactly one unlock timestamp. - var n int - db.QueryRow(`SELECT COUNT(*) FROM usage_daily WHERE user_id=? AND ad_unlocked_at IS NOT NULL`, uid).Scan(&n) - if n != 1 { - t.Errorf("expected 1 unlocked day, got %d", n) + // Additive model: the two ads accumulate into a single day row's + // ad_bonus_minutes (10 + 10 = 20). (ad_unlocked_at is legacy from the old + // per-day boolean unlock and is no longer stamped by UnlockAd.) + var rows, bonus int + db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(ad_bonus_minutes),0) FROM usage_daily WHERE user_id=?`, uid).Scan(&rows, &bonus) + if rows != 1 { + t.Errorf("expected 1 usage_daily row, got %d", rows) + } + if bonus != 20 { + t.Errorf("expected ad_bonus_minutes=20, got %d", bonus) } } @@ -505,17 +517,22 @@ func TestIntTodaySummary(t *testing.T) { store := usage.NewStore(db) svc := usage.NewService(store, nil, nil, time.Hour) - // Free user: limited + ad flag. + // Free user: base 10 + ad bonus 10 = cap 20; used 3 → remaining 17. uFree := createUser(t, db, "dp-sf") - store.MarkAdUnlocked(context.Background(), uFree, time.Now().UTC()) + if _, _, err := store.AddAdBonusMinutes(context.Background(), uFree, time.Now().UTC(), 10, 120); err != nil { + t.Fatalf("add bonus: %v", err) + } store.AggregateUsage(context.Background(), uFree, time.Now().UTC(), 0, 0, 3) sum, apiErr := svc.TodaySummary(context.Background(), uFree) if apiErr != nil { t.Fatalf("summary: %v", apiErr) } - if sum.MinutesUsed != 3 || sum.MinutesRemaining == nil || *sum.MinutesRemaining != 7 || !sum.AdUnlocked { - t.Errorf("free summary wrong: %+v (remaining=%v)", sum, sum.MinutesRemaining) + if sum.MinutesUsed != 3 || + sum.MinutesCap == nil || *sum.MinutesCap != 20 || + sum.MinutesRemaining == nil || *sum.MinutesRemaining != 17 || + sum.AdBonusMinutes != 10 || !sum.AdUnlocked { + t.Errorf("free summary wrong: %+v (cap=%v remaining=%v)", sum, sum.MinutesCap, sum.MinutesRemaining) } // Pro user: unlimited (nil remaining). diff --git a/todo/todo.html b/todo/todo.html index 7360431..9b710a2 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -100,6 +100,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } .t-tag:hover { background: #d9e8fb; color: #1d4ed8; } .ver-badge { font-weight: 700; color: #1f7a44; } .sub-progress-badge { background: #f0f4ff; color: #3730a3; } +.owner-user{background:rgba(232,192,122,.16);border:1px solid #e8c07a;color:#e8c07a} +.owner-agent{background:rgba(126,224,162,.14);border:1px solid #7ee0a2;color:#7ee0a2} /* 状态徽章 */ .status-badge { font-size: 11.5px; } @@ -246,6 +248,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } .modal-cmd-code { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; color: #86efac; word-break: break-all; display: block; } .modal-copy-btn { margin-top: 10px; padding: 5px 14px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; } .modal-copy-btn:hover { background: #475569; } +/* ════ 家族深色主题覆盖层(2026-07-06 对齐 brain/docs 家族风格;后置覆盖上方浅色基础样式) ════ */ +:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--green:#3fb950;--amber:#d29922;--red:#f85149} +body{color:var(--fg);background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg)} +header{background:linear-gradient(135deg,#101a2c 0%,#15294a 100%);border-bottom:1px solid var(--border)} +header .header-meta{color:var(--fg-soft)} +.stat-pill{background:rgba(88,166,255,.12);border:1px solid var(--border)} +.filter-bar{background:rgba(13,17,23,.92);backdrop-filter:blur(6px);border-bottom:1px solid var(--border)} +.filter-label{color:var(--muted)} +.filter-chip,.filter-btn{background:var(--card);border-color:var(--border);color:var(--fg-soft)} +.filter-chip:hover,.filter-btn:hover{border-color:var(--accent);color:var(--accent)} +.filter-chip.active,.filter-btn.active{background:var(--accent);border-color:var(--accent);color:#0d1117} +.filter-sep{background:var(--border)} +.section-title .s-count{background:rgba(255,255,255,.08)} +.st-open {background:rgba(88,166,255,.10);color:#79b8ff;border-left-color:var(--accent)} +.st-doing {background:rgba(210,153,34,.10);color:#e3b341;border-left-color:var(--amber)} +.st-done {background:rgba(63,185,80,.08);color:#7ee787;border-left-color:var(--green)} +.st-accepted{background:rgba(63,185,80,.12);color:#7ee787;border-left-color:var(--green)} +.todo-card{background:var(--card);border-color:var(--border)} +.todo-card.s-doing{border-left-color:var(--amber)} +.todo-card.s-done{border-left-color:var(--green)} +.todo-card.s-accepted{background:rgba(63,185,80,.06);border-left-color:var(--green)} +.item-id{color:var(--accent);background:#0b1020;border-color:var(--border)} +.item-title{color:var(--fg)} +.todo-card.s-accepted .item-title{color:var(--muted)} +.item-desc{color:var(--fg-soft)} +.item-desc code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6} +.item-meta{color:var(--muted)} +.t-block{background:rgba(248,81,73,.15);color:#ff9492} +.t-high{background:rgba(210,153,34,.15);color:#e3b341} +.t-low{background:rgba(88,166,255,.12);color:#79b8ff} +.t-tag{background:var(--card-2);color:var(--fg-soft)} +.t-tag:hover{background:rgba(88,166,255,.15);color:var(--accent)} +.ver-badge{color:var(--green)} +.sub-progress-badge{background:rgba(88,166,255,.12);color:#79b8ff} +.s-open{background:rgba(88,166,255,.15);color:#79b8ff} +.s-doing{background:rgba(210,153,34,.18);color:#e3b341} +.s-done{background:rgba(63,185,80,.15);color:#7ee787} +.s-accepted{background:rgba(63,185,80,.18);color:#7ee787} +.empty-tip{color:var(--muted)} +.tier-1{background:rgba(163,113,247,.15);color:#d2a8ff} +.tier-2{background:rgba(57,197,187,.12);color:#76e3db} +.tier-3{background:var(--card-2);color:var(--fg-soft)} +.reject-btn{background:transparent;border-color:var(--red);color:#ff9492} +.reject-btn:hover{background:var(--red);color:#0d1117} +.reject-note{background:rgba(248,81,73,.08);border-color:rgba(248,81,73,.4);color:#ff9492} +.reject-date{color:rgba(248,81,73,.6)} +.gate-pending{background:rgba(210,153,34,.08);border-color:rgba(210,153,34,.4)} +.gate-granted{background:rgba(63,185,80,.08);border-color:rgba(63,185,80,.4)} +.gate-info{background:var(--card-2);border-color:var(--border)} +.gate-kind,.gate-note,.gate-ref{color:var(--fg-soft)} +.gate-date{color:var(--muted)} +.gate-note code,.gate-ref code{background:#0b1020;border:1px solid var(--border);color:#c9d6e6} +.subtask-block{background:var(--card-2);border-color:var(--border)} +.subtask-label{color:var(--muted)} +.subtask-progress-text{color:var(--fg-soft)} +.subtask-progress-bar{background:var(--border)} +.subtask-item{background:var(--card);border-color:var(--border)} +.subtask-item.s-done,.subtask-item.s-accepted{background:rgba(63,185,80,.06);border-color:rgba(63,185,80,.3)} +.subtask-item.s-doing{background:rgba(210,153,34,.06);border-color:rgba(210,153,34,.3)} +.sub-icon.s-open{color:var(--muted)} +.sub-sid{color:var(--muted)} +.subtask-item.s-done .sub-title,.subtask-item.s-accepted .sub-title{color:var(--muted)} +.dep-label{color:var(--muted)} +.dep-done{background:rgba(63,185,80,.15);color:#7ee787} +.dep-pending{background:rgba(248,81,73,.15);color:#ff9492} +.modal-overlay{background:rgba(0,0,0,.6)} +.modal-box{background:var(--card);border:1px solid var(--border)} +.modal-box h3{color:var(--fg)} +.modal-subtitle{color:var(--fg-soft)} +.modal-label{color:var(--fg-soft)} +.modal-textarea{background:#0b1020;border-color:var(--border);color:var(--fg)} +.modal-btn-cancel{background:var(--card-2);color:var(--fg-soft)} </style> </head> <body> @@ -253,12 +327,12 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <header> <div class="wrap"> <h1>feature+windows — 项目 TODO</h1> - <div class="header-meta">生成于 2026-06-30 · 真相源 todo/todo.json</div> + <div class="header-meta">生成于 2026-07-08 · 真相源 todo/todo.json</div> <div class="stats"> - <div class="stat-pill"><strong>16</strong>全部</div> + <div class="stat-pill"><strong>18</strong>全部</div> <div class="stat-pill"><strong>8</strong>待开始</div> <div class="stat-pill"><strong>0</strong>开发中</div> - <div class="stat-pill"><strong>0</strong>待验收</div> + <div class="stat-pill"><strong>2</strong>待验收</div> <div class="stat-pill"><strong>8</strong>已验收</div> </div> @@ -293,7 +367,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="filter-sep"></div> <div class="filter-group"> <span class="filter-label">平台 / 标签</span> - <button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button> + <button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="CI/CD">CI/CD</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="Windows">Windows</button><button class="filter-chip" data-filter-tag="gRPC">gRPC</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="mac">mac</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="运维">运维</button> </div> </div> @@ -332,6 +406,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-22</span> </div> @@ -362,6 +437,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-22</span> </div> @@ -392,6 +468,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="运维">运维</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> </div> @@ -422,6 +499,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> </div> @@ -452,6 +530,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> </div> @@ -482,6 +561,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-29</span> </div> @@ -512,6 +592,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-30</span> </div> @@ -542,6 +623,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="Windows">Windows</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-22</span> </div> @@ -563,12 +645,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } </div> <div class="section-block" id="section-done"> <div class="section-title st-done" data-toggle="done"> - 🔍 待验收 <span class="s-count">0</span> + 🔍 待验收 <span class="s-count">2</span> <span class="s-arrow">▴ 收起</span> </div> <div class="section-list-wrap " id="list-wrap-done"> <ul class="todo-list" id="list-done"> - <p class="empty-tip">暂无条目</p> + + <li class="todo-card s-done" + data-id="18" + data-level="mid" + data-status="done" + data-tier="3" + data-tags="mac,前端"> + <div class="card-header"> + <span class="item-id">#18</span> + <span class="item-title">看门狗后台唤醒误判节点死→假重连</span> + <div class="card-badges"> + <span class="tag status-badge s-done">待验收</span> + <span class="tag t-high">重要</span> + <span class="tag tier-3">三级</span> + + <button class="reject-btn" data-id="18" data-title="看门狗后台唤醒误判节点死→假重连">拒绝验收</button> + </div> + </div> + + <div class="item-desc">app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。</div> + + + <div class="card-footer"> + <div class="tag-row"><span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="前端">前端</span></div> + <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> + <span class="meta-date">🕐 2026-07-01</span> + + </div> + </div> + </li> + + <li class="todo-card s-done" + data-id="19" + data-level="mid" + data-status="done" + data-tier="1" + data-tags="前端,Web,mac,iOS,Android,Windows,CI/CD"> + <div class="card-header"> + <span class="item-id">#19</span> + <span class="item-title">前端设计系统治理重构(ds-flow 全端)</span> + <div class="card-badges"> + <span class="tag status-badge s-done">待验收</span> + <span class="tag t-high">重要</span> + <span class="tag tier-1">一级</span> + + <button class="reject-btn" data-id="19" data-title="前端设计系统治理重构(ds-flow 全端)">拒绝验收</button> + </div> + </div> + + <div class="item-desc">用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</div> + <div class="gate-block gate-granted"> + <div class="gate-head"><span class="gate-badge granted">✓ 已确认</span> + <span class="gate-kind">一级方案规划</span> + <span class="gate-date">2026-07-07</span></div> + <div class="gate-note">6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。</div><div class="gate-ref">📄 详见 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code></div> + </div> + + <div class="card-footer"> + <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span> <span class="tag t-tag" data-tag="mac">mac</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span> <span class="tag t-tag" data-tag="Windows">Windows</span> <span class="tag t-tag" data-tag="CI/CD">CI/CD</span></div> + <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> + <span class="meta-date">🕐 2026-07-07</span> + + </div> + </div> + </li> </ul> </div> </div> @@ -604,6 +752,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -634,6 +783,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -664,6 +814,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -694,6 +845,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="前端">前端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-30</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -724,6 +876,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -754,6 +907,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="gRPC">gRPC</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-29</span> <span class="meta-date">✅ 验收 2026-06-30</span> </div> @@ -784,6 +938,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-28</span> </div> @@ -814,6 +969,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } <div class="card-footer"> <div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span></div> <div class="item-meta"> + <span class="tag owner-agent">🤖 agent</span> <span class="meta-date">🕐 2026-06-28</span> <span class="meta-date">✅ 验收 2026-06-28</span> </div> diff --git a/todo/todo.json b/todo/todo.json index c5550f1..a7cd675 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "feature+windows — 项目 TODO", - "updated_at": "2026-06-30T14:47:39.344Z" + "updated_at": "2026-07-07T17:45:18.036Z" }, - "seq": 17, + "seq": 19, "items": [ { "id": 1, @@ -18,7 +18,8 @@ "created_at": "2026-06-22T09:32:42.340Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 2, @@ -33,7 +34,8 @@ "created_at": "2026-06-22T09:32:56.098Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 3, @@ -48,7 +50,8 @@ "created_at": "2026-06-22T09:32:56.169Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 4, @@ -64,7 +67,8 @@ "created_at": "2026-06-27T16:39:31.549Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 5, @@ -79,7 +83,8 @@ "created_at": "2026-06-27T23:37:24.980Z", "done": true, "completed_at": "2026-06-28T13:19:24.207Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 6, @@ -95,7 +100,8 @@ "created_at": "2026-06-28T00:21:30.990Z", "done": true, "completed_at": "2026-06-28T13:19:24.299Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 7, @@ -110,7 +116,8 @@ "created_at": "2026-06-28T00:26:44.498Z", "done": true, "completed_at": "2026-06-30T14:44:35.082Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 8, @@ -125,7 +132,8 @@ "created_at": "2026-06-28T00:38:22.413Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 9, @@ -141,7 +149,8 @@ "created_at": "2026-06-28T09:32:26.008Z", "done": true, "completed_at": "2026-06-30T14:44:34.810Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 10, @@ -156,7 +165,8 @@ "created_at": "2026-06-28T09:32:26.080Z", "done": true, "completed_at": "2026-06-30T14:44:34.991Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 11, @@ -171,7 +181,8 @@ "created_at": "2026-06-28T11:27:56.637Z", "done": true, "completed_at": "2026-06-30T14:47:39.343Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 12, @@ -187,7 +198,8 @@ "created_at": "2026-06-28T11:27:56.708Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 13, @@ -204,7 +216,8 @@ "created_at": "2026-06-29T04:55:46.733Z", "done": true, "completed_at": "2026-06-29T16:59:39.926Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 14, @@ -221,7 +234,8 @@ "created_at": "2026-06-29T09:36:14.004Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" }, { "id": 15, @@ -237,7 +251,8 @@ "created_at": "2026-06-30T10:56:48.894Z", "done": true, "completed_at": "2026-06-30T14:44:34.900Z", - "version": null + "version": null, + "owner": "agent" }, { "id": 16, @@ -253,7 +268,55 @@ "created_at": "2026-06-30T14:33:48.996Z", "done": false, "completed_at": null, - "version": null + "version": null, + "owner": "agent" + }, + { + "id": 18, + "title": "看门狗后台唤醒误判节点死→假重连", + "desc": "app/Mac 后台或睡眠一段时间再打开,连通看门狗路径A(urltest stale)用墙上时钟算陈旧度,_lastUrltestOk 在挂起期冻住→唤醒时误判>45s→_onNodeUnhealthy 触发 disconnect+connect 假重连(智能模式还自动切节点),但隧道没掉。修:_onStats 记 _lastStatsAt,路径A 仅在 stats 仍在流但 urltest 停更时才判死;stats 整体停(挂起)则跳过。connection_provider.dart 一处。", + "level": "mid", + "tier": 3, + "tags": [ + "mac", + "前端" + ], + "status": "done", + "created_at": "2026-06-30T23:00:25.193Z", + "done": false, + "completed_at": null, + "version": null, + "owner": "agent" + }, + { + "id": 19, + "title": "前端设计系统治理重构(ds-flow 全端)", + "desc": "用 ds-flow 把 Flutter 五端+官网+用户中心收口到设计单源。补原型三件套(atoms.css/icons.js/index.html)+Web共享原子层去重(各自实现+同源闸)+硬编码色/fidelity闸+启用pre-commit。6阶段。计划见 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md", + "level": "mid", + "tier": 1, + "tags": [ + "前端", + "Web", + "mac", + "iOS", + "Android", + "Windows", + "CI/CD" + ], + "owner": "agent", + "status": "done", + "created_at": "2026-07-07T15:41:25.760Z", + "done": false, + "completed_at": null, + "version": null, + "gate": { + "kind": "plan", + "note": "6 阶段 ds-flow 治理重构:Phase0 CLAUDE.md+计划落库 / Phase1 原型三件套(atoms.css/icons.js/index.html,收敛ui_kits) / Phase2 Web token同源闸 / Phase3 Web共享原子层去重(各自实现+同源闸,★工作量最大) / Phase4 Flutter收尾+golden补齐 / Phase5 静态闸挂满+启用pre-commit+fidelity体检。已定决策:Web各自实现+同源闸、ui_kits收敛纯HTML、先定稿再逐刀。主题保持light/dark。", + "ref": "docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md", + "approval": "granted", + "proposed_at": "2026-07-07T15:41:36.536Z", + "approved_at": "2026-07-07T15:54:54.386Z" + } } ] } diff --git a/tools/check-l1-sync.mjs b/tools/check-l1-sync.mjs new file mode 100644 index 0000000..5a56301 --- /dev/null +++ b/tools/check-l1-sync.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖) +// +// 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以 +// design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。 +// Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff), +// 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。 +// +// 本地/CI 直接跑:node tools/check-l1-sync.mjs +// +// 四道检查(任一违规 exit 1): +// ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token +// 值 ≡ design/prototype/tokens.css(:root + dark)。 +// ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。 +// ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons +// _byName(键)必须 ⊆ 原型 icons.js ICONS。 +// ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex; +// 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免; +// 排除生成的 token 定义文件。 + +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const read = (p) => readFileSync(join(ROOT, p), 'utf8'); +const problems = []; + +// 解析 CSS 块里的 --var: value;(压掉空白便于比较) +function parseVars(cssBlock) { + const out = {}; + for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) { + out[k] = v.replace(/\s+/g, ' ').trim(); + } + return out; +} +// 取 tokens.css 的 :root + [data-theme="dark"] 全量 token +function protoTokens() { + const css = read('design/prototype/tokens.css'); + const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? ''; + const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? ''; + return { ...parseVars(root), ...parseVars(dark) }; +} + +// ── ①② Web token 值同源 ──────────────────────────────────── +function checkWebTokenSync(label, webPath) { + if (!existsSync(join(ROOT, webPath))) { + problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`); + return; + } + const proto = protoTokens(); + const web = parseVars(read(webPath)); + for (const [k, v] of Object.entries(proto)) { + if (!(k in web)) { + problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`); + } else if (web[k] !== v) { + problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`); + } + } +} + +// ── ③ 图标 ⊆ 原型 sprite ──────────────────────────────────── +{ + // 原型 icons.js: var ICONS = { 'name': '<path.../>', ... } + const protoJs = read('design/prototype/icons.js'); + const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? ''; + const protoIcons = {}; + for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body; + if (!Object.keys(protoIcons).length) { + problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构'); + } + + // usercenter: export const LUCIDE: Record<string,string> = { 'name': '<path/>', ... } + const ucJs = read('web/usercenter/components/icons.tsx'); + const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; + // 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。 + for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) { + if (!(id in protoIcons)) { + problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`); + } else if (protoIcons[id] !== body) { + problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`); + } + } + + // Flutter: static const Map<String,IconData> _byName = { 'name': ..., } + const dart = read('client/lib/widgets/pangolin_icons.dart'); + const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? ''; + for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) { + if (!(id in protoIcons)) { + problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`); + } + } +} + +// ── ④ Web 硬编码色扫描 ────────────────────────────────────── +{ + // 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色) + const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']); + // 排除:生成的 token 定义文件 + 构建产物 + const SKIP = new Set([ + 'web/website/src/styles/tokens.gen.css', + 'web/usercenter/public/colors_and_type.css', + ]); + const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']); + const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib']; + const files = []; + const walk = (dir) => { + if (!existsSync(join(ROOT, dir))) return; + for (const name of readdirSync(join(ROOT, dir))) { + if (SKIP_DIR.has(name)) continue; + const rel = `${dir}/${name}`; + const st = statSync(join(ROOT, rel)); + if (st.isDirectory()) walk(rel); + else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel); + } + }; + ROOTS.forEach(walk); + for (const f of files) { + read(f).split('\n').forEach((line, i) => { + if (line.includes('ds-allow')) return; + for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) { + const v = hex.slice(1).toLowerCase(); + if (!ALLOW.has(v)) { + problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`); + } + } + }); + } +} + +checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css'); +checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css'); + +// ── 汇总 ──────────────────────────────────────────────────── +const line = '='.repeat(60); +if (problems.length) { + console.error(line); + console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`); + for (const p of problems) console.error(' · ' + p); + console.error(line); + process.exit(1); +} +console.log(line); +console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)'); +console.log(line); diff --git a/web/usercenter/app/layout.tsx b/web/usercenter/app/layout.tsx index c3d4d2a..f16963a 100644 --- a/web/usercenter/app/layout.tsx +++ b/web/usercenter/app/layout.tsx @@ -3,8 +3,8 @@ import './globals.css'; import { UIProvider } from '../lib/theme'; export const metadata: Metadata = { - title: '穿山甲 · 用户中心', - description: '管理订阅、兑换激活码、查看用量、邀请返利与账户安全设置。', + title: 'Pangolin · Account', + description: 'Manage your subscription, redeem codes, view usage, referrals and account security.', }; export const viewport: Viewport = { @@ -12,13 +12,17 @@ export const viewport: Viewport = { initialScale: 1, }; +// basePath 前缀(与 next.config.mjs 同源)。public/ 的根绝对资源(colors_and_type.css) +// 不会被 Next 自动加 basePath,需手动拼,否则挂到 /user/ 下会 404。 +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '/user'; + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - <html lang="zh" data-theme="light"> + <html lang="en" data-theme="light" suppressHydrationWarning> <head> {/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */} {/* eslint-disable-next-line @next/next/no-css-tags */} - <link rel="stylesheet" href="/colors_and_type.css" /> + <link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} /> </head> <body> <UIProvider>{children}</UIProvider> diff --git a/web/usercenter/app/sso/page.tsx b/web/usercenter/app/sso/page.tsx new file mode 100644 index 0000000..61fdb05 --- /dev/null +++ b/web/usercenter/app/sso/page.tsx @@ -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> + ); +} diff --git a/web/usercenter/components/Login.tsx b/web/usercenter/components/Login.tsx index 8a55b65..c3fdbb8 100644 --- a/web/usercenter/components/Login.tsx +++ b/web/usercenter/components/Login.tsx @@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) { <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <Mark size={32} /> <div> - <div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div> + <div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div> <div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div> </div> </div> diff --git a/web/usercenter/components/Subscription.tsx b/web/usercenter/components/Subscription.tsx index b2edb13..f361c8e 100644 --- a/web/usercenter/components/Subscription.tsx +++ b/web/usercenter/components/Subscription.tsx @@ -18,7 +18,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean }) }, [api]); const clients = [ - { name: '穿山甲 App', sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true }, + { name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true }, { name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' }, { name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' }, { name: 'v2rayN', sub: 'Windows', icon: 'external-link' }, diff --git a/web/usercenter/components/UserCenter.tsx b/web/usercenter/components/UserCenter.tsx index 80c2c9c..5346906 100644 --- a/web/usercenter/components/UserCenter.tsx +++ b/web/usercenter/components/UserCenter.tsx @@ -18,6 +18,14 @@ import type { Me } from '../lib/api/types'; type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings'; const ORDER: View[] = ['overview', 'sub', 'redeem', 'invite', 'settings']; +/** redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对 + * 路径(防 open redirect,与 app/sso/page.tsx::safeRedirect 一致);否则返回 null。 */ +function safeRedirect(raw: string | null): string | null { + if (!raw) return null; + if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return null; + return raw; +} + function useIsMobile() { const [m, setM] = useState(false); useEffect(() => { @@ -100,11 +108,22 @@ export default function UserCenter() { setView('overview'); } - if (!ready) { - return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />; + // 登录成功回调:若 URL 带合法 ?redirect=<本站相对路径>(如官网带 ?redirect=/ 过来), + // 回跳来源页;否则进用户中心概览。 + function onLoginDone() { + const redirect = safeRedirect(new URLSearchParams(window.location.search).get('redirect')); + if (redirect) { + window.location.replace(redirect); + return; + } + setAuthed(true); + setView('overview'); } - if (!authed) { - return <Login onDone={() => { setAuthed(true); setView('overview'); }} />; + + // 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白 + // 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。 + if (!ready || !authed) { + return <Login onDone={onLoginDone} />; } const nav: [View, string, string][] = [ @@ -149,10 +168,14 @@ export default function UserCenter() { <div style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '0 16px' : '0 24px', height: mobile ? 54 : 60, display: 'flex', alignItems: 'center', gap: mobile ? 12 : 22 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 9 }}> <Mark size={26} /> - <span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>穿山甲</span> + <span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>{t('brandName')}</span> </div> {!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>} {mobile && <div style={{ flex: 1 }} />} + <a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}> + <Icon name="home" size={15} color="var(--fg3)" /> + {!mobile && t('backHome')} + </a> <button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}> <Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" /> </button> diff --git a/web/usercenter/components/icons.tsx b/web/usercenter/components/icons.tsx index 99db11d..cb6c3f2 100644 --- a/web/usercenter/components/icons.tsx +++ b/web/usercenter/components/icons.tsx @@ -25,6 +25,7 @@ export const LUCIDE: Record<string, string> = { 'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>', 'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>', 'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>', + home: '<path d="M3 9.5 12 3l9 6.5"/><path d="M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10"/>', gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>', smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>', lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', diff --git a/web/usercenter/components/shared.tsx b/web/usercenter/components/shared.tsx index aaa632b..ef97b24 100644 --- a/web/usercenter/components/shared.tsx +++ b/web/usercenter/components/shared.tsx @@ -1,5 +1,6 @@ // shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang) -import React from 'react'; +'use client'; +import React, { useEffect, useRef, useState } from 'react'; import type { Lang } from '../lib/i18n'; export const card: React.CSSProperties = { @@ -22,29 +23,110 @@ export const input: React.CSSProperties = { boxSizing: 'border-box', }; +const LANGS: [Lang, string][] = [ + ['en', 'English'], ['zh', '中文'], ['ja', '日本語'], + ['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'], +]; + +// 自定义下拉:原生 <select> 的弹层由系统定位(macOS 锚在选中项、会"漂移"), +// 改成自控菜单(按钮 + 绝对定位列表),不漂移、样式统一、跨端一致。 export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) { + const [open, setOpen] = useState(false); + const ref = useRef<HTMLDivElement>(null); + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const current = LANGS.find(([v]) => v === lang)?.[1] ?? String(lang); return ( - <div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}> - {([['zh', '中文'], ['en', 'EN']] as [Lang, string][]).map(([v, l]) => ( - <button - key={v} - onClick={() => setLang(v)} - aria-pressed={lang === v} + <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}> + <button + type="button" + onClick={() => setOpen((o) => !o)} + aria-haspopup="listbox" + aria-expanded={open} + aria-label="Language" + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: 6, + border: '1px solid color-mix(in oklab, var(--border), transparent 55%)', + cursor: 'pointer', + borderRadius: 10, + padding: '5px 10px', + fontFamily: 'var(--font-sans)', + fontSize: 12.5, + fontWeight: 700, + background: 'var(--bg-subtle)', + color: 'var(--fg2)', + boxShadow: '0 1px 2px rgba(45, 30, 20, 0.05)', + }} + > + <span>{current}</span> + <svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true" + style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 140ms' }}> + <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" + strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </button> + {open && ( + <div + role="listbox" style={{ - border: 'none', - cursor: 'pointer', - borderRadius: 999, - padding: '5px 12px', - fontFamily: 'var(--font-sans)', - fontSize: 12.5, - fontWeight: 700, - background: lang === v ? 'var(--accent)' : 'transparent', - color: lang === v ? 'var(--fg-on-accent)' : 'var(--fg3)', + position: 'absolute', + top: 'calc(100% + 6px)', + right: 0, + minWidth: 132, + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 12, + boxShadow: '0 10px 30px rgba(20, 12, 6, 0.22)', + padding: 5, + zIndex: 60, + display: 'flex', + flexDirection: 'column', + gap: 1, }} > - {l} - </button> - ))} + {LANGS.map(([v, l]) => { + const on = v === lang; + return ( + <button + key={v} + type="button" + role="option" + aria-selected={on} + onClick={() => { setLang(v); setOpen(false); }} + style={{ + textAlign: 'left', + border: 'none', + cursor: 'pointer', + background: on ? 'var(--accent-subtle, var(--bg-subtle))' : 'transparent', + color: on ? 'var(--accent)' : 'var(--fg1)', + fontWeight: on ? 700 : 500, + fontFamily: 'var(--font-sans)', + fontSize: 13, + padding: '8px 11px', + borderRadius: 8, + whiteSpace: 'nowrap', + }} + > + {l} + </button> + ); + })} + </div> + )} </div> ); } diff --git a/web/usercenter/lib/api/errors.ts b/web/usercenter/lib/api/errors.ts index 0337c39..bed1688 100644 --- a/web/usercenter/lib/api/errors.ts +++ b/web/usercenter/lib/api/errors.ts @@ -1,34 +1,33 @@ -// errors.ts — 错误码 → 双语文案映射表(阶段 B 落地) +// errors.ts — 错误码 → 多语文案映射表(阶段 B 落地) import { ApiError } from './types'; import type { Lang } from '../i18n'; /** - * 后端统一返回 {code, message_zh, message_en};前端优先用后端文案, - * 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。 + * 后端统一返回 {code, message_zh, message_en};前端优先用本表(按 code 命中当前语言), + * 再回退后端文案(en 优先),最后 unknown。codes 与 #1 openapi 对齐。 */ -export const ERROR_TEXT: Record<string, { zh: string; en: string }> = { - invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' }, - rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' }, - account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' }, - totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' }, - totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' }, - code_invalid: { zh: '激活码无效', en: 'Invalid activation code' }, - code_used: { zh: '激活码已被使用', en: 'Activation code already used' }, - code_expired: { zh: '激活码已过期', en: 'Activation code expired' }, - device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' }, - device_not_found: { zh: '设备不存在', en: 'Device not found' }, - unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' }, - network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' }, - unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' }, +export const ERROR_TEXT: Record<string, { zh: string; en: string; ja: string; ko: string; ru: string; es: string }> = { + invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' }, + rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down', ja: 'リクエストが多すぎます。少し時間をおいてください', ko: '요청이 너무 많습니다. 잠시 후 다시 시도하세요', ru: 'Слишком много запросов, помедленнее', es: 'Demasiadas solicitudes, ve más despacio' }, + account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' }, + totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required', ja: '二要素認証コードが必要です', ko: '2단계 인증 코드가 필요합니다', ru: 'Требуется код двухфакторной аутентификации', es: 'Se requiere el código de verificación en dos pasos' }, + totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' }, + code_invalid: { zh: '激活码无效', en: 'Invalid activation code', ja: 'アクティベーションコードが無効です', ko: '인증 코드가 유효하지 않습니다', ru: 'Недействительный код активации', es: 'Código de activación no válido' }, + code_used: { zh: '激活码已被使用', en: 'Activation code already used', ja: 'アクティベーションコードは使用済みです', ko: '이미 사용된 인증 코드입니다', ru: 'Код активации уже использован', es: 'El código de activación ya se ha usado' }, + code_expired: { zh: '激活码已过期', en: 'Activation code expired', ja: 'アクティベーションコードの有効期限が切れています', ko: '인증 코드가 만료되었습니다', ru: 'Срок действия кода активации истёк', es: 'El código de activación ha caducado' }, + device_limit: { zh: '设备数量已达上限', en: 'Device limit reached', ja: 'デバイス数が上限に達しました', ko: '기기 수가 한도에 도달했습니다', ru: 'Достигнут лимит устройств', es: 'Se alcanzó el límite de dispositivos' }, + device_not_found: { zh: '设备不存在', en: 'Device not found', ja: 'デバイスが見つかりません', ko: '기기를 찾을 수 없습니다', ru: 'Устройство не найдено', es: 'Dispositivo no encontrado' }, + unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again', ja: 'セッションの有効期限が切れました。再度ログインしてください', ko: '세션이 만료되었습니다. 다시 로그인하세요', ru: 'Сеанс истёк, войдите снова', es: 'La sesión ha caducado, inicia sesión de nuevo' }, + 'auth.ticket_invalid': { zh: '登录票据无效或已过期,请重新从 App 打开', en: 'Login ticket is invalid or expired, please reopen from the app', ja: 'ログインチケットが無効か期限切れです。アプリから開き直してください', ko: '로그인 티켓이 유효하지 않거나 만료되었습니다. 앱에서 다시 여세요', ru: 'Токен входа недействителен или истёк, откройте снова из приложения', es: 'El ticket de inicio de sesión no es válido o ha caducado, vuelve a abrirlo desde la app' }, + network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' }, + unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' }, }; -/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */ +/** 把任意错误转成当前语言文案(按 code 查表→后端文案 en 优先→通用兜底) */ export function bilingual(err: unknown, lang: Lang): string { if (err instanceof ApiError) { - const fromServer = lang === 'zh' ? err.message_zh : err.message_en; - if (fromServer) return fromServer; const m = ERROR_TEXT[err.code]; - if (m) return m[lang]; + return m?.[lang] ?? m?.en ?? err.message_en ?? err.message_zh ?? ERROR_TEXT.unknown[lang]; } return ERROR_TEXT.unknown[lang]; } diff --git a/web/usercenter/lib/api/http.ts b/web/usercenter/lib/api/http.ts index 587867d..9ee4254 100644 --- a/web/usercenter/lib/api/http.ts +++ b/web/usercenter/lib/api/http.ts @@ -17,6 +17,7 @@ import { clearSession, getAccessToken, getRefreshToken, + setEmail, setSession, } from './session'; @@ -142,6 +143,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' }); @@ -156,7 +172,11 @@ export class HttpClient implements ApiClient { return session; } - getMe = async (): Promise<Me> => mapMe(await this.request<RawMe>('/v1/me')); + getMe = async (): Promise<Me> => { + const me = mapMe(await this.request<RawMe>('/v1/me')); + setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除 + return me; + }; getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription'); resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' }); listDevices = async (): Promise<Device[]> => { diff --git a/web/usercenter/lib/api/mock.ts b/web/usercenter/lib/api/mock.ts index a7b4eba..d636456 100644 --- a/web/usercenter/lib/api/mock.ts +++ b/web/usercenter/lib/api/mock.ts @@ -10,7 +10,7 @@ import { SubscriptionInfo, TotpSetup, } from './types'; -import { setSession, clearSession } from './session'; +import { setSession, clearSession, setEmail } from './session'; const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms)); @@ -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(); @@ -73,6 +88,7 @@ export class MockClient implements ApiClient { async getMe(): Promise<Me> { await delay(260); + setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除 return { email: 'me@pangolin.vpn', plan: 'pro', diff --git a/web/usercenter/lib/api/session.ts b/web/usercenter/lib/api/session.ts index a0b0f59..afd3c8b 100644 --- a/web/usercenter/lib/api/session.ts +++ b/web/usercenter/lib/api/session.ts @@ -5,6 +5,17 @@ import type { Session } from './types'; const REFRESH_KEY = 'pg_uc_refresh'; +// 登录用户邮箱:同源官网(pangolin website)读取以显示用户名。仅邮箱、非敏感凭证。 +const EMAIL_KEY = 'pg_uc_email'; + +export function setEmail(email: string): void { + if (typeof window === 'undefined' || !email) return; + try { + window.localStorage.setItem(EMAIL_KEY, email); + } catch { + /* ignore quota / privacy mode */ + } +} let accessToken: string | null = null; let accessExpiresAt = 0; @@ -44,6 +55,7 @@ export function clearSession(): void { if (typeof window !== 'undefined') { try { window.localStorage.removeItem(REFRESH_KEY); + window.localStorage.removeItem(EMAIL_KEY); } catch { /* ignore */ } diff --git a/web/usercenter/lib/api/types.ts b/web/usercenter/lib/api/types.ts index 26654c3..58daa26 100644 --- a/web/usercenter/lib/api/types.ts +++ b/web/usercenter/lib/api/types.ts @@ -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>; diff --git a/web/usercenter/lib/i18n.ts b/web/usercenter/lib/i18n.ts index ab4cb55..91f3bfe 100644 --- a/web/usercenter/lib/i18n.ts +++ b/web/usercenter/lib/i18n.ts @@ -1,134 +1,142 @@ -// i18n.ts — 穿山甲 Web 用户中心 · 双语字串(单显,绝不并排) +// i18n.ts — 穿山甲 Web 用户中心 · 多语字串(单显,绝不并排) // 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCSTRINGS,并补充设置/设备/TOTP 键。 -export type Lang = 'zh' | 'en'; +export type Lang = 'zh' | 'en' | 'ja' | 'ko' | 'ru' | 'es'; -type Entry = { zh: string; en: string }; +type Entry = { zh: string; en: string; ja: string; ko: string; ru: string; es: string }; export const STRINGS: Record<string, Entry> = { - navOverview: { zh: '概览', en: 'Overview' }, - navSub: { zh: '订阅', en: 'Subscription' }, - navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy' }, - navInvite: { zh: '邀请返利', en: 'Referral' }, - navSettings: { zh: '设置', en: 'Settings' }, - signOut: { zh: '退出', en: 'Sign out' }, + navOverview: { zh: '概览', en: 'Overview', ja: '概要', ko: '개요', ru: 'Обзор', es: 'Resumen' }, + navSub: { zh: '订阅', en: 'Subscription', ja: 'サブスクリプション', ko: '구독', ru: 'Подписка', es: 'Suscripción' }, + navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy', ja: '引き換え & 購入', ko: '코드 등록 & 구매', ru: 'Активация и покупка', es: 'Canjear y comprar' }, + navInvite: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' }, + navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' }, + signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' }, + backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' }, + brandName: { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' }, /* login */ - loginTitle: { zh: '登录用户中心', en: 'Log in to your account' }, - loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage' }, - emailLabel: { zh: '邮箱', en: 'Email' }, - emailPh: { zh: '你的邮箱地址', en: 'your@email.com' }, - pwLabel: { zh: '密码', en: 'Password' }, - pwPh: { zh: '输入密码', en: 'Enter password' }, - doLogin: { zh: '登录', en: 'Log in' }, - forgotPw: { zh: '忘记密码?', en: 'Forgot password?' }, - noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app' }, - loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password' }, - loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' }, - lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' }, + loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' }, + loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage', ja: 'サブスクの管理、コードの引き換え、使用状況の確認', ko: '구독 관리, 코드 등록, 사용량 확인', ru: 'Управление подпиской, активация кодов, статистика использования', es: 'Gestiona tu suscripción, canjea códigos y consulta el uso' }, + emailLabel: { zh: '邮箱', en: 'Email', ja: 'メールアドレス', ko: '이메일', ru: 'Эл. почта', es: 'Correo electrónico' }, + emailPh: { zh: '你的邮箱地址', en: 'your@email.com', ja: 'your@email.com', ko: 'your@email.com', ru: 'your@email.com', es: 'your@email.com' }, + pwLabel: { zh: '密码', en: 'Password', ja: 'パスワード', ko: '비밀번호', ru: 'Пароль', es: 'Contraseña' }, + pwPh: { zh: '输入密码', en: 'Enter password', ja: 'パスワードを入力', ko: '비밀번호 입력', ru: 'Введите пароль', es: 'Introduce la contraseña' }, + doLogin: { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' }, + forgotPw: { zh: '忘记密码?', en: 'Forgot password?', ja: 'パスワードをお忘れですか?', ko: '비밀번호를 잊으셨나요?', ru: 'Забыли пароль?', es: '¿Olvidaste tu contraseña?' }, + noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app', ja: 'アカウントがない場合はアプリで登録', ko: '계정이 없나요? 앱에서 가입하세요', ru: 'Нет аккаунта? Зарегистрируйтесь в приложении', es: '¿No tienes cuenta? Regístrate en la app' }, + loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password', ja: 'メールアドレスまたはパスワードが違います', ko: '이메일 또는 비밀번호가 올바르지 않습니다', ru: 'Неверная почта или пароль', es: 'Correo o contraseña incorrectos' }, + loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked', ja: '試行回数が多すぎます — アカウントを一時的にロックしました', ko: '시도 횟수가 너무 많습니다 — 계정이 일시적으로 잠겼습니다', ru: 'Слишком много попыток — аккаунт временно заблокирован', es: 'Demasiados intentos: la cuenta está bloqueada temporalmente' }, + lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s', ja: '{s} 秒後に再試行してください', ko: '{s}초 후에 다시 시도하세요', ru: 'Повторите через {s} с', es: 'Vuelve a intentarlo en {s}s' }, + + /* sso(App→网页免登录落地页) */ + ssoTitle: { zh: '正在登录…', en: 'Signing in…', ja: 'ログイン中…', ko: '로그인 중…', ru: 'Вход…', es: 'Iniciando sesión…' }, + ssoMsg: { zh: '正在验证来自 App 的登录票据', en: 'Verifying the sign-in ticket from the app', ja: 'アプリからのログインチケットを確認しています', ko: '앱에서 받은 로그인 티켓을 확인하는 중', ru: 'Проверяем токен входа из приложения', es: 'Verificando el ticket de inicio de sesión de la app' }, + ssoFailTitle: { zh: '登录跳转失败', en: 'Sign-in redirect failed', ja: 'ログインのリダイレクトに失敗しました', ko: '로그인 리디렉션에 실패했습니다', ru: 'Не удалось выполнить переход для входа', es: 'Error en la redirección de inicio de sesión' }, + ssoFailHint: { zh: '即将跳转到登录页,请手动登录', en: 'Redirecting to the login page, please sign in manually', ja: 'ログインページに移動します。手動でログインしてください', ko: '로그인 페이지로 이동합니다. 직접 로그인해 주세요', ru: 'Перенаправляем на страницу входа, войдите вручную', es: 'Redirigiendo a la página de inicio de sesión, inicia sesión manualmente' }, /* overview */ - greeting: { zh: '欢迎回来', en: 'Welcome back' }, - curPlan: { zh: '当前套餐', en: 'Current plan' }, - freePlan: { zh: '免费版', en: 'Free' }, - proMember: { zh: 'PRO 会员', en: 'PRO member' }, - expires: { zh: '有效期至', en: 'Expires' }, - renew: { zh: '续费 / 升级', en: 'Renew / Upgrade' }, - quotaToday: { zh: '今日剩余时长', en: 'Time left today' }, - dataToday: { zh: '今日已用流量', en: 'Data used today' }, - devices: { zh: '在线设备', en: 'Devices online' }, - quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' }, - usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)' }, - quickSub: { zh: '快速操作', en: 'Quick actions' }, - qaSub: { zh: '获取订阅链接', en: 'Get subscription' }, - qaRedeem: { zh: '兑换激活码', en: 'Redeem a code' }, - qaApp: { zh: '下载 App', en: 'Download apps' }, + greeting: { zh: '欢迎回来', en: 'Welcome back', ja: 'おかえりなさい', ko: '다시 오신 것을 환영합니다', ru: 'С возвращением', es: 'Bienvenido de nuevo' }, + curPlan: { zh: '当前套餐', en: 'Current plan', ja: '現在のプラン', ko: '현재 요금제', ru: 'Текущий тариф', es: 'Plan actual' }, + freePlan: { zh: '免费版', en: 'Free', ja: '無料版', ko: '무료', ru: 'Бесплатный', es: 'Gratis' }, + proMember: { zh: 'PRO 会员', en: 'PRO member', ja: 'PRO会員', ko: 'PRO 회원', ru: 'PRO-участник', es: 'Miembro PRO' }, + expires: { zh: '有效期至', en: 'Expires', ja: '有効期限', ko: '만료일', ru: 'Действует до', es: 'Caduca' }, + renew: { zh: '续费 / 升级', en: 'Renew / Upgrade', ja: '更新 / アップグレード', ko: '갱신 / 업그레이드', ru: 'Продлить / Улучшить', es: 'Renovar / Mejorar' }, + quotaToday: { zh: '今日剩余时长', en: 'Time left today', ja: '本日の残り時間', ko: '오늘 남은 시간', ru: 'Осталось времени сегодня', es: 'Tiempo restante hoy' }, + dataToday: { zh: '今日已用流量', en: 'Data used today', ja: '本日の使用データ量', ko: '오늘 사용한 데이터', ru: 'Трафик за сегодня', es: 'Datos usados hoy' }, + devices: { zh: '在线设备', en: 'Devices online', ja: 'オンラインのデバイス', ko: '온라인 기기', ru: 'Устройств онлайн', es: 'Dispositivos en línea' }, + quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day', ja: '無料版 · 1日10分', ko: '무료 · 하루 10분', ru: 'Бесплатно · 10 мин/день', es: 'Gratis · 10 min/día' }, + usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)', ja: '過去7日間 (GB)', ko: '최근 7일 (GB)', ru: 'За 7 дней (ГБ)', es: 'Últimos 7 días (GB)' }, + quickSub: { zh: '快速操作', en: 'Quick actions', ja: 'クイック操作', ko: '빠른 작업', ru: 'Быстрые действия', es: 'Acciones rápidas' }, + qaSub: { zh: '获取订阅链接', en: 'Get subscription', ja: 'サブスクリプションを取得', ko: '구독 가져오기', ru: 'Получить подписку', es: 'Obtener suscripción' }, + qaRedeem: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' }, + qaApp: { zh: '下载 App', en: 'Download apps', ja: 'アプリをダウンロード', ko: '앱 다운로드', ru: 'Скачать приложения', es: 'Descargar apps' }, /* subscription */ - subTitle: { zh: '我的订阅', en: 'My subscription' }, - subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.' }, - subCopy: { zh: '复制链接', en: 'Copy link' }, - subCopied: { zh: '已复制', en: 'Copied' }, - subReset: { zh: '重置链接', en: 'Reset link' }, - subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices' }, - subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices' }, - scanTitle: { zh: '扫码导入', en: 'Scan to import' }, - scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client' }, - importTitle: { zh: '一键导入三方客户端', en: 'One-tap import' }, - importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.' }, - fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.' }, + subTitle: { zh: '我的订阅', en: 'My subscription', ja: 'マイサブスクリプション', ko: '내 구독', ru: 'Моя подписка', es: 'Mi suscripción' }, + subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.', ja: 'サブスクリプションリンクはあなた専用の認証情報です。他人と共有しないでください。', ko: '구독 링크는 개인 인증 정보입니다. 절대 공유하지 마세요.', ru: 'Ссылка на подписку — ваш личный ключ доступа. Никому не передавайте её.', es: 'Tu enlace de suscripción es una credencial privada. Nunca lo compartas.' }, + subCopy: { zh: '复制链接', en: 'Copy link', ja: 'リンクをコピー', ko: '링크 복사', ru: 'Копировать ссылку', es: 'Copiar enlace' }, + subCopied: { zh: '已复制', en: 'Copied', ja: 'コピーしました', ko: '복사됨', ru: 'Скопировано', es: 'Copiado' }, + subReset: { zh: '重置链接', en: 'Reset link', ja: 'リンクをリセット', ko: '링크 재설정', ru: 'Сбросить ссылку', es: 'Restablecer enlace' }, + subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices', ja: '古いリンクは即座に無効になり、すべてのデバイスで再インポートが必要です', ko: '기존 링크는 즉시 만료되며 모든 기기에서 다시 가져와야 합니다', ru: 'Старая ссылка перестанет работать; переимпортируйте на всех устройствах', es: 'El enlace anterior deja de funcionar; vuelve a importarlo en todos los dispositivos' }, + subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices', ja: 'リセットしました。デバイスで再インポートしてください', ko: '재설정됨 — 기기에서 다시 가져오세요', ru: 'Сброшено — переимпортируйте на устройствах', es: 'Restablecido: vuelve a importarlo en tus dispositivos' }, + scanTitle: { zh: '扫码导入', en: 'Scan to import', ja: 'スキャンしてインポート', ko: '스캔하여 가져오기', ru: 'Сканировать для импорта', es: 'Escanear para importar' }, + scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client', ja: 'Pangolinアプリまたはサードパーティクライアントでスキャン', ko: 'Pangolin 앱 또는 서드파티 클라이언트로 스캔하세요', ru: 'Сканируйте в приложении Pangolin или стороннем клиенте', es: 'Escanea con la app Pangolin o un cliente de terceros' }, + importTitle: { zh: '一键导入三方客户端', en: 'One-tap import', ja: 'ワンタップインポート', ko: '원탭 가져오기', ru: 'Импорт в одно касание', es: 'Importar con un toque' }, + importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.', ja: '対応クライアントがインストール済みなら、タップするだけでサブスクリプションが自動インポートされます。', ko: '해당 클라이언트가 설치되어 있으면 탭 한 번으로 구독이 자동 가져오기 됩니다.', ru: 'Если клиент установлен, нажатие автоматически импортирует подписку.', es: 'Si el cliente está instalado, al tocar se importa la suscripción automáticamente.' }, + fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.', ja: '1つのリンクで sing-box / Clash.Meta / v2ray 形式に対応します。', ko: '하나의 링크로 sing-box / Clash.Meta / v2ray 형식을 모두 지원합니다.', ru: 'Одна ссылка работает с форматами sing-box / Clash.Meta / v2ray.', es: 'Un mismo enlace sirve para los formatos sing-box / Clash.Meta / v2ray.' }, /* redeem */ - redeemTitle: { zh: '兑换激活码', en: 'Redeem a code' }, - redeemPh: { zh: '输入激活码', en: 'Enter activation code' }, - redeemBtn: { zh: '激活', en: 'Redeem' }, - redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied' }, - buyTitle: { zh: '购买渠道', en: 'Where to buy' }, - buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:' }, - chStore: { zh: '自助发卡商店', en: 'Self-serve store' }, - chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' }, - chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' }, - chEmail: { zh: '邮箱客服', en: 'Email support' }, + redeemTitle: { zh: '兑换激活码', en: 'Redeem a code', ja: 'コードを引き換える', ko: '코드 등록', ru: 'Активировать код', es: 'Canjear un código' }, + redeemPh: { zh: '输入激活码', en: 'Enter activation code', ja: 'アクティベーションコードを入力', ko: '인증 코드 입력', ru: 'Введите код активации', es: 'Introduce el código de activación' }, + redeemBtn: { zh: '激活', en: 'Redeem', ja: '引き換える', ko: '등록', ru: 'Активировать', es: 'Canjear' }, + redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied', ja: 'アクティベート完了 — プランが適用されました', ko: '활성화 완료 — 요금제가 적용되었습니다', ru: 'Активировано — тариф применён', es: 'Activado: plan aplicado' }, + buyTitle: { zh: '购买渠道', en: 'Where to buy', ja: '購入方法', ko: '구매처', ru: 'Где купить', es: 'Dónde comprar' }, + buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:', ja: '当サイトでは直接お支払いを受け付けていません。以下のチャネルでコードを購入し、このページで引き換えてください:', ko: '본 사이트에서는 직접 결제를 받지 않습니다. 아래 채널에서 코드를 구매한 뒤 이 페이지에서 등록하세요:', ru: 'Мы не принимаем оплату на этом сайте. Купите код через один из каналов ниже и активируйте его здесь:', es: 'No aceptamos pagos en este sitio. Compra un código por uno de los canales de abajo y canjéalo aquí:' }, + chStore: { zh: '自助发卡商店', en: 'Self-serve store', ja: 'セルフサービスストア', ko: '셀프 서비스 스토어', ru: 'Магазин самообслуживания', es: 'Tienda de autoservicio' }, + chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code', ja: 'Alipay / WeChat · コード即時発行', ko: 'Alipay / WeChat · 즉시 발급', ru: 'Alipay / WeChat · мгновенная выдача кода', es: 'Alipay / WeChat · código al instante' }, + chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code', ja: 'オンチェーン送金 · 最もプライベート · コード即時発行', ko: '온체인 송금 · 가장 프라이빗 · 즉시 발급', ru: 'Ончейн-перевод · максимальная приватность · мгновенная выдача кода', es: 'En cadena · máxima privacidad · código al instante' }, + chEmail: { zh: '邮箱客服', en: 'Email support', ja: 'メールサポート', ko: '이메일 고객지원', ru: 'Поддержка по эл. почте', es: 'Soporte por correo' }, /* invite */ - inviteTitle: { zh: '邀请返利', en: 'Referral' }, - inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.' }, - inviteLink: { zh: '我的邀请链接', en: 'My invite link' }, - invited: { zh: '已邀请', en: 'Invited' }, - paidUsers: { zh: '已付费', en: 'Converted' }, - earned: { zh: '累计返利', en: 'Credit earned' }, - inviteRecord: { zh: '邀请记录', en: 'History' }, - recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet' }, - recRegistered: { zh: '已注册', en: 'Registered' }, - recPaid: { zh: '已付费', en: 'Paid' }, + inviteTitle: { zh: '邀请返利', en: 'Referral', ja: '紹介', ko: '추천', ru: 'Рефералы', es: 'Referidos' }, + inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.', ja: '友達があなたのリンクから登録して支払うと、初回注文の20%がクレジットとして還元されます。', ko: '친구가 내 링크로 가입하고 결제하면 첫 주문 금액의 20%를 크레딧으로 받습니다.', ru: 'Когда друг регистрируется и платит по вашей ссылке, вы получаете 20% его первого заказа в виде бонуса.', es: 'Cuando un amigo se registra y paga con tu enlace, ganas el 20% de su primer pedido como saldo.' }, + inviteLink: { zh: '我的邀请链接', en: 'My invite link', ja: 'マイ紹介リンク', ko: '내 초대 링크', ru: 'Моя реферальная ссылка', es: 'Mi enlace de invitación' }, + invited: { zh: '已邀请', en: 'Invited', ja: '招待済み', ko: '초대함', ru: 'Приглашено', es: 'Invitados' }, + paidUsers: { zh: '已付费', en: 'Converted', ja: '成約', ko: '전환됨', ru: 'Оплатили', es: 'Convertidos' }, + earned: { zh: '累计返利', en: 'Credit earned', ja: '獲得クレジット', ko: '적립 크레딧', ru: 'Начислено бонусов', es: 'Saldo ganado' }, + inviteRecord: { zh: '邀请记录', en: 'History', ja: '履歴', ko: '내역', ru: 'История', es: 'Historial' }, + recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet', ja: 'まだ紹介はありません', ko: '아직 추천 내역이 없습니다', ru: 'Пока нет рефералов', es: 'Aún no hay referidos' }, + recRegistered: { zh: '已注册', en: 'Registered', ja: '登録済み', ko: '가입함', ru: 'Зарегистрирован', es: 'Registrado' }, + recPaid: { zh: '已付费', en: 'Paid', ja: '支払い済み', ko: '결제함', ru: 'Оплачено', es: 'Pagado' }, /* 2FA login step */ - twoFATitle: { zh: '双重认证', en: 'Two-factor auth' }, - twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.' }, - twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in' }, - twoFABack: { zh: '返回上一步', en: 'Back' }, - twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' }, + twoFATitle: { zh: '双重认证', en: 'Two-factor auth', ja: '二要素認証', ko: '2단계 인증', ru: 'Двухфакторная аутентификация', es: 'Verificación en dos pasos' }, + twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.', ja: 'このアカウントでは二要素認証が有効です。認証アプリの6桁コードを入力してください。', ko: '이 계정은 2단계 인증이 켜져 있습니다. 인증 앱의 6자리 코드를 입력하세요.', ru: 'Для этого аккаунта включена двухфакторная аутентификация. Введите 6-значный код из приложения-аутентификатора.', es: 'La verificación en dos pasos está activada en esta cuenta. Introduce el código de 6 dígitos de tu app de autenticación.' }, + twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in', ja: '確認してログイン', ko: '인증 후 로그인', ru: 'Подтвердить и войти', es: 'Verificar e iniciar sesión' }, + twoFABack: { zh: '返回上一步', en: 'Back', ja: '戻る', ko: '뒤로', ru: 'Назад', es: 'Atrás' }, + twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again', ja: 'コードが正しくありません。もう一度お試しください', ko: '코드가 올바르지 않습니다. 다시 시도하세요', ru: 'Неверный код, попробуйте снова', es: 'Código no válido, inténtalo de nuevo' }, /* settings */ - settingsTitle: { zh: '设置', en: 'Settings' }, - prefTitle: { zh: '偏好', en: 'Preferences' }, - prefLang: { zh: '显示语言', en: 'Language' }, - prefTheme: { zh: '外观主题', en: 'Theme' }, - themeLight: { zh: '浅色', en: 'Light' }, - themeDark: { zh: '深色', en: 'Dark' }, + settingsTitle: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' }, + prefTitle: { zh: '偏好', en: 'Preferences', ja: '環境設定', ko: '환경설정', ru: 'Параметры', es: 'Preferencias' }, + prefLang: { zh: '显示语言', en: 'Language', ja: '表示言語', ko: '언어', ru: 'Язык', es: 'Idioma' }, + prefTheme: { zh: '外观主题', en: 'Theme', ja: 'テーマ', ko: '테마', ru: 'Тема', es: 'Tema' }, + themeLight: { zh: '浅色', en: 'Light', ja: 'ライト', ko: '라이트', ru: 'Светлая', es: 'Claro' }, + themeDark: { zh: '深色', en: 'Dark', ja: 'ダーク', ko: '다크', ru: 'Тёмная', es: 'Oscuro' }, /* devices */ - devTitle: { zh: '设备管理', en: 'Devices' }, - devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.' }, - devCurrent: { zh: '当前设备', en: 'This device' }, - devLastActive: { zh: '最近活跃', en: 'Last active' }, - devRemove: { zh: '移除', en: 'Remove' }, - devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?' }, - devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.' }, - devEmpty: { zh: '暂无其他设备', en: 'No devices yet' }, - cancel: { zh: '取消', en: 'Cancel' }, - confirm: { zh: '确认移除', en: 'Remove' }, + devTitle: { zh: '设备管理', en: 'Devices', ja: 'デバイス', ko: '기기', ru: 'Устройства', es: 'Dispositivos' }, + devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.', ja: 'このアカウントにログイン中のデバイスです。削除するとそのデバイスはログアウトされます。', ko: '이 계정에 로그인된 기기입니다. 제거하면 해당 기기는 로그아웃됩니다.', ru: 'Устройства, вошедшие в ваш аккаунт. Удаление приведёт к выходу из аккаунта на устройстве.', es: 'Dispositivos con sesión iniciada en tu cuenta. Al quitar uno, se cierra su sesión.' }, + devCurrent: { zh: '当前设备', en: 'This device', ja: 'このデバイス', ko: '현재 기기', ru: 'Это устройство', es: 'Este dispositivo' }, + devLastActive: { zh: '最近活跃', en: 'Last active', ja: '最終アクティブ', ko: '마지막 활동', ru: 'Последняя активность', es: 'Última actividad' }, + devRemove: { zh: '移除', en: 'Remove', ja: '削除', ko: '제거', ru: 'Удалить', es: 'Quitar' }, + devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?', ja: 'このデバイスを削除しますか?', ko: '이 기기를 제거할까요?', ru: 'Удалить это устройство?', es: '¿Quitar este dispositivo?' }, + devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.', ja: 'このデバイスはログアウトされ、再接続には再認証が必要になります。', ko: '해당 기기는 로그아웃되며 다시 연결하려면 재인증이 필요합니다.', ru: 'Устройство выйдет из аккаунта и должно будет пройти повторную аутентификацию для подключения.', es: 'Se cerrará su sesión y deberá volver a autenticarse para conectarse de nuevo.' }, + devEmpty: { zh: '暂无其他设备', en: 'No devices yet', ja: '他のデバイスはありません', ko: '다른 기기가 없습니다', ru: 'Пока нет устройств', es: 'Aún no hay dispositivos' }, + cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル', ko: '취소', ru: 'Отмена', es: 'Cancelar' }, + confirm: { zh: '确认移除', en: 'Remove', ja: '削除する', ko: '제거', ru: 'Удалить', es: 'Quitar' }, /* TOTP management */ - totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)' }, - totpOff: { zh: '未开启', en: 'Off' }, - totpOn: { zh: '已开启', en: 'On' }, - totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.' }, - totpEnable: { zh: '开启双重认证', en: 'Enable 2FA' }, - totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA' }, - totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually' }, - totpSecretLabel: { zh: '手动密钥', en: 'Manual key' }, - totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm' }, - totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable' }, - totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled' }, - totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled' }, - totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.' }, - copyKey: { zh: '复制密钥', en: 'Copy key' }, + totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)', ja: '二要素認証 (TOTP)', ko: '2단계 인증 (TOTP)', ru: 'Двухфакторная аутентификация (TOTP)', es: 'Verificación en dos pasos (TOTP)' }, + totpOff: { zh: '未开启', en: 'Off', ja: 'オフ', ko: '꺼짐', ru: 'Выкл.', es: 'Desactivado' }, + totpOn: { zh: '已开启', en: 'On', ja: 'オン', ko: '켜짐', ru: 'Вкл.', es: 'Activado' }, + totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.', ja: '認証アプリのワンタイムコードでログインを保護します。', ko: '인증 앱의 일회용 코드로 로그인을 보호하세요.', ru: 'Защитите вход одноразовыми кодами из приложения-аутентификатора.', es: 'Protege el inicio de sesión con códigos de un solo uso de una app de autenticación.' }, + totpEnable: { zh: '开启双重认证', en: 'Enable 2FA', ja: '二要素認証を有効にする', ko: '2단계 인증 켜기', ru: 'Включить 2FA', es: 'Activar 2FA' }, + totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA', ja: '二要素認証を無効にする', ko: '2단계 인증 끄기', ru: 'Отключить 2FA', es: 'Desactivar 2FA' }, + totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually', ja: '1. 認証アプリでスキャンするか、キーを手動で入力してください', ko: '1. 인증 앱으로 스캔하거나 키를 직접 입력하세요', ru: '1. Отсканируйте в приложении-аутентификаторе или введите ключ вручную', es: '1. Escanea con una app de autenticación o introduce la clave manualmente' }, + totpSecretLabel: { zh: '手动密钥', en: 'Manual key', ja: '手動入力キー', ko: '수동 키', ru: 'Ключ для ручного ввода', es: 'Clave manual' }, + totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm', ja: '2. アプリに表示された6桁コードを入力して確認してください', ko: '2. 앱에 표시된 6자리 코드를 입력해 확인하세요', ru: '2. Введите 6-значный код из приложения для подтверждения', es: '2. Introduce el código de 6 dígitos que muestra la app para confirmar' }, + totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable', ja: '確認して有効にする', ko: '확인 후 켜기', ru: 'Подтвердить и включить', es: 'Confirmar y activar' }, + totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled', ja: '二要素認証を有効にしました', ko: '2단계 인증이 켜졌습니다', ru: 'Двухфакторная аутентификация включена', es: 'Verificación en dos pasos activada' }, + totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled', ja: '二要素認証を無効にしました', ko: '2단계 인증이 꺼졌습니다', ru: 'Двухфакторная аутентификация отключена', es: 'Verificación en dos pasos desactivada' }, + totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.', ja: '二要素認証を無効にするには現在のコードを入力してください。', ko: '2단계 인증을 끄려면 현재 코드를 입력하세요.', ru: 'Введите текущий код, чтобы отключить 2FA.', es: 'Introduce tu código actual para desactivar la 2FA.' }, + copyKey: { zh: '复制密钥', en: 'Copy key', ja: 'キーをコピー', ko: '키 복사', ru: 'Копировать ключ', es: 'Copiar clave' }, /* generic errors */ - errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' }, - errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' }, - loading: { zh: '加载中…', en: 'Loading…' }, + errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry', ja: 'ネットワークエラーです。後でもう一度お試しください', ko: '네트워크 오류입니다. 잠시 후 다시 시도하세요', ru: 'Ошибка сети, повторите попытку', es: 'Error de red, vuelve a intentarlo' }, + errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry', ja: '問題が発生しました。後でもう一度お試しください', ko: '문제가 발생했습니다. 잠시 후 다시 시도하세요', ru: 'Что-то пошло не так, повторите попытку', es: 'Algo salió mal, vuelve a intentarlo' }, + loading: { zh: '加载中…', en: 'Loading…', ja: '読み込み中…', ko: '불러오는 중…', ru: 'Загрузка…', es: 'Cargando…' }, }; export type TFn = (key: string, vars?: Record<string, string | number>) => string; @@ -136,7 +144,7 @@ export type TFn = (key: string, vars?: Record<string, string | number>) => strin export function makeT(lang: Lang): TFn { return (key, vars) => { const e = STRINGS[key]; - let s = e ? e[lang] || key : key; + let s = e ? e[lang] || e.en || key : key; if (vars) for (const k of Object.keys(vars)) s = s.replace(`{${k}}`, String(vars[k])); return s; }; diff --git a/web/usercenter/lib/theme.tsx b/web/usercenter/lib/theme.tsx index 28e7d70..fc17d0d 100644 --- a/web/usercenter/lib/theme.tsx +++ b/web/usercenter/lib/theme.tsx @@ -18,7 +18,7 @@ const LANG_KEY = 'pg_uc_lang'; const THEME_KEY = 'pg_uc_theme'; export function UIProvider({ children }: { children: React.ReactNode }) { - const [lang, setLangState] = useState<Lang>('zh'); + const [lang, setLangState] = useState<Lang>('en'); // 默认英文(国际化默认语种) const [theme, setThemeState] = useState<Theme>('light'); // 挂载后读取持久化(避免 hydration 不一致) @@ -26,8 +26,10 @@ export function UIProvider({ children }: { children: React.ReactNode }) { try { const l = window.localStorage.getItem(LANG_KEY) as Lang | null; const t = window.localStorage.getItem(THEME_KEY) as Theme | null; - if (l === 'zh' || l === 'en') setLangState(l); - const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l); + // 默认恒浅色:只有用户手动切换过(localStorage 有显式偏好)才用保存值, + // 不跟随系统 prefers-color-scheme(避免系统暗色把登录页/用户中心染黑)。 + const initial: Theme = t === 'dark' || t === 'light' ? t : 'light'; setThemeState(initial); } catch { /* ignore */ @@ -36,7 +38,7 @@ export function UIProvider({ children }: { children: React.ReactNode }) { useEffect(() => { document.documentElement.setAttribute('data-theme', theme); - document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh' : 'en'); + document.documentElement.setAttribute('lang', lang); }, [theme, lang]); const setLang = useCallback((l: Lang) => { diff --git a/web/usercenter/next.config.mjs b/web/usercenter/next.config.mjs index dc79e52..45fba95 100644 --- a/web/usercenter/next.config.mjs +++ b/web/usercenter/next.config.mjs @@ -5,6 +5,10 @@ const nextConfig = { output: 'export', reactStrictMode: true, trailingSlash: true, + // 用户中心从独立子域 app.yanmeiai.com 迁到主站子路径:与官网合并部署到同一 + // CF Pages(pangolin-site),挂在 pangolin.yanmeiai.com/user/。basePath 让所有 + // 路由/资源前缀带 /user;可用 NEXT_PUBLIC_BASE_PATH 覆盖(如本地 dev 置空)。 + basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '/user', // 静态导出无图片优化服务,关闭以保证产物无服务端依赖。 images: { unoptimized: true }, }; diff --git a/web/usercenter/public/colors_and_type.css b/web/usercenter/public/colors_and_type.css index 3b05fd7..b84aff8 100644 --- a/web/usercenter/public/colors_and_type.css +++ b/web/usercenter/public/colors_and_type.css @@ -1,4 +1,4 @@ -/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */ +/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */ /* ============================================================= 穿山甲 VPN · Pangolin VPN — Design Tokens colors_and_type.css diff --git a/web/usercenter/scripts/build-tokens.mjs b/web/usercenter/scripts/build-tokens.mjs index b4e2add..d98050c 100644 --- a/web/usercenter/scripts/build-tokens.mjs +++ b/web/usercenter/scripts/build-tokens.mjs @@ -2,7 +2,7 @@ /** * build-tokens.mjs — 设计令牌同源生成器(usercenter) * - * 唯一真相来源是仓库根的 `design/colors_and_type.css`。 + * 唯一真相来源是仓库根的 `design/prototype/tokens.css`。 * 本脚本把它原样读入,仅删除 Google Fonts @import 行后写入 * `public/colors_and_type.css`(usercenter 通过 <link> 引入此文件)。 * @@ -15,11 +15,11 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SRC = resolve(__dirname, '../../../design/colors_and_type.css'); +const SRC = resolve(__dirname, '../../../design/prototype/tokens.css'); const OUT = resolve(__dirname, '../public/colors_and_type.css'); const BANNER = - '/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' + + '/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' + '生成器: web/usercenter/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n'; if (!existsSync(SRC)) { @@ -39,4 +39,4 @@ const stripped = raw .join('\n'); writeFileSync(OUT, BANNER + stripped, 'utf8'); -console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`); +console.log('[build-tokens] ✅ 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`); diff --git a/web/website/astro.config.mjs b/web/website/astro.config.mjs index eb9ef7e..ffd99c7 100644 --- a/web/website/astro.config.mjs +++ b/web/website/astro.config.mjs @@ -1,13 +1,14 @@ // @ts-check import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; +import { SITE } from './src/config/site.ts'; // 纯静态站点 (SSG),零 SSR。 // `site` 仅用于生成绝对 URL(sitemap / canonical),镜像部署时可被任意域名覆盖, // 不影响「整站可秒级复制到任意备用域名」:产物为纯相对路径静态文件。 // 主站 canonical 域名。镜像构建务必用「同一个」SITE_URL,使 canonical 始终指向主站 // (避免镜像与主站 SEO 竞争),且主站/镜像产物 hash 完全一致(验收:内容一致性)。 -const SITE_URL = process.env.SITE_URL || 'https://pangolin.example'; +const SITE_URL = process.env.SITE_URL || SITE.url; export default defineConfig({ site: SITE_URL, diff --git a/web/website/scripts/build-tokens.mjs b/web/website/scripts/build-tokens.mjs index 01f4b8b..5f36f88 100644 --- a/web/website/scripts/build-tokens.mjs +++ b/web/website/scripts/build-tokens.mjs @@ -2,7 +2,7 @@ /** * build-tokens.mjs — 设计令牌同源生成器 * - * 唯一真相来源是仓库根的 `design/colors_and_type.css`(铁律 1:颜色只用语义 token)。 + * 唯一真相来源是仓库根的 `design/prototype/tokens.css`(铁律 1:颜色只用语义 token)。 * 本脚本把它原样读入,仅做一处「必须的」改写后写入 `src/styles/tokens.gen.css`: * * 删除其中加载 Google Fonts 的 `@import url('https://fonts.googleapis.com/...')` 一行。 @@ -20,11 +20,11 @@ import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SRC = resolve(__dirname, '../../../design/colors_and_type.css'); +const SRC = resolve(__dirname, '../../../design/prototype/tokens.css'); const OUT = resolve(__dirname, '../src/styles/tokens.gen.css'); const BANNER = - '/* AUTO-GENERATED — 勿手改。源: design/colors_and_type.css。' + + '/* AUTO-GENERATED — 勿手改。源: design/prototype/tokens.css。' + '生成器: web/website/scripts/build-tokens.mjs。仅移除第三方 Google Fonts @import。 */\n'; if (!existsSync(SRC)) { @@ -50,4 +50,4 @@ if (/fonts\.(googleapis|gstatic)\.com/i.test(stripped.replace(/^\s*(\/\/|\*|\/\* } writeFileSync(OUT, BANNER + stripped, 'utf8'); -console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/colors_and_type.css)`); +console.log('[build-tokens] 已生成', OUT, `(${stripped.length} 字节,源自 design/prototype/tokens.css)`); diff --git a/web/website/src/components/Docs.astro b/web/website/src/components/Docs.astro index 21e517b..b827009 100644 --- a/web/website/src/components/Docs.astro +++ b/web/website/src/components/Docs.astro @@ -1,15 +1,18 @@ --- import Icon from './Icon.astro'; -import type { T } from '../i18n/strings'; +import type { T, Lang } from '../i18n/strings'; -interface Props { t: T } -const { t } = Astro.props; +interface Props { t: T; lang: Lang } +const { t, lang } = Astro.props; + +// 中文走 /zh/docs/*,其余语言暂共用英文文档(/docs/*)。 +const base = lang === 'zh' ? '/zh/docs' : '/docs'; const docs = [ - { icon: 'rocket', t: 'docs.1t', d: 'docs.1d' }, - { icon: 'circle-help', t: 'docs.2t', d: 'docs.2d' }, - { icon: 'shield-check', t: 'docs.3t', d: 'docs.3d' }, - { icon: 'lock', t: 'docs.4t', d: 'docs.4d' }, + { icon: 'rocket', t: 'docs.1t', d: 'docs.1d', slug: 'quickstart' }, + { icon: 'circle-help', t: 'docs.2t', d: 'docs.2d', slug: 'faq' }, + { icon: 'shield-check', t: 'docs.3t', d: 'docs.3d', slug: 'protocol' }, + { icon: 'lock', t: 'docs.4t', d: 'docs.4d', slug: 'privacy' }, ]; --- <section id="docs"> @@ -21,11 +24,11 @@ const docs = [ <div class="wrap"> <div class="docs-grid"> {docs.map((d) => ( - <a class="doc"> + <a class="doc" href={`${base}/${d.slug}/`}> <div class="ico"><Icon name={d.icon} /></div> <h3>{t(d.t)}</h3> <p>{t(d.d)}</p> - <span class="ln">{t('docs.read')}</span> + <span class="ln">{t('docs.read')}<Icon name="arrow-right" /></span> </a> ))} </div> diff --git a/web/website/src/components/Download.astro b/web/website/src/components/Download.astro index fc1b4d7..2da4913 100644 --- a/web/website/src/components/Download.astro +++ b/web/website/src/components/Download.astro @@ -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 走 TestFlight / Linux),按钮渲染为禁用态占位。 +// Android + Windows + macOS 已由客户端 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: 'laptop', name: 'macOS', ver: 'macOS 12+' }, - { icon: 'monitor', name: 'Windows', ver: 'Win 10/11' }, + { icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android }, + { icon: 'laptop', name: 'macOS', ver: 'macOS 12+', href: SITE.downloads.macos }, + { 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> diff --git a/web/website/src/components/Footer.astro b/web/website/src/components/Footer.astro index b61cd68..8ca6b4a 100644 --- a/web/website/src/components/Footer.astro +++ b/web/website/src/components/Footer.astro @@ -1,6 +1,7 @@ --- import Brand from './Brand.astro'; import type { T } from '../i18n/strings'; +import { SITE } from '../config/site'; interface Props { t: T } const { t } = Astro.props; @@ -11,7 +12,7 @@ const { t } = Astro.props; <div> <div class="nm"> <Brand variant="footer" size={28} /> - 穿山甲 + {t('nav.brand')} </div> <p class="tag">{t('ft.tag')}</p> </div> @@ -27,7 +28,6 @@ const { t } = Astro.props; <h4>{t('ft.resources')}</h4> <ul> <li><a href="#docs">{t('ft.docs')}</a></li> - <li><a href="#blog">{t('ft.blog')}</a></li> <li><a href="#docs">{t('ft.faq')}</a></li> <li><a href="#docs">{t('ft.privacy')}</a></li> </ul> @@ -35,10 +35,10 @@ const { t } = Astro.props; <div> <h4>{t('ft.contact')}</h4> <ul> - <li><a class="mono">shop.pangolin.vpn</a></li> - <li><a class="mono">Telegram @PangolinVPN_bot</a></li> - <li><a class="mono">LINE @pangolinvpn</a></li> - <li><a class="mono">support@pangolin.vpn</a></li> + <li><a class="mono">{SITE.store.label}</a></li> + <li><a class="mono" href={SITE.telegram.url} target="_blank" rel="noopener">Telegram {SITE.telegram.handle}</a></li> + <li><a class="mono">LINE {SITE.line.handle}</a></li> + <li><a class="mono" href={`mailto:${SITE.email}`}>{SITE.email}</a></li> </ul> </div> </div> diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index 352569d..d7ecbd0 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -3,8 +3,9 @@ * 迁自 design/ui_kits/website/index.html 的 <header> + site.js 的菜单/滚动毛玻璃逻辑。 * 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。 */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Download, Menu } from 'lucide-react'; +import { SITE } from '../config/site'; function Mark() { return ( @@ -28,6 +29,55 @@ function Mark() { export default function Header({ lang = 'zh', t = {} }) { const [open, setOpen] = useState(false); const [scrolled, setScrolled] = useState(false); + const [langOpen, setLangOpen] = useState(false); + const [userOpen, setUserOpen] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + const [email, setEmail] = useState(''); + const langRef = useRef(null); + const userRef = useRef(null); + + // 登录后回跳主页:用户中心带 ?redirect=/(配合 usercenter 登录成功后回跳)。 + const loginHref = `${SITE.usercenter}?redirect=/`; + // 用户名截断显示:优先邮箱 @ 前部分,缺失时回退通用词。 + const displayName = (email && email.split('@')[0]) || 'Account'; + + // 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。 + useEffect(() => { + try { + setLoggedIn(!!localStorage.getItem('pg_uc_refresh')); + setEmail(localStorage.getItem('pg_uc_email') || ''); + } catch { + setLoggedIn(false); + } + }, []); + + // 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。 + const clearSession = () => { + try { + localStorage.removeItem('pg_uc_refresh'); + localStorage.removeItem('pg_uc_email'); + } catch { /* ignore */ } + }; + const onSwitch = () => { clearSession(); window.location.href = loginHref; }; + const onLogout = () => { clearSession(); window.location.href = '/'; }; + + useEffect(() => { + if (!langOpen) return; + const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); }; + const onKey = (e) => { if (e.key === 'Escape') setLangOpen(false); }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; + }, [langOpen]); + + useEffect(() => { + if (!userOpen) return; + const onDoc = (e) => { if (userRef.current && !userRef.current.contains(e.target)) setUserOpen(false); }; + const onKey = (e) => { if (e.key === 'Escape') setUserOpen(false); }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; + }, [userOpen]); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); @@ -41,15 +91,21 @@ export default function Header({ lang = 'zh', t = {} }) { ['#pricing', t.pricing], ['#download', t.download], ['#docs', t.docs], - ['#blog', t.blog], ]; + // 6 语言:默认 en=/,其余 /<lang>/。段控横排会挤,用原生下拉。 + const langs = [ + ['en', 'English'], ['zh', '中文'], ['ja', '日本語'], + ['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'], + ]; + const langHref = (l) => (l === 'en' ? '/' : `/${l}/`); + return ( <header class={`hdr${scrolled ? ' scrolled' : ''}`}> <div class="wrap row"> <a class="brand" href="#top"> <Mark /> - <span class="nm">穿山甲</span> + <span class="nm">{t.brand || 'Pangolin'}</span> </a> <nav class="nav"> {nav.map(([href, label]) => ( @@ -57,11 +113,58 @@ export default function Header({ lang = 'zh', t = {} }) { ))} </nav> <div class="right"> - <div class="langseg"> - <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 ref={langRef} class="langwrap"> + <button + type="button" + class="langsel" + onClick={() => setLangOpen((o) => !o)} + aria-haspopup="listbox" + aria-expanded={langOpen} + aria-label="Language" + > + <span>{(langs.find(([c]) => c === lang) || ['', 'English'])[1]}</span> + <svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true"> + <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </button> + {langOpen && ( + <div class="langmenu" role="listbox"> + {langs.map(([code, label]) => { + const on = code === lang; + return ( + <a key={code} role="option" aria-selected={on} href={langHref(code)}> + {label} + </a> + ); + })} + </div> + )} </div> - <span class="linklogin">{t.login}</span> + {loggedIn ? ( + <div ref={userRef} class="langwrap usermenu"> + <button + type="button" + class="langsel userbtn" + onClick={() => setUserOpen((o) => !o)} + aria-haspopup="menu" + aria-expanded={userOpen} + > + <span class="uname">{displayName}</span> + <svg class="caret" viewBox="0 0 24 24" fill="none" aria-hidden="true"> + <path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </button> + {userOpen && ( + <div class="langmenu" role="menu"> + <a role="menuitem" href={SITE.usercenter}>{t.mcenter}</a> + <button role="menuitem" type="button" onClick={onSwitch}>{t.mswitch}</button> + <button role="menuitem" type="button" onClick={onLogout}>{t.mlogout}</button> + </div> + )} + </div> + ) : ( + <a class="linklogin" href={loginHref}>{t.login}</a> + )} <a class="btn btn-primary" href="#download"> <Download /> <span>{t.get}</span> diff --git a/web/website/src/components/Pricing.astro b/web/website/src/components/Pricing.astro index 90c4791..a9ee142 100644 --- a/web/website/src/components/Pricing.astro +++ b/web/website/src/components/Pricing.astro @@ -1,10 +1,12 @@ --- import Icon from './Icon.astro'; import PricingPlans from './PricingPlans.jsx'; -import { PRICES, type T } from '../i18n/strings'; +import { PRICES, type T, type Lang } from '../i18n/strings'; +import { SITE } from '../config/site'; -interface Props { t: T } -const { t } = Astro.props; +interface Props { t: T; lang: Lang } +const { t, lang } = Astro.props; +const P = PRICES[lang]; const plansData = { monthly: t('price.monthly'), @@ -16,7 +18,7 @@ const plansData = { { key: 'free', name: t('price.free'), - price: PRICES.free, + price: P.free, desc: t('price.freedesc'), feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')], cta: t('price.cta_free'), @@ -26,7 +28,7 @@ const plansData = { { key: 'pro', name: t('price.pro'), - price: PRICES.pro, + price: P.pro, desc: t('price.prodesc'), feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')], cta: t('price.cta_pro'), @@ -36,7 +38,7 @@ const plansData = { { key: 'team', name: t('price.team'), - price: PRICES.team, + price: P.team, desc: t('price.teamdesc'), feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')], cta: t('price.cta_team'), @@ -48,7 +50,7 @@ const plansData = { // 功能对比表行:[名称键, free, pro, team];值可为字符串或 'check' / 'dash'。 const rows = [ - { label: 'cmp.locations', free: '1', pro: '80+', team: '80+' }, + { label: 'cmp.locations', free: '1', pro: t('cmp.loc_all'), team: t('cmp.loc_all') }, { label: 'cmp.data', free: t('cmp.daily'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') }, { label: 'cmp.time', free: t('cmp.time_free'), pro: t('cmp.unlimited'), team: t('cmp.unlimited') }, { label: 'cmp.devices', free: '1', pro: '5', team: '10' }, @@ -81,9 +83,9 @@ function cell(v: string) { <div class="chips"> <span class="chip"><Icon name="shopping-bag" /><span>{t('pay.store')}</span></span> <span class="chip"><Icon name="credit-card" /><span>{t('pay.usdt')}</span></span> - <span class="chip"><Icon name="send" />Telegram @PangolinVPN_bot</span> - <span class="chip"><Icon name="message-circle" />LINE @pangolinvpn</span> - <span class="chip"><Icon name="mail" />buy@pangolin.vpn</span> + <a class="chip" href={SITE.telegram.url} target="_blank" rel="noopener"><Icon name="send" />Telegram {SITE.telegram.handle}</a> + <span class="chip"><Icon name="message-circle" />LINE {SITE.line.handle}</span> + <a class="chip" href={`mailto:${SITE.email}`}><Icon name="mail" />{SITE.email}</a> </div> </div> </div> diff --git a/web/website/src/config/site.ts b/web/website/src/config/site.ts new file mode 100644 index 0000000..0f4362f --- /dev/null +++ b/web/website/src/config/site.ts @@ -0,0 +1,38 @@ +/** + * site.ts — 站点级配置「单一真相源」:域名、联系方式、对外渠道。 + * + * 改这一处即可:组件(Footer / Pricing)与 astro.config 的 canonical 域名都从这里读。 + * 此处只承载「事实值」(URL / 邮箱 / handle);可翻译的展示文案仍走 i18n/strings.ts。 + */ +export const SITE = { + /** canonical 主域名。镜像部署可用 SITE_URL 环境变量覆盖(见 astro.config.mjs)。 */ + url: 'https://pangolin.yanmeiai.com', + + /** 联系邮箱。Cloudflare Email Routing 转发到个人 Gmail。 */ + email: 'pangolin@yanmeiai.com', + + /** Telegram 公开频道(群为私有,公开页不放)。 */ + telegram: { handle: '@pangolin_app', url: 'https://t.me/pangolin_app' }, + + /** LINE 官方账号 —— 待确认(#24)。 */ + line: { handle: '@pangolinvpn' }, + + /** 自助发卡商店 —— 占位待定(#24)。 */ + store: { label: 'shop.pangolin.vpn' }, + + /** Web 用户中心(web/usercenter,Next.js 静态导出 → CF Pages)。登录/订阅/设备管理入口。 */ + usercenter: 'https://pangolin.yanmeiai.com/user/', + + /** + * 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露 + * /downloads/<file>(origin = 127.0.0.1:8080),文件由 + * scripts/ci/deploy-client.sh 每次构建 scp 覆盖到 pangolin1:/var/lib/pangolin/downloads/, + * 每平台固定文件名、只保留最新一份。macOS 自 client-v1.0.59 起接入; + * iOS 走 TestFlight(无直接下载文件)、Linux 本轮未接入下载按钮。 + */ + downloads: { + android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk', + windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe', + macos: 'https://api.yanmeiai.com/downloads/pangolin-macos-x64.zip', + }, +} as const; diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index cf79e90..56fccd4 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -1,200 +1,238 @@ -// strings.ts — 官网 i18n 字典(中 / EN) +// strings.ts — 官网 i18n 字典(中 / EN / 日 / 韩 / 俄 / 西 六语) // 文案逐字沿用 design/ui_kits/website/site.js 的 I18N(已脱敏,符合 design/CLAUDE.md 铁律 13)。 -// 与原型不同的是:这里是「构建期」按路由整页单显(zh = /,en = /en/),不在运行时 JS 切换文本。 +// zh/en 原样保留;ja/ko/ru/es 依据 English 自然本地化(营销官网口吻,非机翻)。 +// 与原型不同的是:这里是「构建期」按路由整页单显,不在运行时 JS 切换文本。 -export type Lang = 'zh' | 'en'; +export type Lang = 'zh' | 'en' | 'ja' | 'ko' | 'ru' | 'es'; -// 每条 = [中文, English] -export const STRINGS: Record<string, [string, string]> = { - 'ann.txt': ['新用户注册即享 7 天免费试用', 'New users get a 7-day free trial'], - 'ann.cta': ['立即注册 →', 'Sign up →'], +// 每条 = { zh, en, ja, ko, ru, es } +export const STRINGS: Record<string, Record<Lang, string>> = { + 'ann.txt': { zh: '新用户注册即享 7 天免费试用', en: 'New users get a 7-day free trial', ja: '新規登録で7日間の無料トライアル', ko: '신규 가입 시 7일 무료 체험', ru: 'Новым пользователям — 7 дней бесплатно', es: 'Los nuevos usuarios reciben 7 días de prueba gratis' }, + 'ann.cta': { zh: '立即注册 →', en: 'Sign up →', ja: '今すぐ登録 →', ko: '지금 가입 →', ru: 'Зарегистрироваться →', es: 'Registrarse →' }, - 'su.ph': ['输入邮箱,免费注册', 'Enter email to sign up free'], - 'su.btn': ['免费注册', 'Sign up free'], - 'su.ok': ['验证码已发送!下载 App 并用同一邮箱继续完成注册。', 'Code sent! Download the app and continue with the same email.'], - 'su.hint': ['免费注册 · 无需付款 · 7 天免费试用', 'Free to join · no payment · 7-day free trial'], - 'su.dl': ['或直接下载 App', 'or just download the app'], + 'su.ph': { zh: '输入邮箱,免费注册', en: 'Enter email to sign up free', ja: 'メールアドレスで無料登録', ko: '이메일로 무료 가입', ru: 'Введите e-mail для бесплатной регистрации', es: 'Introduce tu correo para registrarte gratis' }, + 'su.btn': { zh: '免费注册', en: 'Sign up free', ja: '無料で登録', ko: '무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' }, + 'su.ok': { zh: '验证码已发送!下载 App 并用同一邮箱继续完成注册。', en: 'Code sent! Download the app and continue with the same email.', ja: '認証コードを送信しました!アプリをダウンロードし、同じメールアドレスで登録を続けてください。', ko: '인증 코드를 보냈습니다! 앱을 내려받아 같은 이메일로 가입을 이어가세요.', ru: 'Код отправлен! Скачайте приложение и продолжите с той же почтой.', es: '¡Código enviado! Descarga la app y continúa con el mismo correo.' }, + 'su.hint': { zh: '免费注册 · 无需付款 · 7 天免费试用', en: 'Free to join · no payment · 7-day free trial', ja: '登録無料 · 支払い不要 · 7日間無料トライアル', ko: '가입 무료 · 결제 불필요 · 7일 무료 체험', ru: 'Регистрация бесплатна · без оплаты · 7 дней бесплатно', es: 'Registro gratis · sin pago · 7 días de prueba' }, + 'su.dl': { zh: '或直接下载 App', en: 'or just download the app', ja: 'アプリを直接ダウンロード', ko: '또는 앱 바로 내려받기', ru: 'или просто скачайте приложение', es: 'o simplemente descarga la app' }, - 'why.eyebrow': ['免费注册', 'Sign up'], - 'why.h': ['注册一次,处处可用', 'One account, everywhere'], - 'why.sub': ['一个邮箱就能开始。注册免费,不需要任何付款信息。', 'All you need is an email. Free to join — no payment details, ever.'], - 'why.1t': ['7 天免费试用', '7-day free trial'], - 'why.1d': ['新用户注册即享 7 天免费使用,无需任何付款信息。', 'New accounts get 7 days of free, unrestricted use — no payment details.'], - 'why.2t': ['多端同步', 'Multi-device sync'], - 'why.2d': ['手机、平板、电脑共用一个账户,套餐与设置随身走。', 'Phone, tablet and desktop share one account — your plan follows you.'], - 'why.3t': ['无需付款信息', 'No payment details'], - 'why.3d': ['注册只要邮箱。想升级时,再通过外部渠道兑换激活码。', 'Just an email to join. Upgrade later with an activation code.'], - 'why.s1': ['填写邮箱', 'Enter email'], - 'why.s2': ['输入验证码', 'Verify code'], - 'why.s3': ['开始畅连', 'Start connecting'], - 'why.cta': ['立即免费注册', 'Sign up free'], + 'why.eyebrow': { zh: '免费注册', en: 'Sign up', ja: '無料登録', ko: '가입하기', ru: 'Регистрация', es: 'Registrarse' }, + 'why.h': { zh: '注册一次,处处可用', en: 'One account, everywhere', ja: '一つのアカウントで、どこでも', ko: '하나의 계정으로 어디서나', ru: 'Один аккаунт — везде', es: 'Una cuenta, en todas partes' }, + 'why.sub': { zh: '一个邮箱就能开始。注册免费,不需要任何付款信息。', en: 'All you need is an email. Free to join — no payment details, ever.', ja: '必要なのはメールアドレスだけ。登録は無料、支払い情報は一切不要です。', ko: '필요한 건 이메일 하나뿐. 가입은 무료이며 결제 정보도 필요 없습니다.', ru: 'Нужна лишь почта. Регистрация бесплатна — платёжные данные не требуются.', es: 'Solo necesitas un correo. Registro gratis, sin datos de pago nunca.' }, + 'why.1t': { zh: '7 天免费试用', en: '7-day free trial', ja: '7日間無料トライアル', ko: '7일 무료 체험', ru: '7 дней бесплатно', es: '7 días de prueba gratis' }, + 'why.1d': { zh: '新用户注册即享 7 天免费使用,无需任何付款信息。', en: 'New accounts get 7 days of free, unrestricted use — no payment details.', ja: '新規アカウントは7日間、制限なしで無料利用。支払い情報は不要です。', ko: '신규 계정은 7일간 제한 없이 무료로 이용, 결제 정보도 필요 없습니다.', ru: 'Новые аккаунты — 7 дней свободного использования без ограничений и без платёжных данных.', es: 'Las cuentas nuevas disfrutan 7 días de uso libre y sin límites, sin datos de pago.' }, + 'why.2t': { zh: '多端同步', en: 'Multi-device sync', ja: 'マルチデバイス同期', ko: '여러 기기 동기화', ru: 'Синхронизация устройств', es: 'Sincronización multidispositivo' }, + 'why.2d': { zh: '手机、平板、电脑共用一个账户,套餐与设置随身走。', en: 'Phone, tablet and desktop share one account — your plan follows you.', ja: 'スマホ・タブレット・PCで一つのアカウントを共有。プランも設定も一緒に持ち運べます。', ko: '휴대폰, 태블릿, PC가 하나의 계정을 공유해 요금제와 설정이 따라다닙니다.', ru: 'Телефон, планшет и компьютер — один аккаунт, а тариф всегда с вами.', es: 'Teléfono, tableta y ordenador comparten una cuenta; tu plan te acompaña.' }, + 'why.3t': { zh: '无需付款信息', en: 'No payment details', ja: '支払い情報は不要', ko: '결제 정보 불필요', ru: 'Без платёжных данных', es: 'Sin datos de pago' }, + 'why.3d': { zh: '注册只要邮箱。想升级时,再通过外部渠道兑换激活码。', en: 'Just an email to join. Upgrade later with an activation code.', ja: '登録はメールだけ。アップグレードは後からアクティベーションコードで。', ko: '가입은 이메일만. 업그레이드는 나중에 활성화 코드로.', ru: 'Для входа нужна только почта. Обновиться можно позже кодом активации.', es: 'Solo un correo para unirte. Mejora luego con un código de activación.' }, + 'why.s1': { zh: '填写邮箱', en: 'Enter email', ja: 'メールを入力', ko: '이메일 입력', ru: 'Введите почту', es: 'Introduce el correo' }, + 'why.s2': { zh: '输入验证码', en: 'Verify code', ja: '認証コードを入力', ko: '인증 코드 입력', ru: 'Введите код', es: 'Verifica el código' }, + 'why.s3': { zh: '开始畅连', en: 'Start connecting', ja: '接続を開始', ko: '연결 시작', ru: 'Начните подключение', es: 'Empieza a conectar' }, + 'why.cta': { zh: '立即免费注册', en: 'Sign up free', ja: '今すぐ無料登録', ko: '지금 무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' }, - 'cta.btn2': ['免费注册', 'Sign up free'], + 'cta.btn2': { zh: '免费注册', en: 'Sign up free', ja: '無料で登録', ko: '무료 가입', ru: 'Зарегистрироваться бесплатно', es: 'Registrarse gratis' }, - 'nav.product': ['产品', 'Product'], - 'nav.pricing': ['定价', 'Pricing'], - 'nav.download': ['下载', 'Download'], - 'nav.docs': ['文档', 'Docs'], - 'nav.blog': ['Blog', 'Blog'], - 'nav.login': ['登录', 'Log in'], - 'nav.get': ['立即下载', 'Get the app'], + 'nav.product': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' }, + 'nav.pricing': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' }, + 'nav.download': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' }, + 'nav.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' }, + 'nav.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' }, + 'nav.login': { zh: '登录', en: 'Log in', ja: 'ログイン', ko: '로그인', ru: 'Войти', es: 'Iniciar sesión' }, + 'nav.center': { zh: '用户中心', en: 'Account', ja: 'アカウント', ko: '계정', ru: 'Личный кабинет', es: 'Mi cuenta' }, + 'nav.get': { zh: '立即下载', en: 'Get the app', ja: 'アプリを入手', ko: '앱 받기', ru: 'Получить приложение', es: 'Obtener la app' }, + // 品牌字标:中文显「穿山甲」,其余语言统一显「Pangolin」(英文版 logo 本地化)。 + 'nav.brand': { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' }, + // 登录态用户下拉菜单项(3 项 × 6 语)。 + 'menu.center': { zh: '进入用户中心', en: 'Open account center', ja: 'アカウントセンターへ', ko: '계정 센터 열기', ru: 'Личный кабинет', es: 'Ir a mi cuenta' }, + 'menu.switch': { zh: '切换用户', en: 'Switch account', ja: 'アカウントを切替', ko: '계정 전환', ru: 'Сменить аккаунт', es: 'Cambiar de cuenta' }, + 'menu.logout': { zh: '退出登录', en: 'Log out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' }, - 'hero.eyebrow': ['极速 · 稳定 · 省心', 'Fast · Stable · Effortless'], - 'hero.h1': ['极速畅连,\n网络如丝顺滑', 'Faster, smoother,\neverywhere'], - 'hero.lede': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'], - 'hero.cta1': ['免费下载', 'Download free'], - 'hero.cta2': ['查看定价', 'See pricing'], - 'hero.t1': ['80+ 全球加速线路', '80+ global routes'], - 'hero.t2': ['严格无日志', 'Strict no-logs'], - 'hero.t3': ['端到端加密', 'End-to-end encrypted'], - 'orb.cap': ['已连接', 'CONNECTED'], - 'orb.down': ['下载', 'Down'], - 'orb.up': ['上传', 'Up'], - 'orb.node': ['香港 · 流媒体', 'Hong Kong'], - 'orb.nodesub': ['延迟 18ms · 已连接', '18ms · Connected'], + 'hero.eyebrow': { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless', ja: '高速 · 安定 · 快適', ko: '빠름 · 안정 · 간편', ru: 'Быстро · Стабильно · Без забот', es: 'Rápido · Estable · Sin complicaciones' }, + 'hero.h1': { zh: '极速畅连,\n网络如丝顺滑', en: 'Faster, smoother,\neverywhere', ja: 'もっと速く、もっと滑らかに、\nどこでも', ko: '더 빠르고 더 매끄럽게,\n어디서나', ru: 'Быстрее, плавнее,\nвезде', es: 'Más rápido, más fluido,\nen todas partes' }, + 'hero.lede': { zh: '轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', en: 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.', ja: '軽快で使いやすい、クロスプラットフォームのネットワークアクセラレーター。ワンタップ接続、スマートルーティング、安定した通信。', ko: '가볍고 친근한 크로스플랫폼 네트워크 가속 앱. 원 탭 연결, 스마트 라우팅, 흔들림 없는 안정성.', ru: 'Лёгкий и удобный кроссплатформенный ускоритель сети. Одно касание, умная маршрутизация, надёжное соединение.', es: 'Un acelerador de red multiplataforma, ligero y fácil de usar. Un toque, enrutamiento inteligente, total estabilidad.' }, + 'hero.cta1': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' }, + 'hero.cta2': { zh: '查看定价', en: 'See pricing', ja: '料金を見る', ko: '요금 보기', ru: 'Посмотреть цены', es: 'Ver precios' }, + 'hero.t1': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' }, + 'hero.t2': { zh: '严格无日志', en: 'Strict no-logs', ja: '厳格なノーログ', ko: '엄격한 노로그', ru: 'Строго без логов', es: 'Sin registros, estricto' }, + 'hero.t3': { zh: '端到端加密', en: 'End-to-end encrypted', ja: 'エンドツーエンド暗号化', ko: '엔드투엔드 암호화', ru: 'Сквозное шифрование', es: 'Cifrado de extremo a extremo' }, + 'orb.cap': { zh: '已连接', en: 'CONNECTED', ja: '接続済み', ko: '연결됨', ru: 'ПОДКЛЮЧЕНО', es: 'CONECTADO' }, + 'orb.down': { zh: '下载', en: 'Down', ja: '下り', ko: '다운', ru: 'Приём', es: 'Bajada' }, + 'orb.up': { zh: '上传', en: 'Up', ja: '上り', ko: '업', ru: 'Отдача', es: 'Subida' }, + 'orb.node': { zh: '香港 · 流媒体', en: 'Hong Kong', ja: '香港', ko: '홍콩', ru: 'Гонконг', es: 'Hong Kong' }, + 'orb.nodesub': { zh: '延迟 18ms · 已连接', en: '18ms · Connected', ja: '18ms · 接続済み', ko: '18ms · 연결됨', ru: '18 мс · Подключено', es: '18 ms · Conectado' }, - 'strip.txt': ['已支持', 'Available on'], + 'strip.txt': { zh: '已支持', en: 'Available on', ja: '対応プラットフォーム', ko: '지원 플랫폼', ru: 'Доступно на', es: 'Disponible en' }, - 'feat.eyebrow': ['产品', 'Product'], - 'feat.h': ['为日常而生的连接体验', 'Built for everyday connection'], - 'feat.sub': ['没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。', 'No complex setup, no jargon. Just open and go — we handle speed and privacy.'], - 'feat.1t': ['一键连接', 'One-tap connect'], - 'feat.1d': ['一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', 'One button picks the fastest route. No manual setup, instant connect.'], - 'feat.2t': ['智能分流', 'Smart routing'], - 'feat.2d': ['按规则智能分流,该加速的加速,本地服务直连,互不打扰。', 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.'], - 'feat.3t': ['80+ 全球加速线路', '80+ global routes'], - 'feat.3d': ['覆盖港日新美欧韩等地,视频与游戏优化线路随心选。', 'Routes across HK / JP / SG / US / EU and more, tuned for video & gaming.'], - 'feat.4t': ['严格无日志', 'Strict no-logs'], - 'feat.4d': ['我们不记录你的浏览数据。隐私是底线,不是卖点。', 'We never log your browsing. Privacy is the baseline, not a feature.'], - 'feat.5t': ['Kill Switch', 'Kill Switch'], - 'feat.5d': ['一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。', 'If the tunnel drops, traffic is blocked instantly — no IP leaks.'], - 'feat.6t': ['多端同步', 'Multi-device'], - 'feat.6d': ['一个账户,手机、平板、电脑同时在线,最多 5 台设备。', 'One account across phone, tablet and desktop — up to 5 devices.'], + 'feat.eyebrow': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' }, + 'feat.h': { zh: '为日常而生的连接体验', en: 'Built for everyday connection', ja: '毎日の接続のために', ko: '일상을 위한 연결 경험', ru: 'Создан для повседневных подключений', es: 'Diseñado para la conexión de cada día' }, + 'feat.sub': { zh: '没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。', en: 'No complex setup, no jargon. Just open and go — we handle speed and privacy.', ja: '複雑な設定も専門用語も不要。開いて使うだけ。速度とプライバシーはお任せください。', ko: '복잡한 설정도, 어려운 용어도 없습니다. 열면 바로 사용, 속도와 개인정보는 저희가 책임집니다.', ru: 'Никаких сложных настроек и жаргона. Просто откройте и пользуйтесь — скорость и приватность на нас.', es: 'Sin configuraciones complejas ni tecnicismos. Ábrelo y listo: la velocidad y la privacidad corren por nuestra cuenta.' }, + 'feat.1t': { zh: '一键连接', en: 'One-tap connect', ja: 'ワンタップ接続', ko: '원 탭 연결', ru: 'Подключение одним касанием', es: 'Conexión con un toque' }, + 'feat.1d': { zh: '一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。', en: 'One button picks the fastest route. No manual setup, instant connect.', ja: 'ボタン一つで最速回線を自動選択。手動設定なしで瞬時に接続。', ko: '버튼 하나로 가장 빠른 라우트를 자동 선택. 수동 설정 없이 바로 연결.', ru: 'Одна кнопка выбирает самый быстрый маршрут. Без ручных настроек — мгновенное подключение.', es: 'Un botón elige la ruta más rápida. Sin ajustes manuales, conexión instantánea.' }, + 'feat.2t': { zh: '智能分流', en: 'Smart routing', ja: 'スマートルーティング', ko: '스마트 라우팅', ru: 'Умная маршрутизация', es: 'Enrutamiento inteligente' }, + 'feat.2d': { zh: '按规则智能分流,该加速的加速,本地服务直连,互不打扰。', en: 'Smart rule-based routing — accelerate what needs it, keep local traffic direct.', ja: 'ルールベースのスマートルーティングで、必要な通信は高速化、ローカル通信は直結。', ko: '규칙 기반 스마트 라우팅으로 필요한 트래픽은 가속하고 로컬 트래픽은 직접 연결.', ru: 'Умная маршрутизация по правилам: ускоряем нужное, локальный трафик идёт напрямую.', es: 'Enrutamiento inteligente por reglas: acelera lo que lo necesita y deja el tráfico local directo.' }, + 'feat.3t': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' }, + 'feat.3d': { zh: '覆盖多地优质线路,视频与游戏优化随心选。', en: 'Premium routes across regions, tuned for video & gaming.', ja: '各地の高品質回線を網羅、動画・ゲーム向けに最適化。', ko: '여러 지역의 프리미엄 라우트, 영상과 게임에 맞춰 최적화.', ru: 'Премиум-маршруты по регионам, настроенные для видео и игр.', es: 'Rutas premium en varias regiones, optimizadas para vídeo y juegos.' }, + 'feat.4t': { zh: '严格无日志', en: 'Strict no-logs', ja: '厳格なノーログ', ko: '엄격한 노로그', ru: 'Строго без логов', es: 'Sin registros, estricto' }, + 'feat.4d': { zh: '我们不记录你的浏览数据。隐私是底线,不是卖点。', en: 'We never log your browsing. Privacy is the baseline, not a feature.', ja: '閲覧データは一切記録しません。プライバシーは売り文句ではなく、当然の前提です。', ko: '고객의 브라우징 데이터를 절대 기록하지 않습니다. 개인정보 보호는 기본이지 특별 기능이 아닙니다.', ru: 'Мы не ведём журнал вашего трафика. Приватность — это база, а не опция.', es: 'Nunca registramos tu navegación. La privacidad es la base, no un extra.' }, + 'feat.5t': { zh: 'Kill Switch', en: 'Kill Switch', ja: 'キルスイッチ', ko: '킬 스위치', ru: 'Kill Switch', es: 'Kill Switch' }, + 'feat.5d': { zh: '一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。', en: 'If the tunnel drops, traffic is blocked instantly — no IP leaks.', ja: '接続が切れた瞬間に通信を遮断し、実IPの漏洩を防ぎます。', ko: '터널이 끊기면 즉시 트래픽을 차단해 실제 IP 노출을 막습니다.', ru: 'Если соединение обрывается, трафик мгновенно блокируется — без утечек IP.', es: 'Si la conexión se cae, el tráfico se bloquea al instante: sin filtraciones de IP.' }, + 'feat.6t': { zh: '多端同步', en: 'Multi-device', ja: 'マルチデバイス', ko: '멀티 디바이스', ru: 'Много устройств', es: 'Multidispositivo' }, + 'feat.6d': { zh: '一个账户,手机、平板、电脑同时在线,最多 5 台设备。', en: 'One account across phone, tablet and desktop — up to 5 devices.', ja: '一つのアカウントでスマホ・タブレット・PCを同時に、最大5台まで。', ko: '하나의 계정으로 휴대폰, 태블릿, PC를 동시에, 최대 5대까지.', ru: 'Один аккаунт на телефоне, планшете и компьютере — до 5 устройств.', es: 'Una cuenta en teléfono, tableta y ordenador: hasta 5 dispositivos.' }, - 'price.eyebrow': ['定价', 'Pricing'], - 'price.h': ['简单透明的价格', 'Simple, honest pricing'], - 'price.sub': ['随时升级或取消。所有套餐均含核心加密与无日志承诺。', 'Upgrade or cancel anytime. Every plan includes core encryption and our no-logs promise.'], - 'price.monthly': ['按月', 'Monthly'], - 'price.yearly': ['按年', 'Yearly'], - 'price.save': ['省 20%', 'Save 20%'], - 'price.permo': ['/月', '/mo'], - 'price.free': ['免费版', 'Free'], - 'price.freedesc': ['适合轻度使用与尝鲜', 'For light, casual use'], - 'price.pro': ['专业版', 'Pro'], - 'price.prodesc': ['个人用户的最佳选择', 'Best for individuals'], - 'price.team': ['团队版', 'Team'], - 'price.teamdesc': ['小团队集中管理', 'For small teams'], - 'price.popular': ['最受欢迎', 'Most popular'], - 'price.cta_free': ['免费下载', 'Download free'], - 'price.cta_pro': ['获取激活码', 'Get a code'], - 'price.cta_team': ['获取激活码', 'Get a code'], - 'pf.free1': ['每日 10 分钟时长', '10 min per day'], - 'pf.free2': ['仅 1 个基础节点', '1 basic node only'], - 'pf.free3': ['使用前观看广告', 'Watch an ad to start'], - 'pf.free4': ['核心加密 · 无日志', 'Core encryption · no-logs'], - 'pf.pro1': ['80+ 全球加速线路', '80+ global routes'], - 'pf.pro2': ['无限流量 · 极速', 'Unlimited · top speed'], - 'pf.pro3': ['5 台设备同时在线', '5 devices at once'], - 'pf.pro4': ['流媒体 & P2P 优化', 'Streaming & P2P routes'], - 'pf.pro5': ['Kill Switch · 智能分流', 'Kill Switch · smart routing'], - 'pf.team1': ['专业版全部功能', 'Everything in Pro'], - 'pf.team2': ['10 个成员席位', '10 seats'], - 'pf.team3': ['集中计费与管理', 'Central billing & admin'], - 'pf.team4': ['优先客服', 'Priority support'], + 'price.eyebrow': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' }, + 'price.h': { zh: '简单透明的价格', en: 'Simple, honest pricing', ja: 'シンプルで正直な料金', ko: '단순하고 정직한 가격', ru: 'Простые и честные цены', es: 'Precios simples y honestos' }, + 'price.sub': { zh: '随时升级或取消。所有套餐均含核心加密与无日志承诺。', en: 'Upgrade or cancel anytime. Every plan includes core encryption and our no-logs promise.', ja: 'いつでもアップグレード・解約可能。すべてのプランにコア暗号化とノーログの約束が含まれます。', ko: '언제든 업그레이드하거나 해지하세요. 모든 요금제에 핵심 암호화와 노로그 약속이 포함됩니다.', ru: 'Меняйте тариф или отменяйте в любой момент. В каждый план входит базовое шифрование и обещание не вести логи.', es: 'Mejora o cancela cuando quieras. Cada plan incluye cifrado esencial y nuestra promesa sin registros.' }, + 'price.monthly': { zh: '按月', en: 'Monthly', ja: '月払い', ko: '월간', ru: 'Помесячно', es: 'Mensual' }, + 'price.yearly': { zh: '按年', en: 'Yearly', ja: '年払い', ko: '연간', ru: 'Ежегодно', es: 'Anual' }, + 'price.save': { zh: '省 20%', en: 'Save 20%', ja: '20%お得', ko: '20% 절약', ru: 'Экономия 20%', es: 'Ahorra 20%' }, + 'price.permo': { zh: '/月', en: '/mo', ja: '/月', ko: '/월', ru: '/мес', es: '/mes' }, + 'price.free': { zh: '免费版', en: 'Free', ja: '無料版', ko: '무료', ru: 'Бесплатный', es: 'Gratis' }, + 'price.freedesc': { zh: '适合轻度使用与尝鲜', en: 'For light, casual use', ja: 'ライトなお試し利用に', ko: '가볍게 써보는 사용자에게', ru: 'Для лёгкого, эпизодического использования', es: 'Para un uso ligero y ocasional' }, + 'price.pro': { zh: '专业版', en: 'Pro', ja: 'プロ版', ko: '프로', ru: 'Pro', es: 'Pro' }, + 'price.prodesc': { zh: '个人用户的最佳选择', en: 'Best for individuals', ja: '個人ユーザーに最適', ko: '개인 사용자에게 최적', ru: 'Оптимально для одного пользователя', es: 'Ideal para uso individual' }, + 'price.team': { zh: '团队版', en: 'Team', ja: 'チーム版', ko: '팀', ru: 'Команда', es: 'Equipo' }, + 'price.teamdesc': { zh: '小团队集中管理', en: 'For small teams', ja: '小規模チームの一括管理に', ko: '소규모 팀 통합 관리', ru: 'Для небольших команд', es: 'Para equipos pequeños' }, + 'price.popular': { zh: '最受欢迎', en: 'Most popular', ja: '一番人気', ko: '가장 인기', ru: 'Самый популярный', es: 'El más popular' }, + 'price.cta_free': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' }, + 'price.cta_pro': { zh: '获取激活码', en: 'Get a code', ja: 'コードを入手', ko: '코드 받기', ru: 'Получить код', es: 'Obtener un código' }, + 'price.cta_team': { zh: '获取激活码', en: 'Get a code', ja: 'コードを入手', ko: '코드 받기', ru: 'Получить код', es: 'Obtener un código' }, + 'pf.free1': { zh: '每日 10 分钟时长', en: '10 min per day', ja: '1日10分', ko: '하루 10분', ru: '10 минут в день', es: '10 min al día' }, + 'pf.free2': { zh: '仅 1 个基础节点', en: '1 basic node only', ja: '基本ノード1つのみ', ko: '기본 노드 1개만', ru: 'Только 1 базовый узел', es: 'Solo 1 nodo básico' }, + 'pf.free3': { zh: '使用前观看广告', en: 'Watch an ad to start', ja: '利用前に広告を視聴', ko: '사용 전 광고 시청', ru: 'Просмотр рекламы перед запуском', es: 'Ver un anuncio para empezar' }, + 'pf.free4': { zh: '核心加密 · 无日志', en: 'Core encryption · no-logs', ja: 'コア暗号化 · ノーログ', ko: '핵심 암호화 · 노로그', ru: 'Базовое шифрование · без логов', es: 'Cifrado esencial · sin registros' }, + 'pf.pro1': { zh: '全球加速线路', en: 'Global routes', ja: 'グローバル回線', ko: '글로벌 라우트', ru: 'Глобальные маршруты', es: 'Rutas globales' }, + 'pf.pro2': { zh: '无限流量 · 极速', en: 'Unlimited · top speed', ja: '無制限 · 最高速度', ko: '무제한 · 최고 속도', ru: 'Без лимитов · максимальная скорость', es: 'Ilimitado · máxima velocidad' }, + 'pf.pro3': { zh: '5 台设备同时在线', en: '5 devices at once', ja: '5台同時接続', ko: '동시 5대', ru: '5 устройств одновременно', es: '5 dispositivos a la vez' }, + 'pf.pro4': { zh: '流媒体 & P2P 优化', en: 'Streaming & P2P routes', ja: 'ストリーミング & P2P最適化', ko: '스트리밍 & P2P 라우트', ru: 'Маршруты для стриминга и P2P', es: 'Rutas para streaming y P2P' }, + 'pf.pro5': { zh: 'Kill Switch · 智能分流', en: 'Kill Switch · smart routing', ja: 'キルスイッチ · スマートルーティング', ko: '킬 스위치 · 스마트 라우팅', ru: 'Kill Switch · умная маршрутизация', es: 'Kill Switch · enrutamiento inteligente' }, + 'pf.team1': { zh: '专业版全部功能', en: 'Everything in Pro', ja: 'プロ版の全機能', ko: '프로의 모든 기능', ru: 'Всё из тарифа Pro', es: 'Todo lo de Pro' }, + 'pf.team2': { zh: '10 个成员席位', en: '10 seats', ja: '10メンバー席', ko: '멤버 10석', ru: '10 мест для участников', es: '10 plazas' }, + 'pf.team3': { zh: '集中计费与管理', en: 'Central billing & admin', ja: '一括請求と管理', ko: '통합 결제 및 관리', ru: 'Единый биллинг и администрирование', es: 'Facturación y administración centralizadas' }, + 'pf.team4': { zh: '优先客服', en: 'Priority support', ja: '優先サポート', ko: '우선 지원', ru: 'Приоритетная поддержка', es: 'Soporte prioritario' }, - 'pay.title': ['为什么网页上没有支付按钮?', 'Why no checkout on this page?'], - 'pay.body': ['出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。', 'For risk and privacy reasons, Pangolin never takes payment in the web or app. Get an activation code through any channel below, then redeem it in the client.'], - 'pay.store': ['自助发卡商店', 'Self-serve store'], - 'pay.usdt': ['USDT (TRC20) · 最隐私', 'USDT (TRC20) · most private'], + 'pay.title': { zh: '为什么网页上没有支付按钮?', en: 'Why no checkout on this page?', ja: 'なぜこのページに決済ボタンがないのですか?', ko: '이 페이지에 결제 버튼이 없는 이유는?', ru: 'Почему на этой странице нет оплаты?', es: '¿Por qué no hay pago en esta página?' }, + 'pay.body': { zh: '出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。', en: 'For risk and privacy reasons, Pangolin never takes payment in the web or app. Get an activation code through any channel below, then redeem it in the client.', ja: 'リスク管理とプライバシーの観点から、Pangolinはウェブやアプリ内で直接決済を受け付けません。以下のいずれかの窓口でアクティベーションコードを入手し、クライアント内で引き換えてください。', ko: '리스크 관리와 개인정보 보호를 위해 Pangolin은 웹이나 앱에서 직접 결제를 받지 않습니다. 아래 채널에서 활성화 코드를 받아 클라이언트에서 등록하세요.', ru: 'В целях безопасности и приватности Pangolin не принимает оплату в вебе или приложении. Получите код активации по любому из каналов ниже и активируйте его в клиенте.', es: 'Por motivos de seguridad y privacidad, Pangolin nunca cobra en la web ni en la app. Consigue un código de activación por cualquiera de estos canales y canjéalo en el cliente.' }, + 'pay.store': { zh: '自助发卡商店', en: 'Self-serve store', ja: 'セルフサービスストア', ko: '셀프 발급 스토어', ru: 'Магазин самообслуживания', es: 'Tienda de autoservicio' }, + 'pay.usdt': { zh: 'USDT (TRC20) · 最隐私', en: 'USDT (TRC20) · most private', ja: 'USDT (TRC20) · 最もプライベート', ko: 'USDT (TRC20) · 가장 프라이빗', ru: 'USDT (TRC20) · максимум приватности', es: 'USDT (TRC20) · lo más privado' }, - 'cmp.h': ['功能对比', 'Compare plans'], - 'cmp.feature': ['功能', 'Feature'], - 'cmp.locations': ['可用线路', 'Routes'], - 'cmp.data': ['流量', 'Data'], - 'cmp.time': ['每日时长', 'Daily time'], - 'cmp.time_free': ['10 分钟/天(试用期不限)', '10 min/day (trial: unlimited)'], - 'cmp.devices': ['设备数', 'Devices'], - 'cmp.speed': ['极速线路', 'Top-speed routes'], - 'cmp.stream': ['视频优化', 'Video routes'], - 'cmp.kill': ['Kill Switch', 'Kill Switch'], - 'cmp.support': ['客服', 'Support'], - 'cmp.unlimited': ['无限', 'Unlimited'], - 'cmp.daily': ['不限', 'Unlimited'], - 'cmp.basic': ['标准', 'Standard'], - 'cmp.priority': ['优先', 'Priority'], + 'cmp.h': { zh: '功能对比', en: 'Compare plans', ja: 'プラン比較', ko: '요금제 비교', ru: 'Сравнение тарифов', es: 'Comparar planes' }, + 'cmp.feature': { zh: '功能', en: 'Feature', ja: '機能', ko: '기능', ru: 'Функция', es: 'Función' }, + 'cmp.locations': { zh: '可用线路', en: 'Routes', ja: '利用可能な回線', ko: '이용 가능한 라우트', ru: 'Маршруты', es: 'Rutas' }, + 'cmp.data': { zh: '流量', en: 'Data', ja: 'データ量', ko: '데이터', ru: 'Трафик', es: 'Datos' }, + 'cmp.time': { zh: '每日时长', en: 'Daily time', ja: '1日の利用時間', ko: '하루 사용 시간', ru: 'Время в день', es: 'Tiempo diario' }, + 'cmp.time_free': { zh: '10 分钟/天(试用期不限)', en: '10 min/day (trial: unlimited)', ja: '10分/日(トライアル中は無制限)', ko: '10분/일 (체험 중 무제한)', ru: '10 мин/день (в пробный период — без лимита)', es: '10 min/día (prueba: ilimitado)' }, + 'cmp.devices': { zh: '设备数', en: 'Devices', ja: 'デバイス数', ko: '기기 수', ru: 'Устройства', es: 'Dispositivos' }, + 'cmp.speed': { zh: '极速线路', en: 'Top-speed routes', ja: '最高速回線', ko: '최고 속도 라우트', ru: 'Скоростные маршруты', es: 'Rutas de máxima velocidad' }, + 'cmp.stream': { zh: '视频优化', en: 'Video routes', ja: '動画向け回線', ko: '영상 라우트', ru: 'Маршруты для видео', es: 'Rutas para vídeo' }, + 'cmp.kill': { zh: 'Kill Switch', en: 'Kill Switch', ja: 'キルスイッチ', ko: '킬 스위치', ru: 'Kill Switch', es: 'Kill Switch' }, + 'cmp.support': { zh: '客服', en: 'Support', ja: 'サポート', ko: '고객 지원', ru: 'Поддержка', es: 'Soporte' }, + 'cmp.unlimited': { zh: '无限', en: 'Unlimited', ja: '無制限', ko: '무제한', ru: 'Без лимита', es: 'Ilimitado' }, + 'cmp.loc_all': { zh: '全球', en: 'Global', ja: 'グローバル', ko: '글로벌', ru: 'Глобально', es: 'Global' }, + 'cmp.daily': { zh: '不限', en: 'Unlimited', ja: '無制限', ko: '무제한', ru: 'Без лимита', es: 'Ilimitado' }, + 'cmp.basic': { zh: '标准', en: 'Standard', ja: '標準', ko: '표준', ru: 'Стандарт', es: 'Estándar' }, + 'cmp.priority': { zh: '优先', en: 'Priority', ja: '優先', ko: '우선', ru: 'Приоритет', es: 'Prioritario' }, - 'dl.eyebrow': ['下载', 'Download'], - 'dl.h': ['全平台,随处可用', 'Every platform, everywhere'], - 'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'], - 'dl.get': ['下载', 'Download'], + 'dl.eyebrow': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' }, + 'dl.h': { zh: '全平台,随处可用', en: 'Every platform, everywhere', ja: 'すべてのプラットフォーム、どこでも', ko: '모든 플랫폼, 어디서나', ru: 'Все платформы, везде', es: 'Todas las plataformas, en todas partes' }, + 'dl.sub': { zh: '一个账户,所有设备同步。下载即用,无需配置。', en: 'One account syncs every device. Download and go.', ja: '一つのアカウントで全デバイスを同期。ダウンロードしてすぐ使えます。', ko: '하나의 계정으로 모든 기기를 동기화. 내려받아 바로 사용.', ru: 'Один аккаунт синхронизирует все устройства. Скачал — и в путь.', es: 'Una cuenta sincroniza todos tus dispositivos. Descarga y listo.' }, + 'dl.get': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' }, + 'dl.soon': { zh: '敬请期待', en: 'Coming soon', ja: '近日公開', ko: '출시 예정', ru: 'Скоро', es: 'Próximamente' }, - 'docs.eyebrow': ['文档', 'Docs'], - 'docs.h': ['需要帮助?都在这里', 'Need help? It’s all here'], - 'docs.sub': ['从快速上手到协议细节,清晰直接。', 'From quickstart to protocol details — clear and direct.'], - 'docs.1t': ['快速开始', 'Quickstart'], - 'docs.1d': ['三分钟完成注册、下载与首次连接。', 'Sign up, download and connect in three minutes.'], - 'docs.2t': ['常见问题', 'FAQ'], - 'docs.2d': ['连接、计费、设备与兑换码的常见疑问。', 'Connection, billing, devices and redeem codes.'], - 'docs.3t': ['协议与安全', 'Protocol & security'], - 'docs.3d': ['WireGuard、加密方式与无日志架构说明。', 'WireGuard, encryption and our no-logs architecture.'], - 'docs.4t': ['隐私政策', 'Privacy policy'], - 'docs.4d': ['我们收集什么、不收集什么,一目了然。', 'Exactly what we collect — and what we never do.'], - 'docs.read': ['阅读', 'Read'], + 'docs.eyebrow': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' }, + 'docs.h': { zh: '需要帮助?都在这里', en: 'Need help? It’s all here', ja: 'お困りですか?すべてここに', ko: '도움이 필요하세요? 모두 여기에', ru: 'Нужна помощь? Всё здесь', es: '¿Necesitas ayuda? Todo está aquí' }, + 'docs.sub': { zh: '从快速上手到协议细节,清晰直接。', en: 'From quickstart to protocol details — clear and direct.', ja: 'クイックスタートからプロトコルの詳細まで、明快に。', ko: '빠른 시작부터 프로토콜 세부까지, 명확하고 간결하게.', ru: 'От быстрого старта до деталей протокола — ясно и по делу.', es: 'Desde el inicio rápido hasta los detalles del protocolo, claro y directo.' }, + 'docs.1t': { zh: '快速开始', en: 'Quickstart', ja: 'クイックスタート', ko: '빠른 시작', ru: 'Быстрый старт', es: 'Inicio rápido' }, + 'docs.1d': { zh: '三分钟完成注册、下载与首次连接。', en: 'Sign up, download and connect in three minutes.', ja: '3分で登録・ダウンロード・初回接続まで完了。', ko: '3분 만에 가입, 다운로드, 첫 연결까지.', ru: 'Регистрация, загрузка и первое подключение за три минуты.', es: 'Regístrate, descarga y conéctate en tres minutos.' }, + 'docs.2t': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' }, + 'docs.2d': { zh: '连接、计费、设备与兑换码的常见疑问。', en: 'Connection, billing, devices and redeem codes.', ja: '接続・請求・デバイス・引き換えコードのよくある疑問。', ko: '연결, 결제, 기기, 등록 코드에 대한 궁금증.', ru: 'Подключение, оплата, устройства и коды активации.', es: 'Conexión, facturación, dispositivos y códigos de canje.' }, + 'docs.3t': { zh: '协议与安全', en: 'Protocol & security', ja: 'プロトコルとセキュリティ', ko: '프로토콜 & 보안', ru: 'Протокол и безопасность', es: 'Protocolo y seguridad' }, + 'docs.3d': { zh: 'sing-box + REALITY、加密方式与无日志架构说明。', en: 'sing-box + REALITY, encryption and our no-logs architecture.', ja: 'sing-box + REALITY、暗号化方式、ノーログ設計の解説。', ko: 'sing-box + REALITY, 암호화 방식, 노로그 아키텍처 설명.', ru: 'sing-box + REALITY, шифрование и наша архитектура без логов.', es: 'sing-box + REALITY, cifrado y nuestra arquitectura sin registros.' }, + 'docs.4t': { zh: '隐私政策', en: 'Privacy policy', ja: 'プライバシーポリシー', ko: '개인정보 처리방침', ru: 'Политика конфиденциальности', es: 'Política de privacidad' }, + 'docs.4d': { zh: '我们收集什么、不收集什么,一目了然。', en: 'Exactly what we collect — and what we never do.', ja: '収集するもの・しないものを明確に。', ko: '무엇을 수집하고 무엇을 수집하지 않는지 한눈에.', ru: 'Что мы собираем — и чего не собираем никогда.', es: 'Exactamente qué recopilamos y qué nunca hacemos.' }, + 'docs.read': { zh: '阅读', en: 'Read', ja: '読む', ko: '읽기', ru: 'Читать', es: 'Leer' }, - 'blog.eyebrow': ['Blog', 'Blog'], - 'blog.h': ['来自团队的最新动态', 'Latest from the team'], - 'blog.sub': ['产品更新、安全科普与节点公告。', 'Product updates, security explainers and node news.'], - 'blog.1tag': ['产品更新', 'Product'], - 'blog.1t': ['v2.4:更快的智能分流与全新统计页', 'v2.4: faster smart routing & a new stats tab'], - 'blog.1d': ['本次更新重写了分流引擎,连接建立速度提升约 40%。', 'We rewrote the routing engine — connections are ~40% faster.'], - 'blog.2tag': ['安全', 'Security'], - 'blog.2t': ['无日志到底意味着什么?', 'What “no-logs” really means'], - 'blog.2d': ['我们逐条拆解收集与不收集的数据,以及背后的架构。', 'A line-by-line look at what we keep — and what we never touch.'], - 'blog.3tag': ['公告', 'News'], - 'blog.3t': ['新增首尔与法兰克福高速节点', 'New high-speed nodes: Seoul & Frankfurt'], - 'blog.3d': ['两条新线路已上线,为东北亚与欧洲用户带来更低延迟。', 'Two new routes are live, cutting latency for NE Asia and Europe.'], - 'blog.readmore': ['阅读全文', 'Read more'], + 'blog.eyebrow': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' }, + 'blog.h': { zh: '来自团队的最新动态', en: 'Latest from the team', ja: 'チームからの最新情報', ko: '팀의 최신 소식', ru: 'Свежие новости команды', es: 'Lo último del equipo' }, + 'blog.sub': { zh: '产品更新、安全科普与节点公告。', en: 'Product updates, security explainers and node news.', ja: '製品アップデート、セキュリティ解説、ノード情報。', ko: '제품 업데이트, 보안 해설, 노드 소식.', ru: 'Обновления продукта, разборы по безопасности и новости об узлах.', es: 'Novedades del producto, explicaciones de seguridad y avisos de nodos.' }, + 'blog.1tag': { zh: '产品更新', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' }, + 'blog.1t': { zh: 'v2.4:更快的智能分流与全新统计页', en: 'v2.4: faster smart routing & a new stats tab', ja: 'v2.4:より速いスマートルーティングと新しい統計タブ', ko: 'v2.4: 더 빨라진 스마트 라우팅과 새 통계 탭', ru: 'v2.4: быстрее умная маршрутизация и новая вкладка статистики', es: 'v2.4: enrutamiento inteligente más rápido y nueva pestaña de estadísticas' }, + 'blog.1d': { zh: '本次更新重写了分流引擎,连接建立速度提升约 40%。', en: 'We rewrote the routing engine — connections are ~40% faster.', ja: 'ルーティングエンジンを刷新し、接続の確立が約40%高速化しました。', ko: '라우팅 엔진을 새로 작성해 연결 속도가 약 40% 빨라졌습니다.', ru: 'Мы переписали движок маршрутизации — соединения устанавливаются на ~40% быстрее.', es: 'Reescribimos el motor de enrutamiento: las conexiones son ~40% más rápidas.' }, + 'blog.2tag': { zh: '安全', en: 'Security', ja: 'セキュリティ', ko: '보안', ru: 'Безопасность', es: 'Seguridad' }, + 'blog.2t': { zh: '无日志到底意味着什么?', en: 'What “no-logs” really means', ja: '「ノーログ」とは本当はどういう意味か', ko: '“노로그”가 정말 의미하는 것', ru: 'Что на самом деле значит «без логов»', es: 'Qué significa realmente “sin registros”' }, + 'blog.2d': { zh: '我们逐条拆解收集与不收集的数据,以及背后的架构。', en: 'A line-by-line look at what we keep — and what we never touch.', ja: '保存するデータとしないデータを一つずつ、その背後の設計とともに解説。', ko: '저장하는 데이터와 절대 건드리지 않는 데이터를 하나하나 살펴봅니다.', ru: 'Построчный разбор того, что мы храним — и чего не касаемся никогда.', es: 'Un repaso punto por punto de lo que guardamos y lo que nunca tocamos.' }, + 'blog.3tag': { zh: '公告', en: 'News', ja: 'お知らせ', ko: '소식', ru: 'Новости', es: 'Novedades' }, + 'blog.3t': { zh: '新增首尔与法兰克福高速节点', en: 'New high-speed nodes: Seoul & Frankfurt', ja: '高速ノードを新設:ソウルとフランクフルト', ko: '새 고속 노드: 서울과 프랑크푸르트', ru: 'Новые скоростные узлы: Сеул и Франкфурт', es: 'Nuevos nodos de alta velocidad: Seúl y Fráncfort' }, + 'blog.3d': { zh: '两条新线路已上线,为东北亚与欧洲用户带来更低延迟。', en: 'Two new routes are live, cutting latency for NE Asia and Europe.', ja: '2つの新回線が稼働開始、東北アジアと欧州のユーザーに低遅延を。', ko: '두 개의 새 라우트가 가동되어 동북아와 유럽 사용자의 지연이 줄었습니다.', ru: 'Запущены два новых маршрута, снижающих задержку для Северо-Восточной Азии и Европы.', es: 'Dos rutas nuevas ya activas reducen la latencia para el noreste de Asia y Europa.' }, + 'blog.readmore': { zh: '阅读全文', en: 'Read more', ja: '続きを読む', ko: '더 읽기', ru: 'Читать далее', es: 'Leer más' }, - 'cta.h': ['准备好体验极速畅连了吗?', 'Ready for a faster connection?'], - 'cta.p': ['免费下载,数分钟内开始你的第一次极速连接。', 'Download free and get connected in minutes.'], - 'cta.btn': ['免费下载', 'Download free'], + 'cta.h': { zh: '准备好体验极速畅连了吗?', en: 'Ready for a faster connection?', ja: 'より速い接続を体験する準備はできましたか?', ko: '더 빠른 연결을 경험할 준비가 되셨나요?', ru: 'Готовы к более быстрому соединению?', es: '¿Listo para una conexión más rápida?' }, + 'cta.p': { zh: '免费下载,数分钟内开始你的第一次极速连接。', en: 'Download free and get connected in minutes.', ja: '無料でダウンロードして、数分で最初の高速接続を。', ko: '무료로 내려받아 몇 분 안에 연결하세요.', ru: 'Скачайте бесплатно и подключитесь за считанные минуты.', es: 'Descarga gratis y conéctate en minutos.' }, + 'cta.btn': { zh: '免费下载', en: 'Download free', ja: '無料ダウンロード', ko: '무료 다운로드', ru: 'Скачать бесплатно', es: 'Descargar gratis' }, - 'ft.tag': ['极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。', 'A minimal, friendly cross-platform network accelerator. Protecting every connection.'], - 'ft.product': ['产品', 'Product'], - 'ft.resources': ['资源', 'Resources'], - 'ft.contact': ['联系与渠道', 'Contact'], - 'ft.features': ['功能', 'Features'], - 'ft.pricing': ['定价', 'Pricing'], - 'ft.download': ['下载', 'Download'], - 'ft.docs': ['文档', 'Docs'], - 'ft.blog': ['Blog', 'Blog'], - 'ft.faq': ['常见问题', 'FAQ'], - 'ft.privacy': ['隐私政策', 'Privacy'], - 'ft.status': ['服务状态', 'Status'], - 'ft.hours': ['服务时间 每日 9:00–24:00 (GMT+8)', 'Hours: Daily 9:00–24:00 (GMT+8)'], - 'ft.copy': ['© 2026 穿山甲 · Pangolin', '© 2026 Pangolin'], - 'ft.madenote': ['App 内不支持直接支付 · 资金流全走外部渠道', 'No in-app payment · all purchases via external channels'], + 'ft.tag': { zh: '极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。', en: 'A minimal, friendly cross-platform network accelerator. Protecting every connection.', ja: 'ミニマルで軽快、使いやすいクロスプラットフォームのネットワークアクセラレーター。すべての接続を守ります。', ko: '미니멀하고 가벼우며 친근한 크로스플랫폼 네트워크 가속 앱. 모든 연결을 지킵니다.', ru: 'Минималистичный, лёгкий и удобный кроссплатформенный ускоритель сети. Защищает каждое соединение.', es: 'Un acelerador de red multiplataforma minimalista, ligero y amable. Protegiendo cada conexión.' }, + 'ft.product': { zh: '产品', en: 'Product', ja: '製品', ko: '제품', ru: 'Продукт', es: 'Producto' }, + 'ft.resources': { zh: '资源', en: 'Resources', ja: 'リソース', ko: '리소스', ru: 'Ресурсы', es: 'Recursos' }, + 'ft.contact': { zh: '联系与渠道', en: 'Contact', ja: 'お問い合わせ', ko: '문의 및 채널', ru: 'Контакты', es: 'Contacto' }, + 'ft.features': { zh: '功能', en: 'Features', ja: '機能', ko: '기능', ru: 'Возможности', es: 'Funciones' }, + 'ft.pricing': { zh: '定价', en: 'Pricing', ja: '料金', ko: '요금제', ru: 'Цены', es: 'Precios' }, + 'ft.download': { zh: '下载', en: 'Download', ja: 'ダウンロード', ko: '다운로드', ru: 'Скачать', es: 'Descargar' }, + 'ft.docs': { zh: '文档', en: 'Docs', ja: 'ドキュメント', ko: '문서', ru: 'Документация', es: 'Documentación' }, + 'ft.blog': { zh: 'Blog', en: 'Blog', ja: 'ブログ', ko: '블로그', ru: 'Блог', es: 'Blog' }, + 'ft.faq': { zh: '常见问题', en: 'FAQ', ja: 'よくある質問', ko: '자주 묻는 질문', ru: 'Вопросы и ответы', es: 'Preguntas frecuentes' }, + 'ft.privacy': { zh: '隐私政策', en: 'Privacy', ja: 'プライバシー', ko: '개인정보', ru: 'Конфиденциальность', es: 'Privacidad' }, + 'ft.status': { zh: '服务状态', en: 'Status', ja: 'サービス状況', ko: '서비스 상태', ru: 'Статус', es: 'Estado' }, + 'ft.hours': { zh: '服务时间 每日 9:00–24:00 (GMT+8)', en: 'Hours: Daily 9:00–24:00 (GMT+8)', ja: '営業時間 毎日 9:00–24:00 (GMT+8)', ko: '운영 시간 매일 9:00–24:00 (GMT+8)', ru: 'Часы работы: ежедневно 9:00–24:00 (GMT+8)', es: 'Horario: todos los días 9:00–24:00 (GMT+8)' }, + 'ft.copy': { zh: '© 2026 穿山甲 · Pangolin', en: '© 2026 Pangolin', ja: '© 2026 Pangolin', ko: '© 2026 Pangolin', ru: '© 2026 Pangolin', es: '© 2026 Pangolin' }, + 'ft.madenote': { zh: 'App 内不支持直接支付 · 资金流全走外部渠道', en: 'No in-app payment · all purchases via external channels', ja: 'アプリ内決済なし · すべての購入は外部窓口で', ko: '앱 내 결제 없음 · 모든 구매는 외부 채널로', ru: 'Без оплаты в приложении · все покупки через внешние каналы', es: 'Sin pago en la app · todas las compras por canales externos' }, - // 页面 <title> / meta(zh / en) - 'meta.title': ['穿山甲 · Pangolin — 极速 · 稳定 · 省心', 'Pangolin — Fast · Stable · Effortless'], - 'meta.desc': ['轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.'], + // 页面 <title> / meta(六语) + 'meta.title': { zh: '穿山甲 · Pangolin — 极速 · 稳定 · 省心', en: 'Pangolin — Fast · Stable · Effortless', ja: 'Pangolin — 高速 · 安定 · 快適', ko: 'Pangolin — 빠름 · 안정 · 간편', ru: 'Pangolin — Быстро · Стабильно · Без забот', es: 'Pangolin — Rápido · Estable · Sin complicaciones' }, + 'meta.desc': { zh: '轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。', en: 'A lightweight, friendly cross-platform network accelerator. One tap, smart routing, rock-solid.', ja: '軽快で使いやすい、クロスプラットフォームのネットワークアクセラレーター。ワンタップ接続、スマートルーティング、安定した通信。', ko: '가볍고 친근한 크로스플랫폼 네트워크 가속 앱. 원 탭 연결, 스마트 라우팅, 흔들림 없는 안정성.', ru: 'Лёгкий и удобный кроссплатформенный ускоритель сети. Одно касание, умная маршрутизация, надёжное соединение.', es: 'Un acelerador de red multiplataforma, ligero y fácil de usar. Un toque, enrutamiento inteligente, total estabilidad.' }, }; export type T = (key: string) => string; -/** 返回当前语言的取词函数;缺键回退到中文,再回退到键名本身(便于发现遗漏)。 */ +/** 返回当前语言的取词函数;缺键回退英文,再回退键名本身(便于发现遗漏)。 */ export function createT(lang: Lang): T { - const idx = lang === 'zh' ? 0 : 1; return (key: string) => { - const pair = STRINGS[key]; - if (!pair) return key; - return pair[idx] ?? pair[0] ?? key; + const m = STRINGS[key]; + if (!m) return key; + return m[lang] ?? m.en ?? key; }; } -/** 价格表(月 / 年),与 ui_kits/website/site.js 的 PRICES 同步,对齐 design/CLAUDE.md §7。 */ -export const PRICES: Record<'free' | 'pro' | 'team', [string, string]> = { - free: ['¥0', '¥0'], - pro: ['¥25', '¥20'], - team: ['¥99', '¥79'], +/** 价格表(月 / 年·按月),按语言分币种:zh=人民币、其余=美元。年付约 8 折。 + * 对齐 design/CLAUDE.md §7;收款走客户端内兑换激活码,网页价仅为展示。 + * ja/ko/ru/es 暂复用 en 的美元价,币种本地化为后续单独工作。 */ +export const PRICES: Record<Lang, Record<'free' | 'pro' | 'team', [string, string]>> = { + zh: { + free: ['¥0', '¥0'], + pro: ['¥25', '¥20'], + team: ['¥99', '¥79'], + }, + en: { + free: ['$0', '$0'], + pro: ['$3.99', '$3.19'], + team: ['$13.99', '$11.19'], + }, + ja: { + free: ['$0', '$0'], + pro: ['$3.99', '$3.19'], + team: ['$13.99', '$11.19'], + }, + ko: { + free: ['$0', '$0'], + pro: ['$3.99', '$3.19'], + team: ['$13.99', '$11.19'], + }, + ru: { + free: ['$0', '$0'], + pro: ['$3.99', '$3.19'], + team: ['$13.99', '$11.19'], + }, + es: { + free: ['$0', '$0'], + pro: ['$3.99', '$3.19'], + team: ['$13.99', '$11.19'], + }, }; diff --git a/web/website/src/layouts/Doc.astro b/web/website/src/layouts/Doc.astro new file mode 100644 index 0000000..99e6736 --- /dev/null +++ b/web/website/src/layouts/Doc.astro @@ -0,0 +1,77 @@ +--- +/** + * Doc.astro — 文档正文页布局(/docs/* 与 /zh/docs/*)。 + * 复用官网 Header/Footer 与全站样式(tokens.gen / website / site-extra), + * 中间是 .doc-page > .doc-article 可读正文容器。构建期单显一种语言。 + */ +import '@fontsource/sora/500.css'; +import '@fontsource/sora/600.css'; +import '@fontsource/sora/700.css'; +import '@fontsource/manrope/400.css'; +import '@fontsource/manrope/500.css'; +import '@fontsource/manrope/600.css'; +import '@fontsource/manrope/700.css'; +import '@fontsource/noto-sans-sc/400.css'; +import '@fontsource/noto-sans-sc/500.css'; +import '@fontsource/noto-sans-sc/700.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; + +import '../styles/tokens.gen.css'; +import '../styles/website.css'; +import '../styles/site-extra.css'; + +import { createT, type Lang } from '../i18n/strings'; +import Header from '../components/Header.jsx'; +import Footer from '../components/Footer.astro'; + +interface Props { lang: Lang; title: string; desc?: string } +const { lang, title, desc } = Astro.props; +const t = createT(lang); + +const HTML_LANG: Record<Lang, string> = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', ru: 'ru', es: 'es' }; + +const headerT = { + product: t('nav.product'), + pricing: t('nav.pricing'), + download: t('nav.download'), + docs: t('nav.docs'), + blog: t('nav.blog'), + login: t('nav.login'), + center: t('nav.center'), + brand: t('nav.brand'), + mcenter: t('menu.center'), + mswitch: t('menu.switch'), + mlogout: t('menu.logout'), + get: t('nav.get'), + suBtn: t('su.btn'), +}; + +// 导航锚点回主页(文档页无同页锚点):把 #x 改为主页前缀。 +const home = lang === 'zh' ? '/zh/' : '/'; +const backHref = `${home}#docs`; +const metaTitle = `${title} · ${t('nav.brand')}`; +--- +<!doctype html> +<html lang={HTML_LANG[lang]}> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{metaTitle} + {desc && } + + + + +
+
+ +
+