Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-desktops-recover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coder-studio/desktop": patch
---

Recover authentication after Electron Network Service restarts without bypassing WebSocket reconnect backoff, and auto-hide the native application menu bar.
5 changes: 5 additions & 0 deletions .changeset/clear-updates-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@spencer-kit/coder-studio": patch
---

Improve product update version and diagnostics UI, and let Desktop web clients request authentication recovery while reconnecting.
139 changes: 139 additions & 0 deletions packages/desktop/src/desktop-auth-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import {
DesktopAuthRecoveryCoordinator,
isDesktopNetworkService,
} from "./desktop-auth-recovery.js";

describe("Desktop authentication recovery", () => {
it("recognizes Electron Network Service exits without matching unrelated utility processes", () => {
expect(
isDesktopNetworkService({
type: "Utility",
serviceName: "network.mojom.NetworkService",
})
).toBe(true);
expect(isDesktopNetworkService({ type: "Utility", name: "Network Service" })).toBe(true);
expect(isDesktopNetworkService({ type: "Utility", name: "Audio Service" })).toBe(false);
expect(isDesktopNetworkService({ type: "GPU", name: "Network Service" })).toBe(false);
});

it("coalesces concurrent recovery requests and notifies once", async () => {
let finishAuthentication: ((result: "recovered") => void) | undefined;
const authenticate = vi.fn(
() =>
new Promise<"recovered">((resolve) => {
finishAuthentication = resolve;
})
);
const onRecovered = vi.fn();
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => true,
authenticate,
onRecovered,
retryDelaysMs: [],
});

const first = coordinator.recover();
const second = coordinator.recover();
expect(first).toBe(second);
expect(authenticate).toHaveBeenCalledTimes(1);

finishAuthentication?.("recovered");
await expect(first).resolves.toBe(true);
expect(onRecovered).toHaveBeenCalledTimes(1);
});

it("retries transient authentication failures before notifying the renderer", async () => {
const authenticate = vi
.fn<() => Promise<"recovered">>()
.mockRejectedValueOnce(new Error("network service restarting"))
.mockResolvedValueOnce("recovered");
const onRecovered = vi.fn();
const onAttemptFailure = vi.fn();
const wait = vi.fn(async () => undefined);
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => true,
authenticate,
onRecovered,
onAttemptFailure,
retryDelaysMs: [250],
wait,
});

await expect(coordinator.recover()).resolves.toBe(true);
expect(authenticate).toHaveBeenCalledTimes(2);
expect(wait).toHaveBeenCalledWith(250);
expect(onAttemptFailure).toHaveBeenCalledWith(expect.any(Error), 1, true);
expect(onRecovered).toHaveBeenCalledTimes(1);
});

it("keeps the normal WebSocket backoff when authentication is already valid", async () => {
const onRecovered = vi.fn();
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => true,
authenticate: vi.fn(async () => "already_authenticated" as const),
onRecovered,
retryDelaysMs: [],
});

await expect(coordinator.recover()).resolves.toBe(true);
expect(onRecovered).not.toHaveBeenCalled();
});

it("notifies after a Network Service restart even when authentication remains valid", async () => {
const onRecovered = vi.fn();
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => true,
authenticate: vi.fn(async () => "already_authenticated" as const),
onRecovered,
retryDelaysMs: [],
});

await expect(coordinator.recover({ notifyWhenAlreadyAuthenticated: true })).resolves.toBe(true);
expect(onRecovered).toHaveBeenCalledTimes(1);
});

it("preserves a Network Service notification request while recovery is in flight", async () => {
let finishAuthentication: ((result: "already_authenticated") => void) | undefined;
const onRecovered = vi.fn();
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => true,
authenticate: vi.fn(
() =>
new Promise<"already_authenticated">((resolve) => {
finishAuthentication = resolve;
})
),
onRecovered,
retryDelaysMs: [],
});

const reconnectRecovery = coordinator.recover();
const networkServiceRecovery = coordinator.recover({ notifyWhenAlreadyAuthenticated: true });
expect(reconnectRecovery).toBe(networkServiceRecovery);

finishAuthentication?.("already_authenticated");
await expect(reconnectRecovery).resolves.toBe(true);
expect(onRecovered).toHaveBeenCalledTimes(1);
});

