From 0bd326d8b2482a2da54f78cc880f29bf4b9fa4fe Mon Sep 17 00:00:00 2001 From: Twest2 Date: Tue, 23 Jun 2026 18:10:35 -0500 Subject: [PATCH] harden Electron startup repair --- scripts/electron-launcher.js | 171 ++++++++++++++++++++------- tests/unit/electron-launcher.test.js | 40 +++++++ 2 files changed, 171 insertions(+), 40 deletions(-) diff --git a/scripts/electron-launcher.js b/scripts/electron-launcher.js index 6164685..a23a993 100644 --- a/scripts/electron-launcher.js +++ b/scripts/electron-launcher.js @@ -10,6 +10,11 @@ const ELECTRON_SKIP_ENV_KEYS = [ 'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD', ]; +const NPM_IGNORE_SCRIPTS_ENV_KEYS = [ + 'npm_config_ignore_scripts', + 'NPM_CONFIG_IGNORE_SCRIPTS', +]; + function resolveElectronPackageRoot() { try { return path.dirname(require.resolve('electron/package.json')); @@ -48,6 +53,9 @@ function sanitizeElectronEnv(baseEnv = process.env) { for (const key of ELECTRON_SKIP_ENV_KEYS) { delete env[key]; } + for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) { + delete env[key]; + } return env; } @@ -67,8 +75,10 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) { return candidatePaths; } -function runNpmRebuild({ +function runNpmCommand({ packageRoot, + npmArgs, + errorLabel, npmExecPath = process.env.npm_execpath || null, npmNodeExecPath = process.env.npm_node_execpath || process.execPath, }) { @@ -76,31 +86,63 @@ function runNpmRebuild({ return false; } - const result = spawnSync( - npmNodeExecPath, - [npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'], - { - cwd: packageRoot, - env: sanitizeElectronEnv(), - stdio: 'inherit', - } - ); + const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], { + cwd: packageRoot, + env: sanitizeElectronEnv(), + stdio: 'inherit', + }); if (result.error) { throw result.error; } if (result.signal) { - throw new Error(`Electron repair was interrupted by ${result.signal}`); + throw new Error(`${errorLabel} was interrupted by ${result.signal}`); } if (result.status !== 0) { - throw new Error(`Electron rebuild failed with exit code ${result.status ?? 1}`); + throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`); } return true; } +function runNpmRebuild({ + packageRoot, + npmExecPath = process.env.npm_execpath || null, + npmNodeExecPath = process.env.npm_node_execpath || process.execPath, +}) { + return runNpmCommand({ + packageRoot, + npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'], + errorLabel: 'Electron rebuild', + npmExecPath, + npmNodeExecPath, + }); +} + +function runNpmInstall({ + packageRoot, + npmExecPath = process.env.npm_execpath || null, + npmNodeExecPath = process.env.npm_node_execpath || process.execPath, +}) { + return runNpmCommand({ + packageRoot, + npmArgs: [ + 'install', + '--include=dev', + '--ignore-scripts=false', + '--foreground-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + ], + errorLabel: 'Electron dependency install', + npmExecPath, + npmNodeExecPath, + }); +} + function repairElectronInstall({ packageRoot, }) { @@ -153,45 +195,93 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) { function resolveElectronBinary({ packageRoot = resolveElectronPackageRoot(), + projectRoot = process.cwd(), platform = process.platform, overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null, } = {}) { - if (!packageRoot && !overrideDistPath) { + const repairErrors = []; + + function resolveCurrentPackageRoot() { + if (packageRoot) return packageRoot; + const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron'); + if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) { + packageRoot = conventionalRoot; + return packageRoot; + } + packageRoot = resolveElectronPackageRoot(); + return packageRoot; + } + + function tryRepair(label, repairFn) { + try { + if (!repairFn()) { + return null; + } + } catch (error) { + repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`); + return null; + } + + const currentPackageRoot = resolveCurrentPackageRoot(); + if (!currentPackageRoot && !overrideDistPath) { + return null; + } + + const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist'); + return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) => + fs.existsSync(candidate) + ); + } + + let currentPackageRoot = resolveCurrentPackageRoot(); + if (!currentPackageRoot && !overrideDistPath) { + const installed = tryRepair('Electron dependency install', () => + runNpmInstall({ packageRoot: projectRoot }) + ); + if (installed) { + return installed; + } + + currentPackageRoot = resolveCurrentPackageRoot(); + } + + if (!currentPackageRoot && !overrideDistPath) { throw new Error( 'Electron could not be started because node_modules/electron is not installed.\n\n' + 'Run `npm install` from the repo root, then try `npm start` again.' ); } - const distDir = overrideDistPath || path.join(packageRoot, 'dist'); - const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform }); - - const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate)); - if (!resolved) { - if (packageRoot) { - if (runNpmRebuild({ packageRoot })) { - const rebuilt = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) => - fs.existsSync(candidate) - ); - if (rebuilt) { - return rebuilt; - } - } - - if (repairElectronInstall({ packageRoot })) { - const repaired = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) => - fs.existsSync(candidate) - ); - if (repaired) { - return repaired; - } - } - } - - throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths })); + const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist'); + let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }); + let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate)); + if (resolved) { + return resolved; } - return resolved; + const repairAttempts = [ + ['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })], + ['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })], + ['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })], + ]; + + for (const [label, repairFn] of repairAttempts) { + const repaired = tryRepair(label, repairFn); + if (repaired) { + return repaired; + } + } + + throw new Error( + buildMissingElectronError({ + packageRoot: currentPackageRoot, + distDir, + candidatePaths, + }) + + (repairErrors.length + ? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}` + : '') + ); } module.exports = { @@ -200,6 +290,7 @@ module.exports = { readElectronPathHint, repairElectronInstall, runNpmRebuild, + runNpmInstall, sanitizeElectronEnv, resolveElectronBinary, resolveElectronPackageRoot, diff --git a/tests/unit/electron-launcher.test.js b/tests/unit/electron-launcher.test.js index 42ff685..3cefaa1 100644 --- a/tests/unit/electron-launcher.test.js +++ b/tests/unit/electron-launcher.test.js @@ -112,6 +112,46 @@ test('rebuilds Electron through npm when the binary is missing', (t) => { ); }); +test('falls back to npm install when rebuild does not repair the runtime', (t) => { + const root = makeTmpDir('electron-install-fallback'); + t.after(() => rmrf(root)); + + fs.mkdirSync(path.join(root, 'dist'), { recursive: true }); + const fakeNpmCli = path.join(root, 'fake-npm-cli.js'); + fs.writeFileSync( + fakeNpmCli, + [ + "const fs = require('node:fs');", + "const path = require('node:path');", + "const command = process.argv[2];", + "if (command === 'rebuild') process.exit(1);", + "if (command === 'install') {", + " fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });", + " fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');", + " fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');", + " process.exit(0);", + "}", + "process.exit(1);", + ].join('\n') + ); + + const originalNpmExecPath = process.env.npm_execpath; + const originalNpmNodeExecPath = process.env.npm_node_execpath; + process.env.npm_execpath = fakeNpmCli; + process.env.npm_node_execpath = process.execPath; + t.after(() => { + if (originalNpmExecPath === undefined) delete process.env.npm_execpath; + else process.env.npm_execpath = originalNpmExecPath; + if (originalNpmNodeExecPath === undefined) delete process.env.npm_node_execpath; + else process.env.npm_node_execpath = originalNpmNodeExecPath; + }); + + assert.equal( + resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }), + path.join(root, 'dist', 'electron.exe') + ); +}); + test('reports a helpful error when the runtime is missing', (t) => { const root = makeTmpDir('electron-missing'); t.after(() => rmrf(root));