theseus/lib/update-helper.cjs

68 lines
3.9 KiB
JavaScript
Raw Normal View History

fix(theseus/updater): run the installer only after the app has exited, via a detached batch helper with self-heal A 0.3.44 → 0.3.45 auto-update on 2026-09-11 left the install without app.asar and ffmpeg.dll ("ffmpeg.dll not found" at launch). The setup was hash-verified; the old-version uninstaller had moved the whole old install into its temp folder when both NSIS processes died ~8 s after the spawn, and the install step never wrote a file. The killer was not identified, so every overlap with the app's own lifetime is removed instead: - install-update-now no longer spawns the setup; it records the path and quits. will-quit writes <userData>\update-helper.cmd and starts it as a detached cmd.exe (verified to outlive the app; not a child of ours). - The helper waits for our PID to be gone (child powershell Wait-Process), gives Chromium's children a grace period, runs the setup directly, and runs it once more if resources\app.asar is missing afterwards — the installer is idempotent, so a second pass repairs a torn install. The helper deletes itself. - Zone.Identifier is stripped from the verified download so nothing that starts it through the shell raises a mark-of-the-web prompt. Console-less cmd.exe traps discovered and designed around (see the module): child console programs' redirected stdout is empty (no tasklist|find probing), `start /wait` on a .cmd hangs, a detached powershell.exe started straight from Node does nothing, `timeout` needs a console. Scenario tests: setup starts only after the process exits, once with app.asar present, twice without, helper gone afterwards.
2026-09-12 00:31:46 +02:00
// Builds the batch script that installs a downloaded Theseus update AFTER
// the browser has exited. Pure function, no Electron — main.js writes the
// result next to its user data and starts it detached from will-quit; the
// tests run it against a dummy process and a fake setup.
//
// Why a helper at all: on 2026-09-11 an update ran while the app was still
// shutting down. The NSIS installer's uninstall-old-version step had moved
// the whole old install into its temp folder when both NSIS processes died
// ~8 s in, and the install step never wrote a file — the user was left with
// a folder missing app.asar and ffmpeg.dll. The exact killer was never
// identified, so this removes every overlap with our own lifetime:
//
// 1. poll until our PID is gone (tasklist), then a grace period for
// Chromium's child processes to follow;
// 2. start the installer from a process that is not in any job of ours
// (a detached cmd.exe survives the app exiting — verified — whereas a
// detached powershell.exe silently does nothing without a console, and
// a non-detached child is killed with the app);
// 3. wait for the installer and, if resources\app.asar is missing from the
// install dir afterwards, run it once more — the installer is
// idempotent and a second pass repairs a torn install.
//
// Batch specifics: `timeout` refuses to run without a console, so sleeps are
// `ping -n <n+1> 127.0.0.1`; the setup is launched with `start "" /wait`
// so the script blocks until the installer exits. The script deletes itself.
function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--force-run"], graceSec = 2, maxWaitSec = 120 }) {
if (!Number.isInteger(pid) || pid <= 0) throw new Error("pid required");
if (typeof setupPath !== "string" || !setupPath || /["\r\n%]/.test(setupPath)) throw new Error("setupPath required (no quotes, percent signs or newlines)");
if (typeof installDir !== "string" || !installDir || /["\r\n%]/.test(installDir)) throw new Error("installDir required (no quotes, percent signs or newlines)");
const argStr = args.map(String).join(" ");
if (/["\r\n%]/.test(argStr)) throw new Error("installer args must not contain quotes, percent signs or newlines");
const grace = Math.max(0, graceSec | 0) + 1;
const maxIter = Math.max(1, maxWaitSec | 0);
// Everything runs inside a console-less cmd.exe, which has two traps
// (both hit while writing this): child console programs' redirected
// stdout comes back EMPTY (tasklist, wmic, even powershell — so no
// "tasklist | find" style probing), and `start /wait` is unreliable. What
// does work there: exit codes, timing, cmd-internal redirection, and a
// child powershell.exe blocking in Wait-Process. So the wait is a child
// PowerShell that returns once our PID is gone (or after maxWaitSec), the
// installer is invoked directly (from a batch file cmd.exe waits for it,
// and no ShellExecute means no mark-of-the-web prompt), and tools are
// addressed by full path so a Unix toolchain on PATH can't shadow them.
const S32 = "%SystemRoot%\\System32";
const PS = `${S32}\\WindowsPowerShell\\v1.0\\powershell.exe`;
return [
"@echo off",
"setlocal",
`set "SETUP=${setupPath}"`,
`set "ASAR=${installDir}\\resources\\app.asar"`,
`"${PS}" -NoProfile -NonInteractive -Command "Wait-Process -Id ${pid} -Timeout ${maxIter} -ErrorAction SilentlyContinue"`,
`"${S32}\\ping.exe" -n ${grace} 127.0.0.1 >nul`,
`"%SETUP%" ${argStr}`,
`if not exist "%ASAR%" (`,
` "${S32}\\ping.exe" -n 4 127.0.0.1 >nul`,
` "%SETUP%" ${argStr}`,
")",
"endlocal",
// Self-delete without cmd.exe's "The batch file cannot be found" when it
// tries to read the next line: the (goto) 2>nul idiom ends the batch
// context first, then del runs on the same line.
`(goto) 2>nul & del "%~f0"`,
"",
].join("\r\n");
}
module.exports = { buildUpdateHelperCmd };