diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 54a521f9f0253ac2d829b4552047b89fb46640f6..157f3df9601d2c31b8a6af4b57a3455e433f0095 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,7 +7,7 @@ import * as Path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; @@ -15,7 +15,22 @@ import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { + getAutoUpdateDisabledReason, + shouldBroadcastDownloadProgress, +} from "./updateState"; +import { + createInitialDesktopUpdateState, + reduceDesktopUpdateStateOnCheckFailure, + reduceDesktopUpdateStateOnCheckStart, + reduceDesktopUpdateStateOnDownloadComplete, + reduceDesktopUpdateStateOnDownloadFailure, + reduceDesktopUpdateStateOnDownloadProgress, + reduceDesktopUpdateStateOnDownloadStart, + reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateOnUpdateAvailable, +} from "./updateMachine"; fixPath(); @@ -44,6 +59,8 @@ 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; +type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; + let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; @@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; +const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion()); function logTimestamp(): string { return new Date().toISOString(); @@ -208,16 +226,13 @@ let updateStartupTimer: ReturnType | null = null; let updateCheckInFlight = false; let updateDownloadInFlight = false; let updaterConfigured = false; -let updateState: DesktopUpdateState = { - enabled: false, - status: "disabled", - currentVersion: app.getVersion(), - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, -}; +let updateState: DesktopUpdateState = initialUpdateState(); + +function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { + if (updateDownloadInFlight) return "download"; + if (updateCheckInFlight) return "check"; + return updateState.errorContext; +} protocol.registerSchemesAsPrivileged([ { @@ -600,78 +615,67 @@ async function checkForUpdates(reason: string): Promise { return; } updateCheckInFlight = true; - setUpdateState({ - status: "checking", - checkedAt: new Date().toISOString(), - message: null, - downloadPercent: null, - }); + setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); console.info(`[desktop-updater] Checking for updates (${reason})...`); try { await autoUpdater.checkForUpdates(); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - setUpdateState({ - status: "error", - message, - checkedAt: new Date().toISOString(), - downloadPercent: null, - }); + setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString())); console.error(`[desktop-updater] Failed to check for updates: ${message}`); } finally { updateCheckInFlight = false; } } -async function downloadAvailableUpdate(): Promise { +async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> { if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { - return; + return { accepted: false, completed: false }; } updateDownloadInFlight = true; - setUpdateState({ - status: "downloading", - downloadPercent: 0, - message: null, - }); + setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); console.info("[desktop-updater] Downloading update..."); try { await autoUpdater.downloadUpdate(); + return { accepted: true, completed: true }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - setUpdateState({ - status: "error", - message, - downloadPercent: null, - }); + setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); console.error(`[desktop-updater] Failed to download update: ${message}`); + return { accepted: true, completed: false }; } finally { updateDownloadInFlight = false; } } -function installDownloadedUpdate(): void { - if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") return; +async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { + if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { + return { accepted: false, completed: false }; + } isQuitting = true; clearUpdatePollTimer(); - void stopBackendAndWaitForExit().finally(() => { + try { + await stopBackendAndWaitForExit(); autoUpdater.quitAndInstall(); - }); + return { accepted: true, completed: true }; + } catch (error: unknown) { + const message = formatErrorMessage(error); + isQuitting = false; + setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); + console.error(`[desktop-updater] Failed to install update: ${message}`); + return { accepted: true, completed: false }; + } } function configureAutoUpdater(): void { const enabled = shouldEnableAutoUpdates(); setUpdateState({ + ...createInitialDesktopUpdateState(app.getVersion()), enabled, status: enabled ? "idle" : "disabled", - currentVersion: app.getVersion(), - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: null, - message: null, }); if (!enabled) { return; @@ -698,26 +702,12 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { - setUpdateState({ - status: "available", - availableVersion: info.version, - downloadedVersion: null, - downloadPercent: null, - checkedAt: new Date().toISOString(), - message: null, - }); + setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString())); lastLoggedDownloadMilestone = -1; console.info(`[desktop-updater] Update available: ${info.version}`); }); autoUpdater.on("update-not-available", () => { - setUpdateState({ - status: "up-to-date", - availableVersion: null, - downloadedVersion: null, - downloadPercent: null, - checkedAt: new Date().toISOString(), - message: null, - }); + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available."); }); @@ -729,6 +719,8 @@ function configureAutoUpdater(): void { message, checkedAt: new Date().toISOString(), downloadPercent: null, + errorContext: resolveUpdaterErrorContext(), + canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, }); } console.error(`[desktop-updater] Updater error: ${message}`); @@ -739,11 +731,7 @@ function configureAutoUpdater(): void { shouldBroadcastDownloadProgress(updateState, progress.percent) || updateState.message !== null ) { - setUpdateState({ - status: "downloading", - downloadPercent: progress.percent, - message: null, - }); + setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); } const milestone = percent - (percent % 10); if (milestone > lastLoggedDownloadMilestone) { @@ -752,13 +740,7 @@ function configureAutoUpdater(): void { } }); autoUpdater.on("update-downloaded", (info) => { - setUpdateState({ - status: "downloaded", - availableVersion: info.version, - downloadedVersion: info.version, - downloadPercent: 100, - message: null, - }); + setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); console.info(`[desktop-updater] Update downloaded: ${info.version}`); }); @@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void { ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { - await downloadAvailableUpdate(); + const result = await downloadAvailableUpdate(); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + } satisfies DesktopUpdateActionResult; }); ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { - if (isQuitting) return; - installDownloadedUpdate(); + if (isQuitting) { + return { + accepted: false, + completed: false, + state: updateState, + } satisfies DesktopUpdateActionResult; + } + const result = await installDownloadedUpdate(); + return { + accepted: result.accepted, + completed: result.completed, + state: updateState, + } satisfies DesktopUpdateActionResult; }); } diff --git a/apps/desktop/src/updateMachine.test.ts b/apps/desktop/src/updateMachine.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..045bfaf8f4d342e5570dec2bcaa1105319288658 --- /dev/null +++ b/apps/desktop/src/updateMachine.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import { + createInitialDesktopUpdateState, + reduceDesktopUpdateStateOnCheckFailure, + reduceDesktopUpdateStateOnCheckStart, + reduceDesktopUpdateStateOnDownloadComplete, + reduceDesktopUpdateStateOnDownloadFailure, + reduceDesktopUpdateStateOnDownloadProgress, + reduceDesktopUpdateStateOnDownloadStart, + reduceDesktopUpdateStateOnInstallFailure, + reduceDesktopUpdateStateOnNoUpdate, + reduceDesktopUpdateStateOnUpdateAvailable, +} from "./updateMachine"; + +describe("updateMachine", () => { + it("clears transient errors when a check starts", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "error", + message: "network", + errorContext: "check", + canRetry: true, + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.status).toBe("checking"); + expect(state.message).toBeNull(); + expect(state.errorContext).toBeNull(); + expect(state.canRetry).toBe(false); + }); + + it("records a check failure without exposing an action", () => { + const state = reduceDesktopUpdateStateOnCheckFailure( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "checking", + }, + "network unavailable", + "2026-03-04T00:00:00.000Z", + ); + + expect(state.status).toBe("error"); + expect(state.errorContext).toBe("check"); + expect(state.canRetry).toBe(true); + }); + + it("preserves available version on download failure for retry", () => { + const state = reduceDesktopUpdateStateOnDownloadFailure( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "downloading", + availableVersion: "1.1.0", + downloadPercent: 43, + }, + "checksum mismatch", + ); + + expect(state.status).toBe("available"); + expect(state.availableVersion).toBe("1.1.0"); + expect(state.errorContext).toBe("download"); + expect(state.canRetry).toBe(true); + }); + + it("transitions to downloaded and then preserves install retry state", () => { + const downloaded = reduceDesktopUpdateStateOnDownloadComplete( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "downloading", + availableVersion: "1.1.0", + }, + "1.1.0", + ); + const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out"); + + expect(downloaded.status).toBe("downloaded"); + expect(downloaded.downloadedVersion).toBe("1.1.0"); + expect(failedInstall.status).toBe("downloaded"); + expect(failedInstall.errorContext).toBe("install"); + expect(failedInstall.canRetry).toBe(true); + }); + + it("clears stale download state when no update is available", () => { + const state = reduceDesktopUpdateStateOnNoUpdate( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "error", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + message: "old failure", + errorContext: "download", + canRetry: true, + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.status).toBe("up-to-date"); + expect(state.availableVersion).toBeNull(); + expect(state.downloadedVersion).toBeNull(); + expect(state.message).toBeNull(); + expect(state.errorContext).toBeNull(); + }); + + it("tracks available, download start, and progress cleanly", () => { + const available = reduceDesktopUpdateStateOnUpdateAvailable( + { + ...createInitialDesktopUpdateState("1.0.0"), + enabled: true, + status: "checking", + }, + "1.1.0", + "2026-03-04T00:00:00.000Z", + ); + const downloading = reduceDesktopUpdateStateOnDownloadStart(available); + const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); + + expect(available.status).toBe("available"); + expect(downloading.status).toBe("downloading"); + expect(downloading.downloadPercent).toBe(0); + expect(progress.downloadPercent).toBe(55.5); + expect(progress.errorContext).toBeNull(); + }); +}); diff --git a/apps/desktop/src/updateMachine.ts b/apps/desktop/src/updateMachine.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c4e21265772f740e85da0305c75bf888018ec0b --- /dev/null +++ b/apps/desktop/src/updateMachine.ts @@ -0,0 +1,155 @@ +import type { DesktopUpdateState } from "@t3tools/contracts"; + +import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; + +export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState { + return { + enabled: false, + status: "disabled", + currentVersion, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnCheckStart( + state: DesktopUpdateState, + checkedAt: string, +): DesktopUpdateState { + return { + ...state, + status: "checking", + checkedAt, + message: null, + downloadPercent: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnCheckFailure( + state: DesktopUpdateState, + message: string, + checkedAt: string, +): DesktopUpdateState { + return { + ...state, + status: "error", + message, + checkedAt, + downloadPercent: null, + errorContext: "check", + canRetry: true, + }; +} + +export function reduceDesktopUpdateStateOnUpdateAvailable( + state: DesktopUpdateState, + version: string, + checkedAt: string, +): DesktopUpdateState { + return { + ...state, + status: "available", + availableVersion: version, + downloadedVersion: null, + downloadPercent: null, + checkedAt, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnNoUpdate( + state: DesktopUpdateState, + checkedAt: string, +): DesktopUpdateState { + return { + ...state, + status: "up-to-date", + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnDownloadStart( + state: DesktopUpdateState, +): DesktopUpdateState { + return { + ...state, + status: "downloading", + downloadPercent: 0, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnDownloadFailure( + state: DesktopUpdateState, + message: string, +): DesktopUpdateState { + return { + ...state, + status: nextStatusAfterDownloadFailure(state), + message, + downloadPercent: null, + errorContext: "download", + canRetry: getCanRetryAfterDownloadFailure(state), + }; +} + +export function reduceDesktopUpdateStateOnDownloadProgress( + state: DesktopUpdateState, + percent: number, +): DesktopUpdateState { + return { + ...state, + status: "downloading", + downloadPercent: percent, + message: null, + errorContext: null, + canRetry: false, + }; +} + +export function reduceDesktopUpdateStateOnDownloadComplete( + state: DesktopUpdateState, + version: string, +): DesktopUpdateState { + return { + ...state, + status: "downloaded", + availableVersion: version, + downloadedVersion: version, + downloadPercent: 100, + message: null, + errorContext: null, + canRetry: true, + }; +} + +export function reduceDesktopUpdateStateOnInstallFailure( + state: DesktopUpdateState, + message: string, +): DesktopUpdateState { + return { + ...state, + status: "downloaded", + message, + errorContext: "install", + canRetry: true, + }; +} + diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 8b3fa7cb82ed21b5bacc8f0579601ab2cf329b91..e2bc9c62579cb67a04b73f9a81fe14491901361e 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateState } from "@t3tools/contracts"; -import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; +import { + getCanRetryAfterDownloadFailure, + getAutoUpdateDisabledReason, + nextStatusAfterDownloadFailure, + shouldBroadcastDownloadProgress, +} from "./updateState"; const baseState: DesktopUpdateState = { enabled: true, @@ -12,6 +17,8 @@ const baseState: DesktopUpdateState = { downloadPercent: null, checkedAt: null, message: null, + errorContext: null, + canRetry: false, }; describe("shouldBroadcastDownloadProgress", () => { @@ -41,6 +48,15 @@ describe("shouldBroadcastDownloadProgress", () => { ), ).toBe(true); }); + + it("broadcasts progress updates when a retry resets the download percentage", () => { + expect( + shouldBroadcastDownloadProgress( + { ...baseState, status: "downloading", downloadPercent: 50.4 }, + 0.2, + ), + ).toBe(true); + }); }); describe("getAutoUpdateDisabledReason", () => { @@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => { ).toContain("AppImage"); }); }); + +describe("nextStatusAfterDownloadFailure", () => { + it("returns available when an update version is still known", () => { + expect( + nextStatusAfterDownloadFailure({ + ...baseState, + status: "downloading", + availableVersion: "1.1.0", + }), + ).toBe("available"); + }); + + it("returns error when no update version can be retried", () => { + expect( + nextStatusAfterDownloadFailure({ + ...baseState, + status: "downloading", + availableVersion: null, + }), + ).toBe("error"); + }); +}); + +describe("getCanRetryAfterDownloadFailure", () => { + it("returns true when an available version is still present", () => { + expect( + getCanRetryAfterDownloadFailure({ + ...baseState, + status: "downloading", + availableVersion: "1.1.0", + }), + ).toBe(true); + }); + + it("returns false when no version is available to retry", () => { + expect( + getCanRetryAfterDownloadFailure({ + ...baseState, + status: "downloading", + availableVersion: null, + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index b6291d2e5bbb62b2aed79db8831bea2a9b6788c5..8c8ef1ddc97502c3eb5f05601a776f92027e3217 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -15,7 +15,17 @@ export function shouldBroadcastDownloadProgress( const previousStep = Math.floor(currentPercent / 10); const nextStep = Math.floor(nextPercent / 10); - return nextStep > previousStep || nextPercent === 100; + return nextStep !== previousStep || nextPercent === 100; +} + +export function nextStatusAfterDownloadFailure( + currentState: DesktopUpdateState, +): DesktopUpdateState["status"] { + return currentState.availableVersion ? "available" : "error"; +} + +export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { + return currentState.availableVersion !== null; } export function getAutoUpdateDisabledReason(args: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af3d6e2a897e10e929aea75db91ddab1410843fd..827bb75b0fb69acef43ab6b260e13965cd80eaf5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -30,6 +30,15 @@ import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; +import { + getDesktopUpdateActionError, + getDesktopUpdateButtonTooltip, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldHighlightDesktopUpdateError, + shouldShowDesktopUpdateButton, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { @@ -83,21 +92,6 @@ interface PrStatusIndicator { type ThreadPr = GitStatusResult["pr"]; -function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { - if (state.status === "available") { - return `Update ${state.availableVersion ?? "available"} ready to download`; - } - if (state.status === "downloading") { - const progress = - typeof state.downloadPercent === "number" ? ` (${Math.floor(state.downloadPercent)}%)` : ""; - return `Downloading update${progress}`; - } - if (state.status === "downloaded") { - return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; - } - return "Update available"; -} - function hasUnseenCompletion(thread: Thread): boolean { if (!thread.latestTurn?.completedAt) return false; const completedAt = Date.parse(thread.latestTurn.completedAt); @@ -847,18 +841,16 @@ export default function Sidebar() { if (firstProject) void handleNewThread(firstProject.id); }; - const showDesktopUpdateButton = - isElectron && - desktopUpdateState?.enabled === true && - (desktopUpdateState.status === "available" || - desktopUpdateState.status === "downloading" || - desktopUpdateState.status === "downloaded"); + const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); const desktopUpdateTooltip = desktopUpdateState ? getDesktopUpdateButtonTooltip(desktopUpdateState) : "Update available"; - const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading"; + const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); + const desktopUpdateButtonAction = desktopUpdateState + ? resolveDesktopUpdateButtonAction(desktopUpdateState) + : "none"; const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled ? "cursor-not-allowed opacity-60" : "hover:bg-accent hover:text-foreground"; @@ -867,34 +859,67 @@ export default function Sidebar() { ? "text-emerald-500" : desktopUpdateState?.status === "downloading" ? "text-sky-400" + : shouldHighlightDesktopUpdateError(desktopUpdateState) + ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; - if (desktopUpdateState.status === "downloading") return; + if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; - if (desktopUpdateState.status === "available") { - void bridge.downloadUpdate().catch((error) => { - toastManager.add({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + if (desktopUpdateButtonAction === "download") { + void bridge + .downloadUpdate() + .then((result) => { + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); }); - }); return; } - if (desktopUpdateState.status === "downloaded") { - void bridge.installUpdate().catch((error) => { - toastManager.add({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", + if (desktopUpdateButtonAction === "install") { + void bridge + .installUpdate() + .then((result) => { + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); }); - }); } - }, [desktopUpdateState]); + }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); const wordmark = (
@@ -936,6 +961,7 @@ export default function Sidebar() { type="button" aria-label={desktopUpdateTooltip} aria-disabled={desktopUpdateButtonDisabled || undefined} + disabled={desktopUpdateButtonDisabled} className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} onClick={handleDesktopUpdateButtonClick} > diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f80c1f3e46bb18f33e103ad6f197f1572496c573 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; + +import { + getDesktopUpdateActionError, + getDesktopUpdateButtonTooltip, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldHighlightDesktopUpdateError, + shouldShowDesktopUpdateButton, + shouldToastDesktopUpdateActionResult, +} from "./desktopUpdate.logic"; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + currentVersion: "1.0.0", + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +describe("desktop update button state", () => { + it("shows a download action when an update is available", () => { + const state: DesktopUpdateState = { ...baseState, status: "available", availableVersion: "1.1.0" }; + expect(shouldShowDesktopUpdateButton(state)).toBe(true); + expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); + }); + + it("keeps retry action available after a download error", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "error", + availableVersion: "1.1.0", + message: "network timeout", + errorContext: "download", + canRetry: true, + }; + expect(shouldShowDesktopUpdateButton(state)).toBe(true); + expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); + expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); + }); + + it("keeps install action available after an install error", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "error", + downloadedVersion: "1.1.0", + availableVersion: "1.1.0", + message: "shutdown timeout", + errorContext: "install", + canRetry: true, + }; + expect(shouldShowDesktopUpdateButton(state)).toBe(true); + expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); + expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); + }); + + it("hides the button for non-actionable check errors", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "error", + message: "network unavailable", + errorContext: "check", + canRetry: true, + }; + expect(shouldShowDesktopUpdateButton(state)).toBe(false); + expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); + }); + + it("disables the button while downloading", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "downloading", + availableVersion: "1.1.0", + downloadPercent: 42.5, + }; + expect(shouldShowDesktopUpdateButton(state)).toBe(true); + expect(isDesktopUpdateButtonDisabled(state)).toBe(true); + expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); + }); +}); + +describe("getDesktopUpdateActionError", () => { + it("returns user-visible message for accepted failed attempts", () => { + const result: DesktopUpdateActionResult = { + accepted: true, + completed: false, + state: { + ...baseState, + status: "available", + availableVersion: "1.1.0", + message: "checksum mismatch", + errorContext: "download", + canRetry: true, + }, + }; + expect(getDesktopUpdateActionError(result)).toBe("checksum mismatch"); + }); + + it("ignores messages for non-accepted attempts", () => { + const result: DesktopUpdateActionResult = { + accepted: false, + completed: false, + state: { + ...baseState, + status: "error", + message: "background failure", + errorContext: "check", + canRetry: false, + }, + }; + expect(getDesktopUpdateActionError(result)).toBeNull(); + }); + + it("ignores messages for successful attempts", () => { + const result: DesktopUpdateActionResult = { + accepted: true, + completed: true, + state: { + ...baseState, + status: "downloaded", + downloadedVersion: "1.1.0", + availableVersion: "1.1.0", + message: null, + errorContext: null, + canRetry: true, + }, + }; + expect(getDesktopUpdateActionError(result)).toBeNull(); + }); +}); + +describe("desktop update UI helpers", () => { + it("toasts only for accepted incomplete actions", () => { + expect( + shouldToastDesktopUpdateActionResult({ + accepted: true, + completed: false, + state: baseState, + }), + ).toBe(true); + expect( + shouldToastDesktopUpdateActionResult({ + accepted: true, + completed: true, + state: baseState, + }), + ).toBe(false); + }); + + it("highlights only actionable updater errors", () => { + expect( + shouldHighlightDesktopUpdateError({ + ...baseState, + status: "error", + errorContext: "download", + canRetry: true, + }), + ).toBe(true); + expect( + shouldHighlightDesktopUpdateError({ + ...baseState, + status: "error", + errorContext: "check", + canRetry: true, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab7b7cdc419b2b4f11491a91f2a496c33d6306b0 --- /dev/null +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -0,0 +1,77 @@ +import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; + +export type DesktopUpdateButtonAction = "download" | "install" | "none"; + +export function resolveDesktopUpdateButtonAction( + state: DesktopUpdateState, +): DesktopUpdateButtonAction { + if (state.status === "available") { + return "download"; + } + if (state.status === "downloaded") { + return "install"; + } + if (state.status === "error") { + if (state.errorContext === "install" && state.downloadedVersion) { + return "install"; + } + if (state.errorContext === "download" && state.availableVersion) { + return "download"; + } + } + return "none"; +} + +export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { + if (!state || !state.enabled) { + return false; + } + if (state.status === "downloading") { + return true; + } + return resolveDesktopUpdateButtonAction(state) !== "none"; +} + +export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { + return state?.status === "downloading"; +} + +export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { + if (state.status === "available") { + return `Update ${state.availableVersion ?? "available"} ready to download`; + } + if (state.status === "downloading") { + const progress = + typeof state.downloadPercent === "number" ? ` (${Math.floor(state.downloadPercent)}%)` : ""; + return `Downloading update${progress}`; + } + if (state.status === "downloaded") { + return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; + } + if (state.status === "error") { + if (state.errorContext === "download" && state.availableVersion) { + return `Download failed for ${state.availableVersion}. Click to retry.`; + } + if (state.errorContext === "install" && state.downloadedVersion) { + return `Install failed for ${state.downloadedVersion}. Click to retry.`; + } + return state.message ?? "Update failed"; + } + return "Update available"; +} + +export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { + if (!result.accepted || result.completed) return null; + if (typeof result.state.message !== "string") return null; + const message = result.state.message.trim(); + return message.length > 0 ? message : null; +} + +export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { + return result.accepted && !result.completed; +} + +export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { + if (!state || state.status !== "error") return false; + return state.errorContext === "download" || state.errorContext === "install"; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1ec1feee0b352369a7d0a1d08c550a85c366bc18..bf129533c58e3af78ac25f589c16c720aa90be5b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -62,6 +62,14 @@ export interface DesktopUpdateState { downloadPercent: number | null; checkedAt: string | null; message: string | null; + errorContext: "check" | "download" | "install" | null; + canRetry: boolean; +} + +export interface DesktopUpdateActionResult { + accepted: boolean; + completed: boolean; + state: DesktopUpdateState; } export interface DesktopBridge { @@ -75,8 +83,8 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; - downloadUpdate: () => Promise; - installUpdate: () => Promise; + downloadUpdate: () => Promise; + installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; }