diff --git a/lib/update-helper.cjs b/lib/update-helper.cjs new file mode 100644 index 0000000..dd9f44e --- /dev/null +++ b/lib/update-helper.cjs @@ -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 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 }; diff --git a/main.js b/main.js index 66a46e2..c4bd453 100644 --- a/main.js +++ b/main.js @@ -2341,6 +2341,11 @@ function installDownloadTracker() { } updateDownloadPath = savedPath; 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}`); emitUpdateAvailable(); }); @@ -3628,16 +3633,37 @@ ipcMain.handle("recheck-update", async () => { // 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 // 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", () => { if (updateDownloadState !== "ready" || !updateDownloadPath) return false; - try { - const p = spawn(updateDownloadPath, ["/S", "--force-run"], { detached: true, stdio: "ignore" }); - 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); + pendingInstallerPath = updateDownloadPath; + app.quit(); 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 // snoop the preload can't act on the local file. ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);