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;