move from iframe to embed

This commit is contained in:
simosmik
2026-03-05 12:16:02 +00:00
parent 2588851746
commit c9694f9eb6
2 changed files with 127 additions and 73 deletions

View File

@@ -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() {

View File

@@ -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<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(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<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const moduleRef = useRef<any>(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<unknown> {
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 = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
}
}
})();
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 (
<div className="h-full w-full overflow-hidden">
<iframe
ref={iframeRef}
src={iframeSrc}
title={`Plugin: ${pluginName}`}
className="w-full h-full border-0"
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
);
if (isIframe) {
const src = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${plugin?.entry ?? 'index.html'}`;
return (
<div className="h-full w-full overflow-hidden">
<iframe
ref={iframeRef}
src={src}
title={`Plugin: ${pluginName}`}
className="w-full h-full border-0"
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
);
}
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
}