Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,50 @@ describe("isPreviewRefreshShortcut", () => {
});
});

describe("previewWindowOpenAction", () => {
const details = (overrides: {
readonly url?: string;
readonly disposition?: Electron.HandlerDetails["disposition"];
}) => ({
url: "https://accounts.google.com/o/oauth2/auth",
disposition: "new-window" as Electron.HandlerDetails["disposition"],
...overrides,
});

it("opens a real window for scripted popups so the opener survives", () => {
// OAuth SDKs read a null `window.open()` as a blocked popup, and they need
// the opener alive to receive the credential back.
expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup");
expect(
PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })),
).toBe("popup");
});

it("keeps target=_blank links in the preview tab", () => {
expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
"navigate",
);
expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
"navigate",
);
});

it("does not hand a window to schemes that cannot be hardened", () => {
// A popup skips the `will-attach-webview` hardening, so it only gets a window
// when its preferences can be overridden. Chromium copies the guest's
// preferences for `about:blank` and forbids overriding them.
for (const url of [
"about:blank",
"javascript:alert(1)",
"file:///etc/passwd",
"vscode://vscode-remote/ssh-remote+box/tmp",
"not a url",
]) {
expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
}
});
});

const {
browserWindowConstructor,
createFromPath,
Expand Down
75 changes: 73 additions & 2 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,66 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
{ key: "w", meta: true, shift: false, control: false },
]);

/**
* Protocols a preview page may open in a real popup window.
*
* `about:blank` stays out: Chromium skips browser-side navigation for it, so the
* child copies the guest's `contextIsolation: false` preferences and Electron
* gives no way to override them. Those popups keep loading in the preview tab.
*
* Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits
* `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal`
* and not in a window spawned by a third-party page in the preview.
*/
const POPUP_PROTOCOLS = new Set(["http:", "https:"]);

const isPopupUrl = (rawUrl: string): boolean => {
try {
return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
} catch {
return false;
}
};

/**
* Preferences for a popup a preview page opens.
*
* A popup is not a webview attach, so the `will-attach-webview` hardening in
* `DesktopWindow` never sees it, and an unoverridden child would inherit the
* guest's relaxed posture: the picker preload needs `contextIsolation: false`
* to share `globalThis` with the previewed page, and no OAuth provider should
* get that. The window keeps the opener and the guest session either way.
*/
const POPUP_WINDOW_OPTIONS = {
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
// `preload` is a webPreference too, so an unset one is inherited from the
// guest. Preview guests load Pylon's pick/annotation preload, which imports
// `ipcRenderer` and was written for the trusted preview surface — it has no
// business running on a third-party sign-in page.
preload: "",
},
} satisfies Electron.BrowserWindowConstructorOptions;

/**
* Decides what a preview page's `window.open` should do.
*
* `"popup"` opens a real window, which scripted popups need: denying them makes
* `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and
* navigating the preview tab instead destroys the opener the popup has to
* `postMessage` its result back to.
*
* `target="_blank"` links arrive as a tab disposition and keep loading in the
* preview tab, which is what people expect from a link inside a preview.
*/
export const previewWindowOpenAction = (details: {
readonly url: string;
readonly disposition: Electron.HandlerDetails["disposition"];
}): "popup" | "navigate" =>
details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";

