feat(desktop): split daemon and release settings to main process

Move daemon management and release channel settings out of the renderer's
AsyncStorage and into a desktop-owned settings store persisted by the main
process. Adds a "Keep daemon running after quit" option, a daemon lifecycle
section in settings, and a one-time migration of legacy renderer-owned
desktop settings.
This commit is contained in:
Mohamed Boudra
2026-04-25 13:59:07 +07:00
parent 2b37276577
commit ae3e88e7e1
20 changed files with 1792 additions and 187 deletions
@@ -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<void>
>(),
},
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<string, unknown>) => 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(<LocalDaemonSection />);
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(<LocalDaemonSection />);
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(<LocalDaemonSection />);
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
await waitFor(() => {
expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1);
});
expect(settingsState.updateSettings).toHaveBeenCalledWith({
daemon: {
manageBuiltInDaemon: false,
},
});
});
});
@@ -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<unknown>;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
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<DesktopDaemonSettings>) => Promise<unknown>;
}) {
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<string | null>(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 (
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
@@ -425,7 +466,25 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{getDaemonManagementButtonLabel(isUpdatingDaemonManagement, isDaemonManagementPaused)}
{managementButtonLabel}
</Button>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Keep daemon running after quit</Text>
<Text style={settingsStyles.rowHint}>
{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."}
</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleToggleKeepRunningAfterQuit}
disabled={isUpdatingKeepRunningAfterQuit}
>
{keepRunningButtonLabel}
</Button>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
@@ -441,7 +500,7 @@ function DaemonInfoCard(props: DaemonInfoCardProps) {
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{getDaemonRestartButtonLabel(isRestartingDaemon, daemonStatus?.status, daemonActionLabel)}
{restartButtonLabel}
</Button>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
@@ -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<DesktopDaemonSettings>) => 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 ? (
<View style={LOADING_CARD_STYLE}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
@@ -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}