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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user