harden Electron startup repair

This commit is contained in:
2026-06-23 18:10:35 -05:00
parent bd10f7d147
commit 0bd326d8b2
2 changed files with 171 additions and 40 deletions
+123 -32
View File
@@ -10,6 +10,11 @@ const ELECTRON_SKIP_ENV_KEYS = [
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD', 'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
]; ];
const NPM_IGNORE_SCRIPTS_ENV_KEYS = [
'npm_config_ignore_scripts',
'NPM_CONFIG_IGNORE_SCRIPTS',
];
function resolveElectronPackageRoot() { function resolveElectronPackageRoot() {
try { try {
return path.dirname(require.resolve('electron/package.json')); 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) { for (const key of ELECTRON_SKIP_ENV_KEYS) {
delete env[key]; delete env[key];
} }
for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) {
delete env[key];
}
return env; return env;
} }
@@ -67,8 +75,10 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
return candidatePaths; return candidatePaths;
} }
function runNpmRebuild({ function runNpmCommand({
packageRoot, packageRoot,
npmArgs,
errorLabel,
npmExecPath = process.env.npm_execpath || null, npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath, npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) { }) {
@@ -76,31 +86,63 @@ function runNpmRebuild({
return false; return false;
} }
const result = spawnSync( const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
npmNodeExecPath,
[npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'],
{
cwd: packageRoot, cwd: packageRoot,
env: sanitizeElectronEnv(), env: sanitizeElectronEnv(),
stdio: 'inherit', stdio: 'inherit',
} });
);
if (result.error) { if (result.error) {
throw result.error; throw result.error;
} }
if (result.signal) { 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) { 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; 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({ function repairElectronInstall({
packageRoot, packageRoot,
}) { }) {
@@ -153,45 +195,93 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
function resolveElectronBinary({ function resolveElectronBinary({
packageRoot = resolveElectronPackageRoot(), packageRoot = resolveElectronPackageRoot(),
projectRoot = process.cwd(),
platform = process.platform, platform = process.platform,
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null, 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( throw new Error(
'Electron could not be started because node_modules/electron is not installed.\n\n' + '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.' 'Run `npm install` from the repo root, then try `npm start` again.'
); );
} }
const distDir = overrideDistPath || path.join(packageRoot, 'dist'); const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform }); let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate)); if (resolved) {
if (!resolved) { return 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 repairAttempts = [
const repaired = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) => ['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
fs.existsSync(candidate) ['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) { if (repaired) {
return repaired; return repaired;
} }
} }
}
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths })); throw new Error(
} buildMissingElectronError({
packageRoot: currentPackageRoot,
return resolved; distDir,
candidatePaths,
}) +
(repairErrors.length
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
: '')
);
} }
module.exports = { module.exports = {
@@ -200,6 +290,7 @@ module.exports = {
readElectronPathHint, readElectronPathHint,
repairElectronInstall, repairElectronInstall,
runNpmRebuild, runNpmRebuild,
runNpmInstall,
sanitizeElectronEnv, sanitizeElectronEnv,
resolveElectronBinary, resolveElectronBinary,
resolveElectronPackageRoot, resolveElectronPackageRoot,
+40
View File
@@ -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) => { test('reports a helpful error when the runtime is missing', (t) => {
const root = makeTmpDir('electron-missing'); const root = makeTmpDir('electron-missing');
t.after(() => rmrf(root)); t.after(() => rmrf(root));