Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions scripts/ci/verify-installer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
// Verifies the Windows NSIS installer: default safely observes an existing install (dir, an uninstaller that exists, shortcuts); --destructive runs install->verify->uninstall but refuses to clobber an existing install unless --force (clean machine / CI).
// Verifies the Windows installer (Squirrel, per electron/package.json win.target): default safely observes an existing install (dir, an uninstaller that exists, shortcuts); --destructive runs install->verify->uninstall but refuses to clobber an existing install unless --force (clean machine / CI).
// Squirrel conventions, same as smoke-windows-packaged.yml: the setup exe lands in dist\squirrel-windows\, installs per-user into %LOCALAPPDATA%\OpenSwarm as a stub launcher beside a versioned app-<ver>\ folder, honours --silent (NOT /S, which opens a UI and waits for a click), and uninstalls through Update.exe --uninstall.

'use strict';
const fs = require('fs');
Expand All @@ -18,16 +19,31 @@ function parseArgs(argv) {
return out;
}

const INSTALL_DIR = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'OpenSwarm');
const INSTALL_DIR = path.join(process.env.LOCALAPPDATA || '', 'OpenSwarm');
const DATA_DIR = path.join(process.env.APPDATA || '', 'OpenSwarm');
const failures = [];
const ok = (m) => process.stdout.write(` ok ${m}\n`);
const bad = (m) => { failures.push(m); process.stdout.write(` FAIL ${m}\n`); };
const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } }

// electron-builder writes the Squirrel setup under dist\squirrel-windows\; the release flow copies a stable-named alias to dist\ root, so accept either.
function defaultSetup() {
const p = path.join(h.REPO_ROOT, 'electron', 'dist', 'OpenSwarm-Setup-x64.exe');
return exists(p) ? p : null;
const dist = path.join(h.REPO_ROOT, 'electron', 'dist');
for (const p of [path.join(dist, 'squirrel-windows', 'OpenSwarm-Setup-x64.exe'), path.join(dist, 'OpenSwarm-Setup-x64.exe')]) {
if (exists(p)) return p;
}
return null;
}

// The versioned exe (app-<ver>\OpenSwarm.exe) is the one that holds resources\ and is worth launching; the root OpenSwarm.exe is Squirrel's stub launcher.
function installedAppExe() {
let dirs = [];
try { dirs = fs.readdirSync(INSTALL_DIR).filter((d) => /^app-/i.test(d)).sort().reverse(); } catch { return null; }
for (const d of dirs) {
const exe = path.join(INSTALL_DIR, d, 'OpenSwarm.exe');
if (exists(exe)) return exe;
}
return null;
}

// The shipped installer must be a real, complete PE (catch a 0-byte stub or truncated upload).
Expand All @@ -43,13 +59,14 @@ function checkSetupArtifact(setup) {

function regUninstall() {
try {
const ps = `$ErrorActionPreference='SilentlyContinue'; Get-ChildItem 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' | ForEach-Object { $p=Get-ItemProperty $_.PSPath; if ($p.DisplayName -like '*OpenSwarm*') { $p.DisplayName + '|' + $p.QuietUninstallString } }`;
const ps = `$ErrorActionPreference='SilentlyContinue'; Get-ChildItem 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall' | ForEach-Object { $p=Get-ItemProperty $_.PSPath; if ($p.DisplayName -like '*OpenSwarm*') { $p.DisplayName + '|' + $p.QuietUninstallString + '|' + $p.UninstallString } }`;
return execSync(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, { encoding: 'utf8' }).trim();
} catch { return ''; }
}

// Squirrel registers `"...\\Update.exe" --uninstall` (an NSIS build registered "Uninstall OpenSwarm.exe"); accept both.
function uninstallerPathFromReg(reg) {
const m = reg.match(/"([^"]*Uninstall OpenSwarm\.exe)"/i) || reg.match(/([A-Za-z]:\\[^|]*Uninstall OpenSwarm\.exe)/i);
const m = reg.match(/"([^"]*(?:Update\.exe|Uninstall OpenSwarm\.exe))"/i) || reg.match(/([A-Za-z]:\\[^|"]*(?:Update\.exe|Uninstall OpenSwarm\.exe))/i);
return m ? m[1] : null;
}

