diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index 45f63b951f366..882f445dcb353 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -9,7 +9,15 @@ vi.mock("electron", () => ({})); import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; -it.effect("answers only the main window's top frame", () => +const popout = (id: number, url: string) => { + const mainFrame = {}; + return { + sender: { id, getType: () => "window", getURL: () => url, mainFrame }, + senderFrame: mainFrame, + }; +}; + +it.effect("answers only the app's own windows' top frames", () => Effect.gen(function* () { const mainFrame = {}; const invoke = new Map(); @@ -25,7 +33,7 @@ it.effect("answers only the main window's top frame", () => Layer.provide( Layer.mock(ElectronWindow.ElectronWindow, { main: Effect.succeedSome({ - webContents: { id: 1, mainFrame }, + webContents: { id: 1, mainFrame, getURL: () => "t3://app/index.html#/" }, } as unknown as Electron.BrowserWindow), }), ), @@ -46,6 +54,17 @@ it.effect("answers only the main window's top frame", () => app: { sender: { id: 1 }, senderFrame: mainFrame }, appSubframe: { sender: { id: 1 }, senderFrame: {} }, previewGuest: { sender: { id: 2 }, senderFrame: {} }, + popout: popout(3, "t3://app/index.html#/browser-window/tab-1"), + popoutSubframe: { + ...popout(3, "t3://app/index.html#/browser-window/tab-1"), + senderFrame: {}, + }, + popoutOnAppRoute: popout(3, "t3://app/index.html#/settings"), + foreignWindow: popout(4, "https://example.com/#/browser-window/tab-1"), + guestOnAppUrl: { + ...popout(5, "t3://app/index.html#/browser-window/tab-1"), + sender: { ...popout(5, "").sender, getType: () => "webview" }, + }, }; const results: Record = {}; for (const [name, sender] of Object.entries(senders)) { @@ -62,6 +81,11 @@ it.effect("answers only the main window's top frame", () => app: ["secret", "secret"], appSubframe: ["rejected", undefined], previewGuest: ["rejected", undefined], + popout: ["secret", "secret"], + popoutSubframe: ["rejected", undefined], + popoutOnAppRoute: ["rejected", undefined], + foreignWindow: ["rejected", undefined], + guestOnAppUrl: ["rejected", undefined], }); }).pipe(Effect.scoped), ); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 69d4f70c15a0e..b937e7609c1dd 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -8,7 +8,12 @@ import * as Scope from "effect/Scope"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; export interface DesktopIpcInvokeEvent { - readonly sender: { readonly id: number }; + readonly sender: { + readonly id: number; + readonly getType?: () => string; + readonly getURL?: () => string; + readonly mainFrame?: unknown; + }; readonly senderFrame?: unknown; } @@ -118,6 +123,7 @@ export const make = ( Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel }); if (!(yield* isTrustedSender(event))) { + yield* Effect.logWarning("Rejected IPC from an untrusted sender."); return yield* new DesktopIpcUntrustedSenderError({ channel }); } return yield* handler(raw, event); @@ -185,17 +191,29 @@ export const layer = (ipcMain: DesktopIpcMain) => Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; return make(ipcMain, (event) => - Effect.map( - electronWindow.main, - (main) => - Option.isSome(main) && - main.value.webContents.id === event.sender.id && - main.value.webContents.mainFrame === event.senderFrame, - ), + Effect.map(electronWindow.main, (main) => { + if (Option.isNone(main)) return false; + const app = main.value.webContents; + if (app.id === event.sender.id) return app.mainFrame === event.senderFrame; + return isPopoutWindow(event, app.getURL()); + }), ); }), ); +// A pop-out browser window runs the app itself, so it is as trusted as the main window. +// Preview guests are "webview" contents, and their pop-ups never load the app origin. +const isPopoutWindow = (event: DesktopIpcInvokeEvent, appUrl: string): boolean => { + const sender = event.sender; + if (sender.getType?.() !== "window" || sender.mainFrame !== event.senderFrame) return false; + try { + const url = new URL(sender.getURL?.() ?? ""); + return url.origin === new URL(appUrl).origin && url.hash.startsWith("#/browser-window/"); + } catch { + return false; + } +}; + export const layerTest = (ipcMain: DesktopIpcMain) => Layer.succeed( DesktopIpc, diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 33dd9e272df7d..2ef0c8adb0d7d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -115,6 +115,8 @@ export const PREVIEW_COPY_ARTIFACT_CHANNEL = "desktop:preview-copy-artifact"; export const PREVIEW_PICTURE_IN_PICTURE_OPEN_CHANNEL = "desktop:preview-pip-open"; export const PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL = "desktop:preview-pip-close"; export const PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL = "desktop:preview-pip-frame"; +export const PREVIEW_TAB_WINDOW_OPEN_CHANNEL = "desktop:preview-tab-window-open"; +export const PREVIEW_TAB_WINDOW_CLOSE_CHANNEL = "desktop:preview-tab-window-close"; export const PREVIEW_RECORDING_START_CHANNEL = "desktop:preview-recording-start"; export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts index bd7e44bec8307..2529a80147a60 100644 --- a/apps/desktop/src/ipc/methods/preview.test.ts +++ b/apps/desktop/src/ipc/methods/preview.test.ts @@ -9,6 +9,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import * as PreviewIpc from "./preview.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -110,6 +111,40 @@ describe("preview IPC methods", () => { }), ); + effectIt.effect("sends opened links only to the main window, never a pop-out", () => + Effect.gen(function* () { + let openLink: ((event: unknown) => Effect.Effect) | undefined; + const mainSend = vi.fn(); + const sendAll = vi.fn(() => Effect.void); + const listen = () => Effect.void; + yield* PreviewIpc.installPreviewEventForwarding().pipe( + Effect.provideService(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + isDestroyed: () => false, + webContents: { send: mainSend }, + } as unknown as Electron.BrowserWindow), + sendAll, + } as unknown as ElectronWindow.ElectronWindow["Service"]), + Effect.provideService(PreviewManager.PreviewManager, { + subscribeStateChanges: listen, + subscribeRecordingInputs: listen, + subscribePointerEvents: listen, + subscribeFindResults: listen, + subscribeOpenLinks: (listener: (event: unknown) => Effect.Effect) => { + openLink = listener; + return Effect.void; + }, + } as unknown as PreviewManager.PreviewManager["Service"]), + ); + yield* openLink!({ url: "https://example.com" }); + expect(mainSend).toHaveBeenCalledOnce(); + expect(sendAll).not.toHaveBeenCalledWith( + expect.stringContaining("open-link"), + expect.anything(), + ); + }), + ); + beforeEach(() => { fromPartition.mockClear(); }); diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index ea291b66b3149..70c6dab9b035e 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -7,6 +7,7 @@ import { DesktopPreviewConfigInputSchema, DesktopPreviewFindInPageInputSchema, DesktopPreviewNavigateInputSchema, + DesktopPreviewOpenTabWindowInputSchema, DesktopPreviewRecordingArtifactSchema, DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, @@ -57,8 +58,15 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribePointerEvents((event) => electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), ); + // Only the main window opens tabs; pop-out windows would open a duplicate. yield* manager.subscribeOpenLinks((event) => - electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), + Effect.flatMap(electronWindow.main, (main) => + Effect.sync(() => { + if (Option.isSome(main) && !main.value.isDestroyed()) { + main.value.webContents.send(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event); + } + }), + ), ); yield* manager.subscribeFindResults((event) => electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event), @@ -269,6 +277,20 @@ const openPictureInPicture = tabMethod( "desktop.ipc.preview.openPictureInPicture", (manager, tabId) => manager.openPictureInPicture(tabId), ); +const openTabWindow = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_TAB_WINDOW_OPEN_CHANNEL, + payload: DesktopPreviewOpenTabWindowInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.openTabWindow")(function* ({ tabId, partition }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.openTabWindow(tabId, partition); + }), +}); +const closeTabWindow = tabMethod( + IpcChannels.PREVIEW_TAB_WINDOW_CLOSE_CHANNEL, + "desktop.ipc.preview.closeTabWindow", + (manager, tabId) => manager.closeTabWindow(tabId), +); const closePictureInPicture = tabMethod( IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, "desktop.ipc.preview.closePictureInPicture", @@ -495,6 +517,8 @@ export const methods = [ copyArtifactToClipboard, openPictureInPicture, closePictureInPicture, + openTabWindow, + closeTabWindow, startRecording, stopRecording, saveRecording, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5bf05668b5a98..93b814e215745 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -438,6 +438,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { close: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICTURE_IN_PICTURE_CLOSE_CHANNEL, { tabId }), }, + tabWindow: { + open: (tabId, partition) => + ipcRenderer.invoke(IpcChannels.PREVIEW_TAB_WINDOW_OPEN_CHANNEL, { tabId, partition }), + close: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_TAB_WINDOW_CLOSE_CHANNEL, { tabId }), + }, recording: { onInput: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, event: unknown) => { diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 79a059d828943..ad354141f307a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -1651,12 +1651,15 @@ describe("PreviewManager", () => { expect(replacementListenerSpies.on).not.toHaveBeenCalled(); yield* manager.closeTab("tab_close_register_race"); const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race")); + const tabWindowExit = yield* Effect.exit( + manager.openTabWindow("tab_close_register_race", "persist:test"), + ); yield* Deferred.succeed(continueCloseCleanup, undefined); yield* Fiber.join(closeFiber); const recreated = yield* Fiber.join(recreateFiber); const registrationExit = yield* Fiber.await(registrationFiber); - for (const exit of [registrationExit, recordingExit] as ReadonlyArray< + for (const exit of [registrationExit, recordingExit, tabWindowExit] as ReadonlyArray< Exit.Exit >) { expect(Exit.isFailure(exit)).toBe(true); @@ -1675,6 +1678,75 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps a stale tab window from attaching to a recreated tab", () => + withManager((manager) => + Effect.gen(function* () { + const hostHandlers = new Map) => void>(); + const tabWindow = { + webContents: { + id: 900, + on: (event: string, handler: (...args: ReadonlyArray) => void) => + hostHandlers.set(event, handler), + setWindowOpenHandler: vi.fn(), + }, + isDestroyed: () => false, + once: vi.fn(), + close: vi.fn(), + loadURL: vi.fn(async () => undefined), + }; + browserWindowConstructor.mockImplementation(function () { + return tabWindow; + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { getURL: () => "t3code://app/" }, + } as never); + + yield* manager.createTab("tab_stale_window"); + yield* manager.openTabWindow("tab_stale_window", "persist:t3code-preview-test"); + yield* manager.closeTab("tab_stale_window"); + yield* manager.createTab("tab_stale_window"); + const guest = { id: 77, on: vi.fn() }; + tabWindow.close.mockClear(); + hostHandlers.get("did-attach-webview")!({}, guest); + + expect(tabWindow.close).toHaveBeenCalledOnce(); + expect(guest.on).not.toHaveBeenCalled(); + }), + ), + ); + + effectIt.effect("detaches a tab with no page as soon as its window opens", () => + withManager((manager) => + Effect.gen(function* () { + browserWindowConstructor.mockImplementation(function () { + return { + webContents: { id: 901, on: vi.fn(), setWindowOpenHandler: vi.fn() }, + isDestroyed: () => false, + once: vi.fn(), + close: vi.fn(), + loadURL: vi.fn(async () => undefined), + }; + }); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: vi.fn(), + webContents: { getURL: () => "t3code://app/" }, + } as never); + const detached: Array = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => detached.push(state.detached)), + ); + + yield* manager.createTab("tab_empty_window"); + yield* manager.openTabWindow("tab_empty_window", "persist:t3code-preview-test"); + + expect(detached.at(-1)).toBe(true); + }), + ), + ); + effectIt.effect("preserves both screenshots of the same site in the same millisecond", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 14bdd0e1d6ba9..a71bc56c96910 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -71,6 +71,8 @@ import { import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; import * as PreviewPasskeys from "./Passkeys.ts"; import * as BrowserSession from "./BrowserSession.ts"; +import { hardenPreviewWebviewPreferences } from "./WebviewPreferences.ts"; +import { isSameOriginRendererNavigation } from "../window/RendererNavigation.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_SEND_ENABLED_CHANNEL, @@ -112,6 +114,7 @@ export interface PreviewTabState { canGoForward: boolean; zoomFactor: number; pictureInPicture: boolean; + detached: boolean; colorScheme: DesktopPreviewColorScheme; audioMuted: boolean; audible: boolean; @@ -563,6 +566,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const parentScope = yield* Scope.Scope; const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { isPartition: isBrowserPartition } = yield* BrowserSession.BrowserSession; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const resolvedArtifactDirectory = path.resolve(artifactDirectory); @@ -1660,7 +1664,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function currentMainWindow = undefined; frameCaptureWindowOpen = false; mainWindowCleanupFiber = runFork( - Effect.all([closeAllPictureInPicture(), stopAllRecordings()], { + Effect.all([closeAllPictureInPicture(), stopAllRecordings(), closeAllTabWindows()], { concurrency: "unbounded", discard: true, }).pipe(Effect.ignore), @@ -1694,6 +1698,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoForward: false, zoomFactor: normalizeZoomFactor(defaults?.zoomFactor), pictureInPicture: false, + detached: false, colorScheme: defaults?.colorScheme ?? "system", ...(defaults?.serverTab === undefined ? {} : { serverTab: defaults.serverTab }), audioMuted: false, @@ -1732,10 +1737,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function [ cancelPickElement(tabId), closePictureInPicture(tabId), + closeTabWindow(tabId), stopFrameCapture(tabId, "recording"), ], { - concurrency: 3, + concurrency: 4, discard: true, }, ); @@ -1770,6 +1776,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, pictureInPicture: false, + detached: false, colorScheme: "system", audioMuted: false, audible: false, @@ -1817,7 +1824,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function !wc || wc.isDestroyed() || wc.getType() !== "webview" || - (Option.isSome(mainWindow) && wc.hostWebContents !== mainWindow.value.webContents) + (Option.isSome(mainWindow) && + wc.hostWebContents !== mainWindow.value.webContents && + !tabWindowHostIds.has(wc.hostWebContents?.id ?? -1)) ) { return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId }); } @@ -1992,6 +2001,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, pictureInPicture: current?.pictureInPicture ?? false, + detached: current?.detached ?? false, colorScheme: current?.colorScheme ?? "system", // Keep audibility until Chromium replaces the document; clearing it here can lose the final transition. audioMuted: current?.audioMuted ?? false, @@ -2877,6 +2887,160 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }, ); + const tabWindows = new Map(); + const tabWindowHostIds = new Set(); + + const closeTabWindow = Effect.fn("PreviewManager.closeTabWindow")(function* (tabId: string) { + const window = tabWindows.get(tabId); + if (!window || window.isDestroyed()) return yield* update(tabId, { detached: false }); + yield* attempt({ operation: "tabWindow.close", tabId }, () => window.close()); + }); + + const closeAllTabWindows = Effect.fn("PreviewManager.closeAllTabWindows")(function* () { + yield* Effect.forEach( + [...tabWindows.keys()], + (tabId) => closeTabWindow(tabId).pipe(Effect.ignore), + { + discard: true, + }, + ); + }); + + // The guest cannot move between hosts, so the pop-out loads the current URL in a fresh webview. + const openTabWindow = Effect.fn("PreviewManager.openTabWindow")(function* ( + tabId: string, + partition: string, + ) { + // Fail fast instead of queueing behind an in-flight close. + if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const created = yield* withTabLifecycleLock(tabId, createTabWindowUnlocked(tabId, partition)); + if (!created) return; + const { window, appUrl } = created; + yield* attemptPromise({ operation: "tabWindow.load", tabId }, () => + window.loadURL(appUrl.href), + ).pipe( + Effect.onError(() => + attempt({ operation: "tabWindow.close", tabId }, () => window.close()).pipe(Effect.ignore), + ), + ); + }); + + const createTabWindowUnlocked = Effect.fn("PreviewManager.createTabWindowUnlocked")(function* ( + tabId: string, + partition: string, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + const generation = tabLifecycleGenerations.get(tabId); + const existing = tabWindows.get(tabId); + if (existing && !existing.isDestroyed()) { + yield* attempt({ operation: "tabWindow.focus", tabId }, () => existing.focus()); + return null; + } + if (!isBrowserPartition(partition)) { + return yield* new PreviewOperationError({ + operation: "tabWindow.partition", + tabId, + cause: new Error("Not a browser partition."), + }); + } + const mainWindow = yield* Ref.get(mainWindowRef); + if (Option.isNone(mainWindow) || mainWindow.value.isDestroyed()) { + return yield* new PreviewOperationError({ + operation: "tabWindow.appUrl", + tabId, + cause: new Error("Main window is not available."), + }); + } + const appUrl = new URL(mainWindow.value.webContents.getURL()); + appUrl.hash = `#/browser-window/${encodeURIComponent(tabId)}`; + const window = yield* attempt( + { operation: "tabWindow.create", tabId }, + () => + new BrowserWindow({ + width: 1200, + height: 800, + title: "Browser", + autoHideMenuBar: true, + // The toolbar row (40px) doubles as the title bar; the lights sit centered in it. + ...(hostPlatform === "darwin" && { + titleBarStyle: "hidden" as const, + trafficLightPosition: { x: 14, y: 13 }, + }), + webPreferences: { + preload: path.join(path.dirname(pictureInPicturePreloadPath), "preload.cjs"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: true, + }, + }), + ); + const host = window.webContents; + const hostId = host.id; + tabWindows.set(tabId, window); + tabWindowHostIds.add(hostId); + // A tab with no page never attaches a webview here, so nothing else would hand it to this window. + if (tab.webContentsId === null) yield* update(tabId, { detached: true }); + host.on("will-attach-webview", (event, webPreferences, params) => { + if (typeof params.partition !== "string" || !isBrowserPartition(params.partition)) { + event.preventDefault(); + return; + } + hardenPreviewWebviewPreferences(webPreferences, passkeys.bridgeEnabled); + }); + host.on("did-attach-webview", (_event, contents) => { + // The tab was closed or recreated since this window opened; never bind to the replacement. + if (tabLifecycleGenerations.get(tabId) !== generation) { + window.close(); + return; + } + contents.on("page-title-updated", (_titleEvent, nextTitle) => { + if (!window.isDestroyed()) window.setTitle(nextTitle); + }); + runFork( + prepareWebview(contents).pipe( + Effect.andThen( + withTabLifecycleLock( + tabId, + registerWebviewUnlocked(tabId, contents.id, generation).pipe( + // Under the lock, so a close or recreate can't land between registering and this write. + Effect.andThen( + Effect.suspend(() => + window.isDestroyed() || tabWindows.get(tabId) !== window + ? Effect.void + : update(tabId, { detached: true }), + ), + ), + ), + ), + ), + Effect.catchCause((cause) => + Effect.logWarning("Preview tab window failed to register.", { tabId, cause }), + ), + ), + ); + }); + host.setWindowOpenHandler(() => ({ action: "deny" })); + // The preload exposes desktopBridge, so the window must never leave the app origin. + host.on("will-navigate", (event, url) => { + if (!isSameOriginRendererNavigation({ applicationUrl: appUrl.href, navigationUrl: url })) { + event.preventDefault(); + } + }); + window.once("closed", () => { + tabWindowHostIds.delete(hostId); + if (tabWindows.get(tabId) === window) tabWindows.delete(tabId); + if (tabLifecycleGenerations.get(tabId) !== generation) return; + runFork(update(tabId, { detached: false }).pipe(Effect.ignore)); + }); + return { window, appUrl }; + }); + const openPictureInPicture = Effect.fn("PreviewManager.openPictureInPicture")(function* ( tabId: string, ) { @@ -3463,6 +3627,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function hardReload, navigate, openPictureInPicture, + openTabWindow, + closeTabWindow, openDevTools, pickElement, updateAnnotationVoice, @@ -3727,6 +3893,11 @@ export class PreviewManager extends Context.Service< readonly revealArtifact: (path: string) => Effect.Effect; readonly copyArtifactToClipboard: (path: string) => Effect.Effect; readonly openPictureInPicture: (tabId: string) => Effect.Effect; + readonly openTabWindow: ( + tabId: string, + partition: string, + ) => Effect.Effect; + readonly closeTabWindow: (tabId: string) => Effect.Effect; readonly closePictureInPicture: (tabId: string) => Effect.Effect; readonly startRecording: ( tabId: string, @@ -3892,6 +4063,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { revealArtifact: operations.revealArtifact, copyArtifactToClipboard: operations.copyArtifactToClipboard, openPictureInPicture: operations.openPictureInPicture, + openTabWindow: operations.openTabWindow, + closeTabWindow: operations.closeTabWindow, closePictureInPicture: operations.closePictureInPicture, startRecording: operations.startRecording, stopRecording: operations.stopRecording, diff --git a/apps/desktop/src/preview/WebviewPreferences.ts b/apps/desktop/src/preview/WebviewPreferences.ts index 4dc9d9aa6b732..11b9ded284272 100644 --- a/apps/desktop/src/preview/WebviewPreferences.ts +++ b/apps/desktop/src/preview/WebviewPreferences.ts @@ -1,3 +1,22 @@ +import { PASSKEY_BRIDGE_ARGUMENT } from "./GuestProtocol.ts"; + /** sandbox:true is required with contextIsolation:false or the shared page global would expose Node and IPC. */ export const PREVIEW_WEBVIEW_PREFERENCES = "contextIsolation=false,sandbox=true,nodeIntegration=false"; + +export const hardenPreviewWebviewPreferences = ( + webPreferences: Electron.WebPreferences, + passkeyBridgeEnabled: boolean, +): void => { + webPreferences.sandbox = true; + webPreferences.nodeIntegration = false; + webPreferences.nodeIntegrationInSubFrames = false; + webPreferences.contextIsolation = false; + webPreferences.disableHtmlFullscreenWindowResize = true; + if (passkeyBridgeEnabled) { + webPreferences.additionalArguments = [ + ...(webPreferences.additionalArguments ?? []), + PASSKEY_BRIDGE_ARGUMENT, + ]; + } +}; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 91d034cbd772b..d34158bc7f21e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -17,6 +17,7 @@ import { vi } from "vite-plus/test"; vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), + app: { on: vi.fn(), off: vi.fn() }, session: { fromPartition: vi.fn(() => ({ getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), @@ -502,45 +503,6 @@ describe("DesktopWindow", () => { } }); - it("rejects renderer navigations whose origin only serializes as null", () => { - for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { - assert.isTrue( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl, - navigationUrl: `${applicationUrl}threads?id=1#latest`, - }), - applicationUrl, - ); - } - for (const navigationUrl of [ - "t3code://other/", - "t3code://app.example/", - "t3code://app:8080/", - "t3code://user@other/", - "t3code:app", - "t3code-dev://app/", - "file:///etc/passwd", - "data:text/html,

