From 392d63231cce39b2dcd56435ef24579a8b4f06c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 20:26:28 -0800 Subject: [PATCH] Harden desktop update flow and prevent duplicate update actions - Skip redundant update checks while update is available/downloading/downloaded - Wait for backend shutdown before `quitAndInstall` and improve updater error/progress handling - Prevent sidebar update button clicks during download while preserving tooltip interactivity --- apps/desktop/src/main.ts | 87 +++++++++++++++++++++++++---- apps/web/src/components/Sidebar.tsx | 8 ++- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9b7770a617b477d65d13db4563baf94a776d52b6..22948e23dc06672f81d027bdcb166641c4cca1a8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -425,7 +425,9 @@ function dispatchMenuAction(action: string): void { function handleCheckForUpdatesMenuClick(): void { if (updateState.status === "downloaded") { - installDownloadedUpdate(); + console.info( + "[desktop-updater] Check for updates clicked while an update is already downloaded; skipping automatic install.", + ); return; } @@ -576,7 +578,11 @@ function shouldEnableAutoUpdates(): boolean { async function checkForUpdates(reason: string): Promise { if (!updaterConfigured || updateCheckInFlight) return; - if (updateState.status === "downloading" || updateState.status === "downloaded") { + if ( + updateState.status === "downloading" || + updateState.status === "downloaded" || + updateState.status === "available" + ) { console.info( `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, ); @@ -639,8 +645,9 @@ function installDownloadedUpdate(): void { isQuitting = true; clearUpdatePollTimer(); - stopBackend(); - autoUpdater.quitAndInstall(); + void stopBackendAndWaitForExit().finally(() => { + autoUpdater.quitAndInstall(); + }); } function configureAutoUpdater(): void { @@ -674,6 +681,7 @@ function configureAutoUpdater(): void { autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; autoUpdater.allowPrerelease = app.getVersion().includes("-"); + let lastLoggedDownloadMilestone = -1; autoUpdater.on("checking-for-update", () => { console.info("[desktop-updater] Looking for updates..."); @@ -687,6 +695,7 @@ function configureAutoUpdater(): void { checkedAt: new Date().toISOString(), message: null, }); + lastLoggedDownloadMilestone = -1; console.info(`[desktop-updater] Update available: ${info.version}`); }); autoUpdater.on("update-not-available", () => { @@ -698,14 +707,19 @@ function configureAutoUpdater(): void { checkedAt: new Date().toISOString(), message: null, }); + lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available."); }); autoUpdater.on("error", (error) => { - const message = error instanceof Error ? error.message : String(error); - setUpdateState({ - status: "error", - message, - }); + const message = formatErrorMessage(error); + if (!updateCheckInFlight && !updateDownloadInFlight) { + setUpdateState({ + status: "error", + message, + checkedAt: new Date().toISOString(), + downloadPercent: null, + }); + } console.error(`[desktop-updater] Updater error: ${message}`); }); autoUpdater.on("download-progress", (progress) => { @@ -715,7 +729,9 @@ function configureAutoUpdater(): void { downloadPercent: progress.percent, message: null, }); - if (percent % 10 === 0) { + const milestone = percent - (percent % 10); + if (milestone > lastLoggedDownloadMilestone) { + lastLoggedDownloadMilestone = milestone; console.info(`[desktop-updater] Download progress: ${percent}%`); } }); @@ -842,6 +858,57 @@ function stopBackend(): void { } } +async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { + if (restartTimer) { + clearTimeout(restartTimer); + restartTimer = null; + } + + const child = backendProcess; + backendProcess = null; + if (!child) return; + const backendChild = child; + if (backendChild.exitCode !== null || backendChild.signalCode !== null) return; + + await new Promise((resolve) => { + let settled = false; + let forceKillTimer: ReturnType | null = null; + let exitTimeoutTimer: ReturnType | null = null; + + function settle(): void { + if (settled) return; + settled = true; + backendChild.off("exit", onExit); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + if (exitTimeoutTimer) { + clearTimeout(exitTimeoutTimer); + } + resolve(); + } + + function onExit(): void { + settle(); + } + + backendChild.once("exit", onExit); + backendChild.kill("SIGTERM"); + + forceKillTimer = setTimeout(() => { + if (backendChild.exitCode === null && backendChild.signalCode === null) { + backendChild.kill("SIGKILL"); + } + }, 2_000); + forceKillTimer.unref(); + + exitTimeoutTimer = setTimeout(() => { + settle(); + }, timeoutMs); + exitTimeoutTimer.unref(); + }); +} + function registerIpcHandlers(): void { ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0d7ce91b374ce73eae93bf7ceb4786457a316019..af3d6e2a897e10e929aea75db91ddab1410843fd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -859,6 +859,9 @@ export default function Sidebar() { : "Update available"; const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading"; + const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled + ? "cursor-not-allowed opacity-60" + : "hover:bg-accent hover:text-foreground"; const desktopUpdateButtonClasses = desktopUpdateState?.status === "downloaded" ? "text-emerald-500" @@ -869,6 +872,7 @@ export default function Sidebar() { const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; + if (desktopUpdateState.status === "downloading") return; if (desktopUpdateState.status === "available") { void bridge.downloadUpdate().catch((error) => { @@ -931,8 +935,8 @@ export default function Sidebar() {