From cd847cdc181a5b4512a2f47dd9e506836e74e94c Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 8 Aug 2026 19:08:37 -0500 Subject: [PATCH 1/5] Fix sharp packaging for macOS x64 builds pnpm materialises only the host architecture's optional dependencies, but electron-builder packages both mac architectures from one node_modules. The arm64 runner therefore produced an x64 artifact with no @img/sharp-darwin-x64, which threw "Could not load the sharp module" on first launch. Separately, sharp was never unpacked from the asar. Its native binary finds libvips in a sibling package through an @rpath, and dlopen cannot reach a dylib inside an asar, so the arm64 build was broken by the same packaging gap. Add a release gate that inspects each packaged app and loads sharp from it, plus a verify workflow that runs the packaging on real macOS and Windows runners. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 7 + .github/workflows/verify-native-deps.yml | 56 ++++++++ package.json | 14 +- pnpm-workspace.yaml | 12 ++ test/verify-packaged-sharp.mjs | 157 +++++++++++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/verify-native-deps.yml create mode 100644 test/verify-packaged-sharp.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3f38931..8628bf75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,13 @@ jobs: run: ${{ matrix.build_command }} shell: bash + # The mac job packages x64 and arm64 from one node_modules, and pnpm only materialises + # the runner's own architecture, so a missing native binary is invisible until a user + # launches the artifact. Fail the release here instead. + - name: Verify packaged native dependencies + run: node test/verify-packaged-sharp.mjs release + shell: bash + - name: Upload build artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/verify-native-deps.yml b/.github/workflows/verify-native-deps.yml new file mode 100644 index 00000000..03b45ace --- /dev/null +++ b/.github/workflows/verify-native-deps.yml @@ -0,0 +1,56 @@ +name: Verify native dependencies + +# Packaging bugs in sharp's per-architecture binaries do not surface until a user launches +# the app, and release.yml only builds on demand. This runs the same packaging on a real +# macOS runner and asserts both mac architectures carry a working sharp - no version bump, +# no tag, no publish. +on: + push: + branches: + - 'verify/**' + workflow_dispatch: + +jobs: + verify: + name: Package and verify on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + pack_command: pnpm exec electron-builder --mac --dir --arm64 --x64 --publish never + - os: windows-latest + pack_command: pnpm exec electron-builder --win --dir --x64 --publish never + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Report host and installed sharp binaries + run: | + echo "runner arch: $(node -p 'process.arch') on $(node -p 'process.platform')" + ls node_modules/.pnpm | grep '^@img' || echo 'no @img packages installed' + shell: bash + + - name: Build renderer and main process + run: pnpm run electron:build-main && pnpm run build + + - name: Package application + run: ${{ matrix.pack_command }} + shell: bash + + - name: Verify packaged native dependencies + run: node test/verify-packaged-sharp.mjs release + shell: bash diff --git a/package.json b/package.json index d168fc18..439388af 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,10 @@ "dist/**/*", "package.json" ], + "asarUnpack": [ + "**/node_modules/sharp/**/*", + "**/node_modules/@img/**/*" + ], "directories": { "buildResources": "build", "output": "release" @@ -109,7 +113,11 @@ ] } ], - "icon": "build/icon.ico" + "icon": "build/icon.ico", + "files": [ + "!**/node_modules/@img/*darwin*/**", + "!**/node_modules/@img/*linux*/**" + ] }, "mac": { "target": [ @@ -129,6 +137,10 @@ } ], "icon": "build/icon.png", + "files": [ + "!**/node_modules/@img/*win32*/**", + "!**/node_modules/@img/*linux*/**" + ], "category": "public.app-category.productivity", "minimumSystemVersion": "14.4.0", "identity": "-", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d99a658b..df023513 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,15 @@ allowBuilds: electron-winstaller: true esbuild: true sharp: true + +# The macOS runner is arm64 but electron-builder packages both mac arches from one +# node_modules, so sharp's per-arch optional deps must all be materialised at install +# time or the x64 artifact ships without its native binary. +supportedArchitectures: + os: + - win32 + - darwin + - current + cpu: + - x64 + - arm64 diff --git a/test/verify-packaged-sharp.mjs b/test/verify-packaged-sharp.mjs new file mode 100644 index 00000000..c4e5de11 --- /dev/null +++ b/test/verify-packaged-sharp.mjs @@ -0,0 +1,157 @@ +/** + * Release gate: every packaged app must carry sharp's native binary for its own architecture. + * + * pnpm materialises only the host architecture's optional dependencies, so `electron-builder + * --mac` on an arm64 runner produces an x64 artifact with no darwin-x64 binary. It builds + * clean, uploads clean, and throws "Could not load the sharp module" on first launch. The + * dylib half fails the same way: libvips ships in a sibling package that sharp's .node finds + * through an @rpath, so both must be unpacked from the asar or dlopen cannot reach it. + * + * Run against the electron-builder output directory: node test/verify-packaged-sharp.mjs release + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const MACHO_64 = 0xfeedfacf; +const MACHO_CPU = { 0x01000007: 'x64', 0x0100000c: 'arm64' }; +const PE_MACHINE = { 0x8664: 'x64', 0xaa64: 'arm64' }; + +const failures = []; +const check = (name, ok) => { + console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}`); + if (!ok) failures.push(name); +}; + +function readArch(binary) { + const fd = fs.openSync(binary, 'r'); + const head = Buffer.alloc(64); + fs.readSync(fd, head, 0, 64, 0); + try { + if (head.readUInt32LE(0) === MACHO_64) return MACHO_CPU[head.readUInt32LE(4)] ?? null; + if (head.toString('ascii', 0, 2) === 'MZ') { + const peOffset = head.readUInt32LE(0x3c); + const coff = Buffer.alloc(6); + fs.readSync(fd, coff, 0, 6, peOffset); + return PE_MACHINE[coff.readUInt16LE(4)] ?? null; + } + return null; + } finally { + fs.closeSync(fd); + } +} + +/** Every packaged app under `dir`, as { label, executable, resources }. */ +function findApps(dir) { + const apps = []; + const walk = (current, depth) => { + if (depth > 3) return; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const full = path.join(current, entry.name); + if (entry.name.endsWith('.app')) { + const macos = path.join(full, 'Contents', 'MacOS'); + const [binary] = fs.existsSync(macos) ? fs.readdirSync(macos) : []; + if (binary) { + apps.push({ + label: path.relative(dir, full), + executable: path.join(macos, binary), + resources: path.join(full, 'Contents', 'Resources'), + }); + } + continue; + } + if (entry.name.endsWith('-unpacked')) { + const [exe] = fs.readdirSync(full).filter((f) => f.endsWith('.exe')); + if (exe) { + apps.push({ + label: path.relative(dir, full), + executable: path.join(full, exe), + resources: path.join(full, 'resources'), + }); + } + continue; + } + walk(full, depth + 1); + } + }; + walk(dir, 0); + return apps; +} + +function verify(app) { + console.log(`\n# ${app.label}`); + + const arch = readArch(app.executable); + const platform = app.executable.endsWith('.exe') ? 'win32' : 'darwin'; + check(`identifies the app as ${platform}-${arch}`, arch !== null); + if (arch === null) return; + + const img = path.join(app.resources, 'app.asar.unpacked', 'node_modules', '@img'); + const nodeBinary = path.join(img, `sharp-${platform}-${arch}`, 'lib', `sharp-${platform}-${arch}.node`); + + const unpacked = fs.existsSync(nodeBinary); + check(`unpacks sharp-${platform}-${arch}.node outside the asar`, unpacked); + if (unpacked) { + check('builds the binary for this architecture', readArch(nodeBinary) === arch); + } + + // On macOS libvips lives in a sibling package reached via @loader_path/../../ from the + // .node, so resolve it exactly the way dyld will rather than just asserting it exists. + if (platform === 'darwin') { + const rpath = path.join(path.dirname(nodeBinary), '..', '..', `sharp-libvips-darwin-${arch}`, 'lib'); + const dylibs = fs.existsSync(rpath) ? fs.readdirSync(rpath).filter((f) => f.endsWith('.dylib')) : []; + check(`resolves libvips through the .node's @rpath (${dylibs.join(', ') || 'nothing found'})`, dylibs.length > 0); + } + + // The real proof: load sharp out of the packaged asar with the packaged Electron and run + // the operation the app actually performs. A foreign-arch artifact is still worth trying - + // Rosetta runs the x64 build on the arm64 mac runner, which is the exact configuration that + // shipped broken - so only give up when the kernel refuses the binary outright. + try { + const output = execFileSync( + app.executable, + [ + '-e', + `const sharp = require(process.argv[1]); + sharp({ create: { width: 8, height: 8, channels: 3, background: '#f00' } }) + .grayscale().png().toBuffer() + .then((b) => console.log('OK ' + sharp.versions.sharp + ' ' + b.length)) + .catch((e) => { console.error(e); process.exit(1); });`, + path.join(app.resources, 'app.asar', 'node_modules', 'sharp'), + ], + { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, encoding: 'utf8', stdio: 'pipe' } + ); + check(`loads and runs sharp from the packaged app (${output.trim()})`, output.includes('OK ')); + } catch (error) { + const detail = (error.stderr || error.message).toString().trim(); + if (arch !== process.arch && /Bad CPU type|Exec format error|ENOEXEC/i.test(detail)) { + console.log(` skip runtime load test (host is ${process.arch}, artifact is ${arch}, no translation)`); + return; + } + check(`loads and runs sharp from the packaged app`, false); + console.error(` ${detail.split('\n')[0]}`); + } +} + +const releaseDir = process.argv[2] ?? 'release'; +if (!fs.existsSync(releaseDir)) { + console.error(`No such directory: ${releaseDir}`); + process.exit(1); +} + +const apps = findApps(releaseDir); +if (apps.length === 0) { + console.error(`No packaged app found under ${releaseDir}`); + process.exit(1); +} + +for (const app of apps) verify(app); + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed:`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} + +console.log('\nAll packaged native dependency checks passed.'); From 960e29b792db826fa2419c64ecbdb8b7ccada135 Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 8 Aug 2026 19:14:48 -0500 Subject: [PATCH 2/5] Print the full stderr when the packaged sharp load fails The first line of a Node module error is just the loader frame, which says nothing about which module was missing. Co-Authored-By: Claude Opus 5 --- test/verify-packaged-sharp.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/verify-packaged-sharp.mjs b/test/verify-packaged-sharp.mjs index c4e5de11..4298c8e5 100644 --- a/test/verify-packaged-sharp.mjs +++ b/test/verify-packaged-sharp.mjs @@ -130,7 +130,7 @@ function verify(app) { return; } check(`loads and runs sharp from the packaged app`, false); - console.error(` ${detail.split('\n')[0]}`); + for (const line of detail.split('\n')) console.error(` ${line}`); } } From 6d8ba3d3d76f04871cc154a5868e3185904b9b9c Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 8 Aug 2026 19:19:04 -0500 Subject: [PATCH 3/5] Resolve the release directory before handing paths to require CI invokes the gate with a relative path, and require() reads a bare relative specifier as a package name, so the runtime check failed on a correctly packaged app. Co-Authored-By: Claude Opus 5 --- test/verify-packaged-sharp.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/verify-packaged-sharp.mjs b/test/verify-packaged-sharp.mjs index 4298c8e5..64884a22 100644 --- a/test/verify-packaged-sharp.mjs +++ b/test/verify-packaged-sharp.mjs @@ -134,7 +134,9 @@ function verify(app) { } } -const releaseDir = process.argv[2] ?? 'release'; +// Absolute, because the runtime check hands these paths to require() and a bare relative +// path there is read as a package name rather than a location on disk. +const releaseDir = path.resolve(process.argv[2] ?? 'release'); if (!fs.existsSync(releaseDir)) { console.error(`No such directory: ${releaseDir}`); process.exit(1); From 42832bb6c6be9f121a4e5d67dac21b25649e04d3 Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 8 Aug 2026 19:20:41 -0500 Subject: [PATCH 4/5] docs: update macOS installation instructions to support both Apple Silicon and Intel --- .github/release_notes_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/release_notes_template.md b/.github/release_notes_template.md index e399c92d..0843d80c 100644 --- a/.github/release_notes_template.md +++ b/.github/release_notes_template.md @@ -12,7 +12,7 @@ Just use the command lines ``` curl -L -o PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe https://github.com/PowerInterviewAI/client-app/releases/latest/download/PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe && start "" "PowerInterviewAI-Setup-VERSION_PLACEHOLDER.exe" ``` -- MacOS +- MacOS (works on both Apple Silicon and Intel - picks the build matching `uname -m`) ``` -curl -L -o Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg https://github.com/PowerInterviewAI/client-app/releases/latest/download/Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg && open "Power.Interview.AI-VERSION_PLACEHOLDER-arm64.dmg" +SUF=""; [ "$(uname -m)" = "arm64" ] && SUF="-arm64"; DMG="Power.Interview.AI-VERSION_PLACEHOLDER$SUF.dmg"; curl -L -o "$DMG" "https://github.com/PowerInterviewAI/client-app/releases/latest/download/$DMG" && open "$DMG" ``` From 9675c9d24c5410e35439ff5636fd399046606ffc Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 8 Aug 2026 19:34:03 -0500 Subject: [PATCH 5/5] Smoke test that the packaged app actually launches Loading sharp through ELECTRON_RUN_AS_NODE proves the binary resolves but never starts Electron, so it cannot catch a native module that only fails once the main process boots - which is the failure users report. Co-Authored-By: Claude Opus 5 --- .github/workflows/verify-native-deps.yml | 6 ++ test/packaged-apps.mjs | 90 +++++++++++++++++ test/smoke-packaged-launch.mjs | 104 ++++++++++++++++++++ test/verify-packaged-sharp.mjs | 119 +++++++---------------- 4 files changed, 235 insertions(+), 84 deletions(-) create mode 100644 test/packaged-apps.mjs create mode 100644 test/smoke-packaged-launch.mjs diff --git a/.github/workflows/verify-native-deps.yml b/.github/workflows/verify-native-deps.yml index 03b45ace..18be3ba3 100644 --- a/.github/workflows/verify-native-deps.yml +++ b/.github/workflows/verify-native-deps.yml @@ -54,3 +54,9 @@ jobs: - name: Verify packaged native dependencies run: node test/verify-packaged-sharp.mjs release shell: bash + + # Loading sharp through ELECTRON_RUN_AS_NODE never starts Electron proper, so it cannot + # catch a failure that only shows up once the main process boots. + - name: Smoke test packaged app launch + run: node test/smoke-packaged-launch.mjs release + shell: bash diff --git a/test/packaged-apps.mjs b/test/packaged-apps.mjs new file mode 100644 index 00000000..8b55b0f6 --- /dev/null +++ b/test/packaged-apps.mjs @@ -0,0 +1,90 @@ +/** + * Locating packaged apps in an electron-builder output directory, shared by the checks that + * run against a build rather than against source. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const MACHO_64 = 0xfeedfacf; +const MACHO_CPU = { 0x01000007: 'x64', 0x0100000c: 'arm64' }; +const PE_MACHINE = { 0x8664: 'x64', 0xaa64: 'arm64' }; + +/** Architecture a Mach-O or PE binary was built for, or null if unrecognised. */ +export function readArch(binary) { + const fd = fs.openSync(binary, 'r'); + const head = Buffer.alloc(64); + fs.readSync(fd, head, 0, 64, 0); + try { + if (head.readUInt32LE(0) === MACHO_64) return MACHO_CPU[head.readUInt32LE(4)] ?? null; + if (head.toString('ascii', 0, 2) === 'MZ') { + const peOffset = head.readUInt32LE(0x3c); + const coff = Buffer.alloc(6); + fs.readSync(fd, coff, 0, 6, peOffset); + return PE_MACHINE[coff.readUInt16LE(4)] ?? null; + } + return null; + } finally { + fs.closeSync(fd); + } +} + +/** + * Every packaged app under `dir`, as { label, platform, arch, executable, resources }. + * + * Paths are absolute: callers hand them to require(), where a bare relative specifier would be + * read as a package name rather than a location on disk. + */ +export function findApps(dir) { + const root = path.resolve(dir); + const apps = []; + + const add = (bundle, executable, resources) => { + apps.push({ + label: path.relative(root, bundle), + platform: executable.endsWith('.exe') ? 'win32' : 'darwin', + arch: readArch(executable), + executable, + resources, + }); + }; + + const walk = (current, depth) => { + if (depth > 3) return; + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const full = path.join(current, entry.name); + if (entry.name.endsWith('.app')) { + const macos = path.join(full, 'Contents', 'MacOS'); + const [binary] = fs.existsSync(macos) ? fs.readdirSync(macos) : []; + if (binary) { + add(full, path.join(macos, binary), path.join(full, 'Contents', 'Resources')); + } + continue; + } + if (entry.name.endsWith('-unpacked')) { + const [exe] = fs.readdirSync(full).filter((f) => f.endsWith('.exe')); + if (exe) add(full, path.join(full, exe), path.join(full, 'resources')); + continue; + } + walk(full, depth + 1); + } + }; + + walk(root, 0); + return apps; +} + +/** Console reporter shared by the packaged checks. */ +export function createChecker() { + const failures = []; + return { + failures, + check(name, ok) { + console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}`); + if (!ok) failures.push(name); + }, + skip(name) { + console.log(` skip ${name}`); + }, + }; +} diff --git a/test/smoke-packaged-launch.mjs b/test/smoke-packaged-launch.mjs new file mode 100644 index 00000000..348e639a --- /dev/null +++ b/test/smoke-packaged-launch.mjs @@ -0,0 +1,104 @@ +/** + * Launches each packaged app for real and fails if the main process dies or reports an + * uncaught exception. + * + * verify-packaged-sharp.mjs loads sharp through ELECTRON_RUN_AS_NODE, which proves the binary + * resolves but never starts Electron proper. A native module that is missing, built for the + * wrong architecture, or unsigned takes the app down at startup instead - the failure users + * actually see, as a "A JavaScript error occurred in the main process" dialog. That dialog also + * keeps the process alive, so staying up is not on its own evidence of health and the output + * has to be checked too. + * + * Run against the electron-builder output directory: node test/smoke-packaged-launch.mjs release + */ +import { execFileSync, spawn } from 'node:child_process'; +import path from 'node:path'; + +import { createChecker, findApps } from './packaged-apps.mjs'; + +const SETTLE_MS = 20000; +const FATAL = /Uncaught Exception|A JavaScript error occurred|Cannot find module|dlopen|code signature|Could not load the "sharp" module/i; + +const { check, skip, failures } = createChecker(); + +function launch(app) { + return new Promise((resolve) => { + // No extra CLI flags: a packaged Electron binary rejects unrecognised leading-dash options + // outright, so anything passed here would test the launcher rather than the app. + const child = spawn(app.executable, [], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ELECTRON_ENABLE_LOGGING: '1' }, + // Own process group, so teardown can take Electron's GPU and renderer children with it. + detached: process.platform !== 'win32', + }); + + const startedAt = Date.now(); + let output = ''; + let exited = null; + child.stdout.on('data', (d) => (output += d)); + child.stderr.on('data', (d) => (output += d)); + child.on('error', (e) => (output += `spawn error: ${e.message}\n`)); + child.on('exit', (code, signal) => (exited = { code, signal, afterMs: Date.now() - startedAt })); + + setTimeout(() => { + const alive = exited === null; + // Electron's children outlive a kill aimed at the parent alone, and a survivor holds the + // single instance lock - which makes the *next* app under test quit immediately and look + // like a failure. Tear down the whole tree. + if (alive) { + try { + if (process.platform === 'win32') { + execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } else { + process.kill(-child.pid, 'SIGKILL'); + } + } catch { + child.kill('SIGKILL'); + } + } + setTimeout(() => resolve({ output, exited: alive ? null : exited }), 1500); + }, SETTLE_MS); + }); +} + +const releaseDir = path.resolve(process.argv[2] ?? 'release'); +const apps = findApps(releaseDir); +if (apps.length === 0) { + console.error(`No packaged app found under ${releaseDir}`); + process.exit(1); +} + +for (const app of apps) { + console.log(`\n# ${app.label} (${app.platform}-${app.arch})`); + + const { output, exited } = await launch(app); + const fatal = output.match(FATAL); + + if (exited && /Bad CPU type|Exec format error|ENOEXEC/i.test(output)) { + skip(`launch (host is ${process.arch}, artifact is ${app.arch}, no translation available)`); + continue; + } + + check('starts without an uncaught exception in the main process', fatal === null); + check('main process is still running after startup', exited === null); + + if (fatal !== null || exited !== null) { + if (exited) { + console.error(` exited: code=${exited.code} signal=${exited.signal} after ${exited.afterMs}ms`); + // The single instance lock is the only path that quits this cleanly this early, and it + // means something else on the machine already holds it rather than the build being bad. + if (exited.code === 0 && exited.afterMs < 2000) { + console.error(' quit immediately with no error: another instance likely holds the single instance lock'); + } + } + for (const line of output.trim().split('\n').slice(-25)) console.error(` ${line}`); + } +} + +if (failures.length > 0) { + console.error(`\n${failures.length} check(s) failed:`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} + +console.log('\nAll packaged apps launched cleanly.'); diff --git a/test/verify-packaged-sharp.mjs b/test/verify-packaged-sharp.mjs index 64884a22..ef2cecda 100644 --- a/test/verify-packaged-sharp.mjs +++ b/test/verify-packaged-sharp.mjs @@ -13,101 +13,54 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -const MACHO_64 = 0xfeedfacf; -const MACHO_CPU = { 0x01000007: 'x64', 0x0100000c: 'arm64' }; -const PE_MACHINE = { 0x8664: 'x64', 0xaa64: 'arm64' }; +import { createChecker, findApps, readArch } from './packaged-apps.mjs'; -const failures = []; -const check = (name, ok) => { - console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}`); - if (!ok) failures.push(name); -}; - -function readArch(binary) { - const fd = fs.openSync(binary, 'r'); - const head = Buffer.alloc(64); - fs.readSync(fd, head, 0, 64, 0); - try { - if (head.readUInt32LE(0) === MACHO_64) return MACHO_CPU[head.readUInt32LE(4)] ?? null; - if (head.toString('ascii', 0, 2) === 'MZ') { - const peOffset = head.readUInt32LE(0x3c); - const coff = Buffer.alloc(6); - fs.readSync(fd, coff, 0, 6, peOffset); - return PE_MACHINE[coff.readUInt16LE(4)] ?? null; - } - return null; - } finally { - fs.closeSync(fd); - } -} - -/** Every packaged app under `dir`, as { label, executable, resources }. */ -function findApps(dir) { - const apps = []; - const walk = (current, depth) => { - if (depth > 3) return; - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const full = path.join(current, entry.name); - if (entry.name.endsWith('.app')) { - const macos = path.join(full, 'Contents', 'MacOS'); - const [binary] = fs.existsSync(macos) ? fs.readdirSync(macos) : []; - if (binary) { - apps.push({ - label: path.relative(dir, full), - executable: path.join(macos, binary), - resources: path.join(full, 'Contents', 'Resources'), - }); - } - continue; - } - if (entry.name.endsWith('-unpacked')) { - const [exe] = fs.readdirSync(full).filter((f) => f.endsWith('.exe')); - if (exe) { - apps.push({ - label: path.relative(dir, full), - executable: path.join(full, exe), - resources: path.join(full, 'resources'), - }); - } - continue; - } - walk(full, depth + 1); - } - }; - walk(dir, 0); - return apps; -} +const { check, skip, failures } = createChecker(); function verify(app) { console.log(`\n# ${app.label}`); - const arch = readArch(app.executable); - const platform = app.executable.endsWith('.exe') ? 'win32' : 'darwin'; - check(`identifies the app as ${platform}-${arch}`, arch !== null); - if (arch === null) return; + check(`identifies the app as ${app.platform}-${app.arch}`, app.arch !== null); + if (app.arch === null) return; const img = path.join(app.resources, 'app.asar.unpacked', 'node_modules', '@img'); - const nodeBinary = path.join(img, `sharp-${platform}-${arch}`, 'lib', `sharp-${platform}-${arch}.node`); + const name = `sharp-${app.platform}-${app.arch}`; + const nodeBinary = path.join(img, name, 'lib', `${name}.node`); const unpacked = fs.existsSync(nodeBinary); - check(`unpacks sharp-${platform}-${arch}.node outside the asar`, unpacked); + check(`unpacks ${name}.node outside the asar`, unpacked); if (unpacked) { - check('builds the binary for this architecture', readArch(nodeBinary) === arch); + check('builds the binary for this architecture', readArch(nodeBinary) === app.arch); } - // On macOS libvips lives in a sibling package reached via @loader_path/../../ from the - // .node, so resolve it exactly the way dyld will rather than just asserting it exists. - if (platform === 'darwin') { - const rpath = path.join(path.dirname(nodeBinary), '..', '..', `sharp-libvips-darwin-${arch}`, 'lib'); + if (app.platform === 'darwin') { + // libvips lives in a sibling package reached via @loader_path/../../ from the .node, so + // resolve it the way dyld will rather than just asserting the directory exists. + const rpath = path.join(path.dirname(nodeBinary), '..', '..', `sharp-libvips-darwin-${app.arch}`, 'lib'); const dylibs = fs.existsSync(rpath) ? fs.readdirSync(rpath).filter((f) => f.endsWith('.dylib')) : []; check(`resolves libvips through the .node's @rpath (${dylibs.join(', ') || 'nothing found'})`, dylibs.length > 0); + + // Unpacking moves these out of the asar and into the bundle as real Mach-O files. arm64 + // refuses to load any that the bundle signature does not cover. + if (process.platform === 'darwin') { + try { + execFileSync('codesign', ['--verify', '--deep', '--strict', path.join(app.resources, '..', '..')], { + stdio: 'pipe', + }); + check('bundle signature covers the unpacked native binaries', true); + } catch (error) { + check('bundle signature covers the unpacked native binaries', false); + console.error(` ${(error.stderr || error.message).toString().trim()}`); + } + } else { + skip('codesign verification (not running on macOS)'); + } } - // The real proof: load sharp out of the packaged asar with the packaged Electron and run - // the operation the app actually performs. A foreign-arch artifact is still worth trying - - // Rosetta runs the x64 build on the arm64 mac runner, which is the exact configuration that - // shipped broken - so only give up when the kernel refuses the binary outright. + // The real proof: load sharp out of the packaged asar with the packaged Electron and run the + // operation the app actually performs. A foreign-arch artifact is still worth trying - Rosetta + // runs the x64 build on the arm64 mac runner, which is the exact configuration that shipped + // broken - so only give up when the kernel refuses the binary outright. try { const output = execFileSync( app.executable, @@ -125,17 +78,15 @@ function verify(app) { check(`loads and runs sharp from the packaged app (${output.trim()})`, output.includes('OK ')); } catch (error) { const detail = (error.stderr || error.message).toString().trim(); - if (arch !== process.arch && /Bad CPU type|Exec format error|ENOEXEC/i.test(detail)) { - console.log(` skip runtime load test (host is ${process.arch}, artifact is ${arch}, no translation)`); + if (app.arch !== process.arch && /Bad CPU type|Exec format error|ENOEXEC/i.test(detail)) { + skip(`runtime load test (host is ${process.arch}, artifact is ${app.arch}, no translation)`); return; } - check(`loads and runs sharp from the packaged app`, false); + check('loads and runs sharp from the packaged app', false); for (const line of detail.split('\n')) console.error(` ${line}`); } } -// Absolute, because the runtime check hands these paths to require() and a bare relative -// path there is read as a package name rather than a location on disk. const releaseDir = path.resolve(process.argv[2] ?? 'release'); if (!fs.existsSync(releaseDir)) { console.error(`No such directory: ${releaseDir}`);