hi

", - "javascript:alert(1)", - "about:blank", - "blob:t3code://app/0b5e2c4a", - ]) { - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "t3code://app/", - navigationUrl, - }), - navigationUrl, - ); - } - assert.isFalse( - DesktopWindow.isSameOriginRendererNavigation({ - applicationUrl: "data:text/html,app", - navigationUrl: "data:text/html,other", - }), - ); - }); - it.effect( "opens and reopens the window without backend readiness when local execution is disabled", () => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 73f5b41810dae..a4ad39f164257 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -29,7 +29,7 @@ import { TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; -import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; +import { hardenPreviewWebviewPreferences } from "../preview/WebviewPreferences.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -37,6 +37,8 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; +import { isSameOriginRendererNavigation } from "./RendererNavigation.ts"; + import { copyContextMenuImage } from "./ContextMenuImage.ts"; const TITLEBAR_HEIGHT = 40; @@ -213,24 +215,6 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } -export function isSameOriginRendererNavigation(input: { - readonly applicationUrl: string; - readonly navigationUrl: string; -}): boolean { - try { - const applicationUrl = new URL(input.applicationUrl); - const navigationUrl = new URL(input.navigationUrl); - // Node serializes custom-scheme origins as "null", even for different hosts and schemes. - return ( - applicationUrl.host !== "" && - navigationUrl.protocol === applicationUrl.protocol && - navigationUrl.host === applicationUrl.host - ); - } catch { - return false; - } -} - function isRetryableDevelopmentRendererLoadFailure(input: { readonly applicationUrl: string; readonly errorCode: number; @@ -550,17 +534,7 @@ export const make = Effect.gen(function* () { event.preventDefault(); return; } - webPreferences.sandbox = true; - webPreferences.nodeIntegration = false; - webPreferences.nodeIntegrationInSubFrames = false; - webPreferences.contextIsolation = false; - webPreferences.disableHtmlFullscreenWindowResize = true; - if (previewPasskeys.bridgeEnabled) { - webPreferences.additionalArguments = [ - ...(webPreferences.additionalArguments ?? []), - PASSKEY_BRIDGE_ARGUMENT, - ]; - } + hardenPreviewWebviewPreferences(webPreferences, previewPasskeys.bridgeEnabled); }); const contextMenuContents = new WeakSet(); @@ -657,6 +631,14 @@ export const make = Effect.gen(function* () { installContextMenu(window, contents); void runPromise(previewManager.prepareWebview(contents)); }); + // Pop-out browser windows host their own guest, which needs the same menu. + const onWindowCreated = (_event: Electron.Event, created: Electron.BrowserWindow) => { + created.webContents.on("did-attach-webview", (_attachEvent, contents) => { + installContextMenu(created, contents); + }); + }; + Electron.app.on("browser-window-created", onWindowCreated); + window.once("closed", () => Electron.app.off("browser-window-created", onWindowCreated)); window.webContents.setWindowOpenHandler(({ url }) => { if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) { diff --git a/apps/desktop/src/window/RendererNavigation.test.ts b/apps/desktop/src/window/RendererNavigation.test.ts new file mode 100644 index 0000000000000..4b511166fcb9d --- /dev/null +++ b/apps/desktop/src/window/RendererNavigation.test.ts @@ -0,0 +1,45 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { isSameOriginRendererNavigation } from "./RendererNavigation.ts"; + +describe("isSameOriginRendererNavigation", () => { + it("compares protocol and host instead of origin", () => { + for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { + assert.isTrue( + isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: `${applicationUrl}threads?id=1#latest`, + }), + applicationUrl, + ); + } + for (const navigationUrl of [ + "t3code://other/", + "t3code://app.example/", + "t3code://app:8080/", + "t3code://user@other/", + "t3code:app", + "t3code-dev://app/", + "file:///etc/passwd", + "data:text/html,

