diff --git a/packages/app/src/browser/new-tab-requests/index.test.ts b/packages/app/src/browser/new-tab-requests/index.test.ts new file mode 100644 index 000000000..92fd80f7c --- /dev/null +++ b/packages/app/src/browser/new-tab-requests/index.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceLayout } from "@/stores/workspace-layout-store"; +import { createDefaultLayout } from "@/stores/workspace-layout-store"; +import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions"; +import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from "."; + +function createLayoutWithBrowser(browserId: string): WorkspaceLayout { + return openTabInLayoutFocused({ + layout: createDefaultLayout(), + target: { kind: "browser", browserId }, + now: 1, + }).layout; +} + +describe("browser new-tab requests", () => { + it("accepts desktop requests from browser tabs in the current workspace", () => { + const request = resolveBrowserNewTabRequest({ + payload: { + sourceBrowserId: "browser-1", + url: "https://example.com/target", + }, + workspaceLayout: createLayoutWithBrowser("browser-1"), + }); + + expect(request).toEqual({ + sourceBrowserId: "browser-1", + url: "https://example.com/target", + }); + }); + + it("ignores desktop requests from another workspace", () => { + const request = resolveBrowserNewTabRequest({ + payload: { + sourceBrowserId: "browser-from-other-workspace", + url: "https://example.com/target", + }, + workspaceLayout: createLayoutWithBrowser("browser-1"), + }); + + expect(request).toBeNull(); + }); + + it("rejects unsupported desktop request URLs", () => { + const request = resolveBrowserNewTabRequest({ + payload: { + sourceBrowserId: "browser-1", + url: "file:///etc/passwd", + }, + workspaceLayout: createLayoutWithBrowser("browser-1"), + }); + + expect(request).toBeNull(); + }); +}); diff --git a/packages/app/src/browser/new-tab-requests/index.ts b/packages/app/src/browser/new-tab-requests/index.ts new file mode 100644 index 000000000..e68f0b4f7 --- /dev/null +++ b/packages/app/src/browser/new-tab-requests/index.ts @@ -0,0 +1,99 @@ +import { useEffect } from "react"; +import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host"; +import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store"; +import { getIsElectron } from "@/constants/platform"; +import { useStableEvent } from "@/hooks/use-stable-event"; + +export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent; + +function isAllowedBrowserNewTabUrl(value: string): boolean { + try { + const parsed = new URL(value); + return ( + parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank" + ); + } catch { + return false; + } +} + +function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null { + if (!payload || typeof payload !== "object") { + return null; + } + const candidate = payload as Partial; + if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) { + return null; + } + if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) { + return null; + } + return { + sourceBrowserId: candidate.sourceBrowserId, + url: candidate.url, + }; +} + +function workspaceContainsBrowser(input: { + workspaceLayout: WorkspaceLayout | null | undefined; + browserId: string; +}): boolean { + if (!input.workspaceLayout) { + return false; + } + return collectAllTabs(input.workspaceLayout.root).some((tab) => { + return tab.target.kind === "browser" && tab.target.browserId === input.browserId; + }); +} + +export function resolveBrowserNewTabRequest(input: { + payload: unknown; + workspaceLayout: WorkspaceLayout | null | undefined; +}): BrowserNewTabRequest | null { + const request = readDesktopBrowserNewTabRequest(input.payload); + if (!request) { + return null; + } + if ( + !workspaceContainsBrowser({ + workspaceLayout: input.workspaceLayout, + browserId: request.sourceBrowserId, + }) + ) { + return null; + } + return request; +} + +export function useDesktopBrowserNewTabRequests(input: { + enabled: boolean; + workspaceLayout: WorkspaceLayout | null | undefined; + openUrl: (url: string) => void; +}): void { + const handleNewTabRequest = useStableEvent((payload: unknown) => { + const request = resolveBrowserNewTabRequest({ + payload, + workspaceLayout: input.workspaceLayout, + }); + if (!request) { + return; + } + input.openUrl(request.url); + }); + + useEffect(() => { + if (!input.enabled || !getIsElectron()) { + return; + } + const unsubscribe = getDesktopHost()?.events?.on?.( + "browser-new-tab-request", + handleNewTabRequest, + ); + if (typeof unsubscribe === "function") { + return unsubscribe; + } + return () => { + void unsubscribe?.then((dispose) => dispose()); + }; + }, [handleNewTabRequest, input.enabled]); +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index f61909903..3de1e50b4 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -111,6 +111,11 @@ export interface DesktopBrowserShortcutEvent { action: "focus-url"; } +export interface DesktopBrowserNewTabRequestEvent { + sourceBrowserId: string; + url: string; +} + export interface DesktopBrowserBridge { setWorkspaceActiveBrowser?: (browserId: string | null) => Promise; openDevTools?: (browserId: string) => Promise; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 146514d1f..584e002e0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -130,6 +130,7 @@ import { buildWorkspaceTabMenuEntries, type WorkspaceTabMenuEntry, } from "@/screens/workspace/workspace-tab-menu"; +import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceHeaderRenderState, @@ -2329,6 +2330,12 @@ function WorkspaceScreenContent({ [openWorkspaceTabFocused, persistenceKey], ); + useDesktopBrowserNewTabRequests({ + enabled: Boolean(persistenceKey), + workspaceLayout, + openUrl: handleOpenUrlInBrowserTab, + }); + const handleSelectSwitcherTab = useCallback( (key: string) => { navigateToTabId(key); diff --git a/packages/desktop/src/features/browser-webviews.ts b/packages/desktop/src/features/browser-webviews/index.ts similarity index 53% rename from packages/desktop/src/features/browser-webviews.ts rename to packages/desktop/src/features/browser-webviews/index.ts index f71cb5850..c6c575b8b 100644 --- a/packages/desktop/src/features/browser-webviews.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -1,8 +1,34 @@ import { webContents as allWebContents, type WebContents } from "electron"; +import { + BROWSER_NEW_TAB_REQUEST_EVENT, + handleBrowserWindowOpenRequest, + isAllowedBrowserWebviewUrl, +} from "./window-open.js"; + +export { BROWSER_NEW_TAB_REQUEST_EVENT, handleBrowserWindowOpenRequest }; const browserIdsByWebContentsId = new Map(); let workspaceActiveBrowserId: string | null = null; +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; +} + +export function readBrowserIdFromWebviewAttach(input: { + src?: string; + partition?: string; +}): string | null { + if (!isAllowedBrowserWebviewUrl(input.src)) { + return null; + } + return getBrowserIdFromWebviewPartition(input.partition); +} + export function listRegisteredPaseoBrowserIds(): string[] { return Array.from(new Set(browserIdsByWebContentsId.values())).sort(); } @@ -45,3 +71,24 @@ export function getWorkspaceActivePaseoBrowserWebContents(): WebContents | null } return getPaseoBrowserWebContents(workspaceActiveBrowserId); } + +function preventUnsafeBrowserWebviewNavigation( + event: { preventDefault: () => void }, + url: string | undefined, +): void { + if (!isAllowedBrowserWebviewUrl(url)) { + event.preventDefault(); + } +} + +export function registerBrowserWebviewNavigationGuards(contents: WebContents): void { + contents.on("will-navigate", (event) => { + preventUnsafeBrowserWebviewNavigation(event, event.url); + }); + contents.on("will-frame-navigate", (event) => { + preventUnsafeBrowserWebviewNavigation(event, event.url); + }); + contents.on("will-redirect", (event) => { + preventUnsafeBrowserWebviewNavigation(event, event.url); + }); +} diff --git a/packages/desktop/src/features/browser-webviews/window-open.test.ts b/packages/desktop/src/features/browser-webviews/window-open.test.ts new file mode 100644 index 000000000..9417c4e68 --- /dev/null +++ b/packages/desktop/src/features/browser-webviews/window-open.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +import { handleBrowserWindowOpenRequest } from "."; + +describe("browser webview window-open requests", () => { + it("denies Electron window creation and requests a Paseo browser tab", () => { + const requestNewTab = vi.fn(); + + const result = handleBrowserWindowOpenRequest({ + url: "https://example.com/target", + sourceBrowserId: "browser-1", + requestNewTab, + }); + + expect(result).toEqual({ action: "deny" }); + expect(requestNewTab).toHaveBeenCalledWith({ + sourceBrowserId: "browser-1", + url: "https://example.com/target", + }); + }); + + it("denies unsupported window-open requests before asking for a Paseo browser tab", () => { + const requestNewTab = vi.fn(); + + const result = handleBrowserWindowOpenRequest({ + url: "file:///etc/passwd", + sourceBrowserId: "browser-1", + requestNewTab, + }); + + expect(result).toEqual({ action: "deny" }); + expect(requestNewTab).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/src/features/browser-webviews/window-open.ts b/packages/desktop/src/features/browser-webviews/window-open.ts new file mode 100644 index 000000000..18535722a --- /dev/null +++ b/packages/desktop/src/features/browser-webviews/window-open.ts @@ -0,0 +1,36 @@ +export const BROWSER_NEW_TAB_REQUEST_EVENT = "paseo:event:browser-new-tab-request"; + +export interface BrowserNewTabRequestPayload { + sourceBrowserId: string; + url: string; +} + +export function isAllowedBrowserWebviewUrl(value: string | undefined): boolean { + if (!value) { + return true; + } + try { + const parsed = new URL(value); + return ( + parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank" + ); + } catch { + return false; + } +} + +export function handleBrowserWindowOpenRequest(input: { + url: string; + sourceBrowserId: string | null; + requestNewTab: (payload: BrowserNewTabRequestPayload) => void; +}): { action: "deny" } { + if (!isAllowedBrowserWebviewUrl(input.url) || !input.sourceBrowserId) { + return { action: "deny" }; + } + + input.requestNewTab({ + sourceBrowserId: input.sourceBrowserId, + url: input.url, + }); + return { action: "deny" }; +} diff --git a/packages/desktop/src/features/menu.ts b/packages/desktop/src/features/menu.ts index 94f29ac53..6e56e8efd 100644 --- a/packages/desktop/src/features/menu.ts +++ b/packages/desktop/src/features/menu.ts @@ -1,5 +1,5 @@ import { app, Menu, BrowserWindow, ipcMain } from "electron"; -import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews.js"; +import { getWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js"; interface ShowContextMenuInput { kind?: "terminal"; diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 17721ee30..4f4cdedbc 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -46,12 +46,16 @@ import { registerOpenerHandlers } from "./features/opener.js"; import { registerEditorTargetHandlers } from "./features/editor-targets.js"; import { setupApplicationMenu } from "./features/menu.js"; import { + BROWSER_NEW_TAB_REQUEST_EVENT, getPaseoBrowserIdForWebContents, getPaseoBrowserWebContents, + handleBrowserWindowOpenRequest, listRegisteredPaseoBrowserIds, + readBrowserIdFromWebviewAttach, + registerBrowserWebviewNavigationGuards, registerPaseoBrowserWebContents, setWorkspaceActivePaseoBrowserId, -} from "./features/browser-webviews.js"; +} from "./features/browser-webviews/index.js"; import { parseOpenProjectPathFromArgv } from "./open-project-routing.js"; import { PendingOpenProjectStore } from "./pending-open-project-store.js"; import { getDesktopSettingsStore } from "./settings/desktop-settings-electron.js"; @@ -73,28 +77,6 @@ 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"; -function isAllowedBrowserWebviewUrl(value: string | undefined): boolean { - if (!value) { - return true; - } - try { - const parsed = new URL(value); - return ( - parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank" - ); - } catch { - return false; - } -} - -function preventUnsafeBrowserWebviewNavigation( - event: Electron.Event, - url: string | undefined, -): void { - if (!isAllowedBrowserWebviewUrl(url)) { - event.preventDefault(); - } -} const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut"; const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key"; @@ -127,15 +109,6 @@ const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE"; const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop"; app.setName(APP_NAME); -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; -} - const pendingBrowserWebviewIds: string[] = []; function isBrowserRefreshInput(input: Electron.Input): boolean { @@ -470,11 +443,7 @@ async function createWindow( setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); mainWindow.webContents.on("will-attach-webview", (event, webPreferences, params) => { - if (!isAllowedBrowserWebviewUrl(params.src)) { - event.preventDefault(); - return; - } - const browserId = getBrowserIdFromWebviewPartition(params.partition); + const browserId = readBrowserIdFromWebviewAttach(params); if (!browserId) { event.preventDefault(); return; @@ -534,25 +503,19 @@ async function createWindow( }); } }); - contents.setWindowOpenHandler(({ url }) => { - if (!isAllowedBrowserWebviewUrl(url)) { - return { action: "deny" }; - } - contents.loadURL(url).catch(() => undefined); - return { action: "deny" }; - }); + contents.setWindowOpenHandler(({ url }) => + handleBrowserWindowOpenRequest({ + url, + sourceBrowserId: getPaseoBrowserIdForWebContents(contents), + requestNewTab: (payload) => { + mainWindow.webContents.send(BROWSER_NEW_TAB_REQUEST_EVENT, payload); + }, + }), + ); contents.on("context-menu", (_contextMenuEvent, params) => { showBrowserWebviewContextMenu(mainWindow, contents, params); }); - contents.on("will-navigate", (event) => { - preventUnsafeBrowserWebviewNavigation(event, event.url); - }); - contents.on("will-frame-navigate", (event) => { - preventUnsafeBrowserWebviewNavigation(event, event.url); - }); - contents.on("will-redirect", (event) => { - preventUnsafeBrowserWebviewNavigation(event, event.url); - }); + registerBrowserWebviewNavigationGuards(contents); }); mainWindow.once("ready-to-show", () => {