Skip to content

Commit fa24eb0

Browse files
authored
Merge pull request #165 from pylon-code/upstream/2026-08-29-desktop
fix(desktop): suspend hidden previews, open OAuth popups from the preview
2 parents 2c748f2 + 1544202 commit fa24eb0

11 files changed

Lines changed: 236 additions & 34 deletions

apps/desktop/src/preview/Manager.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ describe("isPreviewRefreshShortcut", () => {
5858
});
5959
});
6060

61+
describe("previewWindowOpenAction", () => {
62+
const details = (overrides: {
63+
readonly url?: string;
64+
readonly disposition?: Electron.HandlerDetails["disposition"];
65+
}) => ({
66+
url: "https://accounts.google.com/o/oauth2/auth",
67+
disposition: "new-window" as Electron.HandlerDetails["disposition"],
68+
...overrides,
69+
});
70+
71+
it("opens a real window for scripted popups so the opener survives", () => {
72+
// OAuth SDKs read a null `window.open()` as a blocked popup, and they need
73+
// the opener alive to receive the credential back.
74+
expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup");
75+
expect(
76+
PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })),
77+
).toBe("popup");
78+
});
79+
80+
it("keeps target=_blank links in the preview tab", () => {
81+
expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
82+
"navigate",
83+
);
84+
expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
85+
"navigate",
86+
);
87+
});
88+
89+
it("does not hand a window to schemes that cannot be hardened", () => {
90+
// A popup skips the `will-attach-webview` hardening, so it only gets a window
91+
// when its preferences can be overridden. Chromium copies the guest's
92+
// preferences for `about:blank` and forbids overriding them.
93+
for (const url of [
94+
"about:blank",
95+
"javascript:alert(1)",
96+
"file:///etc/passwd",
97+
"vscode://vscode-remote/ssh-remote+box/tmp",
98+
"not a url",
99+
]) {
100+
expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
101+
}
102+
});
103+
});
104+
61105
const {
62106
browserWindowConstructor,
63107
createFromPath,

apps/desktop/src/preview/Manager.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,66 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
434434
{ key: "w", meta: true, shift: false, control: false },
435435
]);
436436

