From 3b33114a400a3521fd6a82245a872de7927a07a8 Mon Sep 17 00:00:00 2001 From: zhayujie Date: Fri, 3 Jul 2026 14:59:31 +0800 Subject: [PATCH] fix(desktop): sign embedded backend via mac.binaries for notarization --- .github/workflows/release.yml | 9 +-- .gitignore | 1 - desktop/build/sign-backend.js | 119 ---------------------------------- desktop/electron-builder.js | 77 ++++++++++++++++++++++ desktop/package.json | 1 - 5 files changed, 80 insertions(+), 127 deletions(-) delete mode 100644 desktop/build/sign-backend.js create mode 100644 desktop/electron-builder.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bc5fe81..879f00d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,8 +108,6 @@ 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 }} @@ -125,9 +123,6 @@ 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 @@ -137,7 +132,9 @@ jobs: # (read-only) feed served from R2/D1, which it can't upload to. We mirror # installers to R2 and register them in D1 ourselves (publish-r2 job). # `--publish never` still emits the latest*.yml files we parse for sha512. - npx electron-builder ${{ matrix.eb_flags }} --publish never + # Use the dynamic config (electron-builder.js) so mac.binaries is + # populated with the embedded backend's Mach-O files for signing. + npx electron-builder ${{ matrix.eb_flags }} --config electron-builder.js --publish never - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 5f9ea9e7..287a69ca 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,6 @@ 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 diff --git a/desktop/build/sign-backend.js b/desktop/build/sign-backend.js deleted file mode 100644 index b187987e..00000000 --- a/desktop/build/sign-backend.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 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 cscName = process.env.CSC_NAME - if (!cscName) { - console.log('[sign-backend] CSC_NAME not set — unsigned build, skipping backend signing') - return - } - - // Resolve CSC_NAME to a certificate SHA-1 hash. In CI electron-builder - // imports the cert into a temporary keychain (not login.keychain), so - // matching by name is unreliable — `codesign` may not find it. The SHA-1 is - // unambiguous and works across the whole keychain search list. - // - // `security find-identity -v -p codesigning` searches every keychain on the - // user search list (including the temp one electron-builder registers) and - // prints lines like: 1) "Developer ID Application: ... (TEAMID)" - let identity = cscName - try { - const idList = execFileSync( - 'security', - ['find-identity', '-v', '-p', 'codesigning'], - { encoding: 'utf8' } - ) - for (const line of idList.split('\n')) { - const m = line.match(/\)\s+([0-9A-F]{40})\s+"(.+)"/i) - if (m && m[2] === cscName) { - identity = m[1] - break - } - } - } catch (e) { - console.warn('[sign-backend] could not resolve identity hash, falling back to name') - } - console.log(`[sign-backend] using signing identity: ${identity}`) - - 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/electron-builder.js b/desktop/electron-builder.js new file mode 100644 index 00000000..7f63d696 --- /dev/null +++ b/desktop/electron-builder.js @@ -0,0 +1,77 @@ +/** + * Dynamic electron-builder config. + * + * We keep the base config in package.json's "build" field and extend it here + * only to populate `mac.binaries` — the list of extra Mach-O files that must + * be signed with hardened runtime + entitlements. + * + * Why this is needed: + * The Python backend is a PyInstaller onedir bundle shipped via extraResources + * into Contents/Resources/backend/. electron-builder only hands the top-level + * `.app` to codesign, which does NOT deep-sign the ~180 nested .so/.dylib + * files under Resources/. Left unsigned (or without hardened runtime), Apple + * notarization rejects the whole app. + * + * `mac.binaries` is the officially supported way to sign extra binaries: they + * are signed inside electron-builder's own signing pass, AFTER it has created + * the temporary keychain and imported the Developer ID cert (from CSC_LINK). + * A previous afterPack approach failed because afterPack runs BEFORE that + * keychain exists, so `codesign` couldn't find the identity. + * + * Paths are resolved relative to the `.app` at signing time. We enumerate the + * pre-build backend source dir (build/dist/cowagent-backend, produced by + * PyInstaller before packaging) — its layout mirrors the in-app copy — and map + * each Mach-O to its in-app relative path. + */ +const { execFileSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const config = require('./package.json').build + +// PyInstaller output that gets copied into the app at +// Contents/Resources/backend/cowagent-backend (see extraResources). +const backendSrc = path.join(__dirname, 'build', 'dist', 'cowagent-backend') +const inAppPrefix = path.join('Contents', 'Resources', 'backend', 'cowagent-backend') + +function isMachO(file) { + try { + return execFileSync('file', ['-b', file], { encoding: 'utf8' }).includes('Mach-O') + } catch { + return false + } +} + +function collectBackendBinaries() { + if (!fs.existsSync(backendSrc)) { + console.warn(`[electron-builder.js] backend not found at ${backendSrc}; mac.binaries left empty`) + return [] + } + const rels = [] + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name) + const st = fs.lstatSync(full) + if (st.isSymbolicLink()) continue + if (st.isDirectory()) { + walk(full) + continue + } + if (isMachO(full)) { + // Map source path -> in-app relative path (resolved against the .app). + const rel = path.relative(backendSrc, full) + rels.push(path.join(inAppPrefix, rel)) + } + } + } + walk(backendSrc) + return rels +} + +if (process.platform === 'darwin') { + const binaries = collectBackendBinaries() + console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`) + config.mac = { ...config.mac, binaries } +} + +module.exports = config diff --git a/desktop/package.json b/desktop/package.json index fd3f67de..8ce21cad 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -44,7 +44,6 @@ "build": { "appId": "com.cowagent.desktop", "productName": "CowAgent", - "afterPack": "build/sign-backend.js", "directories": { "output": "release" },