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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions products/desktop/apps/code/src/main/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions products/desktop/apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Events>
implements IEmbeddedBrowser
{
private readonly views = new Map<string, WebContentsView>();
private sessionHardened = false;

constructor(
@inject(MAIN_WINDOW_SERVICE)
private readonly mainWindow: ElectronMainWindow,
) {
super();
this.setMaxListeners(0);
}

async create(options: EmbeddedBrowserCreateOptions): Promise<void> {
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<void> {
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<void> {
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<EmbeddedBrowserEvent> {
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" };
});
});
}
}
2 changes: 2 additions & 0 deletions products/desktop/apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -100,6 +101,7 @@ export const trpcRouter = router({
notification: notificationRouter,
oauth: oauthRouter,
logs: logsRouter,
embeddedBrowser: embeddedBrowserRouter,
os: osRouter,
piSession: piSessionRouter,
processTracking: processTrackingRouter,
Expand Down
Loading
Loading