it("stops retrying when the Desktop environment begins shutting down", async () => {
let recoverable = true;
const authenticate = vi.fn(async (): Promise<"recovered"> => {
recoverable = false;
throw new Error("shutting down");
});
const onRecovered = vi.fn();
const coordinator = new DesktopAuthRecoveryCoordinator({
canRecover: () => recoverable,
authenticate,
onRecovered,
retryDelaysMs: [250, 1_000],
wait: vi.fn(async () => undefined),
});

await expect(coordinator.recover()).resolves.toBe(false);
expect(authenticate).toHaveBeenCalledTimes(1);
expect(onRecovered).not.toHaveBeenCalled();
});
});
83 changes: 83 additions & 0 deletions packages/desktop/src/desktop-auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export interface DesktopChildProcessGoneDetails {
type: string;
name?: string;
serviceName?: string;
}

export function isDesktopNetworkService(details: DesktopChildProcessGoneDetails): boolean {
if (details.type !== "Utility") return false;
return (
details.name === "Network Service" || details.serviceName === "network.mojom.NetworkService"
);
}

export interface DesktopAuthRecoveryOptions {
canRecover(): boolean;
authenticate(): Promise<"already_authenticated" | "recovered">;
onRecovered(): void;
onAttemptFailure?(error: unknown, attempt: number, willRetry: boolean): void;
retryDelaysMs?: readonly number[];
wait?(delayMs: number): Promise<void>;
}

export interface DesktopAuthRecoveryRequest {
notifyWhenAlreadyAuthenticated?: boolean;
}

const DEFAULT_RETRY_DELAYS_MS = [250, 1_000, 3_000] as const;

const waitForDelay = (delayMs: number) =>
new Promise<void>((resolve) => setTimeout(resolve, delayMs));

export class DesktopAuthRecoveryCoordinator {
private inFlight: Promise<boolean> | null = null;
private notifyWhenAlreadyAuthenticated = false;

constructor(private readonly options: DesktopAuthRecoveryOptions) {}

recover(request: DesktopAuthRecoveryRequest = {}): Promise<boolean> {
if (request.notifyWhenAlreadyAuthenticated) {
this.notifyWhenAlreadyAuthenticated = true;
}
if (this.inFlight) return this.inFlight;
if (!this.options.canRecover()) {
this.notifyWhenAlreadyAuthenticated = false;
return Promise.resolve(false);
}

const recovery = this.run().finally(() => {
if (this.inFlight === recovery) {
this.inFlight = null;
this.notifyWhenAlreadyAuthenticated = false;
}
});
this.inFlight = recovery;
return recovery;
}

private async run(): Promise<boolean> {
const retryDelays = this.options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
const wait = this.options.wait ?? waitForDelay;

for (let attemptIndex = 0; attemptIndex <= retryDelays.length; attemptIndex += 1) {
if (!this.options.canRecover()) return false;
if (attemptIndex > 0) {
await wait(retryDelays[attemptIndex - 1] as number);
if (!this.options.canRecover()) return false;
}

try {
const result = await this.options.authenticate();
if (!this.options.canRecover()) return false;
if (result === "recovered" || this.notifyWhenAlreadyAuthenticated) {
this.options.onRecovered();
}
return true;
} catch (error) {
this.options.onAttemptFailure?.(error, attemptIndex + 1, attemptIndex < retryDelays.length);
}
}

return false;
}
}
50 changes: 50 additions & 0 deletions packages/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
import { autoUpdater, CancellationToken } from "electron-updater";
import { BackendManager } from "./backend-manager.js";
import { readDesktopBuildInfo } from "./build-info.js";
import {
DesktopAuthRecoveryCoordinator,
isDesktopNetworkService,
} from "./desktop-auth-recovery.js";
import {
parseDesktopChannel,
resolveDesktopChannelUrl,
Expand Down Expand Up @@ -93,6 +97,45 @@ let shutdownComplete = false;
let shutdownStarted = false;
let shutdownActivationPromise: Promise<void> = Promise.resolve();
const smokeResultPath = process.env.CODER_STUDIO_DESKTOP_SMOKE_RESULT?.trim() || null;
const desktopAuthRecovery = new DesktopAuthRecoveryCoordinator({
canRecover: () =>
!shutdownStarted &&
backendManager?.getStatus()?.source === "managed" &&
activeSession !== null &&
activeGatewayUrl !== null,
authenticate: async () => {
const manager = backendManager;
const browserSession = activeSession;
const gatewayUrl = activeGatewayUrl;
if (!manager || !browserSession || !gatewayUrl) {
throw new Error("Desktop authentication recovery is not ready");
}

try {
const response = await browserSession.fetch(`${gatewayUrl}/auth/status`);
if (response.ok) {
const status = (await response.json()) as { authenticated?: unknown };
if (status.authenticated === true) return "already_authenticated";
}
} catch {
// A newly relaunched Network Service may fail its first request. The login below
// and the coordinator retry schedule provide the recovery path.
}

await manager.authenticatePublicSession(browserSession, gatewayUrl);
return "recovered";
},
onRecovered: () => {
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) return;
mainWindow.webContents.send("desktop:authentication-recovered");
},
onAttemptFailure: (error, attempt, willRetry) => {
console.warn(
`[desktop-auth] Recovery attempt ${attempt} failed${willRetry ? "; retrying" : ""}`,
error
);
},
});
const environmentActivation = new EnvironmentActivationCoordinator({
focusWindow: () => {
if (shutdownStarted || !mainWindow) return false;
Expand Down Expand Up @@ -261,6 +304,7 @@ function registerIpcHandlers(rootUserDataDir: string): void {
typeof value === "string" ? openExternal(value) : false
);
ipcMain.handle("desktop:get-backend-status", () => backendManager?.getStatus() ?? null);
ipcMain.handle("desktop:recover-authentication", () => desktopAuthRecovery.recover());
ipcMain.handle("desktop:get-window-activity-state", () =>
readDesktopWindowActivityState(mainWindow)
);
Expand Down Expand Up @@ -441,6 +485,7 @@ function createMainWindow(url: string, browserSession = activeSession): BrowserW
height: 900,
minWidth: 960,
minHeight: 640,
autoHideMenuBar: process.platform !== "darwin",
show: false,
backgroundColor: "#111318",
webPreferences: {
Expand Down Expand Up @@ -868,6 +913,11 @@ if (!hasSingleInstanceLock) {
});
});

app.on("child-process-gone", (_event, details) => {
if (!isDesktopNetworkService(details)) return;
void desktopAuthRecovery.recover({ notifyWhenAlreadyAuthenticated: true });
});

app.whenReady().then(startApplication).catch(handleStartupFailure);
}

