diff --git a/apps/code/src/main/bootstrap.ts b/apps/code/src/main/bootstrap.ts index 87f850cb82..f9c7ec5cca 100644 --- a/apps/code/src/main/bootstrap.ts +++ b/apps/code/src/main/bootstrap.ts @@ -37,7 +37,11 @@ if (shouldRefuseInternalChildBoot(app.isPackaged, process.env)) { const isDev = !app.isPackaged; // Set app name for single-instance lock, crashReporter, etc -const appName = isDev ? "posthog-code-dev" : "posthog-code"; +// POSTHOG_CODE_APP_NAME (dev only) isolates userData + the single-instance +// lock so a second dev instance can run beside another checkout's app. +const appName = isDev + ? (process.env.POSTHOG_CODE_APP_NAME ?? "posthog-code-dev") + : "posthog-code"; app.setName(isDev ? "PostHog Code (Development)" : "PostHog Code"); // Set userData path for @posthog/code diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 1c3db763fa..5632515d8b 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -149,6 +149,10 @@ import type { } from "@posthog/workspace-server/services/archive/ports"; import type { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import type { AuthProxyAuth } from "@posthog/workspace-server/services/auth-proxy/ports"; +import type { + EMBEDDED_APP_PROXY_AUTH, + EmbeddedAppProxyAuth, +} from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; import type { ENRICHMENT_AUTH, ENRICHMENT_FILE_READER, @@ -361,6 +365,8 @@ export interface MainBindings { // Auth proxy / mcp proxy [AUTH_PROXY_AUTH]: AuthProxyAuth; [MCP_PROXY_AUTH]: McpProxyAuth; + // EXPERIMENT (embedded webapp) + [EMBEDDED_APP_PROXY_AUTH]: EmbeddedAppProxyAuth; // Archive / suspension host ports [ARCHIVE_SESSION_CANCELLER]: SessionCanceller; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index e620dce39d..e4c6c337ed 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -119,6 +119,7 @@ import { STORAGE_PATHS_SERVICE } from "@posthog/platform/storage-paths"; import { UPDATER_SERVICE } from "@posthog/platform/updater"; import { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher"; import { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-settings"; +import { getCloudUrlFromRegion } from "@posthog/shared"; import type { WorkspaceClient } from "@posthog/workspace-client/client"; import { databaseModule } from "@posthog/workspace-server/db/db.module"; import { @@ -155,6 +156,8 @@ import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/a import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import { browserTabsModule } from "@posthog/workspace-server/services/browser-tabs/browser-tabs.module"; import { claudeCliSessionsModule } from "@posthog/workspace-server/services/claude-cli-sessions/claude-cli-sessions.module"; +import { embeddedAppProxyModule } from "@posthog/workspace-server/services/embedded-app-proxy/embedded-app-proxy.module"; +import { EMBEDDED_APP_PROXY_AUTH } from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; import { ENRICHMENT_AUTH, @@ -380,6 +383,18 @@ container.bind(AUTH_PROXY_AUTH).toDynamicValue((ctx) => ({ .get(MAIN_AUTH_SERVICE) .authenticatedFetch(fetch, url, init), })); +// EXPERIMENT (embedded webapp): local same-origin proxy for the iframe'd app +container.load(embeddedAppProxyModule); +container.bind(EMBEDDED_APP_PROXY_AUTH).toDynamicValue((ctx) => ({ + authenticatedFetch: (url: string, init?: RequestInit) => + ctx + .get(MAIN_AUTH_SERVICE) + .authenticatedFetch(fetch, url, init), + getUpstreamUrl: async () => { + const state = ctx.get(MAIN_AUTH_SERVICE).getState(); + return getCloudUrlFromRegion(state.cloudRegion ?? "us"); + }, +})); container.load(mcpProxyModule); container.bind(MCP_PROXY_AUTH).toDynamicValue((ctx) => { const auth = () => ctx.get(MAIN_AUTH_SERVICE); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index c6bd789ffe..6d963c38c8 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -15,6 +15,7 @@ import { connectivityRouter } from "@posthog/host-router/routers/connectivity.ro import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router"; import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; +import { embeddedAppRouter } from "@posthog/host-router/routers/embedded-app.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"; @@ -72,6 +73,7 @@ export const trpcRouter = router({ contextMenu: contextMenuRouter, dev: devRouter, discordPresence: discordPresenceRouter, + embeddedApp: embeddedAppRouter, enrichment: enrichmentRouter, environment: environmentRouter, encryption: encryptionRouter, diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 71f787c4c2..577dcf7dda 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -14,6 +14,7 @@ import { connectivityRouter } from "./routers/connectivity.router"; import { contextMenuRouter } from "./routers/context-menu.router"; import { dashboardsRouter } from "./routers/dashboards.router"; import { deepLinkRouter } from "./routers/deep-link.router"; +import { embeddedAppRouter } from "./routers/embedded-app.router"; import { enrichmentRouter } from "./routers/enrichment.router"; import { environmentRouter } from "./routers/environment.router"; import { externalAppsRouter } from "./routers/external-apps.router"; @@ -64,6 +65,7 @@ export const hostRouter = router({ contextMenu: contextMenuRouter, dashboards: dashboardsRouter, deepLink: deepLinkRouter, + embeddedApp: embeddedAppRouter, enrichment: enrichmentRouter, environment: environmentRouter, externalApps: externalAppsRouter, diff --git a/packages/host-router/src/routers/embedded-app.router.ts b/packages/host-router/src/routers/embedded-app.router.ts new file mode 100644 index 0000000000..529068324e --- /dev/null +++ b/packages/host-router/src/routers/embedded-app.router.ts @@ -0,0 +1,15 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { EmbeddedAppProxyService } from "@posthog/workspace-server/services/embedded-app-proxy/embedded-app-proxy"; +import { EMBEDDED_APP_PROXY_SERVICE } from "@posthog/workspace-server/services/embedded-app-proxy/identifiers"; +import { z } from "zod"; + +/** EXPERIMENT (embedded webapp): expose the local proxy URL to the renderer. */ +export const embeddedAppRouter = router({ + getUrl: publicProcedure + .output(z.object({ url: z.string() })) + .query(async ({ ctx }) => ({ + url: await ctx.container + .get(EMBEDDED_APP_PROXY_SERVICE) + .ensureStarted(), + })), +}); diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index bd52aef08e..39600fab8c 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + GlobeIcon, HouseIcon, PlugsConnectedIcon, RobotIcon, @@ -35,6 +36,10 @@ import { } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { + embeddedAppTabPath, + embeddedAppTabView, +} from "@posthog/ui/features/embedded-app/embeddedAppTab"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import { getLeafPanel } from "@posthog/ui/features/panels/panelStoreHelpers"; @@ -183,7 +188,12 @@ export function BrowserTabStrip() { // Home) are tab targets too. useAppView normalizes both the /code routes and // their /website mirrors to the same view.type, so a tab survives either space. const view = useAppView(); - const routeAppView: AppView | null = isAppView(view.type) ? view.type : null; + const routeAppView: string | null = + view.type === "embedded-app" + ? embeddedAppTabView(view.embedPath ?? "/notebooks") + : isAppView(view.type) + ? view.type + : null; const { channels } = useChannels(); const { createChannel } = useChannelMutations(); @@ -533,6 +543,17 @@ export function BrowserTabStrip() { pinned, }; } + // An embedded PostHog webapp tab — label by its webapp path. + const embedPath = embeddedAppTabPath(appView); + if (embedPath !== null) { + return { + id: t.id, + label: `PostHog ${embedPath.split("?")[0] || "/"}`, + icon: , + channelName: null, + pinned, + }; + } // A top-level app page (Inbox, Agents, Skills, …). if (appView && isAppView(appView)) { return { @@ -601,6 +622,13 @@ export function BrowserTabStrip() { } else { navigate({ to: "/website/$channelId", params, state }); } + } else if (tab.appView && embeddedAppTabPath(tab.appView) !== null) { + // An embedded PostHog webapp tab — its route carries the path in search. + navigate({ + to: "/embedded-app", + search: { path: embeddedAppTabPath(tab.appView) ?? undefined }, + state, + }); } else if (tab.appView && isAppView(tab.appView)) { // A top-level app page — back to its canonical route (literal `to` per // case so the router types stay checked). diff --git a/packages/ui/src/features/browser-tabs/useOpenEmbeddedAppTab.ts b/packages/ui/src/features/browser-tabs/useOpenEmbeddedAppTab.ts new file mode 100644 index 0000000000..bcf7a2868e --- /dev/null +++ b/packages/ui/src/features/browser-tabs/useOpenEmbeddedAppTab.ts @@ -0,0 +1,62 @@ +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { openOrFocusTab, primaryWindow } from "@posthog/shared"; +import { embeddedAppTabView } from "@posthog/ui/features/embedded-app/embeddedAppTab"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { applyLocalTransform, persistWrite, readMirror } from "./tabsSync"; + +/** + * EXPERIMENT (embedded webapp): open (or focus) a browser tab hosting the + * embedded PostHog webapp at `path`. + * + * A plain router navigation is in-tab by design (the strip's navigation + * effect retargets the active tab), so opening a NEW tab mirrors the strip's + * own flow: apply the openOrFocus transform to the mirror local-first, + * persist in the background, then navigate with the history entry stamped + * with the tab id so the effect activates that tab instead of retargeting. + */ +export function useOpenEmbeddedAppTab(): (path: string) => void { + const hostClient = useHostTRPCClient(); + const navigate = useNavigate(); + + return useCallback( + (path: string) => { + const appView = embeddedAppTabView(path); + const windowId = primaryWindow(readMirror())?.id; + let tabId: string | undefined; + if (windowId) { + const input = { + windowId, + dashboardId: null, + taskId: null, + channelId: null, + channelSection: null, + appView, + }; + const mintedId = crypto.randomUUID(); + tabId = mintedId; + applyLocalTransform((s) => { + const result = openOrFocusTab(s, { + ...input, + makeId: () => mintedId, + now: Date.now, + }); + tabId = result.tabId; + return result.snapshot; + }); + void persistWrite(() => + hostClient.browserTabs.openOrFocus.mutate({ + ...input, + tabId: mintedId, + }), + ); + } + void navigate({ + to: "/embedded-app", + search: { path }, + state: (prev) => ({ ...prev, tabId }), + }); + }, + [hostClient, navigate], + ); +} diff --git a/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx b/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx new file mode 100644 index 0000000000..07928df149 --- /dev/null +++ b/packages/ui/src/features/embedded-app/EmbeddedAppView.tsx @@ -0,0 +1,180 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { Button } from "@posthog/quill"; +import { useOpenEmbeddedAppTab } from "@posthog/ui/features/browser-tabs/useOpenEmbeddedAppTab"; +import { useQuery } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useThemeStore } from "../../shell/themeStore"; +import { normalizeEmbedPath } from "./embeddedAppTab"; + +/** + * EXPERIMENT (embedded webapp): renders the real PostHog webapp in an iframe. + * + * The iframe points at a local main-process proxy that serves the webapp + * shell and forwards /api to PostHog cloud with the app's OAuth token, so + * the webapp sees a same-origin cookie-less API. A postMessage bridge keeps + * navigation and theme in sync, and forwards new-tab intents (window.open / + * target="_blank" inside the webapp) so they open as host browser tabs + * (see frontend/src/embed/bridge.ts in the posthog repo). + */ + +const FROM_EMBED = "posthog-embed"; +const FROM_HOST = "posthog-embed-host"; + +const QUICK_LINKS: Array<{ label: string; path: string }> = [ + { label: "Notebooks", path: "/notebooks" }, + { label: "Feature flags", path: "/feature_flags" }, + { label: "Dashboards", path: "/dashboard" }, + { label: "Activity", path: "/activity/explore" }, +]; + +interface PostHogAppProps { + /** Initial webapp path, e.g. "/notebooks" */ + url: string; +} + +export function PostHogApp({ url: initialPath }: PostHogAppProps) { + const trpc = useHostTRPC(); + const { data, error, isLoading } = useQuery( + trpc.embeddedApp.getUrl.queryOptions(undefined, { + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }), + ); + const isDarkMode = useThemeStore((s) => s.isDarkMode); + const openEmbeddedAppTab = useOpenEmbeddedAppTab(); + const iframeRef = useRef(null); + const [embedRoute, setEmbedRoute] = useState(null); + const [ready, setReady] = useState(false); + + // Compute the iframe src once per proxy URL: theme changes and navigation + // afterwards go over the bridge instead of reloading the iframe. + const initialThemeRef = useRef(isDarkMode ? "dark" : "light"); + const iframeSrc = useMemo(() => { + if (!data?.url) return null; + return `${data.url}${initialPath}?__posthog_embed_theme=${initialThemeRef.current}`; + }, [data?.url, initialPath]); + + const postToEmbed = useCallback((message: Record) => { + iframeRef.current?.contentWindow?.postMessage( + { source: FROM_HOST, ...message }, + "*", + ); + }, []); + + // Open a webapp path as a NEW host browser tab (or focus the tab already + // at that path — tab identity encodes the path). + const openHostTab = useCallback( + (path: string) => { + openEmbeddedAppTab(normalizeEmbedPath(path)); + }, + [openEmbeddedAppTab], + ); + const openHostTabRef = useRef(openHostTab); + openHostTabRef.current = openHostTab; + + // Read inside the message handler without re-subscribing on theme changes. + const isDarkModeRef = useRef(isDarkMode); + isDarkModeRef.current = isDarkMode; + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data: unknown = event.data; + if ( + !data || + typeof data !== "object" || + (data as Record).source !== FROM_EMBED || + event.source !== iframeRef.current?.contentWindow + ) { + return; + } + const message = data as { type?: string; url?: unknown }; + if (message.type === "ready") { + setReady(true); + if (typeof message.url === "string") setEmbedRoute(message.url); + // Re-sync theme on every ready — the iframe may have reloaded and + // `ready` state (already true) won't re-run the theme effect. + postToEmbed({ + type: "setTheme", + theme: isDarkModeRef.current ? "dark" : "light", + }); + } else if ( + message.type === "routeChanged" && + typeof message.url === "string" + ) { + setEmbedRoute(message.url); + } else if ( + message.type === "openTab" && + typeof message.url === "string" + ) { + // window.open / target="_blank" inside the webapp: open a host tab. + openHostTabRef.current(message.url); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [postToEmbed]); + + // Host theme wins: push it whenever it changes (and once the embed is ready). + useEffect(() => { + if (ready) { + postToEmbed({ type: "setTheme", theme: isDarkMode ? "dark" : "light" }); + } + }, [ready, isDarkMode, postToEmbed]); + + const navigate = useCallback( + (path: string) => postToEmbed({ type: "navigate", url: path }), + [postToEmbed], + ); + + if (isLoading) { + return ( +
+ Starting embedded PostHog… +
+ ); + } + if (error || !iframeSrc) { + return ( +
+ Could not start the embedded PostHog proxy + {error ? `: ${String(error)}` : ""} +
+ ); + } + + return ( +
+
+ {QUICK_LINKS.map((link) => ( + + ))} + + + {ready ? (embedRoute ?? "") : "connecting…"} + +
+