Ship Theseus 0.3.1 fe59105d (one-click updates: silent prefetch + install-and-restart)

Setup    fe59105d2e99a41b7000caeb86601a8e1675846d193e92204034669f5b368d60
Portable 1b6eda55b53894cf9889548116c7b6100888fb160edc84cc1592bb79f9d95b53

The update flow no longer asks the user to click Download. When
checkForUpdate detects a newer version, autoDownloadUpdate() kicks off
session.defaultSession.downloadURL against the setup URL immediately.
will-download recognises the update URL and routes the file to a
fixed %TEMP% path (bypassing the visible downloads panel entirely),
streams updateDownloadReceived/Total into the chip via
emitUpdateAvailable, and flips updateDownloadState to "ready" when
the transfer finishes.

Chip states:
  idle         first render before the fetch starts — clickable to
               trigger the manual download (kept as a fallback).
  downloading  "↓ 42% — 0.3.2" — no click, just progress.
  ready        "✓ Install 0.3.2 & restart" — one click.
  failed       fall back to the pre-0.3.1 explicit-download click.

install-update-now IPC: spawns the cached setup with /S (detached,
stdio ignored), then app.quit() 400ms later so the installer can
overwrite the running exe. Our nsis/installer.nsh detects an existing
Ariadne install via the HKLM registry and skips its Ariadne prompt on
upgrades, so the /S run is fully unattended.

The one-click flow eliminates two long-standing sources of confusion:
  - "Download opens a different browser" — Theseus's default session
    fetches the installer itself, not a URL handoff to shell.
  - "Update requires multiple wizard clicks" — /S skips them.

Extensions aren't touched by this. The framework lives in
addons-host.js + sidebar-preload.js; add-ons themselves live in
%APPDATA%\Theseus Navigator\addons\<id>\ and are a separate layer.
New extensions ship by drop-a-folder, no browser release required.

Deployed: scp + sia-upload, verified 200 + 0.3.1 in the manifest.
This commit is contained in:
Local Dev 2026-08-31 18:47:50 +02:00
parent 5d248541cf
commit 19ffde3bfa
4 changed files with 108 additions and 10 deletions

View file

