From 0d826b5daae3b0715b276c6573b1d4262fbf18f9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 00:05:54 -0700 Subject: [PATCH] [eric] build: drop foreign-arch native prebuilds from the app, with a release gate to keep them out --- electron/build/after-pack.js | 37 ++++++++++++++ scripts/ci/verify-all.js | 1 + scripts/ci/verify-native-arch.js | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 scripts/ci/verify-native-arch.js diff --git a/electron/build/after-pack.js b/electron/build/after-pack.js index f6b5177e..30325ca8 100644 --- a/electron/build/after-pack.js +++ b/electron/build/after-pack.js @@ -83,8 +83,45 @@ function stageRouterNodeModules(context) { console.log(`[afterPack] staged 9Router node_modules into ${routerDir}`); } +// prebuildify packages (uiohook-napi and friends) ship a .node for EVERY platform+arch they support. +// Six of the seven are dead weight in any one build, and on macOS an x86_64 Mach-O sitting inside an +// arm64 bundle is what makes the OS put up its Intel-deprecation dialog. node-gyp-build only ever +// looks in prebuilds/-, so deleting the rest is invisible to the app. Runs here in +// afterPack because code-signing comes next and seals the bundle; a later delete breaks the seal. +function pruneForeignPrebuilds(context) { + const { appOutDir, electronPlatformName, arch } = context; + // Read the arch name off electron-builder's own enum rather than hardcoding its numbering. + const archName = require('builder-util').Arch[arch]; + const root = electronPlatformName === 'darwin' + ? path.join(appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources') + : path.join(appOutDir, 'resources'); + let removed = 0; + + (function walk(dir, depth) { + if (depth > 12) return; + let ents = []; + try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of ents) { + if (!e.isDirectory()) continue; + const full = path.join(dir, e.name); + if (e.name !== 'prebuilds') { walk(full, depth + 1); continue; } + for (const tuple of fs.readdirSync(full, { withFileTypes: true })) { + // prebuildify names dirs "-", sometimes fat: "darwin-x64+arm64". + const [tuplePlatform, archPart] = tuple.name.split('-'); + const covers = tuplePlatform === electronPlatformName && String(archPart || '').split('+').includes(archName); + if (covers) continue; + fs.rmSync(path.join(full, tuple.name), { recursive: true, force: true }); + removed += 1; + } + } + })(root, 0); + + console.log(`[afterPack] pruned ${removed} foreign prebuild dir(s), kept ${electronPlatformName}-${archName}`); +} + exports.default = async function afterPack(context) { stageRouterNodeModules(context); + pruneForeignPrebuilds(context); // VMP signing runs last and unconditionally, after every file is staged, so the // OS code-sign that electron-builder runs next seals the VMP signature too. signVmp(context); diff --git a/scripts/ci/verify-all.js b/scripts/ci/verify-all.js index 20a6ef6b..28504e12 100644 --- a/scripts/ci/verify-all.js +++ b/scripts/ci/verify-all.js @@ -36,6 +36,7 @@ function main() { ['deps fully pinned (reproducible backend builds)', 'verify-deps-pinned.js', []], ['no build-host paths leaked into the artifact', 'verify-host-leakage.js', appArg], ['locale paks shipped (empty --lang -> Blink null-deref crash)', 'verify-locale-paks.js', appArg], + ['native modules match the target arch (no x86_64 .node in an arm64 app)', 'verify-native-arch.js', appArg], ['9router deps shipped (else subscription service hangs)', 'verify-router-deps.js', appArg], ['bundled python runs (--version + import smoke)', 'verify-python-health.js', appArg], ['MCP bundles answer initialize over stdio', 'verify-mcp-bundles.js', appArg], diff --git a/scripts/ci/verify-native-arch.js b/scripts/ci/verify-native-arch.js new file mode 100644 index 00000000..2828c5dd --- /dev/null +++ b/scripts/ci/verify-native-arch.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// Guards against shipping a native module built for the WRONG architecture. +// prebuildify packages (uiohook-napi, the dictation hotkey tap) ship one .node per platform+arch +// they support: seven dirs, of which exactly one is ours. Six are dead weight, and on macOS an +// x86_64 Mach-O inside an arm64 bundle is what makes the OS raise its Intel-deprecation dialog at +// the user. electron/build/after-pack.js prunes them; this asserts the prune actually happened, +// because an afterPack hook that silently no-ops (layout changed, module moved into the asar) would +// otherwise ship the same bundle it always did with nobody the wiser. + +'use strict'; +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const h = require('./lib/app-harness'); + +function parseArgs(argv) { + const out = { app: null }; + for (let i = 0; i < argv.length; i++) if (argv[i] === '--app') out.app = argv[++i]; + return out; +} + +function findNodeFiles(root) { + const found = []; + (function walk(dir, depth) { + if (depth > 14) return; + let ents = []; + try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of ents) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, depth + 1); + else if (e.isFile() && e.name.endsWith('.node')) found.push(full); + } + })(root, 0); + return found; +} + +// `file` names every slice in a Mach-O, so a fat binary reports both and a wrong-arch one reports +// only the wrong slice. On Windows/Linux there is no equivalent worth the dependency, so the gate +// there just asserts no foreign prebuild DIRS survived. +function machoArches(file) { + try { + return execFileSync('file', [file], { encoding: 'utf8' }).trim(); + } catch (err) { + return `file failed: ${err && err.message}`; + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const exe = h.packagedAppPath(args.app); + const root = process.platform === 'darwin' ? exe.slice(0, exe.indexOf('.app') + 4) : path.dirname(exe); + const want = process.arch === 'arm64' ? 'arm64' : 'x86_64'; + + const nodes = findNodeFiles(root); + process.stdout.write(` ${nodes.length} .node file(s) under ${path.basename(root)}\n`); + + const offenders = []; + for (const n of nodes) { + // A prebuilds dir for another OS is wrong no matter what `file` says about it. + const tuple = path.basename(path.dirname(n)); + const platformOk = !tuple.includes('-') || tuple.startsWith(process.platform); + const desc = process.platform === 'darwin' ? machoArches(n) : ''; + const archOk = process.platform !== 'darwin' || desc.includes(want); + if (!platformOk || !archOk) offenders.push(`${path.relative(root, n)} [${tuple}] ${desc.split(':').slice(1).join(':').trim()}`); + } + + if (!offenders.length) { + process.stdout.write(`PASS every bundled .node targets ${process.platform}/${want}\n`); + process.exit(0); + } + process.stderr.write( + `FAIL packaged build ships ${offenders.length} native module(s) for the WRONG target:\n` + + offenders.map((o) => ` ${o}\n`).join('') + + ` An x86_64 .node inside an arm64 bundle makes macOS show its Intel-deprecation\n` + + ` dialog, and every foreign prebuild is dead weight the user downloads.\n` + + ` Fix: electron/build/after-pack.js pruneForeignPrebuilds() should have deleted these.\n` + + ` If they now live inside app.asar rather than app.asar.unpacked, the prune needs to\n` + + ` run before the asar is built (beforePack) instead.\n`); + process.exit(1); +} + +main();