Expand Down
7 changes: 7 additions & 0 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const api: DesktopApi = {
openExternal: (url: string) => ipcRenderer.invoke("desktop:open-external", url),
getBackendStatus: () =>
ipcRenderer.invoke("desktop:get-backend-status") as Promise<DesktopBackendStatus | null>,
recoverAuthentication: () =>
ipcRenderer.invoke("desktop:recover-authentication") as Promise<boolean>,
onAuthenticationRecovered: (listener: () => void) => {
const handler = () => listener();
ipcRenderer.on("desktop:authentication-recovered", handler);
return () => ipcRenderer.removeListener("desktop:authentication-recovered", handler);
},
getWindowActivityState: () =>
ipcRenderer.invoke("desktop:get-window-activity-state") as Promise<DesktopWindowActivityState>,
onWindowActivityStateChanged: (listener: (state: DesktopWindowActivityState) => void) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface DesktopApi {
selectWorkspaceDirectory(): Promise<string | null>;
openExternal(url: string): Promise<boolean>;
getBackendStatus(): Promise<DesktopBackendStatus | null>;
recoverAuthentication(): Promise<boolean>;
onAuthenticationRecovered(listener: () => void): () => void;
getWindowActivityState(): Promise<DesktopWindowActivityState>;
onWindowActivityStateChanged(listener: (state: DesktopWindowActivityState) => void): () => void;
listEnvironments(): Promise<DesktopEnvironmentSummary[]>;
Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/app/providers.lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,41 @@ describe("AppProviders lifecycle recovery", () => {
});
});

it("asks Desktop to restore authentication and retries immediately after recovery", async () => {
let authenticationRecovered: (() => void) | undefined;
const recoverAuthentication = vi.fn(async () => true);
const unsubscribe = vi.fn();
Object.defineProperty(window, "coderStudioDesktop", {
configurable: true,
value: {
recoverAuthentication,
onAuthenticationRecovered: vi.fn((listener: () => void) => {
authenticationRecovered = listener;
return unsubscribe;
}),
} as unknown as CoderStudioDesktopApi,
});

const rendered = renderProviders();
await vi.waitFor(() => {
expect(wsState.client?.connect).toHaveBeenCalled();
});
wsState.client?.recoverConnection.mockClear();

act(() => {
wsState.client?.statusHandler?.("reconnecting");
});
await vi.waitFor(() => {
expect(recoverAuthentication).toHaveBeenCalledTimes(1);
});

act(() => authenticationRecovered?.());
expect(wsState.client?.recoverConnection).toHaveBeenCalledWith("manual_retry");

rendered.unmount();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});

it("probes and reconciles on visibility return instead of forcing replay semantics", async () => {
const probeConnection = vi.fn().mockResolvedValue({ ok: true });
const sendCommand = createWsSendCommandMock((op) => {
Expand Down
Loading
Loading