export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
input.type === "keyDown" &&
input.key.toLowerCase() === "r" &&
Expand Down Expand Up @@ -1661,6 +1721,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
],
});
});
// A popup opens with Electron's default handler, so the page inside it could
// otherwise spawn native windows without limit. Nothing in an OAuth flow
// opens a second popup, so the chain stops at the first one.
const windowCreated = (window: Electron.BrowserWindow): void => {
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
};
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
if (isPreviewRefreshShortcut(input)) {
event.preventDefault();
Expand All @@ -1686,6 +1752,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.off("did-stop-loading", sync);
wc.off("did-fail-load", failed as never);
wc.off("audio-state-changed", audioStateChanged);
wc.off("did-create-window", windowCreated);
wc.off("before-input-event", beforeInput);
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
Expand All @@ -1704,14 +1771,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("audio-state-changed", audioStateChanged);
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
wc.setWindowOpenHandler(({ url }) => {
wc.setWindowOpenHandler((details) => {
if (previewWindowOpenAction(details) === "popup") {
return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
}
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
wc.loadURL(url),
wc.loadURL(details.url),
).pipe(Effect.ignore),
);
return { action: "deny" };
});
wc.on("did-create-window", windowCreated);
wc.on("before-input-event", beforeInput);
});
yield* Ref.update(attachedRef, (attached) =>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/browser/ElectronBrowserHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function ElectronBrowserHost() {
previewState.serverEpoch,
snapshot.tabId,
),
pictureInPicture:
previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
}))
: [];
Expand Down Expand Up @@ -80,7 +82,7 @@ export function ElectronBrowserHost() {
if (!isElectron) return null;
return (
<div className="contents" data-electron-browser-host>
{sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => {
{sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
return (
<HostedBrowserWebview
Expand All @@ -90,6 +92,7 @@ export function ElectronBrowserHost() {
runtimeTabId={runtimeTabId}
initialUrl={url}
viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
pictureInPicture={pictureInPicture}
zoomFactor={zoomFactor}
/>
);
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/browser/HostedBrowserWebview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
import { cn } from "~/lib/utils";

import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
import { useActiveBrowserRecordingTabIds } from "./browserRecording";
import {
browserViewportSettingKey,
resolveBrowserViewportLayout,
Expand Down Expand Up @@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: {
readonly runtimeTabId: string;
readonly initialUrl: string | null;
readonly viewport: PreviewViewportSetting;
readonly pictureInPicture: boolean;
readonly zoomFactor: number;
}) {
const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props;
const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =
props;
const config = usePreviewWebviewConfig(threadRef.environmentId);
const [initialSrc] = useState(() => initialUrl ?? "about:blank");
const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
Expand All @@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: {
};
}),
);
const backgroundActivity = useBrowserSurfaceStore(
(state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
);
const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
usePreviewBridge({ threadRef, tabId, runtimeTabId });

useEffect(() => {
Expand All @@ -92,7 +99,6 @@ export function HostedBrowserWebview(props: {

const setWebviewRef = useCallback((node: HTMLElement | null) => {
webviewRef.current = node as ElectronWebview | null;
if (node && !node.hasAttribute("allowpopups")) node.setAttribute("allowpopups", "true");
}, []);

useEffect(() => {
Expand Down Expand Up @@ -231,8 +237,10 @@ export function HostedBrowserWebview(props: {

if (!config) return null;

const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
active,
renderingActive,
cornerRadius: presentation.cornerRadius,
rect: lastRect,
hiddenSize,
Expand All @@ -244,6 +252,7 @@ export function HostedBrowserWebview(props: {
className="fixed overflow-hidden bg-muted/35"
style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
onScroll={syncContentPresentation}
data-preview-rendering={renderingActive ? "active" : "suspended"}
data-preview-viewport={runtimeTabId}
>
<div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
Expand All @@ -259,6 +268,12 @@ export function HostedBrowserWebview(props: {
<webview
key={webviewGeneration}
ref={setWebviewRef}
// Must be an attribute on the element itself: Electron reads it when the
// guest attaches, so setting it from the ref callback lands too late and
// the guest attaches with popups disabled. React types `allowpopups` as a
// boolean, but react-dom drops boolean values for unrecognized attributes,
// so the literal string has to be spread past the type.
{...({ allowpopups: "true" } as unknown as { readonly allowpopups?: boolean })}
src={webviewGeneration === 0 ? initialSrc : recoverySrc}
partition={config.partition}
webpreferences={config.webPreferences}
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/browser/browserRecording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ describe("browser recording", () => {

it("starts recording for a visible tab", async () => {
await startBrowserRecording("recording-tab");

expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
const startupEvents = [...events];

await stopBrowserRecording("recording-tab");
expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
});

it("records a hidden tab without requiring it to become visible", async () => {
Expand All @@ -195,11 +195,11 @@ describe("browser recording", () => {
};

await startBrowserRecording("recording-tab");
const startupEvents = [...events];

expect(startScreencast).toHaveBeenCalledWith("recording-tab");
expect(events).toEqual(["start-screencast", "publish:recording-tab"]);

await stopBrowserRecording("recording-tab");
expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
});

it("fails startup instead of locking a fallback size when no frame arrives", async () => {
Expand Down
14 changes: 8 additions & 6 deletions apps/web/src/browser/browserRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
const activeRecordings = new Map<string, ActiveRecording>();
let unsubscribeFrames: (() => void) | null = null;

const publishActiveRecordingTabIds = (): void => {
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
tabIds: new Set(activeRecordings.keys()),
});
};

export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;

Expand Down Expand Up @@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => {
unsubscribeFrames?.();
unsubscribeFrames = null;
}
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
tabIds: new Set(activeRecordings.keys()),
});
publishActiveRecordingTabIds();
};

const cleanupFailedRecordingStart = async (
Expand Down Expand Up @@ -377,6 +381,7 @@ export async function startBrowserRecording(
lifecycle: { phase: "starting" },
};
activeRecordings.set(tabId, recording);
publishActiveRecordingTabIds();
try {
try {
unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
Expand Down Expand Up @@ -487,9 +492,6 @@ export async function startBrowserRecording(
if (recording.lifecycle.phase === "starting") {
recording.lifecycle = { phase: "recording" };
}
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
tabIds: new Set(activeRecordings.keys()),
});
return startedAt;
} finally {
settleStartup?.();
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/browser/browserSurfaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";

import {
acquireBrowserSurface,
acquireBrowserSurfaceActivity,
resolveBrowserSurfacePanelRect,
useBrowserSurfaceStore,
} from "./browserSurfaceStore";

describe("browserSurfaceStore", () => {
beforeEach(() => {
useBrowserSurfaceStore.setState({ byTabId: {} });
useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
});

it("keeps concurrent background work active until every lease is released", () => {
const first = acquireBrowserSurfaceActivity("background-browser");
const second = acquireBrowserSurfaceActivity("background-browser");

first();
expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1);

second();
expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined();
});

it("freezes the source content dimensions for a fitted presentation", () => {
Expand Down
Loading
Loading