mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
Stop local daemon when removing localhost (#1297)
* fix(app): stop local daemon when removing localhost * fix(app): handle daemon removal rollback * fix(app): return listen address in desktop e2e mock * fix(app): refresh daemon status after registration failure * fix(app): unslop daemon status error coverage * fix(app): preserve daemon toggle error context --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
@@ -124,7 +124,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
|||||||
return {
|
return {
|
||||||
serverId: cfg.serverId,
|
serverId: cfg.serverId,
|
||||||
status: daemonRunning ? "running" : "stopped",
|
status: daemonRunning ? "running" : "stopped",
|
||||||
listen: null,
|
listen: "127.0.0.1:6767",
|
||||||
hostname: null,
|
hostname: null,
|
||||||
pid: currentPid,
|
pid: currentPid,
|
||||||
home: "",
|
home: "",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
52
packages/app/src/desktop/daemon/daemon-management-error.ts
Normal file
52
packages/app/src/desktop/daemon/daemon-management-error.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,8 +9,15 @@ import {
|
|||||||
executeDaemonManagementToggle,
|
executeDaemonManagementToggle,
|
||||||
type DaemonManagementToggleResult,
|
type DaemonManagementToggleResult,
|
||||||
} from "@/desktop/daemon/daemon-management-toggle";
|
} 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 { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
|
||||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
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";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
|
||||||
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
||||||
@@ -37,27 +44,51 @@ export function useBuiltInDaemonManagement(
|
|||||||
DaemonManagementToggleResult,
|
DaemonManagementToggleResult,
|
||||||
Error
|
Error
|
||||||
>({
|
>({
|
||||||
mutationFn: () =>
|
mutationFn: async () => {
|
||||||
executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
|
const wasManagingDaemon = settings.manageBuiltInDaemon;
|
||||||
confirm: () =>
|
try {
|
||||||
confirmDialog({
|
const result = await executeDaemonManagementToggle(wasManagingDaemon, daemonStatus, {
|
||||||
title: "Pause built-in daemon",
|
confirm: () =>
|
||||||
message:
|
confirmDialog({
|
||||||
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
title: "Pause built-in daemon",
|
||||||
confirmLabel: "Pause and stop",
|
message:
|
||||||
cancelLabel: "Cancel",
|
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
|
||||||
destructive: true,
|
confirmLabel: "Pause and stop",
|
||||||
}),
|
cancelLabel: "Cancel",
|
||||||
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
destructive: true,
|
||||||
startDaemon: startDesktopDaemon,
|
}),
|
||||||
stopDaemon: stopDesktopDaemon,
|
persistSettings: (next) => updateSettings(next) as Promise<void>,
|
||||||
}),
|
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) => {
|
onError: (error) => {
|
||||||
|
const presentation = getDaemonManagementErrorPresentation(
|
||||||
|
error,
|
||||||
|
settings.manageBuiltInDaemon,
|
||||||
|
);
|
||||||
|
if (presentation.refreshStatus) {
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
reportError({
|
reportError({
|
||||||
error,
|
error,
|
||||||
message: settings.manageBuiltInDaemon
|
message: presentation.message,
|
||||||
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
|
|
||||||
: "Unable to update built-in daemon management.",
|
|
||||||
logLabel: "[Settings] Failed to update built-in daemon management",
|
logLabel: "[Settings] Failed to update built-in daemon management",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 { HostRuntimeStore } from "./host-runtime";
|
||||||
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
@@ -223,3 +223,50 @@ describe("DaemonStartService", () => {
|
|||||||
expect(notifications).toBe(countAfterFirst);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,13 +4,41 @@ import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
|||||||
|
|
||||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
type DaemonConnectionStore = Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
||||||
|
|
||||||
export interface DaemonStartServiceDeps {
|
export interface DaemonStartServiceDeps {
|
||||||
store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
store: DaemonConnectionStore;
|
||||||
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
startDesktopDaemon?: () => Promise<DesktopDaemonStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertDesktopDaemonConnection(
|
||||||
|
store: DaemonConnectionStore,
|
||||||
|
daemon: DesktopDaemonStatus,
|
||||||
|
): Promise<DaemonStartResult> {
|
||||||
|
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 {
|
export class DaemonStartService {
|
||||||
private readonly store: Pick<HostRuntimeStore, "upsertConnectionFromListen">;
|
private readonly store: DaemonConnectionStore;
|
||||||
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||||
private readonly listeners = new Set<() => void>();
|
private readonly listeners = new Set<() => void>();
|
||||||
private lastError: string | null = null;
|
private lastError: string | null = null;
|
||||||
@@ -25,23 +53,8 @@ export class DaemonStartService {
|
|||||||
this.beginRequest();
|
this.beginRequest();
|
||||||
try {
|
try {
|
||||||
const daemon = await this.invokeStartDesktopDaemon();
|
const daemon = await this.invokeStartDesktopDaemon();
|
||||||
const listenAddress = daemon.listen?.trim() ?? "";
|
const result = await upsertDesktopDaemonConnection(this.store, daemon);
|
||||||
const serverId = daemon.serverId.trim();
|
return result.ok ? result : this.fail(result.error);
|
||||||
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 };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.fail(error instanceof Error ? error.message : String(error));
|
return this.fail(error instanceof Error ? error.message : String(error));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||||
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
@@ -133,6 +134,7 @@ interface SidebarSectionItem {
|
|||||||
|
|
||||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||||
{ id: "general", label: "General", icon: Settings },
|
{ id: "general", label: "General", icon: Settings },
|
||||||
|
{ id: "daemon", label: "Daemon", icon: Server, desktopOnly: true },
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
|
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
|
||||||
{ id: "integrations", label: "Integrations", icon: Puzzle, 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 },
|
{ id: "host", label: "Host", icon: Server },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function renderHostSettingsContent(
|
||||||
|
view: Extract<SettingsView, { kind: "host" }>,
|
||||||
|
onHostRemoved: () => void,
|
||||||
|
): ReactNode {
|
||||||
|
switch (view.section) {
|
||||||
|
case "connections":
|
||||||
|
return <HostConnectionsPage serverId={view.serverId} />;
|
||||||
|
case "agents":
|
||||||
|
return <HostAgentsPage serverId={view.serverId} />;
|
||||||
|
case "workspaces":
|
||||||
|
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||||
|
case "providers":
|
||||||
|
return <HostProvidersPage serverId={view.serverId} />;
|
||||||
|
case "host":
|
||||||
|
return <HostSettingsPage serverId={view.serverId} onHostRemoved={onHostRemoved} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Trigger + sidebar style helpers
|
// Trigger + sidebar style helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1325,18 +1345,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (view.kind === "host") {
|
if (view.kind === "host") {
|
||||||
switch (view.section) {
|
return renderHostSettingsContent(view, handleHostRemoved);
|
||||||
case "connections":
|
|
||||||
return <HostConnectionsPage serverId={view.serverId} />;
|
|
||||||
case "agents":
|
|
||||||
return <HostAgentsPage serverId={view.serverId} />;
|
|
||||||
case "workspaces":
|
|
||||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
|
||||||
case "providers":
|
|
||||||
return <HostProvidersPage serverId={view.serverId} />;
|
|
||||||
case "host":
|
|
||||||
return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (view.kind === "projects") {
|
if (view.kind === "projects") {
|
||||||
return <ProjectsScreen view={view} />;
|
return <ProjectsScreen view={view} />;
|
||||||
@@ -1356,6 +1365,8 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
|||||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "daemon":
|
||||||
|
return <LocalDaemonSection />;
|
||||||
case "appearance":
|
case "appearance":
|
||||||
return <AppearanceSection />;
|
return <AppearanceSection />;
|
||||||
case "shortcuts":
|
case "shortcuts":
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { AdaptiveRenameModal } from "@/components/rename-modal";
|
|||||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
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 { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
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_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
|
||||||
|
|
||||||
function useHostProfile(serverId: string): HostProfile | null {
|
function useHostProfile(serverId: string): HostProfile | null {
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
@@ -276,7 +278,7 @@ export function HostSettingsPage({
|
|||||||
|
|
||||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||||
|
|
||||||
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
|
<RemoveHostSection host={host} isLocalDaemon={isLocalDaemon} onRemoved={onHostRemoved} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 { theme } = useUnistyles();
|
||||||
const { removeHost } = useHostMutations();
|
const { removeHost } = useHostMutations();
|
||||||
|
const { updateSettings } = useDesktopSettings();
|
||||||
|
const { data: daemonStatusData, setStatus } = useDaemonStatus();
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
const [isConfirming, setIsConfirming] = useState(false);
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
|
const daemonStatus = daemonStatusData?.status ?? null;
|
||||||
|
|
||||||
const destructiveTextStyle = useMemo(
|
const destructiveTextStyle = useMemo(
|
||||||
() => ({ color: theme.colors.destructive }),
|
() => ({ color: theme.colors.destructive }),
|
||||||
@@ -843,9 +856,45 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
}, [isRemoving]);
|
}, [isRemoving]);
|
||||||
const handleCancel = useCallback(() => setIsConfirming(false), []);
|
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(() => {
|
const handleConfirmRemove = useCallback(() => {
|
||||||
setIsRemoving(true);
|
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(() => {
|
.then(() => {
|
||||||
setIsConfirming(false);
|
setIsConfirming(false);
|
||||||
onRemoved?.();
|
onRemoved?.();
|
||||||
@@ -853,10 +902,29 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[HostPage] Failed to remove host", 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));
|
.finally(() => setIsRemoving(false));
|
||||||
}, [host.serverId, onRemoved, removeHost]);
|
}, [
|
||||||
|
daemonStatus,
|
||||||
|
host.serverId,
|
||||||
|
isLocalDaemon,
|
||||||
|
onRemoved,
|
||||||
|
removeHost,
|
||||||
|
rollbackLocalhostRemoval,
|
||||||
|
setStatus,
|
||||||
|
updateSettings,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const confirmationHeader = useMemo<SheetHeader>(
|
||||||
|
() => ({
|
||||||
|
title: isLocalDaemon ? "Remove localhost connection and stop daemon?" : "Remove host",
|
||||||
|
}),
|
||||||
|
[isLocalDaemon],
|
||||||
|
);
|
||||||
|
|
||||||
const removeIcon = useMemo(
|
const removeIcon = useMemo(
|
||||||
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
|
() => <Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />,
|
||||||
@@ -870,9 +938,13 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Remove host</Text>
|
<Text style={settingsStyles.rowTitle}>
|
||||||
|
{isLocalDaemon ? "Remove localhost connection" : "Remove host"}
|
||||||
|
</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
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"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
@@ -890,13 +962,15 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
|
|
||||||
{isConfirming ? (
|
{isConfirming ? (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
header={REMOVE_HOST_HEADER}
|
header={confirmationHeader}
|
||||||
visible
|
visible
|
||||||
onClose={handleCloseConfirm}
|
onClose={handleCloseConfirm}
|
||||||
testID="remove-host-confirm-modal"
|
testID="remove-host-confirm-modal"
|
||||||
>
|
>
|
||||||
<Text style={styles.confirmText}>
|
<Text style={styles.confirmText}>
|
||||||
Remove {host.label}? This will delete its saved connections.
|
{isLocalDaemon
|
||||||
|
? "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected."
|
||||||
|
: `Remove ${host.label}? This will delete its saved connections.`}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.confirmActions}>
|
<View style={styles.confirmActions}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ export function buildHostNewWorkspaceRoute(
|
|||||||
|
|
||||||
export const SETTINGS_SECTION_SLUGS = [
|
export const SETTINGS_SECTION_SLUGS = [
|
||||||
"general",
|
"general",
|
||||||
|
"daemon",
|
||||||
"appearance",
|
"appearance",
|
||||||
"shortcuts",
|
"shortcuts",
|
||||||
"integrations",
|
"integrations",
|
||||||
|
|||||||
Reference in New Issue
Block a user