Skip to content

Commit f82bae1

Browse files
shivamhwpNoojunoAriajSarkarjuliusmarmingecodex
authored
Update system overhaul (#1505)
Co-authored-by: Jono Kemball <jono-kemball@idexx.com> Co-authored-by: Ariaj Sarkar <rajsarkar02205@gmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 9e60597 commit f82bae1

11 files changed

Lines changed: 575 additions & 83 deletions

File tree

apps/desktop/src/main.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import * as Effect from "effect/Effect";
2020
import type {
2121
DesktopTheme,
2222
DesktopUpdateActionResult,
23+
DesktopUpdateCheckResult,
2324
DesktopUpdateState,
2425
} from "@t3tools/contracts";
2526
import { autoUpdater } from "electron-updater";
@@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
5657
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5758
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
5859
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
60+
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
5961
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
6062
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6163
const STATE_DIR = Path.join(BASE_DIR, "userdata");
@@ -756,26 +758,28 @@ function shouldEnableAutoUpdates(): boolean {
756758
);
757759
}
758760

759-
async function checkForUpdates(reason: string): Promise<void> {
760-
if (isQuitting || !updaterConfigured || updateCheckInFlight) return;
761+
async function checkForUpdates(reason: string): Promise<boolean> {
762+
if (isQuitting || !updaterConfigured || updateCheckInFlight) return false;
761763
if (updateState.status === "downloading" || updateState.status === "downloaded") {
762764
console.info(
763765
`[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`,
764766
);
765-
return;
767+
return false;
766768
}
767769
updateCheckInFlight = true;
768770
setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString()));
769771
console.info(`[desktop-updater] Checking for updates (${reason})...`);
770772

771773
try {
772774
await autoUpdater.checkForUpdates();
775+
return true;
773776
} catch (error: unknown) {
774777
const message = error instanceof Error ? error.message : String(error);
775778
setUpdateState(
776779
reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()),
777780
);
778781
console.error(`[desktop-updater] Failed to check for updates: ${message}`);
782+
return true;
779783
} finally {
780784
updateCheckInFlight = false;
781785
}
@@ -1263,6 +1267,21 @@ function registerIpcHandlers(): void {
12631267
state: updateState,
12641268
} satisfies DesktopUpdateActionResult;
12651269
});
1270+
1271+
ipcMain.removeHandler(UPDATE_CHECK_CHANNEL);
1272+
ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => {
1273+
if (!updaterConfigured) {
1274+
return {
1275+
checked: false,
1276+
state: updateState,
1277+
} satisfies DesktopUpdateCheckResult;
1278+
}
1279+
const checked = await checkForUpdates("web-ui");
1280+
return {
1281+
checked,
1282+
state: updateState,
1283+
} satisfies DesktopUpdateCheckResult;
1284+
});
12661285
}
12671286

12681287
function getIconOption(): { icon: string } | Record<string, never> {

apps/desktop/src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
99
const MENU_ACTION_CHANNEL = "desktop:menu-action";
1010
const UPDATE_STATE_CHANNEL = "desktop:update-state";
1111
const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12+
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1213
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1314
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1415
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
@@ -35,6 +36,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
3536
};
3637
},
3738
getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
39+
checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
3840
downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
3941
installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
4042
onUpdateState: (listener) => {

apps/web/src/components/Sidebar.tsx

Lines changed: 10 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
FolderIcon,
66
GitPullRequestIcon,
77
PlusIcon,
8-
RocketIcon,
98
SettingsIcon,
109
SquarePenIcon,
1110
TerminalIcon,
@@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
7877
import {
7978
getArm64IntelBuildWarningDescription,
8079
getDesktopUpdateActionError,
81-
getDesktopUpdateButtonTooltip,
80+
getDesktopUpdateInstallConfirmationMessage,
8281
isDesktopUpdateButtonDisabled,
8382
resolveDesktopUpdateButtonAction,
8483
shouldShowArm64IntelBuildWarning,
85-
shouldHighlightDesktopUpdateError,
86-
shouldShowDesktopUpdateButton,
8784
shouldToastDesktopUpdateActionResult,
8885
} from "./desktopUpdate.logic";
8986
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
@@ -120,6 +117,7 @@ import {
120117
sortProjectsForSidebar,
121118
sortThreadsForSidebar,
122119
} from "./Sidebar.logic";
120+
import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill";
123121
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
124122
import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
125123

@@ -1661,12 +1659,6 @@ export default function Sidebar() {
16611659
};
16621660
}, []);
16631661

