diff --git a/chrome.html b/chrome.html
index 056870a..4912165 100644
--- a/chrome.html
+++ b/chrome.html
@@ -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());
diff --git a/main.js b/main.js
index b3af882..a9fcb87 100644
--- a/main.js
+++ b/main.js
@@ -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() : []);
diff --git a/package.json b/package.json
index 7af2074..77482fd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/preload.js b/preload.js
index 770af42..47eeb00 100644
--- a/preload.js
+++ b/preload.js
@@ -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"),