From c9694f9eb6d2ed9c65be2f6255d6991a46e24fb2 Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 5 Mar 2026 12:16:02 +0000 Subject: [PATCH] move from iframe to embed --- server/utils/plugin-loader.js | 2 +- src/components/plugins/PluginTabContent.tsx | 198 +++++++++++++------- 2 files changed, 127 insertions(+), 73 deletions(-) diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js index c0725ef6..a13a7fff 100644 --- a/server/utils/plugin-loader.js +++ b/server/utils/plugin-loader.js @@ -7,7 +7,7 @@ const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins'); const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json'); const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry']; -const ALLOWED_TYPES = ['iframe', 'react']; +const ALLOWED_TYPES = ['iframe', 'react', 'module']; const ALLOWED_SLOTS = ['tab']; export function getPluginsDir() { diff --git a/src/components/plugins/PluginTabContent.tsx b/src/components/plugins/PluginTabContent.tsx index 9b521989..aefab91e 100644 --- a/src/components/plugins/PluginTabContent.tsx +++ b/src/components/plugins/PluginTabContent.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { useTheme } from '../../contexts/ThemeContext'; import { authenticatedFetch } from '../../utils/api'; +import { usePlugins } from '../../contexts/PluginsContext'; import type { Project, ProjectSession } from '../../types/app'; type PluginTabContentProps = { @@ -9,126 +10,179 @@ type PluginTabContentProps = { selectedSession: ProjectSession | null; }; +type PluginContext = { + theme: 'dark' | 'light'; + project: { name: string; path: string } | null; + session: { id: string; title: string } | null; +}; + +function buildContext( + isDarkMode: boolean, + selectedProject: Project | null, + selectedSession: ProjectSession | null, +): PluginContext { + return { + theme: isDarkMode ? 'dark' : 'light', + project: selectedProject + ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } + : null, + session: selectedSession + ? { id: selectedSession.id, title: selectedSession.title } + : null, + }; +} + export default function PluginTabContent({ pluginName, selectedProject, selectedSession, }: PluginTabContentProps) { + const containerRef = useRef(null); const iframeRef = useRef(null); const { isDarkMode } = useTheme(); + const { plugins } = usePlugins(); - const iframeSrc = `/api/plugins/${encodeURIComponent(pluginName)}/assets/index.html`; + // Stable refs so effects don't need context values in their dep arrays + const contextRef = useRef(buildContext(isDarkMode, selectedProject, selectedSession)); + const contextCallbacksRef = useRef void>>(new Set()); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const moduleRef = useRef(null); - // Send context to iframe when it loads or when context changes. - // Use '*' as targetOrigin because the sandbox (without allow-same-origin) gives the - // iframe an opaque origin that cannot be matched with a specific origin string. + const plugin = plugins.find(p => p.name === pluginName); + // 'iframe' is the explicit legacy type; everything else (including 'module' and unset) uses module loading + const isIframe = plugin?.type === 'iframe'; + + // Keep contextRef current and notify the mounted plugin on every context change useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; + const ctx = buildContext(isDarkMode, selectedProject, selectedSession); + contextRef.current = ctx; - const sendContext = () => { - iframe.contentWindow?.postMessage( - { - type: 'ccui:context', - theme: isDarkMode ? 'dark' : 'light', - project: selectedProject - ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } - : null, - session: selectedSession - ? { id: selectedSession.id, title: selectedSession.title } - : null, - }, - '*', - ); - }; - - iframe.addEventListener('load', sendContext); - - // Also send when context changes (iframe already loaded) - if (iframe.contentWindow) { - sendContext(); + for (const cb of contextCallbacksRef.current) { + try { cb(ctx); } catch { /* plugin error — ignore */ } } - return () => { - iframe.removeEventListener('load', sendContext); - }; - }, [isDarkMode, selectedProject, selectedSession]); + // Also push to legacy iframe plugin + if (isIframe && iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ type: 'ccui:context', ...ctx }, '*'); + } + }, [isDarkMode, selectedProject, selectedSession, isIframe]); - // Listen for messages from plugin iframe. - // We verify by event.source rather than event.origin because the sandboxed iframe - // (without allow-same-origin) has an opaque origin that shows up as "null". + // ── Module plugin (default) ────────────────────────────────────── useEffect(() => { + if (isIframe || !containerRef.current) return; + + let active = true; + const container = containerRef.current; + const entryFile = plugin?.entry ?? 'index.js'; + + (async () => { + try { + // @vite-ignore — path is dynamic at runtime, not a static import + const mod = await import(/* @vite-ignore */ `/api/plugins/${encodeURIComponent(pluginName)}/assets/${entryFile}`); + if (!active || !containerRef.current) return; + + moduleRef.current = mod; + + const api = { + get context(): PluginContext { return contextRef.current; }, + + onContextChange(cb: (ctx: PluginContext) => void): () => void { + contextCallbacksRef.current.add(cb); + return () => contextCallbacksRef.current.delete(cb); + }, + + async rpc(method: string, path: string, body?: unknown): Promise { + const cleanPath = String(path).replace(/^\//, ''); + const res = await authenticatedFetch( + `/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`, + { + method: method || 'GET', + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }, + ); + if (!res.ok) throw new Error(`RPC error ${res.status}`); + return res.json(); + }, + }; + + await mod.mount?.(container, api); + } catch (err) { + if (!active) return; + console.error(`[Plugin:${pluginName}] Failed to load:`, err); + if (containerRef.current) { + containerRef.current.innerHTML = `
Plugin failed to load: ${String(err)}
`; + } + } + })(); + + return () => { + active = false; + try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ } + contextCallbacksRef.current.clear(); + moduleRef.current = null; + }; + }, [pluginName, isIframe, plugin?.entry]); // re-mount only when the plugin itself changes + + // ── Legacy iframe plugin ───────────────────────────────────────── + useEffect(() => { + if (!isIframe) return; + const handleMessage = (event: MessageEvent) => { - // Only accept messages originating from our plugin iframe if (event.source !== iframeRef.current?.contentWindow) return; if (!event.data || typeof event.data !== 'object') return; const { type } = event.data; switch (type) { - case 'ccui:request-context': { - // Plugin is requesting current context + case 'ccui:request-context': iframeRef.current?.contentWindow?.postMessage( - { - type: 'ccui:context', - theme: isDarkMode ? 'dark' : 'light', - project: selectedProject - ? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path } - : null, - session: selectedSession - ? { id: selectedSession.id, title: selectedSession.title } - : null, - }, + { type: 'ccui:context', ...contextRef.current }, '*', ); break; - } + case 'ccui:rpc': { - // Plugin is making an RPC call to its server subprocess. - // We bridge this because the sandboxed iframe has no auth token. const { requestId, method, path: rpcPath, body } = event.data; if (!requestId || !rpcPath) break; - const cleanPath = String(rpcPath).replace(/^\//, ''); - const url = `/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`; - - authenticatedFetch(url, { + authenticatedFetch(`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`, { method: method || 'GET', ...(body ? { body: JSON.stringify(body) } : {}), }) .then(async (res) => { const data = await res.json().catch(() => null); iframeRef.current?.contentWindow?.postMessage( - { type: 'ccui:rpc-response', requestId, status: res.status, data }, - '*', + { type: 'ccui:rpc-response', requestId, status: res.status, data }, '*', ); }) .catch((err) => { iframeRef.current?.contentWindow?.postMessage( - { type: 'ccui:rpc-response', requestId, status: 500, error: (err as Error).message }, - '*', + { type: 'ccui:rpc-response', requestId, status: 500, error: (err as Error).message }, '*', ); }); break; } - default: - break; } }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [isDarkMode, selectedProject, selectedSession]); + }, [isIframe, pluginName]); - return ( -
-