diff --git a/products/desktop/apps/code/src/main/di/bindings.ts b/products/desktop/apps/code/src/main/di/bindings.ts index 3d80c5653a3c..61c64ebea734 100644 --- a/products/desktop/apps/code/src/main/di/bindings.ts +++ b/products/desktop/apps/code/src/main/di/bindings.ts @@ -119,6 +119,7 @@ import type { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import type { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; import type { DIALOG_SERVICE } from "@posthog/platform/dialog"; import type { DISK_CACHE_SERVICE } from "@posthog/platform/disk-cache"; +import type { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import type { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; import type { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; import type { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; @@ -232,6 +233,7 @@ import type { ElectronContextMenu } from "../platform-adapters/electron-context- import type { ElectronCrypto } from "../platform-adapters/electron-crypto"; import type { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import type { ElectronDialog } from "../platform-adapters/electron-dialog"; +import type { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import type { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import type { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; import type { ElectronMainWindow } from "../platform-adapters/electron-main-window"; @@ -327,6 +329,7 @@ export interface MainBindings { [FILE_ICON_SERVICE]: ElectronFileIcon; [SECURE_STORAGE_SERVICE]: ElectronSecureStorage; [MAIN_WINDOW_SERVICE]: ElectronMainWindow; + [EMBEDDED_BROWSER]: ElectronEmbeddedBrowser; [APP_LIFECYCLE_SERVICE]: ElectronAppLifecycle; [POWER_MANAGER_SERVICE]: ElectronPowerManager; [UPDATER_SERVICE]: ElectronUpdater; diff --git a/products/desktop/apps/code/src/main/di/container.ts b/products/desktop/apps/code/src/main/di/container.ts index bd315058bb24..87dacdf53d5f 100644 --- a/products/desktop/apps/code/src/main/di/container.ts +++ b/products/desktop/apps/code/src/main/di/container.ts @@ -30,6 +30,7 @@ import { CONTEXT_MENU_CONTROLLER, CONTEXT_MENU_EXTERNAL_APPS_SERVICE, } from "@posthog/core/context-menu/identifiers"; +import { embeddedBrowserCoreModule } from "@posthog/core/embedded-browser/embedded-browser.module"; import { FocusHostService } from "@posthog/core/focus/focus-service"; import { FocusServiceEvent } from "@posthog/core/focus/identifiers"; import { gitHostModule } from "@posthog/core/git/git-host.module"; @@ -109,6 +110,7 @@ import { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; import { DEV_HOST_ACTIONS_SERVICE } from "@posthog/platform/dev-host-actions"; import { DIALOG_SERVICE } from "@posthog/platform/dialog"; import { DISK_CACHE_SERVICE } from "@posthog/platform/disk-cache"; +import { EMBEDDED_BROWSER } from "@posthog/platform/embedded-browser"; import { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; import { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; @@ -239,6 +241,7 @@ import { ElectronContextMenu } from "../platform-adapters/electron-context-menu" import { ElectronCrypto } from "../platform-adapters/electron-crypto"; import { ElectronDevHostActions } from "../platform-adapters/electron-dev-host-actions"; import { ElectronDialog } from "../platform-adapters/electron-dialog"; +import { ElectronEmbeddedBrowser } from "../platform-adapters/electron-embedded-browser"; import { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; import { ElectronMainWindow } from "../platform-adapters/electron-main-window"; @@ -354,6 +357,7 @@ container.bind(ANALYTICS_SERVICE).toConstantValue(posthogNodeAnalytics); container.bind(FILE_ICON_SERVICE).to(ElectronFileIcon); container.bind(SECURE_STORAGE_SERVICE).to(ElectronSecureStorage); container.bind(MAIN_WINDOW_SERVICE).to(ElectronMainWindow); +container.bind(EMBEDDED_BROWSER).to(ElectronEmbeddedBrowser); container.bind(APP_LIFECYCLE_SERVICE).to(ElectronAppLifecycle); container.bind(POWER_MANAGER_SERVICE).to(ElectronPowerManager); container.bind(UPDATER_SERVICE).to(ElectronUpdater); @@ -809,6 +813,10 @@ container.bind(QUICK_ASK_RUN_DEFAULTS).toConstantValue(() => { // service in the main process; resolved by the host-router browserTabs router. container.load(browserTabsModule); +// Embedded browser: the URL-policy service (core) over the Electron +// WebContentsView adapter, resolved by the host-router embeddedBrowser router. +container.load(embeddedBrowserCoreModule); + container.bind(MAIN_DEV_FLAGS_SERVICE).to(DevFlagsService); container.bind(MAIN_DEV_METRICS_SERVICE).to(DevMetricsService); container.bind(MAIN_DEV_NETWORK_SERVICE).to(DevNetworkService); diff --git a/products/desktop/apps/code/src/main/platform-adapters/electron-embedded-browser.ts b/products/desktop/apps/code/src/main/platform-adapters/electron-embedded-browser.ts new file mode 100644 index 000000000000..063f041f8f7c --- /dev/null +++ b/products/desktop/apps/code/src/main/platform-adapters/electron-embedded-browser.ts @@ -0,0 +1,313 @@ +import type { + EmbeddedBrowserBounds, + EmbeddedBrowserCreateOptions, + EmbeddedBrowserEvent, + EmbeddedBrowserPageState, + IEmbeddedBrowser, +} from "@posthog/platform/embedded-browser"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; +import { TypedEventEmitter } from "@posthog/shared"; +import { app, session, shell, WebContentsView } from "electron"; +import { inject, injectable } from "inversify"; +import { logger } from "../utils/logger"; +import type { ElectronMainWindow } from "./electron-main-window"; + +const log = logger.scope("embedded-browser"); + +/** + * A separate persistent partition from the app's own session (`persist:main`): + * pages the user browses never see the app's cookies, and their logins + * survive app restarts. + */ +const PARTITION = "persist:embedded-browser"; + +type Events = { event: EmbeddedBrowserEvent }; + +function isWebUrl(raw: string): boolean { + try { + const url = new URL(raw); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +/** + * A standard-Chrome user agent for embedded pages. Identity providers + * (notably Google) reject OAuth from anything that identifies as an embedded + * webview — the default UA carries `Electron/…` and the app token, which + * triggers `disallowed_useragent`. Stripping those tokens leaves the plain + * Chrome UA this build actually is. + */ +function browserlikeUserAgent(defaultUserAgent: string): string { + return defaultUserAgent + .split(" ") + .filter( + (token) => + !token.startsWith("Electron/") && + !token.toLowerCase().includes("posthog"), + ) + .join(" "); +} + +const GUEST_WEB_PREFERENCES = { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + partition: PARTITION, +} as const; + +/** + * Desktop implementation of the embedded browser: one `WebContentsView` per + * view id, attached to the single main window. The view paints natively ABOVE + * the renderer, so the renderer drives bounds and visibility over tRPC — + * nothing in the DOM can cover the view. + * + * Security posture: fully sandboxed guest with no preload and no Node, its + * own cookie partition (never `persist:main`), http(s)-only navigation, and + * all permission requests (camera, mic, geolocation, …) denied. + */ +@injectable() +export class ElectronEmbeddedBrowser + extends TypedEventEmitter + implements IEmbeddedBrowser +{ + private readonly views = new Map(); + private sessionHardened = false; + + constructor( + @inject(MAIN_WINDOW_SERVICE) + private readonly mainWindow: ElectronMainWindow, + ) { + super(); + this.setMaxListeners(0); + } + + async create(options: EmbeddedBrowserCreateOptions): Promise { + const existing = this.views.get(options.viewId); + if (existing && !existing.webContents.isDestroyed()) { + // Re-opening a kept-alive view (tab switch back): re-glue and re-show + // it exactly where the user left it. Never navigate here — options.url + // is a persisted snapshot that lags the live page, so "restoring" it + // would yank an in-progress flow (a multi-step login, a checkout) back + // to a stale page. Explicit navigation goes through navigate(). + this.setBounds(options.viewId, options.bounds); + existing.setVisible(true); + this.emitPageState(options.viewId, existing); + return; + } + if (existing) this.views.delete(options.viewId); + + const window = this.mainWindow.getBrowserWindow(); + if (!window) throw new Error("No main window to attach the view to"); + + this.hardenSession(); + const view = new WebContentsView({ + webPreferences: GUEST_WEB_PREFERENCES, + }); + this.views.set(options.viewId, view); + this.wireEvents(options.viewId, view); + window.contentView.addChildView(view); + this.setBounds(options.viewId, options.bounds); + + try { + await view.webContents.loadURL(options.url); + } catch (error) { + // Load failures (bad host, offline) keep the view alive — the + // load-failed event lets the UI explain, and the user can retry from + // the URL bar. + log.warn("initial load failed", { url: options.url, error }); + } + } + + async navigate(viewId: string, url: string): Promise { + const view = this.mustGet(viewId); + try { + await view.webContents.loadURL(url); + } catch (error) { + log.warn("navigation failed", { url, error }); + this.emitPageState(viewId, view); + } + } + + goBack(viewId: string): void { + this.views.get(viewId)?.webContents.navigationHistory.goBack(); + } + + goForward(viewId: string): void { + this.views.get(viewId)?.webContents.navigationHistory.goForward(); + } + + reload(viewId: string): void { + this.views.get(viewId)?.webContents.reload(); + } + + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void { + const view = this.views.get(viewId); + const window = this.mainWindow.getBrowserWindow(); + if (!view || !window) return; + // The renderer reports CSS pixels; the window may be zoomed (Cmd+/-), so + // scale by the host page's zoom factor to land on real window coordinates. + const zoom = window.webContents.getZoomFactor(); + view.setBounds({ + x: Math.round(bounds.x * zoom), + y: Math.round(bounds.y * zoom), + width: Math.max(0, Math.round(bounds.width * zoom)), + height: Math.max(0, Math.round(bounds.height * zoom)), + }); + } + + setVisible(viewId: string, visible: boolean): void { + this.views.get(viewId)?.setVisible(visible); + } + + openDevTools(viewId: string): void { + this.views.get(viewId)?.webContents.openDevTools({ mode: "detach" }); + } + + async destroy(viewId: string): Promise { + const view = this.views.get(viewId); + if (!view) return; + this.views.delete(viewId); + const window = this.mainWindow.getBrowserWindow(); + window?.contentView.removeChildView(view); + if (!view.webContents.isDestroyed()) view.webContents.close(); + this.emit("event", { type: "view-destroyed", viewId }); + } + + getPageState(viewId: string): EmbeddedBrowserPageState | null { + const view = this.views.get(viewId); + return view ? this.pageState(viewId, view) : null; + } + + events(signal?: AbortSignal): AsyncIterable { + return this.toIterable("event", { signal }); + } + + /** + * Electron approves page permission requests by default. Embedded pages get + * none: a browser panel has no business granting camera, mic, geolocation, + * or notifications, and the user has no permission UI to review grants. + */ + private hardenSession(): void { + if (this.sessionHardened) return; + this.sessionHardened = true; + const guestSession = session.fromPartition(PARTITION); + guestSession.setPermissionRequestHandler((_wc, _permission, callback) => + callback(false), + ); + guestSession.setPermissionCheckHandler(() => false); + // Identity providers (Google) reject OAuth when the request identifies as + // an embedded webview (`disallowed_useragent`). Three layers because no + // single one covers everything: the session UA covers views, the header + // rewrite covers every network request — including a popup's FIRST one, + // which is already in flight before any per-webContents override can run + // (did-create-window fires too late for it). + guestSession.setUserAgent(browserlikeUserAgent(app.userAgentFallback)); + guestSession.webRequest.onBeforeSendHeaders((details, callback) => { + const headers = details.requestHeaders; + const userAgent = headers["User-Agent"]; + if (typeof userAgent === "string") { + headers["User-Agent"] = browserlikeUserAgent(userAgent); + } + callback({ requestHeaders: headers }); + }); + } + + private mustGet(viewId: string): WebContentsView { + const view = this.views.get(viewId); + if (!view) throw new Error(`Unknown embedded browser view: ${viewId}`); + return view; + } + + private pageState( + viewId: string, + view: WebContentsView, + ): EmbeddedBrowserPageState { + const wc = view.webContents; + return { + viewId, + url: wc.getURL(), + title: wc.getTitle(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + isLoading: wc.isLoading(), + }; + } + + private emitPageState(viewId: string, view: WebContentsView): void { + this.emit("event", { + type: "page-state", + state: this.pageState(viewId, view), + }); + } + + private wireEvents(viewId: string, view: WebContentsView): void { + const wc = view.webContents; + const push = () => this.emitPageState(viewId, view); + wc.on("did-navigate", push); + wc.on("did-navigate-in-page", push); + wc.on("page-title-updated", push); + wc.on("did-start-loading", push); + wc.on("did-stop-loading", push); + + wc.on( + "did-fail-load", + (_event, errorCode, errorDescription, url, isMainFrame) => { + // -3 is ERR_ABORTED: fired for normal in-flight cancellations (user + // navigated again, SPA aborts) — not a failure worth surfacing. + if (!isMainFrame || errorCode === -3) return; + this.emit("event", { + type: "load-failed", + viewId, + url, + errorDescription: errorDescription || `Error ${errorCode}`, + }); + push(); + }, + ); + wc.on("render-process-gone", (_event, details) => { + this.emit("event", { + type: "load-failed", + viewId, + url: wc.getURL(), + errorDescription: `The page crashed (${details.reason})`, + }); + }); + + // The guest stays a plain web page: block non-web schemes. + wc.on("will-navigate", (event, url) => { + if (!isWebUrl(url)) event.preventDefault(); + }); + // Allow http(s) popups as real (sandboxed, preload-less) child windows on + // the SAME cookie partition — popup-based SSO (Google sign-in) needs the + // popup and the page to share a session, so bouncing it to the system + // browser can never complete the login. Non-web schemes stay denied. + wc.setWindowOpenHandler(({ url }) => { + log.info("popup requested", { viewId, url, allowed: isWebUrl(url) }); + if (!isWebUrl(url)) return { action: "deny" }; + return { + action: "allow", + overrideBrowserWindowOptions: { + autoHideMenuBar: true, + webPreferences: GUEST_WEB_PREFERENCES, + }, + }; + }); + wc.on("did-create-window", (child) => { + // Covers navigator.userAgent for scripts inside the popup; the header + // rewrite above already covers what servers see. + child.webContents.setUserAgent( + browserlikeUserAgent(child.webContents.getUserAgent()), + ); + child.webContents.on("will-navigate", (event, url) => { + if (!isWebUrl(url)) event.preventDefault(); + }); + // No nested popups from a popup; open anything further externally. + child.webContents.setWindowOpenHandler(({ url }) => { + if (isWebUrl(url)) void shell.openExternal(url); + return { action: "deny" }; + }); + }); + } +} diff --git a/products/desktop/apps/code/src/main/trpc/router.ts b/products/desktop/apps/code/src/main/trpc/router.ts index 8dc5bd1d7359..affe7db07b51 100644 --- a/products/desktop/apps/code/src/main/trpc/router.ts +++ b/products/desktop/apps/code/src/main/trpc/router.ts @@ -16,6 +16,7 @@ import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.rou import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router"; import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; import { diskCacheRouter } from "@posthog/host-router/routers/disk-cache.router"; +import { embeddedBrowserRouter } from "@posthog/host-router/routers/embedded-browser.router"; import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router"; import { environmentRouter } from "@posthog/host-router/routers/environment.router"; import { externalAppsRouter } from "@posthog/host-router/routers/external-apps.router"; @@ -100,6 +101,7 @@ export const trpcRouter = router({ notification: notificationRouter, oauth: oauthRouter, logs: logsRouter, + embeddedBrowser: embeddedBrowserRouter, os: osRouter, piSession: piSessionRouter, processTracking: processTrackingRouter, diff --git a/products/desktop/apps/code/src/renderer/desktop-services.ts b/products/desktop/apps/code/src/renderer/desktop-services.ts index 617843e4e6e2..3de60f432bd9 100644 --- a/products/desktop/apps/code/src/renderer/desktop-services.ts +++ b/products/desktop/apps/code/src/renderer/desktop-services.ts @@ -446,8 +446,9 @@ container container.bind(SETUP_STORE).toConstantValue(setupStore); -container - .bind(HOST_CAPABILITIES) - .toConstantValue({ localWorkspaces: true } satisfies HostCapabilities); +container.bind(HOST_CAPABILITIES).toConstantValue({ + localWorkspaces: true, + embeddedBrowser: true, +} satisfies HostCapabilities); container.bind(DISK_CACHE_IMAGES).toConstantValue(desktopDiskCacheImages); diff --git a/products/desktop/apps/web/src/web-container.ts b/products/desktop/apps/web/src/web-container.ts index 4d4bed06a16f..775f88842290 100644 --- a/products/desktop/apps/web/src/web-container.ts +++ b/products/desktop/apps/web/src/web-container.ts @@ -464,9 +464,10 @@ container.bind(POWER_MANAGER_SERVICE).toConstantValue(webPowerManager); // The web host is cloud-only: no local filesystem, so the UI must use remote // (connected-GitHub-org) repositories and cloud workspaces everywhere it would // otherwise reach for local folders/worktrees/terminal. -container - .bind(HOST_CAPABILITIES) - .toConstantValue({ localWorkspaces: false } satisfies HostCapabilities); +container.bind(HOST_CAPABILITIES).toConstantValue({ + localWorkspaces: false, + embeddedBrowser: false, +} satisfies HostCapabilities); container.load(authUiModule); diff --git a/products/desktop/docs/images/embedded-browser/01-real-view-posthog.png b/products/desktop/docs/images/embedded-browser/01-real-view-posthog.png new file mode 100644 index 000000000000..400251c9a777 Binary files /dev/null and b/products/desktop/docs/images/embedded-browser/01-real-view-posthog.png differ diff --git a/products/desktop/docs/images/embedded-browser/02-story-fresh-tab.png b/products/desktop/docs/images/embedded-browser/02-story-fresh-tab.png new file mode 100644 index 000000000000..8a71455ce6cc Binary files /dev/null and b/products/desktop/docs/images/embedded-browser/02-story-fresh-tab.png differ diff --git a/products/desktop/docs/images/embedded-browser/03-story-page-open.png b/products/desktop/docs/images/embedded-browser/03-story-page-open.png new file mode 100644 index 000000000000..d6826c74e918 Binary files /dev/null and b/products/desktop/docs/images/embedded-browser/03-story-page-open.png differ diff --git a/products/desktop/docs/images/embedded-browser/04-story-load-failed.png b/products/desktop/docs/images/embedded-browser/04-story-load-failed.png new file mode 100644 index 000000000000..6fbf3b3fa8d6 Binary files /dev/null and b/products/desktop/docs/images/embedded-browser/04-story-load-failed.png differ diff --git a/products/desktop/docs/images/embedded-browser/05-tab-strip.png b/products/desktop/docs/images/embedded-browser/05-tab-strip.png new file mode 100644 index 000000000000..89bebb432554 Binary files /dev/null and b/products/desktop/docs/images/embedded-browser/05-tab-strip.png differ diff --git a/products/desktop/packages/core/src/embedded-browser/embedded-browser.module.ts b/products/desktop/packages/core/src/embedded-browser/embedded-browser.module.ts new file mode 100644 index 000000000000..502b081ab5ac --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/embedded-browser.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { EmbeddedBrowserService } from "./embeddedBrowser"; +import { EMBEDDED_BROWSER_SERVICE } from "./identifiers"; + +export const embeddedBrowserCoreModule = new ContainerModule(({ bind }) => { + bind(EMBEDDED_BROWSER_SERVICE).to(EmbeddedBrowserService).inSingletonScope(); +}); diff --git a/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.test.ts b/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.test.ts new file mode 100644 index 000000000000..e462cbfa419f --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.test.ts @@ -0,0 +1,79 @@ +import type { IEmbeddedBrowser } from "@posthog/platform/embedded-browser"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EmbeddedBrowserService } from "./embeddedBrowser"; + +function fakeBrowser(): IEmbeddedBrowser { + return { + create: vi.fn().mockResolvedValue(undefined), + navigate: vi.fn().mockResolvedValue(undefined), + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + setBounds: vi.fn(), + setVisible: vi.fn(), + openDevTools: vi.fn(), + destroy: vi.fn().mockResolvedValue(undefined), + getPageState: vi.fn().mockReturnValue(null), + events: vi.fn(), + }; +} + +const bounds = { x: 0, y: 0, width: 800, height: 600 }; + +describe("EmbeddedBrowserService", () => { + let browser: IEmbeddedBrowser; + let service: EmbeddedBrowserService; + + beforeEach(() => { + browser = fakeBrowser(); + service = new EmbeddedBrowserService(browser); + }); + + it("normalizes the URL before opening", async () => { + await service.open({ viewId: "v1", url: "localhost:8000", bounds }); + expect(browser.create).toHaveBeenCalledWith({ + viewId: "v1", + url: "http://localhost:8000/", + bounds, + }); + }); + + it.each(["file:///etc/passwd", "javascript:alert(1)", "not a url"])( + "refuses to open %s", + async (url) => { + await expect(service.open({ viewId: "v1", url, bounds })).rejects.toThrow( + /not a loadable web url/i, + ); + expect(browser.create).not.toHaveBeenCalled(); + }, + ); + + it("normalizes the URL before navigating", async () => { + await service.navigate("v1", "posthog.com"); + expect(browser.navigate).toHaveBeenCalledWith("v1", "https://posthog.com/"); + }); + + it("refuses to navigate to a non-web URL", async () => { + await expect(service.navigate("v1", "file:///x")).rejects.toThrow( + /not a loadable web url/i, + ); + expect(browser.navigate).not.toHaveBeenCalled(); + }); + + it("forwards view lifecycle calls untouched", async () => { + service.goBack("v1"); + service.goForward("v1"); + service.reload("v1"); + service.setBounds("v1", bounds); + service.setVisible("v1", false); + service.openDevTools("v1"); + await service.destroy("v1"); + expect(browser.goBack).toHaveBeenCalledWith("v1"); + expect(browser.goForward).toHaveBeenCalledWith("v1"); + expect(browser.reload).toHaveBeenCalledWith("v1"); + expect(browser.setBounds).toHaveBeenCalledWith("v1", bounds); + expect(browser.setVisible).toHaveBeenCalledWith("v1", false); + expect(browser.openDevTools).toHaveBeenCalledWith("v1"); + expect(browser.destroy).toHaveBeenCalledWith("v1"); + }); +}); diff --git a/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.ts b/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.ts new file mode 100644 index 000000000000..feffa0a937aa --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/embeddedBrowser.ts @@ -0,0 +1,99 @@ +import { + EMBEDDED_BROWSER, + type EmbeddedBrowserBounds, + type EmbeddedBrowserEvent, + type EmbeddedBrowserPageState, + type IEmbeddedBrowser, +} from "@posthog/platform/embedded-browser"; +import { inject, injectable } from "inversify"; +import { normalizeBrowserUrl } from "./normalizeUrl"; + +export interface IEmbeddedBrowserService { + open(input: { + viewId: string; + url: string; + bounds: EmbeddedBrowserBounds; + }): Promise; + navigate(viewId: string, url: string): Promise; + goBack(viewId: string): void; + goForward(viewId: string): void; + reload(viewId: string): void; + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void; + setVisible(viewId: string, visible: boolean): void; + openDevTools(viewId: string): void; + destroy(viewId: string): Promise; + getPageState(viewId: string): EmbeddedBrowserPageState | null; + events(signal?: AbortSignal): AsyncIterable; +} + +/** + * Policy layer over the host's embedded browser: every URL that reaches the + * native view passes http(s) validation here, so no caller — UI, router, or + * future automation — can point the view at file:, javascript:, or custom + * schemes. The host adapter enforces the same rule for page-initiated + * navigations (defense in depth). + */ +@injectable() +export class EmbeddedBrowserService implements IEmbeddedBrowserService { + constructor( + @inject(EMBEDDED_BROWSER) private readonly browser: IEmbeddedBrowser, + ) {} + + async open(input: { + viewId: string; + url: string; + bounds: EmbeddedBrowserBounds; + }): Promise { + await this.browser.create({ + viewId: input.viewId, + url: this.assertWebUrl(input.url), + bounds: input.bounds, + }); + } + + async navigate(viewId: string, url: string): Promise { + await this.browser.navigate(viewId, this.assertWebUrl(url)); + } + + goBack(viewId: string): void { + this.browser.goBack(viewId); + } + + goForward(viewId: string): void { + this.browser.goForward(viewId); + } + + reload(viewId: string): void { + this.browser.reload(viewId); + } + + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void { + this.browser.setBounds(viewId, bounds); + } + + setVisible(viewId: string, visible: boolean): void { + this.browser.setVisible(viewId, visible); + } + + openDevTools(viewId: string): void { + this.browser.openDevTools(viewId); + } + + async destroy(viewId: string): Promise { + await this.browser.destroy(viewId); + } + + getPageState(viewId: string): EmbeddedBrowserPageState | null { + return this.browser.getPageState(viewId); + } + + events(signal?: AbortSignal): AsyncIterable { + return this.browser.events(signal); + } + + private assertWebUrl(raw: string): string { + const normalized = normalizeBrowserUrl(raw); + if (!normalized) throw new Error(`Not a loadable web URL: ${raw}`); + return normalized; + } +} diff --git a/products/desktop/packages/core/src/embedded-browser/identifiers.ts b/products/desktop/packages/core/src/embedded-browser/identifiers.ts new file mode 100644 index 000000000000..efa8dbf8880d --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/identifiers.ts @@ -0,0 +1,3 @@ +export const EMBEDDED_BROWSER_SERVICE = Symbol.for( + "posthog.embedded-browser.service", +); diff --git a/products/desktop/packages/core/src/embedded-browser/normalizeUrl.test.ts b/products/desktop/packages/core/src/embedded-browser/normalizeUrl.test.ts new file mode 100644 index 000000000000..75481ccd4a36 --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/normalizeUrl.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { normalizeBrowserUrl } from "./normalizeUrl"; + +describe("normalizeBrowserUrl", () => { + it.each([ + // already-complete web URLs pass through + ["https://posthog.com", "https://posthog.com/"], + ["http://example.com/a?b=1#c", "http://example.com/a?b=1#c"], + // bare domains get https + ["posthog.com", "https://posthog.com/"], + ["app.example.com/login", "https://app.example.com/login"], + // local dev hosts get http — dev servers rarely terminate TLS + ["localhost:8000", "http://localhost:8000/"], + ["localhost:3000/app", "http://localhost:3000/app"], + ["127.0.0.1:8010", "http://127.0.0.1:8010/"], + ["localhost", "http://localhost/"], + // surrounding whitespace is tolerated + [" posthog.com ", "https://posthog.com/"], + ])("%s → %s", (input, expected) => { + expect(normalizeBrowserUrl(input)).toBe(expected); + }); + + it.each([ + // non-web schemes are rejected outright, never scheme-prefixed + "file:///etc/passwd", + "javascript:alert(1)", + "mailto:user@example.com", + "chrome://settings", + "about:blank", + "data:text/html,hi", + // not URLs at all + "", + " ", + "not a url", + "https://", + ])("rejects %s", (input) => { + expect(normalizeBrowserUrl(input)).toBeNull(); + }); +}); diff --git a/products/desktop/packages/core/src/embedded-browser/normalizeUrl.ts b/products/desktop/packages/core/src/embedded-browser/normalizeUrl.ts new file mode 100644 index 000000000000..6488550e3c2b --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/normalizeUrl.ts @@ -0,0 +1,49 @@ +/** Hosts whose dev servers almost never terminate TLS — default them to http. */ +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]); + +function parseWebUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (!url.hostname) return null; + return url.href; +} + +/** + * "localhost:8000" parses as a URL with the scheme "localhost", so a plain + * scheme check would misread host:port shorthand as a custom scheme. A real + * non-web scheme (javascript:, file:, mailto:) never continues with a bare + * port number. + */ +function hasNonWebScheme(input: string): boolean { + const match = input.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):(.*)$/); + if (!match) return false; + const scheme = match[1].toLowerCase(); + if (scheme === "http" || scheme === "https") return false; + return !/^\d+([/?#].*)?$/.test(match[2]); +} + +/** + * Normalize user-typed input into a loadable http(s) URL, or null when it + * cannot be one. This is the single URL policy for the embedded browser: + * anything the native view loads must pass through here first. + */ +export function normalizeBrowserUrl(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed || /\s/.test(trimmed)) return null; + if (hasNonWebScheme(trimmed)) return null; + + const direct = parseWebUrl(trimmed); + if (direct) return direct; + // Claimed a scheme but didn't parse to a valid web URL ("https://") — + // prefixing another scheme onto it would fabricate a bogus host. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return null; + + const host = trimmed.split(/[/:?#]/, 1)[0]?.toLowerCase() ?? ""; + const scheme = LOCAL_HOSTS.has(host) ? "http" : "https"; + return parseWebUrl(`${scheme}://${trimmed}`); +} diff --git a/products/desktop/packages/core/src/embedded-browser/schemas.ts b/products/desktop/packages/core/src/embedded-browser/schemas.ts new file mode 100644 index 000000000000..c5ffae601653 --- /dev/null +++ b/products/desktop/packages/core/src/embedded-browser/schemas.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +export const embeddedBrowserBoundsSchema = z.object({ + x: z.number(), + y: z.number(), + width: z.number().nonnegative(), + height: z.number().nonnegative(), +}); + +export const openEmbeddedBrowserInput = z.object({ + viewId: z.string().min(1), + url: z.string().min(1), + bounds: embeddedBrowserBoundsSchema, +}); + +export const navigateEmbeddedBrowserInput = z.object({ + viewId: z.string().min(1), + url: z.string().min(1), +}); + +export const embeddedBrowserViewIdInput = z.object({ + viewId: z.string().min(1), +}); + +export const setEmbeddedBrowserBoundsInput = z.object({ + viewId: z.string().min(1), + bounds: embeddedBrowserBoundsSchema, +}); + +export const setEmbeddedBrowserVisibleInput = z.object({ + viewId: z.string().min(1), + visible: z.boolean(), +}); + +export const embeddedBrowserPageStateSchema = z.object({ + viewId: z.string(), + url: z.string(), + title: z.string(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + isLoading: z.boolean(), +}); diff --git a/products/desktop/packages/core/src/panels/panelLayoutTransforms.test.ts b/products/desktop/packages/core/src/panels/panelLayoutTransforms.test.ts index a7a2c3a2cdab..28678b7af70f 100644 --- a/products/desktop/packages/core/src/panels/panelLayoutTransforms.test.ts +++ b/products/desktop/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + addBrowserTab, addRecentFile, closeTab, createInitialTaskLayout, openTab, + updateBrowserTabUrl, } from "./panelLayoutTransforms"; import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; import { findTabInTree } from "./panelTree"; @@ -75,6 +77,67 @@ describe("panelLayoutTransforms", () => { }); }); + describe("addBrowserTab", () => { + it("adds an empty-url browser tab and activates it", () => { + const layout = createInitialTaskLayout(); + const added = applyUpdates(layout, addBrowserTab(layout, "main-panel")); + + expect(added.panelTree.type).toBe("leaf"); + if (added.panelTree.type !== "leaf") return; + const tab = added.panelTree.content.tabs.at(-1); + expect(tab?.data).toEqual({ + type: "browser", + browserId: tab?.id, + url: "", + }); + expect(tab?.closeable).toBe(true); + expect(added.panelTree.content.activeTabId).toBe(tab?.id); + }); + + it("does nothing for an unknown panel", () => { + const layout = createInitialTaskLayout(); + const result = applyUpdates(layout, addBrowserTab(layout, "nope")); + expect(result.panelTree).toEqual(layout.panelTree); + }); + }); + + describe("updateBrowserTabUrl", () => { + it("persists the current page into the tab data", () => { + const layout = createInitialTaskLayout(); + const added = applyUpdates(layout, addBrowserTab(layout, "main-panel")); + const tabId = + added.panelTree.type === "leaf" + ? (added.panelTree.content.tabs.at(-1)?.id ?? "") + : ""; + + const updated = applyUpdates( + added, + updateBrowserTabUrl(added, tabId, "http://localhost:8000/app"), + ); + const tab = findTabInTree(updated.panelTree, tabId)?.tab; + expect(tab?.data).toEqual({ + type: "browser", + browserId: tabId, + url: "http://localhost:8000/app", + }); + }); + + it("ignores unknown tabs and non-browser tabs", () => { + const layout = createInitialTaskLayout(); + expect(updateBrowserTabUrl(layout, "nope", "https://x.example")).toEqual( + {}, + ); + // "logs" exists but is not a browser tab — its data must not change. + const updated = applyUpdates( + layout, + updateBrowserTabUrl(layout, "logs", "https://x.example"), + ); + expect(findTabInTree(updated.panelTree, "logs")?.tab.data).toEqual({ + type: "logs", + }); + }); + }); + describe("addRecentFile", () => { it("dedupes and prepends, capping at the max", () => { const result = addRecentFile(["b", "a"], "a"); diff --git a/products/desktop/packages/core/src/panels/panelLayoutTransforms.ts b/products/desktop/packages/core/src/panels/panelLayoutTransforms.ts index 361b32b41ffa..eed56819a8b6 100644 --- a/products/desktop/packages/core/src/panels/panelLayoutTransforms.ts +++ b/products/desktop/packages/core/src/panels/panelLayoutTransforms.ts @@ -730,6 +730,59 @@ export function addTerminalTab( return { panelTree: updatedTree }; } +export function addBrowserTab( + layout: TaskLayout, + panelId: string, +): Partial { + const tabId = `browser-${Date.now()}`; + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + return addTabToPanel(panel, { + id: tabId, + label: "Browser", + // url starts empty: the panel shows a URL prompt and fills this in on + // the first navigation, so the tab restores to that page next launch. + data: { type: "browser", browserId: tabId, url: "" }, + component: null, + draggable: true, + closeable: true, + }); + }); + + return { panelTree: updatedTree }; +} + +/** Persist a browser tab's current page so a reopen/restart restores it. */ +export function updateBrowserTabUrl( + layout: TaskLayout, + tabId: string, + url: string, +): Partial { + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (!tabLocation) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + tabLocation.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const updatedTabs = panel.content.tabs.map((tab) => + tab.id === tabId && tab.data.type === "browser" + ? { ...tab, data: { ...tab.data, url } } + : tab, + ); + + return { + ...panel, + content: { ...panel.content, tabs: updatedTabs }, + }; + }, + ); + + return { panelTree: updatedTree }; +} + export function addActionTab( layout: TaskLayout, panelId: string, diff --git a/products/desktop/packages/core/src/panels/panelTypes.ts b/products/desktop/packages/core/src/panels/panelTypes.ts index ded6fe1b349a..1ce707356f92 100644 --- a/products/desktop/packages/core/src/panels/panelTypes.ts +++ b/products/desktop/packages/core/src/panels/panelTypes.ts @@ -14,6 +14,11 @@ export type TabData = terminalId: string; cwd: string; } + | { + type: "browser"; + browserId: string; + url: string; + } | { type: "action"; actionId: string; diff --git a/products/desktop/packages/host-router/src/router.ts b/products/desktop/packages/host-router/src/router.ts index 06fd5b661516..dc551e71dd17 100644 --- a/products/desktop/packages/host-router/src/router.ts +++ b/products/desktop/packages/host-router/src/router.ts @@ -15,6 +15,7 @@ import { contextMenuRouter } from "./routers/context-menu.router"; import { dashboardsRouter } from "./routers/dashboards.router"; import { deepLinkRouter } from "./routers/deep-link.router"; import { diskCacheRouter } from "./routers/disk-cache.router"; +import { embeddedBrowserRouter } from "./routers/embedded-browser.router"; import { enrichmentRouter } from "./routers/enrichment.router"; import { environmentRouter } from "./routers/environment.router"; import { externalAppsRouter } from "./routers/external-apps.router"; @@ -69,6 +70,7 @@ export const hostRouter = router({ deepLink: deepLinkRouter, diskCache: diskCacheRouter, enrichment: enrichmentRouter, + embeddedBrowser: embeddedBrowserRouter, environment: environmentRouter, externalApps: externalAppsRouter, fileWatcher: fileWatcherRouter, diff --git a/products/desktop/packages/host-router/src/routers/embedded-browser.router.ts b/products/desktop/packages/host-router/src/routers/embedded-browser.router.ts new file mode 100644 index 000000000000..35e48eee9407 --- /dev/null +++ b/products/desktop/packages/host-router/src/routers/embedded-browser.router.ts @@ -0,0 +1,72 @@ +import type { IEmbeddedBrowserService } from "@posthog/core/embedded-browser/embeddedBrowser"; +import { EMBEDDED_BROWSER_SERVICE } from "@posthog/core/embedded-browser/identifiers"; +import { + embeddedBrowserPageStateSchema, + embeddedBrowserViewIdInput, + navigateEmbeddedBrowserInput, + openEmbeddedBrowserInput, + setEmbeddedBrowserBoundsInput, + setEmbeddedBrowserVisibleInput, +} from "@posthog/core/embedded-browser/schemas"; +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +const svc = (container: ServiceResolver) => + container.get(EMBEDDED_BROWSER_SERVICE); + +export const embeddedBrowserRouter = router({ + open: publicProcedure + .input(openEmbeddedBrowserInput) + .mutation(({ ctx, input }) => svc(ctx.container).open(input)), + + navigate: publicProcedure + .input(navigateEmbeddedBrowserInput) + .mutation(({ ctx, input }) => + svc(ctx.container).navigate(input.viewId, input.url), + ), + + goBack: publicProcedure + .input(embeddedBrowserViewIdInput) + .mutation(({ ctx, input }) => svc(ctx.container).goBack(input.viewId)), + + goForward: publicProcedure + .input(embeddedBrowserViewIdInput) + .mutation(({ ctx, input }) => svc(ctx.container).goForward(input.viewId)), + + reload: publicProcedure + .input(embeddedBrowserViewIdInput) + .mutation(({ ctx, input }) => svc(ctx.container).reload(input.viewId)), + + setBounds: publicProcedure + .input(setEmbeddedBrowserBoundsInput) + .mutation(({ ctx, input }) => + svc(ctx.container).setBounds(input.viewId, input.bounds), + ), + + setVisible: publicProcedure + .input(setEmbeddedBrowserVisibleInput) + .mutation(({ ctx, input }) => + svc(ctx.container).setVisible(input.viewId, input.visible), + ), + + openDevTools: publicProcedure + .input(embeddedBrowserViewIdInput) + .mutation(({ ctx, input }) => + svc(ctx.container).openDevTools(input.viewId), + ), + + destroy: publicProcedure + .input(embeddedBrowserViewIdInput) + .mutation(({ ctx, input }) => svc(ctx.container).destroy(input.viewId)), + + getPageState: publicProcedure + .input(embeddedBrowserViewIdInput) + .output(embeddedBrowserPageStateSchema.nullable()) + .query(({ ctx, input }) => svc(ctx.container).getPageState(input.viewId)), + + onEvents: publicProcedure.subscription(async function* (opts) { + for await (const event of svc(opts.ctx.container).events(opts.signal)) { + yield event; + } + }), +}); diff --git a/products/desktop/packages/platform/package.json b/products/desktop/packages/platform/package.json index 7f5b6ff85a36..30dc3a05e484 100644 --- a/products/desktop/packages/platform/package.json +++ b/products/desktop/packages/platform/package.json @@ -103,6 +103,10 @@ "./disk-cache": { "types": "./dist/disk-cache.d.ts", "import": "./dist/disk-cache.js" + }, + "./embedded-browser": { + "types": "./dist/embedded-browser.d.ts", + "import": "./dist/embedded-browser.js" } }, "scripts": { diff --git a/products/desktop/packages/platform/src/embedded-browser.ts b/products/desktop/packages/platform/src/embedded-browser.ts new file mode 100644 index 000000000000..3533c849f48c --- /dev/null +++ b/products/desktop/packages/platform/src/embedded-browser.ts @@ -0,0 +1,62 @@ +/** + * A live embedded browser surface the host paints natively above the shared + * UI. The renderer owns placement (bounds, visibility) and navigation intent; + * the host owns the actual web view, its session, and its security posture. + * + * Everything crossing this interface is display-ready navigation data — URLs, + * titles, loading flags. Nothing from the embedded page's content crosses it. + */ + +export interface EmbeddedBrowserBounds { + x: number; + y: number; + width: number; + height: number; +} + +export interface EmbeddedBrowserCreateOptions { + viewId: string; + url: string; + /** CSS pixels relative to the host window's web contents. */ + bounds: EmbeddedBrowserBounds; +} + +export interface EmbeddedBrowserPageState { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +} + +export type EmbeddedBrowserEvent = + | { type: "page-state"; state: EmbeddedBrowserPageState } + | { + type: "load-failed"; + viewId: string; + url: string; + errorDescription: string; + } + | { type: "view-destroyed"; viewId: string }; + +export interface IEmbeddedBrowser { + /** + * Create the view, or re-attach an existing one (a tab the user switched + * back to). Re-attaching must never re-navigate: the caller's URL is a + * persisted snapshot that lags the live page. + */ + create(options: EmbeddedBrowserCreateOptions): Promise; + navigate(viewId: string, url: string): Promise; + goBack(viewId: string): void; + goForward(viewId: string): void; + reload(viewId: string): void; + setBounds(viewId: string, bounds: EmbeddedBrowserBounds): void; + setVisible(viewId: string, visible: boolean): void; + openDevTools(viewId: string): void; + destroy(viewId: string): Promise; + getPageState(viewId: string): EmbeddedBrowserPageState | null; + events(signal?: AbortSignal): AsyncIterable; +} + +export const EMBEDDED_BROWSER = Symbol.for("posthog.platform.embeddedBrowser"); diff --git a/products/desktop/packages/platform/src/host-capabilities.ts b/products/desktop/packages/platform/src/host-capabilities.ts index a65a05bb9fcf..108a09fce2dd 100644 --- a/products/desktop/packages/platform/src/host-capabilities.ts +++ b/products/desktop/packages/platform/src/host-capabilities.ts @@ -11,6 +11,12 @@ export interface HostCapabilities { * (connected-GitHub-org) repositories and cloud workspaces. */ readonly localWorkspaces: boolean; + /** + * Whether the host can paint a live embedded browser view (a native web + * view layered over the shared UI). Desktop (Electron) can; the web and + * mobile hosts cannot, so the UI hides the browser panel entirely. + */ + readonly embeddedBrowser: boolean; } export const HOST_CAPABILITIES = Symbol.for( diff --git a/products/desktop/packages/platform/tsup.config.ts b/products/desktop/packages/platform/tsup.config.ts index 05315800e4e8..34b929b6c940 100644 --- a/products/desktop/packages/platform/tsup.config.ts +++ b/products/desktop/packages/platform/tsup.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ "src/app-metrics.ts", "src/dev-host-actions.ts", "src/disk-cache.ts", + "src/embedded-browser.ts", ], format: ["esm"], dts: true, diff --git a/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.stories.tsx b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.stories.tsx new file mode 100644 index 000000000000..ac345addf711 --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrowserPanelChrome } from "./BrowserPanel"; +import type { EmbeddedBrowserPageState } from "./useEmbeddedBrowser"; + +function pageState( + overrides: Partial = {}, +): EmbeddedBrowserPageState { + return { + viewId: "task-browser:t1:browser-1", + url: "http://localhost:3000/", + title: "My app", + canGoBack: true, + canGoForward: false, + isLoading: false, + ...overrides, + }; +} + +const meta = { + title: "Embedded Browser/BrowserPanelChrome", + component: BrowserPanelChrome, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + onNavigate: () => {}, + onBack: () => {}, + onForward: () => {}, + onReload: () => {}, + onOpenExternal: () => {}, + onOpenDevTools: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** A brand-new browser tab: no page yet, the URL bar is the only affordance. */ +export const FreshTab: Story = { + args: { + hasPage: false, + currentUrl: "", + pageState: null, + loadError: null, + }, +}; + +/** + * A page is open (in the real app the host paints the live page into the grey + * slot area — natively, above the renderer, so it cannot appear here). + */ +export const PageOpen: Story = { + args: { + hasPage: true, + currentUrl: "http://localhost:3000/", + pageState: pageState(), + loadError: null, + }, +}; + +/** Main-frame load failure: banner with the error and a retry. */ +export const LoadFailed: Story = { + args: { + hasPage: true, + currentUrl: "http://localhost:3000/", + pageState: pageState({ canGoBack: false }), + loadError: "net::ERR_CONNECTION_REFUSED", + }, +}; diff --git a/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.test.tsx b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.test.tsx new file mode 100644 index 000000000000..f5b9f170e8bd --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { BrowserPanelChrome } from "./BrowserPanel"; + +function isDisabled(el: HTMLElement): boolean { + return ( + el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true" + ); +} + +function chromeProps(overrides = {}) { + return { + hasPage: false, + currentUrl: "", + pageState: null, + loadError: null, + onNavigate: vi.fn(), + onBack: vi.fn(), + onForward: vi.fn(), + onReload: vi.fn(), + onOpenExternal: vi.fn(), + onOpenDevTools: vi.fn(), + ...overrides, + }; +} + +describe("BrowserPanelChrome", () => { + it("shows the URL prompt for a fresh tab and disables page actions", () => { + render(); + expect(screen.getByText("Open a page")).toBeTruthy(); + expect(isDisabled(screen.getByLabelText("Reload"))).toBe(true); + expect(isDisabled(screen.getByLabelText("Open DevTools"))).toBe(true); + }); + + it("submits the typed URL on Enter", () => { + const onNavigate = vi.fn(); + render(); + const input = screen.getByLabelText("Page URL"); + fireEvent.change(input, { target: { value: "localhost:3000" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onNavigate).toHaveBeenCalledWith("localhost:3000"); + }); + + it("abandons the draft on Escape without navigating", () => { + const onNavigate = vi.fn(); + render( + , + ); + const input = screen.getByLabelText("Page URL") as HTMLInputElement; + fireEvent.change(input, { target: { value: "typo" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onNavigate).not.toHaveBeenCalled(); + expect(input.value).toBe("https://posthog.com/"); + }); + + it("enables history buttons from page state", () => { + render( + , + ); + expect(isDisabled(screen.getByLabelText("Back"))).toBe(false); + expect(isDisabled(screen.getByLabelText("Forward"))).toBe(true); + }); + + it("shows the load-failed banner with a retry", () => { + const onReload = vi.fn(); + render( + , + ); + expect(screen.getByText("net::ERR_CONNECTION_REFUSED")).toBeTruthy(); + fireEvent.click(screen.getByText("Retry")); + expect(onReload).toHaveBeenCalled(); + }); +}); diff --git a/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.tsx b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.tsx new file mode 100644 index 000000000000..09462a607f2b --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/BrowserPanel.tsx @@ -0,0 +1,281 @@ +import { + ArrowClockwise, + ArrowLeft, + ArrowRight, + ArrowSquareOut, + Code, + Globe, +} from "@phosphor-icons/react"; +import { normalizeBrowserUrl } from "@posthog/core/embedded-browser/normalizeUrl"; +import type { PanelNode } from "@posthog/core/panels/panelTypes"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { + Button, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, +} from "@posthog/quill"; +import { useMutation } from "@tanstack/react-query"; +import { type RefObject, useEffect, useState } from "react"; +import { useCommandMenuStore } from "../../shell/commandMenuStore"; +import { openExternalUrl } from "../../shell/openExternal"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { browserViewId } from "./browserViewId"; +import { useEmbeddedBrowserObscuredStore } from "./embeddedBrowserObscuredStore"; +import { + type EmbeddedBrowserPageState, + useEmbeddedBrowserPageState, + useEmbeddedBrowserSlot, +} from "./useEmbeddedBrowser"; + +function isTabActiveInTree(node: PanelNode, tabId: string): boolean { + if (node.type === "leaf") { + return ( + node.content.activeTabId === tabId && + node.content.tabs.some((tab) => tab.id === tabId) + ); + } + return node.children.some((child) => isTabActiveInTree(child, tabId)); +} + +export interface BrowserPanelChromeProps { + /** False until the tab's first navigation; shows the URL prompt. */ + hasPage: boolean; + currentUrl: string; + pageState: EmbeddedBrowserPageState | null; + loadError: string | null; + onNavigate: (rawUrl: string) => void; + onBack: () => void; + onForward: () => void; + onReload: () => void; + onOpenExternal: () => void; + onOpenDevTools: () => void; + /** The host glues the native browser view to this element's rect. */ + slotRef?: RefObject; +} + +/** + * The browser panel's chrome: toolbar, load-error banner, and the slot the + * host paints the native page into. Pure — the container below owns tRPC and + * store wiring. + */ +export function BrowserPanelChrome(props: BrowserPanelChromeProps) { + const { + hasPage, + currentUrl, + pageState, + loadError, + onNavigate, + onBack, + onForward, + onReload, + onOpenExternal, + onOpenDevTools, + slotRef, + } = props; + const [draftUrl, setDraftUrl] = useState(null); + + const submitUrl = () => { + if (draftUrl == null) return; + const raw = draftUrl; + setDraftUrl(null); + onNavigate(raw); + }; + + return ( +
+
+ + + + setDraftUrl(event.target.value)} + onFocus={(event) => event.target.select()} + onBlur={() => setDraftUrl(null)} + onKeyDown={(event) => { + if (event.key === "Enter") submitUrl(); + if (event.key === "Escape") setDraftUrl(null); + }} + aria-label="Page URL" + spellCheck={false} + /> + + +
+ {loadError && ( +
+ {loadError} + +
+ )} + {/* The native browser view is glued to this slot's rect by the host. */} +
+ {!hasPage && ( + + + + + + Open a page + + Type a URL above — your local dev server or your live site. + Logins persist across restarts. + + + + )} +
+
+ ); +} + +/** + * A live browser inside a task panel, alongside Chat and Terminal. The page + * itself is painted by a host-owned native view glued to the chrome's slot + * div; this container wires the chrome to the host over tRPC and to the panel + * layout store. + */ +export function BrowserPanel(props: { + taskId: string; + tabId: string; + initialUrl: string; +}) { + const { taskId, tabId, initialUrl } = props; + const trpc = useHostTRPC(); + const viewId = browserViewId(taskId, tabId); + + // The URL the view opens with. Starts as the persisted tab URL (empty for a + // brand-new tab); intentionally NOT synced to later prop changes — the prop + // updates as we persist the current page, and reacting to that would loop. + const [openUrl, setOpenUrl] = useState(initialUrl || null); + + // Inactive tabs stay mounted with `visibility: hidden` and keep non-zero + // rects, so "is my tab the active one" must come from the layout store — + // the DOM cannot tell us. + const isActiveTab = usePanelLayoutStore((state) => { + const layout = state.taskLayouts[taskId]; + return layout ? isTabActiveInTree(layout.panelTree, tabId) : false; + }); + // Tab drags overlay drop zones on the panel content; hide the view so they + // are visible (and so the drag preview isn't painted over). + const isDraggingTab = usePanelLayoutStore( + (state) => (state.taskLayouts[taskId]?.draggingTabId ?? null) != null, + ); + const commandMenuOpen = useCommandMenuStore((state) => state.isOpen); + const obscuredCount = useEmbeddedBrowserObscuredStore((state) => state.count); + + const slotRef = useEmbeddedBrowserSlot({ + viewId, + url: openUrl, + visible: + isActiveTab && !isDraggingTab && !commandMenuOpen && obscuredCount === 0, + }); + const { pageState, loadError } = useEmbeddedBrowserPageState(viewId); + + const navigate = useMutation(trpc.embeddedBrowser.navigate.mutationOptions()); + const goBack = useMutation(trpc.embeddedBrowser.goBack.mutationOptions()); + const goForward = useMutation( + trpc.embeddedBrowser.goForward.mutationOptions(), + ); + const reload = useMutation(trpc.embeddedBrowser.reload.mutationOptions()); + const openDevTools = useMutation( + trpc.embeddedBrowser.openDevTools.mutationOptions(), + ); + + const currentUrl = pageState?.url || openUrl || ""; + + // Persist where this tab is parked (debounced) so it restores to the same + // page after a close/reopen or app restart. + const updateBrowserTabUrl = usePanelLayoutStore( + (state) => state.updateBrowserTabUrl, + ); + useEffect(() => { + if (!pageState?.url) return; + const handle = setTimeout(() => { + updateBrowserTabUrl(taskId, tabId, pageState.url); + }, 1000); + return () => clearTimeout(handle); + }, [pageState?.url, taskId, tabId, updateBrowserTabUrl]); + + // The tab label follows the page title, like the terminal tab follows its + // foreground process name. + const updateTabLabel = usePanelLayoutStore((state) => state.updateTabLabel); + useEffect(() => { + if (pageState?.title) updateTabLabel(taskId, tabId, pageState.title); + }, [pageState?.title, taskId, tabId, updateTabLabel]); + + const handleNavigate = (rawUrl: string) => { + const normalized = normalizeBrowserUrl(rawUrl); + if (!normalized) return; + updateBrowserTabUrl(taskId, tabId, normalized); + if (openUrl == null) { + // First navigation of a fresh tab: creates the view. + setOpenUrl(normalized); + } else if (normalized !== currentUrl) { + navigate.mutate({ viewId, url: normalized }); + } + }; + + return ( + goBack.mutate({ viewId })} + onForward={() => goForward.mutate({ viewId })} + onReload={() => reload.mutate({ viewId })} + onOpenExternal={() => openExternalUrl(currentUrl)} + onOpenDevTools={() => openDevTools.mutate({ viewId })} + slotRef={slotRef} + /> + ); +} diff --git a/products/desktop/packages/ui/src/features/embedded-browser/browserViewId.ts b/products/desktop/packages/ui/src/features/embedded-browser/browserViewId.ts new file mode 100644 index 000000000000..403dc7aab10f --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/browserViewId.ts @@ -0,0 +1,8 @@ +/** + * The host-side view id for a browser tab. Derived (not stored) so cleanup + * paths that only see the panel tree — tab close, close-others, task archive — + * can address the native view without extra bookkeeping. + */ +export function browserViewId(taskId: string, tabId: string): string { + return `task-browser:${taskId}:${tabId}`; +} diff --git a/products/desktop/packages/ui/src/features/embedded-browser/embeddedBrowserObscuredStore.ts b/products/desktop/packages/ui/src/features/embedded-browser/embeddedBrowserObscuredStore.ts new file mode 100644 index 000000000000..5a2655007f88 --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/embeddedBrowserObscuredStore.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; + +/** + * The embedded browser is a NATIVE view that paints above the renderer, so + * any renderer overlay that can overlap its rectangle (menus, popovers, + * dialogs) must hide the view while open. This counter is that cooperation + * point: acquire() on open, release() on close; the browser panel hides its + * view while count > 0. + */ +interface EmbeddedBrowserObscuredState { + count: number; + acquire: () => void; + release: () => void; +} + +export const useEmbeddedBrowserObscuredStore = + create((set) => ({ + count: 0, + acquire: () => set((state) => ({ count: state.count + 1 })), + release: () => set((state) => ({ count: Math.max(0, state.count - 1) })), + })); diff --git a/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.test.tsx b/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.test.tsx new file mode 100644 index 000000000000..575e7d6343cd --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.test.tsx @@ -0,0 +1,90 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { useBrowserViewCleanup } from "./useBrowserViewCleanup"; + +const destroyMutate = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPCClient: () => ({ + embeddedBrowser: { destroy: { mutate: destroyMutate } }, + }), +})); + +vi.mock("../../shell/useHostCapabilities", () => ({ + useHostCapabilities: () => ({ localWorkspaces: true, embeddedBrowser: true }), +})); + +function layoutWithBrowserTabs(tabIds: string[]) { + return { + panelTree: { + type: "leaf" as const, + id: "main-panel", + content: { + id: "main-panel", + activeTabId: tabIds[0] ?? "logs", + tabs: tabIds.map((id) => ({ + id, + label: "Browser", + data: { type: "browser" as const, browserId: id, url: "" }, + })), + }, + }, + openFiles: [], + recentFiles: [], + draggingTabId: null, + draggingTabPanelId: null, + focusedPanelId: null, + }; +} + +describe("useBrowserViewCleanup", () => { + afterEach(() => { + usePanelLayoutStore.setState({ taskLayouts: {} }); + destroyMutate.mockClear(); + }); + + it("destroys the view of a tab that leaves the layout, however it left", () => { + usePanelLayoutStore.setState({ + taskLayouts: { t1: layoutWithBrowserTabs(["browser-1", "browser-2"]) }, + }); + renderHook(() => useBrowserViewCleanup("t1")); + + // close-others style removal: browser-2 disappears without an onClose + usePanelLayoutStore.setState({ + taskLayouts: { t1: layoutWithBrowserTabs(["browser-1"]) }, + }); + + expect(destroyMutate).toHaveBeenCalledTimes(1); + expect(destroyMutate).toHaveBeenCalledWith({ + viewId: "task-browser:t1:browser-2", + }); + }); + + it("ignores other tasks' layout changes", () => { + usePanelLayoutStore.setState({ + taskLayouts: { + t1: layoutWithBrowserTabs(["browser-1"]), + t2: layoutWithBrowserTabs(["browser-9"]), + }, + }); + renderHook(() => useBrowserViewCleanup("t1")); + + usePanelLayoutStore.setState({ + taskLayouts: { t1: layoutWithBrowserTabs(["browser-1"]) }, + }); + + expect(destroyMutate).not.toHaveBeenCalled(); + }); + + it("stops reconciling after unmount (task switch must not destroy)", () => { + usePanelLayoutStore.setState({ + taskLayouts: { t1: layoutWithBrowserTabs(["browser-1"]) }, + }); + const { unmount } = renderHook(() => useBrowserViewCleanup("t1")); + unmount(); + + usePanelLayoutStore.setState({ taskLayouts: {} }); + expect(destroyMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.ts b/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.ts new file mode 100644 index 000000000000..600dcc4d616d --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/useBrowserViewCleanup.ts @@ -0,0 +1,80 @@ +import type { PanelNode } from "@posthog/core/panels/panelTypes"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useEffect } from "react"; +import { useHostCapabilities } from "../../shell/useHostCapabilities"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { browserViewId } from "./browserViewId"; + +function collectBrowserTabIds(node: PanelNode | undefined): Set { + const ids = new Set(); + const walk = (current: PanelNode) => { + if (current.type === "leaf") { + for (const tab of current.content.tabs) { + if (tab.data.type === "browser") ids.add(tab.id); + } + return; + } + for (const child of current.children) walk(child); + }; + if (node) walk(node); + return ids; +} + +function destroyViews( + client: Pick, + taskId: string, + tabIds: Iterable, +): void { + for (const tabId of tabIds) { + // Destroying a view that was never created is a no-op host-side; a + // failure only means the view is already gone. + void client.embeddedBrowser.destroy + .mutate({ viewId: browserViewId(taskId, tabId) }) + .catch(() => {}); + } +} + +/** + * Native views outlive their tabs unless someone reconciles: a browser tab + * can leave the layout through paths that never tell its panel component it + * was closed rather than switched away from (close-others, close-to-right, + * closing a whole panel). Watching the layout and destroying views whose + * tabs disappeared covers every close path with one rule. + */ +export function useBrowserViewCleanup(taskId: string): void { + const client = useHostTRPCClient(); + const { embeddedBrowser } = useHostCapabilities(); + + useEffect(() => { + if (!embeddedBrowser) return; + let prev = collectBrowserTabIds( + usePanelLayoutStore.getState().taskLayouts[taskId]?.panelTree, + ); + return usePanelLayoutStore.subscribe((state) => { + const next = collectBrowserTabIds(state.taskLayouts[taskId]?.panelTree); + const removed = [...prev].filter((id) => !next.has(id)); + if (removed.length > 0) destroyViews(client, taskId, removed); + prev = next; + }); + }, [taskId, client, embeddedBrowser]); +} + +/** + * Destroy every browser view belonging to a task. Mirrors + * `destroyTaskTerminals`: called from task lifecycle (delete), not from tab + * close — the reconciler above owns that. Resolves the host client the same + * way `openExternalUrl` does so lifecycle code can call it as a plain + * function. + */ +export function destroyTaskBrowserViews(taskId: string): void { + const layout = usePanelLayoutStore.getState().taskLayouts[taskId]; + const tabIds = collectBrowserTabIds(layout?.panelTree); + if (tabIds.size === 0) return; + const client = resolveService(HOST_TRPC_CLIENT); + destroyViews(client, taskId, tabIds); +} diff --git a/products/desktop/packages/ui/src/features/embedded-browser/useEmbeddedBrowser.ts b/products/desktop/packages/ui/src/features/embedded-browser/useEmbeddedBrowser.ts new file mode 100644 index 000000000000..cf9fc061f275 --- /dev/null +++ b/products/desktop/packages/ui/src/features/embedded-browser/useEmbeddedBrowser.ts @@ -0,0 +1,165 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useEffect, useRef, useState } from "react"; + +export interface EmbeddedBrowserPageState { + viewId: string; + url: string; + title: string; + canGoBack: boolean; + canGoForward: boolean; + isLoading: boolean; +} + +/** + * Live page state of one embedded view, seeded by query, kept fresh by the + * host event stream. `loadError` carries the last main-frame load failure and + * clears when a new load starts. + */ +export function useEmbeddedBrowserPageState(viewId: string) { + const trpc = useHostTRPC(); + const [pageState, setPageState] = useState( + null, + ); + const [loadError, setLoadError] = useState(null); + + const { data: initial } = useQuery( + trpc.embeddedBrowser.getPageState.queryOptions({ viewId }), + ); + useEffect(() => { + if (initial) setPageState((prev) => prev ?? initial); + }, [initial]); + + useSubscription( + trpc.embeddedBrowser.onEvents.subscriptionOptions(undefined, { + onData: (event) => { + if (event.type === "page-state" && event.state.viewId === viewId) { + if (event.state.isLoading) setLoadError(null); + setPageState(event.state); + } + if (event.type === "load-failed" && event.viewId === viewId) { + setLoadError(event.errorDescription); + } + }, + }), + ); + + return { pageState, loadError }; +} + +/** + * Own an embedded view behind a slot element: open it once a URL is chosen + * and the slot has real bounds, keep the native view glued to the slot's + * rect, and drive visibility from `visible`. The native view paints ABOVE the + * renderer, so visibility must come from here — nothing in the DOM can cover + * it. Inactive panel tabs stay mounted with `visibility: hidden` (their rects + * stay non-zero), which is exactly why the caller must gate `visible` on the + * tab being active. + */ +export function useEmbeddedBrowserSlot(input: { + viewId: string; + /** null = no URL chosen yet; the view is not created until one is. */ + url: string | null; + visible: boolean; +}) { + const { viewId, url, visible } = input; + const trpc = useHostTRPC(); + const slotRef = useRef(null); + const openedRef = useRef(false); + + const open = useMutation(trpc.embeddedBrowser.open.mutationOptions()); + const setBounds = useMutation( + trpc.embeddedBrowser.setBounds.mutationOptions(), + ); + const setVisible = useMutation( + trpc.embeddedBrowser.setVisible.mutationOptions(), + ); + + const openMutate = open.mutateAsync; + const setBoundsMutate = setBounds.mutate; + const setVisibleMutate = setVisible.mutate; + + // The open URL is only consumed once, at creation; it must NOT re-run the + // effect. It tracks the persisted current URL as the user browses, and + // re-running on that would tear down and reopen the live view mid-session. + const urlRef = useRef(url); + // Applied after open resolves, so a visibility change that raced the open + // (user switched tabs while the first load was in flight) still lands. + const visibleRef = useRef(visible); + // Mirrored on commit, never during render: React can discard or replay a + // render, and a value from one that never committed must not leak into the + // live view. Both are read from async callbacks, which always run later. + useEffect(() => { + urlRef.current = url; + visibleRef.current = visible; + }, [url, visible]); + + const hasUrl = url != null; + + useEffect(() => { + const slot = slotRef.current; + if (!slot || !hasUrl) return; + + let frame: number | null = null; + let lastRect = ""; + + const report = () => { + frame = null; + const rect = slot.getBoundingClientRect(); + const bounds = { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + if (bounds.width === 0 || bounds.height === 0) return; + const key = JSON.stringify(bounds); + if (key === lastRect) return; + lastRect = key; + if (!openedRef.current) { + openedRef.current = true; + const openUrl = urlRef.current; + if (openUrl == null) return; + void openMutate({ viewId, url: openUrl, bounds }) + .then(() => { + setVisibleMutate({ viewId, visible: visibleRef.current }); + }) + .catch(() => { + openedRef.current = false; + }); + } else { + setBoundsMutate({ viewId, bounds }); + } + }; + const schedule = () => { + if (frame == null) frame = requestAnimationFrame(report); + }; + + schedule(); + const observer = new ResizeObserver(schedule); + observer.observe(slot); + // Layout shifts that move the slot without resizing it (sidebar toggle, + // panel collapse) resize an ancestor — observing the body catches them. + observer.observe(document.body); + window.addEventListener("resize", schedule); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", schedule); + if (frame != null) cancelAnimationFrame(frame); + // Keep the view (and the page in it) alive across task switches; just + // stop painting over whatever replaces this slot. Actual destruction is + // owned by useBrowserViewCleanup / task archive. + setVisibleMutate({ viewId, visible: false }); + openedRef.current = false; + }; + }, [viewId, hasUrl, openMutate, setBoundsMutate, setVisibleMutate]); + + useEffect(() => { + if (!openedRef.current) return; + setVisibleMutate({ viewId, visible }); + }, [visible, viewId, setVisibleMutate]); + + return slotRef; +} diff --git a/products/desktop/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx b/products/desktop/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index c9b9983fd429..771382f863e7 100644 --- a/products/desktop/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx +++ b/products/desktop/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -29,6 +29,7 @@ interface LeafNodeRendererProps { onActiveTabChange: (panelId: string, tabId: string) => void; onPanelFocus: (panelId: string) => void; onAddTerminal: (panelId: string) => void; + onAddBrowser: (panelId: string) => void; onSplitPanel: (panelId: string, direction: SplitDirection) => void; } @@ -45,18 +46,23 @@ export const LeafNodeRenderer: React.FC = ({ onActiveTabChange, onPanelFocus, onAddTerminal, + onAddBrowser, onSplitPanel, }) => { const isCloud = useIsCloudTask(task); - const { localWorkspaces } = useHostCapabilities(); - // Hide the terminal for cloud runs, and on cloud-only hosts (web). + const { localWorkspaces, embeddedBrowser } = useHostCapabilities(); + // Hide the terminal for cloud runs, and on cloud-only hosts (web). The + // browser only needs the host capability — it works for cloud runs too. const hideTerminal = isCloud || !localWorkspaces; + const hideBrowser = !embeddedBrowser; const inputTabs = useMemo( () => - hideTerminal - ? node.content.tabs.filter((t) => t.data.type !== "terminal") - : node.content.tabs, - [node.content.tabs, hideTerminal], + node.content.tabs.filter( + (t) => + !(hideTerminal && t.data.type === "terminal") && + !(hideBrowser && t.data.type === "browser"), + ), + [node.content.tabs, hideTerminal, hideBrowser], ); const tabs = useTabInjection(inputTabs, node.id, taskId, task, closeTab); const activeTabId = tabs.some((t) => t.id === node.content.activeTabId) @@ -109,6 +115,7 @@ export const LeafNodeRenderer: React.FC = ({ draggingTabPanelId={draggingTabPanelId} allowPanelSplit={!isCloud} onAddTerminal={hideTerminal ? undefined : () => onAddTerminal(node.id)} + onAddBrowser={hideBrowser ? undefined : () => onAddBrowser(node.id)} onSplitPanel={ isCloud ? undefined : (direction) => onSplitPanel(node.id, direction) } diff --git a/products/desktop/packages/ui/src/features/panels/components/PanelLayout.tsx b/products/desktop/packages/ui/src/features/panels/components/PanelLayout.tsx index 9141acdbc229..88780f469b8e 100644 --- a/products/desktop/packages/ui/src/features/panels/components/PanelLayout.tsx +++ b/products/desktop/packages/ui/src/features/panels/components/PanelLayout.tsx @@ -2,6 +2,7 @@ import { DragDropProvider } from "@dnd-kit/react"; import type { Task } from "@posthog/shared/domain-types"; import type React from "react"; import { useCallback, useEffect } from "react"; +import { useBrowserViewCleanup } from "../../embedded-browser/useBrowserViewCleanup"; import { useDragDropHandlers } from "../hooks/useDragDropHandlers"; import { usePanelKeyboardShortcuts } from "../hooks/usePanelKeyboardShortcuts"; import { @@ -72,6 +73,13 @@ const PanelLayoutRenderer: React.FC<{ [layoutState, taskId], ); + const handleAddBrowser = useCallback( + (panelId: string) => { + layoutState.addBrowserTab(taskId, panelId); + }, + [layoutState, taskId], + ); + const handleSplitPanel = useCallback( (panelId: string, direction: SplitDirection) => { const layout = usePanelLayoutStore.getState().getLayout(taskId); @@ -128,6 +136,7 @@ const PanelLayoutRenderer: React.FC<{ onActiveTabChange={handleSetActiveTab} onPanelFocus={handlePanelFocus} onAddTerminal={handleAddTerminal} + onAddBrowser={handleAddBrowser} onSplitPanel={handleSplitPanel} /> ); @@ -156,6 +165,7 @@ const PanelLayoutRenderer: React.FC<{ handleKeepTab, handlePanelFocus, handleAddTerminal, + handleAddBrowser, handleSplitPanel, setGroupRef, handleLayout, @@ -171,6 +181,7 @@ export const PanelLayout: React.FC = ({ taskId, task }) => { const dragDropHandlers = useDragDropHandlers(taskId); usePanelKeyboardShortcuts(taskId); + useBrowserViewCleanup(taskId); useEffect(() => { if (!layout) { diff --git a/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.stories.tsx b/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.stories.tsx new file mode 100644 index 000000000000..f3832b85b17c --- /dev/null +++ b/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.stories.tsx @@ -0,0 +1,106 @@ +import { DragDropProvider } from "@dnd-kit/react"; +import { ChatCenteredText, Globe, Terminal } from "@phosphor-icons/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { BrowserPanelChrome } from "../../embedded-browser/BrowserPanel"; +import type { PanelContent } from "../panelTypes"; +import { TabbedPanel } from "./TabbedPanel"; + +function placeholder(label: string) { + return ( +
+ {label} +
+ ); +} + +const content: PanelContent = { + id: "main-panel", + activeTabId: "browser-1", + droppable: true, + tabs: [ + { + id: "logs", + label: "Chat", + data: { type: "logs" }, + closeable: false, + draggable: true, + icon: , + component: placeholder("Chat"), + }, + { + id: "shell", + label: "Terminal", + data: { type: "terminal", terminalId: "shell", cwd: "" }, + closeable: true, + draggable: true, + icon: , + component: placeholder("Terminal"), + }, + { + id: "browser-1", + label: "PostHog", + data: { + type: "browser", + browserId: "browser-1", + url: "https://posthog.com", + }, + closeable: true, + draggable: true, + icon: , + component: ( + {}} + onBack={() => {}} + onForward={() => {}} + onReload={() => {}} + onOpenExternal={() => {}} + onOpenDevTools={() => {}} + /> + ), + }, + ], +}; + +const meta = { + title: "Panels/TabbedPanel", + component: TabbedPanel, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * A browser tab alongside the Chat and Terminal tabs, with the "new terminal" + * and "new browser" tab-bar buttons. In the real app the host paints the live + * page into the grey area (natively, above the renderer, so it cannot appear + * in Storybook). + */ +export const WithBrowserTab: Story = { + args: { + panelId: "main-panel", + mountScopeKey: "story", + content, + onAddTerminal: () => {}, + onAddBrowser: () => {}, + }, +}; diff --git a/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.tsx b/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.tsx index e2ba4d8208ce..4698d0a2898c 100644 --- a/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/products/desktop/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,5 +1,10 @@ import { useDroppable } from "@dnd-kit/react"; -import { Plus, SquareSplitHorizontalIcon, X } from "@phosphor-icons/react"; +import { + Globe, + Plus, + SquareSplitHorizontalIcon, + X, +} from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; import { CONTENT_CHROME_RIGHT_VAR } from "@posthog/ui/features/navigation/rightPanelSide"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; @@ -70,6 +75,7 @@ interface TabbedPanelProps { draggingTabPanelId?: string | null; allowPanelSplit?: boolean; onAddTerminal?: () => void; + onAddBrowser?: () => void; onSplitPanel?: (direction: SplitDirection) => void; onClosePanel?: () => void; rightContent?: React.ReactNode; @@ -89,6 +95,7 @@ export const TabbedPanel: React.FC = ({ draggingTabPanelId = null, allowPanelSplit = true, onAddTerminal, + onAddBrowser, onSplitPanel, onClosePanel, rightContent, @@ -271,6 +278,13 @@ export const TabbedPanel: React.FC = ({ )} + {content.droppable && onAddBrowser && ( + + + + + + )} {/* Spacer to increase DND area */} {content.droppable && ( diff --git a/products/desktop/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx b/products/desktop/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index 7c8d07fe6fb1..b888995459fd 100644 --- a/products/desktop/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/products/desktop/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -2,6 +2,7 @@ import { ChartLineUp, ChatCenteredText, FileText, + Globe, PackageIcon, Scroll, Terminal, @@ -32,6 +33,7 @@ export interface PanelLayoutState { keepTab: (taskId: string, panelId: string, tabId: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string) => void; splitPanel: ( taskId: string, tabId: string, @@ -56,6 +58,7 @@ export function usePanelLayoutState(taskId: string): PanelLayoutState { keepTab: state.keepTab, setFocusedPanel: state.setFocusedPanel, addTerminalTab: state.addTerminalTab, + addBrowserTab: state.addBrowserTab, splitPanel: state.splitPanel, draggingTabId: state.getLayout(taskId)?.draggingTabId ?? null, draggingTabPanelId: state.getLayout(taskId)?.draggingTabPanelId ?? null, @@ -114,6 +117,8 @@ export function useTabInjection( icon = ; } else if (tab.data.type === "terminal") { icon = ; + } else if (tab.data.type === "browser") { + icon = ; } else if (tab.data.type === "logs") { icon = ; } else if (tab.data.type === "action") { diff --git a/products/desktop/packages/ui/src/features/panels/panelLayoutStore.ts b/products/desktop/packages/ui/src/features/panels/panelLayoutStore.ts index c608c459236e..af44a916dddd 100644 --- a/products/desktop/packages/ui/src/features/panels/panelLayoutStore.ts +++ b/products/desktop/packages/ui/src/features/panels/panelLayoutStore.ts @@ -3,6 +3,7 @@ import { contentHash } from "@posthog/core/code-review/contentHash"; import { addRecentFile, addActionTab as coreAddActionTab, + addBrowserTab as coreAddBrowserTab, addTerminalTab as coreAddTerminalTab, closeOtherTabs as coreCloseOtherTabs, closeTab as coreCloseTab, @@ -14,6 +15,7 @@ import { openTabInSplit as coreOpenTabInSplit, reorderTabs as coreReorderTabs, setActiveTab as coreSetActiveTab, + updateBrowserTabUrl as coreUpdateBrowserTabUrl, updateSizes as coreUpdateSizes, updateTabLabel as coreUpdateTabLabel, updateTabMetadata as coreUpdateTabMetadata, @@ -123,6 +125,8 @@ interface PanelLayoutStore { updateTabLabel: (taskId: string, tabId: string, label: string) => void; setFocusedPanel: (taskId: string, panelId: string) => void; addTerminalTab: (taskId: string, panelId: string) => void; + addBrowserTab: (taskId: string, panelId: string) => void; + updateBrowserTabUrl: (taskId: string, tabId: string, url: string) => void; addActionTab: ( taskId: string, panelId: string, @@ -545,6 +549,32 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + addBrowserTab: (taskId, panelId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddBrowserTab(layout, panelId) as Partial, + ), + ); + }, + + updateBrowserTabUrl: (taskId, tabId, url) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateBrowserTabUrl( + layout, + tabId, + url, + ) as Partial, + ), + ); + }, + addActionTab: (taskId, panelId, action) => { set((state) => updateTaskLayout( diff --git a/products/desktop/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/products/desktop/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index b8bccac95b93..d399b91f5f67 100644 --- a/products/desktop/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx +++ b/products/desktop/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -5,6 +5,7 @@ import { LazyCloudReviewPage as CloudReviewPage, LazyReviewPage as ReviewPage, } from "../../code-review/components/LazyReviewPages"; +import { BrowserPanel } from "../../embedded-browser/BrowserPanel"; import type { Tab } from "../../panels/panelTypes"; import { PiSessionView } from "../../pi-sessions/PiSessionView"; import { PostHogObjectPage } from "../../posthog-objects/PostHogObjectPage"; @@ -45,6 +46,15 @@ export function TabContentRenderer({ ); + case "browser": + return ( + + ); + case "file": return (