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"),