diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 4023f4f54..15af76323 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -124,7 +124,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi return { serverId: cfg.serverId, status: daemonRunning ? "running" : "stopped", - listen: null, + listen: "127.0.0.1:6767", hostname: null, pid: currentPid, home: "", diff --git a/packages/app/src/desktop/daemon/daemon-management-error.test.ts b/packages/app/src/desktop/daemon/daemon-management-error.test.ts new file mode 100644 index 000000000..c328b2ce6 --- /dev/null +++ b/packages/app/src/desktop/daemon/daemon-management-error.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + DaemonConnectionRegistrationError, + DaemonManagementOperationError, + getDaemonManagementErrorPresentation, +} from "./daemon-management-error"; + +describe("getDaemonManagementErrorPresentation", () => { + it("refreshes status when the daemon started but localhost registration failed", () => { + const presentation = getDaemonManagementErrorPresentation( + new DaemonConnectionRegistrationError("Desktop daemon did not return a listen address."), + false, + ); + + expect(presentation).toEqual({ + message: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + refreshStatus: true, + }); + }); + + it("does not refresh status for daemon stop failures", () => { + const presentation = getDaemonManagementErrorPresentation(new Error("stop failed"), true); + + expect(presentation).toEqual({ + message: "Built-in daemon management was paused, but Paseo could not stop the daemon.", + refreshStatus: false, + }); + }); + + it("uses the pre-mutation daemon management state for operation failures", () => { + const presentation = getDaemonManagementErrorPresentation( + new DaemonManagementOperationError(new Error("stop failed"), true), + false, + ); + + expect(presentation).toEqual({ + message: "Built-in daemon management was paused, but Paseo could not stop the daemon.", + refreshStatus: false, + }); + }); + + it("does not refresh status for generic update failures", () => { + const presentation = getDaemonManagementErrorPresentation(new Error("settings failed"), false); + + expect(presentation).toEqual({ + message: "Unable to update built-in daemon management.", + refreshStatus: false, + }); + }); +}); diff --git a/packages/app/src/desktop/daemon/daemon-management-error.ts b/packages/app/src/desktop/daemon/daemon-management-error.ts new file mode 100644 index 000000000..38a6816c6 --- /dev/null +++ b/packages/app/src/desktop/daemon/daemon-management-error.ts @@ -0,0 +1,52 @@ +export class DaemonConnectionRegistrationError extends Error { + constructor(message: string) { + super(message); + this.name = "DaemonConnectionRegistrationError"; + } +} + +export class DaemonManagementOperationError extends Error { + readonly originalError: Error; + readonly wasManagingDaemon: boolean; + + constructor(error: Error, wasManagingDaemon: boolean) { + super(error.message); + this.name = error.name; + this.cause = error; + this.originalError = error; + this.wasManagingDaemon = wasManagingDaemon; + } +} + +export interface DaemonManagementErrorPresentation { + message: string; + refreshStatus: boolean; +} + +export function getDaemonManagementErrorPresentation( + error: Error, + isManagingDaemon: boolean, +): DaemonManagementErrorPresentation { + const presentationError = + error instanceof DaemonManagementOperationError ? error.originalError : error; + const wasManagingDaemon = + error instanceof DaemonManagementOperationError ? error.wasManagingDaemon : isManagingDaemon; + + if (presentationError instanceof DaemonConnectionRegistrationError) { + return { + message: + "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + refreshStatus: true, + }; + } + if (wasManagingDaemon) { + return { + message: "Built-in daemon management was paused, but Paseo could not stop the daemon.", + refreshStatus: false, + }; + } + return { + message: "Unable to update built-in daemon management.", + refreshStatus: false, + }; +} diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index abecc9fc3..c2e39da17 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -9,8 +9,15 @@ import { executeDaemonManagementToggle, type DaemonManagementToggleResult, } from "@/desktop/daemon/daemon-management-toggle"; +import { + DaemonConnectionRegistrationError, + DaemonManagementOperationError, + getDaemonManagementErrorPresentation, +} from "@/desktop/daemon/daemon-management-error"; import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service"; import { confirmDialog } from "@/utils/confirm-dialog"; type DesktopDaemonSettings = DesktopSettings["daemon"]; @@ -37,27 +44,51 @@ export function useBuiltInDaemonManagement( DaemonManagementToggleResult, Error >({ - mutationFn: () => - executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, { - confirm: () => - confirmDialog({ - title: "Pause built-in daemon", - message: - "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", - confirmLabel: "Pause and stop", - cancelLabel: "Cancel", - destructive: true, - }), - persistSettings: (next) => updateSettings(next) as Promise, - startDaemon: startDesktopDaemon, - stopDaemon: stopDesktopDaemon, - }), + mutationFn: async () => { + const wasManagingDaemon = settings.manageBuiltInDaemon; + try { + const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, { + confirm: () => + confirmDialog({ + title: "Pause built-in daemon", + message: + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + confirmLabel: "Pause and stop", + cancelLabel: "Cancel", + destructive: true, + }), + persistSettings: (next) => updateSettings(next) as Promise, + startDaemon: startDesktopDaemon, + stopDaemon: stopDesktopDaemon, + }); + if (result.kind === "enabled") { + const upsertResult = await upsertDesktopDaemonConnection( + getHostRuntimeStore(), + result.newStatus, + ); + if (!upsertResult.ok) { + throw new DaemonConnectionRegistrationError(upsertResult.error); + } + } + return result; + } catch (error) { + throw new DaemonManagementOperationError( + error instanceof Error ? error : new Error(String(error)), + wasManagingDaemon, + ); + } + }, onError: (error) => { + const presentation = getDaemonManagementErrorPresentation( + error, + settings.manageBuiltInDaemon, + ); + if (presentation.refreshStatus) { + refreshStatus(); + } reportError({ error, - message: settings.manageBuiltInDaemon - ? "Built-in daemon management was paused, but Paseo could not stop the daemon." - : "Unable to update built-in daemon management.", + message: presentation.message, logLabel: "[Settings] Failed to update built-in daemon management", }); }, diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index b01ccd841..4793fb8af 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { DaemonStartService } from "./daemon-start-service"; +import { DaemonStartService, upsertDesktopDaemonConnection } from "./daemon-start-service"; import type { HostRuntimeStore } from "./host-runtime"; import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; @@ -223,3 +223,50 @@ describe("DaemonStartService", () => { expect(notifications).toBe(countAfterFirst); }); }); + +describe("upsertDesktopDaemonConnection", () => { + it("upserts a valid desktop daemon status", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus()); + + expect(result).toEqual({ ok: true }); + expect(fake.upserts).toEqual([ + { listenAddress: "127.0.0.1:6767", serverId: "srv_desktop", hostname: "desktop" }, + ]); + }); + + it("rejects a missing listen address without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null })); + + expect(result).toEqual({ + ok: false, + error: "Desktop daemon did not return a listen address.", + }); + expect(fake.upserts).toEqual([]); + }); + + it("rejects a missing server id without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ serverId: "" })); + + expect(result).toEqual({ + ok: false, + error: "Desktop daemon did not return a server id.", + }); + expect(fake.upserts).toEqual([]); + }); + + it("rejects an unsupported listen address without upserting", async () => { + const fake = createFakeStore(); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: "???" })); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.error).toContain("unsupported listen address"); + expect(fake.upserts).toEqual([]); + }); +}); diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index e9437931f..e3c178bde 100644 --- a/packages/app/src/runtime/daemon-start-service.ts +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -4,13 +4,41 @@ import type { HostRuntimeStore } from "@/runtime/host-runtime"; export type DaemonStartResult = { ok: true } | { ok: false; error: string }; +type DaemonConnectionStore = Pick; + export interface DaemonStartServiceDeps { - store: Pick; + store: DaemonConnectionStore; startDesktopDaemon?: () => Promise; } +export async function upsertDesktopDaemonConnection( + store: DaemonConnectionStore, + daemon: DesktopDaemonStatus, +): Promise { + const listenAddress = daemon.listen?.trim() ?? ""; + const serverId = daemon.serverId.trim(); + if (!listenAddress) { + return { ok: false, error: "Desktop daemon did not return a listen address." }; + } + if (!serverId) { + return { ok: false, error: "Desktop daemon did not return a server id." }; + } + if (!connectionFromListen(listenAddress)) { + return { + ok: false, + error: `Desktop daemon returned an unsupported listen address: ${listenAddress}`, + }; + } + await store.upsertConnectionFromListen({ + listenAddress, + serverId, + hostname: daemon.hostname, + }); + return { ok: true }; +} + export class DaemonStartService { - private readonly store: Pick; + private readonly store: DaemonConnectionStore; private readonly invokeStartDesktopDaemon: () => Promise; private readonly listeners = new Set<() => void>(); private lastError: string | null = null; @@ -25,23 +53,8 @@ export class DaemonStartService { this.beginRequest(); try { const daemon = await this.invokeStartDesktopDaemon(); - const listenAddress = daemon.listen?.trim() ?? ""; - const serverId = daemon.serverId.trim(); - if (!listenAddress) { - return this.fail("Desktop daemon did not return a listen address."); - } - if (!serverId) { - return this.fail("Desktop daemon did not return a server id."); - } - if (!connectionFromListen(listenAddress)) { - return this.fail(`Desktop daemon returned an unsupported listen address: ${listenAddress}`); - } - await this.store.upsertConnectionFromListen({ - listenAddress, - serverId, - hostname: daemon.hostname, - }); - return { ok: true }; + const result = await upsertDesktopDaemonConnection(this.store, daemon); + return result.ok ? result : this.fail(result.error); } catch (error) { return this.fail(error instanceof Error ? error.message : String(error)); } finally { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 0dd19f9bf..6d0ab7a2e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -84,6 +84,7 @@ import { import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; +import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -133,6 +134,7 @@ interface SidebarSectionItem { const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", label: "General", icon: Settings }, + { id: "daemon", label: "Daemon", icon: Server, desktopOnly: true }, { id: "appearance", label: "Appearance", icon: Palette }, { id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true }, @@ -155,6 +157,24 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "host", label: "Host", icon: Server }, ]; +function renderHostSettingsContent( + view: Extract, + onHostRemoved: () => void, +): ReactNode { + switch (view.section) { + case "connections": + return ; + case "agents": + return ; + case "workspaces": + return ; + case "providers": + return ; + case "host": + return ; + } +} + // --------------------------------------------------------------------------- // Trigger + sidebar style helpers // --------------------------------------------------------------------------- @@ -1325,18 +1345,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { const content = (() => { if (view.kind === "host") { - switch (view.section) { - case "connections": - return ; - case "agents": - return ; - case "workspaces": - return ; - case "providers": - return ; - case "host": - return ; - } + return renderHostSettingsContent(view, handleHostRemoved); } if (view.kind === "projects") { return ; @@ -1356,6 +1365,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange} /> ); + case "daemon": + return ; case "appearance": return ; case "shortcuts": diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 78835e378..2680838fd 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -7,7 +7,10 @@ import { AdaptiveRenameModal } from "@/components/rename-modal"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; +import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; @@ -72,7 +75,6 @@ function formatDaemonVersionBadge(version: string | null): string | null { } const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; -const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; function useHostProfile(serverId: string): HostProfile | null { const daemons = useHosts(); @@ -276,7 +278,7 @@ export function HostSettingsPage({ {isLocalDaemon ? : null} - + ); } @@ -826,11 +828,22 @@ function PairDeviceRow() { ); } -function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: () => void }) { +function RemoveHostSection({ + host, + isLocalDaemon, + onRemoved, +}: { + host: HostProfile; + isLocalDaemon: boolean; + onRemoved?: () => void; +}) { const { theme } = useUnistyles(); const { removeHost } = useHostMutations(); + const { updateSettings } = useDesktopSettings(); + const { data: daemonStatusData, setStatus } = useDaemonStatus(); const [isConfirming, setIsConfirming] = useState(false); const [isRemoving, setIsRemoving] = useState(false); + const daemonStatus = daemonStatusData?.status ?? null; const destructiveTextStyle = useMemo( () => ({ color: theme.colors.destructive }), @@ -843,9 +856,45 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: setIsConfirming(false); }, [isRemoving]); const handleCancel = useCallback(() => setIsConfirming(false), []); + const rollbackLocalhostRemoval = useCallback( + async (shouldRestartDaemon: boolean) => { + await updateSettings({ daemon: { manageBuiltInDaemon: true } }); + if (!shouldRestartDaemon) { + return; + } + setStatus(await startDesktopDaemon()); + }, + [setStatus, updateSettings], + ); const handleConfirmRemove = useCallback(() => { setIsRemoving(true); - void removeHost(host.serverId) + const remove = async () => { + let didDisableDaemonManagement = false; + let didStopDaemon = false; + if (isLocalDaemon) { + try { + await updateSettings({ daemon: { manageBuiltInDaemon: false } }); + didDisableDaemonManagement = true; + if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + setStatus(await stopDesktopDaemon()); + didStopDaemon = true; + } + await removeHost(host.serverId); + } catch (error) { + if (didDisableDaemonManagement) { + try { + await rollbackLocalhostRemoval(didStopDaemon); + } catch (rollbackError) { + console.error("[HostPage] Failed to roll back localhost removal", rollbackError); + } + } + throw error; + } + return; + } + await removeHost(host.serverId); + }; + void remove() .then(() => { setIsConfirming(false); onRemoved?.(); @@ -853,10 +902,29 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: }) .catch((error) => { console.error("[HostPage] Failed to remove host", error); - Alert.alert("Error", "Unable to remove host"); + Alert.alert( + "Error", + isLocalDaemon ? "Unable to remove localhost connection" : "Unable to remove host", + ); }) .finally(() => setIsRemoving(false)); - }, [host.serverId, onRemoved, removeHost]); + }, [ + daemonStatus, + host.serverId, + isLocalDaemon, + onRemoved, + removeHost, + rollbackLocalhostRemoval, + setStatus, + updateSettings, + ]); + + const confirmationHeader = useMemo( + () => ({ + title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host", + }), + [isLocalDaemon], + ); const removeIcon = useMemo( () => , @@ -870,9 +938,13 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: - Remove host + + {isLocalDaemon ? "Remove localhost connection" : "Remove host"} + - Removes this host and its saved connections from this device + {isLocalDaemon + ? "Removes localhost from this device and stops the built-in daemon" + : "Removes this host and its saved connections from this device"}