authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 22:12:34-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 22:31:45-08:00
logec89587c37fe250fb12c90c10d27a9d0871d1968
treecf94d9b11a2c177c0da222c15873cb8b4137d2fb
parent32d89806d7b17dbcffa4f02b46b24b410bf54b5e

Refactor desktop update flow with retryable action results

- 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 tooltips

9 files changed, 758 insertions(+), 120 deletions(-)

apps/desktop/src/main.ts+75-77
......@@ -7,7 +7,7 @@ import * as Path from "node:path";
77import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
88import type { MenuItemConstructorOptions } from "electron";
99import * as Effect from "effect/Effect";
10import type { DesktopUpdateState } from "@t3tools/contracts";
10import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
1111import { autoUpdater } from "electron-updater";
1212
1313import type { ContextMenuItem } from "@t3tools/contracts";
......@@ -15,7 +15,22 @@ import { NetService } from "@t3tools/shared/Net";
1515import { RotatingFileSink } from "@t3tools/shared/logging";
1616import { showDesktopConfirmDialog } from "./confirmDialog";
1717import { fixPath } from "./fixPath";
18import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
18import {
19 getAutoUpdateDisabledReason,
20 shouldBroadcastDownloadProgress,
21} from "./updateState";
22import {
23 createInitialDesktopUpdateState,
24 reduceDesktopUpdateStateOnCheckFailure,
25 reduceDesktopUpdateStateOnCheckStart,
26 reduceDesktopUpdateStateOnDownloadComplete,
27 reduceDesktopUpdateStateOnDownloadFailure,
28 reduceDesktopUpdateStateOnDownloadProgress,
29 reduceDesktopUpdateStateOnDownloadStart,
30 reduceDesktopUpdateStateOnInstallFailure,
31 reduceDesktopUpdateStateOnNoUpdate,
32 reduceDesktopUpdateStateOnUpdateAvailable,
33} from "./updateMachine";
1934
2035fixPath();
2136
......@@ -44,6 +59,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex");
4459const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
4560const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
4661
62type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
63
4764let mainWindow: BrowserWindow | null = null;
4865let backendProcess: ChildProcess.ChildProcess | null = null;
4966let backendPort = 0;
......@@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null;
5976let restoreStdIoCapture: (() => void) | null = null;
6077
6178let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
79const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion());
6280
6381function logTimestamp(): string {
6482 return new Date().toISOString();
......@@ -208,16 +226,13 @@ let updateStartupTimer: ReturnType<typeof setTimeout> | null = null;
208226let updateCheckInFlight = false;
209227let updateDownloadInFlight = false;
210228let updaterConfigured = false;
211let 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};
229let updateState: DesktopUpdateState = initialUpdateState();
230
231function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
232 if (updateDownloadInFlight) return "download";
233 if (updateCheckInFlight) return "check";
234 return updateState.errorContext;
235}
221236
222237protocol.registerSchemesAsPrivileged([
223238 {
......@@ -600,78 +615,67 @@ async function checkForUpdates(reason: string): Promise<void> {
600615 return;
601616 }
602617 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()));
609619 console.info(`[desktop-updater] Checking for updates (${reason})...`);
610620
611621 try {
612622 await autoUpdater.checkForUpdates();
613623 } catch (error: unknown) {
614624 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()));
621626 console.error(`[desktop-updater] Failed to check for updates: ${message}`);
622627 } finally {
623628 updateCheckInFlight = false;
624629 }
625630}
626631
627async function downloadAvailableUpdate(): Promise<void> {
632async function downloadAvailableUpdate(): Promise<{ accepted: boolean; completed: boolean }> {
628633 if (!updaterConfigured || updateDownloadInFlight || updateState.status !== "available") {
629 return;
634 return { accepted: false, completed: false };
630635 }
631636 updateDownloadInFlight = true;
632 setUpdateState({
633 status: "downloading",
634 downloadPercent: 0,
635 message: null,
636 });
637 setUpdateState(reduceDesktopUpdateStateOnDownloadStart(updateState));
637638 console.info("[desktop-updater] Downloading update...");
638639
639640 try {
640641 await autoUpdater.downloadUpdate();
642 return { accepted: true, completed: true };
641643 } catch (error: unknown) {
642644 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));
648646 console.error(`[desktop-updater] Failed to download update: ${message}`);
647 return { accepted: true, completed: false };
649648 } finally {
650649 updateDownloadInFlight = false;
651650 }
652651}
653652
654function installDownloadedUpdate(): void {
655 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") return;
653async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed: boolean }> {
654 if (isQuitting || !updaterConfigured || updateState.status !== "downloaded") {
655 return { accepted: false, completed: false };
656 }
656657
657658 isQuitting = true;
658659 clearUpdatePollTimer();
659 void stopBackendAndWaitForExit().finally(() => {
660 try {
661 await stopBackendAndWaitForExit();
660662 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 }
662671}
663672
664673function configureAutoUpdater(): void {
665674 const enabled = shouldEnableAutoUpdates();
666675 setUpdateState({
676 ...createInitialDesktopUpdateState(app.getVersion()),
667677 enabled,
668678 status: enabled ? "idle" : "disabled",
669 currentVersion: app.getVersion(),
670 availableVersion: null,
671 downloadedVersion: null,
672 downloadPercent: null,
673 checkedAt: null,
674 message: null,
675679 });
676680 if (!enabled) {
677681 return;
......@@ -698,26 +702,12 @@ function configureAutoUpdater(): void {
698702 console.info("[desktop-updater] Looking for updates...");
699703 });
700704 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()));
709706 lastLoggedDownloadMilestone = -1;
710707 console.info(`[desktop-updater] Update available: ${info.version}`);
711708 });
712709 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()));
721711 lastLoggedDownloadMilestone = -1;
722712 console.info("[desktop-updater] No updates available.");
723713 });
......@@ -729,6 +719,8 @@ function configureAutoUpdater(): void {
729719 message,
730720 checkedAt: new Date().toISOString(),
731721 downloadPercent: null,
722 errorContext: resolveUpdaterErrorContext(),
723 canRetry: updateState.availableVersion !== null || updateState.downloadedVersion !== null,
732724 });
733725 }
734726 console.error(`[desktop-updater] Updater error: ${message}`);
......@@ -739,11 +731,7 @@ function configureAutoUpdater(): void {
739731 shouldBroadcastDownloadProgress(updateState, progress.percent) ||
740732 updateState.message !== null
741733 ) {
742 setUpdateState({
743 status: "downloading",
744 downloadPercent: progress.percent,
745 message: null,
746 });
734 setUpdateState(reduceDesktopUpdateStateOnDownloadProgress(updateState, progress.percent));
747735 }
748736 const milestone = percent - (percent % 10);
749737 if (milestone > lastLoggedDownloadMilestone) {
......@@ -752,13 +740,7 @@ function configureAutoUpdater(): void {
752740 }
753741 });
754742 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));
762744 console.info(`[desktop-updater] Update downloaded: ${info.version}`);
763745 });
764746
......@@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void {
10441026
10451027 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
10461028 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;
10481035 });
10491036
10501037 ipcMain.removeHandler(UPDATE_INSTALL_CHANNEL);
10511038 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;
10541052 });
10551053}
10561054
apps/desktop/src/updateMachine.test.ts created+130
......@@ -0,0 +1,130 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 createInitialDesktopUpdateState,
5 reduceDesktopUpdateStateOnCheckFailure,
6 reduceDesktopUpdateStateOnCheckStart,
7 reduceDesktopUpdateStateOnDownloadComplete,
8 reduceDesktopUpdateStateOnDownloadFailure,
9 reduceDesktopUpdateStateOnDownloadProgress,
10 reduceDesktopUpdateStateOnDownloadStart,
11 reduceDesktopUpdateStateOnInstallFailure,
12 reduceDesktopUpdateStateOnNoUpdate,
13 reduceDesktopUpdateStateOnUpdateAvailable,
14} from "./updateMachine";
15
16describe("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 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
2
3import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";
4
5export 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
20export 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
35export 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
51export 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
69export 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
86export 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
99export 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
113export 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
127export 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
143export 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 @@
11import { describe, expect, it } from "vitest";
22import type { DesktopUpdateState } from "@t3tools/contracts";
33
4import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
4import {
5 getCanRetryAfterDownloadFailure,
6 getAutoUpdateDisabledReason,
7 nextStatusAfterDownloadFailure,
8 shouldBroadcastDownloadProgress,
9} from "./updateState";
510
611const baseState: DesktopUpdateState = {
712 enabled: true,
......@@ -12,6 +17,8 @@ const baseState: DesktopUpdateState = {
1217 downloadPercent: null,
1318 checkedAt: null,
1419 message: null,
20 errorContext: null,
21 canRetry: false,
1522};
1623
1724describe("shouldBroadcastDownloadProgress", () => {
......@@ -41,6 +48,15 @@ describe("shouldBroadcastDownloadProgress", () => {
4148 ),
4249 ).toBe(true);
4350 });
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 });
4460});
4561
4662describe("getAutoUpdateDisabledReason", () => {
......@@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => {
8096 ).toContain("AppImage");
8197 });
8298});
99
100describe("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
122describe("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(
1515
1616 const previousStep = Math.floor(currentPercent / 10);
1717 const nextStep = Math.floor(nextPercent / 10);
18 return nextStep > previousStep || nextPercent === 100;
18 return nextStep !== previousStep || nextPercent === 100;
19}
20
21export function nextStatusAfterDownloadFailure(
22 currentState: DesktopUpdateState,
23): DesktopUpdateState["status"] {
24 return currentState.availableVersion ? "available" : "error";
25}
26
27export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState): boolean {
28 return currentState.availableVersion !== null;
1929}
2030
2131export function getAutoUpdateDisabledReason(args: {
apps/web/src/components/Sidebar.tsx+65-39
......@@ -30,6 +30,15 @@ import { readNativeApi } from "../nativeApi";
3030import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
3131import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
3232import { toastManager } from "./ui/toast";
33import {
34 getDesktopUpdateActionError,
35 getDesktopUpdateButtonTooltip,
36 isDesktopUpdateButtonDisabled,
37 resolveDesktopUpdateButtonAction,
38 shouldHighlightDesktopUpdateError,
39 shouldShowDesktopUpdateButton,
40 shouldToastDesktopUpdateActionResult,
41} from "./desktopUpdate.logic";
3342import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
3443import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
3544import {
......@@ -83,21 +92,6 @@ interface PrStatusIndicator {
8392
8493type ThreadPr = GitStatusResult["pr"];
8594
86function 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
10195function hasUnseenCompletion(thread: Thread): boolean {
10296 if (!thread.latestTurn?.completedAt) return false;
10397 const completedAt = Date.parse(thread.latestTurn.completedAt);
......@@ -847,18 +841,16 @@ export default function Sidebar() {
847841 if (firstProject) void handleNewThread(firstProject.id);
848842 };
849843
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);
856845
857846 const desktopUpdateTooltip = desktopUpdateState
858847 ? getDesktopUpdateButtonTooltip(desktopUpdateState)
859848 : "Update available";
860849
861 const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading";
850 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
851 const desktopUpdateButtonAction = desktopUpdateState
852 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
853 : "none";
862854 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
863855 ? "cursor-not-allowed opacity-60"
864856 : "hover:bg-accent hover:text-foreground";
......@@ -867,34 +859,67 @@ export default function Sidebar() {
867859 ? "text-emerald-500"
868860 : desktopUpdateState?.status === "downloading"
869861 ? "text-sky-400"
862 : shouldHighlightDesktopUpdateError(desktopUpdateState)
863 ? "text-rose-500 animate-pulse"
870864 : "text-amber-500 animate-pulse";
871865
872866 const handleDesktopUpdateButtonClick = useCallback(() => {
873867 const bridge = window.desktopBridge;
874868 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 });
883897 });
884 });
885898 return;
886899 }
887900
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 });
894920 });
895 });
896921 }
897 }, [desktopUpdateState]);
922 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
898923
899924 const wordmark = (
900925 <div className="flex items-center gap-2">
......@@ -936,6 +961,7 @@ export default function Sidebar() {
936961 type="button"
937962 aria-label={desktopUpdateTooltip}
938963 aria-disabled={desktopUpdateButtonDisabled || undefined}
964 disabled={desktopUpdateButtonDisabled}
939965 className={`inline-flex size-7 ml-auto mt-2 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
940966 onClick={handleDesktopUpdateButtonClick}
941967 >
apps/web/src/components/desktopUpdate.logic.test.ts created+174
......@@ -0,0 +1,174 @@
1import { describe, expect, it } from "vitest";
2import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
3
4import {
5 getDesktopUpdateActionError,
6 getDesktopUpdateButtonTooltip,
7 isDesktopUpdateButtonDisabled,
8 resolveDesktopUpdateButtonAction,
9 shouldHighlightDesktopUpdateError,
10 shouldShowDesktopUpdateButton,
11 shouldToastDesktopUpdateActionResult,
12} from "./desktopUpdate.logic";
13
14const 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
27describe("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
88describe("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
138describe("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 @@
1import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
2
3export type DesktopUpdateButtonAction = "download" | "install" | "none";
4
5export 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
25export 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
35export function isDesktopUpdateButtonDisabled(state: DesktopUpdateState | null): boolean {
36 return state?.status === "downloading";
37}
38
39export 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
63export 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
70export function shouldToastDesktopUpdateActionResult(result: DesktopUpdateActionResult): boolean {
71 return result.accepted && !result.completed;
72}
73
74export 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 {
6262 downloadPercent: number | null;
6363 checkedAt: string | null;
6464 message: string | null;
65 errorContext: "check" | "download" | "install" | null;
66 canRetry: boolean;
67}
68
69export interface DesktopUpdateActionResult {
70 accepted: boolean;
71 completed: boolean;
72 state: DesktopUpdateState;
6573}
6674
6775export interface DesktopBridge {
......@@ -75,8 +83,8 @@ export interface DesktopBridge {
7583 openExternal: (url: string) => Promise<boolean>;
7684 onMenuAction: (listener: (action: string) => void) => () => void;
7785 getUpdateState: () => Promise<DesktopUpdateState>;
78 downloadUpdate: () => Promise<void>;
79 installUpdate: () => Promise<void>;
86 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
87 installUpdate: () => Promise<DesktopUpdateActionResult>;
8088 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
8189}
8290