diff --git a/build/electron-builder.mas-dev.yaml b/build/electron-builder.mas-dev.yaml index 75719af8ae..07101f065a 100644 --- a/build/electron-builder.mas-dev.yaml +++ b/build/electron-builder.mas-dev.yaml @@ -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' diff --git a/build/electron-builder.mas.yaml b/build/electron-builder.mas.yaml index a14da12e93..2d7fd8102b 100644 --- a/build/electron-builder.mas.yaml +++ b/build/electron-builder.mas.yaml @@ -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' diff --git a/electron-builder.yaml b/electron-builder.yaml index 62250e70ba..a1407205f2 100644 --- a/electron-builder.yaml +++ b/electron-builder.yaml @@ -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: diff --git a/tools/verify-electron-requires.js b/tools/verify-electron-requires.js index e35109ae7f..e724485807 100644 --- a/tools/verify-electron-requires.js +++ b/tools/verify-electron-requires.js @@ -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 * - * 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 `'!**//**'` 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 `!**//**` 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)`, );