| ... | ... | @@ -434,6 +434,61 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ |
| 434 | 434 | { key: "w", meta: true, shift: false, control: false }, |
| 435 | 435 | ]); |
| 436 | 436 | |
| 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 | }, |
| 473 | } satisfies Electron.BrowserWindowConstructorOptions; |
| 474 | |
| 475 | /** |
| 476 | * Decides what a preview page's `window.open` should do. |
| 477 | * |
| 478 | * `"popup"` opens a real window, which scripted popups need: denying them makes |
| 479 | * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and |
| 480 | * navigating the preview tab instead destroys the opener the popup has to |
| 481 | * `postMessage` its result back to. |
| 482 | * |
| 483 | * `target="_blank"` links arrive as a tab disposition and keep loading in the |
| 484 | * preview tab, which is what people expect from a link inside a preview. |
| 485 | */ |
| 486 | export const previewWindowOpenAction = (details: { |
| 487 | readonly url: string; |
| 488 | readonly disposition: Electron.HandlerDetails["disposition"]; |
| 489 | }): "popup" | "navigate" => |
| 490 | details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate"; |
| 491 | |
| 437 | 492 | export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => |
| 438 | 493 | input.type === "keyDown" && |
| 439 | 494 | input.key.toLowerCase() === "r" && |
| ... | ... | @@ -1661,6 +1716,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1661 | 1716 | ], |
| 1662 | 1717 | }); |
| 1663 | 1718 | }); |
| 1719 | // A popup opens with Electron's default handler, so the page inside it could |
| 1720 | // otherwise spawn native windows without limit. Nothing in an OAuth flow |
| 1721 | // opens a second popup, so the chain stops at the first one. |
| 1722 | const windowCreated = (window: Electron.BrowserWindow): void => { |
| 1723 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); |
| 1724 | }; |
| 1664 | 1725 | const beforeInput = (event: Electron.Event, input: Electron.Input): void => { |
| 1665 | 1726 | if (isPreviewRefreshShortcut(input)) { |
| 1666 | 1727 | event.preventDefault(); |
| ... | ... | @@ -1686,6 +1747,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1686 | 1747 | wc.off("did-stop-loading", sync); |
| 1687 | 1748 | wc.off("did-fail-load", failed as never); |
| 1688 | 1749 | wc.off("audio-state-changed", audioStateChanged); |
| 1750 | wc.off("did-create-window", windowCreated); |
| 1689 | 1751 | wc.off("before-input-event", beforeInput); |
| 1690 | 1752 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1691 | 1753 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| ... | ... | @@ -1704,14 +1766,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1704 | 1766 | wc.on("audio-state-changed", audioStateChanged); |
| 1705 | 1767 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1706 | 1768 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| 1707 | | wc.setWindowOpenHandler(({ url }) => { |
| 1769 | wc.setWindowOpenHandler((details) => { |
| 1770 | if (previewWindowOpenAction(details) === "popup") { |
| 1771 | return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; |
| 1772 | } |
| 1708 | 1773 | runFork( |
| 1709 | 1774 | attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => |
| 1710 | | wc.loadURL(url), |
| 1775 | wc.loadURL(details.url), |
| 1711 | 1776 | ).pipe(Effect.ignore), |
| 1712 | 1777 | ); |
| 1713 | 1778 | return { action: "deny" }; |
| 1714 | 1779 | }); |
| 1780 | wc.on("did-create-window", windowCreated); |
| 1715 | 1781 | wc.on("before-input-event", beforeInput); |
| 1716 | 1782 | }); |
| 1717 | 1783 | yield* Ref.update(attachedRef, (attached) => |