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 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/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 f2e16ec..3f15402 100644 --- a/src/modules/ui/dropdownMenus.js +++ b/src/modules/ui/dropdownMenus.js @@ -143,76 +143,178 @@ 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: 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.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); + } }); // 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/modules/updater/download.js b/src/modules/updater/download.js index 7ae2219..d9d42e3 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 } = require("./pendingUpdate"); // Logging autoUpdater.logger = log; @@ -44,6 +45,10 @@ async function downloadUpdate() { // ----------------------------- function installUpdate() { try { + // 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); @@ -66,7 +71,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..c1cca94 100644 --- a/src/modules/updater/index.js +++ b/src/modules/updater/index.js @@ -1,7 +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 { resolvePendingUpdate, getPendingUpdate } = require("./pendingUpdate"); module.exports = function setupUpdater(mainWindow) { @@ -15,6 +16,17 @@ module.exports = function setupUpdater(mainWindow) { return source; }); + // ----------------------------- + // Expose any persisted pending update + // ----------------------------- + ipcMain.handle("update:pending", () => { + // 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; + }); + // ----------------------------- // Store builds → disable updater // ----------------------------- @@ -37,7 +49,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 }; } // ----------------------------- @@ -69,4 +82,21 @@ 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 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 new file mode 100644 index 0000000..791fc26 --- /dev/null +++ b/src/modules/updater/pendingUpdate.js @@ -0,0 +1,117 @@ +// 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); + } +} + +/** + * 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 +}; 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"), 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; -}