build(electron): exclude uuidv7 and guard against future asar regressions

uuidv7 is imported only from src/app/util/uuid-v7.ts; the Angular
bundler inlines it (verified via chunk-65AEYVPY.js.map source
attribution). Adding it to the three electron-builder configs drops
another ~80 KB from the asar.

Extend tools/verify-electron-requires.js to also flag bare require()
targets in electron/ that match a `\!**/<pkg>/**` exclusion in
electron-builder.yaml. Without this, adding `require('@noble/ciphers')`
or similar in electron/main.ts would pass dev (which resolves against
on-disk node_modules) and only crash with MODULE_NOT_FOUND on packaged
releases. The script is already invoked from .github/workflows/
electron-smoke.yml, so the new check runs in CI automatically.

Manual verification covers 14 cases (8 excluded, 6 allowed) including
@noble/ciphers, @noble/hashes, scoped-vs-bare resolution, and Node
built-ins.
This commit is contained in:
Johannes Millan 2026-05-13 21:22:59 +02:00
parent 95b0d8e2f7
commit ddc01ab6cf
4 changed files with 87 additions and 20 deletions

View file

@ -29,6 +29,8 @@ files:
# copies are dead weight at runtime.
- '!**/@noble/**'
- '!**/hash-wasm/**'
# uuidv7 is imported by src/ (Angular bundle inlines it); not used by electron/
- '!**/uuidv7/**'
directories:
output: './.tmp/app-builds'

View file

@ -29,6 +29,8 @@ files:
# copies are dead weight at runtime.
- '!**/@noble/**'
- '!**/hash-wasm/**'
# uuidv7 is imported by src/ (Angular bundle inlines it); not used by electron/
- '!**/uuidv7/**'
directories:
output: './.tmp/app-builds'

View file

@ -30,6 +30,8 @@ files:
# copies are dead weight at runtime.
- '!**/@noble/**'
- '!**/hash-wasm/**'
# uuidv7 is imported by src/ (Angular bundle inlines it); not used by electron/
- '!**/uuidv7/**'
#afterSign: ./tools/notarizeMacApp.js
directories:

View file

