From 01ec49afd2cf69b24c513ae71b7f298633b15c7f Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 3 Jul 2026 12:01:42 +0800 Subject: [PATCH] build(desktop): enable macOS signing & notarization for release --- .github/workflows/release.yml | 5 ++ .gitignore | 2 + desktop/build/entitlements.mac.plist | 19 ++++++ desktop/build/sign-backend.js | 92 ++++++++++++++++++++++++++++ desktop/package.json | 6 ++ 5 files changed, 124 insertions(+) create mode 100644 desktop/build/entitlements.mac.plist create mode 100644 desktop/build/sign-backend.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4d515a7..4bc5fe81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,6 +108,8 @@ jobs: # is the correct state for unsigned builds. MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + # Signing identity name for the backend-signing afterPack hook. + MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} @@ -123,6 +125,9 @@ jobs: if [ -n "$MAC_CSC_LINK" ]; then export CSC_LINK="$MAC_CSC_LINK" export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" + # Identity name used by the afterPack hook to sign the embedded + # PyInstaller backend (extraResources aren't signed by default). + export CSC_NAME="$MAC_CSC_NAME" fi if [ -z "$WIN_CSC_LINK" ]; then unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD diff --git a/.gitignore b/.gitignore index 1d73ecf6..5f9ea9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,8 @@ desktop/build/* !desktop/build/cowagent-backend.spec !desktop/build/requirements-desktop.txt !desktop/build/build-backend.sh +!desktop/build/sign-backend.js +!desktop/build/entitlements.mac.plist # Icon authoring scratch dir: intermediate assets used to produce the final # icons. Only the finished icons under desktop/resources/ should be committed. diff --git a/desktop/build/entitlements.mac.plist b/desktop/build/entitlements.mac.plist new file mode 100644 index 00000000..6cb294b1 --- /dev/null +++ b/desktop/build/entitlements.mac.plist @@ -0,0 +1,19 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.inherit + + + diff --git a/desktop/build/sign-backend.js b/desktop/build/sign-backend.js new file mode 100644 index 00000000..9dd4a1fd --- /dev/null +++ b/desktop/build/sign-backend.js @@ -0,0 +1,92 @@ +/** + * electron-builder afterPack hook. + * + * The Python backend is a PyInstaller onedir bundle shipped via extraResources + * into Contents/Resources/backend/. electron-builder's deep signing does NOT + * cover extraResources, so its executable + hundreds of .so/.dylib files stay + * unsigned — which makes notarization reject the whole app. + * + * This hook runs before electron-builder signs the .app: it code-signs every + * Mach-O file under the backend dir with hardened runtime + entitlements, so + * the outer app signature and notarization succeed. + */ +const { execFileSync, execSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +exports.default = async function signBackend(context) { + const { electronPlatformName, appOutDir, packager } = context + if (electronPlatformName !== 'darwin') return + + // Signing identity comes from CSC_NAME (set in CI and, for local builds, + // your shell). No identity => unsigned build (e.g. dry runs), so skip the + // backend signing rather than fail. + const identity = process.env.CSC_NAME + if (!identity) { + console.log('[sign-backend] CSC_NAME not set — unsigned build, skipping backend signing') + return + } + + const appName = packager.appInfo.productFilename + const backendDir = path.join( + appOutDir, + `${appName}.app`, + 'Contents', + 'Resources', + 'backend' + ) + + if (!fs.existsSync(backendDir)) { + console.log(`[sign-backend] no backend dir at ${backendDir}, skipping`) + return + } + + const entitlements = path.join(__dirname, 'entitlements.mac.plist') + + // Collect every Mach-O file (executables + dylibs/so) under backend/. + // `file` output contains "Mach-O" for native binaries. + const files = [] + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name) + const stat = fs.lstatSync(full) + if (stat.isSymbolicLink()) continue + if (stat.isDirectory()) { + walk(full) + continue + } + try { + const out = execSync(`file -b "${full}"`, { encoding: 'utf8' }) + if (out.includes('Mach-O')) files.push(full) + } catch { + // ignore unreadable entries + } + } + } + walk(backendDir) + + console.log(`[sign-backend] signing ${files.length} Mach-O files under backend/`) + + // Sign inner libraries first, then the main executable last (inside-out). + files.sort((a, b) => b.length - a.length) + + for (const f of files) { + execFileSync( + 'codesign', + [ + '--force', + '--timestamp', + '--options', + 'runtime', + '--entitlements', + entitlements, + '--sign', + identity, + f, + ], + { stdio: 'inherit' } + ) + } + + console.log('[sign-backend] backend signing complete') +} diff --git a/desktop/package.json b/desktop/package.json index 849ddcc8..fd3f67de 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -44,6 +44,7 @@ "build": { "appId": "com.cowagent.desktop", "productName": "CowAgent", + "afterPack": "build/sign-backend.js", "directories": { "output": "release" }, @@ -67,6 +68,11 @@ "mac": { "category": "public.app-category.productivity", "icon": "resources/icon.icns", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.plist", + "notarize": true, "target": [ { "target": "dmg",