mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -138,7 +138,9 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** Browser webviews are tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus still records the workspace-active browser for UI state and `list_tabs` reporting, but agent automation targets only explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. The webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) is still process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
|
||||
|
||||
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
|
||||
@@ -27,6 +27,17 @@ Run the browser automation fixture with:
|
||||
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Run the shared browser profile fixture with:
|
||||
|
||||
```bash
|
||||
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
The browser profile group runs two Electron processes in sequence. It verifies that each
|
||||
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
|
||||
tabs share cookies and local storage through one persistent session, and that the data is
|
||||
still present after the first Electron process exits and the second starts.
|
||||
|
||||
The automation group uses a real guest webview to verify the page-side ref contract:
|
||||
ARIA-like snapshot text includes headings, static text, and controls; refs survive
|
||||
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
80
packages/app/src/desktop/components/browser-data-section.tsx
Normal file
80
packages/app/src/desktop/components/browser-data-section.tsx
Normal file
@@ -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"
|
||||
|
||||
208
packages/desktop/src/features/browser-profile.test.ts
Normal file
208
packages/desktop/src/features/browser-profile.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
123
packages/desktop/src/features/browser-profile.ts
Normal file
123
packages/desktop/src/features/browser-profile.ts
Normal file
@@ -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;
|
||||
|
||||
@@ -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