diff --git a/apps/web/src/managedDevPc.test.ts b/apps/web/src/managedDevPc.test.ts index 58b5888f028a..212745e1e8da 100644 --- a/apps/web/src/managedDevPc.test.ts +++ b/apps/web/src/managedDevPc.test.ts @@ -122,4 +122,57 @@ describe("managed DevPC WebSocket authorization", () => { "invalid WebSocket URL", ); }); + + it("reloads once when the managed browser session must be paired again", async () => { + vi.stubEnv("VITE_DEVPC_MANAGED", "1"); + vi.resetModules(); + const reload = vi.fn(); + const storage = new Map(); + vi.stubGlobal("window", { + location: { origin: "https://app.example.test", reload }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 401 })), + ); + + const { prepareManagedWebSocketUrl } = await import("./managedDevPc"); + await expect(prepareManagedWebSocketUrl("wss://app.example.test/ws")).rejects.toThrow( + "Refreshing the managed workspace connection.", + ); + expect(reload).toHaveBeenCalledOnce(); + expect(storage.size).toBe(1); + + await expect(prepareManagedWebSocketUrl("wss://app.example.test/ws")).rejects.toThrow( + "could not be authorized", + ); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("clears the managed session recovery cooldown after authorization succeeds", async () => { + vi.stubEnv("VITE_DEVPC_MANAGED", "1"); + vi.resetModules(); + const storage = new Map([["devpc-managed-session-recovery-at", String(Date.now())]]); + vi.stubGlobal("window", { + location: { origin: "https://app.example.test" }, + sessionStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ ticket: "gateway-ticket-that-is-long-enough" })), + ); + + const { prepareManagedWebSocketUrl } = await import("./managedDevPc"); + await prepareManagedWebSocketUrl("wss://app.example.test/ws"); + expect(storage.size).toBe(0); + }); }); diff --git a/apps/web/src/managedDevPc.ts b/apps/web/src/managedDevPc.ts index af9931ce95c8..0bc935737b23 100644 --- a/apps/web/src/managedDevPc.ts +++ b/apps/web/src/managedDevPc.ts @@ -15,6 +15,9 @@ export const isManagedDevPc = import.meta.env.VITE_DEVPC_MANAGED === "1"; const BOOTSTRAP_PATH = "/_devpc/bootstrap"; const WEBSOCKET_TICKET_PATH = "/_devpc/ws-ticket"; +const SESSION_RECOVERY_KEY = "devpc-managed-session-recovery-at"; +const SESSION_RECOVERY_COOLDOWN_MS = 30_000; +let sessionRecoveryReloadScheduled = false; function updateBootstrapMessage(message: string, failed = false): void { const root = document.getElementById("root"); @@ -50,6 +53,31 @@ function pairingHash(token: string): string { return new URLSearchParams([["token", token]]).toString(); } +function clearManagedSessionRecovery(): void { + try { + window.sessionStorage.removeItem(SESSION_RECOVERY_KEY); + } catch { + // Storage can be unavailable in privacy-restricted browser contexts. + } +} + +function scheduleManagedSessionRecovery(): boolean { + if (sessionRecoveryReloadScheduled) return false; + const now = Date.now(); + try { + const previous = Number(window.sessionStorage.getItem(SESSION_RECOVERY_KEY) ?? 0); + if (Number.isFinite(previous) && now - previous < SESSION_RECOVERY_COOLDOWN_MS) { + return false; + } + window.sessionStorage.setItem(SESSION_RECOVERY_KEY, String(now)); + } catch { + // The in-memory guard still prevents a reload loop within this document. + } + sessionRecoveryReloadScheduled = true; + window.location.reload(); + return true; +} + export async function prepareManagedDevPc(): Promise { if (!isManagedDevPc) return; @@ -104,8 +132,12 @@ export async function prepareManagedWebSocketUrl(socketUrl: string): Promise