#!/usr/bin/env node /** * Codesign peardock macOS artifacts so Gatekeeper does not report * "is damaged and can't be opened. You should move it to the Trash." * * Targets: * - Electron .app bundles (deep sign / nested helpers) * - Standalone peardock-server Mach-O binaries (Bare) * * That message almost always means an *invalid* signature (linker ad-hoc * stamp after packager/embed rewrites the binary, or unsigned download). * * Usage: * node scripts/sign-macos-app.cjs path/to/peardock.app * node scripts/sign-macos-app.cjs path/to/out/peardock-darwin-arm64 * node scripts/sign-macos-app.cjs path/to/out/peardock-server-darwin-arm64 * node scripts/sign-macos-app.cjs path/to/peardock-server * * Identity (first match wins): * MAC_CODESIGN_IDENTITY / CSC_NAME — "Developer ID Application: …" or team identity * otherwise ad-hoc (`-`) which is enough to make the artifact *valid* (not "damaged") * * Tools: * macOS: /usr/bin/codesign (required for production identities) * Linux CI: rcodesign (apple-codesign) for self-signed seal when present */ 'use strict' const fs = require('fs') const path = require('path') const { spawnSync, execFileSync } = require('child_process') const ENTITLEMENTS = path.join(__dirname, 'entitlements.mac.plist') const SERVER_BIN_NAMES = new Set(['peardock-server', 'peardock-server.exe']) function log(...a) { console.log('[sign-macos]', ...a) } function findApps(input) { const st = fs.statSync(input) if (st.isFile() && input.endsWith('.app')) return [input] if (st.isDirectory() && input.endsWith('.app')) return [input] if (st.isDirectory()) { return fs .readdirSync(input) .filter((n) => n.endsWith('.app')) .map((n) => path.join(input, n)) } return [] } /** * Find standalone peardock-server Mach-O binaries under a path. * @param {string} input * @returns {string[]} */ function findServerBinaries(input) { if (!fs.existsSync(input)) return [] const st = fs.statSync(input) if (st.isFile()) { const base = path.basename(input) if (SERVER_BIN_NAMES.has(base) || base === 'peardock-server') return [input] // Allow explicit path to any file named like our server binary if (!base.endsWith('.app') && !base.endsWith('.dmg') && !base.endsWith('.pkg')) { // Heuristic: treat as binary if caller passed a file path that looks executable if (base.includes('peardock-server')) return [input] } return [] } if (!st.isDirectory()) return [] const found = [] const stack = [input] while (stack.length) { const dir = stack.pop() let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue } for (const ent of entries) { const p = path.join(dir, ent.name) if (ent.isDirectory()) { if (ent.name === 'node_modules' || ent.name.endsWith('.app') || ent.name.startsWith('.')) { continue } stack.push(p) } else if (ent.isFile() && SERVER_BIN_NAMES.has(ent.name)) { found.push(p) } } } return found } function which(cmd) { try { const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { encoding: 'utf8', }) if (r.status === 0) return r.stdout.trim().split(/\r?\n/)[0] } catch { // ignore } return null } function identity() { const id = process.env.MAC_CODESIGN_IDENTITY || process.env.CSC_NAME || '' if (id && id !== '-' && id.toLowerCase() !== 'null') return id return '-' // ad-hoc } function ensureEntitlements() { if (fs.existsSync(ENTITLEMENTS)) return ENTITLEMENTS // Minimal Electron-friendly entitlements (JIT, unsigned memory for V8) const xml = ` com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.network.client com.apple.security.network.server ` fs.writeFileSync(ENTITLEMENTS, xml) return ENTITLEMENTS } /** * Walk Mach-O / nested code to sign inside-out (required for valid deep signatures). */ function listSignTargets(appPath) { const targets = [] const walk = (dir) => { let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return } for (const ent of entries) { const p = path.join(dir, ent.name) if (ent.isDirectory()) { if (ent.name === 'node_modules' || ent.name.startsWith('.')) { // still need natives under node_modules if (ent.name === 'node_modules') walk(p) else if (!ent.name.startsWith('.')) walk(p) continue } if (ent.name.endsWith('.app') || ent.name.endsWith('.framework')) { // nested apps/frameworks signed as units later — still recurse for helpers walk(p) targets.push(p) continue } walk(p) } else if (ent.isFile() || ent.isSymbolicLink()) { const base = ent.name if ( base.endsWith('.dylib') || base.endsWith('.so') || base.endsWith('.node') || base.endsWith('.bare') || base === 'peardock-client' || base.startsWith('peardock Helper') || base === 'Electron Framework' || base === 'Squirrel' || base === 'ReactiveObjC' || base === 'Mantle' || base === 'chrome_crashpad_handler' ) { targets.push(p) } } } } walk(appPath) // De-dupe, nested bundles last (app itself last) const uniq = [...new Set(targets)] uniq.sort((a, b) => { // deeper paths first const da = a.split(path.sep).length const db = b.split(path.sep).length if (da !== db) return db - da return b.length - a.length }) // Ensure root .app is last const rootIdx = uniq.indexOf(appPath) if (rootIdx >= 0) uniq.splice(rootIdx, 1) uniq.push(appPath) return uniq } function codesignDarwin(appPath, id) { const entitlements = ensureEntitlements() const hardened = id !== '-' // Clear broken signatures / quarantine that confuse Gatekeeper try { execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' }) } catch { // ignore } // Deep re-sign seals Info.plist + resources (fixes "damaged / Trash"). // Nested-first pass is only needed when --deep alone fails verification. log(`deep codesign identity=${id === '-' ? 'ad-hoc' : id}`) const rootArgs = [ '--force', '--deep', '--sign', id, '--entitlements', entitlements, ] if (hardened) rootArgs.push('--options', 'runtime', '--timestamp') else rootArgs.push('--timestamp=none') rootArgs.push(appPath) let root = spawnSync('codesign', rootArgs, { encoding: 'utf8' }) if (root.status !== 0) { throw new Error(`codesign failed for app:\n${root.stderr || root.stdout}`) } let v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], { encoding: 'utf8', }) if (v.status !== 0) { // Inside-out sign nested Mach-O then re-seal the app log('strict verify failed — signing nested code then app…') const targets = listSignTargets(appPath) for (const target of targets) { if (target === appPath) continue const args = ['--force', '--sign', id] if (hardened) args.push('--options', 'runtime', '--timestamp') else args.push('--timestamp=none') args.push(target) spawnSync('codesign', args, { encoding: 'utf8' }) } root = spawnSync('codesign', rootArgs, { encoding: 'utf8' }) if (root.status !== 0) { throw new Error(`codesign failed for app (retry):\n${root.stderr || root.stdout}`) } v = spawnSync('codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], { encoding: 'utf8', }) if (v.status !== 0) { throw new Error(`codesign verify failed:\n${v.stderr || v.stdout}`) } } log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | ')) } /** * rcodesign 0.29+ does not accept `--ad-hoc`. * "Ad-hoc" equivalent for Linux CI: self-signed cert via p12/pem, then `rcodesign sign`. * Self-signed is enough for a *valid* sealed signature (avoids Gatekeeper "damaged"); * it is NOT Developer ID / notarization. */ function ensureRcodesignSelfSignedP12(bin) { const certDir = process.env.PEARDOCK_RCODESIGN_CERT_DIR || path.join(__dirname, '..', 'tools', 'rcodesign', 'ci-cert') const p12Path = path.join(certDir, 'peardock-ci.p12') const password = process.env.PEARDOCK_RCODESIGN_P12_PASSWORD || 'peardock-ci-sign' fs.mkdirSync(certDir, { recursive: true }) if (fs.existsSync(p12Path) && fs.statSync(p12Path).size > 100) { return { p12Path, password } } log('generating self-signed signing cert for rcodesign…') // Flags match apple-codesign 0.29 generate-self-signed-certificate CLI const gen = spawnSync( bin, [ 'generate-self-signed-certificate', '--p12-file', p12Path, '--p12-password', password, '--person-name', 'peardock-ci', '--country-name', 'US', '--validity-days', '3650', '--team-id', 'NONE', '--profile', 'developer-id-application', ], { encoding: 'utf8', stdio: 'pipe' } ) if (gen.status !== 0 || !fs.existsSync(p12Path)) { // Older/newer flag variants const gen2 = spawnSync( bin, [ 'generate-self-signed-certificate', '--p12-file', p12Path, '--p12-password', password, '--person-name', 'peardock-ci', '--country-name', 'US', '--validity-days', '3650', ], { encoding: 'utf8', stdio: 'pipe' } ) if (gen2.status !== 0 || !fs.existsSync(p12Path)) { throw new Error( `rcodesign generate-self-signed-certificate failed:\n` + `${gen.stderr || gen.stdout}\n${gen2.stderr || gen2.stdout}` ) } } log('wrote', p12Path) return { p12Path, password } } /** * Sign a path (`.app` or standalone Mach-O) with rcodesign on Linux CI. * Uses a self-signed p12 (not Developer ID) so the signature is *valid/sealed*. */ function codesignRcodesign(targetPath, id) { const bin = which('rcodesign') if (!bin) { throw new Error( 'rcodesign not found (needed to sign macOS artifacts on Linux). ' + 'CI installs tools/rcodesign//rcodesign, or build/sign on macOS.' ) } if (id !== '-') { log( 'WARN: Linux rcodesign path uses self-signed cert only; ' + 'Developer ID needs codesign on macOS + Apple certs' ) } const entitlements = ensureEntitlements() const { p12Path, password } = ensureRcodesignSelfSignedP12(bin) // Prefer full Electron/Bare-friendly sign: p12 + runtime + entitlements const attempts = [ [ 'sign', '--p12-file', p12Path, '--p12-password', password, '--code-signature-flags', 'runtime', '--entitlements-xml-file', entitlements, targetPath, ], [ 'sign', '--p12-file', p12Path, '--p12-password', password, '--code-signature-flags', 'runtime', targetPath, ], ['sign', '--p12-file', p12Path, '--p12-password', password, targetPath], ] let lastErr = '' for (const args of attempts) { // Never log the password log( 'rcodesign', args.map((a) => (a === password ? '***' : a)).join(' ') ) const r = spawnSync(bin, args, { encoding: 'utf8', stdio: 'pipe' }) if (r.status === 0) { log('rcodesign sign complete (self-signed / sealed)') return } lastErr += `${r.stderr || r.stdout || ''}\n` } throw new Error(`rcodesign failed:\n${lastErr}`) } /** * Sign a standalone Mach-O binary (peardock-server) with Apple codesign. */ function codesignDarwinBinary(binPath, id) { const entitlements = ensureEntitlements() const hardened = id !== '-' try { execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' }) } catch { // ignore } log(`codesign binary identity=${id === '-' ? 'ad-hoc' : id}`) const args = ['--force', '--sign', id, '--entitlements', entitlements] if (hardened) args.push('--options', 'runtime', '--timestamp') else args.push('--timestamp=none') // Stable identifier (bare-runtime defaults to peardock-server-) args.push('--identifier', 'com.peardock.server', binPath) const r = spawnSync('codesign', args, { encoding: 'utf8' }) if (r.status !== 0) { throw new Error(`codesign failed for binary:\n${r.stderr || r.stdout}`) } const v = spawnSync('codesign', ['--verify', '--strict', '--verbose=2', binPath], { encoding: 'utf8', }) if (v.status !== 0) { throw new Error(`codesign verify failed for binary:\n${v.stderr || v.stdout}`) } log('verify ok:', (v.stderr || v.stdout || '').trim().split('\n').slice(0, 3).join(' | ')) } /** * Codesign a standalone peardock-server binary (Bare). * Call after bare-standalone embeds the runtime for each darwin host. * * @param {string} binPath * @returns {Promise} */ function signBinary(binPath) { if (!fs.existsSync(binPath)) throw new Error(`Binary not found: ${binPath}`) const id = identity() log('binary:', binPath) log('identity:', id === '-' ? 'ad-hoc (-)' : id) if (process.platform === 'darwin') { codesignDarwinBinary(binPath, id) try { execFileSync('xattr', ['-cr', binPath], { stdio: 'pipe' }) } catch { // ignore } return Promise.resolve() } // Linux CI cross-compile of darwin server binaries codesignRcodesign(binPath, id) return Promise.resolve() } function signApp(appPath) { if (!fs.existsSync(appPath)) throw new Error(`App not found: ${appPath}`) const id = identity() log('app:', appPath) log('identity:', id === '-' ? 'ad-hoc (-)' : id) if (process.platform === 'darwin') { // Ad-hoc: always use codesign -s - (osx-sign rejects identity "-" / "no identity") // Developer ID: prefer @electron/osx-sign for Electron's nested helper layout if (id === '-') { codesignDarwin(appPath, id) try { execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' }) } catch { // ignore } return Promise.resolve() } try { const { signAsync } = require('@electron/osx-sign') return signAsync({ app: appPath, identity: id, platform: 'darwin', hardenedRuntime: true, gatekeeperAssess: false, optionsForFile: () => ({ entitlements: ensureEntitlements(), hardenedRuntime: true, }), }).then(() => { try { execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' }) } catch { // ignore } const v = spawnSync( 'codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath], { encoding: 'utf8' } ) if (v.status !== 0) { log('osx-sign verify soft-fail, falling back to codesign deep…') codesignDarwin(appPath, id) } else { log('osx-sign + verify ok') } }) } catch (err) { log('osx-sign unavailable or failed, using codesign:', err.message || err) codesignDarwin(appPath, id) try { execFileSync('xattr', ['-cr', appPath], { stdio: 'pipe' }) } catch { // ignore } return Promise.resolve() } } // Linux (CI cross-package of darwin) codesignRcodesign(appPath, id) return Promise.resolve() } async function main() { const input = process.argv[2] if (!input) { console.error( 'Usage: node scripts/sign-macos-app.cjs ' ) process.exit(2) } const resolved = path.resolve(input) if (!fs.existsSync(resolved)) { console.error('Path not found:', resolved) process.exit(1) } const apps = findApps(resolved) const bins = findServerBinaries(resolved) if (!apps.length && !bins.length) { console.error('No .app or peardock-server binary found at', resolved) process.exit(1) } for (const app of apps) { await signApp(app) } for (const bin of bins) { // Avoid double-signing if someone nested a binary inside an .app (unlikely) if (apps.some((a) => bin === a || bin.startsWith(a + path.sep))) continue await signBinary(bin) } log('done') } module.exports = { signApp, signBinary, findApps, findServerBinaries, identity } if (require.main === module) { main().catch((err) => { console.error('[sign-macos] FAILED:', err.message || err) process.exit(1) }) }