Keep agent browser tabs connected across workspace switches (#2156)

* fix(desktop): keep browser tabs connected across workspaces

Electron can replace a guest WebContents when a retained browser tab is reparented. Re-register each attachment and keep background actionability checks running so agent browser tools retain the tab through workspace eviction.\n\nCover the full app, daemon, Electron, and MCP path in the existing desktop CI job.

* fix(desktop): launch Electron E2E reliably on Linux

CI Electron must receive --no-sandbox before the app starts because the hosted runner cannot use Electron's bundled SUID sandbox helper. Forward explicit dev-runner arguments and fail readiness waits as soon as a child exits.

* fix(desktop): preserve active browser on repeated registration

* fix(desktop): keep browser keyboard attachment idempotent

* fix(desktop): wait for E2E bridge readiness

* fix(desktop): close E2E logs after output drains
This commit is contained in:
Mohamed Boudra
2026-07-17 13:02:09 +02:00
committed by GitHub
parent 557fc42c89
commit 388f1d426c
12 changed files with 601 additions and 39 deletions

View File

@@ -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

View File

@@ -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 `<webview>` 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.
>

View File

@@ -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`

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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(`<!doctype html>
<html>
<head><title>Tab bridge target</title></head>
<body>
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
</body>
</html>`);
});
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();

View File

@@ -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,

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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 });
}