Fix built-in daemon management toggle

This commit is contained in:
Mohamed Boudra
2026-05-03 22:02:40 +07:00
parent f8d4758e6b
commit f0d96f8e5a
7 changed files with 262 additions and 15 deletions

View File

@@ -50,11 +50,12 @@ import {
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context"; import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-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 { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { listenToDesktopEvent } from "@/desktop/electron/events"; import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host"; import { getDesktopHost } from "@/desktop/host";
import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source"; import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source"; import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action"; import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
@@ -312,6 +313,14 @@ function useDaemonStartIsRunning(): boolean {
const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000; const STARTUP_GIVE_UP_TIMEOUT_MS = 5_000;
async function shouldStartBuiltInDaemon(): Promise<boolean> {
if (!shouldUseDesktopDaemon()) {
return false;
}
const settings = await loadDesktopSettings();
return settings.daemon.manageBuiltInDaemon;
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const store = getHostRuntimeStore(); const store = getHostRuntimeStore();
@@ -319,7 +328,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
startHostRuntimeBootstrap({ startHostRuntimeBootstrap({
store, store,
daemonStartService, daemonStartService,
shouldStartDaemon: shouldUseDesktopDaemon(), shouldStartDaemon: shouldStartBuiltInDaemon,
}); });
}, []); }, []);
@@ -346,7 +355,10 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]); }, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
const retry = useCallback(() => { const retry = useCallback(() => {
void getDaemonStartService({ store: getHostRuntimeStore() }).start(); startDaemonIfGateAllows({
daemonStartService: getDaemonStartService({ store: getHostRuntimeStore() }),
shouldStartDaemon: shouldStartBuiltInDaemon,
});
}, []); }, []);
const splashError = !anyOnlineHostServerId ? daemonStartError : null; const splashError = !anyOnlineHostServerId ? daemonStartError : null;

View File

