diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 234374fcd..bebab954a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,10 +160,29 @@ jobs: - name: Run desktop tests run: npm run test --workspace=@getpaseo/desktop + - name: Build app dependencies for desktop E2E + if: matrix.os == 'ubuntu-latest' + run: npm run build:app-deps + - name: Install virtual display - if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' + if: matrix.os == 'ubuntu-latest' run: sudo apt-get update && sudo apt-get install -y xvfb xauth + - name: Run real Electron browser tab bridge E2E + if: matrix.os == 'ubuntu-latest' + run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop + env: + PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e + + - name: Upload browser tab bridge diagnostics + uses: actions/upload-artifact@v4 + if: failure() && matrix.os == 'ubuntu-latest' + with: + name: browser-tab-bridge-e2e + path: ${{ runner.temp }}/browser-tab-bridge-e2e + if-no-files-found: ignore + retention-days: 7 + - name: Build and smoke unpacked desktop app if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true' run: npm run build:desktop -- --publish never --linux --x64 --dir diff --git a/docs/architecture.md b/docs/architecture.md index 8ba52a3ca..5b0d8b89a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,7 +138,7 @@ Electron wrapper for macOS, Linux, and Windows. > **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys. > -> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. +> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after every `did-attach`, the renderer explicitly registers its browser id, workspace id, and current guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Registration is intentionally repeated because reparenting a retained `` can replace its guest without replacing the DOM element. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs. > > **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path. > diff --git a/docs/testing.md b/docs/testing.md index 98e41d635..06a73d34b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -124,6 +124,16 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \ npm run build:desktop -- --publish never --linux --x64 --dir ``` +### Browser tab bridge regression + +The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click. + +Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check: + +```bash +npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop +``` + ## Test organization - Collocate tests with implementation: `thing.ts` + `thing.test.ts` diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index a97190018..92577f7da 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -54,22 +54,20 @@ function registerBrowserWhenAttached( identity: BrowserWebviewIdentity, browser: BrowserWebviewProfileHost, ): void { - webview.addEventListener( - "did-attach", - () => { - const webContentsId = webview.getWebContentsId(); - void browser - .registerAttachedBrowser({ - browserId: identity.browserId, - workspaceId: identity.workspaceId, - webContentsId, - }) - .catch((error) => { - console.error("[browser-webview] attached registration failed", error); - }); - }, - { once: true }, - ); + // Reparenting a webview can replace its guest WebContents without replacing + // this DOM element, so every attachment needs a fresh main-process registration. + webview.addEventListener("did-attach", () => { + const webContentsId = webview.getWebContentsId(); + void browser + .registerAttachedBrowser({ + browserId: identity.browserId, + workspaceId: identity.workspaceId, + webContentsId, + }) + .catch((error) => { + console.error("[browser-webview] attached registration failed", error); + }); + }); } function trimNonEmpty(value: string | null | undefined): string | null { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 85e907a67..4173fae13 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,6 +20,7 @@ "capture-harness": "./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", + "test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", "test": "vitest run", "typecheck": "tsgo --noEmit -p tsconfig.json" diff --git a/packages/desktop/scripts/browser-tab-bridge.e2e.mjs b/packages/desktop/scripts/browser-tab-bridge.e2e.mjs new file mode 100644 index 000000000..139a186e6 --- /dev/null +++ b/packages/desktop/scripts/browser-tab-bridge.e2e.mjs @@ -0,0 +1,472 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { createServer } from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { experimental_createMCPClient } from "ai"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { chromium } from "playwright"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const desktopDir = path.resolve(scriptDir, ".."); +const rootDir = path.resolve(desktopDir, "../.."); +const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs"); +const workspaceIds = [ + "tab-bridge-original", + "tab-bridge-evict-one", + "tab-bridge-evict-two", + "tab-bridge-evict-three", +]; +const timeoutMs = 90_000; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function reservePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => { + if (error) return reject(error); + if (!address || typeof address === "string") { + return reject(new Error("Failed to reserve a local port")); + } + resolve(address.port); + }); + }); + }); +} + +async function waitForPort(port, label, processInfo) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + processInfo && + (processInfo.child.exitCode !== null || processInfo.child.signalCode !== null) + ) { + throw new Error( + `${label} process exited before opening its port; see ${processInfo.logPath}`, + ); + } + const connected = await new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(500); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("timeout", () => { + socket.destroy(); + resolve(false); + }); + socket.once("error", () => resolve(false)); + }); + if (connected) return; + await delay(200); + } + throw new Error(`Timed out waiting for ${label} on port ${port}`); +} + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function seedPaseoHome(paseoHome, listen, workspaceRoot) { + const timestamp = "2026-01-01T00:00:00.000Z"; + const projects = workspaceIds.map((workspaceId, index) => { + const cwd = path.join(workspaceRoot, `workspace-${index + 1}`); + fs.mkdirSync(cwd, { recursive: true }); + return { + projectId: `project-${workspaceId}`, + rootPath: cwd, + kind: "non_git", + displayName: `Tab bridge project ${index + 1}`, + customName: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + }; + }); + const workspaces = workspaceIds.map((workspaceId, index) => ({ + workspaceId, + projectId: projects[index].projectId, + cwd: projects[index].rootPath, + kind: "directory", + displayName: `Tab bridge workspace ${index + 1}`, + title: `Tab bridge workspace ${index + 1}`, + branch: null, + baseBranch: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + pinnedAt: null, + })); + + writeJson(path.join(paseoHome, "config.json"), { + version: 1, + daemon: { + listen, + relay: { enabled: false }, + mcp: { enabled: true, injectIntoAgents: false }, + browserTools: { enabled: true }, + cors: { allowedOrigins: ["*"] }, + }, + }); + writeJson(path.join(paseoHome, "projects", "projects.json"), projects); + writeJson(path.join(paseoHome, "projects", "workspaces.json"), workspaces); +} + +function spawnLogged(name, command, args, options, logDir) { + const logPath = path.join(logDir, `${name}.log`); + const log = fs.createWriteStream(logPath, { flags: "a" }); + const child = spawn(command, args, { + ...options, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + let openStreams = 2; + const closeLogStream = () => { + openStreams -= 1; + if (openStreams === 0) log.end(); + }; + child.stdout.pipe(log, { end: false }); + child.stderr.pipe(log, { end: false }); + child.stdout.once("end", closeLogStream); + child.stderr.once("end", closeLogStream); + return { child, logPath }; +} + +function stopProcess(child) { + if (!child?.pid || child.exitCode !== null) return; + try { + if (process.platform === "win32") child.kill("SIGTERM"); + else process.kill(-child.pid, "SIGTERM"); + } catch { + // The process may exit between the liveness check and signal delivery. + } +} + +async function waitForAppPage(browser, expoPort) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const context of browser.contexts()) { + for (const page of context.pages()) { + if (page.url().includes(`localhost:${expoPort}`)) return page; + } + } + await delay(250); + } + throw new Error("Timed out waiting for the real Electron app renderer"); +} + +async function waitForDesktopStatus(page) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const status = await page.evaluate(async () => { + if (typeof window.paseoDesktop?.invoke !== "function") return null; + return await window.paseoDesktop.invoke("desktop_daemon_status"); + }); + if (typeof status?.serverId === "string") return status; + } catch (error) { + // Metro may replace the renderer execution context during its initial load. + lastError = error; + } + await delay(250); + } + throw new Error( + `Timed out waiting for the Electron desktop bridge${lastError ? `: ${String(lastError)}` : ""}`, + ); +} + +async function startTargetPage() { + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + response.end(` + + Tab bridge target + + + + `); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert(address && typeof address !== "string", "Target page did not bind a TCP port"); + return { server, url: `http://127.0.0.1:${address.port}/` }; +} + +async function closeServer(server) { + await new Promise((resolve) => server.close(resolve)); +} + +function mcpPayload(result, command) { + const payload = result.structuredContent; + assert( + payload && typeof payload === "object", + `${command} returned no structured payload: ${JSON.stringify(result)}`, + ); + assert(payload.ok === true, `${command} failed: ${JSON.stringify(payload)}`); + return payload.result; +} + +async function callBrowserTool(client, name, args = {}) { + return mcpPayload(await client.callTool({ name, args }), name); +} + +async function createCallerAgent(daemonPort) { + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), + ); + const client = await experimental_createMCPClient({ transport }); + try { + const response = await client.callTool({ + name: "create_agent", + args: { + relationship: { kind: "detached" }, + workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + title: "Browser tab bridge E2E caller", + provider: "mock/ten-second-stream", + settings: { modeId: "load-test" }, + initialPrompt: "Remain available while the browser bridge regression runs.", + background: true, + }, + }); + const result = response.structuredContent; + assert( + result && typeof result === "object", + `create_agent returned no structured payload: ${JSON.stringify(response)}`, + ); + assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); + assert( + result.workspaceId === workspaceIds[0], + `MCP caller attached to unexpected workspace ${result.workspaceId}`, + ); + return result.agentId; + } finally { + await client.close(); + } +} + +async function readGuest(page, browserId) { + return await page.evaluate((id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + if (!(webview instanceof HTMLElement) || typeof webview.getWebContentsId !== "function") { + return null; + } + return { + webContentsId: webview.getWebContentsId(), + parentId: webview.parentElement?.id ?? null, + }; + }, browserId); +} + +async function runRegression({ page, client, serverId, targetUrl }) { + const originalWorkspaceId = workspaceIds[0]; + const originalWorkspaceRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${originalWorkspaceId}`, + ); + await originalWorkspaceRow.waitFor({ state: "visible", timeout: timeoutMs }); + await originalWorkspaceRow.click(); + + const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl }); + const browserId = created.browserId; + assert(typeof browserId === "string", "browser_new_tab returned no browserId"); + + const originalDeck = page.getByTestId(`workspace-deck-entry-${serverId}:${originalWorkspaceId}`); + await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); + await page.waitForFunction( + (id) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + }, + browserId, + { timeout: timeoutMs }, + ); + const firstGuest = await readGuest(page, browserId); + assert(firstGuest, "Original browser guest was not attached to its workspace pane"); + + for (const workspaceId of workspaceIds.slice(1)) { + await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click(); + await page + .getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`) + .waitFor({ state: "visible" }); + } + + await page.waitForFunction( + ({ id, previousWebContentsId }) => { + const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); + return ( + webview?.parentElement?.id === "paseo-browser-resident-webviews" && + typeof webview.getWebContentsId === "function" && + webview.getWebContentsId() !== previousWebContentsId + ); + }, + { id: browserId, previousWebContentsId: firstGuest.webContentsId }, + { timeout: timeoutMs }, + ); + const replacementGuest = await readGuest(page, browserId); + assert(replacementGuest, "Replacement browser guest was not parked after workspace eviction"); + + const listed = await callBrowserTool(client, "browser_list_tabs"); + assert( + listed.tabs.some((tab) => tab.browserId === browserId), + "browser_list_tabs lost the original tab after guest replacement", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/button "Bridge target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the target button: ${snapshot.snapshot}`); + + const clicked = await callBrowserTool(client, "browser_click", { browserId, ref }); + assert( + clicked.browserId === browserId && clicked.ref === ref, + "browser_click targeted another tab", + ); + await callBrowserTool(client, "browser_wait", { + browserId, + text: "Clicked", + timeoutMs: 5_000, + }); + + return { + browserId, + originalWebContentsId: firstGuest.webContentsId, + replacementWebContentsId: replacementGuest.webContentsId, + list: "passed", + snapshot: "passed", + click: "passed", + }; +} + +async function main() { + const artifactDir = + process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ?? + fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-")); + const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-")); + fs.mkdirSync(artifactDir, { recursive: true }); + const paseoHome = path.join(runtimeDir, "paseo-home"); + const userData = path.join(runtimeDir, "electron-user-data"); + const workspaceRoot = path.join(runtimeDir, "workspaces"); + fs.mkdirSync(paseoHome, { recursive: true }); + + const [daemonPort, expoPort, cdpPort] = await Promise.all([ + reservePort(), + reservePort(), + reservePort(), + ]); + const listen = `127.0.0.1:${daemonPort}`; + seedPaseoHome(paseoHome, listen, workspaceRoot); + const target = await startTargetPage(); + const children = []; + let browser = null; + let client = null; + + try { + const commonEnv = { + ...process.env, + PASEO_HOME: paseoHome, + PASEO_LISTEN: listen, + PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`, + PASEO_CORS_ORIGINS: "*", + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", + FORCE_COLOR: "0", + NO_COLOR: "1", + }; + const daemon = spawnLogged( + "daemon", + process.execPath, + ["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")], + { cwd: rootDir, env: { ...commonEnv, PASEO_NODE_ENV: "development" } }, + artifactDir, + ); + children.push(daemon.child); + await waitForPort(daemonPort, "daemon", daemon); + + const desktopArgs = [ + process.execPath, + devRunner, + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + ]; + const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift(); + const desktopCommandArgs = + process.platform === "linux" + ? ["-a", "--server-args=-screen 0 1280x800x24", ...desktopArgs] + : desktopArgs; + const desktop = spawnLogged( + "desktop", + desktopCommand, + desktopCommandArgs, + { + cwd: rootDir, + env: { + ...commonEnv, + EXPO_PORT: String(expoPort), + EXPO_DEV_URL: `http://localhost:${expoPort}`, + PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort), + PASEO_ELECTRON_USER_DATA_DIR: userData, + PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`, + }, + }, + artifactDir, + ); + children.push(desktop.child); + await waitForPort(cdpPort, "Electron CDP", desktop); + + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + const page = await waitForAppPage(browser, expoPort); + const status = await waitForDesktopStatus(page); + + const callerAgentId = await createCallerAgent(daemonPort); + const transport = new StreamableHTTPClientTransport( + new URL( + `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, + ), + ); + client = await experimental_createMCPClient({ transport }); + const report = await runRegression({ + page, + client, + serverId: status.serverId, + targetUrl: target.url, + }); + writeJson(path.join(artifactDir, "result.json"), report); + console.log( + `Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`, + ); + } catch (error) { + console.error(`Browser tab bridge E2E failed. Artifacts: ${artifactDir}`); + console.error(error); + throw error; + } finally { + await client?.close().catch(() => undefined); + await browser?.close().catch(() => undefined); + for (const child of children.toReversed()) stopProcess(child); + await closeServer(target.server); + await delay(1_000); + try { + fs.rmSync(runtimeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + console.warn(`Failed to remove isolated E2E state ${runtimeDir}`, error); + } + } +} + +await main(); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 51ca14130..f2b16f822 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -19,6 +19,7 @@ if (!Number.isInteger(expoPort) || expoPort <= 0) { } const expoDevUrl = process.env.EXPO_DEV_URL || `http://localhost:${expoPort}`; +const electronArgs = process.argv.slice(2); const colorEnv = { FORCE_COLOR: process.env.FORCE_COLOR || "1", npm_config_color: process.env.npm_config_color || "always", @@ -175,7 +176,7 @@ try { } if (!stopping) { - spawnChild("electron", electron, [desktopDir], { + spawnChild("electron", electron, [...electronArgs, desktopDir], { detached: true, env: { ...process.env, diff --git a/packages/desktop/src/features/browser-automation/actionability.ts b/packages/desktop/src/features/browser-automation/actionability.ts index b5e48db76..0d2039904 100644 --- a/packages/desktop/src/features/browser-automation/actionability.ts +++ b/packages/desktop/src/features/browser-automation/actionability.ts @@ -100,8 +100,11 @@ function buildActionabilityScript(input: { const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)}; const requiresEditable = ${JSON.stringify(input.editable)}; - const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + // Electron can suspend requestAnimationFrame while a guest is parked after + // workspace LRU eviction even though the guest remains CDP-controllable. + // Timed layout samples keep background browser automation live. + const waitForLayout = () => sleep(16); const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25; const sameRect = (a, b) => nearlyEqual(a.x, b.x) && @@ -174,9 +177,9 @@ function buildActionabilityScript(input: { } element.scrollIntoView?.({ block: 'center', inline: 'center' }); - await nextFrame(); + await waitForLayout(); const firstRect = element.getBoundingClientRect(); - await nextFrame(); + await waitForLayout(); const secondRect = element.getBoundingClientRect(); if (!sameRect(firstRect, secondRect)) { detail = 'moving'; diff --git a/packages/desktop/src/features/browser-keyboard/index.test.ts b/packages/desktop/src/features/browser-keyboard/index.test.ts index 695750d21..2c792266b 100644 --- a/packages/desktop/src/features/browser-keyboard/index.test.ts +++ b/packages/desktop/src/features/browser-keyboard/index.test.ts @@ -21,11 +21,11 @@ class FakeBrowserContents { public readonly sent: SentMessage[] = []; private destroyed = false; private readonly destroyedListeners: Array<() => void> = []; - private domReadyListener: (() => void) | null = null; + private readonly domReadyListeners: Array<() => void> = []; private finishLoadListener: (() => void) | null = null; - private inputListener: - | ((event: { preventDefault(): void }, input: Electron.Input) => void) - | null = null; + private readonly inputListeners: Array< + (event: { preventDefault(): void }, input: Electron.Input) => void + > = []; public constructor(private readonly webContentsId: number) {} @@ -64,13 +64,12 @@ class FakeBrowserContents { return; } if (event === "dom-ready") { - this.domReadyListener = listener as () => void; + this.domReadyListeners.push(listener as () => void); return; } - this.inputListener = listener as ( - event: { preventDefault(): void }, - input: Electron.Input, - ) => void; + this.inputListeners.push( + listener as (event: { preventDefault(): void }, input: Electron.Input) => void, + ); } public send(channel: string, payload: unknown): void { @@ -105,19 +104,23 @@ class FakeBrowserContents { } public domReady(): void { - this.domReadyListener?.(); + for (const listener of this.domReadyListeners) { + listener(); + } } public input(input: Electron.Input): boolean { let wasPrevented = false; - this.inputListener?.( - { - preventDefault: () => { - wasPrevented = true; + for (const listener of this.inputListeners) { + listener( + { + preventDefault: () => { + wasPrevented = true; + }, }, - }, - input, - ); + input, + ); + } return wasPrevented; } } @@ -196,6 +199,20 @@ describe("BrowserKeyboard", () => { ]); }); + test("handles a reserved shortcut once when the same guest attaches again", () => { + const { attach } = createBrowserKeyboard(); + const guest = new FakeBrowserContents(53); + const host = new FakeBrowserContents(54); + attach({ browserId: "browser-a", contents: guest, hostContents: host }); + attach({ browserId: "browser-a", contents: guest, hostContents: host }); + const command = process.platform === "darwin" ? { meta: true } : { control: true }; + + const wasPrevented = guest.input(electronInput({ ...command, code: "KeyR", key: "r" })); + + expect(wasPrevented).toBe(true); + expect(guest.reloads).toEqual(["reload"]); + }); + test("republishes the latest shortcut policy when the next guest document is ready", () => { const { attach, keyboard } = createBrowserKeyboard(); const guest = new FakeBrowserContents(61); diff --git a/packages/desktop/src/features/browser-keyboard/index.ts b/packages/desktop/src/features/browser-keyboard/index.ts index 8084ddb97..0d90f019f 100644 --- a/packages/desktop/src/features/browser-keyboard/index.ts +++ b/packages/desktop/src/features/browser-keyboard/index.ts @@ -87,6 +87,13 @@ export class BrowserKeyboard { if (!registration || registration.hostWebContentsId !== input.hostContents.id) { return; } + const attachedGuest = this.attachedGuestsByWebContentsId.get(webContentsId); + if ( + attachedGuest?.contents === input.contents && + attachedGuest.hostContents === input.hostContents + ) { + return; + } const guest: BrowserKeyboardGuest = input; this.attachedGuestsByWebContentsId.set(webContentsId, guest); diff --git a/packages/desktop/src/features/browser-webviews/registry.test.ts b/packages/desktop/src/features/browser-webviews/registry.test.ts index 93b8c353b..5ef7a6cf5 100644 --- a/packages/desktop/src/features/browser-webviews/registry.test.ts +++ b/packages/desktop/src/features/browser-webviews/registry.test.ts @@ -33,6 +33,32 @@ describe("PaseoBrowserWebviewRegistry", () => { expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a"); }); + it("keeps the active browser when the same guest registers again", () => { + const registry = new PaseoBrowserWebviewRegistry(); + + registry.registerWebContents({ + webContentsId: 1, + browserId: "browser-a", + hostWebContentsId: 101, + }); + registry.setWorkspaceActiveBrowser({ + hostWebContentsId: 101, + workspaceId: "workspace-a", + browserId: "browser-a", + }); + + registry.registerWebContents({ + webContentsId: 1, + browserId: "browser-a", + hostWebContentsId: 101, + }); + + expect(registry.getActiveBrowserIdForWorkspaceInHostWindow(101, "workspace-a")).toBe( + "browser-a", + ); + expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(1); + }); + it("ignores stale destroy events after a duplicate browserId moved", () => { const registry = new PaseoBrowserWebviewRegistry(); diff --git a/packages/desktop/src/features/browser-webviews/registry.ts b/packages/desktop/src/features/browser-webviews/registry.ts index 4e49fdf6e..4fb8381f3 100644 --- a/packages/desktop/src/features/browser-webviews/registry.ts +++ b/packages/desktop/src/features/browser-webviews/registry.ts @@ -21,6 +21,14 @@ export class PaseoBrowserWebviewRegistry { }): void { const hostBrowserKey = this.hostBrowserKey(input.hostWebContentsId, input.browserId); const replacedWebContentsId = this.webContentsIdsByHostAndBrowserId.get(hostBrowserKey); + const existingRegistration = this.registrationsByWebContentsId.get(input.webContentsId); + if ( + replacedWebContentsId === input.webContentsId && + existingRegistration?.browserId === input.browserId && + existingRegistration.hostWebContentsId === input.hostWebContentsId + ) { + return; + } if (replacedWebContentsId !== undefined && replacedWebContentsId !== input.webContentsId) { this.removeWebContents(replacedWebContentsId, { preserveActiveBrowser: true }); }