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:
@@ -73,10 +73,7 @@ class FakeDaemonClient {
|
||||
|
||||
class FakeBrowserBridge {
|
||||
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
|
||||
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
|
||||
[];
|
||||
public readonly unregisteredWorkspaceBrowsers: string[] = [];
|
||||
public readonly clearedPartitions: string[] = [];
|
||||
public readonly activeWorkspaceBrowsers: Array<{
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
@@ -94,21 +91,10 @@ class FakeBrowserBridge {
|
||||
return this.response ?? currentListTabsPayload(request.requestId);
|
||||
};
|
||||
|
||||
public registerWorkspaceBrowser = async (input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> => {
|
||||
this.registeredWorkspaceBrowsers.push(input);
|
||||
};
|
||||
|
||||
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
|
||||
this.unregisteredWorkspaceBrowsers.push(browserId);
|
||||
};
|
||||
|
||||
public clearPartition = async (browserId: string): Promise<void> => {
|
||||
this.clearedPartitions.push(browserId);
|
||||
};
|
||||
|
||||
public setWorkspaceActiveBrowser = async (input: {
|
||||
browserId: string | null;
|
||||
workspaceId: string;
|
||||
@@ -118,9 +104,17 @@ class FakeBrowserBridge {
|
||||
}
|
||||
|
||||
class FakeResidentBrowser {
|
||||
public readonly ensuredWebviews: Array<{ browserId: string; url: string }> = [];
|
||||
public readonly ensuredWebviews: Array<{
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
url: string;
|
||||
}> = [];
|
||||
|
||||
public ensure = (input: { browserId: string; url: string }): HTMLElement | null => {
|
||||
public ensure = (input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
url: string;
|
||||
}): HTMLElement | null => {
|
||||
this.ensuredWebviews.push(input);
|
||||
return null;
|
||||
};
|
||||
@@ -321,12 +315,13 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
}),
|
||||
);
|
||||
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
|
||||
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
|
||||
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
|
||||
]);
|
||||
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
{ browserId: result.browserId, url: "https://example.com" },
|
||||
{
|
||||
browserId: result.browserId,
|
||||
workspaceId: "wks_workspace_a",
|
||||
url: "https://example.com",
|
||||
},
|
||||
]);
|
||||
expect(browser.browser.executedRequests).toEqual([
|
||||
{
|
||||
@@ -366,7 +361,10 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
},
|
||||
]);
|
||||
expect(browser.resident.ensuredWebviews).toEqual([
|
||||
expect.objectContaining({ url: "https://example.com" }),
|
||||
expect.objectContaining({
|
||||
workspaceId: "wks_workspace_a",
|
||||
url: "https://example.com",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -440,7 +438,7 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
|
||||
test("browser_close_tab removes the workspace tab, browser record, resident webview, and registry entry", async () => {
|
||||
const browser = new BrowserAutomationHandlerHarness();
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: "server-1",
|
||||
@@ -466,7 +464,6 @@ describe("mountBrowserAutomationHandler", () => {
|
||||
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
|
||||
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
|
||||
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
|
||||
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
|
||||
expect(currentBrowserTabs()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -258,7 +258,6 @@ async function closeBrowserTabForRequest(params: {
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
removeResidentBrowserWebview(browserId);
|
||||
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
|
||||
await browserHost?.clearPartition?.(browserId);
|
||||
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
@@ -337,10 +336,8 @@ async function openBrowserTabForRequest(params: {
|
||||
browserId,
|
||||
});
|
||||
|
||||
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
|
||||
if (browserHost?.executeAutomationCommand) {
|
||||
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
|
||||
ensureResidentBrowserWebview({ browserId, workspaceId, url: normalizedUrl });
|
||||
const registered = await waitForBrowserRegistration({
|
||||
request,
|
||||
browserId,
|
||||
|
||||
@@ -740,10 +740,10 @@ export function BrowserPane({
|
||||
const residentWebview = takeResidentBrowserWebview(browserId) as ElectronWebview | null;
|
||||
const webview = residentWebview ?? (document.createElement("webview") as ElectronWebview);
|
||||
webviewRef.current = webview;
|
||||
void getDesktopHost()?.browser?.registerWorkspaceBrowser?.({ browserId, workspaceId });
|
||||
if (!residentWebview) {
|
||||
prepareBrowserWebview(webview, {
|
||||
browserId,
|
||||
workspaceId,
|
||||
initialUrl: initialUnsafeNavigationMessage ? "about:blank" : initialUrlRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BrowserWebviewProfileHost,
|
||||
clearResidentBrowserWebviewsForTests,
|
||||
ensureResidentBrowserWebview,
|
||||
prepareBrowserWebview,
|
||||
@@ -9,6 +10,32 @@ import {
|
||||
} from "./browser-webview-resident";
|
||||
|
||||
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const attachedBrowsers: Array<{
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
webContentsId: number;
|
||||
}> = [];
|
||||
const profileHost: BrowserWebviewProfileHost = {
|
||||
profilePartition: "persist:paseo-browser",
|
||||
registerAttachedBrowser: async (input) => {
|
||||
attachedBrowsers.push(input);
|
||||
},
|
||||
};
|
||||
|
||||
function ensureTestBrowser(input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
url: string;
|
||||
}): HTMLElement | null {
|
||||
return ensureResidentBrowserWebview({ ...input, profileHost });
|
||||
}
|
||||
|
||||
function prepareTestBrowser(
|
||||
webview: HTMLElement,
|
||||
input: { browserId: string; workspaceId: string; initialUrl?: string | null },
|
||||
): void {
|
||||
prepareBrowserWebview(webview, { ...input, profileHost });
|
||||
}
|
||||
|
||||
function residentHost(): HTMLElement {
|
||||
const host = document.getElementById(RESIDENT_HOST_ID);
|
||||
@@ -44,6 +71,10 @@ function expectResidentWebviewParking(webview: HTMLElement): void {
|
||||
}
|
||||
|
||||
describe("resident browser webviews", () => {
|
||||
beforeEach(() => {
|
||||
attachedBrowsers.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearResidentBrowserWebviewsForTests();
|
||||
});
|
||||
@@ -65,15 +96,16 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
it("creates a resident webview for an agent-created unfocused tab", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-agent",
|
||||
workspaceId: "workspace-agent",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
expect(webview).not.toBeNull();
|
||||
expect(webview?.isConnected).toBe(true);
|
||||
expect(webview?.getAttribute("data-paseo-browser-id")).toBe("browser-agent");
|
||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser-browser-agent");
|
||||
expect(webview?.getAttribute("partition")).toBe("persist:paseo-browser");
|
||||
expect((webview as HTMLUnknownElement & { src?: string })?.src).toContain(
|
||||
"https://example.com",
|
||||
);
|
||||
@@ -81,6 +113,34 @@ describe("resident browser webviews", () => {
|
||||
expectResidentWebviewParking(webview as HTMLElement);
|
||||
});
|
||||
|
||||
it("shares one profile and registers attached guests with explicit identity", () => {
|
||||
const firstWebview = ensureTestBrowser({
|
||||
browserId: "browser-first",
|
||||
workspaceId: "workspace-a",
|
||||
url: "https://example.com/first",
|
||||
});
|
||||
const secondWebview = ensureTestBrowser({
|
||||
browserId: "browser-second",
|
||||
workspaceId: "workspace-b",
|
||||
url: "https://example.com/second",
|
||||
});
|
||||
if (!firstWebview || !secondWebview) {
|
||||
throw new Error("Expected resident webviews");
|
||||
}
|
||||
Object.assign(firstWebview, { getWebContentsId: () => 101 });
|
||||
Object.assign(secondWebview, { getWebContentsId: () => 202 });
|
||||
|
||||
firstWebview.dispatchEvent(new Event("did-attach"));
|
||||
secondWebview.dispatchEvent(new Event("did-attach"));
|
||||
|
||||
expect(firstWebview.getAttribute("partition")).toBe("persist:paseo-browser");
|
||||
expect(secondWebview.getAttribute("partition")).toBe("persist:paseo-browser");
|
||||
expect(attachedBrowsers).toEqual([
|
||||
{ browserId: "browser-first", workspaceId: "workspace-a", webContentsId: 101 },
|
||||
{ browserId: "browser-second", workspaceId: "workspace-b", webContentsId: 202 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes an existing resident host back to permanent parking", () => {
|
||||
const staleHost = document.createElement("div");
|
||||
staleHost.id = RESIDENT_HOST_ID;
|
||||
@@ -91,8 +151,9 @@ describe("resident browser webviews", () => {
|
||||
staleHost.style.display = "none";
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-stale-host",
|
||||
workspaceId: "workspace-stale-host",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
@@ -111,8 +172,9 @@ describe("resident browser webviews", () => {
|
||||
staleHost.style.display = "none";
|
||||
|
||||
const staleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(staleWebview, {
|
||||
prepareTestBrowser(staleWebview, {
|
||||
browserId: "browser-stale-child",
|
||||
workspaceId: "workspace-stale-child",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
staleWebview.style.display = "none";
|
||||
@@ -123,8 +185,9 @@ describe("resident browser webviews", () => {
|
||||
staleHost.appendChild(staleWebview);
|
||||
document.body.appendChild(staleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-stale-child",
|
||||
workspaceId: "workspace-stale-child",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
@@ -135,12 +198,14 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
it("parks resident webviews as an overlapping stack", () => {
|
||||
const firstWebview = ensureResidentBrowserWebview({
|
||||
const firstWebview = ensureTestBrowser({
|
||||
browserId: "browser-first",
|
||||
workspaceId: "workspace-stack",
|
||||
url: "https://example.com/first",
|
||||
});
|
||||
const secondWebview = ensureResidentBrowserWebview({
|
||||
const secondWebview = ensureTestBrowser({
|
||||
browserId: "browser-second",
|
||||
workspaceId: "workspace-stack",
|
||||
url: "https://example.com/second",
|
||||
});
|
||||
|
||||
@@ -152,8 +217,9 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
it("moves a resident webview into a visible pane without recreating the node", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-visible",
|
||||
workspaceId: "workspace-visible",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
@@ -170,15 +236,17 @@ describe("resident browser webviews", () => {
|
||||
it("returns an existing visible pane webview instead of creating a resident duplicate", () => {
|
||||
const visibleHost = document.createElement("div");
|
||||
const visibleWebview = document.createElement("webview");
|
||||
prepareBrowserWebview(visibleWebview, {
|
||||
prepareTestBrowser(visibleWebview, {
|
||||
browserId: "browser-visible-pane",
|
||||
workspaceId: "workspace-visible-pane",
|
||||
initialUrl: "https://example.com",
|
||||
});
|
||||
visibleHost.appendChild(visibleWebview);
|
||||
document.body.appendChild(visibleHost);
|
||||
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-visible-pane",
|
||||
workspaceId: "workspace-visible-pane",
|
||||
url: "https://example.com/agent",
|
||||
});
|
||||
|
||||
@@ -187,8 +255,9 @@ describe("resident browser webviews", () => {
|
||||
});
|
||||
|
||||
it("removes a resident webview when its browser tab closes", () => {
|
||||
const webview = ensureResidentBrowserWebview({
|
||||
const webview = ensureTestBrowser({
|
||||
browserId: "browser-closed",
|
||||
workspaceId: "workspace-closed",
|
||||
url: "https://example.com",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
getDesktopHost,
|
||||
type DesktopAttachedBrowserRegistration,
|
||||
type DesktopBrowserBridge,
|
||||
} from "@/desktop/host";
|
||||
|
||||
const RESIDENT_BROWSER_HOST_ID = "paseo-browser-resident-webviews";
|
||||
const BROWSER_ID_ATTRIBUTE = "data-paseo-browser-id";
|
||||
const RESIDENT_VIEWPORT_WIDTH = 1280;
|
||||
@@ -8,6 +14,62 @@ const residentWebviewSizesByBrowserId = new Map<string, { width: number; height:
|
||||
|
||||
interface BrowserWebviewElement extends HTMLElement {
|
||||
src: string;
|
||||
getWebContentsId(): number;
|
||||
}
|
||||
|
||||
interface BrowserWebviewIdentity {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface BrowserWebviewProfileHost {
|
||||
profilePartition: string;
|
||||
registerAttachedBrowser(input: DesktopAttachedBrowserRegistration): Promise<void>;
|
||||
}
|
||||
|
||||
function isAttachedBrowserBridge(
|
||||
browser: DesktopBrowserBridge | undefined,
|
||||
): browser is BrowserWebviewProfileHost {
|
||||
return (
|
||||
browser !== undefined &&
|
||||
typeof browser.profilePartition === "string" &&
|
||||
browser.profilePartition.startsWith("persist:") &&
|
||||
typeof browser.registerAttachedBrowser === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function getBrowserBridge(override?: BrowserWebviewProfileHost): BrowserWebviewProfileHost {
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const browser = getDesktopHost()?.browser;
|
||||
if (!isAttachedBrowserBridge(browser)) {
|
||||
throw new Error("Electron browser profile bridge is unavailable");
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
function registerBrowserWhenAttached(
|
||||
webview: BrowserWebviewElement,
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -104,21 +166,30 @@ function clearResidentWebviewParkingStyle(webview: HTMLElement): void {
|
||||
|
||||
export function prepareBrowserWebview(
|
||||
webview: HTMLElement,
|
||||
input: { browserId: string; initialUrl?: string | null },
|
||||
input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
initialUrl?: string | null;
|
||||
profileHost?: BrowserWebviewProfileHost;
|
||||
},
|
||||
): void {
|
||||
const browser = getBrowserBridge(input.profileHost);
|
||||
webview.setAttribute(BROWSER_ID_ATTRIBUTE, input.browserId);
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${input.browserId}`);
|
||||
webview.setAttribute("partition", browser.profilePartition);
|
||||
webview.setAttribute("allowpopups", "true");
|
||||
webview.setAttribute("spellcheck", "false");
|
||||
webview.setAttribute("autosize", "on");
|
||||
if (input.initialUrl) {
|
||||
(webview as BrowserWebviewElement).src = input.initialUrl;
|
||||
}
|
||||
registerBrowserWhenAttached(webview as BrowserWebviewElement, input, browser);
|
||||
}
|
||||
|
||||
export function ensureResidentBrowserWebview(input: {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
url: string;
|
||||
profileHost?: BrowserWebviewProfileHost;
|
||||
}): HTMLElement | null {
|
||||
const browserId = trimNonEmpty(input.browserId);
|
||||
if (!browserId) {
|
||||
@@ -144,7 +215,12 @@ export function ensureResidentBrowserWebview(input: {
|
||||
}
|
||||
|
||||
const webview = ownerDocument.createElement("webview") as BrowserWebviewElement;
|
||||
prepareBrowserWebview(webview, { browserId, initialUrl: input.url });
|
||||
prepareBrowserWebview(webview, {
|
||||
browserId,
|
||||
workspaceId: input.workspaceId,
|
||||
initialUrl: input.url,
|
||||
profileHost: input.profileHost,
|
||||
});
|
||||
releaseResidentBrowserWebview(browserId, webview);
|
||||
return webview;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
export function BrowserDataSection() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const clearInFlightRef = useRef(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
if (clearInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInFlightRef.current = true;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.general.browserData.confirmTitle"),
|
||||
message: t("settings.general.browserData.confirmMessage"),
|
||||
confirmLabel: t("settings.general.browserData.clear"),
|
||||
cancelLabel: t("common.actions.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearProfile = getDesktopHost()?.browser?.clearProfile;
|
||||
if (!clearProfile) {
|
||||
throw new Error("Electron browser profile bridge is unavailable");
|
||||
}
|
||||
|
||||
await clearProfile(Object.keys(useBrowserStore.getState().browsersById));
|
||||
toast.show(t("settings.general.browserData.success"), { variant: "success" });
|
||||
} catch {
|
||||
toast.error(t("settings.general.browserData.error"));
|
||||
} finally {
|
||||
clearInFlightRef.current = false;
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [t, toast]);
|
||||
const clearButtonLabel = isClearing
|
||||
? t("settings.general.browserData.clearing")
|
||||
: t("settings.general.browserData.clear");
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.browserData.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.browserData.siteData")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.browserData.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
onPress={handleClear}
|
||||
>
|
||||
{clearButtonLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -128,15 +128,22 @@ export interface DesktopBrowserNewTabRequestEvent {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface DesktopAttachedBrowserRegistration {
|
||||
browserId: string;
|
||||
workspaceId: string;
|
||||
webContentsId: number;
|
||||
}
|
||||
|
||||
export interface DesktopBrowserBridge {
|
||||
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
|
||||
readonly profilePartition?: string;
|
||||
registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise<void>;
|
||||
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
|
||||
setWorkspaceActiveBrowser?: (input: {
|
||||
workspaceId: string;
|
||||
browserId: string | null;
|
||||
}) => Promise<void>;
|
||||
openDevTools?: (browserId: string) => Promise<unknown>;
|
||||
clearPartition?: (browserId: string) => Promise<void>;
|
||||
clearProfile?: (legacyBrowserIds: string[]) => Promise<void>;
|
||||
executeAutomationCommand?: (
|
||||
request: BrowserAutomationExecuteRequest,
|
||||
) => Promise<BrowserAutomationExecuteResponse["payload"]>;
|
||||
|
||||
@@ -1488,6 +1488,17 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
browserData: {
|
||||
title: "بيانات المتصفح",
|
||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
description: "تتشارك علامات تبويب المتصفح تسجيلات الدخول وبيانات المواقع عبر Paseo.",
|
||||
clear: "مسح بيانات المتصفح",
|
||||
clearing: "جارٍ المسح...",
|
||||
confirmTitle: "هل تريد مسح بيانات المتصفح؟",
|
||||
confirmMessage: "سيتم تسجيل خروجك من المواقع وإعادة تحميل علامات تبويب المتصفح المفتوحة.",
|
||||
success: "تم مسح بيانات المتصفح.",
|
||||
error: "تعذر مسح بيانات المتصفح.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "إرسال افتراضي",
|
||||
descriptions: {
|
||||
|
||||
@@ -1496,6 +1496,17 @@ export const en = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
browserData: {
|
||||
title: "Browser data",
|
||||
siteData: "Cookies and site data",
|
||||
description: "Browser tabs share sign-ins and site data across Paseo.",
|
||||
clear: "Clear browser data",
|
||||
clearing: "Clearing...",
|
||||
confirmTitle: "Clear browser data?",
|
||||
confirmMessage: "Sites will be signed out and open browser tabs will reload.",
|
||||
success: "Browser data cleared.",
|
||||
error: "Couldn't clear browser data.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "Default send",
|
||||
descriptions: {
|
||||
|
||||
@@ -1527,6 +1527,19 @@ export const es: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
browserData: {
|
||||
title: "Datos del navegador",
|
||||
siteData: "Cookies y datos de sitios",
|
||||
description:
|
||||
"Las pestañas del navegador comparten inicios de sesión y datos de sitios en Paseo.",
|
||||
clear: "Borrar datos del navegador",
|
||||
clearing: "Borrando...",
|
||||
confirmTitle: "¿Borrar los datos del navegador?",
|
||||
confirmMessage:
|
||||
"Se cerrarán las sesiones de los sitios y se recargarán las pestañas abiertas del navegador.",
|
||||
success: "Datos del navegador borrados.",
|
||||
error: "No se pudieron borrar los datos del navegador.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "Envío predeterminado",
|
||||
descriptions: {
|
||||
|
||||
@@ -1530,6 +1530,18 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
browserData: {
|
||||
title: "Données du navigateur",
|
||||
siteData: "Cookies et données des sites",
|
||||
description:
|
||||
"Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.",
|
||||
clear: "Effacer les données du navigateur",
|
||||
clearing: "Effacement...",
|
||||
confirmTitle: "Effacer les données du navigateur ?",
|
||||
confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.",
|
||||
success: "Données du navigateur effacées.",
|
||||
error: "Impossible d'effacer les données du navigateur.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "Envoi par défaut",
|
||||
descriptions: {
|
||||
|
||||
@@ -1505,6 +1505,17 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
browserData: {
|
||||
title: "ブラウザーデータ",
|
||||
siteData: "Cookie とサイトデータ",
|
||||
description: "ブラウザータブ間でログイン情報とサイトデータが共有されます。",
|
||||
clear: "ブラウザーデータを消去",
|
||||
clearing: "消去中...",
|
||||
confirmTitle: "ブラウザーデータを消去しますか?",
|
||||
confirmMessage: "サイトからログアウトし、開いているブラウザータブを再読み込みします。",
|
||||
success: "ブラウザーデータを消去しました。",
|
||||
error: "ブラウザーデータを消去できませんでした。",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "デフォルトの送信",
|
||||
descriptions: {
|
||||
|
||||
@@ -1513,6 +1513,18 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
browserData: {
|
||||
title: "Dados do navegador",
|
||||
siteData: "Cookies e dados de sites",
|
||||
description: "As abas do navegador compartilham logins e dados de sites no Paseo.",
|
||||
clear: "Limpar dados do navegador",
|
||||
clearing: "Limpando...",
|
||||
confirmTitle: "Limpar dados do navegador?",
|
||||
confirmMessage:
|
||||
"Você será desconectado dos sites e as abas abertas do navegador serão recarregadas.",
|
||||
success: "Dados do navegador limpos.",
|
||||
error: "Não foi possível limpar os dados do navegador.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "Envio padrão",
|
||||
descriptions: {
|
||||
|
||||
@@ -1519,6 +1519,18 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Общий",
|
||||
browserData: {
|
||||
title: "Данные браузера",
|
||||
siteData: "Файлы cookie и данные сайтов",
|
||||
description: "Вкладки браузера используют общие данные входа и данные сайтов в Paseo.",
|
||||
clear: "Очистить данные браузера",
|
||||
clearing: "Очистка...",
|
||||
confirmTitle: "Очистить данные браузера?",
|
||||
confirmMessage:
|
||||
"На сайтах будет выполнен выход, а открытые вкладки браузера перезагрузятся.",
|
||||
success: "Данные браузера очищены.",
|
||||
error: "Не удалось очистить данные браузера.",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "Отправка по умолчанию",
|
||||
descriptions: {
|
||||
|
||||
@@ -1472,6 +1472,17 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
browserData: {
|
||||
title: "浏览器数据",
|
||||
siteData: "Cookie 和网站数据",
|
||||
description: "浏览器标签页在 Paseo 中共享登录状态和网站数据。",
|
||||
clear: "清除浏览器数据",
|
||||
clearing: "正在清除...",
|
||||
confirmTitle: "清除浏览器数据?",
|
||||
confirmMessage: "网站帐号将退出登录,打开的浏览器标签页将重新加载。",
|
||||
success: "浏览器数据已清除。",
|
||||
error: "无法清除浏览器数据。",
|
||||
},
|
||||
defaultSend: {
|
||||
label: "默认发送",
|
||||
descriptions: {
|
||||
|
||||
@@ -70,6 +70,7 @@ import { CommunityLinks } from "@/components/community-links";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { BrowserDataSection } from "@/desktop/components/browser-data-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
@@ -1385,14 +1386,17 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
<>
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
{isDesktopApp ? <BrowserDataSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
|
||||
@@ -1970,7 +1970,7 @@ function WorkspaceScreenContent({
|
||||
const { browserId } = input.target;
|
||||
useBrowserStore.getState().removeBrowser(browserId);
|
||||
removeResidentBrowserWebview(browserId);
|
||||
void getDesktopHost()?.browser?.clearPartition?.(browserId);
|
||||
void getDesktopHost()?.browser?.unregisterWorkspaceBrowser?.(browserId);
|
||||
}
|
||||
closeWorkspaceTab(persistenceKey, normalizedTabId);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user