@@ -55,6 +55,21 @@ describe("startHostRuntimeBootstrap", () => {
expect(daemonStartService.start).not.toHaveBeenCalled(); 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", () => { it("does not await the daemon-start promise", () => {
const store = createFakeStore(); const store = createFakeStore();
let resolveStart: ((value: { ok: true }) => void) | undefined; let resolveStart: ((value: { ok: true }) => void) | undefined;

View File

@@ -11,17 +11,39 @@ export interface HostRuntimeBootstrapDaemonStartService {
start: () => Promise<DaemonStartResult>; start: () => Promise<DaemonStartResult>;
} }
type HostRuntimeBootstrapStartGate = boolean | (() => boolean | Promise<boolean>);
export interface StartHostRuntimeBootstrapInput { export interface StartHostRuntimeBootstrapInput {
store: HostRuntimeBootstrapStore; store: HostRuntimeBootstrapStore;
daemonStartService: HostRuntimeBootstrapDaemonStartService; daemonStartService: HostRuntimeBootstrapDaemonStartService;
shouldStartDaemon: boolean; shouldStartDaemon: HostRuntimeBootstrapStartGate;
} }
export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void { export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput): void {
input.store.boot(); input.store.boot();
if (input.shouldStartDaemon) { startDaemonIfGateAllows({
void input.daemonStartService.start(); 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"; export const WELCOME_ROUTE: Href = "/welcome";

View File

@@ -208,12 +208,14 @@ vi.mock("@/desktop/updates/desktop-updates", () => ({
const daemonCommandMocks = vi.hoisted(() => ({ const daemonCommandMocks = vi.hoisted(() => ({
getCliDaemonStatusMock: vi.fn(), getCliDaemonStatusMock: vi.fn(),
startDesktopDaemonMock: vi.fn(),
stopDesktopDaemonMock: vi.fn(), stopDesktopDaemonMock: vi.fn(),
})); }));
vi.mock("@/desktop/daemon/desktop-daemon", () => ({ vi.mock("@/desktop/daemon/desktop-daemon", () => ({
getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock, getCliDaemonStatus: daemonCommandMocks.getCliDaemonStatusMock,
shouldUseDesktopDaemon: vi.fn(() => true), shouldUseDesktopDaemon: vi.fn(() => true),
startDesktopDaemon: daemonCommandMocks.startDesktopDaemonMock,
stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock, stopDesktopDaemon: daemonCommandMocks.stopDesktopDaemonMock,
})); }));
@@ -234,8 +236,15 @@ describe("LocalDaemonSection", () => {
settingsState.updateSettings.mockReset(); settingsState.updateSettings.mockReset();
settingsState.updateSettings.mockResolvedValue(); settingsState.updateSettings.mockResolvedValue();
daemonStatusState.data.status.status = "running"; daemonStatusState.data.status.status = "running";
daemonStatusState.data.status.desktopManaged = true;
daemonStatusState.setStatus.mockReset(); daemonStatusState.setStatus.mockReset();
daemonStatusState.refetch.mockReset(); daemonStatusState.refetch.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockReset();
daemonCommandMocks.startDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status,
status: "running",
desktopManaged: true,
});
daemonCommandMocks.stopDesktopDaemonMock.mockReset(); daemonCommandMocks.stopDesktopDaemonMock.mockReset();
daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({ daemonCommandMocks.stopDesktopDaemonMock.mockResolvedValue({
...daemonStatusState.data.status, ...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(<LocalDaemonSection />);
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(<LocalDaemonSection />);
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(<LocalDaemonSection />);
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,
});
});
}); });

View File

@@ -14,6 +14,7 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon, stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
@@ -41,6 +42,12 @@ function useDaemonManagementToggle(args: {
if (!settings.manageBuiltInDaemon) { if (!settings.manageBuiltInDaemon) {
setIsUpdatingDaemonManagement(true); setIsUpdatingDaemonManagement(true);
void updateSettings({ manageBuiltInDaemon: true }) void updateSettings({ manageBuiltInDaemon: true })
.then(() => startDesktopDaemon())
.then((newStatus) => {
setStatus(newStatus);
refetch();
return;
})
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error); console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert("Error", "Unable to update built-in daemon management."); Alert.alert("Error", "Unable to update built-in daemon management.");
@@ -66,25 +73,29 @@ function useDaemonManagementToggle(args: {
setIsUpdatingDaemonManagement(true); setIsUpdatingDaemonManagement(true);
const stopPromise = void updateSettings({ manageBuiltInDaemon: false })
daemonStatus?.status === "running" .then(() => {
? stopDesktopDaemon() if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) {
: Promise.resolve(daemonStatus ?? null); return stopDesktopDaemon();
}
void stopPromise return daemonStatus ?? null;
})
.then((newStatus) => { .then((newStatus) => {
if (newStatus) { if (newStatus) {
setStatus(newStatus); setStatus(newStatus);
} }
return updateSettings({ manageBuiltInDaemon: false }); return;
}) })
.then(() => { .then(() => {
refetch(); refetch();
return; return;
}) })
.catch((error) => { .catch((error) => {
console.error("[Settings] Failed to pause built-in daemon management", error); console.error("[Settings] Failed to stop built-in daemon", error);
Alert.alert("Error", "Unable to pause built-in daemon management."); Alert.alert(
"Error",
"Built-in daemon management was paused, but Paseo could not stop the daemon.",
);
}) })
.finally(() => { .finally(() => {
setIsUpdatingDaemonManagement(false); setIsUpdatingDaemonManagement(false);

View File

@@ -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"]);
});
});

View File

@@ -38,6 +38,7 @@ import {
createDesktopSettingsCommandHandlers, createDesktopSettingsCommandHandlers,
type DesktopCommandHandler, type DesktopCommandHandler,
} from "../settings/desktop-settings-commands.js"; } from "../settings/desktop-settings-commands.js";
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
@@ -285,6 +286,12 @@ function shouldRestartForVersion(current: DesktopDaemonStatus): boolean {
return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion); 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( function buildStartupFailureError(
result: { code: number | null; signal: string | null; error?: Error }, result: { code: number | null; signal: string | null; error?: Error },
stdout: string, stdout: string,
@@ -322,6 +329,8 @@ async function pollForRunningDaemon(): Promise<DesktopDaemonStatus> {
} }
async function startDaemon(): Promise<DesktopDaemonStatus> { async function startDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
const current = await resolveDesktopDaemonStatus(); const current = await resolveDesktopDaemonStatus();
logDesktopDaemonLifecycle("initial status check before start", { logDesktopDaemonLifecycle("initial status check before start", {
status: current.status, status: current.status,
@@ -457,6 +466,7 @@ export async function stopDesktopDaemon(): Promise<DesktopDaemonStatus> {
} }
async function restartDaemon(): Promise<DesktopDaemonStatus> { async function restartDaemon(): Promise<DesktopDaemonStatus> {
assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get());
await stopDesktopDaemon(); await stopDesktopDaemon();
return startDaemon(); return startDaemon();
} }