From ac375f152d295afcacbe2bce6f01d4ddeedecdf8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 6 May 2026 19:58:17 +0800 Subject: [PATCH] Wrap desktop IPC in shared mutation/query hooks (fix #761) (#765) * Wrap desktop IPC calls in shared hooks * Unslop desktop IPC hook callsites * Redesign desktop IPC hooks around domains --- .../components/desktop-updates-section.tsx | 95 ++---------- .../components/integrations-section.tsx | 72 +++------- .../src/desktop/hooks/desktop-ipc-error.ts | 56 ++++++++ .../hooks/use-built-in-daemon-management.ts | 84 +++++++++++ .../src/desktop/hooks/use-daemon-status.ts | 9 +- .../desktop/hooks/use-install-status.test.tsx | 135 ++++++++++++++++++ .../src/desktop/hooks/use-install-status.ts | 126 ++++++++++++++++ .../src/desktop/settings/desktop-settings.ts | 71 +++++++-- .../updates/use-desktop-app-updater.test.tsx | 27 +++- .../updates/use-desktop-app-updater.ts | 22 ++- 10 files changed, 546 insertions(+), 151 deletions(-) create mode 100644 packages/app/src/desktop/hooks/desktop-ipc-error.ts create mode 100644 packages/app/src/desktop/hooks/use-built-in-daemon-management.ts create mode 100644 packages/app/src/desktop/hooks/use-install-status.test.tsx create mode 100644 packages/app/src/desktop/hooks/use-install-status.ts diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index de3dd8e55..a37794ffd 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -8,87 +8,16 @@ import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; -import { - getCliDaemonStatus, - shouldUseDesktopDaemon, - startDesktopDaemon, - stopDesktopDaemon, -} from "@/desktop/daemon/desktop-daemon"; -import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle"; +import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import { resolveAppVersion } from "@/utils/app-version"; type DesktopDaemonSettings = DesktopSettings["daemon"]; -function useDaemonManagementToggle(args: { - daemonStatus: DesktopDaemonStatus | null; - settings: DesktopDaemonSettings; - updateSettings: (next: Partial) => Promise; - setStatus: (status: DesktopDaemonStatus) => void; - refetch: () => void; -}) { - const { daemonStatus, settings, updateSettings, setStatus, refetch } = args; - const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); - - const handleToggleDaemonManagement = useCallback(() => { - if (isUpdatingDaemonManagement) { - return; - } - - setIsUpdatingDaemonManagement(true); - void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, { - confirm: () => - confirmDialog({ - title: "Pause built-in daemon", - message: - "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", - confirmLabel: "Pause and stop", - cancelLabel: "Cancel", - destructive: true, - }), - persistSettings: (next) => updateSettings(next) as Promise, - startDaemon: startDesktopDaemon, - stopDaemon: stopDesktopDaemon, - }) - .then((result) => { - if (result.kind === "cancelled") { - return; - } - if (result.newStatus) { - setStatus(result.newStatus); - } - refetch(); - return; - }) - .catch((error) => { - console.error("[Settings] Failed to update built-in daemon management", error); - Alert.alert( - "Error", - settings.manageBuiltInDaemon - ? "Built-in daemon management was paused, but Paseo could not stop the daemon." - : "Unable to update built-in daemon management.", - ); - }) - .finally(() => { - setIsUpdatingDaemonManagement(false); - }); - }, [ - daemonStatus, - isUpdatingDaemonManagement, - refetch, - setStatus, - settings.manageBuiltInDaemon, - updateSettings, - ]); - - return { isUpdatingDaemonManagement, handleToggleDaemonManagement }; -} - function useKeepRunningAfterQuitToggle(args: { settings: DesktopDaemonSettings; updateSettings: (next: Partial) => Promise; @@ -99,9 +28,8 @@ function useKeepRunningAfterQuitToggle(args: { const handleToggleKeepRunningAfterQuit = useCallback(() => { setIsUpdatingKeepRunningAfterQuit(true); void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit }) - .catch((error) => { - console.error("[Settings] Failed to update desktop quit daemon behavior", error); - Alert.alert("Error", "Unable to update the desktop quit daemon behavior."); + .catch(() => { + // useDesktopSettings owns the user-visible IPC error. }) .finally(() => { setIsUpdatingKeepRunningAfterQuit(false); @@ -393,13 +321,14 @@ export function LocalDaemonSection() { const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon; - const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({ - daemonStatus, - settings: daemonSettings, - updateSettings: updateDaemonSettings, - setStatus, - refetch, - }); + const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } = + useBuiltInDaemonManagement({ + daemonStatus, + settings: daemonSettings, + updateSettings: updateDaemonSettings, + setStatus, + refreshStatus: refetch, + }); const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } = useKeepRunningAfterQuitToggle({ settings: daemonSettings, diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index e663dd10c..c4b35a947 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { Text, View } from "react-native"; import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -7,14 +7,8 @@ import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; -import { - shouldUseDesktopDaemon, - getCliInstallStatus, - installCli, - getSkillsInstallStatus, - installSkills, - type InstallStatus, -} from "@/desktop/daemon/desktop-daemon"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; @@ -23,59 +17,37 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; export function IntegrationsSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); - - const [cliStatus, setCliStatus] = useState(null); - const [skillsStatus, setSkillsStatus] = useState(null); - const [isInstallingCli, setIsInstallingCli] = useState(false); - const [isInstallingSkills, setIsInstallingSkills] = useState(false); - - const loadStatus = useCallback(() => { - if (!showSection) return; - void getCliInstallStatus() - .then(setCliStatus) - .catch((error) => { - console.error("[Integrations] Failed to load CLI status", error); - }); - void getSkillsInstallStatus() - .then(setSkillsStatus) - .catch((error) => { - console.error("[Integrations] Failed to load skills status", error); - }); - }, [showSection]); + const { + status: cliStatus, + isInstalling: isInstallingCli, + install: installCli, + refresh: refreshCliStatus, + } = useCliInstall(); + const { + status: skillsStatus, + isInstalling: isInstallingSkills, + install: installSkills, + refresh: refreshSkillsStatus, + } = useSkillsInstall(); useFocusEffect( useCallback(() => { if (!showSection) return undefined; - loadStatus(); + refreshCliStatus(); + refreshSkillsStatus(); return undefined; - }, [loadStatus, showSection]), + }, [refreshCliStatus, refreshSkillsStatus, showSection]), ); const handleInstallCli = useCallback(() => { if (isInstallingCli) return; - setIsInstallingCli(true); - void installCli() - .then(setCliStatus) - .catch((error) => { - console.error("[Integrations] Failed to install CLI", error); - }) - .finally(() => { - setIsInstallingCli(false); - }); - }, [isInstallingCli]); + installCli(); + }, [installCli, isInstallingCli]); const handleInstallSkills = useCallback(() => { if (isInstallingSkills) return; - setIsInstallingSkills(true); - void installSkills() - .then(setSkillsStatus) - .catch((error) => { - console.error("[Integrations] Failed to install skills", error); - }) - .finally(() => { - setIsInstallingSkills(false); - }); - }, [isInstallingSkills]); + installSkills(); + }, [installSkills, isInstallingSkills]); const handleOpenCliDocs = useCallback(() => { void openExternalUrl(CLI_DOCS_URL); diff --git a/packages/app/src/desktop/hooks/desktop-ipc-error.ts b/packages/app/src/desktop/hooks/desktop-ipc-error.ts new file mode 100644 index 000000000..670bd77f8 --- /dev/null +++ b/packages/app/src/desktop/hooks/desktop-ipc-error.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { ToastApi } from "@/components/toast-host"; +import { useToast } from "@/contexts/toast-context"; + +interface DesktopIpcErrorReport { + toast: ToastApi; + logLabel: string; + message: string; + error: unknown; +} + +interface DesktopIpcQueryErrorToastOptions { + error: Error | null; + logLabel: string; + message: string; +} + +interface DesktopIpcErrorReporterInput { + logLabel: string; + message: string; + error: unknown; +} + +export function reportDesktopIpcError(input: DesktopIpcErrorReport): void { + console.error(input.logLabel, input.error); + input.toast.error(input.message); +} + +export function useDesktopIpcErrorReporter(): (input: DesktopIpcErrorReporterInput) => void { + const toast = useToast(); + return useCallback( + (input: DesktopIpcErrorReporterInput) => { + reportDesktopIpcError({ ...input, toast }); + }, + [toast], + ); +} + +export function useDesktopIpcQueryErrorToast(options: DesktopIpcQueryErrorToastOptions): void { + const toast = useToast(); + const lastReportedErrorRef = useRef(null); + + useEffect(() => { + if (!options.error || options.error === lastReportedErrorRef.current) { + return; + } + + lastReportedErrorRef.current = options.error; + reportDesktopIpcError({ + toast, + logLabel: options.logLabel, + message: options.message, + error: options.error, + }); + }, [options.error, options.logLabel, options.message, toast]); +} diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts new file mode 100644 index 000000000..abecc9fc3 --- /dev/null +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -0,0 +1,84 @@ +import { useCallback } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + type DesktopDaemonStatus, + startDesktopDaemon, + stopDesktopDaemon, +} from "@/desktop/daemon/desktop-daemon"; +import { + executeDaemonManagementToggle, + type DaemonManagementToggleResult, +} from "@/desktop/daemon/daemon-management-toggle"; +import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; +import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; + +type DesktopDaemonSettings = DesktopSettings["daemon"]; + +interface UseBuiltInDaemonManagementInput { + daemonStatus: DesktopDaemonStatus | null; + settings: DesktopDaemonSettings; + updateSettings: (next: Partial) => Promise; + setStatus: (status: DesktopDaemonStatus) => void; + refreshStatus: () => void; +} + +interface UseBuiltInDaemonManagementResult { + isUpdating: boolean; + toggle: () => void; +} + +export function useBuiltInDaemonManagement( + input: UseBuiltInDaemonManagementInput, +): UseBuiltInDaemonManagementResult { + const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input; + const reportError = useDesktopIpcErrorReporter(); + const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation< + DaemonManagementToggleResult, + Error + >({ + mutationFn: () => + executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, { + confirm: () => + confirmDialog({ + title: "Pause built-in daemon", + message: + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + confirmLabel: "Pause and stop", + cancelLabel: "Cancel", + destructive: true, + }), + persistSettings: (next) => updateSettings(next) as Promise, + startDaemon: startDesktopDaemon, + stopDaemon: stopDesktopDaemon, + }), + onError: (error) => { + reportError({ + error, + message: settings.manageBuiltInDaemon + ? "Built-in daemon management was paused, but Paseo could not stop the daemon." + : "Unable to update built-in daemon management.", + logLabel: "[Settings] Failed to update built-in daemon management", + }); + }, + onSuccess: (result) => { + if (result.kind === "cancelled") { + return; + } + if (result.newStatus) { + setStatus(result.newStatus); + } + refreshStatus(); + }, + }); + + const toggle = useCallback(() => { + if (isUpdating) { + return; + } + + toggleDaemonManagement(); + }, [isUpdating, toggleDaemonManagement]); + + return { isUpdating, toggle }; +} diff --git a/packages/app/src/desktop/hooks/use-daemon-status.ts b/packages/app/src/desktop/hooks/use-daemon-status.ts index 5260ae43b..a9188e2fc 100644 --- a/packages/app/src/desktop/hooks/use-daemon-status.ts +++ b/packages/app/src/desktop/hooks/use-daemon-status.ts @@ -7,6 +7,7 @@ import { type DesktopDaemonLogs, type DesktopDaemonStatus, } from "@/desktop/daemon/desktop-daemon"; +import { useDesktopIpcQueryErrorToast } from "@/desktop/hooks/desktop-ipc-error"; const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const; @@ -19,16 +20,22 @@ export function useDaemonStatus() { const queryClient = useQueryClient(); const enabled = shouldUseDesktopDaemon(); - const query = useQuery({ + const query = useQuery({ queryKey: DAEMON_STATUS_QUERY_KEY, enabled, staleTime: 30_000, refetchOnMount: "always", + retry: false, queryFn: async () => { const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); return { status, logs }; }, }); + useDesktopIpcQueryErrorToast({ + error: query.error, + message: "Unable to load desktop daemon status.", + logLabel: "[DesktopDaemon] Failed to load daemon status", + }); const setStatus = useCallback( (status: DesktopDaemonStatus) => { diff --git a/packages/app/src/desktop/hooks/use-install-status.test.tsx b/packages/app/src/desktop/hooks/use-install-status.test.tsx new file mode 100644 index 000000000..edec5c6e2 --- /dev/null +++ b/packages/app/src/desktop/hooks/use-install-status.test.tsx @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useCliInstall, useSkillsInstall } from "./use-install-status"; + +const toast = vi.hoisted(() => ({ + error: vi.fn(), + show: vi.fn(), + copied: vi.fn(), +})); + +const desktopDaemon = vi.hoisted(() => ({ + getCliInstallStatus: vi.fn(), + getSkillsInstallStatus: vi.fn(), + installCli: vi.fn(), + installSkills: vi.fn(), + shouldUseDesktopDaemon: vi.fn(() => true), +})); + +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => toast, +})); + +vi.mock("@/desktop/daemon/desktop-daemon", () => desktopDaemon); + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderDesktopHook(callback: () => TResult) { + const queryClient = createQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return renderHook(callback, { wrapper }); +} + +describe("useCliInstall", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: true }); + desktopDaemon.installCli.mockResolvedValue({ installed: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("loads CLI install status", async () => { + const { result } = renderDesktopHook(() => useCliInstall()); + + await waitFor(() => { + expect(result.current.status).toEqual({ installed: true }); + }); + + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("toasts and exposes CLI install errors", async () => { + const error = new Error("Missing IPC handler"); + desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false }); + desktopDaemon.installCli.mockRejectedValue(error); + const { result } = renderDesktopHook(() => useCliInstall()); + + await waitFor(() => { + expect(result.current.status).toEqual({ installed: false }); + }); + + act(() => { + result.current.install(); + }); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI."); + expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error); + }); +}); + +describe("useSkillsInstall", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true }); + desktopDaemon.installSkills.mockResolvedValue({ installed: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("loads skills install status", async () => { + const { result } = renderDesktopHook(() => useSkillsInstall()); + + await waitFor(() => { + expect(result.current.status).toEqual({ installed: true }); + }); + + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("toasts and exposes skills install errors", async () => { + const error = new Error("Missing IPC handler"); + desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false }); + desktopDaemon.installSkills.mockRejectedValue(error); + const { result } = renderDesktopHook(() => useSkillsInstall()); + + await waitFor(() => { + expect(result.current.status).toEqual({ installed: false }); + }); + + act(() => { + result.current.install(); + }); + + await waitFor(() => { + expect(result.current.error).toBe(error); + }); + + expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills."); + expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error); + }); +}); diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts new file mode 100644 index 000000000..fb7a39974 --- /dev/null +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -0,0 +1,126 @@ +import { useCallback } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getCliInstallStatus, + getSkillsInstallStatus, + installCli, + installSkills, + shouldUseDesktopDaemon, + type InstallStatus, +} from "@/desktop/daemon/desktop-daemon"; +import { + useDesktopIpcErrorReporter, + useDesktopIpcQueryErrorToast, +} from "@/desktop/hooks/desktop-ipc-error"; + +const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const; +const SKILLS_INSTALL_STATUS_QUERY_KEY = [ + "desktop", + "integrations", + "skills-install-status", +] as const; + +interface DesktopInstallHookResult { + status: InstallStatus | null; + isLoading: boolean; + isInstalling: boolean; + error: Error | null; + install: () => void; + refresh: () => void; +} + +export function useCliInstall(): DesktopInstallHookResult { + const queryClient = useQueryClient(); + const reportError = useDesktopIpcErrorReporter(); + const enabled = shouldUseDesktopDaemon(); + + const statusQuery = useQuery({ + queryKey: CLI_INSTALL_STATUS_QUERY_KEY, + queryFn: getCliInstallStatus, + enabled, + retry: false, + }); + const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery; + useDesktopIpcQueryErrorToast({ + error: statusQuery.error, + message: "Unable to check CLI install status.", + logLabel: "[Integrations] Failed to load CLI status", + }); + + const installMutation = useMutation({ + mutationFn: installCli, + onError: (error) => { + reportError({ + error, + message: "Unable to install the Paseo CLI.", + logLabel: "[Integrations] Failed to install CLI", + }); + }, + onSuccess: (nextStatus) => { + queryClient.setQueryData(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus); + void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY }); + }, + }); + const { error: installError, isPending: isInstalling, mutate: install } = installMutation; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + return { + status: installStatus ?? null, + isLoading, + isInstalling, + error: statusError ?? installError ?? null, + install, + refresh, + }; +} + +export function useSkillsInstall(): DesktopInstallHookResult { + const queryClient = useQueryClient(); + const reportError = useDesktopIpcErrorReporter(); + const enabled = shouldUseDesktopDaemon(); + + const statusQuery = useQuery({ + queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY, + queryFn: getSkillsInstallStatus, + enabled, + retry: false, + }); + const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery; + useDesktopIpcQueryErrorToast({ + error: statusQuery.error, + message: "Unable to check orchestration skills install status.", + logLabel: "[Integrations] Failed to load skills status", + }); + + const installMutation = useMutation({ + mutationFn: installSkills, + onError: (error) => { + reportError({ + error, + message: "Unable to install orchestration skills.", + logLabel: "[Integrations] Failed to install skills", + }); + }, + onSuccess: (nextStatus) => { + queryClient.setQueryData(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus); + void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY }); + }, + }); + const { error: installError, isPending: isInstalling, mutate: install } = installMutation; + + const refresh = useCallback(() => { + void refetch(); + }, [refetch]); + + return { + status: installStatus ?? null, + isLoading, + isInstalling, + error: statusError ?? installError ?? null, + install, + refresh, + }; +} diff --git a/packages/app/src/desktop/settings/desktop-settings.ts b/packages/app/src/desktop/settings/desktop-settings.ts index 22df5bae3..6911fa88b 100644 --- a/packages/app/src/desktop/settings/desktop-settings.ts +++ b/packages/app/src/desktop/settings/desktop-settings.ts @@ -1,10 +1,14 @@ import { useCallback } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getIsElectron } from "@/constants/platform"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { + useDesktopIpcErrorReporter, + useDesktopIpcQueryErrorToast, +} from "@/desktop/hooks/desktop-ipc-error"; import type { ReleaseChannel } from "@/hooks/use-settings"; -export const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const; +const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const; export interface DesktopSettings { releaseChannel: ReleaseChannel; @@ -30,15 +34,59 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { export function useDesktopSettings(): { settings: DesktopSettings; isLoading: boolean; + isSaving: boolean; error: unknown; updateSettings: (updates: DesktopSettingsPatch) => Promise; } { const queryClient = useQueryClient(); - const { data, isPending, error } = useQuery({ + const reportError = useDesktopIpcErrorReporter(); + const { + data, + isPending, + error: loadError, + } = useQuery({ queryKey: DESKTOP_SETTINGS_QUERY_KEY, queryFn: loadDesktopSettings, staleTime: Infinity, gcTime: Infinity, + retry: false, + }); + useDesktopIpcQueryErrorToast({ + error: loadError, + message: "Unable to load desktop settings.", + logLabel: "[DesktopSettings] Failed to load settings", + }); + + const { mutateAsync: saveDesktopSettings, isPending: isSaving } = useMutation< + DesktopSettings, + Error, + DesktopSettingsPatch, + DesktopSettingsMutationContext + >({ + mutationFn: updatePersistedDesktopSettings, + onMutate: (updates) => { + const previous = + queryClient.getQueryData(DESKTOP_SETTINGS_QUERY_KEY) ?? + DEFAULT_DESKTOP_SETTINGS; + queryClient.setQueryData( + DESKTOP_SETTINGS_QUERY_KEY, + mergeDesktopSettings(previous, updates), + ); + return { previous }; + }, + onSuccess: (persisted) => { + queryClient.setQueryData(DESKTOP_SETTINGS_QUERY_KEY, persisted); + }, + onError: (saveError, _updates, context) => { + if (context) { + queryClient.setQueryData(DESKTOP_SETTINGS_QUERY_KEY, context.previous); + } + reportError({ + error: saveError, + message: "Unable to save desktop settings.", + logLabel: "[DesktopSettings] Failed to save settings", + }); + }, }); const updateSettings = useCallback( @@ -47,25 +95,24 @@ export function useDesktopSettings(): { return; } - const previous = - queryClient.getQueryData(DESKTOP_SETTINGS_QUERY_KEY) ?? - DEFAULT_DESKTOP_SETTINGS; - const next = mergeDesktopSettings(previous, updates); - queryClient.setQueryData(DESKTOP_SETTINGS_QUERY_KEY, next); - const persisted = await updatePersistedDesktopSettings(updates); - queryClient.setQueryData(DESKTOP_SETTINGS_QUERY_KEY, persisted); + await saveDesktopSettings(updates); }, - [queryClient], + [saveDesktopSettings], ); return { settings: data ?? DEFAULT_DESKTOP_SETTINGS, isLoading: isPending, - error: error ?? null, + isSaving, + error: loadError ?? null, updateSettings, }; } +interface DesktopSettingsMutationContext { + previous: DesktopSettings; +} + export async function loadDesktopSettings(): Promise { if (!getIsElectron()) { return DEFAULT_DESKTOP_SETTINGS; diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx index 99d9f3d16..5efbbe980 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx @@ -1,7 +1,9 @@ /** * @vitest-environment jsdom */ +import React from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useDesktopAppUpdater } from "./use-desktop-app-updater"; @@ -37,6 +39,27 @@ vi.mock("@/desktop/settings/desktop-settings", () => ({ }), })); +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }), +})); + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderUpdaterHook() { + const queryClient = createQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + return renderHook(() => useDesktopAppUpdater(), { wrapper }); +} + describe("useDesktopAppUpdater", () => { beforeEach(() => { settingsState.releaseChannel = "stable"; @@ -47,7 +70,7 @@ describe("useDesktopAppUpdater", () => { it("uses the effective desktop release channel when checking for updates", async () => { settingsState.releaseChannel = "beta"; - renderHook(() => useDesktopAppUpdater()); + renderUpdaterHook(); await waitFor(() => { expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({ @@ -58,7 +81,7 @@ describe("useDesktopAppUpdater", () => { it("uses the effective desktop release channel when installing updates", async () => { settingsState.releaseChannel = "beta"; - const { result } = renderHook(() => useDesktopAppUpdater()); + const { result } = renderUpdaterHook(); await act(async () => { await result.current.installUpdate(); diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index c4dd7d2c8..c67f146ee 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; import { checkDesktopAppUpdate, formatVersionWithPrefix, @@ -8,6 +9,7 @@ import { type DesktopAppUpdateInstallResult, } from "@/desktop/updates/desktop-updates"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; +import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; export type DesktopAppUpdateStatus = | "idle" @@ -86,12 +88,26 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { const isDesktopApp = shouldShowDesktopUpdateSection(); const { settings: desktopSettings } = useDesktopSettings(); const releaseChannel = desktopSettings.releaseChannel; + const reportError = useDesktopIpcErrorReporter(); const requestVersionRef = useRef(0); const [status, setStatus] = useState("idle"); const [availableUpdate, setAvailableUpdate] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [installMessage, setInstallMessage] = useState(null); const [lastCheckedAt, setLastCheckedAt] = useState(null); + const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation< + DesktopAppUpdateInstallResult, + Error + >({ + mutationFn: () => installDesktopAppUpdate({ releaseChannel }), + onError: (error) => { + reportError({ + error, + message: "Unable to install the desktop app update.", + logLabel: "[DesktopUpdater] Failed to install app update", + }); + }, + }); const checkForUpdates = useCallback( async (options: { silent?: boolean } = {}) => { @@ -177,7 +193,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { setErrorMessage(null); try { - const result = await installDesktopAppUpdate({ releaseChannel }); + const result = await installAppUpdate(); setLastCheckedAt(Date.now()); if (result.installed) { @@ -197,7 +213,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { setErrorMessage(message); return null; } - }, [isDesktopApp, releaseChannel]); + }, [installAppUpdate, isDesktopApp]); return { isDesktopApp, @@ -211,7 +227,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { errorMessage, lastCheckedAt, isChecking: status === "checking", - isInstalling: status === "installing", + isInstalling: status === "installing" || isInstallingAppUpdate, checkForUpdates, installUpdate, };