| 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 | import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; |
| 8 | 8 | import type { MenuItemConstructorOptions } from "electron"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import type { DesktopUpdateState } from "@t3tools/contracts"; | |
| 10 | import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; | |
| 11 | 11 | import { autoUpdater } from "electron-updater"; |
| 12 | 12 | |
| 13 | 13 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| ... | ... | @@ -15,7 +15,22 @@ import { NetService } from "@t3tools/shared/Net"; |
| 15 | 15 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 16 | 16 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 17 | 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 | 35 | fixPath(); |
| 21 | 36 | |
| ... | ... | @@ -44,6 +59,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); |
| 44 | 59 | const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 45 | 60 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 46 | 61 | |
| 62 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; | |
| 63 | ||
| 47 | 64 | let mainWindow: BrowserWindow | null = null; |
| 48 | 65 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 49 | 66 | let backendPort = 0; |
| ... | ... | @@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null; |
| 59 | 76 | let restoreStdIoCapture: (() => void) | null = null; |
| 60 | 77 | |
| 61 | 78 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 79 | const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion()); | |
| 62 | 80 | |
| 63 | 81 | function logTimestamp(): string { |
| 64 | 82 | return new Date().toISOString(); |
| ... | ... | @@ -208,16 +226,13 @@ let updateStartupTimer: ReturnType<typeof setTimeout> | null = null; |
| 208 | 226 | let updateCheckInFlight = false; |
| 209 | 227 | let updateDownloadInFlight = false; |
| 210 | 228 | let updaterConfigured = false; |
| 211 | let updateState: DesktopUpdateState = { | |
| 212 | enabled: false, | |
| 213 | status: "disabled", | |
| 214 | currentVersion: app.getVersion(), | |
| 215 | availableVersion: null, | |
| 216 | downloadedVersion: null, | |
| 217 | downloadPercent: null, | |
| 218 | checkedAt: null, | |
| 219 | message: null, | |
| 220 | }; | |
| 229 | let updateState: DesktopUpdateState = initialUpdateState(); | |
| 230 | ||
| 231 | function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { | |
| 232 | if (updateDownloadInFlight) return "download"; | |
| 233 | if (updateCheckInFlight) return "check"; | |
| 234 | return updateState.errorContext; | |
| 235 | } | |
| 221 | 236 | |
| 222 | 237 | protocol.registerSchemesAsPrivileged([ |
| 223 | 238 | { |
| ... | ... | @@ -600,78 +615,67 @@ async function checkForUpdates(reason: string): Promise<void> { |
| 600 | 615 | return; |
| 601 | 616 | } |
| 602 | 617 | updateCheckInFlight = true; |
| 603 | setUpdateState({ | |
| 604 | status: "checking", | |
| 605 | checkedAt: new Date().toISOString(), | |
| 606 | message: null, | |
| 607 | downloadPercent: null, | |
| 608 | }); | |
| 618 | setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); | |
| 609 | 619 | console.info(`[desktop-updater] Checking for updates (${reason})...`); |
| 610 | 620 | |
| 611 | 621 | try { |
| 612 | 622 | await autoUpdater.checkForUpdates(); |
| 613 | 623 | } catch (error: unknown) { |
| 614 | 624 | const message = error instanceof Error ? error.message : String(error); |
| 615 | setUpdateState({ | |
| 616 | status: "error", | |
| 617 | message, | |
| 618 | checkedAt: new Date().toISOString(), | |
| 619 | downloadPercent: null, | |
| 620 | }); | |
| 625 | setUpdateState(reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString())); | |
| 621 | 626 | console.error(`[desktop-updater] Failed to check for updates: ${message}`); |
| 622 | 627 | } finally { |
| 623 | 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 | 633 | if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") { |
| 629 | return; | |
| 634 | return { accepted: false, completed: false }; | |
| 630 | 635 | } |
| 631 | 636 | updateDownloadInFlight = true; |
| 632 | setUpdateState({ | |
| 633 | status: "downloading", | |
| 634 | downloadPercent: 0, | |
| 635 | message: null, | |
| 636 | }); | |
| 637 | setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState)); | |
| 637 | 638 | console.info("[desktop-updater] Downloading update..."); |
| 638 | 639 | |
| 639 | 640 | try { |
| 640 | 641 | await autoUpdater.downloadUpdate(); |
| 642 | return { accepted: true, completed: true }; | |
| 641 | 643 | } catch (error: unknown) { |
| 642 | 644 | const message = error instanceof Error ? error.message : String(error); |
| 643 | setUpdateState({ | |
| 644 | status: "error", | |
| 645 | message, | |
| 646 | downloadPercent: null, | |
| 647 | }); | |
| 645 | setUpdateState(reduceDesktopUpdateStateOnDownloadFailure(updateState, message)); | |
| 648 | 646 | console.error(`[desktop-updater] Failed to download update: ${message}`); |
| 647 | return { accepted: true, completed: false }; | |
| 649 | 648 | } finally { |
| 650 | 649 | updateDownloadInFlight = false; |
| 651 | 650 | } |
| 652 | 651 | } |
| 653 | 652 | |
| 654 | function installDownloadedUpdate(): void { | |
| 655 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") return; | |
| 653 | async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> { | |
| 654 | if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") { | |
| 655 | return { accepted: false, completed: false }; | |
| 656 | } | |
| 656 | 657 | |
| 657 | 658 | isQuitting = true; |
| 658 | 659 | clearUpdatePollTimer(); |
| 659 | void stopBackendAndWaitForExit().finally(() => { | |
| 660 | try { | |
| 661 | await stopBackendAndWaitForExit(); | |
| 660 | 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 | 673 | function configureAutoUpdater(): void { |
| 665 | 674 | const enabled = shouldEnableAutoUpdates(); |
| 666 | 675 | setUpdateState({ |
| 676 | ...createInitialDesktopUpdateState(app.getVersion()), | |
| 667 | 677 | enabled, |
| 668 | 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 | 680 | if (!enabled) { |
| 677 | 681 | return; |
| ... | ... | @@ -698,26 +702,12 @@ function configureAutoUpdater(): void { |
| 698 | 702 | console.info("[desktop-updater] Looking for updates..."); |
| 699 | 703 | }); |
| 700 | 704 | autoUpdater.on("update-available", (info) => { |
| 701 | setUpdateState({ | |
| 702 | status: "available", | |
| 703 | availableVersion: info.version, | |
| 704 | downloadedVersion: null, | |
| 705 | downloadPercent: null, | |
| 706 | checkedAt: new Date().toISOString(), | |
| 707 | message: null, | |
| 708 | }); | |
| 705 | setUpdateState(reduceDesktopUpdateStateOnUpdateAvailable(updateState, info.version, new Date().toISOString())); | |
| 709 | 706 | lastLoggedDownloadMilestone = -1; |
| 710 | 707 | console.info(`[desktop-updater] Update available: ${info.version}`); |
| 711 | 708 | }); |
| 712 | 709 | autoUpdater.on("update-not-available", () => { |
| 713 | setUpdateState({ | |
| 714 | status: "up-to-date", | |
| 715 | availableVersion: null, | |
| 716 | downloadedVersion: null, | |
| 717 | downloadPercent: null, | |
| 718 | checkedAt: new Date().toISOString(), | |
| 719 | message: null, | |
| 720 | }); | |
| 710 | setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); | |
| 721 | 711 | lastLoggedDownloadMilestone = -1; |
| 722 | 712 | console.info("[desktop-updater] No updates available."); |
| 723 | 713 | }); |
| ... | ... | @@ -729,6 +719,8 @@ function configureAutoUpdater(): void { |
| 729 | 719 | message, |
| 730 | 720 | checkedAt: new Date().toISOString(), |
| 731 | 721 | downloadPercent: null, |
| 722 | errorContext: resolveUpdaterErrorContext(), | |
| 723 | canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null, | |
| 732 | 724 | }); |
| 733 | 725 | } |
| 734 | 726 | console.error(`[desktop-updater] Updater error: ${message}`); |
| ... | ... | @@ -739,11 +731,7 @@ function configureAutoUpdater(): void { |
| 739 | 731 | shouldBroadcastDownloadProgress(updateState, progress.percent) || |
| 740 | 732 | updateState.message !== null |
| 741 | 733 | ) { |
| 742 | setUpdateState({ | |
| 743 | status: "downloading", | |
| 744 | downloadPercent: progress.percent, | |
| 745 | message: null, | |
| 746 | }); | |
| 734 | setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent)); | |
| 747 | 735 | } |
| 748 | 736 | const milestone = percent - (percent % 10); |
| 749 | 737 | if (milestone > lastLoggedDownloadMilestone) { |
| ... | ... | @@ -752,13 +740,7 @@ function configureAutoUpdater(): void { |
| 752 | 740 | } |
| 753 | 741 | }); |
| 754 | 742 | autoUpdater.on("update-downloaded", (info) => { |
| 755 | setUpdateState({ | |
| 756 | status: "downloaded", | |
| 757 | availableVersion: info.version, | |
| 758 | downloadedVersion: info.version, | |
| 759 | downloadPercent: 100, | |
| 760 | message: null, | |
| 761 | }); | |
| 743 | setUpdateState(reduceDesktopUpdateStateOnDownloadComplete(updateState, info.version)); | |
| 762 | 744 | console.info(`[desktop-updater] Update downloaded: ${info.version}`); |
| 763 | 745 | }); |
| 764 | 746 | |
| ... | ... | @@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void { |
| 1044 | 1026 | |
| 1045 | 1027 | ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); |
| 1046 | 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 | 1037 | ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL); |
| 1051 | 1038 | ipcMain.handle(UPDATE_INSTALL_CHANNEL, async () => { |
| 1052 | if (isQuitting) return; | |
| 1053 | installDownloadedUpdate(); | |
| 1039 | if (isQuitting) { | |
| 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 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 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 | 11 | const baseState: DesktopUpdateState = { |
| 7 | 12 | enabled: true, |
| ... | ... | @@ -12,6 +17,8 @@ const baseState: DesktopUpdateState = { |
| 12 | 17 | downloadPercent: null, |
| 13 | 18 | checkedAt: null, |
| 14 | 19 | message: null, |
| 20 | errorContext: null, | |
| 21 | canRetry: false, | |
| 15 | 22 | }; |
| 16 | 23 | |
| 17 | 24 | describe("shouldBroadcastDownloadProgress", () => { |
| ... | ... | @@ -41,6 +48,15 @@ describe("shouldBroadcastDownloadProgress", () => { |
| 41 | 48 | ), |
| 42 | 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 | 62 | describe("getAutoUpdateDisabledReason", () => { |
| ... | ... | @@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => { |
| 80 | 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 | 15 | |
| 16 | 16 | const previousStep = Math.floor(currentPercent / 10); |
| 17 | 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 | 31 | export function getAutoUpdateDisabledReason(args: { |
apps/web/src/components/Sidebar.tsx+65-39| ... | ... | @@ -30,6 +30,15 @@ import { readNativeApi } from "../nativeApi"; |
| 30 | 30 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; |
| 31 | 31 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 32 | 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 | 42 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 34 | 43 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 35 | 44 | import { |
| ... | ... | @@ -83,21 +92,6 @@ interface PrStatusIndicator { |
| 83 | 92 | |
| 84 | 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 | 95 | function hasUnseenCompletion(thread: Thread): boolean { |
| 102 | 96 | if (!thread.latestTurn?.completedAt) return false; |
| 103 | 97 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | ... | @@ -847,18 +841,16 @@ export default function Sidebar() { |
| 847 | 841 | if (firstProject) void handleNewThread(firstProject.id); |
| 848 | 842 | }; |
| 849 | 843 | |
| 850 | const showDesktopUpdateButton = | |
| 851 | isElectron && | |
| 852 | desktopUpdateState?.enabled === true && | |
| 853 | (desktopUpdateState.status === "available" || | |
| 854 | desktopUpdateState.status === "downloading" || | |
| 855 | desktopUpdateState.status === "downloaded"); | |
| 844 | const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); | |
| 856 | 845 | |
| 857 | 846 | const desktopUpdateTooltip = desktopUpdateState |
| 858 | 847 | ? getDesktopUpdateButtonTooltip(desktopUpdateState) |
| 859 | 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 | 854 | const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled |
| 863 | 855 | ? "cursor-not-allowed opacity-60" |
| 864 | 856 | : "hover:bg-accent hover:text-foreground"; |
| ... | ... | @@ -867,34 +859,67 @@ export default function Sidebar() { |
| 867 | 859 | ? "text-emerald-500" |
| 868 | 860 | : desktopUpdateState?.status === "downloading" |
| 869 | 861 | ? "text-sky-400" |
| 862 | : shouldHighlightDesktopUpdateError(desktopUpdateState) | |
| 863 | ? "text-rose-500 animate-pulse" | |
| 870 | 864 | : "text-amber-500 animate-pulse"; |
| 871 | 865 | |
| 872 | 866 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 873 | 867 | const bridge = window.desktopBridge; |
| 874 | 868 | if (!bridge || !desktopUpdateState) return; |
| 875 | if (desktopUpdateState.status === "downloading") return; | |
| 876 | ||
| 877 | if (desktopUpdateState.status === "available") { | |
| 878 | void bridge.downloadUpdate().catch((error) => { | |
| 879 | toastManager.add({ | |
| 880 | type: "error", | |
| 881 | title: "Could not start update download", | |
| 882 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 869 | if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return; | |
| 870 | ||
| 871 | if (desktopUpdateButtonAction === "download") { | |
| 872 | void bridge | |
| 873 | .downloadUpdate() | |
| 874 | .then((result) => { | |
| 875 | if (result.completed) { | |
| 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 | 898 | return; |
| 886 | 899 | } |
| 887 | 900 | |
| 888 | if (desktopUpdateState.status === "downloaded") { | |
| 889 | void bridge.installUpdate().catch((error) => { | |
| 890 | toastManager.add({ | |
| 891 | type: "error", | |
| 892 | title: "Could not install update", | |
| 893 | description: error instanceof Error ? error.message : "An unexpected error occurred.", | |
| 901 | if (desktopUpdateButtonAction === "install") { | |
| 902 | void bridge | |
| 903 | .installUpdate() | |
| 904 | .then((result) => { | |
| 905 | if (!shouldToastDesktopUpdateActionResult(result)) return; | |
| 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 | 924 | const wordmark = ( |
| 900 | 925 | <div className="flex items-center gap-2"> |
| ... | ... | @@ -936,6 +961,7 @@ export default function Sidebar() { |
| 936 | 961 | type="button" |
| 937 | 962 | aria-label={desktopUpdateTooltip} |
| 938 | 963 | aria-disabled={desktopUpdateButtonDisabled || undefined} |
| 964 | disabled={desktopUpdateButtonDisabled} | |
| 939 | 965 | className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} |
| 940 | 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 | 62 | downloadPercent: number | null; |
| 63 | 63 | checkedAt: string | null; |
| 64 | 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 | 75 | export interface DesktopBridge { |
| ... | ... | @@ -75,8 +83,8 @@ export interface DesktopBridge { |
| 75 | 83 | openExternal: (url: string) => Promise<boolean>; |
| 76 | 84 | onMenuAction: (listener: (action: string) => void) => () => void; |
| 77 | 85 | getUpdateState: () => Promise<DesktopUpdateState>; |
| 78 | downloadUpdate: () => Promise<void>; | |
| 79 | installUpdate: () => Promise<void>; | |
| 86 | downloadUpdate: () => Promise<DesktopUpdateActionResult>; | |
| 87 | installUpdate: () => Promise<DesktopUpdateActionResult>; | |
| 80 | 88 | onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; |
| 81 | 89 | } |
| 82 | 90 |