diff --git a/.github/release_notes_template.md b/.github/release_notes_template.md index e399c92..0843d80 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" ``` diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3f3893..8628bf7 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 0000000..18be3ba --- /dev/null +++ b/.github/workflows/verify-native-deps.yml @@ -0,0 +1,62 @@ +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 + + # 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/package.json b/package.json index d168fc1..439388a 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 d99a658..df02351 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/packaged-apps.mjs b/test/packaged-apps.mjs new file mode 100644 index 0000000..8b55b0f --- /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 0000000..348e639 --- /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 new file mode 100644 index 0000000..ef2cecd --- /dev/null +++ b/test/verify-packaged-sharp.mjs @@ -0,0 +1,110 @@ +/** + * 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'; + +import { createChecker, findApps, readArch } from './packaged-apps.mjs'; + +const { check, skip, failures } = createChecker(); + +function verify(app) { + console.log(`\n# ${app.label}`); + + 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 name = `sharp-${app.platform}-${app.arch}`; + const nodeBinary = path.join(img, name, 'lib', `${name}.node`); + + const unpacked = fs.existsSync(nodeBinary); + check(`unpacks ${name}.node outside the asar`, unpacked); + if (unpacked) { + check('builds the binary for this architecture', readArch(nodeBinary) === app.arch); + } + + 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. + 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 (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); + for (const line of detail.split('\n')) console.error(` ${line}`); + } +} + +const releaseDir = path.resolve(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.');