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