Files
openswarm/electron/preflight.js

275 lines
13 KiB
JavaScript

'use strict';
// Runtime preflight: at first launch per app version, fans out parallel checks
// (OS, resources, write permission, security block, system libs, network, GPU,
// IPv4/v6, clock skew) under hard per-check timeouts, writes a verdict file
// keyed by version, and emits a [preflight] beacon line backend.log can pick up
// for fleet reporting. Subsequent launches read the cache and skip work.
// Every check is `check(env, opts) => {status, reason}` where env is injected
// so unit tests can drive every branch without touching real OS/network/spawn.
const path = require('path');
function defaultEnv() {
return {
fs: require('fs'),
child_process: require('child_process'),
dns: require('dns'),
http: require('http'),
https: require('https'),
os: require('os'),
now: () => Date.now(),
platform: process.platform,
arch: process.arch,
};
}
// Wraps a check fn with a hard timeout + never-throw contract. Unknown after
// timeout is 'warn' (not 'fail') so transient hangs don't false-positive.
async function withTimeout(name, fn, timeoutMs) {
const t0 = Date.now();
let timer;
const timeoutPromise = new Promise((resolve) => { timer = setTimeout(() => resolve({ status: 'warn', reason: `timeout ${timeoutMs}ms` }), timeoutMs); });
let result;
try {
result = await Promise.race([
Promise.resolve().then(() => fn()).catch((e) => ({ status: 'warn', reason: `threw: ${String((e && e.message) || e)}` })),
timeoutPromise,
]);
} catch (e) {
result = { status: 'warn', reason: `wrapper-threw: ${String((e && e.message) || e)}` };
} finally {
if (timer) clearTimeout(timer);
}
if (!result || !result.status) result = { status: 'warn', reason: 'no result returned' };
return { name, status: result.status, reason: result.reason || '', durationMs: Date.now() - t0 };
}
async function checkOs(env) {
const p = env.platform, a = env.arch;
if (!['win32', 'darwin', 'linux'].includes(p)) return { status: 'fail', reason: `unsupported platform ${p}` };
if (!['x64', 'arm64'].includes(a)) return { status: 'fail', reason: `unsupported arch ${a}` };
let rel = '';
try { rel = env.os.release(); } catch {}
if (p === 'darwin') {
const major = Number(String(rel).split('.')[0] || 0);
if (major < 22) return { status: 'warn', reason: `macOS darwin ${rel} < 22 (macOS 13)` };
}
if (p === 'win32') {
const major = Number(String(rel).split('.')[0] || 0);
if (major < 10) return { status: 'fail', reason: `windows ${rel} < 10` };
}
return { status: 'ok', reason: `${p}/${a} release=${rel}` };
}
async function checkResources(env) {
let total = 0, free = 0, cpus = 0;
try { total = env.os.totalmem(); free = env.os.freemem(); cpus = (env.os.cpus() || []).length; }
catch (e) { return { status: 'warn', reason: `os api: ${String(e)}` }; }
if (total < 4 * 1024 ** 3) return { status: 'warn', reason: `total memory ${(total / 1073741824).toFixed(1)}GB < 4GB` };
if (cpus < 2) return { status: 'warn', reason: `${cpus} cpu(s), recommended 2+` };
if (typeof env.fs.statfsSync === 'function') {
try {
const stat = env.fs.statfsSync(env.os.homedir());
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
if (freeBytes < 2 * 1024 ** 3) return { status: 'warn', reason: `home dir free ${(freeBytes / 1073741824).toFixed(1)}GB < 2GB` };
} catch (e) { return { status: 'warn', reason: `statfs threw: ${String(e)}` }; }
}
return { status: 'ok', reason: `mem=${(total / 1073741824).toFixed(1)}GB free=${(free / 1073741824).toFixed(1)}GB cpus=${cpus}` };
}
async function checkAppdataWritable(env, dataDir) {
if (!dataDir) return { status: 'warn', reason: 'no dataDir provided' };
try {
env.fs.mkdirSync(dataDir, { recursive: true });
const probe = path.join(dataDir, '.preflight-probe');
env.fs.writeFileSync(probe, 'ok');
env.fs.unlinkSync(probe);
return { status: 'ok', reason: `writable: ${dataDir}` };
} catch (e) {
return { status: 'fail', reason: `write blocked at ${dataDir}: ${String((e && e.message) || e)}` };
}
}
async function checkSecurityBlock(env) {
// We're already running, so any platform-level launch block already fired.
// This is a soft probe to surface "may prompt next launch" cases.
if (env.platform === 'darwin') {
return await new Promise((resolve) => {
try {
env.child_process.execFile('xattr', ['-l', process.execPath], { timeout: 1500 }, (err, stdout) => {
if (err) return resolve({ status: 'warn', reason: `xattr failed: ${String(err.message || err)}` });
if (/com\.apple\.quarantine/.test(String(stdout || ''))) return resolve({ status: 'warn', reason: 'app has com.apple.quarantine flag' });
resolve({ status: 'ok', reason: 'no quarantine flag' });
});
} catch (e) { resolve({ status: 'warn', reason: `xattr threw: ${String(e)}` }); }
});
}
if (env.platform === 'win32') {
return await new Promise((resolve) => {
try {
env.child_process.execFile('powershell.exe', ['-NoProfile', '-Command', '(Get-MpComputerStatus).AntivirusEnabled'], { timeout: 1800 }, (err, stdout) => {
if (err) return resolve({ status: 'warn', reason: `Get-MpComputerStatus failed: ${String(err.message || err)}` });
resolve({ status: 'ok', reason: `defender antivirus=${String(stdout || '').trim()}` });
});
} catch (e) { resolve({ status: 'warn', reason: `pwsh threw: ${String(e)}` }); }
});
}
return { status: 'ok', reason: 'linux: no os-level launch gate' };
}
async function checkSystemLibs(env) {
// We're a running Electron process; CRT/dyld/glibc are loaded by definition.
// The detailed VCRedist/dyld probe lives in verify-python-health which spawns
// the bundled interpreter; here we just attest we made it this far.
return { status: 'ok', reason: `${env.platform} libs loaded (process is running)` };
}
// Pick the module that matches the URL scheme; http rigs in tests should not
// require https, and a malformed URL should warn cleanly rather than throw.
function pickHttpModule(env, url) {
return /^https:/i.test(url) ? env.https : /^http:/i.test(url) ? env.http : null;
}
async function checkNetwork(env, opts = {}) {
const url = opts.url || 'https://api.openswarm.com/';
const timeoutMs = opts.timeoutMs || 4000;
const mod = pickHttpModule(env, url);
if (!mod) return { status: 'warn', reason: `unsupported URL scheme: ${url}` };
return await new Promise((resolve) => {
let done = false;
const finish = (v) => { if (done) return; done = true; resolve(v); };
try {
const req = mod.get(url, (res) => { res.resume(); const sc = res.statusCode; finish({ status: sc >= 500 ? 'warn' : 'ok', reason: `${url} HTTP ${sc}` }); });
req.on('error', (e) => finish({ status: 'warn', reason: `${url} error: ${String((e && e.message) || e)}` }));
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {} finish({ status: 'warn', reason: `${url} timed out at ${timeoutMs}ms` }); });
} catch (e) { finish({ status: 'warn', reason: `${url} threw: ${String(e)}` }); }
});
}
async function checkGpu(env, opts = {}) {
const app = opts.app;
if (!app || typeof app.getGPUFeatureStatus !== 'function') return { status: 'warn', reason: 'no app handle (electron not in main proc)' };
try {
const status = app.getGPUFeatureStatus() || {};
const compositing = status['compositing'] || 'unknown';
if (/disabled|software/i.test(String(compositing))) return { status: 'warn', reason: `gpu compositing=${compositing}` };
return { status: 'ok', reason: `gpu compositing=${compositing}` };
} catch (e) { return { status: 'warn', reason: `gpu probe threw: ${String(e)}` }; }
}
async function checkDualStack(env, opts = {}) {
const host = opts.host || 'api.openswarm.com';
const timeoutMs = opts.timeoutMs || 3000;
const lookup = (family) => new Promise((resolve) => {
let done = false;
const t = setTimeout(() => { if (!done) { done = true; resolve(null); } }, timeoutMs);
try {
env.dns.lookup(host, { family }, (err, addr) => { if (done) return; done = true; clearTimeout(t); resolve(err ? null : addr); });
} catch { if (!done) { done = true; clearTimeout(t); resolve(null); } }
});
const [v4, v6] = await Promise.all([lookup(4), lookup(6)]);
if (!v4 && !v6) return { status: 'warn', reason: `dns failed for both families on ${host}` };
return { status: 'ok', reason: `v4=${!!v4} v6=${!!v6}` };
}
async function checkClock(env, opts = {}) {
const url = opts.url || 'https://www.google.com';
const timeoutMs = opts.timeoutMs || 3000;
const mod = pickHttpModule(env, url);
if (!mod) return { status: 'warn', reason: `unsupported URL scheme: ${url}` };
return await new Promise((resolve) => {
let done = false;
const finish = (v) => { if (done) return; done = true; resolve(v); };
try {
const req = mod.request(url, { method: 'HEAD' }, (res) => {
const remote = res.headers['date'];
if (!remote) return finish({ status: 'warn', reason: 'no Date header on response' });
const remoteMs = Date.parse(remote);
const skewMs = Math.abs(env.now() - remoteMs);
if (skewMs > 5 * 60 * 1000) return finish({ status: 'warn', reason: `clock skew ${(skewMs / 60000).toFixed(1)}min vs ${url}` });
finish({ status: 'ok', reason: `clock skew ${skewMs}ms` });
});
req.on('error', (e) => finish({ status: 'warn', reason: `${url} error: ${String((e && e.message) || e)}` }));
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch {} finish({ status: 'warn', reason: `${url} timed out` }); });
req.end();
} catch (e) { finish({ status: 'warn', reason: `clock probe threw: ${String(e)}` }); }
});
}
// Load auto-tunings emitted by the dogfood aggregator: a check the loop has
// repeatedly seen false-positive on its platform is silently downgraded from
// fail -> warn here so a known-noisy probe can't single-handedly scare a user.
// File is bundled at build time (scripts/ci/preflight-tunings.json -> resources).
function loadTunings(env) {
const candidates = [
path.join(__dirname, '..', 'scripts', 'ci', 'preflight-tunings.json'),
path.join(process.resourcesPath || '', 'preflight-tunings.json'),
];
for (const p of candidates) {
try { return JSON.parse(env.fs.readFileSync(p, 'utf8')); } catch {}
}
return null;
}
function applyTunings(results, tunings, platform) {
if (!tunings || !Array.isArray(tunings.demote)) return results;
const demoted = new Set(tunings.demote.filter((d) => d.platform === platform).map((d) => d.check));
if (!demoted.size) return results;
return results.map((r) => (r.status === 'fail' && demoted.has(r.name)) ? { ...r, status: 'warn', reason: `${r.reason} [auto-demoted by dogfood tuning]` } : r);
}
async function run(env, opts = {}) {
env = env || defaultEnv();
const tasks = [
withTimeout('os', () => checkOs(env), 500),
withTimeout('resources', () => checkResources(env), 2000),
withTimeout('appdata-writable', () => checkAppdataWritable(env, opts.dataDir), 2000),
withTimeout('security-block', () => checkSecurityBlock(env), 2200),
withTimeout('system-libs', () => checkSystemLibs(env), 500),
withTimeout('network', () => checkNetwork(env, opts.network), 4500),
withTimeout('gpu', () => checkGpu(env, opts.gpu), 1500),
withTimeout('dual-stack', () => checkDualStack(env, opts.dualStack), 3500),
withTimeout('clock', () => checkClock(env, opts.clock), 3500),
];
const rawResults = await Promise.all(tasks);
const tunings = loadTunings(env);
const results = applyTunings(rawResults, tunings, env.platform);
const verdict = results.some((r) => r.status === 'fail') ? 'fail' : results.some((r) => r.status === 'warn') ? 'warn' : 'ok';
return { verdict, results, totalMs: Math.max(...results.map((r) => r.durationMs)), startedAt: env.now(), tuningsApplied: tunings ? tunings.demote.length : 0 };
}
function cachePath(dataDir, appVersion) { return path.join(dataDir, `preflight-${appVersion}.json`); }
function readCache(env, dataDir, appVersion) {
try {
const raw = JSON.parse(env.fs.readFileSync(cachePath(dataDir, appVersion), 'utf8'));
if (raw && raw.appVersion === appVersion && raw.verdict === 'ok') return raw;
} catch {}
return null;
}
function writeCache(env, dataDir, appVersion, payload) {
try {
env.fs.mkdirSync(dataDir, { recursive: true });
env.fs.writeFileSync(cachePath(dataDir, appVersion), JSON.stringify({ appVersion, ...payload }, null, 2));
return true;
} catch { return false; }
}
function pruneOldCaches(env, dataDir, currentVersion) {
try {
for (const f of env.fs.readdirSync(dataDir)) {
const m = /^preflight-(.+)\.json$/.exec(f);
if (m && m[1] !== currentVersion) { try { env.fs.unlinkSync(path.join(dataDir, f)); } catch {} }
}
} catch {}
}
module.exports = {
defaultEnv, withTimeout,
checkOs, checkResources, checkAppdataWritable, checkSecurityBlock, checkSystemLibs,
checkNetwork, checkGpu, checkDualStack, checkClock,
run, cachePath, readCache, writeCache, pruneOldCaches,
loadTunings, applyTunings,
};