diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 8268a31a8..94d98967a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -48,6 +48,7 @@ import { import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { ToastProvider } from "@/contexts/toast-context"; import { VoiceProvider } from "@/contexts/voice-context"; +import { initializeHostRuntime } from "@/app/host-runtime-bootstrap"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; @@ -179,19 +180,18 @@ function PushNotificationRouter() { }); } - const target = globalThis as unknown as EventTarget; const openFromWebClick = (event: Event) => { const customEvent = event as CustomEvent; event.preventDefault(); openNotification(customEvent.detail?.data); }; - target.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); + window.addEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); return () => { cancelled = true; removeDesktopNotificationListener?.(); - target.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); + window.removeEventListener(WEB_NOTIFICATION_CLICK_EVENT, openFromWebClick as EventListener); }; } @@ -278,93 +278,45 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { let cancelled = false; - let cancelAnyOnline: (() => void) | null = null; + let cancelInitialization: (() => void) | null = null; const shouldManageDesktop = shouldUseDesktopDaemon(); const store = getHostRuntimeStore(); - const init = async () => { - const settings = await loadSettingsFromStorage(); - const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon; - await store.loadFromStorage(); - if (isDesktopManaged) { - setPhase("starting-daemon"); - setError(null); - - let raceSettled = false; - - const anyOnline = store.waitForAnyConnectionOnline(); - cancelAnyOnline = anyOnline.cancel; - - const bootstrapPromise = (async (): Promise< - { type: "online" } | { type: "error"; error: string } - > => { - try { - const bootstrapResult = await store.bootstrapDesktop(); - if (!bootstrapResult.ok) { - return { type: "error", error: bootstrapResult.error }; - } - if (!cancelled && !raceSettled) { - setPhase("connecting"); - } - await store.addConnectionFromListenAndWaitForOnline({ - listenAddress: bootstrapResult.listenAddress, - serverId: bootstrapResult.serverId, - hostname: bootstrapResult.hostname, - }); - return { type: "online" }; - } catch (err) { - return { - type: "error", - error: err instanceof Error ? err.message : String(err), - }; - } - })(); - - const result = await Promise.race([ - anyOnline.promise.then((): { type: "online" } => ({ type: "online" })), - bootstrapPromise, - ]); - - raceSettled = true; - anyOnline.cancel(); - - if (!cancelled) { - if (result.type === "online") { - setPhase("online"); - setError(null); - } else { - setPhase("error"); - setError(result.error); - } + void initializeHostRuntime({ + shouldManageDesktop, + loadSettings: loadSettingsFromStorage, + store, + setPhase, + setError, + isCancelled: () => cancelled, + }) + .then((cleanup) => { + if (cancelled) { + cleanup(); + return cleanup; } - } else { - setPhase("connecting"); - setError(null); - await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); - if (!cancelled) { - setPhase("online"); - setError(null); + cancelInitialization = cleanup; + return cleanup; + }) + .catch((bootstrapError) => { + console.error("[HostRuntime] Failed to initialize store", bootstrapError); + if (cancelled) { + return; } - } - }; - - void init().catch((bootstrapError) => { - console.error("[HostRuntime] Failed to initialize store", bootstrapError); - if (cancelled) { - return; - } - if (shouldManageDesktop) { - setPhase("error"); - setError(bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError)); - return; - } - setPhase("online"); - setError(null); - }); + if (shouldManageDesktop) { + setPhase("error"); + setError( + bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError), + ); + return; + } + setPhase("online"); + setError(null); + }); return () => { cancelled = true; - cancelAnyOnline?.(); + cancelInitialization?.(); }; }, [retryToken]); diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts new file mode 100644 index 000000000..1653a5f89 --- /dev/null +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { initializeHostRuntime } from "./host-runtime-bootstrap"; + +describe("initializeHostRuntime", () => { + it("uses effective desktop settings to skip desktop-managed bootstrap when daemon management is disabled", async () => { + const loadFromStorage = vi.fn(async () => {}); + const bootstrap = vi.fn(async () => {}); + const bootstrapDesktop = vi.fn(async () => ({ + ok: true as const, + listenAddress: "127.0.0.1:6767", + serverId: "srv_test", + hostname: "test", + })); + const addConnectionFromListenAndWaitForOnline = vi.fn(async () => {}); + const setPhase = vi.fn(); + const setError = vi.fn(); + + await initializeHostRuntime({ + shouldManageDesktop: true, + loadSettings: async () => ({ + theme: "auto", + sendBehavior: "interrupt", + manageBuiltInDaemon: false, + releaseChannel: "stable", + }), + store: { + loadFromStorage, + bootstrap, + bootstrapDesktop, + addConnectionFromListenAndWaitForOnline, + waitForAnyConnectionOnline: () => ({ + promise: new Promise<{ type: "online" }>(() => {}), + cancel: vi.fn(), + }), + }, + setPhase, + setError, + isCancelled: () => false, + }); + + expect(bootstrap).toHaveBeenCalledWith({ manageBuiltInDaemon: false }); + expect(setPhase).toHaveBeenLastCalledWith("online"); + expect(setError).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts new file mode 100644 index 000000000..373352236 --- /dev/null +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -0,0 +1,96 @@ +import type { HostRuntimeBootstrapResult } from "@/runtime/host-runtime"; +import type { Settings } from "@/hooks/use-settings"; + +type HostRuntimeBootstrapPhase = "starting-daemon" | "connecting" | "online" | "error"; + +interface HostRuntimeBootstrapStore { + loadFromStorage: () => Promise; + bootstrap: (options?: { manageBuiltInDaemon?: boolean }) => Promise; + bootstrapDesktop: () => Promise; + addConnectionFromListenAndWaitForOnline: (input: { + listenAddress: string; + serverId: string; + hostname: string | null; + }) => Promise; + waitForAnyConnectionOnline: () => { + promise: Promise; + cancel: () => void; + }; +} + +export async function initializeHostRuntime(args: { + shouldManageDesktop: boolean; + loadSettings: () => Promise; + store: HostRuntimeBootstrapStore; + setPhase: (phase: HostRuntimeBootstrapPhase) => void; + setError: (error: string | null) => void; + isCancelled: () => boolean; +}): Promise<() => void> { + const { shouldManageDesktop, loadSettings, store, setPhase, setError, isCancelled } = args; + + const settings = await loadSettings(); + const isDesktopManaged = shouldManageDesktop && settings.manageBuiltInDaemon; + await store.loadFromStorage(); + + if (!isDesktopManaged) { + setPhase("connecting"); + setError(null); + await store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon }); + if (!isCancelled()) { + setPhase("online"); + setError(null); + } + return () => {}; + } + + setPhase("starting-daemon"); + setError(null); + + const anyOnline = store.waitForAnyConnectionOnline(); + + const bootstrapPromise = (async (): Promise => { + try { + const bootstrapResult = await store.bootstrapDesktop(); + if (!bootstrapResult.ok) { + return { type: "error", error: bootstrapResult.error }; + } + if (!isCancelled()) { + setPhase("connecting"); + } + await store.addConnectionFromListenAndWaitForOnline({ + listenAddress: bootstrapResult.listenAddress, + serverId: bootstrapResult.serverId, + hostname: bootstrapResult.hostname, + }); + return { type: "online" }; + } catch (error) { + return { + type: "error", + error: error instanceof Error ? error.message : String(error), + }; + } + })(); + + const onlineFromAny: Promise = anyOnline.promise.then(() => ({ + type: "online", + })); + const result = await Promise.race([onlineFromAny, bootstrapPromise]); + + anyOnline.cancel(); + + if (!isCancelled()) { + if (result.type === "online") { + setPhase("online"); + setError(null); + } else { + setPhase("error"); + setError(result.error); + } + } + + return () => { + anyOnline.cancel(); + }; +} + +type BootstrapOutcome = { type: "online" } | { type: "error"; error: string }; diff --git a/packages/app/src/desktop/components/desktop-updates-section.test.tsx b/packages/app/src/desktop/components/desktop-updates-section.test.tsx new file mode 100644 index 000000000..1d2c1fe4b --- /dev/null +++ b/packages/app/src/desktop/components/desktop-updates-section.test.tsx @@ -0,0 +1,279 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LocalDaemonSection } from "./desktop-updates-section"; + +const { alertMock, confirmDialogMock, settingsState, daemonStatusState } = vi.hoisted(() => ({ + alertMock: vi.fn(), + confirmDialogMock: vi.fn(), + settingsState: { + settings: { + releaseChannel: "stable" as const, + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, + }, + updateSettings: vi.fn< + (updates: { + daemon?: { + manageBuiltInDaemon?: boolean; + keepRunningAfterQuit?: boolean; + }; + }) => Promise + >(), + }, + daemonStatusState: { + data: { + status: { + serverId: "desktop", + status: "running" as const, + listen: null, + hostname: null, + pid: 123, + home: "/tmp/paseo", + version: "1.2.3", + desktopManaged: true, + error: null, + }, + logs: { + logPath: "/tmp/paseo/daemon.log", + contents: "daemon log", + }, + }, + isLoading: false, + error: null as string | null, + setStatus: vi.fn(), + refetch: vi.fn(), + }, +})); + +vi.mock("react-native", () => ({ + ActivityIndicator: () => React.createElement("div", { "data-testid": "loading-spinner" }), + Alert: { alert: alertMock }, + Text: ({ children }: { children?: React.ReactNode }) => + React.createElement("span", null, children), + View: ({ children }: { children?: React.ReactNode }) => + React.createElement("div", null, children), +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" + ? (factory as (theme: Record) => unknown)({ + spacing: { 1: 4, 2: 8, 3: 12, 6: 24 }, + borderRadius: { lg: 12 }, + fontSize: { xs: 12, sm: 14 }, + colors: { + foreground: "#111", + foregroundMuted: "#666", + palette: { amber: { 500: "#f59e0b" } }, + }, + }) + : factory, + }, + useUnistyles: () => ({ + theme: { + iconSize: { sm: 14 }, + colors: { + foreground: "#111", + foregroundMuted: "#666", + }, + }, + }), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: vi.fn(() => Promise.resolve()), +})); + +vi.mock("lucide-react-native", () => { + const icon = (name: string) => () => React.createElement("span", { "data-icon": name }); + return { + Activity: icon("Activity"), + ArrowUpRight: icon("ArrowUpRight"), + Copy: icon("Copy"), + FileText: icon("FileText"), + Pause: icon("Pause"), + Play: icon("Play"), + RotateCw: icon("RotateCw"), + }; +}); + +vi.mock("@/styles/settings", () => ({ + settingsStyles: new Proxy( + {}, + { + get: (_target, prop) => String(prop), + }, + ), +})); + +vi.mock("@/screens/settings/settings-section", () => ({ + SettingsSection: ({ + children, + title, + trailing, + }: { + children?: React.ReactNode; + title: string; + trailing?: React.ReactNode; + }) => + React.createElement( + "section", + null, + React.createElement("h2", null, title), + trailing, + children, + ), +})); + +vi.mock("@/components/adaptive-modal-sheet", () => ({ + AdaptiveModalSheet: ({ + children, + visible, + title, + }: { + children?: React.ReactNode; + visible?: boolean; + title?: string; + }) => (visible ? React.createElement("div", { "data-title": title }, children) : null), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onPress, + disabled, + accessibilityLabel, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + accessibilityLabel?: string; + }) => + React.createElement( + "button", + { type: "button", onClick: onPress, disabled, "aria-label": accessibilityLabel }, + children, + ), +})); + +vi.mock("@/desktop/settings/desktop-settings", () => ({ + useDesktopSettings: () => ({ + ...settingsState, + isLoading: false, + error: null, + }), +})); + +vi.mock("@/desktop/hooks/use-daemon-status", () => ({ + useDaemonStatus: () => daemonStatusState, +})); + +vi.mock("@/utils/confirm-dialog", () => ({ + confirmDialog: confirmDialogMock, +})); + +vi.mock("@/utils/open-external-url", () => ({ + openExternalUrl: vi.fn(), +})); + +vi.mock("@/desktop/updates/desktop-updates", () => ({ + isVersionMismatch: vi.fn(() => false), +})); + +const daemonCommandMocks = vi.hoisted(() => ({ + getCliDaemonStatusMock: vi.fn(), + restartDesktopDaemonMock: vi.fn(), + startDesktopDaemonMock: vi.fn(), + stopDesktopDaemonMock: vi.fn(), +})); + +vi.mock("@/desktop/daemon/desktop-daemon", () => ({ + getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock, + restartDesktopDaemon: daemonCommandMocks.restartDesktopDaemonMock, + shouldUseDesktopDaemon: vi.fn(() => true), + startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock, + stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock, +})); + +vi.mock("@/utils/app-version", () => ({ + resolveAppVersion: vi.fn(() => "1.2.3"), +})); + +describe("LocalDaemonSection", () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + alertMock.mockReset(); + confirmDialogMock.mockReset(); + settingsState.settings.daemon.manageBuiltInDaemon = true; + settingsState.settings.daemon.keepRunningAfterQuit = true; + settingsState.updateSettings.mockReset(); + settingsState.updateSettings.mockResolvedValue(); + daemonStatusState.data.status.status = "running"; + daemonStatusState.setStatus.mockReset(); + daemonStatusState.refetch.mockReset(); + daemonCommandMocks.startDesktopDaemonMock.mockReset(); + daemonCommandMocks.stopDesktopDaemonMock.mockReset(); + daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({ + ...daemonStatusState.data.status, + status: "stopped", + }); + daemonCommandMocks.restartDesktopDaemonMock.mockReset(); + daemonCommandMocks.getCliDaemonStatusMock.mockReset(); + }); + + it("shows the keep-running-after-quit control enabled by default", () => { + const screen = render(); + + expect(screen.getByText("Keep daemon running after quit")).toBeTruthy(); + expect( + screen.getByText( + "Enabled. The built-in daemon keeps running after you close the desktop app.", + ), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disable" })).toBeTruthy(); + }); + + it("updates keep-running-after-quit without changing daemon lifecycle", async () => { + const screen = render(); + + fireEvent.click(screen.getByRole("button", { name: "Disable" })); + + await waitFor(() => { + expect(settingsState.updateSettings).toHaveBeenCalledWith({ + daemon: { + keepRunningAfterQuit: false, + }, + }); + }); + expect(confirmDialogMock).not.toHaveBeenCalled(); + expect(daemonCommandMocks.startDesktopDaemonMock).not.toHaveBeenCalled(); + expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled(); + expect(daemonCommandMocks.restartDesktopDaemonMock).not.toHaveBeenCalled(); + }); + + it("pauses built-in daemon management and persists the setting through desktop settings", async () => { + confirmDialogMock.mockResolvedValue(true); + const screen = render(); + + fireEvent.click(screen.getByRole("button", { name: "Pause" })); + + await waitFor(() => { + expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1); + }); + expect(settingsState.updateSettings).toHaveBeenCalledWith({ + daemon: { + manageBuiltInDaemon: false, + }, + }); + }); +}); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index f1d25066b..7a08ae0a1 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import React, { type ReactElement, useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -7,7 +7,6 @@ import { SettingsSection } from "@/screens/settings/settings-section"; import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; -import { useAppSettings } from "@/hooks/use-settings"; import { confirmDialog } from "@/utils/confirm-dialog"; import { openExternalUrl } from "@/utils/open-external-url"; import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; @@ -19,21 +18,25 @@ import { stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; 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"; -function getDaemonManagementButtonLabel(isUpdating: boolean, isPaused: boolean): string { +type DesktopDaemonSettings = DesktopSettings["daemon"]; + +function managementLabel(isUpdating: boolean, isPaused: boolean): string { if (isUpdating) return isPaused ? "Resuming..." : "Pausing..."; return isPaused ? "Resume" : "Pause"; } -function getDaemonRestartButtonLabel( - isRestarting: boolean, - daemonStatus: string | undefined, - actionLabel: string, -): string { - if (!isRestarting) return actionLabel; - return daemonStatus === "running" ? "Restarting..." : "Starting..."; +function keepRunningLabel(isUpdating: boolean, isEnabled: boolean): string { + if (isUpdating) return isEnabled ? "Disabling..." : "Enabling..."; + return isEnabled ? "Disable" : "Enable"; +} + +function restartLabel(isRestarting: boolean, isRunning: boolean, idleLabel: string): string { + if (!isRestarting) return idleLabel; + return isRunning ? "Restarting..." : "Starting..."; } function useDaemonRestartAction(args: { @@ -109,8 +112,8 @@ function useDaemonRestartAction(args: { function useDaemonManagementToggle(args: { daemonStatus: DesktopDaemonStatus | null; - settings: { manageBuiltInDaemon: boolean }; - updateSettings: (next: { manageBuiltInDaemon: boolean }) => Promise; + settings: DesktopDaemonSettings; + updateSettings: (next: Partial) => Promise; setStatus: (status: DesktopDaemonStatus) => void; setStatusMessage: (message: string | null) => void; refetch: () => void; @@ -200,6 +203,28 @@ function useDaemonManagementToggle(args: { return { isUpdatingDaemonManagement, handleToggleDaemonManagement }; } +function useKeepRunningAfterQuitToggle(args: { + settings: DesktopDaemonSettings; + updateSettings: (next: Partial) => Promise; +}) { + const { settings, updateSettings } = args; + const [isUpdatingKeepRunningAfterQuit, setIsUpdatingKeepRunningAfterQuit] = useState(false); + + 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."); + }) + .finally(() => { + setIsUpdatingKeepRunningAfterQuit(false); + }); + }, [settings.keepRunningAfterQuit, updateSettings]); + + return { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit }; +} + function useDaemonCliStatusModal() { const [cliStatusOutput, setCliStatusOutput] = useState(null); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); @@ -233,10 +258,6 @@ function useDaemonCliStatusModal() { }); }, [cliStatusOutput]); - const handleRunCliStatus = useCallback(() => { - void handleOpenCliStatus(); - }, [handleOpenCliStatus]); - const handleCloseCliStatusModal = useCallback(() => setIsCliStatusModalOpen(false), []); return { @@ -244,7 +265,7 @@ function useDaemonCliStatusModal() { isCliStatusModalOpen, isLoadingCliStatus, handleCopyCliStatus, - handleRunCliStatus, + handleOpenCliStatus, handleCloseCliStatusModal, }; } @@ -348,14 +369,17 @@ interface DaemonInfoCardProps { daemonStatusStateText: string; daemonStatusDetailText: string; isDaemonManagementPaused: boolean; - playIcon: React.ReactElement; - pauseIcon: React.ReactElement; - rotateIcon: React.ReactElement; - copyIcon: React.ReactElement; - fileTextIcon: React.ReactElement; - activityIcon: React.ReactElement; + playIcon: ReactElement; + pauseIcon: ReactElement; + rotateIcon: ReactElement; + copyIcon: ReactElement; + fileTextIcon: ReactElement; + activityIcon: ReactElement; handleToggleDaemonManagement: () => void; isUpdatingDaemonManagement: boolean; + keepRunningAfterQuit: boolean; + handleToggleKeepRunningAfterQuit: () => void; + isUpdatingKeepRunningAfterQuit: boolean; daemonActionLabel: string; daemonActionMessage: string; statusMessage: string | null; @@ -382,6 +406,9 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { activityIcon, handleToggleDaemonManagement, isUpdatingDaemonManagement, + keepRunningAfterQuit, + handleToggleKeepRunningAfterQuit, + isUpdatingKeepRunningAfterQuit, daemonActionLabel, daemonActionMessage, statusMessage, @@ -395,6 +422,20 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { isLoadingCliStatus, } = props; + const managementButtonLabel = managementLabel( + isUpdatingDaemonManagement, + isDaemonManagementPaused, + ); + const keepRunningButtonLabel = keepRunningLabel( + isUpdatingKeepRunningAfterQuit, + keepRunningAfterQuit, + ); + const restartButtonLabel = restartLabel( + isRestartingDaemon, + daemonStatus?.status === "running", + daemonActionLabel, + ); + return ( @@ -425,7 +466,25 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { onPress={handleToggleDaemonManagement} disabled={isUpdatingDaemonManagement} > - {getDaemonManagementButtonLabel(isUpdatingDaemonManagement, isDaemonManagementPaused)} + {managementButtonLabel} + + + + + Keep daemon running after quit + + {keepRunningAfterQuit + ? "Enabled. The built-in daemon keeps running after you close the desktop app." + : "Disabled. Quitting the desktop app stops the built-in daemon if desktop management is enabled."} + + + @@ -441,7 +500,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) { onPress={handleUpdateLocalDaemon} disabled={isRestartingDaemon} > - {getDaemonRestartButtonLabel(isRestartingDaemon, daemonStatus?.status, daemonActionLabel)} + {restartButtonLabel} @@ -493,7 +552,12 @@ export function LocalDaemonSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); const appVersion = resolveAppVersion(); - const { settings, updateSettings } = useAppSettings(); + const { settings, updateSettings, isLoading: isLoadingSettings } = useDesktopSettings(); + const daemonSettings = settings.daemon; + const updateDaemonSettings = useCallback( + (updates: Partial) => updateSettings({ daemon: updates }), + [updateSettings], + ); const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const daemonStatus = data?.status ?? null; @@ -504,7 +568,7 @@ export function LocalDaemonSection() { const daemonStatusStateText = statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; - const isDaemonManagementPaused = !settings.manageBuiltInDaemon; + const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon; const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon"; const daemonActionMessage = daemonStatus?.status === "running" @@ -522,12 +586,17 @@ export function LocalDaemonSection() { const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({ daemonStatus, - settings, - updateSettings, + settings: daemonSettings, + updateSettings: updateDaemonSettings, setStatus, setStatusMessage, refetch, }); + const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } = + useKeepRunningAfterQuitToggle({ + settings: daemonSettings, + updateSettings: updateDaemonSettings, + }); const { isLogsModalOpen, handleCopyLogPath, handleOpenLogs, handleCloseLogsModal } = useDaemonLogsModal(daemonLogs); @@ -537,9 +606,12 @@ export function LocalDaemonSection() { isCliStatusModalOpen, isLoadingCliStatus, handleCopyCliStatus, - handleRunCliStatus, + handleOpenCliStatus, handleCloseCliStatusModal, } = useDaemonCliStatusModal(); + const handleRunCliStatus = useCallback(() => { + void handleOpenCliStatus(); + }, [handleOpenCliStatus]); const handleOpenAdvancedSettings = useCallback( () => void openExternalUrl(ADVANCED_DAEMON_SETTINGS_URL), @@ -602,7 +674,7 @@ export function LocalDaemonSection() { trailing={advancedSettingsButton} testID="host-page-daemon-lifecycle-card" > - {isLoading ? ( + {isLoading || isLoadingSettings ? ( @@ -620,6 +692,9 @@ export function LocalDaemonSection() { activityIcon={activityIcon} handleToggleDaemonManagement={handleToggleDaemonManagement} isUpdatingDaemonManagement={isUpdatingDaemonManagement} + keepRunningAfterQuit={daemonSettings.keepRunningAfterQuit} + handleToggleKeepRunningAfterQuit={handleToggleKeepRunningAfterQuit} + isUpdatingKeepRunningAfterQuit={isUpdatingKeepRunningAfterQuit} daemonActionLabel={daemonActionLabel} daemonActionMessage={daemonActionMessage} statusMessage={statusMessage} diff --git a/packages/app/src/desktop/settings/desktop-settings.ts b/packages/app/src/desktop/settings/desktop-settings.ts new file mode 100644 index 000000000..22df5bae3 --- /dev/null +++ b/packages/app/src/desktop/settings/desktop-settings.ts @@ -0,0 +1,138 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getIsElectron } from "@/constants/platform"; +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import type { ReleaseChannel } from "@/hooks/use-settings"; + +export const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const; + +export interface DesktopSettings { + releaseChannel: ReleaseChannel; + daemon: { + manageBuiltInDaemon: boolean; + keepRunningAfterQuit: boolean; + }; +} + +export interface DesktopSettingsPatch { + releaseChannel?: ReleaseChannel; + daemon?: Partial; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, +}; + +export function useDesktopSettings(): { + settings: DesktopSettings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: DesktopSettingsPatch) => Promise; +} { + const queryClient = useQueryClient(); + const { data, isPending, error } = useQuery({ + queryKey: DESKTOP_SETTINGS_QUERY_KEY, + queryFn: loadDesktopSettings, + staleTime: Infinity, + gcTime: Infinity, + }); + + const updateSettings = useCallback( + async (updates: DesktopSettingsPatch) => { + if (!getIsElectron()) { + 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); + }, + [queryClient], + ); + + return { + settings: data ?? DEFAULT_DESKTOP_SETTINGS, + isLoading: isPending, + error: error ?? null, + updateSettings, + }; +} + +export async function loadDesktopSettings(): Promise { + if (!getIsElectron()) { + return DEFAULT_DESKTOP_SETTINGS; + } + return parseDesktopSettings(await invokeDesktopCommand("get_desktop_settings")); +} + +export async function updatePersistedDesktopSettings( + updates: DesktopSettingsPatch, +): Promise { + if (!getIsElectron()) { + return DEFAULT_DESKTOP_SETTINGS; + } + return parseDesktopSettings( + await invokeDesktopCommand("patch_desktop_settings", normalizePatch(updates)), + ); +} + +export async function migrateLegacyDesktopSettings(input: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; +}): Promise { + if (!getIsElectron()) { + return; + } + await invokeDesktopCommand("migrate_legacy_desktop_settings", input); +} + +function parseDesktopSettings(raw: unknown): DesktopSettings { + const record = isRecord(raw) ? raw : {}; + const daemon = isRecord(record.daemon) ? record.daemon : {}; + + return { + releaseChannel: record.releaseChannel === "beta" ? "beta" : "stable", + daemon: { + manageBuiltInDaemon: + typeof daemon.manageBuiltInDaemon === "boolean" + ? daemon.manageBuiltInDaemon + : DEFAULT_DESKTOP_SETTINGS.daemon.manageBuiltInDaemon, + keepRunningAfterQuit: + typeof daemon.keepRunningAfterQuit === "boolean" + ? daemon.keepRunningAfterQuit + : DEFAULT_DESKTOP_SETTINGS.daemon.keepRunningAfterQuit, + }, + }; +} + +function mergeDesktopSettings( + current: DesktopSettings, + updates: DesktopSettingsPatch, +): DesktopSettings { + return { + releaseChannel: updates.releaseChannel ?? current.releaseChannel, + daemon: { + ...current.daemon, + ...updates.daemon, + }, + }; +} + +function normalizePatch(updates: DesktopSettingsPatch): Record { + return { + ...(updates.releaseChannel ? { releaseChannel: updates.releaseChannel } : {}), + ...(updates.daemon ? { daemon: updates.daemon } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} 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 new file mode 100644 index 000000000..99d9f3d16 --- /dev/null +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.test.tsx @@ -0,0 +1,71 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDesktopAppUpdater } from "./use-desktop-app-updater"; + +const desktopUpdatesMock = vi.hoisted(() => ({ + shouldShowDesktopUpdateSection: vi.fn(() => true), + checkDesktopAppUpdate: vi.fn(async () => ({ + hasUpdate: false, + readyToInstall: false, + latestVersion: null, + })), + installDesktopAppUpdate: vi.fn(async () => ({ + installed: false, + message: null, + })), + formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"), +})); + +const settingsState = vi.hoisted(() => ({ + releaseChannel: "stable" as "stable" | "beta", +})); + +vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock); + +vi.mock("@/desktop/settings/desktop-settings", () => ({ + useDesktopSettings: () => ({ + settings: { + releaseChannel: settingsState.releaseChannel, + daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true }, + }, + isLoading: false, + error: null, + updateSettings: vi.fn(async () => {}), + }), +})); + +describe("useDesktopAppUpdater", () => { + beforeEach(() => { + settingsState.releaseChannel = "stable"; + desktopUpdatesMock.checkDesktopAppUpdate.mockClear(); + desktopUpdatesMock.installDesktopAppUpdate.mockClear(); + }); + + it("uses the effective desktop release channel when checking for updates", async () => { + settingsState.releaseChannel = "beta"; + + renderHook(() => useDesktopAppUpdater()); + + await waitFor(() => { + expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({ + releaseChannel: "beta", + }); + }); + }); + + it("uses the effective desktop release channel when installing updates", async () => { + settingsState.releaseChannel = "beta"; + const { result } = renderHook(() => useDesktopAppUpdater()); + + await act(async () => { + await result.current.installUpdate(); + }); + + expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({ + releaseChannel: "beta", + }); + }); +}); 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 53c27494e..c4dd7d2c8 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -7,7 +7,7 @@ import { type DesktopAppUpdateCheckResult, type DesktopAppUpdateInstallResult, } from "@/desktop/updates/desktop-updates"; -import { useAppSettings } from "@/hooks/use-settings"; +import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; export type DesktopAppUpdateStatus = | "idle" @@ -84,8 +84,8 @@ function formatStatusText(input: { export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { const isDesktopApp = shouldShowDesktopUpdateSection(); - const { settings } = useAppSettings(); - const releaseChannel = settings.releaseChannel; + const { settings: desktopSettings } = useDesktopSettings(); + const releaseChannel = desktopSettings.releaseChannel; const requestVersionRef = useRef(0); const [status, setStatus] = useState("idle"); const [availableUpdate, setAvailableUpdate] = useState(null); diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts index e8a9d4914..a4494c8e3 100644 --- a/packages/app/src/hooks/use-settings.test.ts +++ b/packages/app/src/hooks/use-settings.test.ts @@ -5,15 +5,33 @@ const asyncStorageMock = vi.hoisted(() => ({ setItem: vi.fn<(_: string, __: string) => Promise>(), })); +const electronRuntimeState = vi.hoisted(() => ({ + isElectron: false, +})); + +const desktopSettingsMock = vi.hoisted(() => ({ + loadDesktopSettings: vi.fn<() => Promise>(), + migrateLegacyDesktopSettings: vi.fn<(_: unknown) => Promise>(), +})); + vi.mock("@react-native-async-storage/async-storage", () => ({ default: asyncStorageMock, })); +vi.mock("@/desktop/host", () => ({ + isElectronRuntime: () => electronRuntimeState.isElectron, +})); + +vi.mock("@/desktop/settings/desktop-settings", () => desktopSettingsMock); + describe("use-settings", () => { beforeEach(() => { vi.resetModules(); asyncStorageMock.getItem.mockReset(); asyncStorageMock.setItem.mockReset(); + electronRuntimeState.isElectron = false; + desktopSettingsMock.loadDesktopSettings.mockReset(); + desktopSettingsMock.migrateLegacyDesktopSettings.mockReset(); }); it("defaults built-in daemon management to enabled when storage is empty", async () => { @@ -26,7 +44,7 @@ describe("use-settings", () => { expect(result).toEqual(mod.DEFAULT_APP_SETTINGS); expect(asyncStorageMock.setItem).toHaveBeenCalledWith( mod.APP_SETTINGS_KEY, - JSON.stringify(mod.DEFAULT_APP_SETTINGS), + JSON.stringify(mod.DEFAULT_CLIENT_SETTINGS), ); }); @@ -50,7 +68,7 @@ describe("use-settings", () => { expect(result.releaseChannel).toBe("stable"); }); - it("loads persisted built-in daemon management state", async () => { + it("ignores renderer-owned daemon management state outside Electron", async () => { asyncStorageMock.getItem.mockImplementation(async (key: string) => { if (key === "@paseo:app-settings") { return JSON.stringify({ @@ -66,14 +84,13 @@ describe("use-settings", () => { expect(result).toEqual({ theme: "light", - manageBuiltInDaemon: false, + manageBuiltInDaemon: true, sendBehavior: "interrupt", releaseChannel: "stable", }); - expect(asyncStorageMock.setItem).not.toHaveBeenCalled(); }); - it("loads persisted beta release channel", async () => { + it("ignores renderer-owned release channel outside Electron", async () => { asyncStorageMock.getItem.mockImplementation(async (key: string) => { if (key === "@paseo:app-settings") { return JSON.stringify({ @@ -86,6 +103,89 @@ describe("use-settings", () => { const mod = await import("./use-settings"); const result = await mod.loadSettingsFromStorage(); - expect(result.releaseChannel).toBe("beta"); + expect(result.releaseChannel).toBe("stable"); + }); + + it("keeps legacy AsyncStorage migration for client settings only", async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === "@paseo:app-settings") { + return null; + } + if (key === "@paseo:settings") { + return JSON.stringify({ + theme: "dark", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + } + return null; + }); + asyncStorageMock.setItem.mockResolvedValue(); + + const mod = await import("./use-settings"); + const result = await mod.loadAppSettingsFromStorage(); + + expect(result).toEqual({ + theme: "dark", + sendBehavior: "interrupt", + }); + expect(asyncStorageMock.setItem).toHaveBeenCalledWith( + mod.APP_SETTINGS_KEY, + JSON.stringify(result), + ); + }); + + it("migrates legacy desktop-owned settings through Electron before reading effective settings", async () => { + electronRuntimeState.isElectron = true; + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === "@paseo:app-settings") { + return JSON.stringify({ + theme: "light", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + } + return null; + }); + desktopSettingsMock.migrateLegacyDesktopSettings.mockResolvedValue(); + desktopSettingsMock.loadDesktopSettings.mockResolvedValue({ + releaseChannel: "beta", + daemon: { + manageBuiltInDaemon: false, + keepRunningAfterQuit: true, + }, + }); + + const mod = await import("./use-settings"); + const result = await mod.loadSettingsFromStorage(); + + expect(desktopSettingsMock.migrateLegacyDesktopSettings).toHaveBeenCalledWith({ + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + manageBuiltInDaemon: false, + releaseChannel: "beta", + }); + }); + + it("skips desktop IPC when loading effective settings outside Electron", async () => { + asyncStorageMock.getItem.mockResolvedValue( + JSON.stringify({ + theme: "light", + }), + ); + + const mod = await import("./use-settings"); + const result = await mod.loadSettingsFromStorage(); + + expect(result).toEqual({ + theme: "light", + sendBehavior: "interrupt", + manageBuiltInDaemon: true, + releaseChannel: "stable", + }); }); }); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index e88ab2327..c964004e1 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -1,46 +1,66 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + DEFAULT_DESKTOP_SETTINGS, + loadDesktopSettings, + migrateLegacyDesktopSettings, + useDesktopSettings, +} from "@/desktop/settings/desktop-settings"; +import { isElectronRuntime } from "@/desktop/host"; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; -import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; - export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); -const VALID_RELEASE_CHANNELS = new Set(["stable", "beta"]); export interface AppSettings { theme: ThemeName | "auto"; - manageBuiltInDaemon: boolean; sendBehavior: SendBehavior; +} + +export interface Settings extends AppSettings { + manageBuiltInDaemon: boolean; releaseChannel: ReleaseChannel; } -export const DEFAULT_APP_SETTINGS: AppSettings = { +export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: "auto", - manageBuiltInDaemon: true, sendBehavior: "interrupt", +}; + +export const DEFAULT_APP_SETTINGS: Settings = { + ...DEFAULT_CLIENT_SETTINGS, + manageBuiltInDaemon: true, releaseChannel: "stable", }; export interface UseAppSettingsReturn { settings: AppSettings; isLoading: boolean; - error: unknown | null; + error: unknown; updateSettings: (updates: Partial) => Promise; resetSettings: () => Promise; } +export interface UseSettingsReturn { + settings: Settings; + isLoading: boolean; + error: unknown; + updateSettings: (updates: Partial) => Promise; + resetSettings: () => Promise; +} + export function useAppSettings(): UseAppSettingsReturn { const queryClient = useQueryClient(); const { data, isPending, error } = useQuery({ queryKey: APP_SETTINGS_QUERY_KEY, - queryFn: loadSettingsFromStorage, + queryFn: loadAppSettingsFromStorage, staleTime: Infinity, gcTime: Infinity, }); @@ -49,7 +69,7 @@ export function useAppSettings(): UseAppSettingsReturn { async (updates: Partial) => { try { const prev = - queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS; + queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_CLIENT_SETTINGS; const next = { ...prev, ...updates }; queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); @@ -63,7 +83,7 @@ export function useAppSettings(): UseAppSettingsReturn { const resetSettings = useCallback(async () => { try { - const next = { ...DEFAULT_APP_SETTINGS }; + const next = { ...DEFAULT_CLIENT_SETTINGS }; queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); } catch (err) { @@ -73,7 +93,7 @@ export function useAppSettings(): UseAppSettingsReturn { }, [queryClient]); return { - settings: data ?? DEFAULT_APP_SETTINGS, + settings: data ?? DEFAULT_CLIENT_SETTINGS, isLoading: isPending, error: error ?? null, updateSettings, @@ -81,51 +101,172 @@ export function useAppSettings(): UseAppSettingsReturn { }; } -export async function loadSettingsFromStorage(): Promise { +export function useSettings(): UseSettingsReturn { + const appSettings = useAppSettings(); + const desktopSettings = useDesktopSettings(); + + const updateSettings = useCallback( + async (updates: Partial) => { + const appUpdates: Partial = {}; + if (updates.theme !== undefined) { + appUpdates.theme = updates.theme; + } + if (updates.sendBehavior !== undefined) { + appUpdates.sendBehavior = updates.sendBehavior; + } + + const promises: Promise[] = []; + if (Object.keys(appUpdates).length > 0) { + promises.push(appSettings.updateSettings(appUpdates)); + } + + if (isElectronRuntime()) { + const desktopUpdates: Parameters[0] = {}; + if (updates.manageBuiltInDaemon !== undefined) { + desktopUpdates.daemon = { + manageBuiltInDaemon: updates.manageBuiltInDaemon, + }; + } + if (updates.releaseChannel !== undefined) { + desktopUpdates.releaseChannel = updates.releaseChannel; + } + if (Object.keys(desktopUpdates).length > 0) { + promises.push(desktopSettings.updateSettings(desktopUpdates)); + } + } + + await Promise.all(promises); + }, + [appSettings, desktopSettings], + ); + + const resetSettings = useCallback(async () => { + const resets: Promise[] = [appSettings.resetSettings()]; + if (isElectronRuntime()) { + resets.push(desktopSettings.updateSettings(DEFAULT_DESKTOP_SETTINGS)); + } + await Promise.all(resets); + }, [appSettings, desktopSettings]); + + return { + settings: { + ...DEFAULT_APP_SETTINGS, + ...appSettings.settings, + manageBuiltInDaemon: desktopSettings.settings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.settings.releaseChannel, + }, + isLoading: appSettings.isLoading || desktopSettings.isLoading, + error: appSettings.error ?? desktopSettings.error, + updateSettings, + resetSettings, + }; +} + +export async function loadAppSettingsFromStorage(): Promise { try { const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; - if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { - parsed.theme = DEFAULT_APP_SETTINGS.theme; - } - if (parsed.releaseChannel && !VALID_RELEASE_CHANNELS.has(parsed.releaseChannel)) { - parsed.releaseChannel = DEFAULT_APP_SETTINGS.releaseChannel; - } - return { ...DEFAULT_APP_SETTINGS, ...parsed }; + return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(parsed) }; } const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); if (legacyStored) { const legacyParsed = JSON.parse(legacyStored) as Record; const next = { - ...DEFAULT_APP_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettingsFromLegacy(legacyParsed), } satisfies AppSettings; await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); return next; } - await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS)); - return DEFAULT_APP_SETTINGS; + await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_CLIENT_SETTINGS)); + return DEFAULT_CLIENT_SETTINGS; } catch (error) { console.error("[AppSettings] Failed to load settings:", error); throw error; } } +export async function loadSettingsFromStorage(): Promise { + const legacyDesktopSettings = isElectronRuntime() + ? await loadLegacyDesktopSettingsFromStorage() + : null; + const appSettings = await loadAppSettingsFromStorage(); + + if (!isElectronRuntime()) { + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + }; + } + + if (legacyDesktopSettings) { + await migrateLegacyDesktopSettings(legacyDesktopSettings); + } + + const desktopSettings = await loadDesktopSettings(); + return { + ...DEFAULT_APP_SETTINGS, + ...appSettings, + manageBuiltInDaemon: desktopSettings.daemon.manageBuiltInDaemon, + releaseChannel: desktopSettings.releaseChannel, + }; +} + +function pickAppSettings(stored: Partial): Partial { + const result: Partial = {}; + if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) { + result.theme = stored.theme as AppSettings["theme"]; + } + if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") { + result.sendBehavior = stored.sendBehavior; + } + return result; +} + function pickAppSettingsFromLegacy(legacy: Record): Partial { const result: Partial = {}; if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { result.theme = legacy.theme; } - if (typeof legacy.manageBuiltInDaemon === "boolean") { - result.manageBuiltInDaemon = legacy.manageBuiltInDaemon; - } - if (legacy.releaseChannel === "stable" || legacy.releaseChannel === "beta") { - result.releaseChannel = legacy.releaseChannel; - } return result; } -export const useSettings = useAppSettings; +async function loadLegacyDesktopSettingsFromStorage(): Promise<{ + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; +} | null> { + const stored = await loadRendererSettingsPayload(); + if (!stored) { + return null; + } + + const result: { + manageBuiltInDaemon?: boolean; + releaseChannel?: ReleaseChannel; + } = {}; + + if (typeof stored.manageBuiltInDaemon === "boolean") { + result.manageBuiltInDaemon = stored.manageBuiltInDaemon; + } + if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") { + result.releaseChannel = stored.releaseChannel; + } + + return Object.keys(result).length > 0 ? result : null; +} + +async function loadRendererSettingsPayload(): Promise | null> { + const current = await AsyncStorage.getItem(APP_SETTINGS_KEY); + if (current) { + return JSON.parse(current) as Record; + } + + const legacy = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY); + if (!legacy) { + return null; + } + return JSON.parse(legacy) as Record; +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0eae159d6..2f6bddcb7 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -33,7 +33,13 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { SettingsSection } from "@/screens/settings/settings-section"; -import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { + useAppSettings, + useSettings, + type AppSettings, + type SendBehavior, + type Settings as EffectiveSettings, +} from "@/hooks/use-settings"; import { THEME_SWATCHES } from "@/styles/theme"; import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; @@ -354,7 +360,7 @@ function getUpdateButtonLabel( } function DesktopAppUpdateRow() { - const { settings, updateSettings } = useAppSettings(); + const { settings, updateSettings } = useSettings(); const { isDesktopApp, statusText, @@ -384,7 +390,7 @@ function DesktopAppUpdateRow() { }, [checkForUpdates, isDesktopApp]); const handleReleaseChannelChange = useCallback( - (releaseChannel: AppSettings["releaseChannel"]) => { + (releaseChannel: EffectiveSettings["releaseChannel"]) => { void updateSettings({ releaseChannel }); }, [updateSettings], diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 1e3583775..1e9c10748 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -33,6 +33,11 @@ import { runCliJsonCommand, runCliTextCommand, } from "./runtime-paths.js"; +import { + createDesktopSettingsCommandHandlers, + type DesktopCommandHandler, +} from "../settings/desktop-settings-commands.js"; +import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; const DAEMON_LOG_FILENAME = "daemon.log"; const PID_POLL_INTERVAL_MS = 100; @@ -44,7 +49,7 @@ const DETACHED_STARTUP_GRACE_MS = 1200; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; -interface DesktopDaemonStatus { +export interface DesktopDaemonStatus { serverId: string; status: DesktopDaemonState; listen: string | null; @@ -67,10 +72,16 @@ interface DesktopPairingOffer { qr: string | null; } -type DesktopCommandHandler = (args?: Record) => Promise | unknown; - -function parseReleaseChannel(args: Record | undefined): AppReleaseChannel { - return args?.releaseChannel === "beta" ? "beta" : "stable"; +function parseReleaseChannel( + args: Record | undefined, +): AppReleaseChannel | undefined { + if (args?.releaseChannel === "beta") { + return "beta"; + } + if (args?.releaseChannel === "stable") { + return "stable"; + } + return undefined; } // --------------------------------------------------------------------------- @@ -195,7 +206,7 @@ function resolveDesktopAppVersion(): string { // Daemon lifecycle // --------------------------------------------------------------------------- -async function resolveStatus(): Promise { +export async function resolveDesktopDaemonStatus(): Promise { const home = getPaseoHome(); try { @@ -265,8 +276,8 @@ function buildStartupFailureError( async function pollForRunningDaemon(): Promise { async function poll(attempt: number): Promise { - if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveStatus(); - const status = await resolveStatus(); + if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveDesktopDaemonStatus(); + const status = await resolveDesktopDaemonStatus(); if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { logDesktopDaemonLifecycle("polling daemon status after detached start", { attempt: attempt + 1, @@ -284,7 +295,7 @@ async function pollForRunningDaemon(): Promise { } async function startDaemon(): Promise { - const current = await resolveStatus(); + const current = await resolveDesktopDaemonStatus(); logDesktopDaemonLifecycle("initial status check before start", { status: current.status, pid: current.pid, @@ -299,7 +310,7 @@ async function startDaemon(): Promise { appVersion: normalizeVersion(resolveDesktopAppVersion()), daemonVersion: normalizeVersion(current.version), }); - await stopDaemon(); + await stopDesktopDaemon(); } else { return current; } @@ -389,8 +400,8 @@ async function startDaemon(): Promise { return pollForRunningDaemon(); } -async function stopDaemon(): Promise { - const status = await resolveStatus(); +export async function stopDesktopDaemon(): Promise { + const status = await resolveDesktopDaemonStatus(); if (status.status !== "running" || !status.pid) return status; const pid = status.pid; @@ -406,11 +417,11 @@ async function stopDaemon(): Promise { throw new Error(`Timed out waiting for daemon PID ${pid} to stop`); } - return await resolveStatus(); + return await resolveDesktopDaemonStatus(); } async function restartDaemon(): Promise { - await stopDaemon(); + await stopDesktopDaemon(); return startDaemon(); } @@ -427,7 +438,7 @@ async function getCliDaemonStatus(): Promise { } async function getDaemonPairing(): Promise { - const status = await resolveStatus(); + const status = await resolveDesktopDaemonStatus(); if (status.status !== "running") { return { relayEnabled: false, @@ -457,7 +468,7 @@ async function getDaemonPairing(): Promise { } async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { - const status = await resolveStatus(); + const status = await resolveDesktopDaemonStatus(); if (status.status !== "running") { return { version: null, error: "Daemon is not running." }; } @@ -467,12 +478,10 @@ async function getLocalDaemonVersion(): Promise<{ version: string | null; error: }; } -function resolveCurrentUpdateVersion(): string { - return resolveDesktopAppVersion(); -} - -function getSystemIdleTimeMs(): number { - return powerMonitor.getSystemIdleTime() * 1000; +async function resolveRequestedReleaseChannel( + args: Record | undefined, +): Promise { + return parseReleaseChannel(args) ?? (await getDesktopSettingsStore().get()).releaseChannel; } // --------------------------------------------------------------------------- @@ -481,13 +490,14 @@ function getSystemIdleTimeMs(): number { export function createDaemonCommandHandlers(): Record { return { - desktop_daemon_status: () => resolveStatus(), + ...createDesktopSettingsCommandHandlers({ settingsStore: getDesktopSettingsStore() }), + desktop_daemon_status: () => resolveDesktopDaemonStatus(), start_desktop_daemon: () => startDaemon(), - stop_desktop_daemon: () => stopDaemon(), + stop_desktop_daemon: () => stopDesktopDaemon(), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_daemon_pairing: () => getDaemonPairing(), - desktop_get_system_idle_time: () => getSystemIdleTimeMs(), + desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), copy_attachment_file: (args) => copyAttachmentFileToManagedStorage(args ?? {}), @@ -511,15 +521,18 @@ export function createDaemonCommandHandlers(): Record { - const currentVersion = await resolveCurrentUpdateVersion(); - return checkForAppUpdate({ currentVersion, releaseChannel: parseReleaseChannel(args) }); + const currentVersion = resolveDesktopAppVersion(); + return checkForAppUpdate({ + currentVersion, + releaseChannel: await resolveRequestedReleaseChannel(args), + }); }, install_app_update: async (args) => { - const currentVersion = await resolveCurrentUpdateVersion(); + const currentVersion = resolveDesktopAppVersion(); return downloadAndInstallUpdate( - { currentVersion, releaseChannel: parseReleaseChannel(args) }, + { currentVersion, releaseChannel: await resolveRequestedReleaseChannel(args) }, async () => { - await stopDaemon(); + await stopDesktopDaemon(); }, ); }, diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts new file mode 100644 index 000000000..6254b2ae9 --- /dev/null +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; +import { + createBeforeQuitHandler, + shouldStopDesktopManagedDaemonOnQuit, + stopDesktopManagedDaemonOnQuitIfNeeded, +} from "./quit-lifecycle"; + +describe("quit-lifecycle", () => { + it("stops only when quit persistence disables keep-running and the daemon is desktop-managed", () => { + expect( + shouldStopDesktopManagedDaemonOnQuit({ + settings: { + ...DEFAULT_DESKTOP_SETTINGS, + daemon: { + ...DEFAULT_DESKTOP_SETTINGS.daemon, + keepRunningAfterQuit: false, + }, + }, + daemonStatus: { + status: "running", + desktopManaged: true, + }, + }), + ).toBe(true); + }); + + it("does not stop a manually started daemon on quit", async () => { + const stopDaemon = vi.fn(async () => undefined); + + const stopped = await stopDesktopManagedDaemonOnQuitIfNeeded({ + settingsStore: { + get: async () => ({ + ...DEFAULT_DESKTOP_SETTINGS, + daemon: { + ...DEFAULT_DESKTOP_SETTINGS.daemon, + keepRunningAfterQuit: false, + }, + }), + patch: async () => DEFAULT_DESKTOP_SETTINGS, + migrateLegacyRendererSettings: async () => DEFAULT_DESKTOP_SETTINGS, + }, + resolveStatus: async () => ({ + status: "running", + desktopManaged: false, + }), + stopDaemon, + }); + + expect(stopped).toBe(false); + expect(stopDaemon).not.toHaveBeenCalled(); + }); + + it("gates quit until the stop decision completes, then lets the next quit pass through", async () => { + let resolveStopDecision: (() => void) | null = null; + const app = { quit: vi.fn() }; + const closeTransportSessions = vi.fn(); + const onStopError = vi.fn(); + const preventDefault = vi.fn(); + const secondPreventDefault = vi.fn(); + + const handleBeforeQuit = createBeforeQuitHandler({ + app, + closeTransportSessions, + stopDesktopManagedDaemonIfNeeded: vi.fn( + () => + new Promise((resolve) => { + resolveStopDecision = () => resolve(false); + }), + ), + onStopError, + }); + + handleBeforeQuit({ preventDefault }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(closeTransportSessions).toHaveBeenCalledTimes(1); + expect(app.quit).not.toHaveBeenCalled(); + expect(resolveStopDecision).not.toBeNull(); + + resolveStopDecision?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(app.quit).toHaveBeenCalledTimes(1); + expect(onStopError).not.toHaveBeenCalled(); + + handleBeforeQuit({ preventDefault: secondPreventDefault }); + + expect(secondPreventDefault).not.toHaveBeenCalled(); + expect(closeTransportSessions).toHaveBeenCalledTimes(2); + expect(app.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts new file mode 100644 index 000000000..21d4cc637 --- /dev/null +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -0,0 +1,93 @@ +import type { DesktopSettingsStore } from "../settings/desktop-settings.js"; + +interface QuitLifecycleSettings { + daemon: { + keepRunningAfterQuit: boolean; + }; +} + +interface QuitLifecycleStatus { + status: "starting" | "running" | "stopped" | "errored"; + desktopManaged: boolean; +} + +interface BeforeQuitEvent { + preventDefault(): void; +} + +interface BeforeQuitApp { + quit(): void; +} + +export function shouldStopDesktopManagedDaemonOnQuit({ + settings, + daemonStatus, +}: { + settings: QuitLifecycleSettings; + daemonStatus: QuitLifecycleStatus; +}): boolean { + return ( + settings.daemon.keepRunningAfterQuit === false && + daemonStatus.status === "running" && + daemonStatus.desktopManaged + ); +} + +export async function stopDesktopManagedDaemonOnQuitIfNeeded({ + settingsStore, + resolveStatus, + stopDaemon, +}: { + settingsStore: DesktopSettingsStore; + resolveStatus: () => Promise; + stopDaemon: () => Promise; +}): Promise { + const settings = await settingsStore.get(); + const daemonStatus = await resolveStatus(); + + if (!shouldStopDesktopManagedDaemonOnQuit({ settings, daemonStatus })) { + return false; + } + + await stopDaemon(); + return true; +} + +export function createBeforeQuitHandler({ + app, + closeTransportSessions, + stopDesktopManagedDaemonIfNeeded, + onStopError, +}: { + app: BeforeQuitApp; + closeTransportSessions: () => void; + stopDesktopManagedDaemonIfNeeded: () => Promise; + onStopError: (error: unknown) => void; +}): (event: BeforeQuitEvent) => void { + let allowingQuitToContinue = false; + let quittingInProgress = false; + + return (event) => { + closeTransportSessions(); + + if (allowingQuitToContinue) { + return; + } + + event.preventDefault(); + if (quittingInProgress) { + return; + } + + quittingInProgress = true; + void stopDesktopManagedDaemonIfNeeded() + .catch((error) => { + onStopError(error); + }) + .finally(() => { + allowingQuitToContinue = true; + quittingInProgress = false; + app.quit(); + }); + }; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 5393252c5..d537c6fc2 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -34,6 +34,12 @@ import { import { registerOpenerHandlers } from "./features/opener.js"; import { setupApplicationMenu } from "./features/menu.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; +import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; +import { resolveDesktopDaemonStatus, stopDesktopDaemon } from "./daemon/daemon-manager.js"; +import { + createBeforeQuitHandler, + stopDesktopManagedDaemonOnQuitIfNeeded, +} from "./daemon/quit-lifecycle.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -340,9 +346,22 @@ void bootstrap().catch((error) => { process.exit(1); }); -app.on("before-quit", () => { - closeAllTransportSessions(); -}); +app.on( + "before-quit", + createBeforeQuitHandler({ + app, + closeTransportSessions: closeAllTransportSessions, + stopDesktopManagedDaemonIfNeeded: () => + stopDesktopManagedDaemonOnQuitIfNeeded({ + settingsStore: getDesktopSettingsStore(), + resolveStatus: resolveDesktopDaemonStatus, + stopDaemon: stopDesktopDaemon, + }), + onStopError: (error) => { + log.error("[desktop daemon] failed to stop managed daemon on quit", error); + }, + }), +); app.on("window-all-closed", () => { if (process.platform !== "darwin") { diff --git a/packages/desktop/src/settings/desktop-settings-commands.test.ts b/packages/desktop/src/settings/desktop-settings-commands.test.ts new file mode 100644 index 000000000..498e79d12 --- /dev/null +++ b/packages/desktop/src/settings/desktop-settings-commands.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_DESKTOP_SETTINGS, type DesktopSettingsStore } from "./desktop-settings"; +import { createDesktopSettingsCommandHandlers } from "./desktop-settings-commands"; + +function createStoreMock(): DesktopSettingsStore { + return { + get: vi.fn(async () => DEFAULT_DESKTOP_SETTINGS), + patch: vi.fn(async () => ({ + ...DEFAULT_DESKTOP_SETTINGS, + releaseChannel: "beta", + })), + migrateLegacyRendererSettings: vi.fn(async () => ({ + ...DEFAULT_DESKTOP_SETTINGS, + releaseChannel: "beta", + daemon: { + manageBuiltInDaemon: false, + keepRunningAfterQuit: true, + }, + })), + }; +} + +describe("desktop-settings-commands", () => { + it("exposes get and patch handlers through the desktop command bus shape", async () => { + const store = createStoreMock(); + const handlers = createDesktopSettingsCommandHandlers({ settingsStore: store }); + + await expect(handlers.get_desktop_settings()).resolves.toEqual(DEFAULT_DESKTOP_SETTINGS); + await expect( + handlers.patch_desktop_settings({ + daemon: { keepRunningAfterQuit: false }, + }), + ).resolves.toEqual({ + ...DEFAULT_DESKTOP_SETTINGS, + releaseChannel: "beta", + }); + + expect(store.get).toHaveBeenCalledTimes(1); + expect(store.patch).toHaveBeenCalledWith({ + daemon: { keepRunningAfterQuit: false }, + }); + }); + + it("accepts legacy renderer settings migration payloads", async () => { + const store = createStoreMock(); + const handlers = createDesktopSettingsCommandHandlers({ settingsStore: store }); + + const result = await handlers.migrate_legacy_desktop_settings({ + releaseChannel: "beta", + manageBuiltInDaemon: false, + }); + + expect(result).toEqual({ + ...DEFAULT_DESKTOP_SETTINGS, + releaseChannel: "beta", + daemon: { + manageBuiltInDaemon: false, + keepRunningAfterQuit: true, + }, + }); + expect(store.migrateLegacyRendererSettings).toHaveBeenCalledWith({ + releaseChannel: "beta", + manageBuiltInDaemon: false, + }); + }); +}); diff --git a/packages/desktop/src/settings/desktop-settings-commands.ts b/packages/desktop/src/settings/desktop-settings-commands.ts new file mode 100644 index 000000000..fd1215c4f --- /dev/null +++ b/packages/desktop/src/settings/desktop-settings-commands.ts @@ -0,0 +1,15 @@ +import type { DesktopSettingsStore } from "./desktop-settings.js"; + +export type DesktopCommandHandler = (args?: Record) => Promise | unknown; + +export function createDesktopSettingsCommandHandlers({ + settingsStore, +}: { + settingsStore: DesktopSettingsStore; +}): Record { + return { + get_desktop_settings: () => settingsStore.get(), + patch_desktop_settings: (args) => settingsStore.patch(args), + migrate_legacy_desktop_settings: (args) => settingsStore.migrateLegacyRendererSettings(args), + }; +} diff --git a/packages/desktop/src/settings/desktop-settings-electron.ts b/packages/desktop/src/settings/desktop-settings-electron.ts new file mode 100644 index 000000000..cb24b23f5 --- /dev/null +++ b/packages/desktop/src/settings/desktop-settings-electron.ts @@ -0,0 +1,12 @@ +import { app } from "electron"; + +import { createDesktopSettingsStore, type DesktopSettingsStore } from "./desktop-settings.js"; + +let desktopSettingsStore: DesktopSettingsStore | null = null; + +export function getDesktopSettingsStore(): DesktopSettingsStore { + desktopSettingsStore ??= createDesktopSettingsStore({ + userDataPath: app.getPath("userData"), + }); + return desktopSettingsStore; +} diff --git a/packages/desktop/src/settings/desktop-settings.test.ts b/packages/desktop/src/settings/desktop-settings.test.ts new file mode 100644 index 000000000..f49b7259b --- /dev/null +++ b/packages/desktop/src/settings/desktop-settings.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_DESKTOP_SETTINGS, + type DesktopSettings, + createDesktopSettingsStore, +} from "./desktop-settings"; + +async function createTempUserDataDir(): Promise { + return await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-settings-")); +} + +function settingsFilePath(userDataPath: string): string { + return path.join(userDataPath, "desktop-settings.json"); +} + +describe("desktop-settings", () => { + const directories = new Set(); + + afterEach(async () => { + await Promise.all( + [...directories].map(async (directory) => { + await rm(directory, { recursive: true, force: true }); + }), + ); + directories.clear(); + }); + + it("persists default settings for new users", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + const store = createDesktopSettingsStore({ userDataPath }); + + const settings = await store.get(); + const persisted = JSON.parse(await readFile(settingsFilePath(userDataPath), "utf8")) as { + settings: DesktopSettings; + }; + + expect(settings).toEqual(DEFAULT_DESKTOP_SETTINGS); + expect(persisted.settings).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("coerces invalid persisted values back to safe defaults", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + await writeFile( + settingsFilePath(userDataPath), + JSON.stringify({ + version: 1, + settings: { + releaseChannel: "nightly", + daemon: { + manageBuiltInDaemon: "sometimes", + keepRunningAfterQuit: false, + }, + }, + }), + ); + const store = createDesktopSettingsStore({ userDataPath }); + + const settings = await store.get(); + + expect(settings).toEqual({ + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: false, + }, + }); + }); + + it("patches nested settings and leaves no temp files behind", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + const store = createDesktopSettingsStore({ userDataPath }); + + await store.get(); + const next = await store.patch({ + releaseChannel: "beta", + daemon: { keepRunningAfterQuit: false }, + }); + const files = await readdir(userDataPath); + + expect(next).toEqual({ + releaseChannel: "beta", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: false, + }, + }); + expect(files).toEqual(["desktop-settings.json"]); + }); + + it("migrates desktop-owned values from legacy renderer settings once", async () => { + const userDataPath = await createTempUserDataDir(); + directories.add(userDataPath); + const store = createDesktopSettingsStore({ userDataPath }); + + await store.patch({ + daemon: { + keepRunningAfterQuit: false, + }, + }); + + const migrated = await store.migrateLegacyRendererSettings({ + releaseChannel: "beta", + manageBuiltInDaemon: false, + theme: "dark", + }); + const ignoredSecondMigration = await store.migrateLegacyRendererSettings({ + releaseChannel: "stable", + manageBuiltInDaemon: true, + }); + + expect(migrated).toEqual({ + releaseChannel: "beta", + daemon: { + manageBuiltInDaemon: false, + keepRunningAfterQuit: false, + }, + }); + expect(ignoredSecondMigration).toEqual(migrated); + }); +}); diff --git a/packages/desktop/src/settings/desktop-settings.ts b/packages/desktop/src/settings/desktop-settings.ts new file mode 100644 index 000000000..8903d8245 --- /dev/null +++ b/packages/desktop/src/settings/desktop-settings.ts @@ -0,0 +1,261 @@ +import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { AppReleaseChannel } from "../features/auto-updater.js"; + +export interface DesktopSettings { + releaseChannel: AppReleaseChannel; + daemon: { + manageBuiltInDaemon: boolean; + keepRunningAfterQuit: boolean; + }; +} + +interface DesktopSettingsPatch { + releaseChannel?: AppReleaseChannel; + daemon?: Partial; +} + +interface PersistedDesktopSettingsDocument { + version: 1; + settings: DesktopSettings; + migrations: { + legacyRendererSettingsImported: boolean; + }; +} + +export interface DesktopSettingsStore { + get(): Promise; + patch(patch: unknown): Promise; + migrateLegacyRendererSettings(legacySettings: unknown): Promise; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, +}; + +const DESKTOP_SETTINGS_FILENAME = "desktop-settings.json"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function coerceReleaseChannel(value: unknown): AppReleaseChannel | null { + if (value === "beta") { + return "beta"; + } + if (value === "stable") { + return "stable"; + } + return null; +} + +function coerceBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function buildDefaultDocument(): PersistedDesktopSettingsDocument { + return { + version: 1, + settings: DEFAULT_DESKTOP_SETTINGS, + migrations: { + legacyRendererSettingsImported: false, + }, + }; +} + +function coerceDesktopSettings(input: unknown): DesktopSettings { + const result: DesktopSettings = { + releaseChannel: DEFAULT_DESKTOP_SETTINGS.releaseChannel, + daemon: { ...DEFAULT_DESKTOP_SETTINGS.daemon }, + }; + + if (!isRecord(input)) { + return result; + } + + const releaseChannel = coerceReleaseChannel(input.releaseChannel); + if (releaseChannel) { + result.releaseChannel = releaseChannel; + } + + if (isRecord(input.daemon)) { + const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon); + if (manageBuiltInDaemon !== null) { + result.daemon.manageBuiltInDaemon = manageBuiltInDaemon; + } + + const keepRunningAfterQuit = coerceBoolean(input.daemon.keepRunningAfterQuit); + if (keepRunningAfterQuit !== null) { + result.daemon.keepRunningAfterQuit = keepRunningAfterQuit; + } + } + + return result; +} + +function coerceDesktopSettingsPatch(input: unknown): DesktopSettingsPatch { + if (!isRecord(input)) { + return {}; + } + + const patch: DesktopSettingsPatch = {}; + + const releaseChannel = coerceReleaseChannel(input.releaseChannel); + if (releaseChannel) { + patch.releaseChannel = releaseChannel; + } + + if (isRecord(input.daemon)) { + const daemonPatch: Partial = {}; + const manageBuiltInDaemon = coerceBoolean(input.daemon.manageBuiltInDaemon); + if (manageBuiltInDaemon !== null) { + daemonPatch.manageBuiltInDaemon = manageBuiltInDaemon; + } + const keepRunningAfterQuit = coerceBoolean(input.daemon.keepRunningAfterQuit); + if (keepRunningAfterQuit !== null) { + daemonPatch.keepRunningAfterQuit = keepRunningAfterQuit; + } + if (Object.keys(daemonPatch).length > 0) { + patch.daemon = daemonPatch; + } + } + + return patch; +} + +function pickDesktopSettingsFromLegacyRendererSettings( + legacySettings: unknown, +): DesktopSettingsPatch { + if (!isRecord(legacySettings)) { + return {}; + } + + const patch: DesktopSettingsPatch = {}; + const releaseChannel = coerceReleaseChannel(legacySettings.releaseChannel); + if (releaseChannel) { + patch.releaseChannel = releaseChannel; + } + + const manageBuiltInDaemon = coerceBoolean(legacySettings.manageBuiltInDaemon); + if (manageBuiltInDaemon !== null) { + patch.daemon = { manageBuiltInDaemon }; + } + + return patch; +} + +function mergeDesktopSettings( + current: DesktopSettings, + patch: DesktopSettingsPatch, +): DesktopSettings { + return { + releaseChannel: patch.releaseChannel ?? current.releaseChannel, + daemon: { ...current.daemon, ...patch.daemon }, + }; +} + +function coerceDocument(input: unknown): PersistedDesktopSettingsDocument { + if (!isRecord(input)) { + return buildDefaultDocument(); + } + + const settings = coerceDesktopSettings(input.settings); + const migrations = isRecord(input.migrations) + ? { + legacyRendererSettingsImported: input.migrations.legacyRendererSettingsImported === true, + } + : { + legacyRendererSettingsImported: false, + }; + + return { + version: 1, + settings, + migrations, + }; +} + +export function createDesktopSettingsStore({ + userDataPath, +}: { + userDataPath: string; +}): DesktopSettingsStore { + const filePath = path.join(userDataPath, DESKTOP_SETTINGS_FILENAME); + let cachedDocument: PersistedDesktopSettingsDocument | null = null; + + async function persistDocument(document: PersistedDesktopSettingsDocument): Promise { + await mkdir(userDataPath, { recursive: true }); + const tempFilePath = `${filePath}.tmp`; + await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + await rename(tempFilePath, filePath); + cachedDocument = document; + } + + async function loadDocument(): Promise { + if (cachedDocument) { + return cachedDocument; + } + + try { + await access(filePath); + } catch { + const document = buildDefaultDocument(); + await persistDocument(document); + return document; + } + + try { + const raw = await readFile(filePath, "utf8"); + const document = coerceDocument(JSON.parse(raw)); + await persistDocument(document); + return document; + } catch { + const document = buildDefaultDocument(); + await persistDocument(document); + return document; + } + } + + return { + async get(): Promise { + const document = await loadDocument(); + return document.settings; + }, + + async patch(patch: unknown): Promise { + const current = await loadDocument(); + const next = mergeDesktopSettings(current.settings, coerceDesktopSettingsPatch(patch)); + await persistDocument({ + ...current, + settings: next, + }); + return next; + }, + + async migrateLegacyRendererSettings(legacySettings: unknown): Promise { + const current = await loadDocument(); + if (current.migrations.legacyRendererSettingsImported) { + return current.settings; + } + + const next = mergeDesktopSettings( + current.settings, + pickDesktopSettingsFromLegacyRendererSettings(legacySettings), + ); + await persistDocument({ + ...current, + settings: next, + migrations: { + ...current.migrations, + legacyRendererSettingsImported: true, + }, + }); + return next; + }, + }; +}