From 405418c47087d51118ba233a2a4a57774f392b6a Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Fri, 21 Aug 2026 11:41:29 -0700 Subject: [PATCH] feat(remote-host-updates): reconnect hosts after remote updates - Track restart state through reconnection and version confirmation - Show restart progress and disable remote thread submission - Add coverage for reconnect lifecycle and update controls --- .../thread/RemoteHostUpdateDock.test.tsx | 51 +- .../thread/RemoteHostUpdateDock.tsx | 32 +- .../thread/ThreadDraftComposerArea.tsx | 5 + .../thread/ThreadDraftView.test.tsx | 90 +++- .../components/thread/ThreadDraftView.tsx | 14 +- .../remoteServers/hostUpdateReconnect.ts | 43 ++ src/renderer/state/remoteServers/types.ts | 2 + src/renderer/state/remoteServersStore.test.ts | 471 +++++++++++++++++- src/renderer/state/remoteServersStore.ts | 168 ++++++- .../parts/RemoteHostUpdateControl.test.tsx | 39 ++ .../parts/RemoteHostUpdateControl.tsx | 12 +- 11 files changed, 901 insertions(+), 26 deletions(-) create mode 100644 src/renderer/state/remoteServers/hostUpdateReconnect.ts create mode 100644 src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.test.tsx diff --git a/src/renderer/components/thread/RemoteHostUpdateDock.test.tsx b/src/renderer/components/thread/RemoteHostUpdateDock.test.tsx index 7cb3a5980..a101801de 100644 --- a/src/renderer/components/thread/RemoteHostUpdateDock.test.tsx +++ b/src/renderer/components/thread/RemoteHostUpdateDock.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; @@ -14,6 +14,7 @@ describe("RemoteHostUpdateDock", () => { "desktop-1": { status: "online", projects: [], threads: [] }, }, hostUpdates: {}, + hostUpdateRestarts: {}, installHostUpdate, }); }); @@ -50,8 +51,54 @@ describe("RemoteHostUpdateDock", () => { }); render(); - fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); + const button = screen.getByRole("button", { name: "Install and restart" }); + expect(button).toHaveClass("button--ghost"); + fireEvent.click(button); await waitFor(() => expect(installHostUpdate).toHaveBeenCalledWith("desktop-1")); }); + + it("shows the restart spinner while the install request is pending", async () => { + let rejectInstall: (error: Error) => void = () => {}; + installHostUpdate.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectInstall = reject; + }), + ); + useRemoteServersStore.setState({ + hostUpdates: { + "desktop-1": { + currentVersion: "1.0.0", + status: { type: "downloaded", version: "1.1.0" }, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Install and restart" })); + + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Install and restart" })).not.toBeInTheDocument(); + + await act(async () => rejectInstall(new Error("Install failed"))); + + expect(await screen.findByText("Install failed")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Install and restart" })).toBeInTheDocument(); + }); + + it("replaces the install button with a restart spinner", () => { + useRemoteServersStore.setState({ + runtime: { + "desktop-1": { status: "connecting", projects: [], threads: [] }, + }, + hostUpdateRestarts: { "desktop-1": "1.1.0" }, + }); + + render(); + + expect(screen.getByText("The host is restarting to install the update.")).toBeInTheDocument(); + expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Install and restart" })).not.toBeInTheDocument(); + }); }); diff --git a/src/renderer/components/thread/RemoteHostUpdateDock.tsx b/src/renderer/components/thread/RemoteHostUpdateDock.tsx index 35190028d..1219ec66a 100644 --- a/src/renderer/components/thread/RemoteHostUpdateDock.tsx +++ b/src/renderer/components/thread/RemoteHostUpdateDock.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { Button, toast } from "@heroui/react"; +import { Button, Spinner, toast } from "@heroui/react"; import { useLingui } from "@lingui/react/macro"; import { Download } from "lucide-react"; import { useAsyncOperation } from "@/renderer/hooks/useAsyncOperation"; @@ -9,6 +9,7 @@ import { ThreadDockHeader, ThreadDockSection } from "./ThreadDockUI"; export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string }) { const { t } = useLingui(); const update = useRemoteServersStore((state) => state.hostUpdates[desktopId]); + const restartingVersion = useRemoteServersStore((state) => state.hostUpdateRestarts[desktopId]); const isOnline = useRemoteServersStore((state) => state.runtime[desktopId]?.status === "online"); const installHostUpdate = useRemoteServersStore((state) => state.installHostUpdate); const getHostUpdateState = useRemoteServersStore((state) => state.getHostUpdateState); @@ -28,20 +29,25 @@ export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string }, [desktopId, getHostUpdateState, isUpdating]); if ( - !status || - (status.type !== "update-available" && - status.type !== "downloading" && - status.type !== "downloaded") + !restartingVersion && + (!status || + (status.type !== "update-available" && + status.type !== "downloading" && + status.type !== "downloaded")) ) { return null; } - const title = - status.type === "update-available" + const isInstalling = busy || restartingVersion !== undefined; + const title = isInstalling + ? t`The host is restarting to install the update.` + : status?.type === "update-available" ? t`Remote host update v${status.version} is downloading…` - : status.type === "downloading" + : status?.type === "downloading" ? t`Remote host update is downloading… ${Math.round(status.percent)}%` - : t`Remote host update v${status.version} is ready.`; + : status?.type === "downloaded" + ? t`Remote host update v${status.version} is ready.` + : ""; const install = () => run(async () => { @@ -56,8 +62,12 @@ export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string iconClassName="text-accent" title={title} actions={ - status.type === "downloaded" ? ( - ) : null diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index c536f2057..17825e3d3 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -323,6 +323,9 @@ export function ThreadDraftComposerArea(props: { // Remote-project attachments are stored on the paired desktop; resolve // previews through its image endpoint instead of the local-file protocol. const remoteDesktopId = props.project.remoteServerId; + const hostUpdateRestarting = useRemoteServersStore((state) => + remoteDesktopId ? state.hostUpdateRestarts[remoteDesktopId] !== undefined : false, + ); const attachmentImageUrlForPath = remoteDesktopId ? (path: string) => useRemoteServersStore.getState().localImageUrl(remoteDesktopId, path) : undefined; @@ -676,6 +679,7 @@ export function ThreadDraftComposerArea(props: { } function submitSegments(allSegments: PromptSegment[], fallbackPrompt = "") { + if (hostUpdateRestarting) return; if (experimentMode) { void runExperiment(allSegments, fallbackPrompt); return; @@ -1163,6 +1167,7 @@ export function ThreadDraftComposerArea(props: { submitDisabled={ authRequired || agentUpdating || + hostUpdateRestarting || isSubmitting || !(hasContent || attachments.attachments.length > 0) || (experimentMode && experimentCandidates.length < 2) diff --git a/src/renderer/components/thread/ThreadDraftView.test.tsx b/src/renderer/components/thread/ThreadDraftView.test.tsx index 87eb4c963..055c4a77d 100644 --- a/src/renderer/components/thread/ThreadDraftView.test.tsx +++ b/src/renderer/components/thread/ThreadDraftView.test.tsx @@ -506,7 +506,12 @@ describe("ThreadDraftView", () => { sharedSettingsHydrated: true, }); useAppStore.setState({ pendingDraftWorktreeSelections: {} }); - useRemoteServersStore.setState({ servers: [], runtime: {}, hostUpdates: {} }); + useRemoteServersStore.setState({ + servers: [], + runtime: {}, + hostUpdates: {}, + hostUpdateRestarts: {}, + }); }); it("adds experiment candidates without a prompt and keeps the composer submit button", () => { @@ -1020,6 +1025,89 @@ describe("ThreadDraftView", () => { expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument(); }); + it("shows the remote connection's specific error message", () => { + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "desktop-1", + label: "Remote Mac", + endpoint: "http://remote/", + accessToken: "token", + scopes: [], + }, + ], + runtime: { + "desktop-1": { + status: "error", + message: "This app version is incompatible with that server.", + projects: [], + threads: [], + }, + }, + }); + + render( {}} />); + + expect( + screen.getByText("This app version is incompatible with that server."), + ).toBeInTheDocument(); + expect(screen.queryByText(/remote server is offline/i)).not.toBeInTheDocument(); + }); + + it("shows the remote connecting state instead of the missing-agent message", () => { + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "desktop-1", + label: "Remote Mac", + endpoint: "http://remote/", + accessToken: "token", + scopes: [], + }, + ], + runtime: { + "desktop-1": { status: "connecting", projects: [], threads: [] }, + }, + }); + + render( {}} />); + + expect(screen.getByText("Connecting…")).toBeInTheDocument(); + expect(screen.queryByText("Connection error")).not.toBeInTheDocument(); + expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument(); + }); + + it("keeps the remote composer visible and disables submit during a host update restart", () => { + const onStart = vi.fn<(input: unknown) => void>(); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "desktop-1", + label: "Remote Mac", + endpoint: "http://remote/", + accessToken: "token", + scopes: ["projects:manage"], + hostMode: "desktop", + }, + ], + runtime: { + "desktop-1": { status: "connecting", projects: [], threads: [] }, + }, + hostUpdateRestarts: { "desktop-1": "1.1.0" }, + }); + + render( + , + ); + + expect(screen.queryByText("Connecting…")).not.toBeInTheDocument(); + const composer = composerSpy.mock.lastCall?.[0] as { submitDisabled?: boolean }; + expect(composer.submitDisabled).toBe(true); + fireEvent.click(screen.getByText("set-prompt")); + fireEvent.click(screen.getByText("submit")); + expect(onStart).not.toHaveBeenCalled(); + }); + it("shows the discovery reveal for a WSL project while its distro is probing", () => { const onStart = vi.fn<(input: unknown) => void>(); useAgentStatusesStore.getState().beginFirstLaunchDiscovery({ kind: "wsl", distro: "Ubuntu" }); diff --git a/src/renderer/components/thread/ThreadDraftView.tsx b/src/renderer/components/thread/ThreadDraftView.tsx index 3e69df097..91da474e5 100644 --- a/src/renderer/components/thread/ThreadDraftView.tsx +++ b/src/renderer/components/thread/ThreadDraftView.tsx @@ -209,6 +209,12 @@ export function ThreadDraftView(props: { if (!state.servers.some((server) => server.desktopId === remoteServerId)) return "missing"; return state.runtime[remoteServerId]?.status ?? "connecting"; }); + const hostUpdateRestarting = useRemoteServersStore((state) => + project.remoteServerId ? state.hostUpdateRestarts[project.remoteServerId] !== undefined : false, + ); + const remoteConnectionMessage = useRemoteServersStore((state) => + project.remoteServerId ? state.runtime[project.remoteServerId]?.message : undefined, + ); // Debugging showed config-only edits were rebuilding the provider/model // payload. Keep the installed-agent list stable unless the source inputs @@ -1042,7 +1048,7 @@ export function ThreadDraftView(props: { spacerRef: anchorSpacerRef, }); - if (remoteConnection === "connecting") { + if (remoteConnection === "connecting" && !hostUpdateRestarting) { return (
@@ -1052,14 +1058,16 @@ export function ThreadDraftView(props: {
); } - if (remoteConnection !== "local" && remoteConnection !== "online") { + if (!hostUpdateRestarting && remoteConnection !== "local" && remoteConnection !== "online") { return (

Connection error

- This project's remote server is offline. Reconnect it to start a thread. + {remoteConnectionMessage ?? ( + This project's remote server is offline. Reconnect it to start a thread. + )}

); diff --git a/src/renderer/state/remoteServers/hostUpdateReconnect.ts b/src/renderer/state/remoteServers/hostUpdateReconnect.ts new file mode 100644 index 000000000..c371eec05 --- /dev/null +++ b/src/renderer/state/remoteServers/hostUpdateReconnect.ts @@ -0,0 +1,43 @@ +const RECONNECT_INTERVAL_MS = 1_000; +const RECONNECT_TIMEOUT_MS = 60_000; +const TIMED_OUT = Symbol("timed-out"); + +export type HostUpdateReconnectOutcome = + | { readonly type: "connected" } + | { readonly type: "cancelled" } + | { readonly type: "timeout" } + | { readonly type: "terminal-error"; readonly error: unknown }; + +export async function waitForHostUpdateReconnect(options: { + readonly isCurrent: () => boolean; + readonly attempt: () => Promise; + readonly isTerminalError: (error: unknown) => boolean; +}): Promise { + const deadline = Date.now() + RECONNECT_TIMEOUT_MS; + + while (options.isCurrent() && Date.now() < deadline) { + const remaining = deadline - Date.now(); + let timeout: ReturnType | undefined; + try { + const connected = await Promise.race([ + options.attempt(), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(TIMED_OUT), remaining); + }), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + }); + if (connected === TIMED_OUT) return { type: "timeout" }; + if (connected) return { type: "connected" }; + } catch (error) { + if (options.isTerminalError(error)) return { type: "terminal-error", error }; + } + + const retryDelay = Math.min(RECONNECT_INTERVAL_MS, deadline - Date.now()); + if (retryDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + return options.isCurrent() ? { type: "timeout" } : { type: "cancelled" }; +} diff --git a/src/renderer/state/remoteServers/types.ts b/src/renderer/state/remoteServers/types.ts index 38c28999c..8cbbc4ec9 100644 --- a/src/renderer/state/remoteServers/types.ts +++ b/src/renderer/state/remoteServers/types.ts @@ -73,6 +73,8 @@ export interface RemoteServersState { servers: RemoteServerRecord[]; runtime: Record; hostUpdates: Record; + /** Expected version while a remotely installed desktop update restarts its host. */ + hostUpdateRestarts: Record; /** * Remote (server-side) project ids the user excluded from sync, keyed by * desktopId. Local-only state, so a project can be dropped from — or restored diff --git a/src/renderer/state/remoteServersStore.test.ts b/src/renderer/state/remoteServersStore.test.ts index 166e87914..7c4ef81c1 100644 --- a/src/renderer/state/remoteServersStore.test.ts +++ b/src/renderer/state/remoteServersStore.test.ts @@ -14,6 +14,7 @@ import { filterRemoteThreadEvent } from "./remoteServers/eventRouting"; import { mainProcessFetch } from "./remoteServers/mainProcessFetch"; import type { RemoteClientFactory, + RemoteServerRecord, RemoteSocketFactory, RemoteSocketLike, } from "./remoteServers/types"; @@ -174,10 +175,12 @@ function remoteThreadSnapshot(threadId: string): RemoteThreadHistorySnapshot { function deferred() { let resolve: (value: T) => void = () => {}; - const promise = new Promise((next) => { + let reject: (error: unknown) => void = () => {}; + const promise = new Promise((next, rejectNext) => { resolve = next; + reject = rejectNext; }); - return { promise, resolve }; + return { promise, resolve, reject }; } function makeSocket(overrides: Partial = {}): RemoteSocketLike { @@ -210,7 +213,9 @@ function makeClient(opts?: { startShell?: RemoteDesktopClient["startShell"]; closeShell?: RemoteDesktopClient["closeShell"]; callRemoteProcedure?: RemoteDesktopClient["callRemoteProcedure"]; + hostUpdateState?: RemoteDesktopClient["hostUpdateState"]; checkHostUpdate?: RemoteDesktopClient["checkHostUpdate"]; + installHostUpdate?: RemoteDesktopClient["installHostUpdate"]; }): RemoteDesktopClient { return { exchangePairingCredential: async () => ({ @@ -227,6 +232,12 @@ function makeClient(opts?: { desktopId: "d1", label: "Server One", appVersion: "1.0", + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + scopes: ["session:read", "projects:manage"], + }, endpoints: { httpBaseUrl: opts?.environmentHttpBaseUrl ?? "http://192.168.1.9:38987/", wsBaseUrl: "ws://192.168.1.9:38987/", @@ -267,9 +278,13 @@ function makeClient(opts?: { startShell: opts?.startShell ?? (async () => {}), closeShell: opts?.closeShell ?? (async () => {}), callRemoteProcedure: opts?.callRemoteProcedure ?? (async () => ({})), + hostUpdateState: + opts?.hostUpdateState ?? + (async () => ({ currentVersion: "1.0", status: { type: "update-not-available" } })), checkHostUpdate: opts?.checkHostUpdate ?? (async () => ({ currentVersion: "1.0", status: { type: "update-not-available" } })), + installHostUpdate: opts?.installHostUpdate ?? (async () => {}), } as unknown as RemoteDesktopClient; } @@ -277,6 +292,28 @@ function factoryFor(client: RemoteDesktopClient): RemoteClientFactory { return () => client; } +function makeEnvironment( + appVersion = "1.0", +): Awaited> { + return { + protocolVersion: PORACODE_REMOTE_PROTOCOL_VERSION, + hostMode: "desktop", + desktopId: "d1", + label: "Server One", + appVersion, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["bearer-access-token"], + scopes: ["session:read", "projects:manage"], + }, + endpoints: { + httpBaseUrl: "http://192.168.1.9:38987/", + wsBaseUrl: "ws://192.168.1.9:38987/", + }, + }; +} + /** * Pair a server, then tear down its pair-time event stream and reinstall the * socket factory. Opening a thread restarts the same per-server stream, letting @@ -361,6 +398,436 @@ describe("useRemoteServersStore", () => { expect(checkHostUpdate).not.toHaveBeenCalled(); }); + it("waits for the installed host version before restoring the connection", async () => { + vi.useFakeTimers(); + const environment = vi + .fn() + .mockResolvedValueOnce(makeEnvironment("1.0")) + .mockResolvedValue(makeEnvironment("1.1")); + const installHostUpdate = vi.fn(async () => {}); + const checkHostUpdate = vi.fn(async () => ({ + currentVersion: "1.1", + status: { type: "update-not-available" }, + })); + useRemoteServersStore + .getState() + .setClientFactory( + factoryFor(makeClient({ environment, installHostUpdate, checkHostUpdate })), + ); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + + expect(installHostUpdate).toHaveBeenCalledOnce(); + expect(useRemoteServersStore.getState().runtime.d1?.status).toBe("connecting"); + expect(useRemoteServersStore.getState().hostUpdates.d1).toBeUndefined(); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBe("1.1"); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(useRemoteServersStore.getState().runtime.d1?.status).toBe("online"); + expect(useRemoteServersStore.getState().servers[0]?.appVersion).toBe("1.1"); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + expect(checkHostUpdate).toHaveBeenCalledOnce(); + expect(useRemoteServersStore.getState().hostUpdates.d1).toEqual({ + currentVersion: "1.1", + status: { type: "update-not-available" }, + }); + }); + + it("ignores a stale host update response after installation starts", async () => { + const stale = deferred>>(); + const hostUpdateState = vi.fn(() => stale.promise); + const checkHostUpdate = vi.fn(async () => ({ + currentVersion: "1.1", + status: { type: "update-not-available" }, + })); + const client = makeClient({ + hostUpdateState, + checkHostUpdate, + installHostUpdate: async () => {}, + environment: async () => makeEnvironment("1.1"), + }); + useRemoteServersStore.getState().setClientFactory(factoryFor(client)); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + const staleRequest = useRemoteServersStore.getState().getHostUpdateState("d1"); + await useRemoteServersStore.getState().installHostUpdate("d1"); + await vi.waitFor(() => { + expect(useRemoteServersStore.getState().hostUpdates.d1).toEqual({ + currentVersion: "1.1", + status: { type: "update-not-available" }, + }); + }); + + stale.resolve({ + currentVersion: "1.0", + status: { type: "downloaded", version: "1.1" }, + }); + await staleRequest; + + expect(useRemoteServersStore.getState().hostUpdates.d1).toEqual({ + currentVersion: "1.1", + status: { type: "update-not-available" }, + }); + }); + + it("stops waiting when the updated host does not reconnect in time", async () => { + vi.useFakeTimers(); + const environment = vi.fn(() => new Promise(() => {})); + useRemoteServersStore + .getState() + .setClientFactory(factoryFor(makeClient({ environment, installHostUpdate: async () => {} }))); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + await useRemoteServersStore.getState().connectAll(); + await useRemoteServersStore.getState().reconnectServer("d1"); + await vi.advanceTimersByTimeAsync(60_000); + + expect(environment).toHaveBeenCalledTimes(1); + expect(useRemoteServersStore.getState().runtime.d1).toMatchObject({ + status: "offline", + message: "Can't reach the remote server. Check that it is online, then reconnect it.", + }); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + }); + + it("bounds the connection refresh after the updated version appears", async () => { + vi.useFakeTimers(); + const environment = vi + .fn() + .mockResolvedValueOnce(makeEnvironment("1.1")) + .mockImplementation(() => new Promise(() => {})); + useRemoteServersStore + .getState() + .setClientFactory(factoryFor(makeClient({ environment, installHostUpdate: async () => {} }))); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + await vi.advanceTimersByTimeAsync(60_000); + + expect(environment).toHaveBeenCalledTimes(2); + expect(useRemoteServersStore.getState().runtime.d1?.status).toBe("offline"); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + }); + + it("ignores an update response from a removed server after it is paired again", async () => { + const stale = deferred>>(); + const hostUpdateState = vi + .fn() + .mockImplementationOnce(() => stale.promise) + .mockResolvedValue({ + currentVersion: "2.0", + status: { type: "update-not-available" }, + }); + useRemoteServersStore.getState().setClientFactory(factoryFor(makeClient({ hostUpdateState }))); + const server: RemoteServerRecord = { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }; + useRemoteServersStore.setState({ + servers: [server], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + }); + + const staleRequest = useRemoteServersStore.getState().getHostUpdateState("d1"); + useRemoteServersStore.getState().removeServer("d1"); + useRemoteServersStore.setState({ + servers: [server], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + }); + await useRemoteServersStore.getState().getHostUpdateState("d1"); + + stale.resolve({ + currentVersion: "1.0", + status: { type: "downloaded", version: "1.1" }, + }); + await staleRequest; + + expect(useRemoteServersStore.getState().hostUpdates.d1).toEqual({ + currentVersion: "2.0", + status: { type: "update-not-available" }, + }); + }); + + it("ignores an update reconnect snapshot after the server is removed and paired again", async () => { + const staleSnapshot = deferred(); + const snapshot = vi.fn(() => staleSnapshot.promise); + const environment = vi.fn(async () => + makeEnvironment("1.1"), + ); + useRemoteServersStore + .getState() + .setClientFactory( + factoryFor(makeClient({ environment, snapshot, installHostUpdate: async () => {} })), + ); + const server: RemoteServerRecord = { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }; + useRemoteServersStore.setState({ + servers: [server], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledOnce()); + + useRemoteServersStore.getState().removeServer("d1"); + useRemoteServersStore.setState({ + servers: [{ ...server, appVersion: "2.0" }], + runtime: { d1: { status: "online", projects: [proj2], threads: [] } }, + }); + staleSnapshot.resolve({ + snapshotSeq: 99, + projects: [proj], + threads: [remoteThread], + runtimeSummariesByThread: {}, + updatedAt: "stale", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(useRemoteServersStore.getState().runtime.d1).toMatchObject({ + status: "online", + projects: [proj2], + threads: [], + }); + expect(useRemoteServersStore.getState().servers[0]?.appVersion).toBe("2.0"); + }); + + it("ignores an update reconnect error after the server is removed and paired again", async () => { + const pendingEnvironment = deferred>>(); + const environment = vi.fn(() => pendingEnvironment.promise); + useRemoteServersStore + .getState() + .setClientFactory(factoryFor(makeClient({ environment, installHostUpdate: async () => {} }))); + const server: RemoteServerRecord = { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }; + useRemoteServersStore.setState({ + servers: [server], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + await vi.waitFor(() => expect(environment).toHaveBeenCalledOnce()); + + useRemoteServersStore.getState().removeServer("d1"); + useRemoteServersStore.setState({ + servers: [{ ...server, appVersion: "2.0" }], + runtime: { d1: { status: "online", projects: [proj2], threads: [] } }, + }); + pendingEnvironment.reject( + new RemoteClientError( + "This app version is incompatible with that server.", + 426, + "protocol_version_mismatch", + ), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(useRemoteServersStore.getState().runtime.d1).toMatchObject({ + status: "online", + projects: [proj2], + }); + expect(useRemoteServersStore.getState().runtime.d1?.message).toBeUndefined(); + }); + + it("reports a protocol mismatch immediately after the host update", async () => { + const mismatch = new RemoteClientError( + "This app version is incompatible with that server.", + 426, + "protocol_version_mismatch", + ); + useRemoteServersStore.getState().setClientFactory( + factoryFor( + makeClient({ + environment: async () => { + throw mismatch; + }, + installHostUpdate: async () => {}, + }), + ), + ); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + + await vi.waitFor(() => { + expect(useRemoteServersStore.getState().runtime.d1).toMatchObject({ + status: "error", + message: "This app version is incompatible with that server.", + }); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + }); + }); + + it("clears the restart watch when the server is paired again mid-restart", async () => { + vi.useFakeTimers(); + useRemoteServersStore + .getState() + .setClientFactory(factoryFor(makeClient({ installHostUpdate: async () => {} }))); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: { + d1: { currentVersion: "1.0", status: { type: "downloaded", version: "1.1" } }, + }, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBe("1.1"); + + await useRemoteServersStore.getState().pairServer({ + endpoint: "192.168.1.9:38987", + token: "lc_pair_x", + }); + await vi.advanceTimersByTimeAsync(1_000); + + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + expect(useRemoteServersStore.getState().runtime.d1?.status).toBe("online"); + }); + + it("installs without tracking a restart when no downloaded update is recorded", async () => { + const installHostUpdate = vi.fn(async () => {}); + useRemoteServersStore + .getState() + .setClientFactory(factoryFor(makeClient({ installHostUpdate }))); + useRemoteServersStore.setState({ + servers: [ + { + desktopId: "d1", + label: "Server One", + endpoint: "http://192.168.1.9:38987/", + accessToken: "acc-token", + scopes: ["session:read", "projects:manage"], + appVersion: "1.0", + hostMode: "desktop", + }, + ], + runtime: { d1: { status: "online", projects: [proj], threads: [] } }, + hostUpdates: {}, + }); + + await useRemoteServersStore.getState().installHostUpdate("d1"); + + expect(installHostUpdate).toHaveBeenCalledOnce(); + expect(useRemoteServersStore.getState().hostUpdateRestarts.d1).toBeUndefined(); + }); + it("pairs a server and stores its snapshot online", async () => { useRemoteServersStore.getState().setClientFactory(factoryFor(makeClient())); const record = await useRemoteServersStore diff --git a/src/renderer/state/remoteServersStore.ts b/src/renderer/state/remoteServersStore.ts index 14e179df6..114154a57 100644 --- a/src/renderer/state/remoteServersStore.ts +++ b/src/renderer/state/remoteServersStore.ts @@ -54,6 +54,7 @@ import { shouldRefreshRemoteServerAfterEvent, } from "@/renderer/state/remoteServers/eventRouting"; import { syncRemoteGitSummaries } from "@/renderer/state/remoteServers/gitSummaries"; +import { waitForHostUpdateReconnect } from "@/renderer/state/remoteServers/hostUpdateReconnect"; import { mainProcessFetch } from "@/renderer/state/remoteServers/mainProcessFetch"; import { persistedRemoteServersState, @@ -358,6 +359,14 @@ const REMOTE_SERVER_REFRESH_DEBOUNCE_MS = 600; const remoteServerRefreshTimers = new Map>(); const remoteServerRefreshSeqByDesktopId = new Map(); const remoteServerAgentStatusRefreshes = new Set(); +const remoteHostUpdateReconnectSeqByDesktopId = new Map(); +const remoteHostUpdateRequestSeqByDesktopId = new Map(); +let remoteHostUpdateSequence = 0; + +function nextRemoteHostUpdateSequence(): number { + remoteHostUpdateSequence += 1; + return remoteHostUpdateSequence; +} function clearRemoteServerRefreshTimer(desktopId: string): void { const timer = remoteServerRefreshTimers.get(desktopId); @@ -368,6 +377,14 @@ function clearRemoteServerRefreshTimer(desktopId: string): void { remoteServerAgentStatusRefreshes.delete(desktopId); } +function invalidateRemoteServerRefresh(desktopId: string): void { + clearRemoteServerRefreshTimer(desktopId); + remoteServerRefreshSeqByDesktopId.set( + desktopId, + (remoteServerRefreshSeqByDesktopId.get(desktopId) ?? 0) + 1, + ); +} + function normalizeEndpoint(raw: string): string { const trimmed = raw.trim(); const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; @@ -458,10 +475,13 @@ export const useRemoteServersStore = create()( const checkHostUpdateInBackground = (server: RemoteServerRecord): void => { if (server.hostMode === "helper" || !server.scopes.includes("projects:manage")) return; + const requestSeq = nextRemoteHostUpdateSequence(); + remoteHostUpdateRequestSeqByDesktopId.set(server.desktopId, requestSeq); void get() .clientFactory(server.endpoint, server.accessToken) .checkHostUpdate() .then((update) => { + if (remoteHostUpdateRequestSeqByDesktopId.get(server.desktopId) !== requestSeq) return; set((state) => ({ hostUpdates: { ...state.hostUpdates, [server.desktopId]: update }, })); @@ -802,13 +822,23 @@ export const useRemoteServersStore = create()( /** Restore a server's transport (SSH tunnel) when needed, then snapshot * it and (re)attach its event stream. Shared by connectAll and * reconnectServer so transport handling lives in one place. */ - const connectServer = async (persistedServer: RemoteServerRecord): Promise => { + const connectServer = async ( + persistedServer: RemoteServerRecord, + shouldContinue: () => boolean = () => true, + ): Promise => { + const reconnectGeneration = remoteHostUpdateReconnectSeqByDesktopId.get( + persistedServer.desktopId, + ); + const canContinue = () => + remoteHostUpdateReconnectSeqByDesktopId.get(persistedServer.desktopId) === + reconnectGeneration && shouldContinue(); let server = persistedServer; if (server.transport?.kind === "ssh") { try { const launched = await readBridge().sshConnect({ connection: server.transport.connection, }); + if (!canContinue()) return; server = { ...server, endpoint: normalizeEndpoint(launched.endpoint) }; const updated = server; set((state) => ({ @@ -817,6 +847,7 @@ export const useRemoteServersStore = create()( ), })); } catch (error) { + if (!canContinue()) return; const message = friendlyError(error) || i18n._(msg`SSH connection failed.`); toast.danger(message); setRemoteServerFailure(server.desktopId, "offline", message); @@ -827,6 +858,7 @@ export const useRemoteServersStore = create()( const environment = await get() .clientFactory(server.endpoint, server.accessToken) .environment(); + if (!canContinue()) return; const keepsLocalAlias = server.remoteLabel !== undefined && server.label !== server.remoteLabel; server = { @@ -843,6 +875,7 @@ export const useRemoteServersStore = create()( ), })); } catch (error) { + if (!canContinue()) return; if (error instanceof RemoteClientError && error.code === "protocol_version_mismatch") { setRemoteServerFailure(server.desktopId, "error", friendlyError(error)); return; @@ -850,10 +883,84 @@ export const useRemoteServersStore = create()( // refreshServer below owns other visible connection errors. } await get().refreshServer(server.desktopId); + if (!canContinue()) return; startRemoteServerEventStream(server); checkHostUpdateInBackground(server); }; + const reconnectAfterHostUpdate = async ( + persistedServer: RemoteServerRecord, + expectedVersion: string, + reconnectSeq: number, + ): Promise => { + const isCurrent = () => + remoteHostUpdateReconnectSeqByDesktopId.get(persistedServer.desktopId) === reconnectSeq && + get().servers.some((server) => server.desktopId === persistedServer.desktopId); + const outcome = await waitForHostUpdateReconnect({ + isCurrent, + isTerminalError: (error) => + error instanceof RemoteClientError && error.code === "protocol_version_mismatch", + attempt: async () => { + const environment = await get() + .clientFactory(persistedServer.endpoint, persistedServer.accessToken) + .environment(); + if (environment.appVersion !== expectedVersion) return false; + const current = get().servers.find( + (server) => server.desktopId === persistedServer.desktopId, + ); + if (!current || !isCurrent()) return false; + await connectServer(current, () => isCurrent()); + if ( + isCurrent() && + get().runtime[persistedServer.desktopId]?.status === "online" && + get().servers.find((server) => server.desktopId === persistedServer.desktopId) + ?.appVersion === expectedVersion + ) { + return true; + } + closeRemoteServerEventSocket(persistedServer.desktopId); + const latest = get().servers.find( + (server) => server.desktopId === persistedServer.desktopId, + ); + if (latest && isCurrent()) setServersConnecting([latest]); + return false; + }, + }); + if (outcome.type === "cancelled" || !isCurrent()) { + set((state) => { + const { [persistedServer.desktopId]: _stale, ...hostUpdateRestarts } = + state.hostUpdateRestarts; + return { hostUpdateRestarts }; + }); + return; + } + remoteHostUpdateReconnectSeqByDesktopId.set( + persistedServer.desktopId, + nextRemoteHostUpdateSequence(), + ); + if (outcome.type === "connected") { + set((state) => { + const { [persistedServer.desktopId]: _finished, ...hostUpdateRestarts } = + state.hostUpdateRestarts; + return { hostUpdateRestarts }; + }); + return; + } + invalidateRemoteServerRefresh(persistedServer.desktopId); + closeRemoteServerEventSocket(persistedServer.desktopId); + const status = outcome.type === "terminal-error" ? "error" : "offline"; + const message = + outcome.type === "terminal-error" + ? friendlyError(outcome.error) + : sharedMsg("remote.server.unreachable"); + setRemoteServerFailure(persistedServer.desktopId, status, message); + set((state) => { + const { [persistedServer.desktopId]: _finished, ...hostUpdateRestarts } = + state.hostUpdateRestarts; + return { hostUpdateRestarts }; + }); + }; + const pairAtEndpoint = async (input: { endpoint: string; token: string; @@ -883,6 +990,10 @@ export const useRemoteServersStore = create()( ...(environment.hostMode ? { hostMode: environment.hostMode } : {}), transport: input.transport, }; + remoteHostUpdateReconnectSeqByDesktopId.set( + record.desktopId, + nextRemoteHostUpdateSequence(), + ); set((state) => ({ servers: [...state.servers.filter((s) => s.desktopId !== record.desktopId), record], lastKnownProjects: replaceCachedProjects( @@ -915,6 +1026,7 @@ export const useRemoteServersStore = create()( servers: [], runtime: {}, hostUpdates: {}, + hostUpdateRestarts: {}, excludedProjectIds: {}, projectWorkspaceIds: {}, projectNameOverrides: {}, @@ -1096,6 +1208,9 @@ export const useRemoteServersStore = create()( removeServer: (desktopId) => { const removed = get().servers.find((server) => server.desktopId === desktopId); + remoteHostUpdateReconnectSeqByDesktopId.set(desktopId, nextRemoteHostUpdateSequence()); + remoteHostUpdateRequestSeqByDesktopId.delete(desktopId); + invalidateRemoteServerRefresh(desktopId); closeRemoteServerEventSocket(desktopId); // If the open live-chat thread belongs to this server, tear it (and its // socket) down first so it isn't left orphaned with no way to interact. @@ -1105,10 +1220,13 @@ export const useRemoteServersStore = create()( set((state) => { const { [desktopId]: _removed, ...runtime } = state.runtime; const { [desktopId]: _removedUpdate, ...hostUpdates } = state.hostUpdates; + const { [desktopId]: _removedRestart, ...hostUpdateRestarts } = + state.hostUpdateRestarts; return { servers: state.servers.filter((server) => server.desktopId !== desktopId), runtime, hostUpdates, + hostUpdateRestarts, lastKnownProjects: removeCachedProjects(state.lastKnownProjects, desktopId), }; }); @@ -1269,7 +1387,11 @@ export const useRemoteServersStore = create()( if (connectAllInFlight) return connectAllInFlight; const servers = get().servers; setServersConnecting(servers); - connectAllInFlight = Promise.all(servers.map(connectServer)) + connectAllInFlight = Promise.all( + servers + .filter((server) => get().hostUpdateRestarts[server.desktopId] === undefined) + .map((server) => connectServer(server)), + ) .then(() => undefined) .finally(() => { connectAllInFlight = null; @@ -1278,6 +1400,7 @@ export const useRemoteServersStore = create()( }, reconnectServer: async (desktopId) => { + if (get().hostUpdateRestarts[desktopId] !== undefined) return; const server = get().servers.find((entry) => entry.desktopId === desktopId); if (!server) return; setServersConnecting([server]); @@ -1285,19 +1408,52 @@ export const useRemoteServersStore = create()( }, getHostUpdateState: async (desktopId) => { + const requestSeq = nextRemoteHostUpdateSequence(); + remoteHostUpdateRequestSeqByDesktopId.set(desktopId, requestSeq); const update = await withClient(desktopId, (client) => client.hostUpdateState()); + if (remoteHostUpdateRequestSeqByDesktopId.get(desktopId) !== requestSeq) return update; set((state) => ({ hostUpdates: { ...state.hostUpdates, [desktopId]: update } })); return update; }, checkHostUpdate: async (desktopId) => { + const requestSeq = nextRemoteHostUpdateSequence(); + remoteHostUpdateRequestSeqByDesktopId.set(desktopId, requestSeq); const update = await withClient(desktopId, (client) => client.checkHostUpdate()); + if (remoteHostUpdateRequestSeqByDesktopId.get(desktopId) !== requestSeq) return update; set((state) => ({ hostUpdates: { ...state.hostUpdates, [desktopId]: update } })); return update; }, - installHostUpdate: (desktopId) => - withClient(desktopId, (client) => client.installHostUpdate()), + installHostUpdate: async (desktopId) => { + if (get().hostUpdateRestarts[desktopId] !== undefined) return; + const server = get().servers.find((entry) => entry.desktopId === desktopId); + const status = get().hostUpdates[desktopId]?.status; + if (!server || status?.type !== "downloaded") { + await withClient(desktopId, (client) => client.installHostUpdate()); + return; + } + + const reconnectGeneration = remoteHostUpdateReconnectSeqByDesktopId.get(desktopId); + await withClient(desktopId, (client) => client.installHostUpdate()); + if (remoteHostUpdateReconnectSeqByDesktopId.get(desktopId) !== reconnectGeneration) { + return; + } + remoteHostUpdateRequestSeqByDesktopId.set(desktopId, nextRemoteHostUpdateSequence()); + const reconnectSeq = nextRemoteHostUpdateSequence(); + remoteHostUpdateReconnectSeqByDesktopId.set(desktopId, reconnectSeq); + invalidateRemoteServerRefresh(desktopId); + closeRemoteServerEventSocket(desktopId); + set((state) => { + const { [desktopId]: _installed, ...hostUpdates } = state.hostUpdates; + return { + hostUpdates, + hostUpdateRestarts: { ...state.hostUpdateRestarts, [desktopId]: status.version }, + }; + }); + setServersConnecting([server]); + void reconnectAfterHostUpdate(server, status.version, reconnectSeq); + }, setProjectNameOverride: (desktopId, remoteId, name) => { set((state) => ({ @@ -1518,6 +1674,8 @@ export function __resetRemoteServersStoreForTest(): void { remoteServerSnapshotSeqByDesktopId.clear(); remoteServerRefreshSeqByDesktopId.clear(); remoteServerAgentStatusRefreshes.clear(); + remoteHostUpdateReconnectSeqByDesktopId.clear(); + remoteHostUpdateRequestSeqByDesktopId.clear(); clearRemoteGitState(); resetRemoteProcedureRouterForTest(); connectAllInFlight = null; @@ -1527,5 +1685,5 @@ export function __resetRemoteServersStoreForTest(): void { projects: state.projects.filter((project) => !project.remoteServerId), threads: state.threads.filter((thread) => !thread.remoteServerId), })); - useRemoteServersStore.setState({ openThread: null, hostUpdates: {} }); + useRemoteServersStore.setState({ openThread: null, hostUpdates: {}, hostUpdateRestarts: {} }); } diff --git a/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.test.tsx b/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.test.tsx new file mode 100644 index 000000000..36027b97a --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.test.tsx @@ -0,0 +1,39 @@ +import { screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useRemoteServersStore } from "@/renderer/state/remoteServersStore"; +import { renderWithI18n as render } from "@/renderer/testUtils/i18n"; +import type { RemoteServerRecord } from "@/renderer/state/remoteServers/types"; +import { RemoteHostUpdateControl } from "./RemoteHostUpdateControl"; + +const server: RemoteServerRecord = { + desktopId: "desktop-1", + label: "Remote Mac", + endpoint: "http://remote/", + accessToken: "token", + scopes: ["projects:manage"], + appVersion: "1.0.0", + hostMode: "desktop", +}; + +describe("RemoteHostUpdateControl", () => { + beforeEach(() => { + useRemoteServersStore.setState({ + hostUpdates: {}, + hostUpdateRestarts: {}, + }); + }); + + it("shows a persistent restart status instead of update actions", () => { + useRemoteServersStore.setState({ + hostUpdateRestarts: { "desktop-1": "1.1.0" }, + }); + + render(); + + expect(screen.getByRole("status")).toHaveTextContent( + "The host is restarting to install the update.", + ); + expect(screen.queryByRole("button", { name: /check for update/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /install/i })).not.toBeInTheDocument(); + }); +}); diff --git a/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.tsx b/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.tsx index 11de9fcd6..29a3c4bcb 100644 --- a/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.tsx +++ b/src/renderer/views/SettingsOverlay/parts/RemoteHostUpdateControl.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Button, toast } from "@heroui/react"; +import { Button, Spinner, toast } from "@heroui/react"; import { Trans, useLingui } from "@lingui/react/macro"; import { RefreshCw } from "lucide-react"; import { useAsyncOperation } from "@/renderer/hooks/useAsyncOperation"; @@ -18,6 +18,9 @@ export function RemoteHostUpdateControl({ const checkHostUpdate = useRemoteServersStore((s) => s.checkHostUpdate); const installHostUpdate = useRemoteServersStore((s) => s.installHostUpdate); const updateState = useRemoteServersStore((s) => s.hostUpdates[server.desktopId]); + const restarting = useRemoteServersStore( + (s) => s.hostUpdateRestarts[server.desktopId] !== undefined, + ); const [checked, setChecked] = useState(false); const { busy, error, run } = useAsyncOperation(); const updateStatus = updateState?.status; @@ -60,7 +63,12 @@ export function RemoteHostUpdateControl({ Host version: {currentVersion} ) : null} - {updateStatus?.type === "downloaded" ? ( + {restarting ? ( + + + The host is restarting to install the update. + + ) : updateStatus?.type === "downloaded" ? (