From 5153261d16ab5f08d059bcb0b92d6de1ba8eb016 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Tue, 1 Sep 2026 11:49:55 +1000 Subject: [PATCH 1/5] feat(updater): persist pending update across restarts (#254) Add a pending-update.json marker in userData so a downloaded update survives closing and reopening SnapDock. The update is written when electron-updater finishes downloading and cleared on install. The renderer can query the persisted state at startup via update:pending so a previously-downloaded update is recognized without re-downloading. --- src/modules/updater/download.js | 9 +++- src/modules/updater/index.js | 8 ++++ src/modules/updater/pendingUpdate.js | 72 ++++++++++++++++++++++++++++ src/preload.js | 3 ++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/modules/updater/pendingUpdate.js diff --git a/src/modules/updater/download.js b/src/modules/updater/download.js index 7ae2219..fc1119c 100644 --- a/src/modules/updater/download.js +++ b/src/modules/updater/download.js @@ -1,6 +1,7 @@ // src/modules/updater/download.js const { autoUpdater } = require("electron-updater"); const log = require("electron-log"); +const { savePendingUpdate, clearPendingUpdate } = require("./pendingUpdate"); // Logging autoUpdater.logger = log; @@ -44,6 +45,7 @@ async function downloadUpdate() { // ----------------------------- function installUpdate() { try { + clearPendingUpdate(); autoUpdater.quitAndInstall(); } catch (err) { log.error("[updater] Failed to install update:", err); @@ -66,7 +68,12 @@ function onProgress(cb) { } function onReady(cb) { - autoUpdater.on("update-downloaded", info => cb(info)); + autoUpdater.on("update-downloaded", info => { + // Persist the pending update so it survives app restarts. It remains + // pending until successfully installed (see installUpdate). + savePendingUpdate({ version: info.version, currentVersion: autoUpdater.currentVersion.version }); + cb(info); + }); } function onError(cb) { diff --git a/src/modules/updater/index.js b/src/modules/updater/index.js index 5544a9e..1d59330 100644 --- a/src/modules/updater/index.js +++ b/src/modules/updater/index.js @@ -2,6 +2,7 @@ const { ipcMain } = require("electron"); const { getInstallSource } = require("./detectSource"); const updater = require("./download"); +const { getPendingUpdate } = require("./pendingUpdate"); module.exports = function setupUpdater(mainWindow) { @@ -15,6 +16,13 @@ module.exports = function setupUpdater(mainWindow) { return source; }); + // ----------------------------- + // Expose any persisted pending update + // ----------------------------- + ipcMain.handle("update:pending", () => { + return getPendingUpdate(); + }); + // ----------------------------- // Store builds → disable updater // ----------------------------- diff --git a/src/modules/updater/pendingUpdate.js b/src/modules/updater/pendingUpdate.js new file mode 100644 index 0000000..970b85e --- /dev/null +++ b/src/modules/updater/pendingUpdate.js @@ -0,0 +1,72 @@ +// src/modules/updater/pendingUpdate.js +// +// Persists the state of a downloaded-but-not-yet-applied update to disk +// so that it survives app restarts. Without this, a downloaded update is +// lost when the user closes SnapDock and must be re-downloaded. + +const { app } = require("electron"); +const fs = require("fs"); +const path = require("path"); +const log = require("electron-log"); + +const FILE_NAME = "pending-update.json"; + +function getFilePath() { + return path.join(app.getPath("userData"), FILE_NAME); +} + +/** + * Write a pending update marker to disk. + * Called after electron-updater finishes downloading an update. + */ +function savePendingUpdate({ version, currentVersion }) { + try { + const data = JSON.stringify({ + version, + currentVersion, + downloadedAt: new Date().toISOString(), + }); + fs.writeFileSync(getFilePath(), data, "utf8"); + log.info(`[updater] Pending update saved: ${version}`); + } catch (err) { + log.error("[updater] Failed to save pending update:", err); + } +} + +/** + * Read the pending update marker. Returns null if none exists or the + * file is invalid. + */ +function getPendingUpdate() { + try { + const filePath = getFilePath(); + if (!fs.existsSync(filePath)) return null; + + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + + if (!data || typeof data.version !== "string") return null; + return data; + } catch (err) { + log.warn("[updater] Failed to read pending update:", err); + return null; + } +} + +/** + * Remove the pending update marker from disk. + * Called after a successful install or when the user explicitly dismisses it. + */ +function clearPendingUpdate() { + try { + const filePath = getFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + log.info("[updater] Pending update cleared"); + } + } catch (err) { + log.error("[updater] Failed to clear pending update:", err); + } +} + +module.exports = { savePendingUpdate, getPendingUpdate, clearPendingUpdate }; diff --git a/src/preload.js b/src/preload.js index e54ee77..7b1600b 100644 --- a/src/preload.js +++ b/src/preload.js @@ -81,6 +81,9 @@ contextBridge.exposeInMainWorld("electronAPI", { checkForUpdates: () => ipcRenderer.invoke("update:check"), + getPendingUpdate: () => + ipcRenderer.invoke("update:pending"), + downloadUpdate: () => ipcRenderer.invoke("update:download"), From fccb01e849cb738d0c4226e8805d35077c1a35d9 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Tue, 1 Sep 2026 11:52:34 +1000 Subject: [PATCH 2/5] feat(updater): centralize update UI state machine (#254) Drive the Tools->Update button and footer indicator from a single updater state (idle/available/checking/downloading/ready/installing/upToDate/ error/disabled) so they never drift out of sync. The state machine also: - Recognizes a persisted pending update at startup and surfaces the ready state immediately without re-downloading. - Adds an explicit 'available' state distinct from 'ready' (downloaded), so a newly-detected update is not incorrectly labeled as installable. - Wires the footer indicator to download (available) or install (ready) on click, giving a second apply path beyond the Tools menu. - Adds CSS modifiers for every state (header.css + footer.css) and removes dead #update styles in modal.css that targeted a nonexistent ID. --- src/modules/ui/dropdownMenus.js | 175 ++++++++++++++++++++++++------- src/styles/components/footer.css | 41 ++++++++ src/styles/components/header.css | 31 ++++++ src/styles/components/modal.css | 38 ------- 4 files changed, 208 insertions(+), 77 deletions(-) diff --git a/src/modules/ui/dropdownMenus.js b/src/modules/ui/dropdownMenus.js index f2e16ec..7ebc535 100644 --- a/src/modules/ui/dropdownMenus.js +++ b/src/modules/ui/dropdownMenus.js @@ -143,76 +143,173 @@ function initSpellcheckButton(btn) { }); } -function initUpdateButton(btn) { - // Check on launch - checkForUpdatesOnLaunch(btn); - - // Manual check - btn.addEventListener("click", async () => { - btn.disabled = true; - btn.textContent = "Checking..."; - setFooterStatus("Checking for updates…"); +// Updater state machine. +// +// All update UI is driven from a single `state` value below, so the Tools +// menu button and the footer indicator never drift out of sync. Each state +// maps to a button label, footer text, and optional CSS modifiers. +const UPDATE_STATES = { + idle: { btn: "Update", footer: "" }, + available: { btn: "Update Available", footer: "Update available" }, + checking: { btn: "Checking...", footer: "Checking for updates…" }, + downloading: { btn: "Downloading…", footer: "Downloading update…" }, + ready: { btn: "Restart to Update", footer: "Update ready — restart to apply" }, + installing: { btn: "Installing…", footer: "Installing update…" }, + upToDate: { btn: "No Updates", footer: "Up to date ✓" }, + error: { btn: "Update Failed", footer: "Update failed" }, + disabled: { btn: "Update (managed by store)", footer: "Updates are managed by your app store" }, +}; - const result = await window.electronAPI.checkForUpdates(); +function initUpdateButton(btn) { + let state = "idle"; + + // Apply the given state to both button and footer from one source of truth. + const applyState = (next, extra) => { + state = next; + const spec = UPDATE_STATES[next]; + + btn.textContent = spec.btn; + btn.disabled = next === "checking" || next === "downloading"; + + // Wire the button action based on state. + btn.onclick = null; + if (next === "ready" || next === "installing") { + btn.onclick = () => { + applyState("installing", "BEFORE_QUIT"); + window.electronAPI.installUpdate(); + }; + } else if (next === "available") { + btn.onclick = async () => { + applyState("downloading"); + await window.electronAPI.downloadUpdate(); + }; + } else if (next !== "checking" && next !== "downloading") { + btn.onclick = () => manualCheck(btn, applyState); + } - if (!result || !result.updateAvailable) { - btn.textContent = "No Updates"; - setFooterStatus("Up to date ✓"); - setTimeout(() => { - btn.textContent = "Update"; - btn.disabled = false; - setFooterStatus(""); - }, 2500); - return; + // Sync button modifier classes (used by header.css). + btn.classList.remove( + "update-available", + "update-checking", + "update-downloading", + "update-ready", + "update-installing", + "update-error", + "update-disabled" + ); + if (next === "available") btn.classList.add("update-available"); + else if (next === "checking") btn.classList.add("update-checking"); + else if (next === "downloading") btn.classList.add("update-downloading"); + else if (next === "ready") btn.classList.add("update-ready"); + else if (next === "installing") btn.classList.add("update-installing"); + else if (next === "error") btn.classList.add("update-error"); + else if (next === "disabled") btn.classList.add("update-disabled"); + + // Update footer indicator. Browser window always shows a raw status; when + // the state is "ready" we also make the footer clickable to apply. + let footerText = spec.footer; + if (next === "downloading" && extra) { + footerText = `Downloading update… ${extra}%`; + btn.textContent = `Downloading ${extra}%`; } + setFooterStatus(footerText, next, () => { + if (state === "ready" || state === "installing") { + applyState("installing", "BEFORE_QUIT"); + window.electronAPI.installUpdate(); + } else if (state === "available") { + applyState("downloading"); + window.electronAPI.downloadUpdate(); + } + }); + }; - btn.textContent = "Downloading..."; - setFooterStatus("Downloading update…"); - await window.electronAPI.downloadUpdate(); + // Startup: if a download was left pending from a previous session, surface + // the ready state immediately instead of requiring a re-download or a + // specific Tools->Update sequence. + window.electronAPI.getPendingUpdate().then((pending) => { + if (pending && pending.version) { + applyState("ready"); + } else { + checkForUpdatesOnLaunch(btn, applyState); + } }); // Progress window.electronAPI.onUpdateProgress((progress) => { - const pct = Math.floor(progress.percent); - btn.textContent = `Downloading ${pct}%`; - setFooterStatus(`Downloading update… ${pct}%`); + applyState("downloading", Math.floor(progress.percent)); }); // Ready window.electronAPI.onUpdateReady(() => { - btn.textContent = "Restart to Update"; - btn.disabled = false; - btn.onclick = () => window.electronAPI.installUpdate(); - setFooterStatus("Update ready — restart to apply", "ready"); + applyState("ready"); }); // Error window.electronAPI.onUpdateError((err) => { - btn.textContent = "Update Failed"; - setFooterStatus("Update failed"); + applyState("error"); console.error("Update error:", err); }); + + // No update found (either on launch check or after a manual check). + window.electronAPI.onUpdateNone(() => { + if (state !== "downloading" && state !== "ready") { + applyState("upToDate"); + setTimeout(() => applyState("idle"), 2500); + } + }); + + // Guard against timeout/double-click by keeping a disabled state while the + // manual check is in flight; re-enable via applyState. + async function manualCheck(btn, apply) { + apply("checking"); + const result = await window.electronAPI.checkForUpdates(); + if (!result) return; + + if (result.disabled) { + apply("disabled"); + setTimeout(() => apply("idle"), 2500); + return; + } + + if (!result.updateAvailable) { + apply("upToDate"); + setTimeout(() => apply("idle"), 2500); + return; + } + + apply("downloading"); + await window.electronAPI.downloadUpdate(); + } } -async function checkForUpdatesOnLaunch(btn) { +async function checkForUpdatesOnLaunch(btn, applyState) { const result = await window.electronAPI.checkForUpdates(); - if (result?.updateAvailable) { - btn.classList.add("update-available"); - btn.textContent = "Update Available"; - setFooterStatus("Update available", "ready"); + if (!result || result.disabled) { + applyState("disabled"); + setTimeout(() => applyState("idle"), 5000); + return; + } + if (result.updateAvailable) { + applyState("available"); } } /** * Mirror update status into the footer bar. - * @param {string} text – status text (empty string to clear) - * @param {string} [state] – optional CSS modifier: "ready" | "error" + * @param {string} text – status text (empty string to clear) + * @param {string} [state] – optional updater state ("ready", "error", ...) + * @param {Function} [onClick] – optional click handler (e.g. apply update) */ -function setFooterStatus(text, state) { +function setFooterStatus(text, state, onClick) { const el = document.getElementById("updateStatus"); if (!el) return; el.textContent = text; el.className = "update-status" + (state ? ` update-status--${state}` : ""); + + // Make the footer indicator clickable when an action is available (e.g. + // "ready" -> apply update). Avoids relying on the Tools->Update route. + el.onclick = onClick || null; + el.classList.toggle("update-status--clickable", !!onClick); } // ─── Helpers ─────────────────────────────────────────────────── diff --git a/src/styles/components/footer.css b/src/styles/components/footer.css index cf9a193..1a4c0eb 100644 --- a/src/styles/components/footer.css +++ b/src/styles/components/footer.css @@ -73,8 +73,49 @@ display: none; } +/* Available — an update exists but hasn't been downloaded. */ +.update-status--available { + color: var(--tab-accent); + opacity: 1; + font-weight: 600; + cursor: pointer; +} + +/* Ready to install — emphasise and make clickable to apply the update. */ .update-status--ready { color: var(--tab-accent); opacity: 1; font-weight: 600; + cursor: pointer; +} + +/* Downloading — show active progress colour. */ +.update-status--downloading { + color: #1e90ff; + opacity: 1; +} + +/* Checking / disabled — dim slightly. */ +.update-status--checking, +.update-status--disabled { + opacity: 0.6; +} + +/* Error — error colour. */ +.update-status--error { + color: #d9534f; + opacity: 1; + font-weight: 600; +} + +/* Up to date / installing states. */ +.update-status--upToDate, +.update-status--installing { + opacity: 1; +} + +/* Hover affordance on clickable states (ready). */ +.update-status--clickable:hover { + text-decoration: underline; + opacity: 1; } diff --git a/src/styles/components/header.css b/src/styles/components/header.css index d5af4f4..546c7c0 100644 --- a/src/styles/components/header.css +++ b/src/styles/components/header.css @@ -312,7 +312,38 @@ body.dark-theme .title-row { } /* ─── Update button states ─── */ +/* Each updater state maps to a distinct highlight so the user can tell at a + glance what the updater is doing, rather than relying on button text alone. + (See dropdownMenus.js UPDATE_STATES for the driving state machine.) */ + +#updateBtn { + color: var(--tab-text); +} + #updateBtn.update-available { color: var(--tab-accent); font-weight: 600; } + +#updateBtn.update-ready, +#updateBtn.update-installing { + color: var(--tab-accent); + font-weight: 600; +} + +#updateBtn.update-downloading { + color: #1e90ff; /* visible against both light and dark themes */ +} + +#updateBtn.update-checking { + opacity: 0.6; +} + +#updateBtn.update-error { + color: #d9534f; + font-weight: 600; +} + +#updateBtn.update-disabled { + opacity: 0.6; +} diff --git a/src/styles/components/modal.css b/src/styles/components/modal.css index c57ff73..afbbdf8 100644 --- a/src/styles/components/modal.css +++ b/src/styles/components/modal.css @@ -62,41 +62,3 @@ .modal-close-float:hover { background: var(--hover-bg, rgba(128, 128, 128, 0.15)); } - -/* ============================ - SnapDock Update System Styles - ============================ */ - -#update { - margin-right: 40px; - transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease; -} - -/* When an update is available */ -#update.update-available { - background-color: #ffcc00; - color: #222; - border-color: #e0b200; - font-weight: 600; -} - -/* Downloading state */ -#update.downloading { - background-color: #0078d4; - color: #fff; - border-color: #005a9e; -} - -/* Restart to update */ -#update.restart-ready { - background-color: #28a745; - color: #fff; - border-color: #1e7e34; -} - -/* Error state */ -#update.update-error { - background-color: #d9534f; - color: #fff; - border-color: #b52b27; -} From e59bbaeab26cb9e72a052f96525bd8226fa6324d Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Tue, 1 Sep 2026 11:53:39 +1000 Subject: [PATCH 3/5] feat(updater): apply pending update on app close (#254) Closing SnapDock while an update is downloaded and pending now applies it, making the long-standing 'close to update' prompt truthful. finishWindowClose checks for a persisted pending update first and routes through quitAndInstall() so the update is applied after unsaved changes are resolved. setupUpdater now returns a handle exposing hasPendingUpdate()/applyPendingUpdate() so the main process can drive update application independently of the UI path. Combined with the clickable footer and Tools menu action, a downloaded update can now be applied via several equivalent exit/restart routes. --- main.js | 14 +++++++++++++- src/modules/updater/index.js | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/main.js b/main.js index 60bc87f..6b8e9ec 100644 --- a/main.js +++ b/main.js @@ -39,6 +39,9 @@ let currentWorkspacePath = null; let mainWindow; let forceClose = false; let relaunchAfterClose = false; +// Handle to the updater API returned by setupUpdater(); used to apply a +// pending update when the user closes or restarts SnapDock. +let updater = null; /** * FIX 241: Request dirty state from renderer with a timeout. @@ -115,6 +118,15 @@ function applySpellcheckState(enabled, targetWindow = mainWindow) { } function finishWindowClose() { + // If a downloaded update is pending, apply it now rather than doing a + // plain close. This gives the "close SnapDock to update" workflow a real + // implementation (previously it was a no-op). quitAndInstall() quits the + // app and runs the installer to apply + relaunch the new version. + if (updater && updater.hasPendingUpdate()) { + updater.applyPendingUpdate(); + return; + } + if (relaunchAfterClose) { ipcMain.once("workspace:clear-for-close:result", () => { app.relaunch(); @@ -285,7 +297,7 @@ function createWindow() { finishWindowClose(); } }); - setupUpdater(mainWindow); + updater = setupUpdater(mainWindow); mainWindow.loadFile("index.html"); diff --git a/src/modules/updater/index.js b/src/modules/updater/index.js index 1d59330..bc861ef 100644 --- a/src/modules/updater/index.js +++ b/src/modules/updater/index.js @@ -2,7 +2,7 @@ const { ipcMain } = require("electron"); const { getInstallSource } = require("./detectSource"); const updater = require("./download"); -const { getPendingUpdate } = require("./pendingUpdate"); +const { getPendingUpdate, clearPendingUpdate } = require("./pendingUpdate"); module.exports = function setupUpdater(mainWindow) { @@ -45,7 +45,8 @@ module.exports = function setupUpdater(mainWindow) { error: "Updates disabled for this install source." })); - return; // Do NOT wire autoUpdater events + // Store installs never have a pending update to apply on close. + return { hasPendingUpdate: () => false }; } // ----------------------------- @@ -77,4 +78,18 @@ module.exports = function setupUpdater(mainWindow) { updater.onError(err => { mainWindow.webContents.send("update:error", err.message); }); + + // ----------------------------- + // API consumed by main process for + // close/restart driven update applies + // ----------------------------- + return { + // True when a downloaded update is persisted as pending and will be + // applied if the user quits or restarts. + hasPendingUpdate: () => !!getPendingUpdate(), + // Apply a persisted pending update immediately (used on close). + applyPendingUpdate: () => { + updater.installUpdate(); + } + }; }; From 7c929ca31a8a3389730c3bd0af8c0cb38c5ef97a Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Tue, 1 Sep 2026 11:55:27 +1000 Subject: [PATCH 4/5] feat(updater): recover from interrupted installs + explicit publish config (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovery: - pendingUpdate.resolvePendingUpdate() compares the running version against the persisted pending marker on startup. If the app now runs the pending version, the update applied → marker cleared. If it is still on the old version, the update remains ready to apply. If neither matches, the install was interrupted → the stale marker is cleared and the UI surfaces a recovery message. - installUpdate no longer clears the marker before quitAndInstall() so the marker survives the install and lets us detect success/failure on next launch (previously it was cleared optimistically). Config: - Add an explicit github publish block (owner/repo) to electron-builder so the update feed is not resolved implicitly from repository.url, removing reliance on a specific/assumed update path. --- package.json | 5 +++ src/modules/ui/dropdownMenus.js | 13 +++++--- src/modules/updater/download.js | 7 +++-- src/modules/updater/index.js | 19 +++++++---- src/modules/updater/pendingUpdate.js | 47 +++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index e36fde3..37cd510 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,11 @@ "snap" ] }, + "publish": { + "provider": "github", + "owner": "ZFordDev", + "repo": "SnapDock" + }, "mac": { "category": "public.app-category.productivity", "icon": "assets/icon.icns", diff --git a/src/modules/ui/dropdownMenus.js b/src/modules/ui/dropdownMenus.js index 7ebc535..3f15402 100644 --- a/src/modules/ui/dropdownMenus.js +++ b/src/modules/ui/dropdownMenus.js @@ -223,12 +223,17 @@ function initUpdateButton(btn) { }); }; - // Startup: if a download was left pending from a previous session, surface - // the ready state immediately instead of requiring a re-download or a - // specific Tools->Update sequence. + // Startup: check if a download was left pending from a previous session. + // The main process resolves it against the running version (see + // pendingUpdate.resolvePendingUpdate) so we know whether it is ready to + // apply, was already applied, or is stale after an interrupted install. window.electronAPI.getPendingUpdate().then((pending) => { - if (pending && pending.version) { + if (pending && pending.status === "ready") { applyState("ready"); + } else if (pending && pending.status === "stale") { + applyState("error"); + setFooterStatus("Previous update was interrupted — check for updates", "error"); + setTimeout(() => applyState("idle"), 5000); } else { checkForUpdatesOnLaunch(btn, applyState); } diff --git a/src/modules/updater/download.js b/src/modules/updater/download.js index fc1119c..d9d42e3 100644 --- a/src/modules/updater/download.js +++ b/src/modules/updater/download.js @@ -1,7 +1,7 @@ // src/modules/updater/download.js const { autoUpdater } = require("electron-updater"); const log = require("electron-log"); -const { savePendingUpdate, clearPendingUpdate } = require("./pendingUpdate"); +const { savePendingUpdate } = require("./pendingUpdate"); // Logging autoUpdater.logger = log; @@ -45,7 +45,10 @@ async function downloadUpdate() { // ----------------------------- function installUpdate() { try { - clearPendingUpdate(); + // NOTE: we intentionally do NOT clear the pending marker here. Leaving it + // in place lets us detect on next launch whether the install actually + // applied (see resolvePendingUpdate in pendingUpdate.js). The marker is + // cleared once the new version is confirmed running. autoUpdater.quitAndInstall(); } catch (err) { log.error("[updater] Failed to install update:", err); diff --git a/src/modules/updater/index.js b/src/modules/updater/index.js index bc861ef..c1cca94 100644 --- a/src/modules/updater/index.js +++ b/src/modules/updater/index.js @@ -1,8 +1,8 @@ // src/modules/updater/index.js -const { ipcMain } = require("electron"); +const { ipcMain, app } = require("electron"); const { getInstallSource } = require("./detectSource"); const updater = require("./download"); -const { getPendingUpdate, clearPendingUpdate } = require("./pendingUpdate"); +const { resolvePendingUpdate, getPendingUpdate } = require("./pendingUpdate"); module.exports = function setupUpdater(mainWindow) { @@ -20,7 +20,11 @@ module.exports = function setupUpdater(mainWindow) { // Expose any persisted pending update // ----------------------------- ipcMain.handle("update:pending", () => { - return getPendingUpdate(); + // Resolving here also clears stale/applied markers and surfaces recovery + // info (ready vs applied vs stale) to the renderer on startup. + const resolved = resolvePendingUpdate(app.getVersion()); + if (!resolved) return null; + return resolved; }); // ----------------------------- @@ -84,9 +88,12 @@ module.exports = function setupUpdater(mainWindow) { // close/restart driven update applies // ----------------------------- return { - // True when a downloaded update is persisted as pending and will be - // applied if the user quits or restarts. - hasPendingUpdate: () => !!getPendingUpdate(), + // True when a downloaded update is persisted as pending and is still + // installable (the app is on the version the update targets for replace). + hasPendingUpdate: () => { + const resolved = resolvePendingUpdate(app.getVersion()); + return resolved && resolved.status === "ready"; + }, // Apply a persisted pending update immediately (used on close). applyPendingUpdate: () => { updater.installUpdate(); diff --git a/src/modules/updater/pendingUpdate.js b/src/modules/updater/pendingUpdate.js index 970b85e..791fc26 100644 --- a/src/modules/updater/pendingUpdate.js +++ b/src/modules/updater/pendingUpdate.js @@ -69,4 +69,49 @@ function clearPendingUpdate() { } } -module.exports = { savePendingUpdate, getPendingUpdate, clearPendingUpdate }; +/** + * Resolve a persisted pending update against the version we actually + * launched with, so we can recover from interrupted/aborted installs. + * + * Returns one of: + * - { status: "applied", version } current app version matches the pending + * update, so the install succeeded → marker is cleared. + * - { status: "ready", version } pending update still outstanding and the + * app is still on the old version → update can be applied. + * - { status: "stale", version } app is neither the old nor the new + * version (e.g. partial/aborted install left an inconsistent state) → + * marker is cleared and the update should be re-fetched. + * - null no pending update exists. + * + * @param {string} currentVersion - the version SnapDock launched with. + */ +function resolvePendingUpdate(currentVersion) { + const pending = getPendingUpdate(); + if (!pending) return null; + + if (pending.version === currentVersion) { + // The update we downloaded is the version we're now running → applied. + clearPendingUpdate(); + return { status: "applied", version: pending.version }; + } + + if (pending.currentVersion === currentVersion) { + // Still on the version the update was meant to replace → installable. + return { status: "ready", version: pending.version, currentVersion }; + } + + // Neither target version matches — the install was interrupted/corrupted. + clearPendingUpdate(); + log.warn( + `[updater] Pending update ${pending.version} did not match running ` + + `${currentVersion}; cleared stale marker` + ); + return { status: "stale", version: pending.version, currentVersion }; +} + +module.exports = { + savePendingUpdate, + getPendingUpdate, + clearPendingUpdate, + resolvePendingUpdate +}; From b716b28042db986a54f025c4c6ee038e7e7e1b8f Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Tue, 1 Sep 2026 11:58:57 +1000 Subject: [PATCH 5/5] docs: describe pending-update behavior in bundled user guide (#254) Document that a downloaded update stays pending until applied and can be applied from the Tools menu, the status-bar indicator, or by closing SnapDock. --- assets/resources/docs/user_guide.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/resources/docs/user_guide.md b/assets/resources/docs/user_guide.md index c7a658a..f5bb1e8 100644 --- a/assets/resources/docs/user_guide.md +++ b/assets/resources/docs/user_guide.md @@ -135,6 +135,8 @@ The **Tools** menu also includes Light, Dark, Solarized, Arctic Dark, and Forest Select **Tools → Update** to check for updates when that option is supported by your installation. Microsoft Store and Snap Store packages are normally updated by their stores, and update behaviour can differ for other Linux packages and WSL. +Once an update has downloaded, it is kept pending until applied. You can apply it from the Tools menu, by selecting the status-bar indicator, or by closing SnapDock while the update is pending. A pending update is not lost if you close and reopen the app. + You can continue using SnapDock offline; an internet connection is only needed to check for or download updates and to open online links. ## Troubleshooting