mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
move from iframe to embed
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user