hi

", + "javascript:alert(1)", + "about:blank", + "blob:t3code://app/0b5e2c4a", + "not a url", + ]) { + assert.isFalse( + isSameOriginRendererNavigation({ + applicationUrl: "t3code://app/", + navigationUrl, + }), + navigationUrl, + ); + } + assert.isFalse( + isSameOriginRendererNavigation({ + applicationUrl: "data:text/html,app", + navigationUrl: "data:text/html,other", + }), + ); + }); +}); diff --git a/apps/desktop/src/window/RendererNavigation.ts b/apps/desktop/src/window/RendererNavigation.ts new file mode 100644 index 0000000000000..74d64ae31079f --- /dev/null +++ b/apps/desktop/src/window/RendererNavigation.ts @@ -0,0 +1,17 @@ +export function isSameOriginRendererNavigation(input: { + readonly applicationUrl: string; + readonly navigationUrl: string; +}): boolean { + try { + const applicationUrl = new URL(input.applicationUrl); + const navigationUrl = new URL(input.navigationUrl); + // Node serializes custom-scheme origins as "null", even for different hosts and schemes. + return ( + applicationUrl.host !== "" && + navigationUrl.protocol === applicationUrl.protocol && + navigationUrl.host === applicationUrl.host + ); + } catch { + return false; + } +} diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index d71b7a2b6b2ba..6b5f34ce20e07 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -52,6 +52,7 @@ function hostedBrowserSessions( runtimeTabId: previewRuntimeTabId(threadRef, previewState.serverEpoch, snapshot.tabId), pictureInPicture: previewState.desktopByTabId[snapshot.tabId]?.pictureInPicture ?? false, + detached: previewState.desktopByTabId[snapshot.tabId]?.detached ?? false, zoomFactor: previewState.desktopByTabId[snapshot.tabId]?.zoomFactor ?? 1, })) : []; @@ -125,40 +126,49 @@ export function startElectronBrowserHost(): () => void { }; } +// A pop-out window hosts exactly one tab; the main window shows a placeholder for it. +const popoutMatch = /^#\/browser-window\/([^/?]+)/.exec(window.location.hash); +const popoutTabId = popoutMatch ? decodeURIComponent(popoutMatch[1]!) : null; + export function ElectronBrowserHost() { const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); - const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId); + const sessions = hostedBrowserSessions(previewByThreadKey, primaryEnvironmentId).filter( + (session) => !popoutTabId || session.runtimeTabId === popoutTabId, + ); if (!isElectron) return null; return (
- {sessions.map(({ threadRef, snapshot, runtimeTabId, pictureInPicture, zoomFactor }) => { - const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; - return ( - - ); - })} + {sessions.map( + ({ threadRef, snapshot, runtimeTabId, pictureInPicture, detached, zoomFactor }) => { + const url = snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; + return ( + + ); + }, + )}
); } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 34c7978b7b40b..3a542572bd6db 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -7,6 +7,8 @@ import { useShallow } from "zustand/react/shallow"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; +import { Button } from "~/components/ui/button"; +import { toastManager } from "~/components/ui/toast"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; @@ -53,6 +55,7 @@ export function HostedBrowserWebview(props: { readonly initialUrl: string | null; readonly viewport: PreviewViewportSetting; readonly pictureInPicture: boolean; + readonly detached: boolean; /** Fixed for the tab lifetime; Electron reads partition before guest attachment. */ readonly profileId: string | undefined; readonly zoomFactor: number; @@ -69,6 +72,7 @@ export function HostedBrowserWebview(props: { initialUrl, viewport, pictureInPicture, + detached, zoomFactor, profileId, serverDriven = false, @@ -138,6 +142,15 @@ export function HostedBrowserWebview(props: { const active = presentation.visible && presentation.rect !== null; const shown = active || pictureInPicture; const reportedShown = useEffectEvent(() => serverDriven && shown); + // The pop-out window's guest replaced ours; coming back needs a fresh guest at the current URL. + const [renderedDetached, setRenderedDetached] = useState(detached); + if (renderedDetached !== detached) { + setRenderedDetached(detached); + if (!detached) { + setRecoverySrc(initialUrl ?? initialSrc); + setWebviewGeneration((generation) => generation + 1); + } + } const remountCrashedWebview = useEffectEvent(() => { setRecoverySrc(initialUrl ?? initialSrc); setWebviewGeneration((generation) => generation + 1); @@ -202,8 +215,9 @@ export function HostedBrowserWebview(props: { }, [ clientSettingsHydrated, config, + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Detaching unmounts the webview and a crash recovery remounts it under a new key, so the listeners attach to the new element. + detached, runtimeTabId, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A crash recovery remounts the webview under a new key, so the listeners attach to the new element. webviewGeneration, ]); @@ -333,47 +347,68 @@ export function HostedBrowserWebview(props: { onChange={commitViewportChange} /> ) : null} - + {detached ? ( +
+ This tab is open in its own window. + +
+ ) : ( + + )} {active && effectiveViewport._tag !== "fill" && !fittedSourceViewport ? ( <> ) : null} - {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( + {activeThreadRef && shownPreviewMiniPlayer && canvasPreview && !embedded ? ( void) | undefined; pictureInPicture?: boolean | undefined; + pictureInPictureLabel?: string | undefined; pictureInPictureDisabled?: boolean | undefined; + windowTitleBar?: boolean | undefined; onPickElement?: (() => void) | undefined; pickActive?: boolean | undefined; pickDisabled?: boolean | undefined; @@ -68,7 +70,9 @@ export function PreviewChromeRow({ recording, onPictureInPicture, pictureInPicture, + pictureInPictureLabel, pictureInPictureDisabled, + windowTitleBar, onPickElement, pickActive, pickDisabled, @@ -76,6 +80,9 @@ export function PreviewChromeRow({ trailingActions, leadingActions, }: Props) { + const pipLabel = + pictureInPictureLabel ?? + (pictureInPicture ? "Close floating preview" : "Float preview over chat"); const [draft, setDraft] = useState(url); const [inputFocused, setInputFocused] = useState(false); @@ -91,7 +98,11 @@ export function PreviewChromeRow({
@@ -269,9 +280,7 @@ export function PreviewChromeRow({ variant={pictureInPicture ? "secondary" : "ghost"} size="icon-xs" onClick={onPictureInPicture} - aria-label={ - pictureInPicture ? "Close floating preview" : "Float preview over chat" - } + aria-label={pipLabel} aria-pressed={pictureInPicture ? "true" : "false"} type="button" disabled={pictureInPictureDisabled} @@ -280,9 +289,7 @@ export function PreviewChromeRow({ > - - {pictureInPicture ? "Close floating preview" : "Float preview over chat"} - + {pipLabel} ) : null} {trailingActions} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 963c74a6743df..ce95930ace2a2 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -37,7 +37,7 @@ export interface PreviewMoreMenuActions { readonly clearCookies: () => void; readonly clearCache: () => void; readonly openDevTools?: () => void; - readonly toggleNativePictureInPicture?: () => void; + readonly toggleTabWindow?: () => void; readonly find?: () => void; } @@ -48,7 +48,7 @@ interface Props { colorScheme: DesktopPreviewColorScheme; deviceToolbarVisible: boolean; onToggleDeviceToolbar: () => void; - nativePictureInPicture: boolean; + tabWindow: boolean; profileName: string | undefined; findShortcutLabel?: string | null; move?: { readonly label: string; readonly onMove: () => void }; @@ -76,7 +76,7 @@ export function PreviewMoreMenu({ colorScheme, deviceToolbarVisible, onToggleDeviceToolbar, - nativePictureInPicture, + tabWindow, profileName, findShortcutLabel, move, @@ -101,11 +101,9 @@ export function PreviewMoreMenu({ Open DevTools ) : null} - {actions.toggleNativePictureInPicture ? ( - - {nativePictureInPicture - ? "Close separate preview window" - : "Open separate preview window"} + {actions.toggleTabWindow ? ( + + {tabWindow ? "Bring back to panel" : "Open in separate window"} ) : null} {move ? {move.label} : null} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 33fd2fa8511b4..a781bcf293ad5 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -32,8 +32,7 @@ import { removeUrlForThread, useThreadRecentHistory, } from "~/browserHistoryStore"; -import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; -import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation"; +import type { ComposerImageAttachment } from "~/composerDraftStore"; import { ensureLocalApi } from "~/localApi"; import { getClientSettings } from "~/hooks/useSettings"; import { @@ -42,6 +41,7 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { usePreviewWebviewConfig } from "~/browser/previewWebviewConfigState"; import { useEnvironmentSupportsServerBrowser } from "~/state/entities"; import { useEnvironment, @@ -64,6 +64,10 @@ import { randomUUID } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { PreviewAnnotationSpeech } from "./PreviewAnnotationSpeech"; import { previewBridge } from "./previewBridge"; +import { + addPreviewAnnotationToDraft, + sendPreviewAnnotationToChatWindow, +} from "./previewAnnotationDelivery"; import { subscribePreviewAction } from "./previewActionBus"; import { closePreviewSession } from "./closePreviewSession"; import { openPreviewSession } from "./openPreviewSession"; @@ -85,7 +89,7 @@ import { Badge } from "~/components/ui/badge"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { alternatePreviewRuntime, useRendersServerTabNatively } from "~/browser/previewRuntime"; import { ServerBrowserSurface, type ServerBrowserHandle } from "~/browser/ServerBrowserSurface"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; @@ -180,8 +184,6 @@ export function PreviewView({ const miniPlayerTabId = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayerTabId(state.byThreadKey, threadRef), ); - const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); - const addImage = useComposerDraftStore((store) => store.addImage); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); const open = useAtomCommand(previewEnvironment.open); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); @@ -241,6 +243,11 @@ export function PreviewView({ const controller = desktopOverlay?.controller ?? "none"; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const browserDefaults = useBrowserDefaults(); + // Same lookup as ElectronBrowserHost so the pop-out joins the webview's partition. + const webviewPartition = usePreviewWebviewConfig( + threadRef.environmentId, + snapshot?.profileId, + )?.partition; // IPC interprets undefined profile ids as all profiles. const activeProfileId = snapshot?.profileId ?? DEFAULT_BROWSER_PROFILE_ID; const activeProfileName = previewProfileName(browserDefaults.profiles, activeProfileId); @@ -487,7 +494,9 @@ export function PreviewView({ ...(nativeServerTab && previewBridge ? { openDevTools: desktopCall(previewBridge.openDevTools), - toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(webviewPartition + ? { toggleTabWindow: () => handleTabWindow(webviewPartition) } + : {}), ...(canFindInPage ? { find: handleFind } : {}), } : {}), @@ -511,20 +520,21 @@ export function PreviewView({ ?.clearCache(threadRef.environmentId, activeProfileId) .catch(() => undefined), openDevTools: desktopCall(previewBridge.openDevTools), - toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(webviewPartition ? { toggleTabWindow: () => handleTabWindow(webviewPartition) } : {}), ...(canFindInPage ? { find: handleFind } : {}), } : null; - const handleNativePictureInPicture = () => { + const isPopoutWindow = window.location.hash.startsWith("#/browser-window/"); + const handleTabWindow = (partition: string) => { if (!previewBridge || !runtimeTabId) return; - const operation = desktopOverlay?.pictureInPicture - ? previewBridge.pictureInPicture.close - : previewBridge.pictureInPicture.open; - void operation(runtimeTabId).catch((error) => { + const request = desktopOverlay?.detached + ? previewBridge.tabWindow.close(runtimeTabId) + : previewBridge.tabWindow.open(runtimeTabId, partition); + void request.catch((error) => { toastManager.add({ type: "error", - title: "Unable to update popped-out preview", + title: "Unable to update browser window", description: error instanceof Error ? error.message : "An error occurred.", }); }); @@ -826,37 +836,15 @@ export function PreviewView({ submitted = true; } const { annotation: picked, submission, screenshotFailed = false } = result; - // Send annotations even if the optional crop fails or stalls; drop failed screenshots so prompts cannot - // claim an attachment. - const capture = capturePreviewAnnotationScreenshot(picked); - const cropDropped = screenshotFailed || capture.status === "failed"; - const annotation = capture.status === "failed" ? { ...picked, screenshot: null } : picked; - addPreviewAnnotation(threadRef, annotation); - if (cropDropped) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not capture the picked element", - description: "The annotation was kept without the screenshot.", - }), - ); - } - const screenshotFile = capture.status === "captured" ? capture.file : null; - const image = - screenshotFile && annotation.screenshot - ? ({ - type: "image", - id: annotation.id, - name: screenshotFile.name, - mimeType: screenshotFile.type, - sizeBytes: screenshotFile.size, - previewUrl: annotation.screenshot.dataUrl, - file: screenshotFile, - } satisfies ComposerImageAttachment) - : null; - if (image) { - addImage(threadRef, image); + if (isPopoutWindow) { + sendPreviewAnnotationToChatWindow({ threadRef, picked, screenshotFailed }); + return; } + const { annotation, image } = addPreviewAnnotationToDraft( + threadRef, + picked, + screenshotFailed, + ); if ( submission === "send" && readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) @@ -962,11 +950,23 @@ export function PreviewView({ captureDisabled={!desktopOverlay || isUnreachable} recording={recordingRuntimeTabId !== null} onPictureInPicture={ - tabId && (isServerTab || previewBridge) ? handlePictureInPicture : undefined + isPopoutWindow + ? () => { + if (previewBridge && runtimeTabId) + void previewBridge.tabWindow.close(runtimeTabId).catch(() => undefined); + } + : tabId && (isServerTab || previewBridge) + ? handlePictureInPicture + : undefined } - pictureInPicture={miniPlayerTabId === tabId} + pictureInPictureLabel={isPopoutWindow ? "Bring back to chat" : undefined} + windowTitleBar={isPopoutWindow && isMacPlatform(navigator.platform)} + pictureInPicture={!isPopoutWindow && miniPlayerTabId === tabId} pictureInPictureDisabled={ - isUnreachable || (!isServerTab && !desktopOverlay?.hasWebContents) + !isPopoutWindow && + (isUnreachable || + Boolean(desktopOverlay?.detached) || + (!isServerTab && !desktopOverlay?.hasWebContents)) } onPickElement={previewBridge && tabId && !isServerTab ? handlePickElement : undefined} pickActive={pickActive} @@ -1009,7 +1009,7 @@ export function PreviewView({ } deviceToolbarVisible={viewport._tag !== "fill"} onToggleDeviceToolbar={handleToggleDeviceToolbar} - nativePictureInPicture={desktopOverlay?.pictureInPicture ?? false} + tabWindow={desktopOverlay?.detached ?? false} /> ) : null } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 50b5ae47c449b..93d2d072e1d17 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -39,6 +39,7 @@ import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { cn } from "~/lib/utils"; import { useThreadPreviewState } from "~/previewStateStore"; +import { usePreviewWebviewConfig } from "~/browser/previewWebviewConfigState"; import { type PreviewMiniPlayerSize, type PreviewMiniPlayerSource, @@ -178,7 +179,9 @@ export function useThreadPreviewCanvasSource( : NO_DEVICE_SCREEN_ATOM, ); if (!miniPlayer || !source || sourceKey === null) return null; - if (source.kind === "browser" && !browser.snapshot) return null; + if (source.kind === "browser" && (!browser.snapshot || browser.desktopOverlay?.detached)) { + return null; + } return { key: sourceKey, width: miniPlayer.width, @@ -220,6 +223,10 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly recordingTabIds.has(runtimeTabId) || findActiveBrowserRecordingRuntimeTabId(threadRef, tabId) !== null; const serverSurfaceRef = useRef(null); + const webviewPartition = usePreviewWebviewConfig( + threadRef.environmentId, + snapshot?.profileId, + )?.partition; const serverPictureInPicture = useServerPictureInPictureKey() === serverPictureInPictureKey(threadRef.threadId, tabId); const viewportKey = streamedSourceKey( @@ -242,11 +249,8 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly tabId, seed: serverSurfaceRef.current?.canvas() ?? null, }); - } else if (previewBridge) { - const operation = desktopOverlay?.pictureInPicture - ? previewBridge.pictureInPicture.close - : previewBridge.pictureInPicture.open; - await operation(runtimeTabId); + } else if (previewBridge && webviewPartition) { + await previewBridge.tabWindow.open(runtimeTabId, webviewPartition); } } catch (error) { toastManager.add({ @@ -262,7 +266,7 @@ function BrowserMiniPlayer({ threadRef, tabId, miniPlayer }: Props & { readonly snapshot.navStatus._tag === "Success" ? previewPdfAssetUrl(snapshot.navStatus.url, httpBaseUrl) : null; - const poppedOut = serverTab ? serverPictureInPicture : Boolean(desktopOverlay?.pictureInPicture); + const poppedOut = serverTab && serverPictureInPicture; const canPopOut = serverTab ? poppedOut || (pdfUrl === null && supportsServerPictureInPicture()) : true; diff --git a/apps/web/src/components/preview/previewAnnotationDelivery.ts b/apps/web/src/components/preview/previewAnnotationDelivery.ts new file mode 100644 index 0000000000000..29160eeb14533 --- /dev/null +++ b/apps/web/src/components/preview/previewAnnotationDelivery.ts @@ -0,0 +1,71 @@ +import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; +import { capturePreviewAnnotationScreenshot } from "~/lib/previewAnnotation"; + +interface PoppedOutAnnotation { + readonly threadRef: ScopedThreadRef; + readonly picked: PreviewAnnotationPayload; + readonly screenshotFailed: boolean; +} + +// The pop-out window has its own composer store, so its picks are handed to the chat window. +const CHANNEL_NAME = "t3code:preview-annotations"; + +export function addPreviewAnnotationToDraft( + threadRef: ScopedThreadRef, + picked: PreviewAnnotationPayload, + screenshotFailed: boolean, +) { + // Keep the annotation even if the crop fails; drop the screenshot so prompts cannot claim an attachment. + const capture = capturePreviewAnnotationScreenshot(picked); + const annotation = capture.status === "failed" ? { ...picked, screenshot: null } : picked; + const store = useComposerDraftStore.getState(); + store.addPreviewAnnotation(threadRef, annotation); + if (screenshotFailed || capture.status === "failed") { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not capture the picked element", + description: "The annotation was kept without the screenshot.", + }), + ); + } + const file = capture.status === "captured" ? capture.file : null; + const image = + file && annotation.screenshot + ? ({ + type: "image", + id: annotation.id, + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + previewUrl: annotation.screenshot.dataUrl, + file, + } satisfies ComposerImageAttachment) + : null; + if (image) store.addImage(threadRef, image); + return { annotation, image }; +} + +export function sendPreviewAnnotationToChatWindow(message: PoppedOutAnnotation) { + const channel = new BroadcastChannel(CHANNEL_NAME); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- BroadcastChannel has no target origin + channel.postMessage(message); + channel.close(); +} + +export function PoppedOutAnnotationReceiver() { + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to picks posted by pop-out preview windows + useEffect(() => { + const channel = new BroadcastChannel(CHANNEL_NAME); + channel.onmessage = (event: MessageEvent) => { + const { threadRef, picked, screenshotFailed } = event.data; + addPreviewAnnotationToDraft(threadRef, picked, screenshotFailed); + }; + return () => channel.close(); + }, []); + return null; +} diff --git a/apps/web/src/components/preview/usePreviewBridge.test.ts b/apps/web/src/components/preview/usePreviewBridge.test.ts index 736ac7d743c32..79e0ecbf96ebd 100644 --- a/apps/web/src/components/preview/usePreviewBridge.test.ts +++ b/apps/web/src/components/preview/usePreviewBridge.test.ts @@ -20,6 +20,7 @@ function state(navStatus: DesktopPreviewTabState["navStatus"]): DesktopPreviewTa canGoForward: false, zoomFactor: 1, pictureInPicture: false, + detached: false, colorScheme: "system", audioMuted: false, audible: false, diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 92bc9b29b35c3..4a4cbd0b17710 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -126,6 +126,7 @@ export function projectDesktopState(state: DesktopPreviewTabState): DesktopPrevi loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, pictureInPicture: state.pictureInPicture, + detached: state.detached, colorScheme: state.colorScheme, audioMuted: state.audioMuted, audible: state.audible, diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index d7775bbd6117b..ca7d07a68f754 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -177,6 +177,7 @@ describe("previewStateStore (single-tab)", () => { loading: false, zoomFactor: 1, pictureInPicture: false, + detached: false, colorScheme: "system" as const, audioMuted: false, audible: false, @@ -211,6 +212,7 @@ describe("previewStateStore (single-tab)", () => { loading: false, zoomFactor: 1, pictureInPicture: false, + detached: false, colorScheme: "system", audioMuted: false, audible: false, @@ -240,6 +242,7 @@ describe("previewStateStore (single-tab)", () => { loading: false, zoomFactor: 1, pictureInPicture: false, + detached: false, colorScheme: "system", audioMuted: false, audible: false, @@ -371,6 +374,7 @@ describe("previewStateStore (single-tab)", () => { loading: false, zoomFactor: 1, pictureInPicture: false, + detached: false, colorScheme: "system", audioMuted: false, audible: false, diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 37a5bec395175..556fbdbb0c916 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -20,6 +20,7 @@ export interface DesktopPreviewOverlay { loading: boolean; zoomFactor: number; pictureInPicture: boolean; + detached: boolean; colorScheme: DesktopPreviewColorScheme; audioMuted: boolean; audible: boolean; @@ -389,6 +390,7 @@ function isPreviewStateEqual( previous.loading === next.loading && previous.zoomFactor === next.zoomFactor && previous.pictureInPicture === next.pictureInPicture && + previous.detached === next.detached && previous.colorScheme === next.colorScheme && previous.audioMuted === next.audioMuted && previous.audible === next.audible && diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1b93b3966b4ef..9f683b718c798 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ApproveAgentRouteImport } from './routes/approve-agent' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' +import { Route as BrowserWindowRuntimeTabIdRouteImport } from './routes/browser-window.$runtimeTabId' import { Route as ChatUsageRouteImport } from './routes/_chat.usage' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' @@ -97,6 +98,12 @@ const ProjectsProjectKeyRoute = ProjectsProjectKeyRouteImport.update({ path: '/projects/$projectKey', getParentRoute: () => rootRouteImport, } as any) +const BrowserWindowRuntimeTabIdRoute = + BrowserWindowRuntimeTabIdRouteImport.update({ + id: '/browser-window/$runtimeTabId', + path: '/browser-window/$runtimeTabId', + getParentRoute: () => rootRouteImport, + } as any) const ChatUsageRoute = ChatUsageRouteImport.update({ id: '/usage', path: '/usage', @@ -286,6 +293,7 @@ export interface FileRoutesByFullPath { '/pull-requests': typeof ChatPullRequestsRoute '/settings': typeof ChatSettingsRouteWithChildren '/usage': typeof ChatUsageRoute + '/browser-window/$runtimeTabId': typeof BrowserWindowRuntimeTabIdRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -329,6 +337,7 @@ export interface FileRoutesByTo { '/pull-requests': typeof ChatPullRequestsRoute '/settings': typeof ChatSettingsRouteWithChildren '/usage': typeof ChatUsageRoute + '/browser-window/$runtimeTabId': typeof BrowserWindowRuntimeTabIdRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -375,6 +384,7 @@ export interface FileRoutesById { '/_chat/pull-requests': typeof ChatPullRequestsRoute '/_chat/settings': typeof ChatSettingsRouteWithChildren '/_chat/usage': typeof ChatUsageRoute + '/browser-window/$runtimeTabId': typeof BrowserWindowRuntimeTabIdRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -422,6 +432,7 @@ export interface FileRouteTypes { | '/pull-requests' | '/settings' | '/usage' + | '/browser-window/$runtimeTabId' | '/projects/$projectKey' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -465,6 +476,7 @@ export interface FileRouteTypes { | '/pull-requests' | '/settings' | '/usage' + | '/browser-window/$runtimeTabId' | '/projects/$projectKey' | '/' | '/$environmentId/$threadId' @@ -510,6 +522,7 @@ export interface FileRouteTypes { | '/_chat/pull-requests' | '/_chat/settings' | '/_chat/usage' + | '/browser-window/$runtimeTabId' | '/projects/$projectKey' | '/_chat/' | '/_chat/$environmentId/$threadId' @@ -552,6 +565,7 @@ export interface RootRouteChildren { GuestRoute: typeof GuestRoute PairRoute: typeof PairRoute WelcomeRoute: typeof WelcomeRoute + BrowserWindowRuntimeTabIdRoute: typeof BrowserWindowRuntimeTabIdRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute ThreadsEnvironmentIdThreadIdRoute: typeof ThreadsEnvironmentIdThreadIdRoute } @@ -621,6 +635,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProjectsProjectKeyRouteImport parentRoute: typeof rootRouteImport } + '/browser-window/$runtimeTabId': { + id: '/browser-window/$runtimeTabId' + path: '/browser-window/$runtimeTabId' + fullPath: '/browser-window/$runtimeTabId' + preLoaderRoute: typeof BrowserWindowRuntimeTabIdRouteImport + parentRoute: typeof rootRouteImport + } '/_chat/usage': { id: '/_chat/usage' path: '/usage' @@ -956,6 +977,7 @@ const rootRouteChildren: RootRouteChildren = { GuestRoute: GuestRoute, PairRoute: PairRoute, WelcomeRoute: WelcomeRoute, + BrowserWindowRuntimeTabIdRoute: BrowserWindowRuntimeTabIdRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, ThreadsEnvironmentIdThreadIdRoute: ThreadsEnvironmentIdThreadIdRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c2e15c4abab37..e5c54c8996fcd 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { } from "@tanstack/react-router"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { PoppedOutAnnotationReceiver } from "~/components/preview/previewAnnotationDelivery"; import { APP_DISPLAY_NAME, APP_VERSION } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; @@ -173,6 +174,8 @@ function RootRouteBranch() { return ; } + if (pathname.startsWith("/browser-window/")) return ; + // Settings can reopen /welcome mid-session, so it mounts everything the app does except the // first-run gate and the event router, whose first navigation would leave the page. const appHosts = ( @@ -208,6 +211,7 @@ function RootRouteBranch() { return ( <> + {isWelcome ? ( <> {appHosts} diff --git a/apps/web/src/routes/browser-window.$runtimeTabId.tsx b/apps/web/src/routes/browser-window.$runtimeTabId.tsx new file mode 100644 index 0000000000000..0b4bea1afd1c7 --- /dev/null +++ b/apps/web/src/routes/browser-window.$runtimeTabId.tsx @@ -0,0 +1,27 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { createFileRoute } from "@tanstack/react-router"; + +import { PreviewPanel } from "~/components/preview/PreviewPanel"; +import { usePreviewSession } from "~/components/preview/usePreviewSession"; + +export const Route = createFileRoute("/browser-window/$runtimeTabId")({ + component: BrowserWindowRoute, +}); + +function BrowserWindowRoute() { + const { runtimeTabId } = Route.useParams(); + const [environmentId, threadId, , tabId] = JSON.parse(runtimeTabId) as [ + string, + string, + string | null, + string, + ]; + const threadRef = scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)); + usePreviewSession(threadRef); + return ( +
+ +
+ ); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b96062b4e0676..fcac39ecff05d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -605,6 +605,8 @@ export interface DesktopPreviewTabState { canGoForward: boolean; zoomFactor: number; pictureInPicture: boolean; + /** Rendered in its own window; the main window shows a placeholder. */ + detached: boolean; colorScheme: DesktopPreviewColorScheme; audioMuted: boolean; /** Chromium reports playback even when audioMuted is true. */ @@ -972,6 +974,11 @@ export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), }); +export const DesktopPreviewOpenTabWindowInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + partition: Schema.String, +}); + export const DesktopPreviewNavigateInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, url: Schema.String, @@ -1313,6 +1320,10 @@ export interface DesktopPreviewBridge { open: (tabId: string) => Promise; close: (tabId: string) => Promise; }; + tabWindow: { + open: (tabId: string, partition: string) => Promise; + close: (tabId: string) => Promise; + }; recording: { onInput: (listener: (event: DesktopPreviewRecordingInputEvent) => void) => () => void; startScreencast: (tabId: string) => Promise;