Expand All @@ -70,8 +87,12 @@ function findShortcuts() {
function assertInstalled() {
if (!exists(INSTALL_DIR)) { bad(`install dir missing: ${INSTALL_DIR}`); return; }
ok(`install dir present: ${INSTALL_DIR}`);
for (const f of ['OpenSwarm.exe', 'resources', 'locales']) {
if (exists(path.join(INSTALL_DIR, f))) ok(`contains ${f}`); else bad(`install dir missing ${f}`);
const appExe = installedAppExe();
if (!appExe) { bad(`no app-*\\OpenSwarm.exe under ${INSTALL_DIR}`); return; }
ok(`versioned app present: ${appExe}`);
const appDir = path.dirname(appExe);
for (const f of ['resources', 'locales']) {
if (exists(path.join(appDir, f))) ok(`contains ${f}`); else bad(`app dir missing ${f}`);
}
const reg = regUninstall();
if (!/OpenSwarm/i.test(reg)) bad('no OpenSwarm uninstall entry in HKCU registry');
Expand Down Expand Up @@ -99,25 +120,25 @@ function runDestructive(setup) {
}
process.stdout.write('\n[destructive] installing silently...\n');
killRunning();
const installedExe = path.join(INSTALL_DIR, 'OpenSwarm.exe');
// Blocking: spawnSync returns only once the installer has fully finished
// (files + registry uninstall entry + shortcuts are all written, the last of
// which assertInstalled checks). The timeout is the safety net: the silent
// path now skips the unbounded --prewarm step (see installer-recovery.nsh), so
// a clean extract finishes well inside this, but if a future installer change
// reintroduces a synchronous stall this fails the gate in minutes instead of
// hanging the whole job until it is force-cancelled.
const inst = spawnSync(setup, ['/S'], { stdio: 'inherit', timeout: 300000 });
if (inst.error) bad(`Setup.exe /S did not complete: ${inst.error.message}`);
else if (inst.status !== 0 && inst.status !== null) bad(`Setup.exe /S exited ${inst.status}`);
// oneClick installer can return microseconds before the exe handle settles.
// Blocking: spawnSync returns only once the installer has fully finished (files + registry
// uninstall entry + shortcuts are all written, the last of which assertInstalled checks). The
// timeout is the safety net: a wrong switch makes Squirrel open a UI and wait forever, which
// looks like a hang, so fail in minutes with the Squirrel log instead of eating the whole job.
const inst = spawnSync(setup, ['--silent'], { stdio: 'inherit', timeout: 600000 });
if (inst.error) {
bad(`Setup.exe --silent did not complete: ${inst.error.message}`);
const log = path.join(process.env.LOCALAPPDATA || '', 'SquirrelTemp', 'SquirrelSetup.log');
if (exists(log)) process.stdout.write(`--- SquirrelSetup.log (tail) ---\n${fs.readFileSync(log, 'utf8').split(/\r?\n/).slice(-60).join('\n')}\n`);
} else if (inst.status !== 0 && inst.status !== null) bad(`Setup.exe --silent exited ${inst.status}`);
// The installer can return microseconds before the versioned exe handle settles.
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exists(installedExe)) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); }
killRunning(); // defensive: ensure nothing the installer launched is holding the dir
while (Date.now() < deadline && !installedAppExe()) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); }
killRunning(); // defensive: Squirrel launches the app after install; nothing may hold the dir
assertInstalled();

// Launch the INSTALLED exe through the full gate (boot/serve/provenance/etc.).
if (exists(installedExe)) {
// Launch the INSTALLED (versioned) exe through the full gate (boot/serve/provenance/etc.).
const installedExe = installedAppExe();
if (installedExe) {
process.stdout.write('\n[destructive] verifying the INSTALLED app...\n');
const v = spawnSync(process.execPath, [path.join(__dirname, 'verify-all.js'), '--app', installedExe], { stdio: 'inherit' });
if (v.status !== 0) bad('verify-all against the installed app failed');
Expand All @@ -127,11 +148,14 @@ function runDestructive(setup) {
killRunning();
const reg = regUninstall();
const up = uninstallerPathFromReg(reg);
if (up && exists(up)) { const un = spawnSync(up, ['/currentuser', '/S'], { stdio: 'inherit' }); if (un.status) bad(`uninstaller exited ${un.status}`); }
else bad('could not find the uninstaller to run');
if (up && exists(up)) {
const args = /Update\.exe$/i.test(up) ? ['--uninstall'] : ['/currentuser', '/S'];
const un = spawnSync(up, args, { stdio: 'inherit', timeout: 300000 });
if (un.status) bad(`uninstaller exited ${un.status}`);
} else bad('could not find the uninstaller to run');
const delDeadline = Date.now() + 60000;
while (Date.now() < delDeadline && exists(path.join(INSTALL_DIR, 'OpenSwarm.exe'))) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); }
if (exists(path.join(INSTALL_DIR, 'OpenSwarm.exe'))) bad('uninstall left OpenSwarm.exe behind');
while (Date.now() < delDeadline && installedAppExe()) { execSync('powershell -NoProfile -Command "Start-Sleep -Milliseconds 1000"'); }
if (installedAppExe()) bad('uninstall left a versioned app folder behind');
else ok('uninstall removed the app');
// deleteAppDataOnUninstall:false - user data MUST survive an uninstall.
if (exists(DATA_DIR)) ok(`user data preserved across uninstall: ${DATA_DIR}`);
Expand All @@ -140,7 +164,7 @@ function runDestructive(setup) {

function main() {
const args = parseArgs(process.argv.slice(2));
if (process.platform !== 'win32') { process.stdout.write('SKIP: NSIS installer check is Windows-only.\n'); process.exit(0); }
if (process.platform !== 'win32') { process.stdout.write('SKIP: Windows installer check is Windows-only.\n'); process.exit(0); }
const setup = args.setup || defaultSetup();
if (!setup) { process.stderr.write('INSTALLER FAIL: no Setup.exe found; build first or pass --setup.\n'); process.exit(1); }

Expand Down