diff --git a/electron-builder.yml b/electron-builder.yml index ea92e17..092d9c0 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -40,6 +40,7 @@ extraResources: to: . filter: ["icon.*"] afterPack: scripts/after-pack.js +afterSign: scripts/after-sign.js asarUnpack: - "**/*.node" - "**/better-sqlite3/**" diff --git a/scripts/after-pack.js b/scripts/after-pack.js index 130e07f..26a0924 100644 --- a/scripts/after-pack.js +++ b/scripts/after-pack.js @@ -119,32 +119,8 @@ module.exports = async function afterPack(context) { } } - // Step 4: Ad-hoc code sign on macOS for auto-update compatibility. - // electron-updater's ShipIt process validates code signatures when applying - // updates. Without at least an ad-hoc signature, the update fails with: - // "Code signature did not pass validation: 代码未能满足指定的代码要求" - // Ad-hoc signing (codesign -s -) creates a valid local signature without - // requiring an Apple Developer certificate. - // This runs AFTER all file modifications (better-sqlite3 replacement above) - // so the signature covers the final app state. If a real certificate is - // available, electron-builder's signing step will override this. - if (platform === 'mac') { - const appName = context.packager.appInfo.productFilename; - const appPath = path.join(appOutDir, `${appName}.app`); - - if (fs.existsSync(appPath)) { - console.log(`[afterPack] Ad-hoc signing ${appPath} for auto-update compatibility...`); - try { - execSync(`codesign --force --deep -s - "${appPath}"`, { - stdio: 'inherit', - timeout: 120000, - }); - console.log('[afterPack] Ad-hoc signing completed successfully'); - } catch (err) { - console.warn('[afterPack] Ad-hoc signing failed (non-fatal):', err.message); - } - } else { - console.warn(`[afterPack] macOS app not found at ${appPath}, skipping ad-hoc signing`); - } - } + // Note: Ad-hoc code signing moved to scripts/after-sign.js (afterSign hook). + // afterSign runs after electron-builder's own signing step (which is a no-op + // with CSC_IDENTITY_AUTO_DISCOVERY=false), ensuring the signature is the last + // modification before DMG/ZIP creation. }; diff --git a/scripts/after-sign.js b/scripts/after-sign.js new file mode 100644 index 0000000..3e010ee --- /dev/null +++ b/scripts/after-sign.js @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * electron-builder afterSign hook — ad-hoc code signing for macOS. + * + * electron-updater's ShipIt process validates code signatures when applying + * updates. Without a valid signature the update fails with: + * "Code signature did not pass validation: 代码未能满足指定的代码要求" + * + * The previous approach used `codesign --force --deep -s -`, but --deep is + * unreliable: it does not guarantee correct signing order and may miss nested + * components, causing kSecCSStrictValidate failures. + * + * This script signs each component individually from the inside out: + * 1. All native binaries (.node, .dylib, .so) + * 2. All Frameworks (*.framework) + * 3. All Helper apps (*.app inside Frameworks/) + * 4. The main .app bundle + * + * Runs in the afterSign hook so it executes AFTER electron-builder's own + * signing step (which is a no-op with CSC_IDENTITY_AUTO_DISCOVERY=false) + * and right before DMG/ZIP artifact creation. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +/** + * Ad-hoc sign a single path. Failures are logged but non-fatal to avoid + * breaking builds on edge-case binaries (e.g. debug symbols). + */ +function codesign(targetPath) { + try { + execSync(`codesign --force --sign - "${targetPath}"`, { + stdio: 'pipe', + timeout: 30000, + }); + } catch (err) { + console.warn(`[afterSign] Failed to sign ${targetPath}: ${err.message}`); + } +} + +/** + * Recursively collect all files matching the given extensions. + */ +function collectFiles(dir, extensions) { + const results = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + // Don't descend into .app or .framework bundles — they are signed as a unit + if (entry.name.endsWith('.app') || entry.name.endsWith('.framework')) { + continue; + } + results.push(...collectFiles(fullPath, extensions)); + } else if (entry.isFile()) { + const ext = path.extname(entry.name); + if (extensions.includes(ext)) { + results.push(fullPath); + } + } + } + return results; +} + +/** + * Collect bundle directories (.app, .framework) at a given depth. + */ +function collectBundles(dir, extension) { + const results = []; + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.endsWith(extension)) { + results.push(path.join(dir, entry.name)); + } + } + return results; +} + +module.exports = async function afterSign(context) { + const platform = context.packager.platform.name; + if (platform !== 'mac') return; + + const appOutDir = context.appOutDir; + const appName = context.packager.appInfo.productFilename; + const appPath = path.join(appOutDir, `${appName}.app`); + + if (!fs.existsSync(appPath)) { + console.warn(`[afterSign] macOS app not found at ${appPath}, skipping ad-hoc signing`); + return; + } + + console.log(`[afterSign] Ad-hoc signing ${appPath} (individual component signing)...`); + + const contentsPath = path.join(appPath, 'Contents'); + const frameworksPath = path.join(contentsPath, 'Frameworks'); + let signed = 0; + + // ── Step 1: Sign all native binaries (.node, .dylib, .so) ───────────── + // These are the innermost signable items. Must be signed before their + // enclosing bundles. + const nativeBinaries = collectFiles(contentsPath, ['.node', '.dylib', '.so']); + for (const bin of nativeBinaries) { + codesign(bin); + signed++; + } + if (nativeBinaries.length > 0) { + console.log(`[afterSign] Signed ${nativeBinaries.length} native binaries (.node/.dylib/.so)`); + } + + // ── Step 2: Sign all Frameworks ─────────────────────────────────────── + // Frameworks contain nested code that was already signed in step 1 (if any + // .dylib/.so lived outside the framework) or that --sign covers here. + const frameworks = collectBundles(frameworksPath, '.framework'); + for (const fw of frameworks) { + codesign(fw); + signed++; + } + if (frameworks.length > 0) { + console.log(`[afterSign] Signed ${frameworks.length} frameworks`); + } + + // ── Step 3: Sign all Helper apps ────────────────────────────────────── + // Electron ships multiple helper apps (GPU, Plugin, Renderer, etc.) + const helperApps = collectBundles(frameworksPath, '.app'); + for (const helper of helperApps) { + codesign(helper); + signed++; + } + if (helperApps.length > 0) { + console.log(`[afterSign] Signed ${helperApps.length} helper apps`); + } + + // ── Step 4: Sign the main app bundle ────────────────────────────────── + codesign(appPath); + signed++; + + console.log(`[afterSign] Ad-hoc signing complete — ${signed} components signed`); + + // ── Verify ──────────────────────────────────────────────────────────── + try { + execSync(`codesign --verify --strict "${appPath}"`, { + stdio: 'pipe', + timeout: 30000, + }); + console.log('[afterSign] Signature verification passed (--strict)'); + } catch (err) { + console.error('[afterSign] WARNING: Signature verification FAILED:', err.stderr?.toString() || err.message); + } +};