#!/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)
})
}