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/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1086e9c29..8e8645552a 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"; @@ -23,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"; @@ -41,6 +43,7 @@ import { reduceDesktopUpdateStateOnDownloadStart, reduceDesktopUpdateStateOnInstallFailure, reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateToIdle, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; @@ -76,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; @@ -292,6 +296,9 @@ 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()); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; @@ -529,64 +536,113 @@ 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 { 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(); } } +function makeCheckForUpdatesMenuItem(): MenuItem { + return new MenuItem({ + label: DesktopUpdateStatusFriendlyLabelMap["idle"], + click: handleCheckForUpdatesMenuClick, + }); +} +const checkForUpdatesMenuItemInAppMenu = makeCheckForUpdatesMenuItem(); +const checkForUpdatesMenuItemInHelpMenu = makeCheckForUpdatesMenuItem(); + +function updateCheckForUpdatesMenuItem(menuItem: MenuItem, state: DesktopUpdateState): void { + menuItem.label = DesktopUpdateStatusFriendlyLabelMap[state.status]; + switch (state.status) { + case "checking": + case "downloading": + case "disabled": + case "error": + case "up-to-date": + menuItem.enabled = false; + break; + case "idle": + case "available": + case "downloaded": + menuItem.enabled = true; + break; + } +} + +updateStateListeners.add((state) => { + updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInAppMenu, state); + updateCheckForUpdatesMenuItem(checkForUpdatesMenuItemInHelpMenu, state); +}); + +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(), - }, + checkForUpdatesMenuItemInAppMenu, { type: "separator" }, { label: "Settings...", @@ -601,7 +657,7 @@ function configureApplicationMenu(): void { { role: "unhide" }, { type: "separator" }, { role: "quit" }, - ], + ]), }); } @@ -641,16 +697,12 @@ function configureApplicationMenu(): void { { role: "windowMenu" }, { role: "help", - submenu: [ - { - label: "Check for Updates...", - click: () => handleCheckForUpdatesMenuClick(), - }, - ], + submenu: Menu.buildFromTemplate([checkForUpdatesMenuItemInHelpMenu]), }, ); - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); + applicationMenu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(applicationMenu); } function resolveResourcePath(fileName: string): string | null { @@ -741,9 +793,46 @@ 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 }; - emitUpdateState(); + if (!shouldResetUpdateStateToIdle(updateState)) { + clearUpdateIdleResetTimer(); + } + for (const listener of updateStateListeners) { + listener(updateState); + } } function shouldEnableAutoUpdates(): boolean { @@ -778,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 { @@ -903,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."); }); @@ -1407,6 +1498,7 @@ app.on("before-quit", () => { isQuitting = true; updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); + clearUpdateIdleResetTimer(); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); @@ -1445,6 +1537,7 @@ if (process.platform !== "win32") { if (isQuitting) return; isQuitting = true; writeDesktopLogHeader("SIGINT received"); + clearUpdateIdleResetTimer(); clearUpdatePollTimer(); stopBackend(); restoreStdIoCapture?.(); @@ -1455,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"; } 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/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=="], 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";