@ -440,14 +440,30 @@
const chip = $("upchip"); if (!chip) return;
if (!d) { chip.hidden = true; return; }
chip.hidden = false;
// Prefer the installer URL when both are available — that's the flow
// that upgrades in place. Users on the portable can right-click the
// chip and choose the portable URL from the shell menu (future).
const url = d.setupUrl || d.portableUrl || "";
const core = $("upDownload");
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Open ${url}` : "Download the new version";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
// State machine: idle (nothing yet) -> downloading (silent fetch running,
// show percent) -> ready (chip becomes "Install & restart, one click") ->
// failed (fall back to explicit user-triggered download).
const state = d.downloadState || "idle";
if (state === "downloading") {
const pct = d.downloadTotal ? Math.floor((d.downloadReceived / d.downloadTotal) * 100) : null;
core.textContent = pct != null ? `↓ ${pct}% — ${d.version}` : `↓ Downloading ${d.version}…`;
core.title = "Downloading update in the background";
core.onclick = () => {};
} else if (state === "ready") {
core.textContent = `✓ Install ${d.version} & restart`;
core.title = "One-click install: launches the installer silently and restarts Theseus";
core.onclick = () => T.installUpdateNow && T.installUpdateNow();
} else if (state === "failed") {
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Retry download — ${url}` : "Retry download";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
} else {
core.textContent = `↓ Update to ${d.version}`;
core.title = url ? `Download ${url}` : "Download the new version";
core.onclick = () => { if (url) T.openUpdateDownload(url); };
}
});
$("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate());

87
main.js
View file

@ -299,13 +299,13 @@ async function checkForUpdate() {
if (!versionIsNewer(rel.version, app.getVersion())) {
// Same version or older — nothing to offer. Clear any stale state so the
// chip disappears after the user has updated + relaunched.
if (updateAvailable) { updateAvailable = null; emitUpdateAvailable(); }
if (updateAvailable) { updateAvailable = null; updateDownloadState = "idle"; updateDownloadPath = null; emitUpdateAvailable(); }
return;
}
const files = rel.files || {};
const setupFile = Object.keys(files).find((k) => /Setup/i.test(k));
const portableFile = Object.keys(files).find((k) => /portable/i.test(k));
updateAvailable = {
const nextAvailable = {
version: rel.version,
date: rel.date || "",
setupUrl: setupFile ? UPDATE_DOWNLOAD_BASE + setupFile : null,
@ -313,11 +313,48 @@ async function checkForUpdate() {
setupHash: setupFile ? files[setupFile] : null,
portableHash: portableFile ? files[portableFile] : null,
};
const versionChanged = !updateAvailable || updateAvailable.version !== nextAvailable.version;
updateAvailable = nextAvailable;
if (versionChanged) {
// New candidate — reset any prior download state and kick off a fresh
// silent background fetch so the chip lands as "ready to install".
updateDownloadState = "idle";
updateDownloadPath = null;
autoDownloadUpdate();
}
emitUpdateAvailable();
} catch { /* offline / manifest unreachable — silent */ }
}
// Silent background pre-download of the update installer. The user never
// has to click Download — clicking the chip goes straight to Install.
// State machine: idle -> downloading -> ready | failed.
let updateDownloadState = "idle";
let updateDownloadPath = null; // path on disk once "ready"
let updateDownloadReceived = 0; // bytes so far
let updateDownloadTotal = 0; // total bytes
function autoDownloadUpdate() {
if (!updateAvailable || !updateAvailable.setupUrl) return;
if (updateDownloadState !== "idle") return;
updateDownloadState = "downloading";
updateDownloadReceived = 0;
updateDownloadTotal = 0;
try {
session.defaultSession.downloadURL(updateAvailable.setupUrl);
console.log(`[update] silent fetch started: ${updateAvailable.setupUrl}`);
} catch (e) {
console.warn("update prefetch failed:", e?.message);
updateDownloadState = "failed";
}
emitUpdateAvailable();
}
function emitUpdateAvailable() {
const payload = (updateDismissedThisSession || !updateAvailable) ? null : updateAvailable;
const base = (updateDismissedThisSession || !updateAvailable) ? null : updateAvailable;
const payload = base ? {
...base,
downloadState: updateDownloadState, // idle | downloading | ready | failed
downloadReceived: updateDownloadReceived,
downloadTotal: updateDownloadTotal,
} : null;
try { chrome?.webContents.send("update-available", payload); } catch {}
}
@ -1557,6 +1594,34 @@ function emitDownloads() {
// attachments, and manual save-as gestures).
function installDownloadTracker() {
session.defaultSession.on("will-download", (_e, item /*, wc */) => {
const url = item.getURL();
// Update installer? Route it to a fixed temp path, keep it out of the
// visible downloads list, drive updateDownloadState instead so the chip
// can show "ready to install" and one-click install-and-restart.
const isUpdate = updateAvailable && (url === updateAvailable.setupUrl || url === updateAvailable.portableUrl);
if (isUpdate) {
const dst = path.join(app.getPath("temp"), item.getFilename());
try { item.setSavePath(dst); } catch {}
updateDownloadTotal = item.getTotalBytes() || 0;
updateDownloadReceived = 0;
item.on("updated", () => {
updateDownloadReceived = item.getReceivedBytes();
updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal;
emitUpdateAvailable();
});
item.once("done", (_ev, state) => {
if (state === "completed") {
updateDownloadPath = item.getSavePath() || dst;
updateDownloadState = "ready";
console.log(`[update] silent fetch complete: ${updateDownloadPath}`);
} else {
updateDownloadState = "failed";
console.warn(`[update] silent fetch ${state}`);
}
emitUpdateAvailable();
});
return;
}
const id = nextDlId++;
const rec = {
id,
@ -2205,6 +2270,22 @@ ipcMain.handle("open-update-download", (_e, url) => {
});
ipcMain.handle("dismiss-update", () => { updateDismissedThisSession = true; emitUpdateAvailable(); return true; });
ipcMain.handle("recheck-update", async () => { await checkForUpdate(); return updateAvailable; });
// One-click "Install & restart". Requires the silent pre-fetch to have
// finished (updateDownloadState === "ready"). Launches the setup with /S
// (skips the wizard; our nsis/installer.nsh's Ariadne prompt is bypassed
// too on upgrades because the Ariadne registry key is already present),
// then quits Theseus so the installer can overwrite it. When the installer
// finishes, the user re-launches Theseus and lands on the new version.
ipcMain.handle("install-update-now", () => {
if (updateDownloadState !== "ready" || !updateDownloadPath) return false;
try {
const p = spawn(updateDownloadPath, ["/S"], { 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);
return true;
});
// 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() : []);

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.0",
"version": "0.3.1",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",

View file

@ -16,6 +16,7 @@ contextBridge.exposeInMainWorld("theseus", {
// the current session.
onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)),
openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),
goHome: () => ipcRenderer.invoke("go-home"),
back: () => ipcRenderer.invoke("go-back"),