| author | |
| committer | |
| log | ec89587c37fe250fb12c90c10d27a9d0871d1968 |
| tree | cf94d9b11a2c177c0da222c15873cb8b4137d2fb |
| parent | 32d89806d7b17dbcffa4f02b46b24b410bf54b5e |
- Extract desktop updater state transitions into `updateMachine` reducers with tests
- Extend update IPC/contracts to return `{accepted, completed, state}` for download/install actions
- Add web sidebar update-button logic for retryable download/install errors and clearer tooltips9 files changed, 758 insertions(+), 120 deletions(-)
apps/desktop/src/main.ts+75-77| ... | @@ -7,7 +7,7 @@ import * as Path from "node:path"; | ... | @@ -7,7 +7,7 @@ import * as Path from "node:path"; |
| 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; | 7 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; |
| 8 | import type { MenuItemConstructorOptions } from "electron"; | 8 | import type { MenuItemConstructorOptions } from "electron"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 10 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; |
| 11 | import { autoUpdater } from "electron-updater"; | 11 | import { autoUpdater } from "electron-updater"; |
| 12 | 12 | ||
| 13 | import type { ContextMenuItem } from "@t3tools/contracts"; | 13 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| ... | @@ -15,7 +15,22 @@ import { NetService } from "@t3tools/shared/Net"; | ... | @@ -15,7 +15,22 @@ import { NetService } from "@t3tools/shared/Net"; |
| 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 17 | import { fixPath } from "./fixPath"; | 17 | import { fixPath } from "./fixPath"; |
| 18 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | 18 | import { |
| 19 | getAutoUpdateDisabledReason, | ||
| 20 | shouldBroadcastDownloadProgress, | ||
| 21 | } from "./updateState"; | ||
| 22 | import { | ||
| 23 | createInitialDesktopUpdateState, | ||
| 24 | reduceDesktopUpdateStateOnCheckFailure, | ||
| 25 | reduceDesktopUpdateStateOnCheckStart, | ||
| 26 | reduceDesktopUpdateStateOnDownloadComplete, | ||
| 27 | reduceDesktopUpdateStateOnDownloadFailure, | ||
| 28 | reduceDesktopUpdateStateOnDownloadProgress, | ||
| 29 | reduceDesktopUpdateStateOnDownloadStart, | ||
| 30 | reduceDesktopUpdateStateOnInstallFailure, | ||
| 31 | reduceDesktopUpdateStateOnNoUpdate, | ||
| 32 | reduceDesktopUpdateStateOnUpdateAvailable, | ||
| 33 | } from "./updateMachine"; | ||
| 19 | 34 | ||
| 20 | fixPath(); | 35 | fixPath(); |
| 21 | 36 | ||
| ... | @@ -44,6 +59,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); | ... | @@ -44,6 +59,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); |
| 44 | const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; | 59 | const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 45 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; | 60 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 46 | 61 | ||
| 62 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; | ||
| 63 | |||
| 47 | let mainWindow: BrowserWindow | null = null; | 64 | let mainWindow: BrowserWindow | null = null; |
| 48 | let backendProcess: ChildProcess.ChildProcess | null = null; | 65 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 49 | let backendPort = 0; | 66 | let backendPort = 0; |
| ... | @@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null; | ... | @@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null; |
| 59 | let restoreStdIoCapture: (() => void) | null = null; | 76 | let restoreStdIoCapture: (() => void) | null = null; |
| 60 | 77 | ||
| 61 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; | 78 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 79 | const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion()); | ||
| 62 | 80 | ||
| 63 | function logTimestamp(): string { | 81 | function logTimestamp(): string { |
| 64 | return new Date().toISOString(); | 82 | return new Date().toISOString(); |
| ... | @@ -208,16 +226,13 @@ let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; | ... | @@ -208,16 +226,13 @@ let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; |
| 208 | let updateCheckInFlight = false; | 226 | let updateCheckInFlight = false; |
| 209 | let updateDownloadInFlight = false; | 227 | let updateDownloadInFlight = false; |
| 210 | let updaterConfigured = false; | 228 | let updaterConfigured = false; |
| 211 | let updateState: DesktopUpdateState = { | 229 | let updateState: DesktopUpdateState = initialUpdateState(); |
| 212 | enabled: false, | 230 | |
| 213 | status: "disabled", | 231 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { |
| 214 | currentVersion: app.getVersion(), | 232 | if (updateDownloadInFlight) return "download"; |
| 215 | availableVersion: null, | 233 | if (updateCheckInFlight) return "check"; |
| 216 | downloadedVersion: null, | 234 | return updateState.errorContext; |
| 217 | downloadPercent: null, | 235 | } |
| 218 | checkedAt: null, | ||
| 219 | message: null, | ||
| 220 | }; | ||
| 221 | 236 | ||
| 222 | protocol.registerSchemesAsPrivileged([ | 237 | protocol.registerSchemesAsPrivileged([ |
| 223 | { | 238 | { |
| ... | @@ -600,78 +615,67 @@ async function checkForUpdates(reason: string): Promise<void> { | ... | @@ -600,78 +615,67 @@ async function checkForUpdates(reason: string): Promise<void> { |
| 600 | return; | 615 | return; |
| 601 | } | 616 | } |
| 602 | updateCheckInFlight = true; | 617 | updateCheckInFlight = true; |
| 603 | setUpdateState({ | 618 | setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); |
| 604 | status: "checking", | ||
| 605 | checkedAt: new Date().toISOString(), | ||
| 606 | message: null, | ||
| 607 | downloadPercent: null, | ||
| 608 | }); | ||
| 609 | console.info(`[desktop-updater] Checking for updates (${reason})...`); | 619 | console.info(`[desktop-updater] Checking for updates (${reason})...`); |
| 610 | 620 | ||
| 611 | try { | 621 | try { |
| 612 | await autoUpdater.checkForUpdates(); | 622 | await autoUpdater.checkForUpdates(); |
| 613 | } catch (error: unknown) { | 623 | } catch (error: unknown) { |
| 614 | const message = error instanceof Error ? error.message : String(error); | 624 | const message = error instanceof Error ? error.message : String(error); |
| 615 | setUpdateState({ | 625 | setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString())); |
| 616 | status: "error", | ||
| 617 | message, | ||
| 618 | checkedAt: new Date().toISOString(), | ||
| 619 | downloadPercent: null, | ||
| 620 | }); | ||
| 621 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); | 626 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); |
| 622 | } finally { | 627 | } finally { |
| 623 | updateCheckInFlight = false; | 628 | updateCheckInFlight = false; |
| 624 | } | 629 | } |
| 625 | } | 630 | } |
| 626 | 631 | ||
| 627 | async function downloadAvailableUpdate(): Promise<void> { | 632 | async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> { |
| 628 | if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { | 633 | if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { |
| 629 | return; | 634 | return { accepted: false, completed: false }; |
| 630 | } | 635 | } |
| 631 | updateDownloadInFlight = true; | 636 | updateDownloadInFlight = true; |
| 632 | setUpdateState({ | 637 | setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); |
| 633 | status: "downloading", | ||
| 634 | downloadPercent: 0, | ||
| 635 | message: null, | ||
| 636 | }); | ||
| 637 | console.info("[desktop-updater] Downloading update..."); | 638 | console.info("[desktop-updater] Downloading update..."); |
| 638 | 639 | ||
| 639 | try { | 640 | try { |
| 640 | await autoUpdater.downloadUpdate(); | 641 | await autoUpdater.downloadUpdate(); |
| 642 | return { accepted: true, completed: true }; | ||
| 641 | } catch (error: unknown) { | 643 | } catch (error: unknown) { |
| 642 | const message = error instanceof Error ? error.message : String(error); | 644 | const message = error instanceof Error ? error.message : String(error); |
| 643 | setUpdateState({ | 645 | setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); |
| 644 | status: "error", | ||
| 645 | message, | ||
| 646 | downloadPercent: null, | ||
| 647 | }); | ||
| 648 | console.error(`[desktop-updater] Failed to download update: ${message}`); | 646 | console.error(`[desktop-updater] Failed to download update: ${message}`); |
| 647 | return { accepted: true, completed: false }; | ||
| 649 | } finally { | 648 | } finally { |
| 650 | updateDownloadInFlight = false; | 649 | updateDownloadInFlight = false; |
| 651 | } | 650 | } |
| 652 | } | 651 | } |
| 653 | 652 | ||
| 654 | function installDownloadedUpdate(): void { | 653 | async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { |
| 655 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") return; | 654 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { |
| 655 | return { accepted: false, completed: false }; | ||
| 656 | } | ||
| 656 | 657 | ||
| 657 | isQuitting = true; | 658 | isQuitting = true; |
| 658 | clearUpdatePollTimer(); | 659 | clearUpdatePollTimer(); |
| 659 | void stopBackendAndWaitForExit().finally(() => { | 660 | try { |
| 661 | await stopBackendAndWaitForExit(); | ||
| 660 | autoUpdater.quitAndInstall(); | 662 | autoUpdater.quitAndInstall(); |
| 661 | }); | 663 | return { accepted: true, completed: true }; |
| 664 | } catch (error: unknown) { | ||
| 665 | const message = formatErrorMessage(error); | ||
| 666 | isQuitting = false; | ||
| 667 | setUpdateState(reduceDesktopUpdateStateOnInstallFailure(updateState, message)); | ||
| 668 | console.error(`[desktop-updater] Failed to install update: ${message}`); | ||
| 669 | return { accepted: true, completed: false }; | ||
| 670 | } | ||
| 662 | } | 671 | } |
| 663 | 672 | ||
| 664 | function configureAutoUpdater(): void { | 673 | function configureAutoUpdater(): void { |
| 665 | const enabled = shouldEnableAutoUpdates(); | 674 | const enabled = shouldEnableAutoUpdates(); |
| 666 | setUpdateState({ | 675 | setUpdateState({ |
| 676 | ...createInitialDesktopUpdateState(app.getVersion()), | ||
| 667 | enabled, | 677 | enabled, |
| 668 | status: enabled ? "idle" : "disabled", | 678 | status: enabled ? "idle" : "disabled", |
| 669 | currentVersion: app.getVersion(), | ||
| 670 | availableVersion: null, | ||
| 671 | downloadedVersion: null, | ||
| 672 | downloadPercent: null, | ||
| 673 | checkedAt: null, | ||
| 674 | message: null, | ||
| 675 | }); | 679 | }); |
| 676 | if (!enabled) { | 680 | if (!enabled) { |
| 677 | return; | 681 | return; |
| ... | @@ -698,26 +702,12 @@ function configureAutoUpdater(): void { | ... | @@ -698,26 +702,12 @@ function configureAutoUpdater(): void { |
| 698 | console.info("[desktop-updater] Looking for updates..."); | 702 | console.info("[desktop-updater] Looking for updates..."); |
| 699 | }); | 703 | }); |
| 700 | autoUpdater.on("update-available", (info) => { | 704 | autoUpdater.on("update-available", (info) => { |
| 701 | setUpdateState({ | 705 | setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString())); |
| 702 | status: "available", | ||
| 703 | availableVersion: info.version, | ||
| 704 | downloadedVersion: null, | ||
| 705 | downloadPercent: null, | ||
| 706 | checkedAt: new Date().toISOString(), | ||
| 707 | message: null, | ||
| 708 | }); | ||
| 709 | lastLoggedDownloadMilestone = -1; | 706 | lastLoggedDownloadMilestone = -1; |
| 710 | console.info(`[desktop-updater] Update available: ${info.version}`); | 707 | console.info(`[desktop-updater] Update available: ${info.version}`); |
| 711 | }); | 708 | }); |
| 712 | autoUpdater.on("update-not-available", () => { | 709 | autoUpdater.on("update-not-available", () => { |
| 713 | setUpdateState({ | 710 | setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); |
| 714 | status: "up-to-date", | ||
| 715 | availableVersion: null, | ||
| 716 | downloadedVersion: null, | ||
| 717 | downloadPercent: null, | ||
| 718 | checkedAt: new Date().toISOString(), | ||
| 719 | message: null, | ||
| 720 | }); | ||
| 721 | lastLoggedDownloadMilestone = -1; | 711 | lastLoggedDownloadMilestone = -1; |
| 722 | console.info("[desktop-updater] No updates available."); | 712 | console.info("[desktop-updater] No updates available."); |
| 723 | }); | 713 | }); |
| ... | @@ -729,6 +719,8 @@ function configureAutoUpdater(): void { | ... | @@ -729,6 +719,8 @@ function configureAutoUpdater(): void { |
| 729 | message, | 719 | message, |
| 730 | checkedAt: new Date().toISOString(), | 720 | checkedAt: new Date().toISOString(), |
| 731 | downloadPercent: null, | 721 | downloadPercent: null, |
| 722 | errorContext: resolveUpdaterErrorContext(), | ||
| 723 | canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, | ||
| 732 | }); | 724 | }); |
| 733 | } | 725 | } |
| 734 | console.error(`[desktop-updater] Updater error: ${message}`); | 726 | console.error(`[desktop-updater] Updater error: ${message}`); |
| ... | @@ -739,11 +731,7 @@ function configureAutoUpdater(): void { | ... | @@ -739,11 +731,7 @@ function configureAutoUpdater(): void { |
| 739 | shouldBroadcastDownloadProgress(updateState, progress.percent) || | 731 | shouldBroadcastDownloadProgress(updateState, progress.percent) || |
| 740 | updateState.message !== null | 732 | updateState.message !== null |
| 741 | ) { | 733 | ) { |
| 742 | setUpdateState({ | 734 | setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); |
| 743 | status: "downloading", | ||
| 744 | downloadPercent: progress.percent, | ||
| 745 | message: null, | ||
| 746 | }); | ||
| 747 | } | 735 | } |
| 748 | const milestone = percent - (percent % 10); | 736 | const milestone = percent - (percent % 10); |
| 749 | if (milestone > lastLoggedDownloadMilestone) { | 737 | if (milestone > lastLoggedDownloadMilestone) { |
| ... | @@ -752,13 +740,7 @@ function configureAutoUpdater(): void { | ... | @@ -752,13 +740,7 @@ function configureAutoUpdater(): void { |
| 752 | } | 740 | } |
| 753 | }); | 741 | }); |
| 754 | autoUpdater.on("update-downloaded", (info) => { | 742 | autoUpdater.on("update-downloaded", (info) => { |
| 755 | setUpdateState({ | 743 | setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); |
| 756 | status: "downloaded", | ||
| 757 | availableVersion: info.version, | ||
| 758 | downloadedVersion: info.version, | ||
| 759 | downloadPercent: 100, | ||
| 760 | message: null, | ||
| 761 | }); | ||
| 762 | console.info(`[desktop-updater] Update downloaded: ${info.version}`); | 744 | console.info(`[desktop-updater] Update downloaded: ${info.version}`); |
| 763 | }); | 745 | }); |
| 764 | 746 | ||
| ... | @@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void { | ... | @@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void { |
| 1044 | 1026 | ||
| 1045 | ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); | 1027 | ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); |
| 1046 | ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { | 1028 | ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { |
| 1047 | await downloadAvailableUpdate(); | 1029 | const result = await downloadAvailableUpdate(); |
| 1030 | return { | ||
| 1031 | accepted: result.accepted, | ||
| 1032 | completed: result.completed, | ||
| 1033 | state: updateState, | ||
| 1034 | } satisfies DesktopUpdateActionResult; | ||
| 1048 | }); | 1035 | }); |
| 1049 | 1036 | ||
| 1050 | ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); | 1037 | ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); |
| 1051 | ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { | 1038 | ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { |
| 1052 | if (isQuitting) return; | 1039 | if (isQuitting) { |
| 1053 | installDownloadedUpdate(); | 1040 | return { |
| 1041 | accepted: false, | ||
| 1042 | completed: false, | ||
| 1043 | state: updateState, | ||
| 1044 | } satisfies DesktopUpdateActionResult; | ||
| 1045 | } | ||
| 1046 | const result = await installDownloadedUpdate(); | ||
| 1047 | return { | ||
| 1048 | accepted: result.accepted, | ||
| 1049 | completed: result.completed, | ||
| 1050 | state: updateState, | ||
| 1051 | } satisfies DesktopUpdateActionResult; | ||
| 1054 | }); | 1052 | }); |
| 1055 | } | 1053 | } |
| 1056 | 1054 |
apps/desktop/src/updateMachine.test.ts created+130| ... | @@ -0,0 +1,130 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | createInitialDesktopUpdateState, | ||
| 5 | reduceDesktopUpdateStateOnCheckFailure, | ||
| 6 | reduceDesktopUpdateStateOnCheckStart, | ||
| 7 | reduceDesktopUpdateStateOnDownloadComplete, | ||
| 8 | reduceDesktopUpdateStateOnDownloadFailure, | ||
| 9 | reduceDesktopUpdateStateOnDownloadProgress, | ||
| 10 | reduceDesktopUpdateStateOnDownloadStart, | ||
| 11 | reduceDesktopUpdateStateOnInstallFailure, | ||
| 12 | reduceDesktopUpdateStateOnNoUpdate, | ||
| 13 | reduceDesktopUpdateStateOnUpdateAvailable, | ||
| 14 | } from "./updateMachine"; | ||
| 15 | |||
| 16 | describe("updateMachine", () => { | ||
| 17 | it("clears transient errors when a check starts", () => { | ||
| 18 | const state = reduceDesktopUpdateStateOnCheckStart( | ||
| 19 | { | ||
| 20 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 21 | enabled: true, | ||
| 22 | status: "error", | ||
| 23 | message: "network", | ||
| 24 | errorContext: "check", | ||
| 25 | canRetry: true, | ||
| 26 | }, | ||
| 27 | "2026-03-04T00:00:00.000Z", | ||
| 28 | ); | ||
| 29 | |||
| 30 | expect(state.status).toBe("checking"); | ||
| 31 | expect(state.message).toBeNull(); | ||
| 32 | expect(state.errorContext).toBeNull(); | ||
| 33 | expect(state.canRetry).toBe(false); | ||
| 34 | }); | ||
| 35 | |||
| 36 | it("records a check failure without exposing an action", () => { | ||
| 37 | const state = reduceDesktopUpdateStateOnCheckFailure( | ||
| 38 | { | ||
| 39 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 40 | enabled: true, | ||
| 41 | status: "checking", | ||
| 42 | }, | ||
| 43 | "network unavailable", | ||
| 44 | "2026-03-04T00:00:00.000Z", | ||
| 45 | ); | ||
| 46 | |||
| 47 | expect(state.status).toBe("error"); | ||
| 48 | expect(state.errorContext).toBe("check"); | ||
| 49 | expect(state.canRetry).toBe(true); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("preserves available version on download failure for retry", () => { | ||
| 53 | const state = reduceDesktopUpdateStateOnDownloadFailure( | ||
| 54 | { | ||
| 55 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 56 | enabled: true, | ||
| 57 | status: "downloading", | ||
| 58 | availableVersion: "1.1.0", | ||
| 59 | downloadPercent: 43, | ||
| 60 | }, | ||
| 61 | "checksum mismatch", | ||
| 62 | ); | ||
| 63 | |||
| 64 | expect(state.status).toBe("available"); | ||
| 65 | expect(state.availableVersion).toBe("1.1.0"); | ||
| 66 | expect(state.errorContext).toBe("download"); | ||
| 67 | expect(state.canRetry).toBe(true); | ||
| 68 | }); | ||
| 69 | |||
| 70 | it("transitions to downloaded and then preserves install retry state", () => { | ||
| 71 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( | ||
| 72 | { | ||
| 73 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 74 | enabled: true, | ||
| 75 | status: "downloading", | ||
| 76 | availableVersion: "1.1.0", | ||
| 77 | }, | ||
| 78 | "1.1.0", | ||
| 79 | ); | ||
| 80 | const failedInstall = reduceDesktopUpdateStateOnInstallFailure(downloaded, "backend shutdown timed out"); | ||
| 81 | |||
| 82 | expect(downloaded.status).toBe("downloaded"); | ||
| 83 | expect(downloaded.downloadedVersion).toBe("1.1.0"); | ||
| 84 | expect(failedInstall.status).toBe("downloaded"); | ||
| 85 | expect(failedInstall.errorContext).toBe("install"); | ||
| 86 | expect(failedInstall.canRetry).toBe(true); | ||
| 87 | }); | ||
| 88 | |||
| 89 | it("clears stale download state when no update is available", () => { | ||
| 90 | const state = reduceDesktopUpdateStateOnNoUpdate( | ||
| 91 | { | ||
| 92 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 93 | enabled: true, | ||
| 94 | status: "error", | ||
| 95 | availableVersion: "1.1.0", | ||
| 96 | downloadedVersion: "1.1.0", | ||
| 97 | message: "old failure", | ||
| 98 | errorContext: "download", | ||
| 99 | canRetry: true, | ||
| 100 | }, | ||
| 101 | "2026-03-04T00:00:00.000Z", | ||
| 102 | ); | ||
| 103 | |||
| 104 | expect(state.status).toBe("up-to-date"); | ||
| 105 | expect(state.availableVersion).toBeNull(); | ||
| 106 | expect(state.downloadedVersion).toBeNull(); | ||
| 107 | expect(state.message).toBeNull(); | ||
| 108 | expect(state.errorContext).toBeNull(); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("tracks available, download start, and progress cleanly", () => { | ||
| 112 | const available = reduceDesktopUpdateStateOnUpdateAvailable( | ||
| 113 | { | ||
| 114 | ...createInitialDesktopUpdateState("1.0.0"), | ||
| 115 | enabled: true, | ||
| 116 | status: "checking", | ||
| 117 | }, | ||
| 118 | "1.1.0", | ||
| 119 | "2026-03-04T00:00:00.000Z", | ||
| 120 | ); | ||
| 121 | const downloading = reduceDesktopUpdateStateOnDownloadStart(available); | ||
| 122 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); | ||
| 123 | |||
| 124 | expect(available.status).toBe("available"); | ||
| 125 | expect(downloading.status).toBe("downloading"); | ||
| 126 | expect(downloading.downloadPercent).toBe(0); | ||
| 127 | expect(progress.downloadPercent).toBe(55.5); | ||
| 128 | expect(progress.errorContext).toBeNull(); | ||
| 129 | }); | ||
| 130 | }); | ||
apps/desktop/src/updateMachine.ts created+155| ... | @@ -0,0 +1,155 @@ | ||
| 1 | import type { DesktopUpdateState } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; | ||
| 4 | |||
| 5 | export function createInitialDesktopUpdateState(currentVersion: string): DesktopUpdateState { | ||
| 6 | return { | ||
| 7 | enabled: false, | ||
| 8 | status: "disabled", | ||
| 9 | currentVersion, | ||
| 10 | availableVersion: null, | ||
| 11 | downloadedVersion: null, | ||
| 12 | downloadPercent: null, | ||
| 13 | checkedAt: null, | ||
| 14 | message: null, | ||
| 15 | errorContext: null, | ||
| 16 | canRetry: false, | ||
| 17 | }; | ||
| 18 | } | ||
| 19 | |||
| 20 | export function reduceDesktopUpdateStateOnCheckStart( | ||
| 21 | state: DesktopUpdateState, | ||
| 22 | checkedAt: string, | ||
| 23 | ): DesktopUpdateState { | ||
| 24 | return { | ||
| 25 | ...state, | ||
| 26 | status: "checking", | ||
| 27 | checkedAt, | ||
| 28 | message: null, | ||
| 29 | downloadPercent: null, | ||
| 30 | errorContext: null, | ||
| 31 | canRetry: false, | ||
| 32 | }; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function reduceDesktopUpdateStateOnCheckFailure( | ||
| 36 | state: DesktopUpdateState, | ||
| 37 | message: string, | ||
| 38 | checkedAt: string, | ||
| 39 | ): DesktopUpdateState { | ||
| 40 | return { | ||
| 41 | ...state, | ||
| 42 | status: "error", | ||
| 43 | message, | ||
| 44 | checkedAt, | ||
| 45 | downloadPercent: null, | ||
| 46 | errorContext: "check", | ||
| 47 | canRetry: true, | ||
| 48 | }; | ||
| 49 | } | ||
| 50 | |||
| 51 | export function reduceDesktopUpdateStateOnUpdateAvailable( | ||
| 52 | state: DesktopUpdateState, | ||
| 53 | version: string, | ||
| 54 | checkedAt: string, | ||
| 55 | ): DesktopUpdateState { | ||
| 56 | return { | ||
| 57 | ...state, | ||
| 58 | status: "available", | ||
| 59 | availableVersion: version, | ||
| 60 | downloadedVersion: null, | ||
| 61 | downloadPercent: null, | ||
| 62 | checkedAt, | ||
| 63 | message: null, | ||
| 64 | errorContext: null, | ||
| 65 | canRetry: false, | ||
| 66 | }; | ||
| 67 | } | ||
| 68 | |||
| 69 | export function reduceDesktopUpdateStateOnNoUpdate( | ||
| 70 | state: DesktopUpdateState, | ||
| 71 | checkedAt: string, | ||
| 72 | ): DesktopUpdateState { | ||
| 73 | return { | ||
| 74 | ...state, | ||
| 75 | status: "up-to-date", | ||
| 76 | availableVersion: null, | ||
| 77 | downloadedVersion: null, | ||
| 78 | downloadPercent: null, | ||
| 79 | checkedAt, | ||
| 80 | message: null, | ||
| 81 | errorContext: null, | ||
| 82 | canRetry: false, | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | export function reduceDesktopUpdateStateOnDownloadStart( | ||
| 87 | state: DesktopUpdateState, | ||
| 88 | ): DesktopUpdateState { | ||
| 89 | return { | ||
| 90 | ...state, | ||
| 91 | status: "downloading", | ||
| 92 | downloadPercent: 0, | ||
| 93 | message: null, | ||
| 94 | errorContext: null, | ||
| 95 | canRetry: false, | ||
| 96 | }; | ||
| 97 | } | ||
| 98 | |||
| 99 | export function reduceDesktopUpdateStateOnDownloadFailure( | ||
| 100 | state: DesktopUpdateState, | ||
| 101 | message: string, | ||
| 102 | ): DesktopUpdateState { | ||
| 103 | return { | ||
| 104 | ...state, | ||
| 105 | status: nextStatusAfterDownloadFailure(state), | ||
| 106 | message, | ||
| 107 | downloadPercent: null, | ||
| 108 | errorContext: "download", | ||
| 109 | canRetry: getCanRetryAfterDownloadFailure(state), | ||
| 110 | }; | ||
| 111 | } | ||
| 112 | |||
| 113 | export function reduceDesktopUpdateStateOnDownloadProgress( | ||
| 114 | state: DesktopUpdateState, | ||
| 115 | percent: number, | ||
| 116 | ): DesktopUpdateState { | ||
| 117 | return { | ||
| 118 | ...state, | ||
| 119 | status: "downloading", | ||
| 120 | downloadPercent: percent, | ||
| 121 | message: null, | ||
| 122 | errorContext: null, | ||
| 123 | canRetry: false, | ||
| 124 | }; | ||
| 125 | } | ||
| 126 | |||
| 127 | export function reduceDesktopUpdateStateOnDownloadComplete( | ||
| 128 | state: DesktopUpdateState, | ||
| 129 | version: string, | ||
| 130 | ): DesktopUpdateState { | ||
| 131 | return { | ||
| 132 | ...state, | ||
| 133 | status: "downloaded", | ||
| 134 | availableVersion: version, | ||
| 135 | downloadedVersion: version, | ||
| 136 | downloadPercent: 100, | ||
| 137 | message: null, | ||
| 138 | errorContext: null, | ||
| 139 | canRetry: true, | ||
| 140 | }; | ||
| 141 | } | ||
| 142 | |||
| 143 | export function reduceDesktopUpdateStateOnInstallFailure( | ||
| 144 | state: DesktopUpdateState, | ||
| 145 | message: string, | ||
| 146 | ): DesktopUpdateState { | ||
| 147 | return { | ||
| 148 | ...state, | ||
| 149 | status: "downloaded", | ||
| 150 | message, | ||
| 151 | errorContext: "install", | ||
| 152 | canRetry: true, | ||
| 153 | }; | ||
| 154 | } | ||
| 155 | |||
apps/desktop/src/updateState.test.ts+61-1| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; | 2 | import type { DesktopUpdateState } from "@t3tools/contracts"; |
| 3 | 3 | ||
| 4 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | 4 | import { |
| 5 | getCanRetryAfterDownloadFailure, | ||
| 6 | getAutoUpdateDisabledReason, | ||
| 7 | nextStatusAfterDownloadFailure, | ||
| 8 | shouldBroadcastDownloadProgress, | ||
| 9 | } from "./updateState"; | ||
| 5 | 10 | ||
| 6 | const baseState: DesktopUpdateState = { | 11 | const baseState: DesktopUpdateState = { |
| 7 | enabled: true, | 12 | enabled: true, |
| ... | @@ -12,6 +17,8 @@ const baseState: DesktopUpdateState = { | ... | @@ -12,6 +17,8 @@ const baseState: DesktopUpdateState = { |
| 12 | downloadPercent: null, | 17 | downloadPercent: null, |
| 13 | checkedAt: null, | 18 | checkedAt: null, |
| 14 | message: null, | 19 | message: null, |
| 20 | errorContext: null, | ||
| 21 | canRetry: false, | ||
| 15 | }; | 22 | }; |
| 16 | 23 | ||
| 17 | describe("shouldBroadcastDownloadProgress", () => { | 24 | describe("shouldBroadcastDownloadProgress", () => { |
| ... | @@ -41,6 +48,15 @@ describe("shouldBroadcastDownloadProgress", () => { | ... | @@ -41,6 +48,15 @@ describe("shouldBroadcastDownloadProgress", () => { |
| 41 | ), | 48 | ), |
| 42 | ).toBe(true); | 49 | ).toBe(true); |
| 43 | }); | 50 | }); |
| 51 | |||
| 52 | it("broadcasts progress updates when a retry resets the download percentage", () => { | ||
| 53 | expect( | ||
| 54 | shouldBroadcastDownloadProgress( | ||
| 55 | { ...baseState, status: "downloading", downloadPercent: 50.4 }, | ||
| 56 | 0.2, | ||
| 57 | ), | ||
| 58 | ).toBe(true); | ||
| 59 | }); | ||
| 44 | }); | 60 | }); |
| 45 | 61 | ||
| 46 | describe("getAutoUpdateDisabledReason", () => { | 62 | describe("getAutoUpdateDisabledReason", () => { |
| ... | @@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => { | ... | @@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => { |
| 80 | ).toContain("AppImage"); | 96 | ).toContain("AppImage"); |
| 81 | }); | 97 | }); |
| 82 | }); | 98 | }); |
| 99 | |||
| 100 | describe("nextStatusAfterDownloadFailure", () => { | ||
| 101 | it("returns available when an update version is still known", () => { | ||
| 102 | expect( | ||
| 103 | nextStatusAfterDownloadFailure({ | ||
| 104 | ...baseState, | ||
| 105 | status: "downloading", | ||
| 106 | availableVersion: "1.1.0", | ||
| 107 | }), | ||
| 108 | ).toBe("available"); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("returns error when no update version can be retried", () => { | ||
| 112 | expect( | ||
| 113 | nextStatusAfterDownloadFailure({ | ||
| 114 | ...baseState, | ||
| 115 | status: "downloading", | ||
| 116 | availableVersion: null, | ||
| 117 | }), | ||
| 118 | ).toBe("error"); | ||
| 119 | }); | ||
| 120 | }); | ||
| 121 | |||
| 122 | describe("getCanRetryAfterDownloadFailure", () => { | ||
| 123 | it("returns true when an available version is still present", () => { | ||
| 124 | expect( | ||
| 125 | getCanRetryAfterDownloadFailure({ | ||
| 126 | ...baseState, | ||
| 127 | status: "downloading", | ||
| 128 | availableVersion: "1.1.0", | ||
| 129 | }), | ||
| 130 | ).toBe(true); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("returns false when no version is available to retry", () => { | ||
| 134 | expect( | ||
| 135 | getCanRetryAfterDownloadFailure({ | ||
| 136 | ...baseState, | ||
| 137 | status: "downloading", | ||
| 138 | availableVersion: null, | ||
| 139 | }), | ||
| 140 | ).toBe(false); | ||
| 141 | }); | ||
| 142 | }); |
apps/desktop/src/updateState.ts+11-1| ... | @@ -15,7 +15,17 @@ export function shouldBroadcastDownloadProgress( | ... | @@ -15,7 +15,17 @@ export function shouldBroadcastDownloadProgress( |
| 15 | 15 | ||
| 16 | const previousStep = Math.floor(currentPercent / 10); | 16 | const previousStep = Math.floor(currentPercent / 10); |
| 17 | const nextStep = Math.floor(nextPercent / 10); | 17 | const nextStep = Math.floor(nextPercent / 10); |
| 18 | return nextStep > previousStep || nextPercent === 100; | 18 | return nextStep !== previousStep || nextPercent === 100; |
| 19 | } | ||
| 20 | |||
| 21 | export function nextStatusAfterDownloadFailure( | ||
| 22 | currentState: DesktopUpdateState, | ||
| 23 | ): DesktopUpdateState["status"] { | ||
| 24 | return currentState.availableVersion ? "available" : "error"; | ||
| 25 | } | ||
| 26 | |||
| 27 | export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean { | ||
| 28 | return currentState.availableVersion !== null; | ||
| 19 | } | 29 | } |
| 20 | 30 | ||
| 21 | export function getAutoUpdateDisabledReason(args: { | 31 | export function getAutoUpdateDisabledReason(args: { |
apps/web/src/components/Sidebar.tsx+65-39| ... | @@ -30,6 +30,15 @@ import { readNativeApi } from "../nativeApi"; | ... | @@ -30,6 +30,15 @@ import { readNativeApi } from "../nativeApi"; |
| 30 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; | 30 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; |
| 31 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 31 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 32 | import { toastManager } from "./ui/toast"; | 32 | import { toastManager } from "./ui/toast"; |
| 33 | import { | ||
| 34 | getDesktopUpdateActionError, | ||
| 35 | getDesktopUpdateButtonTooltip, | ||
| 36 | isDesktopUpdateButtonDisabled, | ||
| 37 | resolveDesktopUpdateButtonAction, | ||
| 38 | shouldHighlightDesktopUpdateError, | ||
| 39 | shouldShowDesktopUpdateButton, | ||
| 40 | shouldToastDesktopUpdateActionResult, | ||
| 41 | } from "./desktopUpdate.logic"; | ||
| 33 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 42 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 34 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 43 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 35 | import { | 44 | import { |
| ... | @@ -83,21 +92,6 @@ interface PrStatusIndicator { | ... | @@ -83,21 +92,6 @@ interface PrStatusIndicator { |
| 83 | 92 | ||
| 84 | type ThreadPr = GitStatusResult["pr"]; | 93 | type ThreadPr = GitStatusResult["pr"]; |
| 85 | 94 | ||
| 86 | function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { | ||
| 87 | if (state.status === "available") { | ||
| 88 | return `Update ${state.availableVersion ?? "available"} ready to download`; | ||
| 89 | } | ||
| 90 | if (state.status === "downloading") { | ||
| 91 | const progress = | ||
| 92 | typeof state.downloadPercent === "number" ? ` (${Math.floor(state.downloadPercent)}%)` : ""; | ||
| 93 | return `Downloading update${progress}`; | ||
| 94 | } | ||
| 95 | if (state.status === "downloaded") { | ||
| 96 | return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; | ||
| 97 | } | ||
| 98 | return "Update available"; | ||
| 99 | } | ||
| 100 | |||
| 101 | function hasUnseenCompletion(thread: Thread): boolean { | 95 | function hasUnseenCompletion(thread: Thread): boolean { |
| 102 | if (!thread.latestTurn?.completedAt) return false; | 96 | if (!thread.latestTurn?.completedAt) return false; |
| 103 | const completedAt = Date.parse(thread.latestTurn.completedAt); | 97 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | @@ -847,18 +841,16 @@ export default function Sidebar() { | ... | @@ -847,18 +841,16 @@ export default function Sidebar() { |
| 847 | if (firstProject) void handleNewThread(firstProject.id); | 841 | if (firstProject) void handleNewThread(firstProject.id); |
| 848 | }; | 842 | }; |
| 849 | 843 | ||
| 850 | const showDesktopUpdateButton = | 844 | const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); |
| 851 | isElectron && | ||
| 852 | desktopUpdateState?.enabled === true && | ||
| 853 | (desktopUpdateState.status === "available" || | ||
| 854 | desktopUpdateState.status === "downloading" || | ||
| 855 | desktopUpdateState.status === "downloaded"); | ||
| 856 | 845 | ||
| 857 | const desktopUpdateTooltip = desktopUpdateState | 846 | const desktopUpdateTooltip = desktopUpdateState |
| 858 | ? getDesktopUpdateButtonTooltip(desktopUpdateState) | 847 | ? getDesktopUpdateButtonTooltip(desktopUpdateState) |
| 859 | : "Update available"; | 848 | : "Update available"; |
| 860 | 849 | ||
| 861 | const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading"; | 850 | const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); |
| 851 | const desktopUpdateButtonAction = desktopUpdateState | ||
| 852 | ? resolveDesktopUpdateButtonAction(desktopUpdateState) | ||
| 853 | : "none"; | ||
| 862 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled | 854 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled |
| 863 | ? "cursor-not-allowed opacity-60" | 855 | ? "cursor-not-allowed opacity-60" |
| 864 | : "hover:bg-accent hover:text-foreground"; | 856 | : "hover:bg-accent hover:text-foreground"; |
| ... | @@ -867,34 +859,67 @@ export default function Sidebar() { | ... | @@ -867,34 +859,67 @@ export default function Sidebar() { |
| 867 | ? "text-emerald-500" | 859 | ? "text-emerald-500" |
| 868 | : desktopUpdateState?.status === "downloading" | 860 | : desktopUpdateState?.status === "downloading" |
| 869 | ? "text-sky-400" | 861 | ? "text-sky-400" |
| 862 | : shouldHighlightDesktopUpdateError(desktopUpdateState) | ||
| 863 | ? "text-rose-500 animate-pulse" | ||
| 870 | : "text-amber-500 animate-pulse"; | 864 | : "text-amber-500 animate-pulse"; |
| 871 | 865 | ||
| 872 | const handleDesktopUpdateButtonClick = useCallback(() => { | 866 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 873 | const bridge = window.desktopBridge; | 867 | const bridge = window.desktopBridge; |
| 874 | if (!bridge || !desktopUpdateState) return; | 868 | if (!bridge || !desktopUpdateState) return; |
| 875 | if (desktopUpdateState.status === "downloading") return; | 869 | if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; |
| 876 | 870 | ||
| 877 | if (desktopUpdateState.status === "available") { | 871 | if (desktopUpdateButtonAction === "download") { |
| 878 | void bridge.downloadUpdate().catch((error) => { | 872 | void bridge |
| 879 | toastManager.add({ | 873 | .downloadUpdate() |
| 880 | type: "error", | 874 | .then((result) => { |
| 881 | title: "Could not start update download", | 875 | if (result.completed) { |
| 882 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | 876 | toastManager.add({ |
| 877 | type: "success", | ||
| 878 | title: "Update downloaded", | ||
| 879 | description: "Restart the app from the update button to install it.", | ||
| 880 | }); | ||
| 881 | } | ||
| 882 | if (!shouldToastDesktopUpdateActionResult(result)) return; | ||
| 883 | const actionError = getDesktopUpdateActionError(result); | ||
| 884 | if (!actionError) return; | ||
| 885 | toastManager.add({ | ||
| 886 | type: "error", | ||
| 887 | title: "Could not download update", | ||
| 888 | description: actionError, | ||
| 889 | }); | ||
| 890 | }) | ||
| 891 | .catch((error) => { | ||
| 892 | toastManager.add({ | ||
| 893 | type: "error", | ||
| 894 | title: "Could not start update download", | ||
| 895 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 896 | }); | ||
| 883 | }); | 897 | }); |
| 884 | }); | ||
| 885 | return; | 898 | return; |
| 886 | } | 899 | } |
| 887 | 900 | ||
| 888 | if (desktopUpdateState.status === "downloaded") { | 901 | if (desktopUpdateButtonAction === "install") { |
| 889 | void bridge.installUpdate().catch((error) => { | 902 | void bridge |
| 890 | toastManager.add({ | 903 | .installUpdate() |
| 891 | type: "error", | 904 | .then((result) => { |
| 892 | title: "Could not install update", | 905 | if (!shouldToastDesktopUpdateActionResult(result)) return; |
| 893 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | 906 | const actionError = getDesktopUpdateActionError(result); |
| 907 | if (!actionError) return; | ||
| 908 | toastManager.add({ | ||
| 909 | type: "error", | ||
| 910 | title: "Could not install update", | ||
| 911 | description: actionError, | ||
| 912 | }); | ||
| 913 | }) | ||
| 914 | .catch((error) => { | ||
| 915 | toastManager.add({ | ||
| 916 | type: "error", | ||
| 917 | title: "Could not install update", | ||
| 918 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | ||
| 919 | }); | ||
| 894 | }); | 920 | }); |
| 895 | }); | ||
| 896 | } | 921 | } |
| 897 | }, [desktopUpdateState]); | 922 | }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); |
| 898 | 923 | ||
| 899 | const wordmark = ( | 924 | const wordmark = ( |
| 900 | <div className="flex items-center gap-2"> | 925 | <div className="flex items-center gap-2"> |
| ... | @@ -936,6 +961,7 @@ export default function Sidebar() { | ... | @@ -936,6 +961,7 @@ export default function Sidebar() { |
| 936 | type="button" | 961 | type="button" |
| 937 | aria-label={desktopUpdateTooltip} | 962 | aria-label={desktopUpdateTooltip} |
| 938 | aria-disabled={desktopUpdateButtonDisabled || undefined} | 963 | aria-disabled={desktopUpdateButtonDisabled || undefined} |
| 964 | disabled={desktopUpdateButtonDisabled} | ||
| 939 | className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} | 965 | className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} |
| 940 | onClick={handleDesktopUpdateButtonClick} | 966 | onClick={handleDesktopUpdateButtonClick} |
| 941 | > | 967 | > |
apps/web/src/components/desktopUpdate.logic.test.ts created+174| ... | @@ -0,0 +1,174 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | getDesktopUpdateActionError, | ||
| 6 | getDesktopUpdateButtonTooltip, | ||
| 7 | isDesktopUpdateButtonDisabled, | ||
| 8 | resolveDesktopUpdateButtonAction, | ||
| 9 | shouldHighlightDesktopUpdateError, | ||
| 10 | shouldShowDesktopUpdateButton, | ||
| 11 | shouldToastDesktopUpdateActionResult, | ||
| 12 | } from "./desktopUpdate.logic"; | ||
| 13 | |||
| 14 | const baseState: DesktopUpdateState = { | ||
| 15 | enabled: true, | ||
| 16 | status: "idle", | ||
| 17 | currentVersion: "1.0.0", | ||
| 18 | availableVersion: null, | ||
| 19 | downloadedVersion: null, | ||
| 20 | downloadPercent: null, | ||
| 21 | checkedAt: null, | ||
| 22 | message: null, | ||
| 23 | errorContext: null, | ||
| 24 | canRetry: false, | ||
| 25 | }; | ||
| 26 | |||
| 27 | describe("desktop update button state", () => { | ||
| 28 | it("shows a download action when an update is available", () => { | ||
| 29 | const state: DesktopUpdateState = { ...baseState, status: "available", availableVersion: "1.1.0" }; | ||
| 30 | expect(shouldShowDesktopUpdateButton(state)).toBe(true); | ||
| 31 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("keeps retry action available after a download error", () => { | ||
| 35 | const state: DesktopUpdateState = { | ||
| 36 | ...baseState, | ||
| 37 | status: "error", | ||
| 38 | availableVersion: "1.1.0", | ||
| 39 | message: "network timeout", | ||
| 40 | errorContext: "download", | ||
| 41 | canRetry: true, | ||
| 42 | }; | ||
| 43 | expect(shouldShowDesktopUpdateButton(state)).toBe(true); | ||
| 44 | expect(resolveDesktopUpdateButtonAction(state)).toBe("download"); | ||
| 45 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); | ||
| 46 | }); | ||
| 47 | |||
| 48 | it("keeps install action available after an install error", () => { | ||
| 49 | const state: DesktopUpdateState = { | ||
| 50 | ...baseState, | ||
| 51 | status: "error", | ||
| 52 | downloadedVersion: "1.1.0", | ||
| 53 | availableVersion: "1.1.0", | ||
| 54 | message: "shutdown timeout", | ||
| 55 | errorContext: "install", | ||
| 56 | canRetry: true, | ||
| 57 | }; | ||
| 58 | expect(shouldShowDesktopUpdateButton(state)).toBe(true); | ||
| 59 | expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); | ||
| 60 | expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("hides the button for non-actionable check errors", () => { | ||
| 64 | const state: DesktopUpdateState = { | ||
| 65 | ...baseState, | ||
| 66 | status: "error", | ||
| 67 | message: "network unavailable", | ||
| 68 | errorContext: "check", | ||
| 69 | canRetry: true, | ||
| 70 | }; | ||
| 71 | expect(shouldShowDesktopUpdateButton(state)).toBe(false); | ||
| 72 | expect(resolveDesktopUpdateButtonAction(state)).toBe("none"); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("disables the button while downloading", () => { | ||
| 76 | const state: DesktopUpdateState = { | ||
| 77 | ...baseState, | ||
| 78 | status: "downloading", | ||
| 79 | availableVersion: "1.1.0", | ||
| 80 | downloadPercent: 42.5, | ||
| 81 | }; | ||
| 82 | expect(shouldShowDesktopUpdateButton(state)).toBe(true); | ||
| 83 | expect(isDesktopUpdateButtonDisabled(state)).toBe(true); | ||
| 84 | expect(getDesktopUpdateButtonTooltip(state)).toContain("42%"); | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | describe("getDesktopUpdateActionError", () => { | ||
| 89 | it("returns user-visible message for accepted failed attempts", () => { | ||
| 90 | const result: DesktopUpdateActionResult = { | ||
| 91 | accepted: true, | ||
| 92 | completed: false, | ||
| 93 | state: { | ||
| 94 | ...baseState, | ||
| 95 | status: "available", | ||
| 96 | availableVersion: "1.1.0", | ||
| 97 | message: "checksum mismatch", | ||
| 98 | errorContext: "download", | ||
| 99 | canRetry: true, | ||
| 100 | }, | ||
| 101 | }; | ||
| 102 | expect(getDesktopUpdateActionError(result)).toBe("checksum mismatch"); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it("ignores messages for non-accepted attempts", () => { | ||
| 106 | const result: DesktopUpdateActionResult = { | ||
| 107 | accepted: false, | ||
| 108 | completed: false, | ||
| 109 | state: { | ||
| 110 | ...baseState, | ||
| 111 | status: "error", | ||
| 112 | message: "background failure", | ||
| 113 | errorContext: "check", | ||
| 114 | canRetry: false, | ||
| 115 | }, | ||
| 116 | }; | ||
| 117 | expect(getDesktopUpdateActionError(result)).toBeNull(); | ||
| 118 | }); | ||
| 119 | |||
| 120 | it("ignores messages for successful attempts", () => { | ||
| 121 | const result: DesktopUpdateActionResult = { | ||
| 122 | accepted: true, | ||
| 123 | completed: true, | ||
| 124 | state: { | ||
| 125 | ...baseState, | ||
| 126 | status: "downloaded", | ||
| 127 | downloadedVersion: "1.1.0", | ||
| 128 | availableVersion: "1.1.0", | ||
| 129 | message: null, | ||
| 130 | errorContext: null, | ||
| 131 | canRetry: true, | ||
| 132 | }, | ||
| 133 | }; | ||
| 134 | expect(getDesktopUpdateActionError(result)).toBeNull(); | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | describe("desktop update UI helpers", () => { | ||
| 139 | it("toasts only for accepted incomplete actions", () => { | ||
| 140 | expect( | ||
| 141 | shouldToastDesktopUpdateActionResult({ | ||
| 142 | accepted: true, | ||
| 143 | completed: false, | ||
| 144 | state: baseState, | ||
| 145 | }), | ||
| 146 | ).toBe(true); | ||
| 147 | expect( | ||
| 148 | shouldToastDesktopUpdateActionResult({ | ||
| 149 | accepted: true, | ||
| 150 | completed: true, | ||
| 151 | state: baseState, | ||
| 152 | }), | ||
| 153 | ).toBe(false); | ||
| 154 | }); | ||
| 155 | |||
| 156 | it("highlights only actionable updater errors", () => { | ||
| 157 | expect( | ||
| 158 | shouldHighlightDesktopUpdateError({ | ||
| 159 | ...baseState, | ||
| 160 | status: "error", | ||
| 161 | errorContext: "download", | ||
| 162 | canRetry: true, | ||
| 163 | }), | ||
| 164 | ).toBe(true); | ||
| 165 | expect( | ||
| 166 | shouldHighlightDesktopUpdateError({ | ||
| 167 | ...baseState, | ||
| 168 | status: "error", | ||
| 169 | errorContext: "check", | ||
| 170 | canRetry: true, | ||
| 171 | }), | ||
| 172 | ).toBe(false); | ||
| 173 | }); | ||
| 174 | }); | ||
apps/web/src/components/desktopUpdate.logic.ts created+77| ... | @@ -0,0 +1,77 @@ | ||
| 1 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type DesktopUpdateButtonAction = "download" | "install" | "none"; | ||
| 4 | |||
| 5 | export function resolveDesktopUpdateButtonAction( | ||
| 6 | state: DesktopUpdateState, | ||
| 7 | ): DesktopUpdateButtonAction { | ||
| 8 | if (state.status === "available") { | ||
| 9 | return "download"; | ||
| 10 | } | ||
| 11 | if (state.status === "downloaded") { | ||
| 12 | return "install"; | ||
| 13 | } | ||
| 14 | if (state.status === "error") { | ||
| 15 | if (state.errorContext === "install" && state.downloadedVersion) { | ||
| 16 | return "install"; | ||
| 17 | } | ||
| 18 | if (state.errorContext === "download" && state.availableVersion) { | ||
| 19 | return "download"; | ||
| 20 | } | ||
| 21 | } | ||
| 22 | return "none"; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function shouldShowDesktopUpdateButton(state: DesktopUpdateState | null): boolean { | ||
| 26 | if (!state || !state.enabled) { | ||
| 27 | return false; | ||
| 28 | } | ||
| 29 | if (state.status === "downloading") { | ||
| 30 | return true; | ||
| 31 | } | ||
| 32 | return resolveDesktopUpdateButtonAction(state) !== "none"; | ||
| 33 | } | ||
| 34 | |||
| 35 | export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean { | ||
| 36 | return state?.status === "downloading"; | ||
| 37 | } | ||
| 38 | |||
| 39 | export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string { | ||
| 40 | if (state.status === "available") { | ||
| 41 | return `Update ${state.availableVersion ?? "available"} ready to download`; | ||
| 42 | } | ||
| 43 | if (state.status === "downloading") { | ||
| 44 | const progress = | ||
| 45 | typeof state.downloadPercent === "number" ? ` (${Math.floor(state.downloadPercent)}%)` : ""; | ||
| 46 | return `Downloading update${progress}`; | ||
| 47 | } | ||
| 48 | if (state.status === "downloaded") { | ||
| 49 | return `Update ${state.downloadedVersion ?? state.availableVersion ?? "ready"} downloaded. Click to restart and install.`; | ||
| 50 | } | ||
| 51 | if (state.status === "error") { | ||
| 52 | if (state.errorContext === "download" && state.availableVersion) { | ||
| 53 | return `Download failed for ${state.availableVersion}. Click to retry.`; | ||
| 54 | } | ||
| 55 | if (state.errorContext === "install" && state.downloadedVersion) { | ||
| 56 | return `Install failed for ${state.downloadedVersion}. Click to retry.`; | ||
| 57 | } | ||
| 58 | return state.message ?? "Update failed"; | ||
| 59 | } | ||
| 60 | return "Update available"; | ||
| 61 | } | ||
| 62 | |||
| 63 | export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { | ||
| 64 | if (!result.accepted || result.completed) return null; | ||
| 65 | if (typeof result.state.message !== "string") return null; | ||
| 66 | const message = result.state.message.trim(); | ||
| 67 | return message.length > 0 ? message : null; | ||
| 68 | } | ||
| 69 | |||
| 70 | export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean { | ||
| 71 | return result.accepted && !result.completed; | ||
| 72 | } | ||
| 73 | |||
| 74 | export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | null): boolean { | ||
| 75 | if (!state || state.status !== "error") return false; | ||
| 76 | return state.errorContext === "download" || state.errorContext === "install"; | ||
| 77 | } | ||
packages/contracts/src/ipc.ts+10-2| ... | @@ -62,6 +62,14 @@ export interface DesktopUpdateState { | ... | @@ -62,6 +62,14 @@ export interface DesktopUpdateState { |
| 62 | downloadPercent: number | null; | 62 | downloadPercent: number | null; |
| 63 | checkedAt: string | null; | 63 | checkedAt: string | null; |
| 64 | message: string | null; | 64 | message: string | null; |
| 65 | errorContext: "check" | "download" | "install" | null; | ||
| 66 | canRetry: boolean; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface DesktopUpdateActionResult { | ||
| 70 | accepted: boolean; | ||
| 71 | completed: boolean; | ||
| 72 | state: DesktopUpdateState; | ||
| 65 | } | 73 | } |
| 66 | 74 | ||
| 67 | export interface DesktopBridge { | 75 | export interface DesktopBridge { |
| ... | @@ -75,8 +83,8 @@ export interface DesktopBridge { | ... | @@ -75,8 +83,8 @@ export interface DesktopBridge { |
| 75 | openExternal: (url: string) => Promise<boolean>; | 83 | openExternal: (url: string) => Promise<boolean>; |
| 76 | onMenuAction: (listener: (action: string) => void) => () => void; | 84 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 77 | getUpdateState: () => Promise<DesktopUpdateState>; | 85 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 78 | downloadUpdate: () => Promise<void>; | 86 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; |
| 79 | installUpdate: () => Promise<void>; | 87 | installUpdate: () => Promise<DesktopUpdateActionResult>; |
| 80 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; | 88 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |
| 81 | } | 89 | } |
| 82 | 90 |