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.
This commit is contained in:
parent
ed48646c71
commit
3c0f13c1e5
2 changed files with 99 additions and 6 deletions
67
lib/update-helper.cjs
Normal file
67
lib/update-helper.cjs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// 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 };
|
||||||
38
main.js
38
main.js
|
|
@ -2341,6 +2341,11 @@ function installDownloadTracker() {
|
||||||
}
|
}
|
||||||
updateDownloadPath = savedPath;
|
updateDownloadPath = savedPath;
|
||||||
updateDownloadState = "ready";
|
updateDownloadState = "ready";
|
||||||
|
// Drop the mark-of-the-web Chromium stamps on downloads. Our
|
||||||
|
// hash check above is the trust decision; the zone marker only
|
||||||
|
// makes Windows raise a security prompt if the file is ever
|
||||||
|
// started through the shell.
|
||||||
|
try { fs.unlinkSync(savedPath + ":Zone.Identifier"); } catch {}
|
||||||
console.log(`[update] silent fetch complete + verified: ${savedPath}`);
|
console.log(`[update] silent fetch complete + verified: ${savedPath}`);
|
||||||
emitUpdateAvailable();
|
emitUpdateAvailable();
|
||||||
});
|
});
|
||||||
|
|
@ -3628,16 +3633,37 @@ ipcMain.handle("recheck-update", async () => {
|
||||||
// commit puts --force-run back on so the auto-restart is part of the
|
// commit puts --force-run back on so the auto-restart is part of the
|
||||||
// standard flow again. --updated stays out: the earlier E2E showed it
|
// standard flow again. --updated stays out: the earlier E2E showed it
|
||||||
// wasn't load-bearing correctness for our NSIS config.
|
// wasn't load-bearing correctness for our NSIS config.
|
||||||
|
//
|
||||||
|
// 2026-09-11: an update ran while the app was still shutting down, both NSIS
|
||||||
|
// processes died ~8 s in, and the install was left without app.asar. The
|
||||||
|
// installer is no longer spawned from here: install-update-now only records
|
||||||
|
// the setup path and quits; will-quit then starts a detached batch helper
|
||||||
|
// that waits for this PID to be gone, runs the installer, and re-runs it
|
||||||
|
// once if app.asar is missing afterwards. See lib/update-helper.cjs for
|
||||||
|
// the script, the console-less cmd.exe traps, and the reasoning.
|
||||||
|
let pendingInstallerPath = null;
|
||||||
ipcMain.handle("install-update-now", () => {
|
ipcMain.handle("install-update-now", () => {
|
||||||
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
|
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
|
||||||
try {
|
pendingInstallerPath = updateDownloadPath;
|
||||||
const p = spawn(updateDownloadPath, ["/S", "--force-run"], { detached: true, stdio: "ignore" });
|
app.quit();
|
||||||
p.unref();
|
|
||||||
} catch (e) { console.warn("update spawn failed:", e?.message); return false; }
|
|
||||||
// Give the child a moment to inherit our arguments before we exit.
|
|
||||||
setTimeout(() => app.quit(), 400);
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
if (!pendingInstallerPath) return;
|
||||||
|
const setupPath = pendingInstallerPath;
|
||||||
|
pendingInstallerPath = null;
|
||||||
|
try {
|
||||||
|
const { buildUpdateHelperCmd } = require("./lib/update-helper.cjs");
|
||||||
|
const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd");
|
||||||
|
fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir: path.dirname(process.execPath) }));
|
||||||
|
// A detached cmd.exe outlives this process (verified) and is in no job
|
||||||
|
// of ours; the batch file itself waits for our PID to disappear.
|
||||||
|
const helper = spawn("cmd.exe", [`/d /c "${cmdPath}"`],
|
||||||
|
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true });
|
||||||
|
helper.unref();
|
||||||
|
console.log(`[update] helper armed for ${setupPath}`);
|
||||||
|
} catch (e) { console.warn("[update] helper spawn failed:", e?.message); }
|
||||||
|
});
|
||||||
// Home page editable cards. Origin-gated to home.html — random pages that
|
// Home page editable cards. Origin-gated to home.html — random pages that
|
||||||
// snoop the preload can't act on the local file.
|
// snoop the preload can't act on the local file.
|
||||||
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
|
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue