From b6fec50dcad8a64294494caf715b805f913cc9a0 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 15 Mar 2026 14:24:50 -0400 Subject: [PATCH 1/7] feat: make check for updates menu item dynamic --- apps/desktop/src/main.ts | 83 ++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..28c5328054 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -14,6 +14,7 @@ import { nativeTheme, protocol, shell, + MenuItem, } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; @@ -292,6 +293,8 @@ let updateDownloadInFlight = false; let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +const updateStateListeners = new Set<(state: DesktopUpdateState) => void>(); +updateStateListeners.add(() => emitUpdateState()); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; @@ -575,18 +578,69 @@ async function checkForUpdatesFromMenu(): Promise { } } +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT = "Check for Updates..."; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING = "Checking for Updates..."; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED = "Updates unavailable"; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING = "Downloading update..."; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED = "Update downloaded"; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE = "Update available"; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE = "You're up to date!"; +const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR = "Update check failed"; +const checkForUpdatesMenuItem: MenuItem = new MenuItem({ + label: CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT, + click: async () => await handleCheckForUpdatesMenuClick(), +}); + +// TODO: Only the enabled status is actually dynamic here. Wait for upstream to allow for dynamic label updates. +updateStateListeners.add((state) => { + switch (state.status) { + case "checking": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING; + checkForUpdatesMenuItem.enabled = false; + break; + case "available": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE; + checkForUpdatesMenuItem.enabled = false; + break; + case "downloading": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING; + checkForUpdatesMenuItem.enabled = false; + break; + case "downloaded": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED; + checkForUpdatesMenuItem.enabled = false; + break; + case "disabled": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED; + checkForUpdatesMenuItem.enabled = false; + break; + case "error": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR; + checkForUpdatesMenuItem.enabled = false; + break; + case "up-to-date": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE; + checkForUpdatesMenuItem.enabled = false; + break; + case "idle": + checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE; + checkForUpdatesMenuItem.enabled = true; + break; + } +}); + +let applicationMenu: Menu | null = null; + function configureApplicationMenu(): void { - const template: MenuItemConstructorOptions[] = []; + const template: (MenuItemConstructorOptions | MenuItem)[] = []; if (process.platform === "darwin") { template.push({ label: app.name, - submenu: [ + submenu: Menu.buildFromTemplate([ { role: "about" }, - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, + checkForUpdatesMenuItem, { type: "separator" }, { label: "Settings...", @@ -601,7 +655,7 @@ function configureApplicationMenu(): void { { role: "unhide" }, { type: "separator" }, { role: "quit" }, - ], + ]), }); } @@ -641,16 +695,13 @@ function configureApplicationMenu(): void { { role: "windowMenu" }, { role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - ], + // TODO: Is it safe to use the same menu item for both the root menu and the help menu? + submenu: Menu.buildFromTemplate([checkForUpdatesMenuItem]), }, ); - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + applicationMenu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(applicationMenu); } function resolveResourcePath(fileName: string): string | null { @@ -743,7 +794,9 @@ function emitUpdateState(): void { function setUpdateState(patch: Partial): void { updateState = { ...updateState, ...patch }; - emitUpdateState(); + for (const listener of updateStateListeners) { + listener(updateState); + } } function shouldEnableAutoUpdates(): boolean { From 5cf682ce0e4a596ed0e3f70faefc1dd8bd776aaa Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Tue, 17 Mar 2026 16:58:34 -0400 Subject: [PATCH 2/7] fix: bump electron version so this actually works --- apps/desktop/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 188e701e7e..287e9463c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "effect": "catalog:", - "electron": "40.6.0", + "electron": "40.7.0", "electron-updater": "^6.6.2" }, "devDependencies": { diff --git a/bun.lock b/bun.lock index fa083cdc53..05cc953d82 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "version": "0.0.14", "dependencies": { "effect": "catalog:", - "electron": "40.6.0", + "electron": "40.7.0", "electron-updater": "^6.6.2", }, "devDependencies": { @@ -1022,7 +1022,7 @@ "effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], - "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], + "electron": ["electron@40.7.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-oQe76S/3V1rcb0+i45hAxnCH8udkRZSaHUNwglzNAEKbB94LSJ1qwbFo8+uRc2UsYZgCqSIMRcyX40GyOkD+Xw=="], "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], From 743811e1f67525d2411e3c902d0386004e2751c5 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Tue, 17 Mar 2026 17:08:19 -0400 Subject: [PATCH 3/7] style: remove inaccurate todo --- apps/desktop/src/main.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 28c5328054..7cad9906f5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -592,7 +592,6 @@ const checkForUpdatesMenuItem: MenuItem = new MenuItem({ click: async () => await handleCheckForUpdatesMenuClick(), }); -// TODO: Only the enabled status is actually dynamic here. Wait for upstream to allow for dynamic label updates. updateStateListeners.add((state) => { switch (state.status) { case "checking": From 96b6d23f219bea96291879397dc48a140b023bac Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Tue, 17 Mar 2026 17:11:29 -0400 Subject: [PATCH 4/7] refactor: address remaining todo by separating out help and app menu update menu items --- apps/desktop/src/main.ts | 56 +++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7cad9906f5..79a6cec52b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -587,46 +587,55 @@ const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED = "Update downloaded"; const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE = "Update available"; const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE = "You're up to date!"; const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR = "Update check failed"; -const checkForUpdatesMenuItem: MenuItem = new MenuItem({ - label: CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT, - click: async () => await handleCheckForUpdatesMenuClick(), -}); +function makeCheckForUpdatesMenuItem(): MenuItem { + return new MenuItem({ + label: CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT, + click: async () => await handleCheckForUpdatesMenuClick(), + }); +} +const checkForUpdatesMenuItemInAppMenu = makeCheckForUpdatesMenuItem(); +const checkForUpdatesMenuItemInHelpMenu = makeCheckForUpdatesMenuItem(); -updateStateListeners.add((state) => { +function updateCheckForUpdatesMenuItem(menuItem: MenuItem, state: DesktopUpdateState): void { switch (state.status) { case "checking": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING; + menuItem.enabled = false; break; case "available": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE; + menuItem.enabled = false; break; case "downloading": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING; + menuItem.enabled = false; break; case "downloaded": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED; + menuItem.enabled = false; break; case "disabled": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED; + menuItem.enabled = false; break; case "error": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR; + menuItem.enabled = false; break; case "up-to-date": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE; - checkForUpdatesMenuItem.enabled = false; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE; + menuItem.enabled = false; break; case "idle": - checkForUpdatesMenuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE; - checkForUpdatesMenuItem.enabled = true; + menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE; + menuItem.enabled = true; break; } +} + +updateStateListeners.add((state) => { + updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInAppMenu, state); + updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInHelpMenu, state); }); let applicationMenu: Menu | null = null; @@ -639,7 +648,7 @@ function configureApplicationMenu(): void { label: app.name, submenu: Menu.buildFromTemplate([ { role: "about" }, - checkForUpdatesMenuItem, + checkForUpdatesMenuItemInAppMenu, { type: "separator" }, { label: "Settings...", @@ -694,8 +703,7 @@ function configureApplicationMenu(): void { { role: "windowMenu" }, { role: "help", - // TODO: Is it safe to use the same menu item for both the root menu and the help menu? - submenu: Menu.buildFromTemplate([checkForUpdatesMenuItem]), + submenu: Menu.buildFromTemplate([checkForUpdatesMenuItemInHelpMenu]), }, ); From cd06a149a50b2b595e0bcc574a27362742a7135f Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 29 Mar 2026 00:54:34 -0400 Subject: [PATCH 5/7] fix: migrate friendly label map to `@t3tools/contracts` --- apps/desktop/src/main.ts | 33 ++----------------- .../components/settings/SettingsPanels.tsx | 10 ++---- packages/contracts/src/ipc.ts | 11 +++++++ 3 files changed, 17 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 79a6cec52b..dc4b8982b9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -24,6 +24,7 @@ import type { DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; +import { DesktopUpdateStatusFriendlyLabelMap } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; @@ -578,18 +579,9 @@ async function checkForUpdatesFromMenu(): Promise { } } -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT = "Check for Updates..."; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING = "Checking for Updates..."; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED = "Updates unavailable"; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING = "Downloading update..."; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED = "Update downloaded"; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE = "Update available"; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE = "You're up to date!"; -const CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR = "Update check failed"; function makeCheckForUpdatesMenuItem(): MenuItem { return new MenuItem({ - label: CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DEFAULT, + label: DesktopUpdateStatusFriendlyLabelMap["idle"], click: async () => await handleCheckForUpdatesMenuClick(), }); } @@ -597,37 +589,18 @@ const checkForUpdatesMenuItemInAppMenu = makeCheckForUpdatesMenuItem(); const checkForUpdatesMenuItemInHelpMenu = makeCheckForUpdatesMenuItem(); function updateCheckForUpdatesMenuItem(menuItem: MenuItem, state: DesktopUpdateState): void { + menuItem.label = DesktopUpdateStatusFriendlyLabelMap[state.status]; switch (state.status) { case "checking": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_CHECKING; - menuItem.enabled = false; - break; case "available": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_AVAILABLE; - menuItem.enabled = false; - break; case "downloading": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADING; - menuItem.enabled = false; - break; case "downloaded": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DOWNLOADED; - menuItem.enabled = false; - break; case "disabled": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_DISABLED; - menuItem.enabled = false; - break; case "error": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_ERROR; - menuItem.enabled = false; - break; case "up-to-date": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_UP_TO_DATE; menuItem.enabled = false; break; case "idle": - menuItem.label = CHECK_FOR_UPDATES_MENU_ITEM_LABEL_IDLE; menuItem.enabled = true; break; } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..00be3d9b92 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -17,6 +17,7 @@ import { type ServerProvider, type ServerProviderModel, ThreadId, + DesktopUpdateStatusFriendlyLabelMap, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -401,13 +402,8 @@ function AboutVersionSection() { : isDesktopUpdateButtonDisabled(updateState); const actionLabel: Record = { download: "Download", install: "Install" }; - const statusLabel: Record = { - checking: "Checking…", - downloading: "Downloading…", - "up-to-date": "Up to Date", - }; - const buttonLabel = - actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; + const statusLabel = DesktopUpdateStatusFriendlyLabelMap; + const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? "idle"]; const description = action === "download" || action === "install" ? "Update available." diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5585e7f309..d534c65445 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -70,6 +70,17 @@ export type DesktopUpdateStatus = | "downloaded" | "error"; +export const DesktopUpdateStatusFriendlyLabelMap: Record = { + disabled: "Updates Unavailable", + idle: "Check for Updates", + checking: "Checking…", + "up-to-date": "Up to Date", + available: "Update Available", + downloading: "Downloading…", + downloaded: "Update Downloaded", + error: "Update check failed", +}; + export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; From 88635eefe791f34982ee310660364d9cdbee627e Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 29 Mar 2026 01:45:47 -0400 Subject: [PATCH 6/7] fix: allow for downloading and installing in menu items --- apps/desktop/src/main.ts | 64 +++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index dc4b8982b9..7b5dbb5240 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -533,29 +533,45 @@ function dispatchMenuAction(action: string): void { } function handleCheckForUpdatesMenuClick(): void { - const disabledReason = getAutoUpdateDisabledReason({ - isDevelopment, - isPackaged: app.isPackaged, - platform: process.platform, - appImage: process.env.APPIMAGE, - disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", - }); - if (disabledReason) { - console.info("[desktop-updater] Manual update check requested, but updates are disabled."); - void dialog.showMessageBox({ - type: "info", - title: "Updates unavailable", - message: "Automatic updates are not available right now.", - detail: disabledReason, - buttons: ["OK"], - }); - return; - } + switch (updateState.status) { + case "idle": + { + const disabledReason = getAutoUpdateDisabledReason({ + isDevelopment, + isPackaged: app.isPackaged, + platform: process.platform, + appImage: process.env.APPIMAGE, + disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", + }); + if (disabledReason) { + console.info( + "[desktop-updater] Manual update check requested, but updates are disabled.", + ); + void dialog.showMessageBox({ + type: "info", + title: "Updates unavailable", + message: "Automatic updates are not available right now.", + detail: disabledReason, + buttons: ["OK"], + }); + return; + } - if (!BrowserWindow.getAllWindows().length) { - mainWindow = createWindow(); + if (!BrowserWindow.getAllWindows().length) { + mainWindow = createWindow(); + } + void checkForUpdatesFromMenu(); + } + break; + case "available": + void downloadAvailableUpdate(); + break; + case "downloaded": + void installDownloadedUpdate(); + break; + default: + break; } - void checkForUpdatesFromMenu(); } async function checkForUpdatesFromMenu(): Promise { @@ -582,7 +598,7 @@ async function checkForUpdatesFromMenu(): Promise { function makeCheckForUpdatesMenuItem(): MenuItem { return new MenuItem({ label: DesktopUpdateStatusFriendlyLabelMap["idle"], - click: async () => await handleCheckForUpdatesMenuClick(), + click: handleCheckForUpdatesMenuClick, }); } const checkForUpdatesMenuItemInAppMenu = makeCheckForUpdatesMenuItem(); @@ -592,15 +608,15 @@ function updateCheckForUpdatesMenuItem(menuItem: MenuItem, state: DesktopUpdateS menuItem.label = DesktopUpdateStatusFriendlyLabelMap[state.status]; switch (state.status) { case "checking": - case "available": case "downloading": - case "downloaded": case "disabled": case "error": case "up-to-date": menuItem.enabled = false; break; case "idle": + case "available": + case "downloaded": menuItem.enabled = true; break; } From dfa94af7521f420e7ccd88817a131f4315619ce8 Mon Sep 17 00:00:00 2001 From: Noah Gregory Date: Sun, 29 Mar 2026 01:53:18 -0400 Subject: [PATCH 7/7] fix: update desktop update state machine to switch back to idle after a delay --- apps/desktop/src/main.ts | 49 ++++++++++++++++++- apps/desktop/src/updateMachine.test.ts | 19 +++++++ apps/desktop/src/updateMachine.ts | 13 +++++ .../components/desktopUpdate.logic.test.ts | 12 ++--- .../web/src/components/desktopUpdate.logic.ts | 7 +-- 5 files changed, 86 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7b5dbb5240..8e8645552a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import { reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateToIdle, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; @@ -78,6 +79,7 @@ const LOG_FILE_MAX_FILES = 10; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; +const AUTO_UPDATE_TRANSIENT_IDLE_RESET_DELAY_MS = 5_000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; @@ -294,6 +296,7 @@ let updateDownloadInFlight = false; let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +let updateIdleResetTimer: ReturnType | null = null; const updateStateListeners = new Set<(state: DesktopUpdateState) => void>(); updateStateListeners.add(() => emitUpdateState()); @@ -578,20 +581,22 @@ async function checkForUpdatesFromMenu(): Promise { await checkForUpdates("menu"); if (updateState.status === "up-to-date") { - void dialog.showMessageBox({ + await dialog.showMessageBox({ type: "info", title: "You're up to date!", message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, buttons: ["OK"], }); + resetUpdateStateToIdleIfNeeded(); } else if (updateState.status === "error") { - void dialog.showMessageBox({ + await dialog.showMessageBox({ type: "warning", title: "Update check failed", message: "Could not check for updates.", detail: updateState.message ?? "An unknown error occurred. Please try again later.", buttons: ["OK"], }); + resetUpdateStateToIdleIfNeeded(); } } @@ -788,8 +793,43 @@ function emitUpdateState(): void { } } +function shouldResetUpdateStateToIdle(state: DesktopUpdateState): boolean { + return state.status === "up-to-date" || state.errorContext === "check"; +} + +function clearUpdateIdleResetTimer(): void { + if (updateIdleResetTimer) { + clearTimeout(updateIdleResetTimer); + updateIdleResetTimer = null; + } +} + +function resetUpdateStateToIdleIfNeeded(): boolean { + clearUpdateIdleResetTimer(); + if (!shouldResetUpdateStateToIdle(updateState)) { + return false; + } + setUpdateState(reduceDesktopUpdateStateToIdle(updateState)); + return true; +} + +function scheduleUpdateStateIdleReset(delayMs = AUTO_UPDATE_TRANSIENT_IDLE_RESET_DELAY_MS): void { + clearUpdateIdleResetTimer(); + if (!shouldResetUpdateStateToIdle(updateState)) { + return; + } + updateIdleResetTimer = setTimeout(() => { + updateIdleResetTimer = null; + resetUpdateStateToIdleIfNeeded(); + }, delayMs); + updateIdleResetTimer.unref(); +} + function setUpdateState(patch: Partial): void { updateState = { ...updateState, ...patch }; + if (!shouldResetUpdateStateToIdle(updateState)) { + clearUpdateIdleResetTimer(); + } for (const listener of updateStateListeners) { listener(updateState); } @@ -827,6 +867,7 @@ async function checkForUpdates(reason: string): Promise { setUpdateState( reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), ); + scheduleUpdateStateIdleReset(); console.error(`[desktop-updater] Failed to check for updates: ${message}`); return true; } finally { @@ -952,6 +993,7 @@ function configureAutoUpdater(): void { }); autoUpdater.on("update-not-available", () => { setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); + scheduleUpdateStateIdleReset(); lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available."); }); @@ -1456,6 +1498,7 @@ app.on("before-quit", () => { isQuitting = true; updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); + clearUpdateIdleResetTimer(); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); @@ -1494,6 +1537,7 @@ if (process.platform !== "win32") { if (isQuitting) return; isQuitting = true; writeDesktopLogHeader("SIGINT received"); + clearUpdateIdleResetTimer(); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); @@ -1504,6 +1548,7 @@ if (process.platform !== "win32") { if (isQuitting) return; isQuitting = true; writeDesktopLogHeader("SIGTERM received"); + clearUpdateIdleResetTimer(); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts index 7fbc982eff..0357cc5af8 100644 --- a/apps/desktop/src/updateMachine.test.ts +++ b/apps/desktop/src/updateMachine.test.ts @@ -10,6 +10,7 @@ import { reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateToIdle, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; @@ -117,6 +118,24 @@ describe("updateMachine", () => { expect(state.errorContext).toBeNull(); }); + it("returns transient check results back to idle", () => { + const upToDate = reduceDesktopUpdateStateToIdle({ + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), + enabled: true, + status: "up-to-date", + checkedAt: "2026-03-04T00:00:00.000Z", + message: "stale", + errorContext: "check", + canRetry: true, + }); + + expect(upToDate.status).toBe("idle"); + expect(upToDate.checkedAt).toBe("2026-03-04T00:00:00.000Z"); + expect(upToDate.message).toBeNull(); + expect(upToDate.errorContext).toBeNull(); + expect(upToDate.canRetry).toBe(false); + }); + it("tracks available, download start, and progress cleanly", () => { const available = reduceDesktopUpdateStateOnUpdateAvailable( { diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts index f13b420281..2bc167a9d0 100644 --- a/apps/desktop/src/updateMachine.ts +++ b/apps/desktop/src/updateMachine.ts @@ -89,6 +89,19 @@ export function reduceDesktopUpdateStateOnNoUpdate( }; } +export function reduceDesktopUpdateStateToIdle(state: DesktopUpdateState): DesktopUpdateState { + return { + ...state, + status: "idle", + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + message: null, + errorContext: null, + canRetry: false, + }; +} + export function reduceDesktopUpdateStateOnDownloadStart( state: DesktopUpdateState, ): DesktopUpdateState { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 84bde53048..3292d70aa3 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -259,17 +259,17 @@ describe("canCheckForUpdate", () => { expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); }); - it("returns true when up-to-date", () => { - expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); + it("returns false when up-to-date is still being shown", () => { + expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(false); }); - it("returns true when an update is available", () => { + it("returns false when an update is already available", () => { expect( canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), - ).toBe(true); + ).toBe(false); }); - it("returns true on error so the user can retry", () => { + it("returns false on check errors until the state returns to idle", () => { expect( canCheckForUpdate({ ...baseState, @@ -277,7 +277,7 @@ describe("canCheckForUpdate", () => { errorContext: "check", message: "network", }), - ).toBe(true); + ).toBe(false); }); }); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 38983c810b..e7686c5533 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -101,10 +101,5 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { if (!state || !state.enabled) return false; - return ( - state.status !== "checking" && - state.status !== "downloading" && - state.status !== "downloaded" && - state.status !== "disabled" - ); + return state.status === "idle"; }