437+
/**
438+
* Protocols a preview page may open in a real popup window.
439+
*
440+
* `about:blank` stays out: Chromium skips browser-side navigation for it, so the
441+
* child copies the guest's `contextIsolation: false` preferences and Electron
442+
* gives no way to override them. Those popups keep loading in the preview tab.
443+
*
444+
* Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits
445+
* `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal`
446+
* and not in a window spawned by a third-party page in the preview.
447+
*/
448+
const POPUP_PROTOCOLS = new Set(["http:", "https:"]);
449+
450+
const isPopupUrl = (rawUrl: string): boolean => {
451+
try {
452+
return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
453+
} catch {
454+
return false;
455+
}
456+
};
457+
458+
/**
459+
* Preferences for a popup a preview page opens.
460+
*
461+
* A popup is not a webview attach, so the `will-attach-webview` hardening in
462+
* `DesktopWindow` never sees it, and an unoverridden child would inherit the
463+
* guest's relaxed posture: the picker preload needs `contextIsolation: false`
464+
* to share `globalThis` with the previewed page, and no OAuth provider should
465+
* get that. The window keeps the opener and the guest session either way.
466+
*/
467+
const POPUP_WINDOW_OPTIONS = {
468+
webPreferences: {
469+
contextIsolation: true,
470+
nodeIntegration: false,
471+
sandbox: true,
472+
// `preload` is a webPreference too, so an unset one is inherited from the
473+
// guest. Preview guests load Pylon's pick/annotation preload, which imports
474+
// `ipcRenderer` and was written for the trusted preview surface — it has no
475+
// business running on a third-party sign-in page.
476+
preload: "",
477+
},
478+
} satisfies Electron.BrowserWindowConstructorOptions;
479+
480+
/**
481+
* Decides what a preview page's `window.open` should do.
482+
*
483+
* `"popup"` opens a real window, which scripted popups need: denying them makes
484+
* `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and
485+
* navigating the preview tab instead destroys the opener the popup has to
486+
* `postMessage` its result back to.
487+
*
488+
* `target="_blank"` links arrive as a tab disposition and keep loading in the
489+
* preview tab, which is what people expect from a link inside a preview.
490+
*/
491+
export const previewWindowOpenAction = (details: {
492+
readonly url: string;
493+
readonly disposition: Electron.HandlerDetails["disposition"];
494+
}): "popup" | "navigate" =>
495+
details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";
496+
437497
export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
438498
input.type === "keyDown" &&
439499
input.key.toLowerCase() === "r" &&
@@ -1661,6 +1721,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16611721
],
16621722
});
16631723
});
1724+
// A popup opens with Electron's default handler, so the page inside it could
1725+
// otherwise spawn native windows without limit. Nothing in an OAuth flow
1726+
// opens a second popup, so the chain stops at the first one.
1727+
const windowCreated = (window: Electron.BrowserWindow): void => {
1728+
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
1729+
};
16641730
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
16651731
if (isPreviewRefreshShortcut(input)) {
16661732
event.preventDefault();
@@ -1686,6 +1752,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16861752
wc.off("did-stop-loading", sync);
16871753
wc.off("did-fail-load", failed as never);
16881754
wc.off("audio-state-changed", audioStateChanged);
1755+
wc.off("did-create-window", windowCreated);
16891756
wc.off("before-input-event", beforeInput);
16901757
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
16911758
wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
@@ -1704,14 +1771,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
17041771
wc.on("audio-state-changed", audioStateChanged);
17051772
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
17061773
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1707-
wc.setWindowOpenHandler(({ url }) => {
1774+
wc.setWindowOpenHandler((details) => {
1775+
if (previewWindowOpenAction(details) === "popup") {
1776+
return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
1777+
}
17081778
runFork(
17091779
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
1710-
wc.loadURL(url),
1780+
wc.loadURL(details.url),
17111781
).pipe(Effect.ignore),
17121782
);
17131783
return { action: "deny" };
17141784
});
1785+
wc.on("did-create-window", windowCreated);
17151786
wc.on("before-input-event", beforeInput);
17161787
});
17171788
yield* Ref.update(attachedRef, (attached) =>

apps/web/src/browser/ElectronBrowserHost.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export function ElectronBrowserHost() {
2929
previewState.serverEpoch,
3030
snapshot.tabId,
3131
),
32+
pictureInPicture:
33+
previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false,
3234
zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1,
3335
}))
3436
: [];
@@ -80,7 +82,7 @@ export function ElectronBrowserHost() {
8082
if (!isElectron) return null;
8183
return (
8284
<div className="contents" data-electron-browser-host>
83-
{sessions.map(({ threadRef, snapshot, runtimeTabId, zoomFactor }) => {
85+
{sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => {
8486
const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url;
8587
return (
8688
<HostedBrowserWebview
@@ -90,6 +92,7 @@ export function ElectronBrowserHost() {
9092
runtimeTabId={runtimeTabId}
9193
initialUrl={url}
9294
viewport={snapshot.viewport ?? FILL_PREVIEW_VIEWPORT}
95+
pictureInPicture={pictureInPicture}
9396
zoomFactor={zoomFactor}
9497
/>
9598
);

apps/web/src/browser/HostedBrowserWebview.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge";
99
import { cn } from "~/lib/utils";
1010

1111
import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore";
12+
import { useActiveBrowserRecordingTabIds } from "./browserRecording";
1213
import {
1314
browserViewportSettingKey,
1415
resolveBrowserViewportLayout,
@@ -47,9 +48,11 @@ export function HostedBrowserWebview(props: {
4748
readonly runtimeTabId: string;
4849
readonly initialUrl: string | null;
4950
readonly viewport: PreviewViewportSetting;
51+
readonly pictureInPicture: boolean;
5052
readonly zoomFactor: number;
5153
}) {
52-
const { threadRef, tabId, runtimeTabId, initialUrl, viewport, zoomFactor } = props;
54+
const { threadRef, tabId, runtimeTabId, initialUrl, viewport, pictureInPicture, zoomFactor } =
55+
props;
5356
const config = usePreviewWebviewConfig(threadRef.environmentId);
5457
const [initialSrc] = useState(() => initialUrl ?? "about:blank");
5558
const tabLeaseRef = useRef<AcquiredDesktopTab | null>(null);
@@ -70,6 +73,10 @@ export function HostedBrowserWebview(props: {
7073
};
7174
}),
7275
);
76+
const backgroundActivity = useBrowserSurfaceStore(
77+
(state) => (state.activityByTabId[runtimeTabId] ?? 0) > 0,
78+
);
79+
const recordingActive = useActiveBrowserRecordingTabIds().has(runtimeTabId);
7380
usePreviewBridge({ threadRef, tabId, runtimeTabId });
7481

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

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

98104
useEffect(() => {
@@ -231,8 +237,10 @@ export function HostedBrowserWebview(props: {
231237

232238
if (!config) return null;
233239

240+
const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive;
234241
const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({
235242
active,
243+
renderingActive,
236244
cornerRadius: presentation.cornerRadius,
237245
rect: lastRect,
238246
hiddenSize,
@@ -244,6 +252,7 @@ export function HostedBrowserWebview(props: {
244252
className="fixed overflow-hidden bg-muted/35"
245253
style={{ ...wrapperStyle, overscrollBehavior: "contain" }}
246254
onScroll={syncContentPresentation}
255+
data-preview-rendering={renderingActive ? "active" : "suspended"}
247256
data-preview-viewport={runtimeTabId}
248257
>
249258
<div className="relative" style={{ width: layout.canvasWidth, height: layout.canvasHeight }}>
@@ -259,6 +268,12 @@ export function HostedBrowserWebview(props: {
259268
<webview
260269
key={webviewGeneration}
261270
ref={setWebviewRef}
271+
// Must be an attribute on the element itself: Electron reads it when the
272+
// guest attaches, so setting it from the ref callback lands too late and
273+
// the guest attaches with popups disabled. React types `allowpopups` as a
274+
// boolean, but react-dom drops boolean values for unrecognized attributes,
275+
// so the literal string has to be spread past the type.
276+
{...({ allowpopups: "true" } as unknown as { readonly allowpopups?: boolean })}
262277
src={webviewGeneration === 0 ? initialSrc : recoverySrc}
263278
partition={config.partition}
264279
webpreferences={config.webPreferences}

apps/web/src/browser/browserRecording.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,10 @@ describe("browser recording", () => {
179179

180180
it("starts recording for a visible tab", async () => {
181181
await startBrowserRecording("recording-tab");
182-
183-
expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
182+
const startupEvents = [...events];
184183

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

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

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

199200
expect(startScreencast).toHaveBeenCalledWith("recording-tab");
200-
expect(events).toEqual(["start-screencast", "publish:recording-tab"]);
201-
202201
await stopBrowserRecording("recording-tab");
202+
expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
203203
});
204204

205205
it("fails startup instead of locking a fallback size when no frame arrives", async () => {

apps/web/src/browser/browserRecording.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
122122
const activeRecordings = new Map<string, ActiveRecording>();
123123
let unsubscribeFrames: (() => void) | null = null;
124124

125+
const publishActiveRecordingTabIds = (): void => {
126+
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
127+
tabIds: new Set(activeRecordings.keys()),
128+
});
129+
};
130+
125131
export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
126132
export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;
127133

@@ -228,9 +234,7 @@ const clearActiveRecording = (recording: ActiveRecording): void => {
228234
unsubscribeFrames?.();
229235
unsubscribeFrames = null;
230236
}
231-
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
232-
tabIds: new Set(activeRecordings.keys()),
233-
});
237+
publishActiveRecordingTabIds();
234238
};
235239

236240
const cleanupFailedRecordingStart = async (
@@ -377,6 +381,7 @@ export async function startBrowserRecording(
377381
lifecycle: { phase: "starting" },
378382
};
379383
activeRecordings.set(tabId, recording);
384+
publishActiveRecordingTabIds();
380385
try {
381386
try {
382387
unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
@@ -487,9 +492,6 @@ export async function startBrowserRecording(
487492
if (recording.lifecycle.phase === "starting") {
488493
recording.lifecycle = { phase: "recording" };
489494
}
490-
appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
491-
tabIds: new Set(activeRecordings.keys()),
492-
});
493495
return startedAt;
494496
} finally {
495497
settleStartup?.();

apps/web/src/browser/browserSurfaceStore.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";
22

33
import {
44
acquireBrowserSurface,
5+
acquireBrowserSurfaceActivity,
56
resolveBrowserSurfacePanelRect,
67
useBrowserSurfaceStore,
78
} from "./browserSurfaceStore";
89

910
describe("browserSurfaceStore", () => {
1011
beforeEach(() => {
11-
useBrowserSurfaceStore.setState({ byTabId: {} });
12+
useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
13+
});
14+
15+
it("keeps concurrent background work active until every lease is released", () => {
16+
const first = acquireBrowserSurfaceActivity("background-browser");
17+
const second = acquireBrowserSurfaceActivity("background-browser");
18+
19+
first();
20+
expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBe(1);
21+
22+
second();
23+
expect(useBrowserSurfaceStore.getState().activityByTabId["background-browser"]).toBeUndefined();
1224
});
1325

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

0 commit comments

Comments
 (0)