feat: sandbox plugins in null-origin iframes with postMessage RPC

This commit is contained in:
Linus Rath
2026-05-18 10:50:49 +02:00
parent c5ac68e137
commit 9f312aa556
15 changed files with 1481 additions and 176 deletions
+3 -3
View File
@@ -92,7 +92,7 @@ import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginStore } from "@/stores/plugin-store";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, uiHooks } from "@/lib/plugin-hooks";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
@@ -970,8 +970,8 @@ export function EmailViewer({
const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false);
// Plugin detail sidebar state
const detailSlots = usePluginStore(s => s.slots['email-detail-sidebar']);
const hasDetailSidebar = detailSlots && detailSlots.length > 0;
const detailSlots = usePluginSlotOffers('email-detail-sidebar');
const hasDetailSidebar = detailSlots.length > 0;
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(false);
const [detailSidebarWidth, setDetailSidebarWidth] = useState(280);
const detailSidebarWidthRef = useRef(280);
+74
View File
@@ -0,0 +1,74 @@
'use client';
// Sandboxed slot mount. One iframe per (plugin, slot) — created lazily after
// the background instance confirms `shouldShow(context)` (if defined). The
// iframe renders the plugin's slot component using the plugin's bundle in a
// null-origin context; its height is pushed back via postMessage and applied
// to a wrapper <div>.
import React, { useEffect, useRef, useState } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
interface Props {
pluginId: string;
slot: SlotName;
extraProps?: Record<string, unknown>;
}
export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
const wrapperRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<SandboxInstance | null>(null);
const [height, setHeight] = useState<number>(0);
// null = pending, true/false = decided
const [show, setShow] = useState<boolean | null>(null);
// Decide whether to mount based on the plugin's shouldShow (background-side).
useEffect(() => {
const active = getActivePlugin(pluginId);
if (!active) { setShow(false); return; }
const offer = active.slotOffers.find((o) => o.name === slot);
if (!offer) { setShow(false); return; }
if (!offer.hasShouldShow) { setShow(true); return; }
let cancelled = false;
active.background
.evaluateShouldShow(slot, extraProps ?? {})
.then((s) => { if (!cancelled) setShow(s); })
.catch(() => { if (!cancelled) setShow(false); });
return () => { cancelled = true; };
}, [pluginId, slot, extraProps]);
// Spawn / tear down the slot iframe.
useEffect(() => {
if (show !== true) return;
const active = getActivePlugin(pluginId);
if (!active || !wrapperRef.current) return;
const locale = (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ ?? 'en';
const inst = createSlotInstance({
plugin: active.plugin,
slot,
code: active.code,
locale,
extraProps: extraProps ?? {},
hostContainer: wrapperRef.current,
onResize: (h) => setHeight(h),
});
instanceRef.current = inst;
return () => {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
};
// We intentionally don't depend on extraProps here — propagating prop
// changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]);
// Push prop updates without remount.
useEffect(() => {
instanceRef.current?.updateProps(extraProps ?? {});
}, [extraProps]);
if (show !== true) return null;
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
}
+14 -9
View File
@@ -1,9 +1,9 @@
'use client';
import React from 'react';
import React, { useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { PluginSlotRenderer } from './plugin-slot-renderer';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
interface PluginSlotProps {
name: SlotName;
@@ -12,16 +12,21 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const registrations = usePluginStore(s => s.slots[name]);
const offers = useSyncExternalStore(
subscribe,
() => offersForSlot(name),
() => [],
);
if (!registrations || registrations.length === 0) return null;
if (offers.length === 0) return null;
return (
<div className={className} data-plugin-slot={name}>
{registrations.map((reg, i) => (
<PluginSlotRenderer
key={`${reg.pluginId}-${i}`}
registration={reg}
{offers.map((offer) => (
<PluginIframeSlot
key={offer.pluginId}
pluginId={offer.pluginId}
slot={name}
extraProps={extraProps}
/>
))}