harden Electron startup repair
This commit is contained in:
+123
-32
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user