Keep browser sign-ins across tabs and restarts (#2089)

* feat(desktop): share persistent browser data across tabs

Keep browser sign-ins and site storage across tabs and app restarts, with a Settings action to clear the shared profile.

* fix(settings): prevent duplicate browser data clears

* fix(desktop): complete browser data cleanup

Clear legacy per-tab profiles after upgrades and localize the browser data settings in every supported language.

* fix(desktop): close browser profile cleanup races
This commit is contained in:
Mohamed Boudra
2026-07-16 00:40:24 +08:00
committed by GitHub
parent 9423b091b9
commit 328361667f
32 changed files with 1413 additions and 148 deletions
+50 -5
View File
@@ -52,29 +52,70 @@
? Math.max(0, requestedWebviewCount)
: 2;
const webviews = [];
const profileAttachPromises = [];
const activeTokens = new Set();
let nextTokenId = 0;
let permanentParkingState = params.get("permanentParkingState") || "";
function appendHarnessWebview(sourceUrl) {
function createHarnessWebview({ sourceUrl, browserId, partition }) {
const index = webviews.length;
const webview = document.createElement("webview");
webview.id = `target-webview-${index + 1}`;
webview.className = "capture-harness-webview";
webview.setAttribute("data-paseo-browser-id", `capture-harness-${index + 1}`);
webview.setAttribute("partition", `persist:paseo-capture-harness-${index + 1}`);
webview.setAttribute("data-paseo-browser-id", browserId);
webview.setAttribute("partition", partition);
webview.setAttribute("allowpopups", "true");
webview.setAttribute("spellcheck", "false");
webview.setAttribute("autosize", "on");
webview.src = sourceUrl;
applyStackedWebviewStyle(webview);
return webview;
}
function appendHarnessWebview(sourceUrl) {
const index = webviews.length;
const webview = createHarnessWebview({
sourceUrl,
browserId: `capture-harness-${index + 1}`,
partition: `persist:paseo-capture-harness-${index + 1}`,
});
host.appendChild(webview);
webviews.push(webview);
return index;
}
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
function appendProfileHarnessWebview({ browserId, partition, sourceUrl }) {
const webview = createHarnessWebview({ browserId, partition, sourceUrl });
const attached = new Promise((resolve) => {
webview.addEventListener(
"did-attach",
() => resolve({ browserId, webContentsId: webview.getWebContentsId() }),
{ once: true },
);
});
host.appendChild(webview);
webviews.push(webview);
profileAttachPromises.push(attached);
return attached;
}
const profilePartition = params.get("profilePartition");
const profileBrowserIds = (params.get("profileBrowserIds") || "").split(",").filter(Boolean);
let profileAttachSequence = Promise.resolve();
if (profilePartition && profileBrowserIds.length > 0) {
profileAttachSequence = (async () => {
for (const browserId of profileBrowserIds) {
await appendProfileHarnessWebview({
browserId,
partition: profilePartition,
sourceUrl: params.get("targetUrl") || "bright.html",
});
}
})();
} else {
for (let index = 0; index < webviewCount; index += 1) {
appendHarnessWebview(params.get("targetUrl") || "bright.html");
}
}
function applyHostParking() {
@@ -318,6 +359,10 @@
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},
async profileIdentities() {
await profileAttachSequence;
return await Promise.all(profileAttachPromises);
},
addPermanentWebview(sourceUrl, stateName) {
const index = appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
applyPermanentParkingState(stateName || permanentParkingState);
+245 -5
View File
@@ -1,7 +1,8 @@
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const path = require("node:path");
const { app, BrowserWindow, nativeImage, screen } = require("electron");
const { app, BrowserWindow, nativeImage, screen, session } = require("electron");
const ROOT = __dirname;
const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out");
@@ -9,11 +10,15 @@ const VIEWPORT_WIDTH = 1280;
const VIEWPORT_HEIGHT = 800;
const FULL_PAGE_HEIGHT = 1600;
const CAPTURE_TIMEOUT_MS = 5000;
const BROWSER_PROFILE_TIMEOUT_MS = 15000;
const CAPTURE_RETRY_INTERVAL_MS = 200;
const REPEAT_COUNT = 5;
const FRESH_REPEAT_COUNT = 3;
const SOAK_MS = Number(process.env.PASEO_CAPTURE_HARNESS_SOAK_MS || 75000);
const HARNESS_GROUP = process.env.PASEO_CAPTURE_HARNESS_GROUP || "permanent-parking";
const BROWSER_PROFILE_PHASE = process.env.PASEO_CAPTURE_HARNESS_PHASE || "";
const BROWSER_PROFILE_ORIGIN_FILE = path.join(OUT_DIR, "browser-profile-origin.txt");
const BROWSER_PROFILE_VALUE_FILE = path.join(OUT_DIR, "browser-profile-value.txt");
const PERMANENT_STATE_FILTER = new Set(
(process.env.PASEO_CAPTURE_HARNESS_STATES || "P1")
.split(",")
@@ -121,6 +126,44 @@ function fileUrl(filePath, params = {}) {
return url.toString();
}
async function startBrowserProfileServer() {
let port = 0;
if (BROWSER_PROFILE_PHASE === "read") {
const previousOrigin = (await fsp.readFile(BROWSER_PROFILE_ORIGIN_FILE, "utf8")).trim();
port = Number(new URL(previousOrigin).port);
}
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end("<!doctype html><title>Shared browser profile</title><h1>Profile fixture</h1>");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("browser profile fixture server has no TCP address");
}
const origin = `http://127.0.0.1:${address.port}`;
if (BROWSER_PROFILE_PHASE === "write") {
await fsp.writeFile(BROWSER_PROFILE_ORIGIN_FILE, `${origin}\n`);
}
return { origin, server };
}
async function closeServer(server) {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
function ensureDirSync(dir) {
fs.mkdirSync(dir, { recursive: true });
}
@@ -160,12 +203,12 @@ async function waitForInactiveReveal(handle, label) {
await delay(250);
}
function withTimeout(promise, label) {
function withTimeout(promise, label, timeoutMs = CAPTURE_TIMEOUT_MS) {
let timeoutId;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${label} timed out after ${CAPTURE_TIMEOUT_MS}ms`));
}, CAPTURE_TIMEOUT_MS);
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
clearTimeout(timeoutId);
@@ -434,6 +477,7 @@ function installHarnessWebviewGuards(win) {
function trackAttachedGuests(win, input = {}) {
const attachedGuests = [];
const waiters = [];
const countWaiters = [];
win.webContents.on("did-attach-webview", (_event, contents) => {
if (input.disableGuestBackgroundThrottlingAtAttach) {
contents.setBackgroundThrottling(false);
@@ -443,6 +487,13 @@ function trackAttachedGuests(win, input = {}) {
if (waiter) {
waiter(contents);
}
for (let index = countWaiters.length - 1; index >= 0; index -= 1) {
const countWaiter = countWaiters[index];
if (attachedGuests.length >= countWaiter.count) {
countWaiters.splice(index, 1);
countWaiter.resolve(attachedGuests.slice(0, countWaiter.count));
}
}
});
return {
attachedGuests,
@@ -451,6 +502,14 @@ function trackAttachedGuests(win, input = {}) {
waiters.push(resolve);
});
},
waitForAttachedGuests(count) {
if (attachedGuests.length >= count) {
return Promise.resolve(attachedGuests.slice(0, count));
}
return new Promise((resolve) => {
countWaiters.push({ count, resolve });
});
},
};
}
@@ -1889,12 +1948,193 @@ function assertAutomationSnapshot(snapshot) {
}
}
async function createBrowserProfileHarnessWindow(partition, sourceUrl) {
const handle = createInactiveHarnessWindow({
width: 640,
height: 480,
backgroundColor: "#202020",
webPreferences: {
webviewTag: true,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
const { win } = handle;
installHarnessWebviewGuards(win);
const tracker = trackAttachedGuests(win);
const guestsPromise = tracker.waitForAttachedGuests(2);
await withTimeout(
win.loadFile(path.join(ROOT, "index.html"), {
query: {
webviewCount: "2",
targetUrl: sourceUrl,
profilePartition: partition,
profileBrowserIds: "browser-first,browser-second",
},
}),
"browser profile window loadFile",
);
await waitForInactiveReveal(handle, "browser profile window");
const [guests, identities] = await withTimeout(
Promise.all([guestsPromise, renderer(win, "window.captureHarness.profileIdentities()")]),
"browser profile did-attach",
BROWSER_PROFILE_TIMEOUT_MS,
);
return { handle, guests, identities };
}
async function readBrowserProfileFixture(guest) {
return await guest.executeJavaScript(`({
cookie: document.cookie,
localStorage: localStorage.getItem("paseo-browser-profile")
})`);
}
function assertBrowserProfileFixture(state, expectedValue, label) {
if (state.localStorage !== expectedValue) {
fail(`${label} localStorage mismatch ${JSON.stringify(state)}`);
}
if (!state.cookie.split("; ").includes(`paseo-browser-profile=${expectedValue}`)) {
fail(`${label} cookie mismatch ${JSON.stringify(state)}`);
}
}
function resolveBrowserProfileGuests(profileWindow, profileSession) {
if (profileWindow.identities.length !== 2 || profileWindow.guests.length !== 2) {
fail("browser profile harness did not attach exactly two guests");
}
const guestsById = new Map(profileWindow.guests.map((guest) => [guest.id, guest]));
const [firstIdentity, secondIdentity] = profileWindow.identities;
const firstGuest = guestsById.get(firstIdentity.webContentsId);
const secondGuest = guestsById.get(secondIdentity.webContentsId);
if (!firstGuest || !secondGuest) {
fail("browser profile renderer identities did not map to attached main-process guests");
}
if (
firstIdentity.browserId !== "browser-first" ||
firstIdentity.webContentsId !== firstGuest.id
) {
fail(
`browser profile first attach mismatch ${JSON.stringify(firstIdentity)} main=${firstGuest.id}`,
);
}
if (
secondIdentity.browserId !== "browser-second" ||
secondIdentity.webContentsId !== secondGuest.id
) {
fail(
`browser profile second attach mismatch ${JSON.stringify(secondIdentity)} main=${secondGuest.id}`,
);
}
if (
firstGuest.hostWebContents !== profileWindow.handle.win.webContents ||
secondGuest.hostWebContents !== profileWindow.handle.win.webContents
) {
fail("browser profile guests were not owned by their renderer");
}
if (firstGuest.session !== profileSession || secondGuest.session !== profileSession) {
fail("browser profile guests did not share the persistent session");
}
return [firstGuest, secondGuest];
}
async function prepareBrowserProfileValue(firstGuest, profileSession) {
if (BROWSER_PROFILE_PHASE === "read") {
return (await fsp.readFile(BROWSER_PROFILE_VALUE_FILE, "utf8")).trim();
}
const profileValue = `profile-${Date.now()}-${process.pid}`;
await firstGuest.executeJavaScript(`(() => {
const value = ${JSON.stringify(profileValue)};
localStorage.setItem("paseo-browser-profile", value);
document.cookie = "paseo-browser-profile=" + value + "; Max-Age=86400; SameSite=Lax";
})()`);
if (BROWSER_PROFILE_PHASE === "write") {
await fsp.writeFile(BROWSER_PROFILE_VALUE_FILE, `${profileValue}\n`);
await profileSession.cookies.flushStore();
}
return profileValue;
}
async function runBrowserProfileGroup() {
if (!["write", "read"].includes(BROWSER_PROFILE_PHASE)) {
fail(`unknown browser profile phase ${BROWSER_PROFILE_PHASE}`);
}
const partition = "persist:paseo-browser-profile-harness-restart";
const profileSession = session.fromPartition(partition);
const fixture = await startBrowserProfileServer();
const windows = [];
try {
if (BROWSER_PROFILE_PHASE === "write") {
await profileSession.clearStorageData();
await profileSession.clearCache();
}
const profileWindow = await createBrowserProfileHarnessWindow(partition, fixture.origin);
windows.push(profileWindow.handle);
const [firstGuest, secondGuest] = resolveBrowserProfileGuests(profileWindow, profileSession);
await Promise.all([waitForGuestLoad(firstGuest), waitForGuestLoad(secondGuest)]);
const profileValue = await prepareBrowserProfileValue(firstGuest, profileSession);
const firstState = await readBrowserProfileFixture(firstGuest);
const secondState = await readBrowserProfileFixture(secondGuest);
assertBrowserProfileFixture(firstState, profileValue, "browser profile first tab");
assertBrowserProfileFixture(secondState, profileValue, "browser profile second tab");
pass("browser profile renderer did-attach identities match their main-process guests");
pass("browser profile tabs share cookies, localStorage, and one persistent session");
if (BROWSER_PROFILE_PHASE === "read") {
pass("browser profile cookies and localStorage survived an Electron process restart");
}
const results = [
{ group: "browser-profile", check: "renderer-main-identity", pass: true },
{ group: "browser-profile", check: "shared-profile-data", pass: true },
];
if (BROWSER_PROFILE_PHASE === "read") {
results.push({
group: "browser-profile",
check: "process-restart-persistence",
pass: true,
});
}
return results;
} finally {
for (const handle of windows) {
await closeHarnessWindow(handle.win);
}
if (BROWSER_PROFILE_PHASE !== "write") {
await profileSession.clearStorageData();
await profileSession.clearCache();
}
await closeServer(fixture.server);
}
}
async function main() {
ensureDirSync(OUT_DIR);
if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) {
if (
!["all", "existing", "permanent-parking", "automation", "browser-profile"].includes(
HARNESS_GROUP,
)
) {
fail(`unknown harness group ${HARNESS_GROUP}`);
}
if (HARNESS_GROUP === "browser-profile") {
const browserProfileResults = await runBrowserProfileGroup();
await fsp.writeFile(
path.join(OUT_DIR, "results.json"),
`${JSON.stringify(
{ generatedAt: new Date().toISOString(), browserProfileResults },
null,
2,
)}\n`,
);
pass(`capture harness browser-profile complete output=${OUT_DIR}`);
return;
}
if (HARNESS_GROUP === "automation") {
const automationResults = await runAutomationGroup();
await fsp.writeFile(
+10 -1
View File
@@ -3,5 +3,14 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
ELECTRON="$REPO_ROOT/node_modules/.bin/electron"
exec "$REPO_ROOT/node_modules/.bin/electron" "$SCRIPT_DIR/main.js"
if [ "${PASEO_CAPTURE_HARNESS_GROUP:-}" = "browser-profile" ] && [ -z "${PASEO_CAPTURE_HARNESS_PHASE:-}" ]; then
PASEO_CAPTURE_HARNESS_PHASE=write "$ELECTRON" "$SCRIPT_DIR/main.js"
# Give Chromium's profile helpers time to release the persistent session before reopening it.
sleep 1
PASEO_CAPTURE_HARNESS_PHASE=read "$ELECTRON" "$SCRIPT_DIR/main.js"
exit
fi
exec "$ELECTRON" "$SCRIPT_DIR/main.js"
@@ -0,0 +1,208 @@
import { describe, expect, test } from "vitest";
import {
clearPaseoBrowserProfile,
getLegacyPaseoBrowserProfileSession,
getPaseoBrowserProfileSessions,
listPaseoBrowserProfileGuests,
readLegacyPaseoBrowserIds,
} from "./browser-profile.js";
class FakeProfileSession {
public readonly storageClears: unknown[] = [];
public cacheClears = 0;
public authClears = 0;
public storageClear: Promise<void> = Promise.resolve();
public clearStorageData(options: unknown): Promise<void> {
this.storageClears.push(options);
return this.storageClear;
}
public clearCache(): Promise<void> {
this.cacheClears += 1;
return Promise.resolve();
}
public clearAuthCache(): Promise<void> {
this.authClears += 1;
return Promise.resolve();
}
}
class FakeLiveGuest {
public reloads = 0;
public constructor(
public readonly id: number,
private readonly destroyed = false,
private readonly reloadError: Error | null = null,
) {}
public isDestroyed(): boolean {
return this.destroyed;
}
public reload(): void {
if (this.reloadError) {
throw this.reloadError;
}
this.reloads += 1;
}
}
class FakeWebContents extends FakeLiveGuest {
public constructor(
id: number,
public readonly session: object,
private readonly type: string,
destroyed = false,
) {
super(id, destroyed);
}
public getType(): string {
return this.type;
}
}
describe("listPaseoBrowserProfileGuests", () => {
test("returns every live webview in the shared profile without deduplicating tabs", () => {
const profileSession = {};
const firstWindowGuest = new FakeWebContents(1, profileSession, "webview");
const secondWindowGuest = new FakeWebContents(2, profileSession, "webview");
const foreignProfileGuest = new FakeWebContents(3, {}, "webview");
const mainRenderer = new FakeWebContents(4, profileSession, "window");
const destroyedGuest = new FakeWebContents(5, profileSession, "webview", true);
const guests = listPaseoBrowserProfileGuests({
profileSession,
webContents: [
firstWindowGuest,
secondWindowGuest,
foreignProfileGuest,
mainRenderer,
destroyedGuest,
],
});
expect(guests).toEqual([firstWindowGuest, secondWindowGuest]);
});
});
describe("legacy browser profiles", () => {
test("accepts only unique saved browser ids and resolves their old partitions", () => {
const uuid = "123e4567-e89b-42d3-a456-426614174000";
const fallbackId = "1700000000000-abcd";
const browserIds = readLegacyPaseoBrowserIds([uuid, fallbackId, uuid, "not-a-browser-id", 123]);
const partitions: string[] = [];
const sessions = getPaseoBrowserProfileSessions(
{
fromPartition: (partition) => {
partitions.push(partition);
return new FakeProfileSession();
},
},
browserIds,
);
expect(partitions).toEqual([
"persist:paseo-browser",
`persist:paseo-browser-${uuid}`,
`persist:paseo-browser-${fallbackId}`,
]);
expect(sessions).toHaveLength(3);
});
test("resolves one valid legacy profile for tab-close cleanup", () => {
const partitions: string[] = [];
const sessions = {
fromPartition: (partition: string) => {
partitions.push(partition);
return new FakeProfileSession();
},
};
expect(getLegacyPaseoBrowserProfileSession(sessions, "1700000000000-abcd")).not.toBeNull();
expect(getLegacyPaseoBrowserProfileSession(sessions, "invalid")).toBeNull();
expect(partitions).toEqual(["persist:paseo-browser-1700000000000-abcd"]);
});
});
describe("clearPaseoBrowserProfile", () => {
test("clears site data, HTTP cache, and auth before reloading live guests", async () => {
const profile = new FakeProfileSession();
const legacyProfile = new FakeProfileSession();
let finishStorageClear: (() => void) | null = null;
profile.storageClear = new Promise((resolve) => {
finishStorageClear = resolve;
});
const firstGuest = new FakeLiveGuest(1);
const secondGuest = new FakeLiveGuest(2);
const clearing = clearPaseoBrowserProfile({
profileSessions: [profile, legacyProfile],
listGuests: () => [firstGuest, secondGuest],
logReloadError: () => {},
});
expect(firstGuest.reloads).toBe(0);
expect(secondGuest.reloads).toBe(0);
finishStorageClear?.();
await clearing;
expect(profile.storageClears).toEqual([
{
storages: [
"cookies",
"filesystem",
"indexdb",
"localstorage",
"serviceworkers",
"cachestorage",
"websql",
],
},
]);
expect(profile.cacheClears).toBe(1);
expect(profile.authClears).toBe(1);
expect(legacyProfile.storageClears).toEqual(profile.storageClears);
expect(legacyProfile.cacheClears).toBe(1);
expect(legacyProfile.authClears).toBe(1);
expect(firstGuest.reloads).toBe(1);
expect(secondGuest.reloads).toBe(1);
});
test("skips destroyed guests and logs individual reload failures", async () => {
const profile = new FakeProfileSession();
const destroyedGuest = new FakeLiveGuest(1, true);
const reloadError = new Error("guest disappeared");
const failedGuest = new FakeLiveGuest(2, false, reloadError);
const reloadErrors: Array<{ guestId: number; error: unknown }> = [];
await clearPaseoBrowserProfile({
profileSessions: [profile],
listGuests: () => [destroyedGuest, failedGuest],
logReloadError: (guestId, error) => reloadErrors.push({ guestId, error }),
});
expect(destroyedGuest.reloads).toBe(0);
expect(failedGuest.reloads).toBe(0);
expect(reloadErrors).toEqual([{ guestId: 2, error: reloadError }]);
});
test("propagates clear failures without reloading guests", async () => {
const profile = new FakeProfileSession();
const clearError = new Error("profile locked");
profile.storageClear = Promise.reject(clearError);
const guest = new FakeLiveGuest(1);
await expect(
clearPaseoBrowserProfile({
profileSessions: [profile],
listGuests: () => [guest],
logReloadError: () => {},
}),
).rejects.toBe(clearError);
expect(guest.reloads).toBe(0);
});
});
@@ -0,0 +1,123 @@
export const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser";
const LEGACY_BROWSER_ID_PATTERN =
/^(?:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|\d{13,}-[0-9a-f]+)$/i;
const MAX_LEGACY_BROWSER_PROFILES = 1000;
const PASEO_BROWSER_STORAGE_TYPES = [
"cookies",
"filesystem",
"indexdb",
"localstorage",
"serviceworkers",
"cachestorage",
"websql",
] as const;
interface BrowserProfileSession {
clearStorageData(options: {
storages: Array<(typeof PASEO_BROWSER_STORAGE_TYPES)[number]>;
}): Promise<void>;
clearCache(): Promise<void>;
clearAuthCache(): Promise<void>;
}
interface BrowserProfileGuest {
readonly id: number;
isDestroyed(): boolean;
reload(): void;
}
interface BrowserProfileWebContents extends BrowserProfileGuest {
readonly session: object;
getType(): string;
}
interface ListBrowserProfileGuestsInput {
profileSession: object;
webContents: BrowserProfileWebContents[];
}
interface ClearBrowserProfileInput {
profileSessions: BrowserProfileSession[];
listGuests(): BrowserProfileGuest[];
logReloadError(guestId: number, error: unknown): void;
}
interface ElectronSessions {
fromPartition(partition: string): BrowserProfileSession;
}
export function getPaseoBrowserProfileSession(sessions: ElectronSessions): BrowserProfileSession {
return sessions.fromPartition(PASEO_BROWSER_PROFILE_PARTITION);
}
export function readLegacyPaseoBrowserIds(input: unknown): string[] {
if (!Array.isArray(input)) {
return [];
}
const browserIds = new Set<string>();
for (const value of input) {
if (typeof value === "string" && LEGACY_BROWSER_ID_PATTERN.test(value)) {
browserIds.add(value);
if (browserIds.size >= MAX_LEGACY_BROWSER_PROFILES) {
break;
}
}
}
return [...browserIds];
}
export function getPaseoBrowserProfileSessions(
sessions: ElectronSessions,
legacyBrowserIds: string[],
): [BrowserProfileSession, ...BrowserProfileSession[]] {
return [
getPaseoBrowserProfileSession(sessions),
// COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15.
...legacyBrowserIds.map((browserId) =>
sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${browserId}`),
),
];
}
export function getLegacyPaseoBrowserProfileSession(
sessions: ElectronSessions,
browserId: string,
): BrowserProfileSession | null {
const [legacyBrowserId] = readLegacyPaseoBrowserIds([browserId]);
return legacyBrowserId
? sessions.fromPartition(`${PASEO_BROWSER_PROFILE_PARTITION}-${legacyBrowserId}`)
: null;
}
export function listPaseoBrowserProfileGuests(
input: ListBrowserProfileGuestsInput,
): BrowserProfileGuest[] {
return input.webContents.filter(
(contents) =>
!contents.isDestroyed() &&
contents.getType() === "webview" &&
contents.session === input.profileSession,
);
}
export async function clearPaseoBrowserProfile(input: ClearBrowserProfileInput): Promise<void> {
await Promise.all(
input.profileSessions.flatMap((profileSession) => [
profileSession.clearStorageData({ storages: [...PASEO_BROWSER_STORAGE_TYPES] }),
profileSession.clearCache(),
profileSession.clearAuthCache(),
]),
);
for (const guest of input.listGuests()) {
if (guest.isDestroyed()) {
continue;
}
try {
guest.reload();
} catch (error) {
input.logReloadError(guest.id, error);
}
}
}
@@ -1,12 +1,31 @@
import { describe, expect, test } from "vitest";
import { getPaseoBrowserIdForWebContents, registerPaseoBrowserWebContents } from "./index.js";
import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js";
import {
getPaseoBrowserIdForWebContents,
getPaseoBrowserWorkspaceId,
isPaseoBrowserWebviewAttach,
preparePaseoBrowserWebContents,
registerAttachedPaseoBrowser,
} from "./index.js";
class FakeRegisteredWebContents {
class FakeRenderer {
public constructor(public readonly id: number) {}
public isDestroyed(): boolean {
return false;
}
}
class FakeBrowserGuest {
public readonly backgroundThrottlingCalls: boolean[] = [];
private destroyedListener: (() => void) | null = null;
private destroyed = false;
public constructor(public readonly id: number) {}
public constructor(
public readonly id: number,
public readonly hostWebContents: FakeRenderer,
public readonly session: object,
) {}
public isDestroyed(): boolean {
return this.destroyed;
@@ -27,18 +46,133 @@ class FakeRegisteredWebContents {
}
}
describe("registerPaseoBrowserWebContents", () => {
test("disables guest background throttling once when the webview is registered", () => {
const contents = new FakeRegisteredWebContents(9001);
describe("browser webview attachment", () => {
test("accepts only allowed URLs on the shared profile partition", () => {
expect(
isPaseoBrowserWebviewAttach({
src: "https://example.com",
partition: PASEO_BROWSER_PROFILE_PARTITION,
}),
).toBe(true);
expect(
isPaseoBrowserWebviewAttach({
src: "https://example.com",
partition: "persist:paseo-browser-tab-a",
}),
).toBe(false);
expect(
isPaseoBrowserWebviewAttach({ src: "https://example.com", partition: "persist:foreign" }),
).toBe(false);
});
registerPaseoBrowserWebContents(contents, "browser-throttle");
test("binds explicit browser identity to the renderer that hosts the guest", () => {
const profileSession = {};
const renderer = new FakeRenderer(1);
const guest = new FakeBrowserGuest(101, renderer, profileSession);
expect(contents.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(contents)).toBe("browser-throttle");
const registered = registerAttachedPaseoBrowser({
browserId: "browser-a",
workspaceId: "workspace-a",
webContentsId: guest.id,
sender: renderer,
profileSession,
findWebContents: () => guest,
});
contents.destroy();
expect(registered).toBe(true);
expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-a");
expect(getPaseoBrowserWorkspaceId("browser-a")).toBe("workspace-a");
});
expect(getPaseoBrowserIdForWebContents(contents)).toBeNull();
expect(contents.backgroundThrottlingCalls).toEqual([false]);
test("rejects a guest hosted by another renderer", () => {
const profileSession = {};
const owner = new FakeRenderer(1);
const claimant = new FakeRenderer(2);
const guest = new FakeBrowserGuest(201, owner, profileSession);
const registered = registerAttachedPaseoBrowser({
browserId: "browser-a",
workspaceId: "workspace-a",
webContentsId: guest.id,
sender: claimant,
profileSession,
findWebContents: () => guest,
});
expect(registered).toBe(false);
expect(getPaseoBrowserIdForWebContents(guest)).toBeNull();
});
test("rejects a guest outside the shared profile", () => {
const profileSession = {};
const renderer = new FakeRenderer(1);
const guest = new FakeBrowserGuest(301, renderer, {});
const registered = registerAttachedPaseoBrowser({
browserId: "browser-a",
workspaceId: "workspace-a",
webContentsId: guest.id,
sender: renderer,
profileSession,
findWebContents: () => guest,
});
expect(registered).toBe(false);
expect(getPaseoBrowserIdForWebContents(guest)).toBeNull();
});
test("concurrent windows cannot swap browser identities", () => {
const profileSession = {};
const firstRenderer = new FakeRenderer(1);
const secondRenderer = new FakeRenderer(2);
const firstGuest = new FakeBrowserGuest(401, firstRenderer, profileSession);
const secondGuest = new FakeBrowserGuest(402, secondRenderer, profileSession);
const guests = new Map([
[firstGuest.id, firstGuest],
[secondGuest.id, secondGuest],
]);
registerAttachedPaseoBrowser({
browserId: "browser-second",
workspaceId: "workspace-second",
webContentsId: secondGuest.id,
sender: secondRenderer,
profileSession,
findWebContents: (id) => guests.get(id) ?? null,
});
registerAttachedPaseoBrowser({
browserId: "browser-first",
workspaceId: "workspace-first",
webContentsId: firstGuest.id,
sender: firstRenderer,
profileSession,
findWebContents: (id) => guests.get(id) ?? null,
});
expect(getPaseoBrowserIdForWebContents(firstGuest)).toBe("browser-first");
expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-second");
});
test("prepares throttling once and removes registration when the guest is destroyed", () => {
const profileSession = {};
const renderer = new FakeRenderer(1);
const guest = new FakeBrowserGuest(501, renderer, profileSession);
preparePaseoBrowserWebContents(guest);
registerAttachedPaseoBrowser({
browserId: "browser-cleanup",
workspaceId: "workspace-cleanup",
webContentsId: guest.id,
sender: renderer,
profileSession,
findWebContents: () => guest,
});
expect(guest.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup");
guest.destroy();
expect(getPaseoBrowserIdForWebContents(guest)).toBeNull();
expect(guest.backgroundThrottlingCalls).toEqual([false]);
});
});
@@ -1,13 +1,18 @@
import { webContents as allWebContents, type WebContents } from "electron";
import { PASEO_BROWSER_PROFILE_PARTITION } from "../browser-profile.js";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,
handleBrowserWindowOpenRequest,
isAllowedBrowserWebviewUrl,
PendingBrowserWindowOpenRequests,
} from "./window-open.js";
import { PaseoBrowserWebviewRegistry, type BrowserWorkspaceRegistration } from "./registry.js";
import { PaseoBrowserWebviewRegistry } from "./registry.js";
export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest };
export type { BrowserWorkspaceRegistration };
export {
BROWSER_NEW_TAB_REQUEST_EVENT,
handleBrowserWindowOpenRequest,
PendingBrowserWindowOpenRequests,
};
const browserRegistry = new PaseoBrowserWebviewRegistry();
@@ -17,27 +22,28 @@ interface BrowserWebContentsIdentity {
}
interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity {
readonly hostWebContents: BrowserWebContentsIdentity | null;
readonly session: object;
setBackgroundThrottling(allowed: boolean): void;
once(event: "destroyed", listener: () => void): void;
}
function getBrowserIdFromWebviewPartition(partition: string | undefined): string | null {
const prefix = "persist:paseo-browser-";
if (!partition?.startsWith(prefix)) {
return null;
}
const browserId = partition.slice(prefix.length).trim();
return browserId.length > 0 ? browserId : null;
interface AttachedBrowserRegistration {
browserId: string;
workspaceId: string;
webContentsId: number;
}
export function readBrowserIdFromWebviewAttach(input: {
src?: string;
partition?: string;
}): string | null {
if (!isAllowedBrowserWebviewUrl(input.src)) {
return null;
}
return getBrowserIdFromWebviewPartition(input.partition);
interface RegisterAttachedBrowserInput extends AttachedBrowserRegistration {
sender: BrowserWebContentsIdentity;
profileSession: object;
findWebContents(webContentsId: number): RegisteredBrowserWebContents | null;
}
export function isPaseoBrowserWebviewAttach(input: { src?: string; partition?: string }): boolean {
return (
isAllowedBrowserWebviewUrl(input.src) && input.partition === PASEO_BROWSER_PROFILE_PARTITION
);
}
export function listRegisteredPaseoBrowserIds(): string[] {
@@ -46,17 +52,35 @@ export function listRegisteredPaseoBrowserIds(): string[] {
.filter((browserId) => getPaseoBrowserWebContents(browserId));
}
export function registerPaseoBrowserWebContents(
contents: RegisteredBrowserWebContents,
browserId: string,
): void {
export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void {
contents.setBackgroundThrottling(false);
browserRegistry.registerWebContents({ webContentsId: contents.id, browserId });
contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(contents.id);
});
}
export function registerAttachedPaseoBrowser(input: RegisterAttachedBrowserInput): boolean {
const guest = input.findWebContents(input.webContentsId);
if (
!guest ||
guest.isDestroyed() ||
guest.hostWebContents !== input.sender ||
guest.session !== input.profileSession
) {
return false;
}
browserRegistry.registerWebContents({
webContentsId: input.webContentsId,
browserId: input.browserId,
});
browserRegistry.registerWorkspace({
browserId: input.browserId,
workspaceId: input.workspaceId,
});
return true;
}
export function getPaseoBrowserIdForWebContents(
contents: BrowserWebContentsIdentity | null,
): string | null {
@@ -66,10 +90,6 @@ export function getPaseoBrowserIdForWebContents(
return browserRegistry.getBrowserIdForWebContents(contents.id);
}
export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistration): void {
browserRegistry.registerWorkspace(input);
}
export function unregisterPaseoBrowser(browserId: string): void {
browserRegistry.unregisterBrowser(browserId);
}
@@ -26,4 +26,18 @@ describe("PaseoBrowserWebviewRegistry", () => {
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
});
it("keeps one browser identity per webContents target", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 1, browserId: "browser-b" });
expect(registry.getWebContentsIdForBrowser("browser-a")).toBeNull();
expect(registry.getWorkspaceId("browser-a")).toBeNull();
expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBeNull();
expect(registry.getBrowserIdForWebContents(1)).toBe("browser-b");
});
});
@@ -10,6 +10,17 @@ export class PaseoBrowserWebviewRegistry {
private readonly activeBrowserIdsByWorkspaceId = new Map<string, string>();
public registerWebContents(input: { webContentsId: number; browserId: string }): void {
const previousBrowserId = this.browserIdsByWebContentsId.get(input.webContentsId) ?? null;
if (
previousBrowserId !== null &&
previousBrowserId !== input.browserId &&
this.webContentsIdsByBrowserId.get(previousBrowserId) === input.webContentsId
) {
this.webContentsIdsByBrowserId.delete(previousBrowserId);
this.workspaceIdsByBrowserId.delete(previousBrowserId);
this.deleteActiveBrowserReferences(previousBrowserId);
}
const previousWebContentsId = this.webContentsIdsByBrowserId.get(input.browserId) ?? null;
if (previousWebContentsId !== null && previousWebContentsId !== input.webContentsId) {
this.browserIdsByWebContentsId.delete(previousWebContentsId);
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { handleBrowserWindowOpenRequest } from ".";
import { handleBrowserWindowOpenRequest, PendingBrowserWindowOpenRequests } from ".";
describe("browser webview window-open requests", () => {
it("denies Electron window creation and requests a Paseo browser tab", () => {
@@ -32,3 +32,23 @@ describe("browser webview window-open requests", () => {
expect(requestNewTab).not.toHaveBeenCalled();
});
});
describe("pending browser window-open requests", () => {
it("holds early allowed popups until browser identity registration", () => {
const pending = new PendingBrowserWindowOpenRequests();
pending.add(101, "https://example.com/first");
pending.add(101, "file:///etc/passwd");
pending.add(101, "https://example.com/second");
expect(pending.take(101)).toEqual(["https://example.com/first", "https://example.com/second"]);
expect(pending.take(101)).toEqual([]);
});
it("drops pending popups when an unregistered guest is destroyed", () => {
const pending = new PendingBrowserWindowOpenRequests();
pending.add(202, "https://example.com/target");
pending.delete(202);
expect(pending.take(202)).toEqual([]);
});
});
@@ -5,6 +5,34 @@ export interface BrowserNewTabRequestPayload {
url: string;
}
const MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST = 20;
export class PendingBrowserWindowOpenRequests {
private readonly urlsByWebContentsId = new Map<number, string[]>();
public add(webContentsId: number, url: string): void {
if (!isAllowedBrowserWebviewUrl(url)) {
return;
}
const urls = this.urlsByWebContentsId.get(webContentsId) ?? [];
if (urls.length >= MAX_PENDING_WINDOW_OPEN_REQUESTS_PER_GUEST) {
return;
}
urls.push(url);
this.urlsByWebContentsId.set(webContentsId, urls);
}
public take(webContentsId: number): string[] {
const urls = this.urlsByWebContentsId.get(webContentsId) ?? [];
this.urlsByWebContentsId.delete(webContentsId);
return urls;
}
public delete(webContentsId: number): void {
this.urlsByWebContentsId.delete(webContentsId);
}
}
export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean {
if (!value) {
return true;
+107 -38
View File
@@ -21,6 +21,7 @@ import {
protocol,
screen,
session,
webContents,
} from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
@@ -52,13 +53,22 @@ import {
getPaseoBrowserWebContents,
handleBrowserWindowOpenRequest,
listRegisteredPaseoBrowserIds,
readBrowserIdFromWebviewAttach,
isPaseoBrowserWebviewAttach,
preparePaseoBrowserWebContents,
PendingBrowserWindowOpenRequests,
registerBrowserWebviewNavigationGuards,
unregisterPaseoBrowser,
registerPaseoBrowserWorkspace,
registerPaseoBrowserWebContents,
registerAttachedPaseoBrowser,
setWorkspaceActivePaseoBrowserId,
} from "./features/browser-webviews/index.js";
import {
clearPaseoBrowserProfile,
getLegacyPaseoBrowserProfileSession,
getPaseoBrowserProfileSession,
getPaseoBrowserProfileSessions,
listPaseoBrowserProfileGuests,
readLegacyPaseoBrowserIds,
} from "./features/browser-profile.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
import { PendingOpenProjectStore } from "./pending-open-project-store.js";
import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js";
@@ -80,6 +90,7 @@ const APP_SCHEME = "paseo";
const PASEO_DEBUG = process.env.PASEO_DEBUG === "1";
const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_LOCK === "1";
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests();
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
@@ -114,9 +125,13 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
app.setName(APP_NAME);
function readBrowserWorkspaceInput(
input: unknown,
): { browserId: string; workspaceId: string } | null {
interface AttachedBrowserInput {
browserId: string;
workspaceId: string;
webContentsId: number;
}
function readAttachedBrowserInput(input: unknown): AttachedBrowserInput | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return null;
}
@@ -127,7 +142,18 @@ function readBrowserWorkspaceInput(
if (typeof record.workspaceId !== "string" || record.workspaceId.trim().length === 0) {
return null;
}
return { browserId: record.browserId.trim(), workspaceId: record.workspaceId.trim() };
if (
typeof record.webContentsId !== "number" ||
!Number.isInteger(record.webContentsId) ||
record.webContentsId <= 0
) {
return null;
}
return {
browserId: record.browserId.trim(),
workspaceId: record.workspaceId.trim(),
webContentsId: record.webContentsId,
};
}
function readActiveBrowserInput(
@@ -144,8 +170,6 @@ function readActiveBrowserInput(
return { workspaceId: record.workspaceId.trim(), browserId: browserId || null };
}
const pendingBrowserWebviewIds: string[] = [];
function isBrowserRefreshInput(input: Electron.Input): boolean {
if (input.type !== "keyDown" || input.alt || input.shift) {
return false;
@@ -322,16 +346,53 @@ function normalizeBrowserCaptureRect(
};
}
ipcMain.handle("paseo:browser:register-workspace-browser", (_event, rawInput: unknown) => {
const input = readBrowserWorkspaceInput(rawInput);
if (input) {
registerPaseoBrowserWorkspace(input);
ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) => {
const input = readAttachedBrowserInput(rawInput);
if (!input) {
throw new Error("Invalid attached browser registration");
}
const registered = registerAttachedPaseoBrowser({
...input,
sender: event.sender,
profileSession: getPaseoBrowserProfileSession(session),
findWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null,
});
if (!registered) {
throw new Error("Attached browser registration was rejected");
}
log.info("[browser-webview] registered", {
browserId: input.browserId,
webContentsId: input.webContentsId,
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
});
for (const url of pendingBrowserWindowOpenRequests.take(input.webContentsId)) {
event.sender.send(BROWSER_NEW_TAB_REQUEST_EVENT, {
sourceBrowserId: input.browserId,
url,
});
}
});
ipcMain.handle("paseo:browser:unregister-workspace-browser", (_event, browserId: unknown) => {
ipcMain.handle("paseo:browser:unregister-workspace-browser", async (_event, browserId: unknown) => {
if (typeof browserId === "string" && browserId.trim().length > 0) {
unregisterPaseoBrowser(browserId.trim());
const normalizedBrowserId = browserId.trim();
unregisterPaseoBrowser(normalizedBrowserId);
// COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15.
const legacyProfile = getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId);
if (legacyProfile) {
try {
await clearPaseoBrowserProfile({
profileSessions: [legacyProfile],
listGuests: () => [],
logReloadError: () => {},
});
} catch (error) {
log.warn("[browser-profile] failed to clear legacy tab profile", {
browserId: normalizedBrowserId,
error,
});
}
}
}
});
@@ -383,12 +444,23 @@ ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => {
return result;
});
ipcMain.handle("paseo:browser:clear-partition", async (_event, browserId: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
return;
}
const partition = `persist:paseo-browser-${browserId}`;
await session.fromPartition(partition).clearStorageData();
ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds: unknown) => {
const profileSessions = getPaseoBrowserProfileSessions(
session,
readLegacyPaseoBrowserIds(rawLegacyBrowserIds),
);
const profileSession = profileSessions[0];
await clearPaseoBrowserProfile({
profileSessions,
listGuests: () =>
listPaseoBrowserProfileGuests({
profileSession,
webContents: webContents.getAllWebContents(),
}),
logReloadError: (webContentsId, error) => {
log.warn("[browser-profile] failed to reload guest", { webContentsId, error });
},
});
});
ipcMain.handle(
@@ -599,12 +671,10 @@ async function createWindow(
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => {
const browserId = readBrowserIdFromWebviewAttach(params);
if (!browserId) {
if (!isPaseoBrowserWebviewAttach(params)) {
event.preventDefault();
return;
}
pendingBrowserWebviewIds.push(browserId);
webPreferences.nodeIntegration = false;
webPreferences.nodeIntegrationInSubFrames = false;
webPreferences.nodeIntegrationInWorker = false;
@@ -619,15 +689,10 @@ async function createWindow(
delete (params as { preloadURL?: string }).preloadURL;
});
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
const browserId = pendingBrowserWebviewIds.shift() ?? null;
if (browserId) {
registerPaseoBrowserWebContents(contents, browserId);
log.info("[browser-webview] registered", {
browserId,
webContentsId: contents.id,
registeredBrowserIds: listRegisteredPaseoBrowserIds(),
});
}
preparePaseoBrowserWebContents(contents);
contents.once("destroyed", () => {
pendingBrowserWindowOpenRequests.delete(contents.id);
});
contents.on("before-input-event", (event, input) => {
if (isBrowserRefreshInput(input)) {
event.preventDefault();
@@ -659,15 +724,19 @@ async function createWindow(
});
}
});
contents.setWindowOpenHandler(({ url }) =>
handleBrowserWindowOpenRequest({
contents.setWindowOpenHandler(({ url }) => {
const sourceBrowserId = getPaseoBrowserIdForWebContents(contents);
if (!sourceBrowserId) {
pendingBrowserWindowOpenRequests.add(contents.id, url);
}
return handleBrowserWindowOpenRequest({
url,
sourceBrowserId: getPaseoBrowserIdForWebContents(contents),
sourceBrowserId,
requestNewTab: (payload) => {
mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload);
},
}),
);
});
});
contents.on("context-menu", (_contextMenuEvent, params) => {
showBrowserWebviewContextMenu(mainWindow, contents, params);
});
+12 -4
View File
@@ -1,7 +1,14 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js";
type EventHandler = (payload: unknown) => void;
interface AttachedBrowserRegistration {
browserId: string;
workspaceId: string;
webContentsId: number;
}
contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
invoke: (command: string, args?: Record<string, unknown>) =>
@@ -78,16 +85,17 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
ipcRenderer.invoke("paseo:menu:set-capturing-shortcut", capturing),
},
browser: {
registerWorkspaceBrowser: (input: { browserId: string; workspaceId: string }) =>
ipcRenderer.invoke("paseo:browser:register-workspace-browser", input),
profilePartition: PASEO_BROWSER_PROFILE_PARTITION,
registerAttachedBrowser: (input: AttachedBrowserRegistration) =>
ipcRenderer.invoke("paseo:browser:register-attached", input),
unregisterWorkspaceBrowser: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:unregister-workspace-browser", browserId),
setWorkspaceActiveBrowser: (input: { workspaceId: string; browserId: string | null }) =>
ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", input),
openDevTools: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:open-devtools", browserId),
clearPartition: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:clear-partition", browserId),
clearProfile: (legacyBrowserIds: string[]) =>
ipcRenderer.invoke("paseo:browser:clear-profile", legacyBrowserIds),
executeAutomationCommand: (request: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:browser:execute-automation-command", request),
captureElement: (