From f0d96f8e5a0868c9ca500ad7f1705c42745d25f5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 3 May 2026 22:02:40 +0700 Subject: [PATCH] Fix built-in daemon management toggle --- packages/app/src/app/_layout.tsx | 18 ++- .../src/app/host-runtime-bootstrap.test.ts | 15 +++ .../app/src/app/host-runtime-bootstrap.ts | 28 ++++- .../desktop-updates-section.test.tsx | 62 ++++++++++ .../components/desktop-updates-section.tsx | 29 +++-- .../desktop/src/daemon/daemon-manager.test.ts | 115 ++++++++++++++++++ packages/desktop/src/daemon/daemon-manager.ts | 10 ++ 7 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 packages/desktop/src/daemon/daemon-manager.test.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 83505e681..00342cbdb 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -50,11 +50,12 @@ import { import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { ToastProvider } from "@/contexts/toast-context"; import { VoiceProvider } from "@/contexts/voice-context"; -import { startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap"; +import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; +import { loadDesktopSettings } from "@/desktop/settings/desktop-settings"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; @@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean { const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; +async function shouldStartBuiltInDaemon(): Promise { + if (!shouldUseDesktopDaemon()) { + return false; + } + const settings = await loadDesktopSettings(); + return settings.daemon.manageBuiltInDaemon; +} + function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { const store = getHostRuntimeStore(); @@ -319,7 +328,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { startHostRuntimeBootstrap({ store, daemonStartService, - shouldStartDaemon: shouldUseDesktopDaemon(), + shouldStartDaemon: shouldStartBuiltInDaemon, }); }, []); @@ -346,7 +355,10 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]); const retry = useCallback(() => { - void getDaemonStartService({ store: getHostRuntimeStore() }).start(); + startDaemonIfGateAllows({ + daemonStartService: getDaemonStartService({ store: getHostRuntimeStore() }), + shouldStartDaemon: shouldStartBuiltInDaemon, + }); }, []); const splashError = !anyOnlineHostServerId ? daemonStartError : null; diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index cf3da544b..d12dd1939 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -55,6 +55,21 @@ describe("startHostRuntimeBootstrap", () => { expect(daemonStartService.start).not.toHaveBeenCalled(); }); + it("skips daemon-start when the startup gate resolves false", async () => { + const store = createFakeStore(); + const daemonStartService = createFakeDaemonStartService(); + + startHostRuntimeBootstrap({ + store, + daemonStartService, + shouldStartDaemon: async () => false, + }); + await Promise.resolve(); + + expect(store.boot).toHaveBeenCalledTimes(1); + expect(daemonStartService.start).not.toHaveBeenCalled(); + }); + it("does not await the daemon-start promise", () => { const store = createFakeStore(); let resolveStart: ((value: { ok: true }) => void) | undefined; diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index c08a7b1ac..f8de3593f 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -11,17 +11,39 @@ export interface HostRuntimeBootstrapDaemonStartService { start: () => Promise; } +type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise); + export interface StartHostRuntimeBootstrapInput { store: HostRuntimeBootstrapStore; daemonStartService: HostRuntimeBootstrapDaemonStartService; - shouldStartDaemon: boolean; + shouldStartDaemon: HostRuntimeBootstrapStartGate; } export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { input.store.boot(); - if (input.shouldStartDaemon) { - void input.daemonStartService.start(); + startDaemonIfGateAllows({ + daemonStartService: input.daemonStartService, + shouldStartDaemon: input.shouldStartDaemon, + }); +} + +export function startDaemonIfGateAllows(input: { + daemonStartService: HostRuntimeBootstrapDaemonStartService; + shouldStartDaemon: HostRuntimeBootstrapStartGate; +}): void { + if (typeof input.shouldStartDaemon === "boolean") { + if (input.shouldStartDaemon) { + void input.daemonStartService.start(); + } + return; } + + void Promise.resolve(input.shouldStartDaemon()).then((shouldStartDaemon) => { + if (shouldStartDaemon) { + void input.daemonStartService.start(); + } + return; + }); } export const WELCOME_ROUTE: Href = "/welcome"; diff --git a/packages/app/src/desktop/components/desktop-updates-section.test.tsx b/packages/app/src/desktop/components/desktop-updates-section.test.tsx index 12830a72e..fc0067950 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.test.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.test.tsx @@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({ const daemonCommandMocks = vi.hoisted(() => ({ getCliDaemonStatusMock: vi.fn(), + startDesktopDaemonMock: vi.fn(), stopDesktopDaemonMock: vi.fn(), })); vi.mock("@/desktop/daemon/desktop-daemon", () => ({ getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock, shouldUseDesktopDaemon: vi.fn(() => true), + startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock, stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock, })); @@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => { settingsState.updateSettings.mockReset(); settingsState.updateSettings.mockResolvedValue(); daemonStatusState.data.status.status = "running"; + daemonStatusState.data.status.desktopManaged = true; daemonStatusState.setStatus.mockReset(); daemonStatusState.refetch.mockReset(); + daemonCommandMocks.startDesktopDaemonMock.mockReset(); + daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({ + ...daemonStatusState.data.status, + status: "running", + desktopManaged: true, + }); daemonCommandMocks.stopDesktopDaemonMock.mockReset(); daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({ ...daemonStatusState.data.status, @@ -297,4 +306,57 @@ describe("LocalDaemonSection", () => { }, }); }); + + it("persists paused management before stopping the desktop-managed daemon", async () => { + confirmDialogMock.mockResolvedValue(true); + const screen = render(); + + fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" })); + + await waitFor(() => { + expect(daemonCommandMocks.stopDesktopDaemonMock).toHaveBeenCalledTimes(1); + }); + + expect(settingsState.updateSettings.mock.invocationCallOrder[0]).toBeLessThan( + daemonCommandMocks.stopDesktopDaemonMock.mock.invocationCallOrder[0], + ); + }); + + it("does not stop a manually managed daemon when pausing built-in daemon management", async () => { + confirmDialogMock.mockResolvedValue(true); + daemonStatusState.data.status.desktopManaged = false; + const screen = render(); + + fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" })); + + await waitFor(() => { + expect(settingsState.updateSettings).toHaveBeenCalledWith({ + daemon: { + manageBuiltInDaemon: false, + }, + }); + }); + expect(daemonCommandMocks.stopDesktopDaemonMock).not.toHaveBeenCalled(); + }); + + it("starts the built-in daemon when management is re-enabled", async () => { + settingsState.settings.daemon.manageBuiltInDaemon = false; + const screen = render(); + + fireEvent.click(screen.getByRole("switch", { name: "Manage built-in daemon" })); + + await waitFor(() => { + expect(daemonCommandMocks.startDesktopDaemonMock).toHaveBeenCalledTimes(1); + }); + expect(settingsState.updateSettings).toHaveBeenCalledWith({ + daemon: { + manageBuiltInDaemon: true, + }, + }); + expect(daemonStatusState.setStatus).toHaveBeenCalledWith({ + ...daemonStatusState.data.status, + status: "running", + desktopManaged: true, + }); + }); }); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index 5991afc97..04e38e560 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliDaemonStatus, shouldUseDesktopDaemon, + startDesktopDaemon, stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; @@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: { if (!settings.manageBuiltInDaemon) { setIsUpdatingDaemonManagement(true); void updateSettings({ manageBuiltInDaemon: true }) + .then(() => startDesktopDaemon()) + .then((newStatus) => { + setStatus(newStatus); + refetch(); + return; + }) .catch((error) => { console.error("[Settings] Failed to update built-in daemon management", error); Alert.alert("Error", "Unable to update built-in daemon management."); @@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: { setIsUpdatingDaemonManagement(true); - const stopPromise = - daemonStatus?.status === "running" - ? stopDesktopDaemon() - : Promise.resolve(daemonStatus ?? null); - - void stopPromise + void updateSettings({ manageBuiltInDaemon: false }) + .then(() => { + if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + return stopDesktopDaemon(); + } + return daemonStatus ?? null; + }) .then((newStatus) => { if (newStatus) { setStatus(newStatus); } - return updateSettings({ manageBuiltInDaemon: false }); + return; }) .then(() => { refetch(); return; }) .catch((error) => { - console.error("[Settings] Failed to pause built-in daemon management", error); - Alert.alert("Error", "Unable to pause built-in daemon management."); + console.error("[Settings] Failed to stop built-in daemon", error); + Alert.alert( + "Error", + "Built-in daemon management was paused, but Paseo could not stop the daemon.", + ); }) .finally(() => { setIsUpdatingDaemonManagement(false); diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts new file mode 100644 index 000000000..ff9fcbf54 --- /dev/null +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; +import { createDaemonCommandHandlers } from "./daemon-manager"; + +const mocks = vi.hoisted(() => ({ + settings: { + releaseChannel: "stable", + daemon: { + manageBuiltInDaemon: true, + keepRunningAfterQuit: true, + }, + }, + runCliJsonCommand: vi.fn(), + runCliTextCommand: vi.fn(), + spawnProcess: vi.fn(), +})); + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp/paseo-user-data"), + getVersion: vi.fn(() => "1.2.3"), + isPackaged: false, + }, + ipcMain: { handle: vi.fn() }, + powerMonitor: { getSystemIdleTime: vi.fn(() => 0) }, +})); + +vi.mock("electron-log/main", () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@getpaseo/server", () => ({ + resolvePaseoHome: vi.fn(() => "/tmp/paseo-home"), + spawnProcess: mocks.spawnProcess, +})); + +vi.mock("../settings/desktop-settings-electron.js", () => ({ + getDesktopSettingsStore: () => ({ + get: async () => mocks.settings, + patch: vi.fn(), + migrateLegacyRendererSettings: vi.fn(), + }), +})); + +vi.mock("./runtime-paths.js", () => ({ + createNodeEntrypointInvocation: vi.fn(() => ({ + command: "node", + args: [], + env: {}, + })), + resolveDaemonRunnerEntrypoint: vi.fn(() => ({ + entryPath: "/tmp/daemon.js", + execArgv: [], + })), + runCliJsonCommand: mocks.runCliJsonCommand, + runCliTextCommand: mocks.runCliTextCommand, +})); + +function desktopSettingsWithManagement(enabled: boolean) { + return { + ...DEFAULT_DESKTOP_SETTINGS, + daemon: { + ...DEFAULT_DESKTOP_SETTINGS.daemon, + manageBuiltInDaemon: enabled, + }, + }; +} + +describe("daemon-manager commands", () => { + beforeEach(() => { + mocks.settings = DEFAULT_DESKTOP_SETTINGS; + mocks.runCliJsonCommand.mockReset(); + mocks.runCliTextCommand.mockReset(); + mocks.spawnProcess.mockReset(); + }); + + it("refuses start and restart while built-in daemon management is disabled", async () => { + mocks.settings = desktopSettingsWithManagement(false); + const handlers = createDaemonCommandHandlers(); + + await expect(handlers.start_desktop_daemon()).rejects.toThrow( + "Built-in daemon management is disabled.", + ); + await expect(handlers.restart_desktop_daemon()).rejects.toThrow( + "Built-in daemon management is disabled.", + ); + + expect(mocks.runCliJsonCommand).not.toHaveBeenCalled(); + expect(mocks.spawnProcess).not.toHaveBeenCalled(); + }); + + it("keeps stop callable while built-in daemon management is disabled", async () => { + mocks.settings = desktopSettingsWithManagement(false); + mocks.runCliJsonCommand.mockResolvedValue({ + localDaemon: "stopped", + serverId: "", + }); + const handlers = createDaemonCommandHandlers(); + + await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ + serverId: "", + status: "stopped", + listen: null, + hostname: null, + pid: null, + home: "/tmp/paseo-home", + version: null, + desktopManaged: false, + error: null, + }); + + expect(mocks.runCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]); + }); +}); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index d802b8097..6da383602 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -38,6 +38,7 @@ import { createDesktopSettingsCommandHandlers, type DesktopCommandHandler, } from "../settings/desktop-settings-commands.js"; +import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; @@ -285,6 +286,12 @@ function shouldRestartForVersion(current: DesktopDaemonStatus): boolean { return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion); } +function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void { + if (!settings.daemon.manageBuiltInDaemon) { + throw new Error("Built-in daemon management is disabled."); + } +} + function buildStartupFailureError( result: { code: number | null; signal: string | null; error?: Error }, stdout: string, @@ -322,6 +329,8 @@ async function pollForRunningDaemon(): Promise { } async function startDaemon(): Promise { + assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); + const current = await resolveDesktopDaemonStatus(); logDesktopDaemonLifecycle("initial status check before start", { status: current.status, @@ -457,6 +466,7 @@ export async function stopDesktopDaemon(): Promise { } async function restartDaemon(): Promise { + assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); await stopDesktopDaemon(); return startDaemon(); }