Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions assets/resources/docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -285,7 +297,7 @@ function createWindow() {
finishWindowClose();
}
});
setupUpdater(mainWindow);
updater = setupUpdater(mainWindow);
mainWindow.loadFile("index.html");


Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"snap"
]
},
"publish": {
"provider": "github",
"owner": "ZFordDev",
"repo": "SnapDock"
},
"mac": {
"category": "public.app-category.productivity",
"icon": "assets/icon.icns",
Expand Down
180 changes: 141 additions & 39 deletions src/modules/ui/dropdownMenus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down
12 changes: 11 additions & 1 deletion src/modules/updater/download.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
34 changes: 32 additions & 2 deletions src/modules/updater/index.js
Original file line number Diff line number Diff line change
@@ -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) {

Expand All @@ -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
// -----------------------------
Expand All @@ -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 };
}

// -----------------------------
Expand Down Expand Up @@ -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();
}
};
};
Loading