chore(desktop-client): add macOS code-signing and notarization scaffolding
Backend CI / Backend (push) Successful in 13m6s
Deployment Config CI / Deployment Config (push) Successful in 17s
Frontend CI / Frontend (push) Successful in 2m24s

- Move the build config out of package.json into electron-builder.yml so
  the mac block can carry hardenedRuntime, entitlements, universal arch,
  and usage-description Info.plist keys. Drop identity:null (which
  forces unsigned builds) and let CSC_NAME or the keychain decide.
- Add entitlements plist files and three helper scripts: sign-setup.sh
  (one-time keychain prep with notarytool store-credentials),
  sign-mac.sh (sign + notarize + staple, supports keychain or CI env
  vars), and ci-import-cert.sh (CI keychain bootstrap).
- Ignore .env.signing so local credentials don't sneak into the repo,
  and pin @emnapi/{core,runtime} as devDependencies so universal builds
  resolve them deterministically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 16:02:14 +08:00
parent 9c58fe2312
commit a9af6c634c
11 changed files with 336 additions and 86 deletions
+2
View File
@@ -5,6 +5,8 @@ coverage/
dist/
.env
.env.local
.env.signing
apps/*/.env.signing
apps/*/dist/
apps/*/out/
apps/*/release/
+2
View File
@@ -365,3 +365,5 @@ The color system is almost entirely achromatic — dark backgrounds with white/g
5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale
6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds
7. Berkeley Mono for any code or technical content, Inter Variable for everything else
rnzy-benw-ynai-ftbl
@@ -0,0 +1,11 @@
<?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.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.inherit</key><true/>
</dict>
</plist>
@@ -0,0 +1,16 @@
<?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.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
<key>com.apple.security.device.camera</key><true/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.network.server</key><true/>
<key>com.apple.security.files.user-selected.read-write</key><true/>
<key>com.apple.security.automation.apple-events</key><true/>
</dict>
</plist>
+13 -6
View File
@@ -35,18 +35,25 @@ files:
- "!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}"
mac:
category: public.app-category.productivity
identity: null
notarize: false
hardenedRuntime: false
# identity 不写死:让 electron-builder 自动从 CSC_NAME 环境变量或钥匙串里挑
# 注意:写 `identity: null` 是"明确不签名",不要这么写
notarize: false # 我们用 notarytool 自己跑,比 electron-builder 内置更稳
hardenedRuntime: true
gatekeeperAssess: false
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.inherit.plist
electronLanguages:
- en
- zh_CN
extendInfo:
NSCameraUsageDescription: 用于视频会议和录屏功能
NSMicrophoneUsageDescription: 用于音频采集
NSAppleEventsUsageDescription: 用于自动化控制其他应用
target:
- target: dmg
arch: [arm64]
- target: zip
arch: [arm64]
arch: [arm64, universal]
# - target: zip
# arch: [arm64, universal]
dmg:
format: ULFO
contents:
+2 -76
View File
@@ -14,6 +14,8 @@
"dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev --inspect=9229",
"build": "electron-vite build",
"package:mac": "electron-builder --mac --arm64",
"sign:setup": "bash scripts/sign-setup.sh",
"sign:mac": "bash scripts/sign-mac.sh",
"test": "vitest run",
"typecheck": "vue-tsc --noEmit"
},
@@ -46,81 +48,5 @@
"vue": "^3.5.31",
"vue-router": "^4.5.1",
"vue-tsc": "^3.2.6"
},
"build": {
"appId": "com.geo.rankly.desktop",
"productName": "省心推",
"copyright": "Copyright © 2026 shengxintui.com. All Rights Reserved.",
"directories": {
"output": "release",
"buildResources": "build"
},
"compression": "maximum",
"removePackageScripts": true,
"removePackageKeywords": true,
"asar": true,
"asarUnpack": [
"**/node_modules/better-sqlite3/build/Release/**",
"**/node_modules/better-sqlite3/lib/**",
"**/node_modules/bindings/**",
"**/node_modules/file-uri-to-path/**",
"**/node_modules/playwright-core/lib/**",
"**/node_modules/playwright-core/bin/**"
],
"files": [
"out/**/*",
"package.json",
"!**/*.map",
"!**/*.ts",
"!**/*.tsx",
"!**/*.md",
"!**/*.markdown",
"!**/{test,tests,__tests__,__mocks__,example,examples,docs,doc,demo,samples,man}/**",
"!**/{LICENSE,LICENSE.*,license,CHANGELOG,CHANGELOG.*,README,README.*,HISTORY,HISTORY.*,AUTHORS,CONTRIBUTING}*",
"!**/.*",
"!**/node_modules/.bin/**",
"!**/node_modules/*/{Makefile,Gulpfile.js,Gruntfile.js,CMakeLists.txt,*.coffee,*.flow,*.c,*.cc,*.cpp,*.h,*.hpp,*.gyp,*.gypi,*.tgz,binding.gyp}",
"!**/node_modules/**/{*.d.ts,tsconfig*.json,jest.config*,rollup.config*,webpack.config*,vite.config*}",
"!**/node_modules/better-sqlite3/{deps,src,docs,benchmark}/**",
"!**/node_modules/better-sqlite3/{test_extension.c,download.sh,*.gyp,*.gypi}",
"!**/node_modules/playwright-core/{types,ThirdPartyNotices.txt}/**",
"!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}"
],
"mac": {
"category": "public.app-category.productivity",
"identity": null,
"electronLanguages": ["en", "zh_CN"],
"target": [
{ "target": "dmg", "arch": ["arm64"] },
{ "target": "zip", "arch": ["arm64"] }
],
"notarize": false,
"hardenedRuntime": false,
"gatekeeperAssess": false
},
"dmg": {
"format": "ULFO",
"contents": [
{ "x": 130, "y": 220 },
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
]
},
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] }
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
{ "target": "AppImage", "arch": ["x64"] }
],
"category": "Office"
},
"publish": null
}
}
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# CI 专用:把 base64 编码的 .p12 证书导入到一个临时钥匙串
# 仅在 CI 环境调用,本地不要跑(会污染你的登录钥匙串体验)
#
# 必需环境变量:
# CSC_LINK - base64 编码的 .p12 文件内容
# CSC_KEY_PASSWORD - .p12 文件的密码
# KEYCHAIN_PASSWORD - 临时钥匙串自身的密码(任意字符串,例如 secrets.GITHUB_TOKEN 截断都行)
#
# 副作用:创建 ~/Library/Keychains/build.keychain-db,已加入搜索路径并解锁
# 销毁:脚本注册了 trap,进程退出时自动 delete-keychain
set -euo pipefail
: "${CSC_LINK:?CI 必须提供 CSC_LINK (base64 .p12)}"
: "${CSC_KEY_PASSWORD:?CI 必须提供 CSC_KEY_PASSWORD}"
: "${KEYCHAIN_PASSWORD:?CI 必须提供 KEYCHAIN_PASSWORD(用于临时钥匙串自身)}"
KEYCHAIN_NAME="build.keychain"
CERT_PATH="$RUNNER_TEMP/build_certificate.p12"
[ -z "${RUNNER_TEMP:-}" ] && CERT_PATH="/tmp/build_certificate.p12"
# 1. 解码证书到临时文件
echo "$CSC_LINK" | base64 --decode > "$CERT_PATH"
# 2. 创建临时钥匙串
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -lut 21600 "$KEYCHAIN_NAME" # 6 小时不锁
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# 3. 导入 .p12(允许 codesign 和 productsign 不弹密码框)
security import "$CERT_PATH" \
-P "$CSC_KEY_PASSWORD" \
-A -t cert -f pkcs12 \
-k "$KEYCHAIN_NAME"
# 4. 关键:允许 codesign 不交互访问私钥
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" >/dev/null
# 5. 把临时钥匙串加进搜索路径(放第一位,登录钥匙串保留作 fallback)
security list-keychain -d user -s "$KEYCHAIN_NAME" $(security list-keychain -d user | tr -d '"')
# 6. 立即清理证书文件
rm -f "$CERT_PATH"
# 7. 输出实际可用的 identity 名(让 sign-mac.sh 拿到精确的 CSC_NAME
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_NAME" \
| grep "Developer ID Application" | head -n1 \
| sed -E 's/.*"(Developer ID Application: [^"]+)".*/\1/')
if [ -z "$IDENTITY" ]; then
echo "✗ 导入后未在临时钥匙串里找到 Developer ID Application" >&2
exit 1
fi
echo "✓ 已导入证书到临时钥匙串: $KEYCHAIN_NAME"
echo "✓ Identity: $IDENTITY"
# 在 GitHub Actions 里把 CSC_NAME 写入后续步骤的 env
if [ -n "${GITHUB_ENV:-}" ]; then
echo "CSC_NAME=$IDENTITY" >> "$GITHUB_ENV"
echo "✓ 已写入 \$GITHUB_ENVCSC_NAME"
fi
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# 一键:构建 → 签名 → 公证 → 装订 → 验证
# 用法: pnpm sign:mac
set -euo pipefail
cd "$(dirname "$0")/.."
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[1m'; N=$'\033[0m'
ok() { echo " ${G}${N} $1"; }
fail() { echo " ${R}${N} $1"; }
step() { echo ""; echo "${B}── $1 ──${N}"; }
# ── 前置检查(本地/CI 双模式)─────────────────────────────────────
# 本地模式:读 .env.signing,公证走钥匙串 profile
# CI 模式:靠环境变量 CSC_NAME / APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID
NOTARY_ARGS=()
if [ -f .env.signing ] && [ -z "${CI:-}" ]; then
# shellcheck source=/dev/null
set -a; source .env.signing; set +a
: "${CSC_NAME:?.env.signing 缺少 CSC_NAME}"
: "${APPLE_TEAM_ID:?.env.signing 缺少 APPLE_TEAM_ID}"
: "${NOTARY_KEYCHAIN_PROFILE:?.env.signing 缺少 NOTARY_KEYCHAIN_PROFILE}"
NOTARY_ARGS=(--keychain-profile "$NOTARY_KEYCHAIN_PROFILE")
ok "模式: 本地(钥匙串 profile"
elif [ -n "${CSC_NAME:-}" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
NOTARY_ARGS=(--apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID")
ok "模式: CI(环境变量)"
else
fail "未找到签名配置"
echo " 本地: 运行 ${B}pnpm sign:setup${N}"
echo " CI: 设置 ${B}CSC_NAME / APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID${N} 环境变量"
exit 1
fi
ok "签名身份: $CSC_NAME"
ok "Team ID: $APPLE_TEAM_ID"
# ── 1. 构建 ──────────────────────────────────────────────────────
step "[1/5] 构建 Electron 产物"
pnpm build
ok "构建完成"
# ── 2. 打包 + 签名 ───────────────────────────────────────────────
step "[2/5] 打包 + 签名 (electron-builder)"
# 清理旧产物,避免 spctl 把上次未公证的 dmg 认成本次产物
rm -f release/*.dmg release/*-mac.zip release/*.blockmap 2>/dev/null || true
pnpm exec electron-builder --mac --arm64
ok "打包完成"
DMG=$(ls -t release/*.dmg 2>/dev/null | head -n1 || true)
ZIP=$(ls -t release/*-mac.zip 2>/dev/null | head -n1 || true)
APP=$(ls -td release/mac*/*.app 2>/dev/null | head -n1 || true)
[ -z "$DMG" ] && { fail "未找到 .dmg 产物"; exit 1; }
ok "DMG: $DMG"
[ -n "$ZIP" ] && ok "ZIP: $ZIP"
[ -n "$APP" ] && ok "APP: $APP"
# 验证签名是否真的盖上了
if [ -n "$APP" ]; then
if ! codesign --verify --deep --strict --verbose=2 "$APP" >/dev/null 2>&1; then
fail ".app 签名验证失败,停止公证"
codesign --verify --deep --strict --verbose=2 "$APP" || true
exit 1
fi
ok ".app 签名校验通过"
fi
# ── 3. 公证 ──────────────────────────────────────────────────────
step "[3/5] 提交公证 (notarytool, 通常 1-5 分钟)"
echo " 正在提交 DMG..."
xcrun notarytool submit "$DMG" \
"${NOTARY_ARGS[@]}" \
--wait
ok "DMG 公证通过"
if [ -n "$ZIP" ]; then
echo " 正在提交 ZIP(用于自动更新)..."
xcrun notarytool submit "$ZIP" \
--keychain-profile "$NOTARY_KEYCHAIN_PROFILE" \
--wait
ok "ZIP 公证通过"
fi
# ── 4. 装订 ticket ───────────────────────────────────────────────
step "[4/5] 装订 ticket (stapler)"
xcrun stapler staple "$DMG"
ok "DMG ticket 已装订(断网也能验证)"
# zip 不能 staple,但用户解压后里面的 .app 已经被 staple 过
# ── 5. 验证 ──────────────────────────────────────────────────────
step "[5/5] 最终验证 (Gatekeeper)"
if spctl -a -vv -t install "$DMG" 2>&1 | tee /tmp/spctl.log | grep -q "accepted"; then
ok "Gatekeeper 验证通过"
grep -E "source=|origin=" /tmp/spctl.log | sed 's/^/ /'
else
fail "Gatekeeper 拒绝了产物"
cat /tmp/spctl.log
exit 1
fi
# ── 完成 ────────────────────────────────────────────────────────
SIZE=$(du -h "$DMG" | awk '{print $1}')
echo ""
echo "${G}${B}━━━━━━━━━━ 全部完成 ━━━━━━━━━━${N}"
echo " 📦 $DMG (${SIZE})"
[ -n "$ZIP" ] && echo " 📦 $ZIP"
echo ""
echo " 这份 DMG 可以直接发给任何 macOS 用户,"
echo " 双击安装,首次打开${B}不会${N}弹\"无法验证开发者\"或被 Gatekeeper 拦截。"
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# 一次性签名环境配置:检查证书 + 录入 Apple ID 凭证 + 写入 .env.signing
# 用法: pnpm sign:setup
set -euo pipefail
cd "$(dirname "$0")/.."
KEYCHAIN_PROFILE="GeoRanklySign"
ENV_FILE=".env.signing"
R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[1m'; N=$'\033[0m'
ok() { echo " ${G}${N} $1"; }
fail() { echo " ${R}${N} $1"; }
warn() { echo " ${Y}!${N} $1"; }
hr() { echo ""; echo "${B}── $1 ──${N}"; }
# ── 1. Xcode CLT ────────────────────────────────────────────────
hr "1/3 检查 Xcode Command Line Tools"
if xcode-select -p >/dev/null 2>&1; then
ok "已安装:$(xcode-select -p)"
else
fail "未安装"
echo " 请在终端运行 ${B}xcode-select --install${N},弹窗装好后再来"
exit 1
fi
# ── 2. Developer ID 证书 ────────────────────────────────────────
hr "2/3 检查 Developer ID Application 证书"
IDENTITIES=$(security find-identity -v -p codesigning | grep "Developer ID Application" || true)
if [ -z "$IDENTITIES" ]; then
fail "钥匙串里没找到 Developer ID Application 证书"
cat <<EOF
${B}申请步骤(一次性,约 10 分钟):${N}
${Y}1.${N} 浏览器登录 https://developer.apple.com/account/resources/certificates
${Y}2.${N} 点 ${B}+${N} 新建证书 → 选 ${B}Developer ID Application${N} → Continue
${Y}3.${N} 它要你上传 CSR,按下面方法生成:
打开 macOS ${B}钥匙串访问.app${N}
菜单栏 → 钥匙串访问 → 证书助理 → ${B}从证书颁发机构请求证书…${N}
邮箱填你的 Apple ID,常用名随意,选 ${B}存储到磁盘${N},保存为 .certSigningRequest
${Y}4.${N} 把这个 .certSigningRequest 上传,下载得到 ${B}developerID_application.cer${N}
${Y}5.${N} 双击 .cer,导入到 ${B}登录${N} 钥匙串
${Y}6.${N} 重新运行 ${B}pnpm sign:setup${N}
EOF
exit 1
fi
# 多张证书时取第一张
IDENTITY_LINE=$(echo "$IDENTITIES" | head -n1)
IDENTITY_NAME=$(echo "$IDENTITY_LINE" | sed -E 's/.*"(Developer ID Application: [^"]+)".*/\1/')
# CSC_NAME 必须去掉 "Developer ID Application: " 前缀,否则 electron-builder 会拒绝
CSC_NAME_VALUE=$(echo "$IDENTITY_NAME" | sed -E 's/^Developer ID Application: //')
TEAM_ID=$(echo "$IDENTITY_NAME" | sed -E 's/.*\(([A-Z0-9]+)\).*/\1/')
ok "找到证书:${B}$IDENTITY_NAME${N}"
ok "Team ID${B}$TEAM_ID${N}"
if [ "$(echo "$IDENTITIES" | wc -l | tr -d ' ')" -gt 1 ]; then
warn "钥匙串里有多张 Developer ID 证书,本脚本默认用上面这一张"
fi
# ── 3. Apple ID 凭证(公证用)─────────────────────────────────────
hr "3/3 录入 Apple ID 凭证(用于公证 notarize"
NEED_CREDENTIALS=1
if security find-generic-password -s "com.apple.gke.notary.tool.saved-creds" -a "$KEYCHAIN_PROFILE" >/dev/null 2>&1; then
ok "钥匙串里已有 profile$KEYCHAIN_PROFILE"
read -r -p " 是否重新录入?(y/N): " RECONFIG
[[ "${RECONFIG:-n}" =~ ^[Yy]$ ]] || NEED_CREDENTIALS=0
fi
if [ "$NEED_CREDENTIALS" = "1" ]; then
echo ""
echo " 需要 ${B}Apple ID 邮箱${N} + ${B}App 专用密码${N}(不是登录密码!)"
echo " 生成 App 专用密码: https://appleid.apple.com → 登录与安全 → App 专用密码 → +"
echo ""
read -r -p " Apple ID 邮箱: " APPLE_ID
read -r -s -p " App 专用密码 (xxxx-xxxx-xxxx-xxxx): " APPLE_PWD
echo ""
if ! xcrun notarytool store-credentials "$KEYCHAIN_PROFILE" \
--apple-id "$APPLE_ID" \
--team-id "$TEAM_ID" \
--password "$APPLE_PWD" >/dev/null; then
fail "凭证录入失败,常见原因:密码格式错(需带短横线),或 Team ID 不匹配"
exit 1
fi
ok "凭证已存入钥匙串(profile: ${KEYCHAIN_PROFILE}"
fi
# ── 写入 .env.signing ───────────────────────────────────────────
cat > "$ENV_FILE" <<EOF
# Auto-generated by scripts/sign-setup.sh — DO NOT COMMIT
# 让 electron-builder 自动选用钥匙串里的证书
# 注意:CSC_NAME 不能带 "Developer ID Application:" 前缀
CSC_NAME="$CSC_NAME_VALUE"
APPLE_TEAM_ID="$TEAM_ID"
NOTARY_KEYCHAIN_PROFILE="$KEYCHAIN_PROFILE"
EOF
ok "写入 ${B}apps/desktop-client/$ENV_FILE${N}"
echo ""
echo "${G}${B}━━━ 配置完成 ━━━${N}"
echo " 下一步:运行 ${B}pnpm sign:mac${N} 一键打包 + 签名 + 公证"
+4
View File
@@ -19,5 +19,9 @@
"electron",
"esbuild"
]
},
"devDependencies": {
"@emnapi/core": "1.9.1",
"@emnapi/runtime": "1.9.1"
}
}
+8 -4
View File
@@ -6,7 +6,14 @@ settings:
importers:
.: {}
.:
devDependencies:
'@emnapi/core':
specifier: 1.9.1
version: 1.9.1
'@emnapi/runtime':
specifier: 1.9.1
version: 1.9.1
apps/admin-web:
dependencies:
@@ -5157,17 +5164,14 @@ snapshots:
dependencies:
'@emnapi/wasi-threads': 1.2.0
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.9.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.0':
dependencies:
tslib: 2.8.1
optional: true
'@emotion/hash@0.9.2': {}