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
142 changes: 142 additions & 0 deletions frontend/src/embed/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* postMessage bridge between the embedded PostHog app and its host window.
*
* Protocol (all messages are plain objects with a `source` discriminator):
* host -> iframe { source: 'posthog-embed-host', type: 'navigate', url }
* host -> iframe { source: 'posthog-embed-host', type: 'setTheme', theme: 'light' | 'dark' | null }
* iframe -> host { source: 'posthog-embed', type: 'ready', url }
* iframe -> host { source: 'posthog-embed', type: 'routeChanged', url }
* iframe -> host { source: 'posthog-embed', type: 'openTab', url }
*
* Navigation goes through kea-router, which auto-prefixes `/project/<id>` —
* the host should send bare paths like `/notebooks`.
*
* New-tab intents (window.open / target="_blank" on same-origin URLs) are
* forwarded to the host as `openTab` so it can open another embedded tab
* instead of the browser context dropping them. External URLs keep the
* native path (Electron's window-open handler opens the system browser).
* In-app kea-router pushes are untouched and navigate in place.
*/
import { getContext } from 'kea'
import { router } from 'kea-router'

import { themeLogic } from 'lib/logic/themeLogic'

const FROM_EMBED = 'posthog-embed'
const FROM_HOST = 'posthog-embed-host'

function postToHost(message: Record<string, unknown>): void {
window.parent.postMessage({ source: FROM_EMBED, ...message }, '*')
}

function currentUrl(): string {
const { pathname, search, hash } = window.location
return `${pathname}${search}${hash}`
}

function keaIsReady(): boolean {
try {
return !!getContext().store
} catch {
return false
}
}

/** Called by the embed entry once the main app bundle has been imported. */
export function initEmbedBridge(): void {
if (window.parent === window) {
return // not framed: nothing to bridge
}
// The main entry lazy-loads the App chunk and only then runs initKea, so
// poll until the kea context exists before touching logics or the router.
const timer = window.setInterval(() => {
if (keaIsReady()) {
window.clearInterval(timer)
start()
}
}, 50)
}

function sendOpenTab(resolved: URL): void {
postToHost({ type: 'openTab', url: `${resolved.pathname}${resolved.search}${resolved.hash}` })
}

function interceptNewTabs(): void {
// window.open with a same-origin URL becomes a host tab; everything else
// (external docs links, OAuth popups) keeps the native path.
const originalOpen = window.open.bind(window)
window.open = (url?: string | URL, target?: string, features?: string): Window | null => {
if (url) {
const resolved = new URL(String(url), window.location.href)
if (resolved.origin === window.location.origin) {
sendOpenTab(resolved)
return null
}
}
return originalOpen(url, target, features)
}

// Plain <a target="_blank"> anchors (the webapp's "open in new tab"
// affordances) — capture phase so we run before any in-app handlers.
document.addEventListener(
'click',
(event) => {
if (event.defaultPrevented || event.button !== 0) {
return
}
const anchor = (event.target as HTMLElement | null)?.closest?.('a[target="_blank"]')
if (!anchor) {
return
}
const href = (anchor as HTMLAnchorElement).href
if (!href) {
return
}
const resolved = new URL(href, window.location.href)
if (resolved.origin === window.location.origin) {
event.preventDefault()
event.stopPropagation()
sendOpenTab(resolved)
}
},
true
)
}

function start(): void {
// Keep themeLogic mounted for the app's lifetime so the forced theme
// doesn't unmount away with whatever scene mounted it first.
themeLogic.mount()

interceptNewTabs()

if (window.__POSTHOG_EMBED_THEME__) {
themeLogic.actions.setEmbedForcedTheme(window.__POSTHOG_EMBED_THEME__)
}

window.addEventListener('message', (event: MessageEvent) => {
const data: unknown = event.data
if (!data || typeof data !== 'object' || (data as Record<string, unknown>).source !== FROM_HOST) {
return
}
const message = data as { type?: string; url?: unknown; theme?: unknown }
if (message.type === 'navigate' && typeof message.url === 'string') {
router.actions.push(message.url)
} else if (message.type === 'setTheme') {
themeLogic.actions.setEmbedForcedTheme(
message.theme === 'dark' ? 'dark' : message.theme === 'light' ? 'light' : null
)
}
})

let lastUrl = currentUrl()
getContext().store.subscribe(() => {
const url = currentUrl()
if (url !== lastUrl) {
lastUrl = url
postToHost({ type: 'routeChanged', url })
}
})

postToHost({ type: 'ready', url: lastUrl })
}
33 changes: 33 additions & 0 deletions frontend/src/embed/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* PostHog Code embedded-app entry.
*
* Boots the full PostHog app inside an iframe controlled by a desktop host
* (PostHog Code). The host serves this entry through a local auth-injecting
* proxy, so the app sees a same-origin `/api` and never handles credentials.
*
* Tracking stays off: we clear JS_POSTHOG_API_KEY before loadPostHogJS runs,
* which makes posthog-js init with a fake token and opt out (same pattern as
* the exporter and render-query entries).
*/

declare global {
interface Window {
__POSTHOG_EMBED__?: boolean
__POSTHOG_EMBED_THEME__?: 'light' | 'dark'
}
}

window.JS_POSTHOG_API_KEY = undefined
window.__POSTHOG_EMBED__ = true

// The host can seed the initial theme via query param (survives until the
// bridge is up and can receive live setTheme messages) or a pre-set global.
const themeParam = new URLSearchParams(window.location.search).get('__posthog_embed_theme')
if (themeParam === 'light' || themeParam === 'dark') {
window.__POSTHOG_EMBED_THEME__ = themeParam
}

// Dynamic imports only: static imports would hoist above the global setup.
void import('../index').then(() => import('./bridge')).then(({ initEmbedBridge }) => initEmbedBridge())

export {}
16 changes: 15 additions & 1 deletion frontend/src/layout/navigation-3000/navigationLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
toggleListItemAccordion: (key: string) => ({ key }),
setZenMode: (zenMode: boolean, trigger: ZenModeTrigger) => ({ zenMode, trigger }),
toggleZenMode: (trigger: ZenModeTrigger) => ({ trigger }),
// Embedded mode (PostHog Code iframe): the host provides all chrome
setEmbedMode: (embed: boolean) => ({ embed }),
}),
reducers({
isSidebarShown: [
Expand Down Expand Up @@ -244,6 +246,12 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
setZenMode: (_, { zenMode }) => zenMode,
},
],
embedMode: [
typeof window !== 'undefined' && !!window.__POSTHOG_EMBED__,
{
setEmbedMode: (_, { embed }) => embed,
},
],
}),
listeners(({ actions, values }) => ({
setZenMode: ({ zenMode, trigger }) => {
Expand Down Expand Up @@ -369,14 +377,20 @@ export const navigation3000Logic = kea<navigation3000LogicType>([
s.zenMode,
s.activeSceneId,
featureFlagLogic.selectors.featureFlags,
s.embedMode,
],
(
sceneConfig,
isCurrentOrganizationUnavailable,
zenMode,
activeSceneId,
featureFlags
featureFlags,
embedMode
): Navigation3000Mode => {
if (embedMode) {
// Embedded in PostHog Code: no chrome, the host wraps the scene
return 'none'
}
if (zenMode) {
return 'zen'
}
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/lib/logic/themeLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const themeLogic = kea<themeLogicType>([
actions({
syncDarkModePreference: (darkModePreference: boolean) => ({ darkModePreference }),
setTheme: (theme: string | null) => ({ theme }),
// Embedded mode (PostHog Code iframe): the host window's theme wins
setEmbedForcedTheme: (theme: 'light' | 'dark' | null) => ({ theme }),
saveCustomCss: true,
setPersistedCustomCss: (css: string | null) => ({ css }),
setPreviewingCustomCss: (css: string | null) => ({ css }),
Expand All @@ -38,6 +40,12 @@ export const themeLogic = kea<themeLogicType>([
setTheme: (_, { theme }) => theme,
},
],
embedForcedTheme: [
(typeof window !== 'undefined' && window.__POSTHOG_EMBED_THEME__) || (null as 'light' | 'dark' | null),
{
setEmbedForcedTheme: (_, { theme }) => theme,
},
],
persistedCustomCss: [
null as string | null,
{ persist: true },
Expand Down Expand Up @@ -74,8 +82,18 @@ export const themeLogic = kea<themeLogicType>([
(persistedCustomCss, previewingCustomCss): string | null => previewingCustomCss || persistedCustomCss,
],
isDarkModeOn: [
(s) => [s.themeMode, s.darkModeSystemPreference, sceneLogic.selectors.sceneConfig, s.theme],
(themeMode, darkModeSystemPreference, sceneConfig, theme) => {
(s) => [
s.themeMode,
s.darkModeSystemPreference,
sceneLogic.selectors.sceneConfig,
s.theme,
s.embedForcedTheme,
],
(themeMode, darkModeSystemPreference, sceneConfig, theme, embedForcedTheme) => {
// Embedded in PostHog Code: the host explicitly controls light/dark
if (embedForcedTheme) {
return embedForcedTheme === 'dark'
}
if (
typeof window !== 'undefined' &&
window.document &&
Expand Down
1 change: 1 addition & 0 deletions frontend/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export default defineConfig(({ mode }) => {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/index.tsx'),
embed: resolve(__dirname, 'src/embed/index.tsx'),
exporter: resolve(__dirname, 'src/exporter/index.tsx'),
render_query: resolve(__dirname, 'src/render-query/index.tsx'),
toolbar: resolve(__dirname, 'src/toolbar/index.tsx'),
Expand Down
Loading