Open browser links in workspace tabs (#1375)

* Open browser window requests as workspace tabs

* Tighten browser new-tab request boundaries
This commit is contained in:
Mohamed Boudra
2026-06-06 19:30:21 +08:00
committed by GitHub
parent fd022bc44b
commit 9a8912b3ef
9 changed files with 299 additions and 54 deletions

View File

@@ -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<BrowserNewTabRequest>({
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();
});
});

View File

@@ -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<BrowserNewTabRequest>;
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]);
}

View File

@@ -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<void>;
openDevTools?: (browserId: string) => Promise<unknown>;

View File

@@ -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);

View File

@@ -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<number, string>();
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);
});
}

View File

@@ -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();
});
});

View File

@@ -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" };
}

View File

@@ -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";

View File

@@ -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", () => {