diff --git a/docs/architecture.md b/docs/architecture.md index 331b0ecb7..8fa5d4dfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 24da98263..5907b1a76 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -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 diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/browser-automation/handler.test.ts index a40fb7699..6a7d3573a 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/browser-automation/handler.test.ts @@ -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 => { - this.registeredWorkspaceBrowsers.push(input); - }; - public unregisterWorkspaceBrowser = async (browserId: string): Promise => { this.unregisteredWorkspaceBrowsers.push(browserId); }; - public clearPartition = async (browserId: string): Promise => { - 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([]); }); diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/browser-automation/handler.ts index d728248ea..6f8ab8ad8 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/browser-automation/handler.ts @@ -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, diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/components/browser-pane.electron.tsx index 95485c043..f3b9141aa 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/components/browser-pane.electron.tsx @@ -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, }); } diff --git a/packages/app/src/components/browser-webview-resident.browser.test.ts b/packages/app/src/components/browser-webview-resident.browser.test.ts index d5be14d43..cca532a44 100644 --- a/packages/app/src/components/browser-webview-resident.browser.test.ts +++ b/packages/app/src/components/browser-webview-resident.browser.test.ts @@ -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", }); diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/components/browser-webview-resident.ts index 63bbed7de..7dfd7c701 100644 --- a/packages/app/src/components/browser-webview-resident.ts +++ b/packages/app/src/components/browser-webview-resident.ts @@ -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; +} + +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; } diff --git a/packages/app/src/desktop/components/browser-data-section.tsx b/packages/app/src/desktop/components/browser-data-section.tsx new file mode 100644 index 000000000..3d61f922e --- /dev/null +++ b/packages/app/src/desktop/components/browser-data-section.tsx @@ -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 ( + + + + + + {t("settings.general.browserData.siteData")} + + + {t("settings.general.browserData.description")} + + + + + + + ); +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 768552b92..88172579c 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -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; + readonly profilePartition?: string; + registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise; unregisterWorkspaceBrowser?: (browserId: string) => Promise; setWorkspaceActiveBrowser?: (input: { workspaceId: string; browserId: string | null; }) => Promise; openDevTools?: (browserId: string) => Promise; - clearPartition?: (browserId: string) => Promise; + clearProfile?: (legacyBrowserIds: string[]) => Promise; executeAutomationCommand?: ( request: BrowserAutomationExecuteRequest, ) => Promise; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 253a43a00..db9cb8e70 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1488,6 +1488,17 @@ export const ar: TranslationResources = { }, general: { title: "عام", + browserData: { + title: "بيانات المتصفح", + siteData: "ملفات تعريف الارتباط وبيانات المواقع", + description: "تتشارك علامات تبويب المتصفح تسجيلات الدخول وبيانات المواقع عبر Paseo.", + clear: "مسح بيانات المتصفح", + clearing: "جارٍ المسح...", + confirmTitle: "هل تريد مسح بيانات المتصفح؟", + confirmMessage: "سيتم تسجيل خروجك من المواقع وإعادة تحميل علامات تبويب المتصفح المفتوحة.", + success: "تم مسح بيانات المتصفح.", + error: "تعذر مسح بيانات المتصفح.", + }, defaultSend: { label: "إرسال افتراضي", descriptions: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 46d55b50f..b1099e66f 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b5a9e1d3a..085435438 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c2e5144b1..302d268c0 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6a74a8a2c..a59d26e72 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1505,6 +1505,17 @@ export const ja: TranslationResources = { }, general: { title: "一般", + browserData: { + title: "ブラウザーデータ", + siteData: "Cookie とサイトデータ", + description: "ブラウザータブ間でログイン情報とサイトデータが共有されます。", + clear: "ブラウザーデータを消去", + clearing: "消去中...", + confirmTitle: "ブラウザーデータを消去しますか?", + confirmMessage: "サイトからログアウトし、開いているブラウザータブを再読み込みします。", + success: "ブラウザーデータを消去しました。", + error: "ブラウザーデータを消去できませんでした。", + }, defaultSend: { label: "デフォルトの送信", descriptions: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8832b1ff2..b5db0d657 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e895100d7..afa6d4c0a 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 585049ca3..e947d3622 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 6267a4fa1..5b4cd584e 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -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 ( - + <> + + {isDesktopApp ? : null} + ); case "appearance": return ; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 1ae7c0778..054ae4f2c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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); }, diff --git a/packages/desktop/capture-harness/index.html b/packages/desktop/capture-harness/index.html index 0efbfafc8..f603bda64 100644 --- a/packages/desktop/capture-harness/index.html +++ b/packages/desktop/capture-harness/index.html @@ -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); diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index e3e9cb54f..538273618 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -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("Shared browser profile

