diff --git a/.gitea/workflows/deploy-client.yml b/.gitea/workflows/deploy-client.yml index 307581c..41d467d 100644 --- a/.gitea/workflows/deploy-client.yml +++ b/.gitea/workflows/deploy-client.yml @@ -46,6 +46,12 @@ jobs: run: sh scripts/ci/provision-mac.sh - name: Compile (Flutter macOS) + env: + MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }} + MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }} + 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 }} run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}" - name: Upload macos artifacts diff --git a/docs/macos-signing.md b/docs/macos-signing.md new file mode 100644 index 0000000..20e6184 --- /dev/null +++ b/docs/macos-signing.md @@ -0,0 +1,71 @@ +# macOS 分发(Developer ID + 公证)— 一次性配置 + +macOS 客户端通过**官网下载 zip + 应用内自动更新**分发(非 Mac App Store)。要让用户双击/自更新重启时不被 Gatekeeper 拦截(「已损坏」「无法验证开发者」),CI 构建出的 `.app` 必须 **用 Developer ID Application 证书签名 + 提交 Apple 公证(Notarization)+ staple 票据**。 + +所有凭证通过 Forgejo Secrets 注入,**不入库**。macOS **强制签名+公证**:未配置证书 secret 时 `build-macos` job 会**直接报错、流水线红**,绝不产出未签名包(避免把会被 Gatekeeper 拦截的包发出去)。 + +> 与 iOS 的关系:公证复用 iOS 那套 **App Store Connect API key**,无需新建。只需**额外**做一张 macOS 专用的 **Developer ID Application** 证书(iOS 的 Apple Distribution 证书在这里不可用)。 + +## 前置 + +- Apple Developer Program 账号(与 iOS 同一个,Team ID `BYL4KQHMTN`)。 +- 已按 `docs/ios-signing.md` 配好 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64`(公证直接复用,本文不再重复)。 + +## 1. 创建 Developer ID Application 证书 + +**为什么不是 Apple Distribution**:Apple Distribution 证书只用于 App Store / TestFlight;官网自分发必须用 **Developer ID Application**,否则公证会失败。 + +两种方式任选: + +- **Xcode(推荐,最省事)**:Xcode → Settings → Accounts → 选中团队 → Manage Certificates → 左下 `+` → **Developer ID Application**。生成后证书带私钥落在「钥匙串访问」的「登录」里。 +- **开发者后台**:Certificates, Identifiers & Profiles → Certificates → `+` → **Developer ID Application** → 用钥匙串「证书助理」生成的 CSR 上传 → 下载 `.cer` 双击导入钥匙串。 + +## 2. 导出为 .p12 + +「钥匙串访问」→「我的证书」里找到 **Developer ID Application: …(BYL4KQHMTN)**,**连同私钥**一起右键导出为 `.p12`,设一个导出密码: + +```bash +base64 -i devid.p12 | pbcopy # 复制 base64 到剪贴板 +``` + +## 3. 在 Forgejo 仓库配置 Secrets + +仓库 → Settings → Actions → Secrets,**新增 2 项**: + +| Secret 名 | 值 | +|-----------|-----| +| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` | 第 2 步 `.p12` 的 base64 | +| `MACOS_DEVELOPER_ID_CERT_PASSWORD` | `.p12` 导出密码 | + +公证用的 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64` 已在 iOS 配置时存在,**复用即可**。 + +## 4. 验证 + +打 `client-v*` tag 触发流水线,确认 `build-macos` job: +- 日志出现 `signing + notarizing`(非 `SKIP`); +- 出现 `signed + notarized + stapled`; +- 在一台**没装过本 app 的干净 Mac** 上双击官网下载的 `jiu-macos-x64.zip` 解出的 `.app`,**不弹** Gatekeeper 警告直接打开; +- 断网再双击仍能打开(票据已 staple 进 bundle)。 + +本地手动验证已签名包: + +```bash +codesign --verify --deep --strict --verbose=2 Jiu.app # 应无报错 +xcrun stapler validate Jiu.app # The validate action worked! +spctl -a -vvv -t install Jiu.app # source=Notarized Developer ID, accepted +``` + +## 故障排查 + +- **公证失败**:`xcrun notarytool log --key … --key-id … --issuer …` 看详细原因。最常见三类: + 1. 没用 `--options runtime`(hardened runtime 未启用); + 2. 没带 `--timestamp`(安全时间戳缺失); + 3. 用了 Apple Distribution 而非 **Developer ID Application** 证书。 +- **`security import` 报私钥缺失**:导出 `.p12` 时没勾选私钥,重新从「我的证书」节点(不是单独的证书条目)导出。 +- **`spctl` 显示 rejected**:通常是 staple 没成功或公证未通过——先确认 notarytool 返回 `Accepted`。 + +## 工作原理 + +- `scripts/ci/compile-macos.sh`:开头先校验证书/API key secret,缺失立即 `exit 1`(fail-fast,不浪费一次 build);`flutter build macos` 后建临时 keychain 导入 Developer ID 证书 → inside-out `codesign`(hardened runtime + timestamp + Release.entitlements)→ `notarytool submit --wait`(复用 App Store Connect API key)→ `stapler staple` → 重新 `ditto` 打 `dist/jiu-macos-x64.zip`。**强制签名+公证,不产出未签名包。** +- `.gitea/workflows/deploy-client.yml` 的 `build-macos` job:通过 `env` 注入上述 secrets。 +- 自更新链路:`client/lib/core/update/app_updater_io.dart` 下载该 zip → `ditto` 解压 → 替换 `.app` → `open` 重启;因票据已 staple,重启的新 app 离线也通过 Gatekeeper。 diff --git a/scripts/ci/compile-macos.sh b/scripts/ci/compile-macos.sh index 14c4b4a..d39d9bc 100755 --- a/scripts/ci/compile-macos.sh +++ b/scripts/ci/compile-macos.sh @@ -10,6 +10,20 @@ VER="${TAG#client-v}" echo "==> compile-macos: version=${VER}" +# macOS 通过官网下载 + 应用内自更新分发,未签名/未公证的包会被 Gatekeeper 拦截, +# 因此【强制】签名+公证:缺任一关键 secret 立即报错、流水线红,绝不产出未签名包。 +# 在跑昂贵的 flutter build 之前先做这一检查,快速失败。详见 docs/macos-signing.md。 +# +# 需要的 CI secrets: +# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书 (.p12) 的 base64 +# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码 +# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 复用 iOS 的 App Store Connect API key(公证用) +if [ -z "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:-}" ] || [ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then + echo "ERROR: macOS 签名/公证所需 secret 未配置,拒绝产出未签名包。" >&2 + echo " 需要 MACOS_DEVELOPER_ID_CERT_P12_BASE64 + APPSTORE_API_KEY_P8_BASE64,详见 docs/macos-signing.md" >&2 + exit 1 +fi + # Sync Flutter pubspec version (BSD sed on macOS) sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml @@ -21,11 +35,70 @@ flutter build macos --release \ "--dart-define=APP_VERSION=v${VER}" cd .. -# Package .app bundle into zip +APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app" + +# --- 代码签名 + 公证(Developer ID + Notarization)--- +# secret 已在脚本开头校验过(缺失会 fail-fast),此处必有证书与 API key。 +echo "==> compile-macos: signing + notarizing" +WORK="$(mktemp -d)" +KEYCHAIN="$WORK/jiu-mac.keychain-db" +cleanup_mac() { + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup_mac EXIT + +# 1. 临时 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" +echo "${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 +# 允许 codesign 非交互访问私钥 +security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null +# 把临时 keychain 加入搜索列表(保留默认 login keychain) +security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + +# 解出签名身份 "Developer ID Application: NAME (TEAMID)" +IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \ + | grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)"/\1/')" +if [ -z "$IDENTITY" ]; then + echo "ERROR: Developer ID Application identity not found in keychain" >&2 + exit 1 +fi +echo "==> compile-macos: signing identity '${IDENTITY}'" + +# 2. inside-out 签名:先签嵌套 framework/dylib,再签 app(均启用 hardened runtime + 安全时间戳) +if [ -d "${APP_SRC}/Contents/Frameworks" ]; then + find "${APP_SRC}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 \ + | xargs -0 -I{} codesign --force --options runtime --timestamp --sign "$IDENTITY" {} +fi +codesign --force --options runtime --timestamp \ + --entitlements client/macos/Runner/Release.entitlements \ + --sign "$IDENTITY" "${APP_SRC}" +codesign --verify --deep --strict --verbose=2 "${APP_SRC}" + +# 3. 提交公证(复用 App Store Connect API key),阻塞等待结果 +ditto -c -k --keepParent "${APP_SRC}" "$WORK/notarize.zip" +echo "${APPSTORE_API_KEY_P8_BASE64}" | base64 --decode > "$WORK/AuthKey.p8" +xcrun notarytool submit "$WORK/notarize.zip" \ + --key "$WORK/AuthKey.p8" \ + --key-id "${APPSTORE_API_KEY_ID}" \ + --issuer "${APPSTORE_API_ISSUER_ID}" \ + --wait + +# 4. staple 公证票据进 .app(离线也能通过 Gatekeeper),并核验 +xcrun stapler staple "${APP_SRC}" +xcrun stapler validate "${APP_SRC}" +spctl -a -vvv -t install "${APP_SRC}" || true # 期望 source=Notarized Developer ID +echo "==> compile-macos: signed + notarized + stapled" + +# Package .app bundle into zip(此时 APP_SRC 已签名+stapled) # ditto preserves symlinks, Unix permissions, and extended attributes (code-signing). # Python's zipfile follows symlinks and drops permissions — do NOT use it for .app bundles. mkdir -p dist -APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app" ditto -c -k --keepParent "${APP_SRC}" dist/jiu-macos-x64.zip echo "Packaged dist/jiu-macos-x64.zip ($(du -sh dist/jiu-macos-x64.zip | cut -f1))"