@ -1,17 +1,25 @@
#!/usr/bin/env node
/*
* Walks the contents of a packaged app.asar and verifies that every relative
* require() call inside electron/ .js/.cjs/.mjs files resolves to a file that
* was actually packaged. Catches #7320-class bugs where tsc happily compiles
* an import reaching out of the electron tree (e.g. '../src/app/util/foo')
* but the compiled dependency lives outside the files glob in
* electron-builder.yaml and is therefore missing from app.asar at runtime.
* Walks the contents of a packaged app.asar and verifies, for every .js/.cjs/.mjs
* file under electron/, that:
*
* 1. Every relative require() target resolves to a file that was actually
* packaged. Catches #7320-class bugs where tsc happily compiles an import
* reaching out of the electron tree (e.g. '../src/app/util/foo') but the
* compiled dependency lives outside the files glob in electron-builder.yaml
* and is therefore missing from app.asar at runtime.
*
* 2. No bare require() targets a package that electron-builder.yaml lists as
* excluded from the asar (e.g. require('@noble/ciphers')). This catches
* the regression class where dev (running against on-disk node_modules)
* succeeds, but the packaged release crashes with MODULE_NOT_FOUND because
* the package was pruned out of app.asar. The exclusion list is parsed
* from electron-builder.yaml; keep that file as the single source of truth.
*
* Usage:
* node tools/verify-electron-requires.js <path/to/app.asar>
*
* Exits 0 on success, 1 when unresolvable requires are found, 2 on usage
* errors.
* Exits 0 on success, 1 when violations are found, 2 on usage errors.
*/
const fs = require('fs');
const os = require('os');
@ -68,6 +76,44 @@ const collectRelativeRequires = (src) => {
return targets;
};
// Bare specifiers: anything that doesn't start with '.' or '/'. Built-ins
// (fs, path, electron, etc.) and runtime deps both pass through here; the
// exclusion check below is what flags forbidden ones.
const collectBareRequires = (src) => {
const targets = [];
const re = /\brequire\(\s*(['"])([^./'"][^'"\n]*)\1\s*\)/g;
let m;
while ((m = re.exec(src)) !== null) targets.push(m[2]);
return targets;
};
// Parse `'!**/<name>/**'` entries from electron-builder.yaml. Multi-segment
// patterns like '!**/@nx/nx-darwin-*/**' don't match this shape and are
// intentionally skipped — only full-package exclusions count as "you can't
// import this from electron/".
const parseExcludedPackages = (yamlPath) => {
const text = fs.readFileSync(yamlPath, 'utf8');
const re = /^\s*-\s*['"]!\*\*\/([^/'"*]+)\/\*\*['"]\s*$/gm;
const set = new Set();
let m;
while ((m = re.exec(text)) !== null) set.add(m[1]);
return set;
};
// '@scope/pkg/sub' -> '@scope/pkg'; 'pkg/sub' -> 'pkg'. Used to test against
// the exclusion set, which can hold either a scope ('@noble' covers all of
// @noble/*) or a full package name ('hash-wasm').
const isExcludedBareRequire = (target, excluded) => {
if (target.startsWith('@')) {
const [scope, pkg] = target.split('/');
if (excluded.has(scope)) return scope;
if (pkg != null && excluded.has(`${scope}/${pkg}`)) return `${scope}/${pkg}`;
return null;
}
const [pkg] = target.split('/');
return excluded.has(pkg) ? pkg : null;
};
// Guard: a resolved path must stay inside the extracted asar tree.
// Without this, a relative require with enough `..` climbs above `tmp` and
// Node's resolver happily hits the host filesystem (or the host's
@ -75,6 +121,17 @@ const collectRelativeRequires = (src) => {
const tmpPrefix = tmp.endsWith(path.sep) ? tmp : tmp + path.sep;
const isInsideTmp = (p) => p === tmp || p.startsWith(tmpPrefix);
// Resolve electron-builder.yaml relative to this script's location so the
// check works regardless of where node is invoked from.
const builderYaml = path.join(__dirname, '..', 'electron-builder.yaml');
let excludedPackages;
try {
excludedPackages = parseExcludedPackages(builderYaml);
} catch (err) {
console.error(`Could not read exclusion list from ${builderYaml}: ${err.message}`);
process.exit(2);
}
let exitCode = 0;
try {
asar.extractAll(asarPath, tmp);
@ -104,8 +161,14 @@ try {
);
}
} catch {
errors.push(`${path.relative(tmp, file)}: cannot resolve require('${target}')`);
}
}
for (const target of collectBareRequires(src)) {
const hit = isExcludedBareRequire(target, excludedPackages);
if (hit != null) {
errors.push(
`${path.relative(tmp, file)}: cannot resolve require('${target}')`,
`${path.relative(tmp, file)}: require('${target}') targets '${hit}', which electron-builder.yaml excludes from app.asar — the packaged release will crash with MODULE_NOT_FOUND`,
);
}
}
@ -118,22 +181,20 @@ try {
if (exitCode !== 0) process.exit(exitCode);
if (errors.length) {
console.error(
`Found ${errors.length} unresolvable require() target(s) inside ${asarPath}:\n`,
);
console.error(`Found ${errors.length} require() problem(s) inside ${asarPath}:\n`);
for (const err of errors) console.error(' ' + err);
console.error('\nFix for relative-require failures: move the imported module under');
console.error('electron/shared-with-frontend/ (or another path covered by files: in');
console.error('electron-builder.yaml) and update importers.');
console.error(
'\nThis means a .ts file under electron/ imported a path that was not packaged.',
);
console.error(
'Fix: move the imported module under electron/shared-with-frontend/ (or another',
);
console.error(
'path covered by files: in electron-builder.yaml) and update importers.',
'\nFix for excluded-package requires: either drop the require in electron/',
);
console.error('or remove the matching `!**/<pkg>/**` line from electron-builder.yaml');
console.error('(and the two build/electron-builder.mas*.yaml siblings).');
process.exit(1);
}
console.log(`OK: all require() targets under electron/ resolve cleanly in ${asarPath}`);
console.log(
`OK: all relative require() targets under electron/ resolve cleanly in ${asarPath}`,
` (${excludedPackages.size} package(s) checked against asar exclusion list)`,
);