diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016..29fcdbaf1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,6 +20,7 @@ import * as Effect from "effect/Effect"; import type { DesktopTheme, DesktopUpdateActionResult, + DesktopUpdateCheckResult, DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; @@ -56,6 +57,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); const DESKTOP_SCHEME = "t3"; @@ -1211,6 +1213,21 @@ function registerIpcHandlers(): void { state: updateState, } satisfies DesktopUpdateActionResult; }); + + ipcMain.removeHandler(UPDATE_CHECK_CHANNEL); + ipcMain.handle(UPDATE_CHECK_CHANNEL, async () => { + if (!updaterConfigured) { + return { + checked: false, + state: updateState, + } satisfies DesktopUpdateCheckResult; + } + await checkForUpdates("web-ui"); + return { + checked: true, + state: updateState, + } satisfies DesktopUpdateCheckResult; + }); } function getIconOption(): { icon: string } | Record { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..29f23cde6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,7 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; +const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; @@ -32,6 +33,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }; }, getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), + checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), onUpdateState: (listener) => { diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b..a37601d81 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + canCheckForUpdate, getArm64IntelBuildWarningDescription, + getCheckForUpdateButtonLabel, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, isDesktopUpdateButtonDisabled, @@ -207,3 +209,102 @@ describe("desktop update UI helpers", () => { expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); }); }); + +describe("canCheckForUpdate", () => { + it("returns false for null state", () => { + expect(canCheckForUpdate(null)).toBe(false); + }); + + it("returns false when updates are disabled", () => { + expect(canCheckForUpdate({ ...baseState, enabled: false, status: "disabled" })).toBe(false); + }); + + it("returns false while checking", () => { + expect(canCheckForUpdate({ ...baseState, status: "checking" })).toBe(false); + }); + + it("returns false while downloading", () => { + expect(canCheckForUpdate({ ...baseState, status: "downloading", downloadPercent: 50 })).toBe( + false, + ); + }); + + it("returns true when idle", () => { + expect(canCheckForUpdate({ ...baseState, status: "idle" })).toBe(true); + }); + + it("returns true when up-to-date", () => { + expect(canCheckForUpdate({ ...baseState, status: "up-to-date" })).toBe(true); + }); + + it("returns true when an update is available", () => { + expect( + canCheckForUpdate({ ...baseState, status: "available", availableVersion: "1.1.0" }), + ).toBe(true); + }); + + it("returns true on error so the user can retry", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "error", + errorContext: "check", + message: "network", + }), + ).toBe(true); + }); +}); + +describe("getCheckForUpdateButtonLabel", () => { + it("returns the default label for null state", () => { + expect(getCheckForUpdateButtonLabel(null)).toBe("Check for Updates"); + }); + + it("returns 'Checking…' while checking", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "checking" })).toBe("Checking…"); + }); + + it("returns 'Up to Date' when up-to-date", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "up-to-date" })).toBe("Up to Date"); + }); + + it("returns 'Download Update' when an update is available", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "available", + availableVersion: "1.2.0", + }), + ).toBe("Download Update"); + }); + + it("returns 'Downloading…' while downloading", () => { + expect( + getCheckForUpdateButtonLabel({ ...baseState, status: "downloading", downloadPercent: 30 }), + ).toBe("Downloading…"); + }); + + it("returns 'Install Update' when downloaded", () => { + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "downloaded", + downloadedVersion: "1.2.0", + }), + ).toBe("Install Update"); + }); + + it("returns the default label for idle and error states", () => { + expect(getCheckForUpdateButtonLabel({ ...baseState, status: "idle" })).toBe( + "Check for Updates", + ); + expect( + getCheckForUpdateButtonLabel({ + ...baseState, + status: "error", + errorContext: "check", + message: "fail", + }), + ).toBe("Check for Updates"); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883c..cc6ae95d6 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -94,3 +94,20 @@ export function shouldHighlightDesktopUpdateError(state: DesktopUpdateState | nu if (!state || state.status !== "error") return false; return state.errorContext === "download" || state.errorContext === "install"; } + +export function canCheckForUpdate(state: DesktopUpdateState | null): boolean { + if (!state || !state.enabled) return false; + return ( + state.status !== "checking" && state.status !== "downloading" && state.status !== "disabled" + ); +} + +export function getCheckForUpdateButtonLabel(state: DesktopUpdateState | null): string { + if (!state) return "Check for Updates"; + if (state.status === "checking") return "Checking…"; + if (state.status === "up-to-date") return "Up to Date"; + if (state.status === "available") return "Download Update"; + if (state.status === "downloading") return "Downloading…"; + if (state.status === "downloaded") return "Install Update"; + return "Check for Updates"; +} diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 000000000..ba1db1798 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.ts @@ -0,0 +1,18 @@ +import { queryOptions } from "@tanstack/react-query"; + +export const desktopUpdateQueryKeys = { + all: ["desktop", "update"] as const, + state: () => ["desktop", "update", "state"] as const, +}; + +export function desktopUpdateStateQueryOptions() { + return queryOptions({ + queryKey: desktopUpdateQueryKeys.state(), + queryFn: async () => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.getUpdateState !== "function") return null; + return bridge.getUpdateState(); + }, + staleTime: Infinity, + }); +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..2ae963752 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; @@ -20,6 +20,12 @@ import { } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; +import { + canCheckForUpdate, + getCheckForUpdateButtonLabel, + resolveDesktopUpdateButtonAction, +} from "../components/desktopUpdate.logic"; +import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; import { SidebarInset } from "~/components/ui/sidebar"; const THEME_OPTIONS = [ @@ -92,6 +98,105 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } } +function DesktopUpdateCheckSection() { + const queryClient = useQueryClient(); + const updateStateQuery = useQuery(desktopUpdateStateQueryOptions()); + const [checkError, setCheckError] = useState(null); + + const updateState = updateStateQuery.data ?? null; + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.onUpdateState !== "function") return; + + const opts = desktopUpdateStateQueryOptions(); + const unsubscribe = bridge.onUpdateState((nextState) => { + queryClient.setQueryData(opts.queryKey, nextState); + }); + + return () => { + unsubscribe(); + }; + }, [queryClient]); + + const handleButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge) return; + setCheckError(null); + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const opts = desktopUpdateStateQueryOptions(); + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + queryClient.setQueryData(opts.queryKey, result.state); + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Download failed."); + }); + return; + } + + if (action === "install") { + void bridge + .installUpdate() + .then((result) => { + queryClient.setQueryData(opts.queryKey, result.state); + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Install failed."); + }); + return; + } + + if (typeof bridge.checkForUpdate !== "function") return; + void bridge + .checkForUpdate() + .then((result) => { + queryClient.setQueryData(opts.queryKey, result.state); + if (!result.checked) { + setCheckError( + result.state.message ?? "Automatic updates are not available in this build.", + ); + } + }) + .catch((error: unknown) => { + setCheckError(error instanceof Error ? error.message : "Update check failed."); + }); + }, [queryClient, updateState]); + + const buttonLabel = getCheckForUpdateButtonLabel(updateState); + const buttonDisabled = !canCheckForUpdate(updateState); + + return ( +
+
+
+

Updates

+

+ {updateState?.checkedAt + ? `Last checked: ${new Date(updateState.checkedAt).toLocaleString()}` + : "Check for available updates."} +

+
+ +
+ + {checkError ?

{checkError}

: null} + + {updateState?.status === "error" && updateState.errorContext === "check" ? ( +

+ {updateState.message ?? "Could not check for updates."} +

+ ) : null} +
+ ); +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); @@ -674,14 +779,18 @@ function SettingsRouteView() {

-
-
-

Version

-

- Current version of the application. -

+
+
+
+

Version

+

+ Current version of the application. +

+
+ {APP_VERSION}
- {APP_VERSION} + + {isElectron ? : null}
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..32b6f109b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -94,6 +94,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -106,6 +111,7 @@ export interface DesktopBridge { openExternal: (url: string) => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getUpdateState: () => Promise; + checkForUpdate: () => Promise; downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;