diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 81a02d446c..f1086e9c29 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 GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); @@ -756,13 +758,13 @@ function shouldEnableAutoUpdates(): boolean { ); } -async function checkForUpdates(reason: string): Promise { - if (isQuitting || !updaterConfigured || updateCheckInFlight) return; +async function checkForUpdates(reason: string): Promise { + if (isQuitting || !updaterConfigured || updateCheckInFlight) return false; if (updateState.status === "downloading" || updateState.status === "downloaded") { console.info( `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, ); - return; + return false; } updateCheckInFlight = true; setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); @@ -770,12 +772,14 @@ async function checkForUpdates(reason: string): Promise { try { await autoUpdater.checkForUpdates(); + return true; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); setUpdateState( reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), ); console.error(`[desktop-updater] Failed to check for updates: ${message}`); + return true; } finally { updateCheckInFlight = false; } @@ -1263,6 +1267,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; + } + const checked = await checkForUpdates("web-ui"); + return { + checked, + state: updateState, + } satisfies DesktopUpdateCheckResult; + }); } function getIconOption(): { icon: string } | Record { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fb7e3a1db..3d59db1714 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 GET_WS_URL_CHANNEL = "desktop:get-ws-url"; @@ -35,6 +36,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 89640ed5d2..100d0e3f47 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -5,7 +5,6 @@ import { FolderIcon, GitPullRequestIcon, PlusIcon, - RocketIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -78,12 +77,10 @@ import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, - getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, - shouldHighlightDesktopUpdateError, - shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "./desktopUpdate.logic"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; @@ -120,6 +117,7 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, } from "./Sidebar.logic"; +import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -1661,12 +1659,6 @@ export default function Sidebar() { }; }, []); - const showDesktopUpdateButton = isElectron && shouldShowDesktopUpdateButton(desktopUpdateState); - - const desktopUpdateTooltip = desktopUpdateState - ? getDesktopUpdateButtonTooltip(desktopUpdateState) - : "Update available"; - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); const desktopUpdateButtonAction = desktopUpdateState ? resolveDesktopUpdateButtonAction(desktopUpdateState) @@ -1677,17 +1669,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const desktopUpdateButtonInteractivityClasses = desktopUpdateButtonDisabled - ? "cursor-not-allowed opacity-60" - : "hover:bg-accent hover:text-foreground"; - const desktopUpdateButtonClasses = - desktopUpdateState?.status === "downloaded" - ? "text-emerald-500" - : desktopUpdateState?.status === "downloading" - ? "text-sky-400" - : shouldHighlightDesktopUpdateError(desktopUpdateState) - ? "text-rose-500 animate-pulse" - : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); @@ -1728,6 +1709,10 @@ export default function Sidebar() { } if (desktopUpdateButtonAction === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), + ); + if (!confirmed) return; void bridge .installUpdate() .then((result) => { @@ -1795,30 +1780,9 @@ export default function Sidebar() { return ( <> {isElectron ? ( - <> - - {wordmark} - {showDesktopUpdateButton && ( - - - - - } - /> - {desktopUpdateTooltip} - - )} - - + + {wordmark} + ) : ( {wordmark} @@ -2006,6 +1970,7 @@ export default function Sidebar() { + { expect(getDesktopUpdateButtonTooltip(state)).toContain("Click to retry"); }); + it("prefers install when a downloaded version already exists", () => { + const state: DesktopUpdateState = { + ...baseState, + status: "available", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + expect(resolveDesktopUpdateButtonAction(state)).toBe("install"); + }); + it("hides the button for non-actionable check errors", () => { const state: DesktopUpdateState = { ...baseState, @@ -169,25 +180,6 @@ describe("desktop update UI helpers", () => { ).toBe(false); }); - it("highlights only actionable updater errors", () => { - expect( - shouldHighlightDesktopUpdateError({ - ...baseState, - status: "error", - errorContext: "download", - canRetry: true, - }), - ).toBe(true); - expect( - shouldHighlightDesktopUpdateError({ - ...baseState, - status: "error", - errorContext: "check", - canRetry: true, - }), - ).toBe(false); - }); - it("shows an Apple Silicon warning for Intel builds under Rosetta", () => { const state: DesktopUpdateState = { ...baseState, @@ -213,4 +205,87 @@ describe("desktop update UI helpers", () => { expect(getArm64IntelBuildWarningDescription(state)).toContain("Download the available update"); }); + + it("includes the downloaded version in the install confirmation copy", () => { + expect( + getDesktopUpdateInstallConfirmationMessage({ + availableVersion: "1.1.0", + downloadedVersion: "1.1.1", + }), + ).toContain("Install update 1.1.1 and restart T3 Code?"); + }); + + it("falls back to generic install confirmation copy when no version is available", () => { + expect( + getDesktopUpdateInstallConfirmationMessage({ + availableVersion: null, + downloadedVersion: null, + }), + ).toContain("Install update and restart T3 Code?"); + }); +}); + +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 false once an update has been downloaded", () => { + expect( + canCheckForUpdate({ + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }), + ).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 5c9c303026..38983c810b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -5,16 +5,13 @@ export type DesktopUpdateButtonAction = "download" | "install" | "none"; export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { + if (state.downloadedVersion) { + return "install"; + } if (state.status === "available") { return "download"; } - if (state.status === "downloaded") { - return "install"; - } if (state.status === "error") { - if (state.errorContext === "install" && state.downloadedVersion) { - return "install"; - } if (state.errorContext === "download" && state.availableVersion) { return "download"; } @@ -76,7 +73,14 @@ export function getDesktopUpdateButtonTooltip(state: DesktopUpdateState): string } return state.message ?? "Update failed"; } - return "Update available"; + return "Up to date"; +} + +export function getDesktopUpdateInstallConfirmationMessage( + state: Pick, +): string { + const version = state.downloadedVersion ?? state.availableVersion; + return `Install update${version ? ` ${version}` : ""} and restart T3 Code?\n\nAny running tasks will be interrupted. Make sure you're ready before continuing.`; } export function getDesktopUpdateActionError(result: DesktopUpdateActionResult): string | null { @@ -94,3 +98,13 @@ 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 !== "downloaded" && + state.status !== "disabled" + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index bb149c00a4..f9fdb1d615 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -22,12 +22,24 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; +import { + canCheckForUpdate, + getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, +} from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; +import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; +import { + setDesktopUpdateStateQueryData, + useDesktopUpdateState, +} from "../../lib/desktopUpdateReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "../../lib/serverReactQuery"; import { MAX_CUSTOM_MODEL_LENGTH, @@ -236,7 +248,7 @@ function SettingsRow({ control, children, }: { - title: string; + title: ReactNode; description: string; status?: ReactNode; resetAction?: ReactNode; @@ -299,6 +311,133 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { ); } +function AboutVersionTitle() { + return ( + + Version + {APP_VERSION} + + ); +} + +function AboutVersionSection() { + const queryClient = useQueryClient(); + const updateStateQuery = useDesktopUpdateState(); + + const updateState = updateStateQuery.data ?? null; + + const handleButtonClick = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge) return; + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not download update", + description: error instanceof Error ? error.message : "Download failed.", + }); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm( + getDesktopUpdateInstallConfirmationMessage( + updateState ?? { availableVersion: null, downloadedVersion: null }, + ), + ); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "Install failed.", + }); + }); + return; + } + + if (typeof bridge.checkForUpdate !== "function") return; + void bridge + .checkForUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (!result.checked) { + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: + result.state.message ?? "Automatic updates are not available in this build.", + }); + } + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not check for updates", + description: error instanceof Error ? error.message : "Update check failed.", + }); + }); + }, [queryClient, updateState]); + + const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; + const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; + const buttonDisabled = + action === "none" + ? !canCheckForUpdate(updateState) + : isDesktopUpdateButtonDisabled(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"; + const description = + action === "download" || action === "install" + ? "Update available." + : "Current version of the application."; + + return ( + } + description={description} + control={ + + + {buttonLabel} + + } + /> + {buttonTooltip ? {buttonTooltip} : null} + + } + /> + ); +} + export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -1258,12 +1397,17 @@ export function GeneralSettingsPanel() { } /> + - {APP_VERSION}} - /> + + {isElectron ? ( + + ) : ( + } + description="Current version of the application." + /> + )} ); @@ -1325,7 +1469,7 @@ export function ArchivedThreadsPanel() { {archivedGroups.length === 0 ? ( - + diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 20914b499d..6ba698c91f 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -20,7 +20,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, - { label: "Archived threads", to: "/settings/archived", icon: ArchiveIcon }, + { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; export function SettingsSidebarNav({ pathname }: { pathname: string }) { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx new file mode 100644 index 0000000000..2f9aec112a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -0,0 +1,176 @@ +import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { isElectron } from "../../env"; +import { + setDesktopUpdateStateQueryData, + useDesktopUpdateState, +} from "../../lib/desktopUpdateReactQuery"; +import { toastManager } from "../ui/toast"; +import { + getArm64IntelBuildWarningDescription, + getDesktopUpdateActionError, + getDesktopUpdateButtonTooltip, + getDesktopUpdateInstallConfirmationMessage, + isDesktopUpdateButtonDisabled, + resolveDesktopUpdateButtonAction, + shouldShowArm64IntelBuildWarning, + shouldShowDesktopUpdateButton, + shouldToastDesktopUpdateActionResult, +} from "../desktopUpdate.logic"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function SidebarUpdatePill() { + const queryClient = useQueryClient(); + const state = useDesktopUpdateState().data ?? null; + const [dismissed, setDismissed] = useState(false); + + const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed; + const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available"; + const disabled = isDesktopUpdateButtonDisabled(state); + const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; + + const showArm64Warning = isElectron && shouldShowArm64IntelBuildWarning(state); + const arm64Description = + state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null; + + const handleAction = useCallback(() => { + const bridge = window.desktopBridge; + if (!bridge || !state) return; + if (disabled || action === "none") return; + + if (action === "download") { + void bridge + .downloadUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (result.completed) { + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); + } + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not download update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not start update download", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + return; + } + + if (action === "install") { + const confirmed = window.confirm(getDesktopUpdateInstallConfirmationMessage(state)); + if (!confirmed) return; + void bridge + .installUpdate() + .then((result) => { + setDesktopUpdateStateQueryData(queryClient, result.state); + if (!shouldToastDesktopUpdateActionResult(result)) return; + const actionError = getDesktopUpdateActionError(result); + if (!actionError) return; + toastManager.add({ + type: "error", + title: "Could not install update", + description: actionError, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Could not install update", + description: error instanceof Error ? error.message : "An unexpected error occurred.", + }); + }); + } + }, [action, disabled, queryClient, state]); + + if (!visible && !showArm64Warning) return null; + + return ( +
+ {showArm64Warning && arm64Description && ( + + + Intel build on Apple Silicon + {arm64Description} + + )} + {visible && ( +
+
+ + + {action === "install" ? ( + <> + + Restart to update + + ) : state?.status === "downloading" ? ( + <> + + + Downloading + {typeof state.downloadPercent === "number" + ? ` (${Math.floor(state.downloadPercent)}%)` + : "…"} + + + ) : ( + <> + + Update available + + )} + + } + /> + {tooltip} + + {action === "download" && ( + + setDismissed(true)} + > + + + } + /> + Dismiss until next launch + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/desktopUpdateReactQuery.test.ts b/apps/web/src/lib/desktopUpdateReactQuery.test.ts new file mode 100644 index 0000000000..a0f4755918 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.test.ts @@ -0,0 +1,49 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import { + desktopUpdateQueryKeys, + desktopUpdateStateQueryOptions, + setDesktopUpdateStateQueryData, +} from "./desktopUpdateReactQuery"; + +const baseState: DesktopUpdateState = { + enabled: true, + status: "idle", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, +}; + +describe("desktopUpdateStateQueryOptions", () => { + it("always refetches on mount so Settings does not reuse stale desktop update state", () => { + const options = desktopUpdateStateQueryOptions(); + + expect(options.staleTime).toBe(Infinity); + expect(options.refetchOnMount).toBe("always"); + }); +}); + +describe("setDesktopUpdateStateQueryData", () => { + it("writes desktop update state into the shared cache key", () => { + const queryClient = new QueryClient(); + const nextState: DesktopUpdateState = { + ...baseState, + status: "downloaded", + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + }; + + setDesktopUpdateStateQueryData(queryClient, nextState); + + expect(queryClient.getQueryData(desktopUpdateQueryKeys.state())).toEqual(nextState); + }); +}); diff --git a/apps/web/src/lib/desktopUpdateReactQuery.ts b/apps/web/src/lib/desktopUpdateReactQuery.ts new file mode 100644 index 0000000000..9315772786 --- /dev/null +++ b/apps/web/src/lib/desktopUpdateReactQuery.ts @@ -0,0 +1,42 @@ +import { queryOptions, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import type { DesktopUpdateState } from "@t3tools/contracts"; + +export const desktopUpdateQueryKeys = { + all: ["desktop", "update"] as const, + state: () => ["desktop", "update", "state"] as const, +}; + +export const setDesktopUpdateStateQueryData = ( + queryClient: QueryClient, + state: DesktopUpdateState | null, +) => queryClient.setQueryData(desktopUpdateQueryKeys.state(), state); + +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, + refetchOnMount: "always", + }); +} + +export function useDesktopUpdateState() { + const queryClient = useQueryClient(); + const query = useQuery(desktopUpdateStateQueryOptions()); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge || typeof bridge.onUpdateState !== "function") return; + + return bridge.onUpdateState((nextState) => { + setDesktopUpdateStateQueryData(queryClient, nextState); + }); + }, [queryClient]); + + return query; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0443128dd5..5585e7f309 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -101,6 +101,11 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export interface DesktopUpdateCheckResult { + checked: boolean; + state: DesktopUpdateState; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -113,6 +118,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;