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";...@@ -7,7 +7,7 @@ import * as Path from "node:path";
7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";7import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron";
8import type { MenuItemConstructorOptions } from "electron";8import type { MenuItemConstructorOptions } from "electron";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import type { DesktopUpdateState } from "@t3tools/contracts";10import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
11import { autoUpdater } from "electron-updater";11import { autoUpdater } from "electron-updater";
1212
13import type { ContextMenuItem } from "@t3tools/contracts";13import 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";
15import { RotatingFileSink } from "@t3tools/shared/logging";15import { RotatingFileSink } from "@t3tools/shared/logging";
16import { showDesktopConfirmDialog } from "./confirmDialog";16import { showDesktopConfirmDialog } from "./confirmDialog";
17import { fixPath } from "./fixPath";17import { 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
20fixPath();35fixPath();
2136
...@@ -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");
44const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;59const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
45const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;60const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
4661
62type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
63
47let mainWindow: BrowserWindow | null = null;64let mainWindow: BrowserWindow | null = null;
48let backendProcess: ChildProcess.ChildProcess | null = null;65let backendProcess: ChildProcess.ChildProcess | null = null;
49let backendPort = 0;66let backendPort = 0;
...@@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null;...@@ -59,6 +76,7 @@ let backendLogSink: RotatingFileSink | null = null;
59let restoreStdIoCapture: (() => void) | null = null;76let restoreStdIoCapture: (() => void) | null = null;
6077
61let destructiveMenuIconCache: Electron.NativeImage | null | undefined;78let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
79const initialUpdateState = (): DesktopUpdateState => createInitialDesktopUpdateState(app.getVersion());
6280
63function logTimestamp(): string {81function 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;
208let updateCheckInFlight = false;226let updateCheckInFlight = false;
209let updateDownloadInFlight = false;227let updateDownloadInFlight = false;
210let updaterConfigured = false;228let updaterConfigured = false;
211let updateState: DesktopUpdateState = {229let updateState: DesktopUpdateState = initialUpdateState();
212 enabled: false,230
213 status: "disabled",231function 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};
221236
222protocol.registerSchemesAsPrivileged([237protocol.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})...`);
610620
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}
626631
627async function downloadAvailableUpdate(): Promise<void> {632async 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...");
638639
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}
653652
654function installDownloadedUpdate(): void {653async 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 }
656657
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}
663672
664function configureAutoUpdater(): void {673function 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 !== null732 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 });
764746
...@@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void {...@@ -1044,13 +1026,29 @@ function registerIpcHandlers(): void {
10441026
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 });
10491036
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}
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 @@...@@ -1,7 +1,12 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import type { DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopUpdateState } from "@t3tools/contracts";
33
4import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";4import {
5 getCanRetryAfterDownloadFailure,
6 getAutoUpdateDisabledReason,
7 nextStatusAfterDownloadFailure,
8 shouldBroadcastDownloadProgress,
9} from "./updateState";
510
6const baseState: DesktopUpdateState = {11const 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};
1623
17describe("shouldBroadcastDownloadProgress", () => {24describe("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});
4561
46describe("getAutoUpdateDisabledReason", () => {62describe("getAutoUpdateDisabledReason", () => {
...@@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => {...@@ -80,3 +96,47 @@ describe("getAutoUpdateDisabledReason", () => {
80 ).toContain("AppImage");96 ).toContain("AppImage");
81 });97 });
82});98});
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(...@@ -15,7 +15,17 @@ export function shouldBroadcastDownloadProgress(
1515
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
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;
19}29}
2030
21export function getAutoUpdateDisabledReason(args: {31export 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";
30import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";30import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
31import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";31import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
32import { toastManager } from "./ui/toast";32import { toastManager } from "./ui/toast";
33import {
34 getDesktopUpdateActionError,
35 getDesktopUpdateButtonTooltip,
36 isDesktopUpdateButtonDisabled,
37 resolveDesktopUpdateButtonAction,
38 shouldHighlightDesktopUpdateError,
39 shouldShowDesktopUpdateButton,
40 shouldToastDesktopUpdateActionResult,
41} from "./desktopUpdate.logic";
33import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";42import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
34import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";43import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
35import {44import {
...@@ -83,21 +92,6 @@ interface PrStatusIndicator {...@@ -83,21 +92,6 @@ interface PrStatusIndicator {
8392
84type ThreadPr = GitStatusResult["pr"];93type 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
101function hasUnseenCompletion(thread: Thread): boolean {95function 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 };
849843
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");
856845
857 const desktopUpdateTooltip = desktopUpdateState846 const desktopUpdateTooltip = desktopUpdateState
858 ? getDesktopUpdateButtonTooltip(desktopUpdateState)847 ? getDesktopUpdateButtonTooltip(desktopUpdateState)
859 : "Update available";848 : "Update available";
860849
861 const desktopUpdateButtonDisabled = desktopUpdateState?.status === "downloading";850 const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
851 const desktopUpdateButtonAction = desktopUpdateState
852 ? resolveDesktopUpdateButtonAction(desktopUpdateState)
853 : "none";
862 const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled854 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";
871865
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;
876870
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 }
887900
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]);
898923
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 @@
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 {...@@ -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
69export interface DesktopUpdateActionResult {
70 accepted: boolean;
71 completed: boolean;
72 state: DesktopUpdateState;
65}73}
6674
67export interface DesktopBridge {75export 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}
8290