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
This commit is contained in:
Mohamed Boudra
2026-05-06 19:58:17 +08:00
committed by GitHub
parent 24bdd3c8ad
commit ac375f152d
10 changed files with 546 additions and 151 deletions
@@ -8,87 +8,16 @@ import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
getCliDaemonStatus, import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings"; import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
type DesktopDaemonSettings = DesktopSettings["daemon"]; type DesktopDaemonSettings = DesktopSettings["daemon"];
function useDaemonManagementToggle(args: {
daemonStatus: DesktopDaemonStatus | null;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
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<void>,
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: { function useKeepRunningAfterQuitToggle(args: {
settings: DesktopDaemonSettings; settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>; updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
@@ -99,9 +28,8 @@ function useKeepRunningAfterQuitToggle(args: {
const handleToggleKeepRunningAfterQuit = useCallback(() => { const handleToggleKeepRunningAfterQuit = useCallback(() => {
setIsUpdatingKeepRunningAfterQuit(true); setIsUpdatingKeepRunningAfterQuit(true);
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit }) void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
.catch((error) => { .catch(() => {
console.error("[Settings] Failed to update desktop quit daemon behavior", error); // useDesktopSettings owns the user-visible IPC error.
Alert.alert("Error", "Unable to update the desktop quit daemon behavior.");
}) })
.finally(() => { .finally(() => {
setIsUpdatingKeepRunningAfterQuit(false); setIsUpdatingKeepRunningAfterQuit(false);
@@ -393,13 +321,14 @@ export function LocalDaemonSection() {
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon; const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({ const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
daemonStatus, useBuiltInDaemonManagement({
settings: daemonSettings, daemonStatus,
updateSettings: updateDaemonSettings, settings: daemonSettings,
setStatus, updateSettings: updateDaemonSettings,
refetch, setStatus,
}); refreshStatus: refetch,
});
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } = const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
useKeepRunningAfterQuitToggle({ useKeepRunningAfterQuitToggle({
settings: daemonSettings, settings: daemonSettings,
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -7,14 +7,8 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
shouldUseDesktopDaemon, import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
getCliInstallStatus,
installCli,
getSkillsInstallStatus,
installSkills,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills"; 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() { export function IntegrationsSection() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon(); const showSection = shouldUseDesktopDaemon();
const {
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null); status: cliStatus,
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null); isInstalling: isInstallingCli,
const [isInstallingCli, setIsInstallingCli] = useState(false); install: installCli,
const [isInstallingSkills, setIsInstallingSkills] = useState(false); refresh: refreshCliStatus,
} = useCliInstall();
const loadStatus = useCallback(() => { const {
if (!showSection) return; status: skillsStatus,
void getCliInstallStatus() isInstalling: isInstallingSkills,
.then(setCliStatus) install: installSkills,
.catch((error) => { refresh: refreshSkillsStatus,
console.error("[Integrations] Failed to load CLI status", error); } = useSkillsInstall();
});
void getSkillsInstallStatus()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to load skills status", error);
});
}, [showSection]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!showSection) return undefined; if (!showSection) return undefined;
loadStatus(); refreshCliStatus();
refreshSkillsStatus();
return undefined; return undefined;
}, [loadStatus, showSection]), }, [refreshCliStatus, refreshSkillsStatus, showSection]),
); );
const handleInstallCli = useCallback(() => { const handleInstallCli = useCallback(() => {
if (isInstallingCli) return; if (isInstallingCli) return;
setIsInstallingCli(true); installCli();
void installCli() }, [installCli, isInstallingCli]);
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to install CLI", error);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli]);
const handleInstallSkills = useCallback(() => { const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return; if (isInstallingSkills) return;
setIsInstallingSkills(true); installSkills();
void installSkills() }, [installSkills, isInstallingSkills]);
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to install skills", error);
})
.finally(() => {
setIsInstallingSkills(false);
});
}, [isInstallingSkills]);
const handleOpenCliDocs = useCallback(() => { const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL); void openExternalUrl(CLI_DOCS_URL);
@@ -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<Error | null>(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]);
}
@@ -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<DesktopDaemonSettings>) => Promise<unknown>;
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<void>,
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 };
}
@@ -7,6 +7,7 @@ import {
type DesktopDaemonLogs, type DesktopDaemonLogs,
type DesktopDaemonStatus, type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { useDesktopIpcQueryErrorToast } from "@/desktop/hooks/desktop-ipc-error";
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const; const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
@@ -19,16 +20,22 @@ export function useDaemonStatus() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon(); const enabled = shouldUseDesktopDaemon();
const query = useQuery<DaemonStatusData>({ const query = useQuery<DaemonStatusData, Error>({
queryKey: DAEMON_STATUS_QUERY_KEY, queryKey: DAEMON_STATUS_QUERY_KEY,
enabled, enabled,
staleTime: 30_000, staleTime: 30_000,
refetchOnMount: "always", refetchOnMount: "always",
retry: false,
queryFn: async () => { queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]); const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return { status, logs }; return { status, logs };
}, },
}); });
useDesktopIpcQueryErrorToast({
error: query.error,
message: "Unable to load desktop daemon status.",
logLabel: "[DesktopDaemon] Failed to load daemon status",
});
const setStatus = useCallback( const setStatus = useCallback(
(status: DesktopDaemonStatus) => { (status: DesktopDaemonStatus) => {
@@ -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<TResult>(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);
});
});
@@ -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<InstallStatus, Error>({
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<InstallStatus, Error>({
mutationFn: installCli,
onError: (error) => {
reportError({
error,
message: "Unable to install the Paseo CLI.",
logLabel: "[Integrations] Failed to install CLI",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(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<InstallStatus, Error>({
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<InstallStatus, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
error,
message: "Unable to install orchestration skills.",
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(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,
};
}
@@ -1,10 +1,14 @@
import { useCallback } from "react"; 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 { getIsElectron } from "@/constants/platform";
import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import {
useDesktopIpcErrorReporter,
useDesktopIpcQueryErrorToast,
} from "@/desktop/hooks/desktop-ipc-error";
import type { ReleaseChannel } from "@/hooks/use-settings"; 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 { export interface DesktopSettings {
releaseChannel: ReleaseChannel; releaseChannel: ReleaseChannel;
@@ -30,15 +34,59 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
export function useDesktopSettings(): { export function useDesktopSettings(): {
settings: DesktopSettings; settings: DesktopSettings;
isLoading: boolean; isLoading: boolean;
isSaving: boolean;
error: unknown; error: unknown;
updateSettings: (updates: DesktopSettingsPatch) => Promise<void>; updateSettings: (updates: DesktopSettingsPatch) => Promise<void>;
} { } {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({ const reportError = useDesktopIpcErrorReporter();
const {
data,
isPending,
error: loadError,
} = useQuery<DesktopSettings, Error>({
queryKey: DESKTOP_SETTINGS_QUERY_KEY, queryKey: DESKTOP_SETTINGS_QUERY_KEY,
queryFn: loadDesktopSettings, queryFn: loadDesktopSettings,
staleTime: Infinity, staleTime: Infinity,
gcTime: 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<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
DEFAULT_DESKTOP_SETTINGS;
queryClient.setQueryData<DesktopSettings>(
DESKTOP_SETTINGS_QUERY_KEY,
mergeDesktopSettings(previous, updates),
);
return { previous };
},
onSuccess: (persisted) => {
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
},
onError: (saveError, _updates, context) => {
if (context) {
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, context.previous);
}
reportError({
error: saveError,
message: "Unable to save desktop settings.",
logLabel: "[DesktopSettings] Failed to save settings",
});
},
}); });
const updateSettings = useCallback( const updateSettings = useCallback(
@@ -47,25 +95,24 @@ export function useDesktopSettings(): {
return; return;
} }
const previous = await saveDesktopSettings(updates);
queryClient.getQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
DEFAULT_DESKTOP_SETTINGS;
const next = mergeDesktopSettings(previous, updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, next);
const persisted = await updatePersistedDesktopSettings(updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
}, },
[queryClient], [saveDesktopSettings],
); );
return { return {
settings: data ?? DEFAULT_DESKTOP_SETTINGS, settings: data ?? DEFAULT_DESKTOP_SETTINGS,
isLoading: isPending, isLoading: isPending,
error: error ?? null, isSaving,
error: loadError ?? null,
updateSettings, updateSettings,
}; };
} }
interface DesktopSettingsMutationContext {
previous: DesktopSettings;
}
export async function loadDesktopSettings(): Promise<DesktopSettings> { export async function loadDesktopSettings(): Promise<DesktopSettings> {
if (!getIsElectron()) { if (!getIsElectron()) {
return DEFAULT_DESKTOP_SETTINGS; return DEFAULT_DESKTOP_SETTINGS;
@@ -1,7 +1,9 @@
/** /**
* @vitest-environment jsdom * @vitest-environment jsdom
*/ */
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/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 { beforeEach, describe, expect, it, vi } from "vitest";
import { useDesktopAppUpdater } from "./use-desktop-app-updater"; 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", () => { describe("useDesktopAppUpdater", () => {
beforeEach(() => { beforeEach(() => {
settingsState.releaseChannel = "stable"; settingsState.releaseChannel = "stable";
@@ -47,7 +70,7 @@ describe("useDesktopAppUpdater", () => {
it("uses the effective desktop release channel when checking for updates", async () => { it("uses the effective desktop release channel when checking for updates", async () => {
settingsState.releaseChannel = "beta"; settingsState.releaseChannel = "beta";
renderHook(() => useDesktopAppUpdater()); renderUpdaterHook();
await waitFor(() => { await waitFor(() => {
expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({ expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
@@ -58,7 +81,7 @@ describe("useDesktopAppUpdater", () => {
it("uses the effective desktop release channel when installing updates", async () => { it("uses the effective desktop release channel when installing updates", async () => {
settingsState.releaseChannel = "beta"; settingsState.releaseChannel = "beta";
const { result } = renderHook(() => useDesktopAppUpdater()); const { result } = renderUpdaterHook();
await act(async () => { await act(async () => {
await result.current.installUpdate(); await result.current.installUpdate();
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { import {
checkDesktopAppUpdate, checkDesktopAppUpdate,
formatVersionWithPrefix, formatVersionWithPrefix,
@@ -8,6 +9,7 @@ import {
type DesktopAppUpdateInstallResult, type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates"; } from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
export type DesktopAppUpdateStatus = export type DesktopAppUpdateStatus =
| "idle" | "idle"
@@ -86,12 +88,26 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection(); const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings: desktopSettings } = useDesktopSettings(); const { settings: desktopSettings } = useDesktopSettings();
const releaseChannel = desktopSettings.releaseChannel; const releaseChannel = desktopSettings.releaseChannel;
const reportError = useDesktopIpcErrorReporter();
const requestVersionRef = useRef(0); const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle"); const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null); const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [installMessage, setInstallMessage] = useState<string | null>(null); const [installMessage, setInstallMessage] = useState<string | null>(null);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null); const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(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( const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => { async (options: { silent?: boolean } = {}) => {
@@ -177,7 +193,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null); setErrorMessage(null);
try { try {
const result = await installDesktopAppUpdate({ releaseChannel }); const result = await installAppUpdate();
setLastCheckedAt(Date.now()); setLastCheckedAt(Date.now());
if (result.installed) { if (result.installed) {
@@ -197,7 +213,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message); setErrorMessage(message);
return null; return null;
} }
}, [isDesktopApp, releaseChannel]); }, [installAppUpdate, isDesktopApp]);
return { return {
isDesktopApp, isDesktopApp,
@@ -211,7 +227,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
errorMessage, errorMessage,
lastCheckedAt, lastCheckedAt,
isChecking: status === "checking", isChecking: status === "checking",
isInstalling: status === "installing", isInstalling: status === "installing" || isInstallingAppUpdate,
checkForUpdates, checkForUpdates,
installUpdate, installUpdate,
}; };