diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 062c79fa69..d1e9e4f47c 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 BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "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 1e1bb3bd8e..29f23cde6b 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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e1d126b06c..cbd034b7fb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,10 +1,10 @@ import { + ArrowDownToLineIcon, ArrowLeftIcon, ChevronRightIcon, FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -1062,7 +1062,9 @@ export default function Sidebar() { ? "text-sky-400" : shouldHighlightDesktopUpdateError(desktopUpdateState) ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; + : desktopUpdateState?.status === "available" + ? "text-emerald-500" + : "text-muted-foreground"; const newThreadShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "chat.newLocal") ?? @@ -1106,6 +1108,11 @@ export default function Sidebar() { } if (desktopUpdateButtonAction === "install") { + const version = desktopUpdateState.downloadedVersion ?? desktopUpdateState.availableVersion; + const confirmed = window.confirm( + `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`, + ); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -1185,10 +1192,10 @@ export default function Sidebar() { aria-label={desktopUpdateTooltip} aria-disabled={desktopUpdateButtonDisabled || undefined} disabled={desktopUpdateButtonDisabled} - className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md text-muted-foreground transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} + className={`inline-flex size-7 ml-auto mt-1.5 items-center justify-center rounded-md transition-colors ${desktopUpdateButtonInteractivityClasses} ${desktopUpdateButtonClasses}`} onClick={handleDesktopUpdateButtonClick} > - + } /> diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 984eebd6b1..cbb6f56fdd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + canCheckForUpdate, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, @@ -207,3 +208,57 @@ 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("getDesktopUpdateButtonTooltip", () => { + it("returns 'Up to date' for non-actionable states", () => { + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "idle" })).toBe("Up to date"); + expect(getDesktopUpdateButtonTooltip({ ...baseState, status: "up-to-date" })).toBe( + "Up to date", + ); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index faf30883cc..df9139908c 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -76,7 +76,7 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string } return state.message ?? "Update failed"; } - return "Update available"; + return "Up to date"; } export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { @@ -94,3 +94,10 @@ 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" + ); +} diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 0000000000..ba1db17987 --- /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 05fd640d0f..379aef85be 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; -import { type ReactNode, useCallback, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -25,11 +25,17 @@ import { } from "../components/ui/select"; import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; +import { + canCheckForUpdate, + getDesktopUpdateButtonTooltip, + resolveDesktopUpdateButtonAction, +} from "../components/desktopUpdate.logic"; import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { desktopUpdateStateQueryOptions } from "../lib/desktopUpdateReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; @@ -58,6 +64,132 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +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") { + const version = updateState?.downloadedVersion ?? updateState?.availableVersion; + const confirmed = window.confirm( + `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`, + ); + if (!confirmed) return; + 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 action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; + const buttonDisabled = !canCheckForUpdate(updateState); + + const actionLabel: Record = { download: "Download", install: "Install" }; + const statusLabel: Record = { + checking: "Checking…", + downloading: "Downloading…", + "up-to-date": "Up to Date", + }; + const buttonLabel = + actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; + + return ( + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + + } + status={ + <> + {checkError ?

{checkError}

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

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

+ ) : null} + + } + /> + ); +} + type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; type InstallProviderSettings = { provider: ProviderKind; @@ -976,6 +1108,7 @@ function SettingsRouteView() { {APP_VERSION} } /> + {isElectron ? : null} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea73024de3..cca25cee5c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -95,6 +95,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -107,6 +112,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;