Profile fixture

"); + }); + 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( diff --git a/packages/desktop/capture-harness/run.sh b/packages/desktop/capture-harness/run.sh index fc1bf139b..f668ffc15 100755 --- a/packages/desktop/capture-harness/run.sh +++ b/packages/desktop/capture-harness/run.sh @@ -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" diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts new file mode 100644 index 000000000..677708250 --- /dev/null +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -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 = Promise.resolve(); + + public clearStorageData(options: unknown): Promise { + this.storageClears.push(options); + return this.storageClear; + } + + public clearCache(): Promise { + this.cacheClears += 1; + return Promise.resolve(); + } + + public clearAuthCache(): Promise { + 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); + }); +}); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts new file mode 100644 index 000000000..1824f48aa --- /dev/null +++ b/packages/desktop/src/features/browser-profile.ts @@ -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; + clearCache(): Promise; + clearAuthCache(): Promise; +} + +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(); + 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 { + 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); + } + } +} diff --git a/packages/desktop/src/features/browser-webviews/index.test.ts b/packages/desktop/src/features/browser-webviews/index.test.ts index bec113075..f9d0d55df 100644 --- a/packages/desktop/src/features/browser-webviews/index.test.ts +++ b/packages/desktop/src/features/browser-webviews/index.test.ts @@ -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]); }); }); diff --git a/packages/desktop/src/features/browser-webviews/index.ts b/packages/desktop/src/features/browser-webviews/index.ts index ff5464994..501ad4fe7 100644 --- a/packages/desktop/src/features/browser-webviews/index.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -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); } diff --git a/packages/desktop/src/features/browser-webviews/registry.test.ts b/packages/desktop/src/features/browser-webviews/registry.test.ts index 1120feba1..11896078b 100644 --- a/packages/desktop/src/features/browser-webviews/registry.test.ts +++ b/packages/desktop/src/features/browser-webviews/registry.test.ts @@ -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"); + }); }); diff --git a/packages/desktop/src/features/browser-webviews/registry.ts b/packages/desktop/src/features/browser-webviews/registry.ts index cdadd643c..6d113f479 100644 --- a/packages/desktop/src/features/browser-webviews/registry.ts +++ b/packages/desktop/src/features/browser-webviews/registry.ts @@ -10,6 +10,17 @@ export class PaseoBrowserWebviewRegistry { private readonly activeBrowserIdsByWorkspaceId = new Map(); 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); diff --git a/packages/desktop/src/features/browser-webviews/window-open.test.ts b/packages/desktop/src/features/browser-webviews/window-open.test.ts index 9417c4e68..e40d490ad 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.test.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.test.ts @@ -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([]); + }); +}); diff --git a/packages/desktop/src/features/browser-webviews/window-open.ts b/packages/desktop/src/features/browser-webviews/window-open.ts index 18535722a..f535abb1b 100644 --- a/packages/desktop/src/features/browser-webviews/window-open.ts +++ b/packages/desktop/src/features/browser-webviews/window-open.ts @@ -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(); + + 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; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index fd4ffb7fd..28bd4fbe9 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index e109b9186..e01a38e66 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -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) => @@ -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) => ipcRenderer.invoke("paseo:browser:execute-automation-command", request), captureElement: (