1664-
const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState);
1665-
1666-
const desktopUpdateTooltip = desktopUpdateState
1667-
? getDesktopUpdateButtonTooltip(desktopUpdateState)
1668-
: "Update available";
1669-
16701662
const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState);
16711663
const desktopUpdateButtonAction = desktopUpdateState
16721664
? resolveDesktopUpdateButtonAction(desktopUpdateState)
@@ -1677,17 +1669,6 @@ export default function Sidebar() {
16771669
desktopUpdateState && showArm64IntelBuildWarning
16781670
? getArm64IntelBuildWarningDescription(desktopUpdateState)
16791671
: null;
1680-
const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled
1681-
? "cursor-not-allowed opacity-60"
1682-
: "hover:bg-accent hover:text-foreground";
1683-
const desktopUpdateButtonClasses =
1684-
desktopUpdateState?.status === "downloaded"
1685-
? "text-emerald-500"
1686-
: desktopUpdateState?.status === "downloading"
1687-
? "text-sky-400"
1688-
: shouldHighlightDesktopUpdateError(desktopUpdateState)
1689-
? "text-rose-500 animate-pulse"
1690-
: "text-amber-500 animate-pulse";
16911672
const newThreadShortcutLabel =
16921673
shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
16931674
shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
@@ -1728,6 +1709,10 @@ export default function Sidebar() {
17281709
}
17291710

17301711
if (desktopUpdateButtonAction === "install") {
1712+
const confirmed = window.confirm(
1713+
getDesktopUpdateInstallConfirmationMessage(desktopUpdateState),
1714+
);
1715+
if (!confirmed) return;
17311716
void bridge
17321717
.installUpdate()
17331718
.then((result) => {
@@ -1795,30 +1780,9 @@ export default function Sidebar() {
17951780
return (
17961781
<>
17971782
{isElectron ? (
1798-
<>
1799-
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1800-
{wordmark}
1801-
{showDesktopUpdateButton && (
1802-
<Tooltip>
1803-
<TooltipTrigger
1804-
render={
1805-
<button
1806-
type="button"
1807-
aria-label={desktopUpdateTooltip}
1808-
aria-disabled={desktopUpdateButtonDisabled || undefined}
1809-
disabled={desktopUpdateButtonDisabled}
1810-
className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`}
1811-
onClick={handleDesktopUpdateButtonClick}
1812-
>
1813-
<RocketIcon className="size-3.5" />
1814-
</button>
1815-
}
1816-
/>
1817-
<TooltipPopup side="bottom">{desktopUpdateTooltip}</TooltipPopup>
1818-
</Tooltip>
1819-
)}
1820-
</SidebarHeader>
1821-
</>
1783+
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">
1784+
{wordmark}
1785+
</SidebarHeader>
18221786
) : (
18231787
<SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
18241788
{wordmark}
@@ -2006,6 +1970,7 @@ export default function Sidebar() {
20061970

20071971
<SidebarSeparator />
20081972
<SidebarFooter className="p-2">
1973+
<SidebarUpdatePill />
20091974
<SidebarMenu>
20101975
<SidebarMenuItem>
20111976
<SidebarMenuButton

apps/web/src/components/desktopUpdate.logic.test.ts

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
22
import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts";
33

44
import {
5+
canCheckForUpdate,
56
getArm64IntelBuildWarningDescription,
67
getDesktopUpdateActionError,
78
getDesktopUpdateButtonTooltip,
9+
getDesktopUpdateInstallConfirmationMessage,
810
isDesktopUpdateButtonDisabled,
911
resolveDesktopUpdateButtonAction,
10-
shouldHighlightDesktopUpdateError,
1112
shouldShowArm64IntelBuildWarning,
1213
shouldShowDesktopUpdateButton,
1314
shouldToastDesktopUpdateActionResult,
@@ -69,6 +70,16 @@ describe("desktop update button state", () => {
6970
expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry");
7071
});
7172

73+
it("prefers install when a downloaded version already exists", () => {
74+
const state: DesktopUpdateState = {
75+
...baseState,
76+
status: "available",
77+
availableVersion: "1.1.0",
78+
downloadedVersion: "1.1.0",
79+
};
80+
expect(resolveDesktopUpdateButtonAction(state)).toBe("install");
81+
});
82+
7283
it("hides the button for non-actionable check errors", () => {
7384
const state: DesktopUpdateState = {
7485
...baseState,
@@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => {
169180
).toBe(false);
170181
});
171182

172-
it("highlights only actionable updater errors", () => {
173-
expect(
174-
shouldHighlightDesktopUpdateError({
175-
...baseState,
176-
status: "error",
177-
errorContext: "download",
178-
canRetry: true,
179-
}),
180-
).toBe(true);
181-
expect(
182-
shouldHighlightDesktopUpdateError({
183-
...baseState,
184-
status: "error",
185-
errorContext: "check",
186-
canRetry: true,
187-
}),
188-
).toBe(false);
189-
});
190-
191183
it("shows an Apple Silicon warning for Intel builds under Rosetta", () => {
192184
const state: DesktopUpdateState = {
193185
...baseState,
@@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => {
213205

214206
expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update");
215207
});
208+
209+
it("includes the downloaded version in the install confirmation copy", () => {
210+
expect(
211+
getDesktopUpdateInstallConfirmationMessage({
212+
availableVersion: "1.1.0",
213+
downloadedVersion: "1.1.1",
214+
}),
215+
).toContain("Install update 1.1.1 and restart T3 Code?");
216+
});
217+
218+
it("falls back to generic install confirmation copy when no version is available", () => {
219+
expect(
220+
getDesktopUpdateInstallConfirmationMessage({
221+
availableVersion: null,
222+
downloadedVersion: null,
223+
}),
224+
).toContain("Install update and restart T3 Code?");
225+
});
226+
});
227+
228+
describe("canCheckForUpdate", () => {
229+
it("returns false for null state", () => {
230+
expect(canCheckForUpdate(null)).toBe(false);
231+
});
232+
233+
it("returns false when updates are disabled", () => {
234+
expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false);
235+
});
236+
237+
it("returns false while checking", () => {
238+
expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false);
239+
});
240+
241+
it("returns false while downloading", () => {
242+
expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe(
243+
false,
244+
);
245+
});
246+
247+
it("returns false once an update has been downloaded", () => {
248+
expect(
249+
canCheckForUpdate({
250+
...baseState,
251+
status: "downloaded",
252+
availableVersion: "1.1.0",
253+
downloadedVersion: "1.1.0",
254+
}),
255+
).toBe(false);
256+
});
257+
258+
it("returns true when idle", () => {
259+
expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true);
260+
});
261+
262+
it("returns true when up-to-date", () => {
263+
expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true);
264+
});
265+
266+
it("returns true when an update is available", () => {
267+
expect(
268+
canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }),
269+
).toBe(true);
270+
});
271+
272+
it("returns true on error so the user can retry", () => {
273+
expect(
274+
canCheckForUpdate({
275+
...baseState,
276+
status: "error",
277+
errorContext: "check",
278+
message: "network",
279+
}),
280+
).toBe(true);
281+
});
282+
});
283+
284+
describe("getDesktopUpdateButtonTooltip", () => {
285+
it("returns 'Up to date' for non-actionable states", () => {
286+
expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date");
287+
expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe(
288+
"Up to date",
289+
);
290+
});
216291
});

apps/web/src/components/desktopUpdate.logic.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none";
55
export function resolveDesktopUpdateButtonAction(
66
state: DesktopUpdateState,
77
): DesktopUpdateButtonAction {
8+
if (state.downloadedVersion) {
9+
return "install";
10+
}
811
if (state.status === "available") {
912
return "download";
1013
}
11-
if (state.status === "downloaded") {
12-
return "install";
13-
}
1414
if (state.status === "error") {
15-
if (state.errorContext === "install" && state.downloadedVersion) {
16-
return "install";
17-
}
1815
if (state.errorContext === "download" && state.availableVersion) {
1916
return "download";
2017
}
@@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string
7673
}
7774
return state.message ?? "Update failed";
7875
}
79-
return "Update available";
76+
return "Up to date";
77+
}
78+
79+
export function getDesktopUpdateInstallConfirmationMessage(
80+
state: Pick<DesktopUpdateState, "availableVersion" | "downloadedVersion">,
81+
): string {
82+
const version = state.downloadedVersion ?? state.availableVersion;
83+
return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`;
8084
}
8185

8286
export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null {
@@ -94,3 +98,13 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu
9498
if (!state || state.status !== "error") return false;
9599
return state.errorContext === "download" || state.errorContext === "install";
96100
}
101+
102+
export function canCheckForUpdate(state: DesktopUpdateState | null): boolean {
103+
if (!state || !state.enabled) return false;
104+
return (
105+
state.status !== "checking" &&
106+
state.status !== "downloading" &&
107+
state.status !== "downloaded" &&
108+
state.status !== "disabled"
109+
);
110+
}

0 commit comments

Comments
 (0)