feat: harden plugin sandbox and migrate in-tree plugins
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Queue for plugin permission-consent requests.
|
||||
//
|
||||
// On first enable the plugin store posts a ConsentRequest here; the
|
||||
// `PluginConsentDialog` component renders the head and resolves the promise
|
||||
// when the user accepts or rejects. The store persists the granted set in
|
||||
// `plugin.grantedPermissions` so future enables don't re-prompt.
|
||||
|
||||
import type { Permission } from '../plugin-types';
|
||||
|
||||
export interface ConsentRequest {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
permissions: Permission[];
|
||||
resolve: (granted: boolean) => void;
|
||||
}
|
||||
|
||||
const queue: ConsentRequest[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function requestConsent(pluginId: string, pluginName: string, permissions: Permission[]): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
queue.push({ id: uid(), pluginId, pluginName, permissions, resolve });
|
||||
notify();
|
||||
});
|
||||
}
|
||||
|
||||
export function head(): ConsentRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveHead(granted: boolean): void {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
try { entry.resolve(granted); } catch { /* ignore */ }
|
||||
notify();
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
// ─── Friendly labels for permission strings ───────────────────
|
||||
|
||||
const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
||||
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
|
||||
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
|
||||
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
|
||||
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
||||
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
||||
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
|
||||
'contacts:write': { title: 'Modify your contacts', body: 'Create, edit, or delete contact entries.' },
|
||||
'files:read': { title: 'Read your files', body: 'Browse files stored in your WebDAV folders.' },
|
||||
'files:write': { title: 'Modify your files', body: 'Create, edit, rename, move, or delete files.' },
|
||||
'identity:read': { title: 'Read your identities', body: 'Access the From addresses and signatures you send mail from.' },
|
||||
'identity:write': { title: 'Modify your identities', body: 'Create, edit, or delete identities.' },
|
||||
'filters:read': { title: 'Read your filters', body: 'Access your Sieve mail-filter rules.' },
|
||||
'filters:write': { title: 'Modify your filters', body: 'Create, edit, or delete Sieve filter rules.' },
|
||||
'tasks:read': { title: 'Read your tasks', body: 'Access your task list.' },
|
||||
'tasks:write': { title: 'Modify your tasks', body: 'Create, edit, or delete tasks.' },
|
||||
'templates:read': { title: 'Read your templates', body: 'Access stored mail templates.' },
|
||||
'templates:write': { title: 'Modify your templates', body: 'Create, edit, or delete mail templates.' },
|
||||
'smime:read': { title: 'Read your S/MIME state', body: 'Access information about installed S/MIME keys and certificates.' },
|
||||
'vacation:read': { title: 'Read your vacation auto-reply', body: 'See the configured vacation auto-reply state.' },
|
||||
'vacation:write': { title: 'Modify your vacation auto-reply',body: 'Create, change, or remove the vacation auto-reply.' },
|
||||
'settings:read': { title: 'Read your settings', body: 'Access non-secret user preferences.' },
|
||||
'settings:write': { title: 'Modify your settings', body: 'Change non-secret user preferences.' },
|
||||
'security:read': { title: 'Read account security state', body: 'See whether TOTP / encryption are enabled (no secrets exposed).' },
|
||||
'auth:observe': { title: 'Observe login events', body: 'See when you log in, log out, or switch accounts.' },
|
||||
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
|
||||
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
|
||||
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
|
||||
};
|
||||
|
||||
export function describePermission(perm: string): { title: string; body: string } {
|
||||
return PERMISSION_LABELS[perm] ?? { title: perm, body: 'No description available.' };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { apiFetch } from '../browser-navigation';
|
||||
import { awaitDialog } from './host-dialog';
|
||||
|
||||
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// storage is unscoped by the manifest - implicit.
|
||||
@@ -27,11 +28,20 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
'admin.getAllConfig': 'admin:config',
|
||||
'admin.setConfig': 'admin:config',
|
||||
'admin.deleteConfig': 'admin:config',
|
||||
// ui — any plugin can ask the host to render a modal or open a URL.
|
||||
'ui.confirm': null,
|
||||
'ui.alert': null,
|
||||
'ui.openExternalUrl': null,
|
||||
};
|
||||
|
||||
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
|
||||
return plugin.permissions.includes(perm);
|
||||
if (!plugin.permissions.includes(perm)) return false;
|
||||
// Defense-in-depth: even if the manifest declares a permission, the host
|
||||
// refuses the API call unless an admin has marked the plugin as managed,
|
||||
// or the user has explicitly granted it via the consent dialog.
|
||||
if (plugin.managed) return true;
|
||||
return (plugin.grantedPermissions ?? []).includes(perm);
|
||||
}
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
@@ -228,7 +238,48 @@ export async function dispatchApiCall(
|
||||
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
|
||||
|
||||
case 'ui.confirm': {
|
||||
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
|
||||
return awaitDialog({
|
||||
pluginId: plugin.id,
|
||||
kind: 'confirm',
|
||||
title: String(opts.title ?? plugin.name ?? 'Confirm'),
|
||||
message: String(opts.message ?? ''),
|
||||
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
|
||||
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
|
||||
danger: !!opts.danger,
|
||||
});
|
||||
}
|
||||
case 'ui.alert': {
|
||||
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string };
|
||||
await awaitDialog({
|
||||
pluginId: plugin.id,
|
||||
kind: 'alert',
|
||||
title: String(opts.title ?? plugin.name ?? 'Notice'),
|
||||
message: String(opts.message ?? ''),
|
||||
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.openExternalUrl': {
|
||||
const url = String(args[0] ?? '');
|
||||
// Only http(s) — the sandbox should not be able to navigate the host
|
||||
// anywhere internal, nor open javascript:/data:/file: schemes.
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
|
||||
}
|
||||
const target = typeof args[1] === 'string' ? (args[1] as string) : '_blank';
|
||||
window.open(parsed.toString(), target, 'noopener,noreferrer');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup hook for unloading plugins ───────────────────────
|
||||
|
||||
export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
|
||||
|
||||
@@ -14,6 +14,39 @@ import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||
} from './protocol';
|
||||
|
||||
// ─── Callback marshalling ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walks an object graph and replaces any function values with
|
||||
* `{ __pluginCallback: id }` markers, registering each function in `table` so
|
||||
* the iframe can call back later via 'callback-invoke'. Non-plain values
|
||||
* (functions on prototype, DOM nodes, etc.) are dropped.
|
||||
*/
|
||||
function encodeCallbacks(
|
||||
value: unknown,
|
||||
table: Map<string, (...args: unknown[]) => unknown>,
|
||||
depth = 0,
|
||||
): unknown {
|
||||
if (depth > 6) return null; // hard cap to avoid pathological graphs
|
||||
if (value === null || value === undefined) return value;
|
||||
const t = typeof value;
|
||||
if (t === 'function') {
|
||||
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
table.set(id, value as (...args: unknown[]) => unknown);
|
||||
return { __pluginCallback: id };
|
||||
}
|
||||
if (t !== 'object') return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => encodeCallbacks(v, table, depth + 1));
|
||||
}
|
||||
// Plain object — copy own enumerable keys.
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = encodeCallbacks(v, table, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Public option types ─────────────────────────────────────
|
||||
|
||||
export interface BackgroundOptions {
|
||||
@@ -59,6 +92,8 @@ export class SandboxInstance {
|
||||
|
||||
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
||||
private pendingShouldShow = new Map<string, (show: boolean) => void>();
|
||||
/** Host-side function references the sandbox can call back via 'callback-invoke'. */
|
||||
private callbackTable = new Map<string, (...args: unknown[]) => unknown>();
|
||||
|
||||
constructor(
|
||||
private plugin: InstalledPlugin,
|
||||
@@ -69,6 +104,12 @@ export class SandboxInstance {
|
||||
this.pluginId = plugin.id;
|
||||
this.mode = initPayload.mode;
|
||||
|
||||
// Slot iframes get `extraProps`; encode any function values now so the
|
||||
// structured-clone send doesn't drop them.
|
||||
if (initPayload.mode === 'slot') {
|
||||
initPayload.extraProps = encodeCallbacks(initPayload.extraProps, this.callbackTable) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
this.readyPromise = new Promise<void>((res) => { this.resolveReady = res; });
|
||||
this.initPromise = new Promise<InitDoneInfo>((res, rej) => {
|
||||
this.resolveInit = res;
|
||||
@@ -148,6 +189,26 @@ export class SandboxInstance {
|
||||
return;
|
||||
}
|
||||
|
||||
case 'callback-invoke': {
|
||||
const { id, callbackId, args } = msg;
|
||||
const fn = this.callbackTable.get(callbackId);
|
||||
if (!fn) {
|
||||
this.send({ type: 'callback-response', id, ok: false, error: `unknown callback ${callbackId}` });
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await Promise.resolve(fn(...(args ?? [])));
|
||||
// Only send back primitives / plain objects; functions inside
|
||||
// results would round-trip but we don't support that yet.
|
||||
this.send({ type: 'callback-response', id, ok: true, result });
|
||||
} catch (err) {
|
||||
this.send({ type: 'callback-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'hook-result': {
|
||||
const entry = this.pendingHookInvokes.get(msg.id);
|
||||
if (!entry) return;
|
||||
@@ -202,7 +263,11 @@ export class SandboxInstance {
|
||||
|
||||
updateProps(props: Record<string, unknown>): void {
|
||||
if (this.destroyed) return;
|
||||
this.send({ type: 'props-update', props });
|
||||
// Stale references would leak if we kept growing the table without
|
||||
// bound; for now we let it grow until destroy(). A future refinement
|
||||
// could diff old vs new props and drop entries no longer referenced.
|
||||
const encoded = encodeCallbacks(props, this.callbackTable) as Record<string, unknown>;
|
||||
this.send({ type: 'props-update', props: encoded });
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@@ -215,6 +280,7 @@ export class SandboxInstance {
|
||||
}
|
||||
this.pendingHookInvokes.clear();
|
||||
this.pendingShouldShow.clear();
|
||||
this.callbackTable.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +319,14 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
pluginId: opts.plugin.id,
|
||||
slot: opts.slot,
|
||||
code: opts.code,
|
||||
manifest: {
|
||||
id: opts.plugin.id,
|
||||
version: opts.plugin.version,
|
||||
permissions: opts.plugin.permissions,
|
||||
settings: { ...opts.plugin.settings },
|
||||
locales: opts.plugin.locales,
|
||||
httpOrigins: opts.plugin.httpOrigins,
|
||||
},
|
||||
extraProps: opts.extraProps,
|
||||
locale: opts.locale,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
|
||||
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
|
||||
// dialog here and resolves the awaited Promise after the user clicks. The
|
||||
// `PluginDialogHost` component subscribes and renders one dialog at a time.
|
||||
|
||||
export type DialogKind = 'confirm' | 'alert';
|
||||
|
||||
export interface DialogRequest {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
kind: DialogKind;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** When true, confirm button uses destructive styling. */
|
||||
danger?: boolean;
|
||||
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
|
||||
resolve: (ok: boolean) => void;
|
||||
}
|
||||
|
||||
const queue: DialogRequest[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function enqueueDialog(req: Omit<DialogRequest, 'id'>): { id: string } {
|
||||
const entry: DialogRequest = { ...req, id: uid() };
|
||||
queue.push(entry);
|
||||
notify();
|
||||
return { id: entry.id };
|
||||
}
|
||||
|
||||
export function head(): DialogRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveHead(ok: boolean): void {
|
||||
const entry = queue.shift();
|
||||
if (!entry) return;
|
||||
try { entry.resolve(ok); } catch { /* ignore */ }
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Cancel every pending dialog for a plugin (called on unload). */
|
||||
export function cancelForPlugin(pluginId: string): void {
|
||||
let changed = false;
|
||||
for (let i = queue.length - 1; i >= 0; i--) {
|
||||
if (queue[i].pluginId === pluginId) {
|
||||
const entry = queue[i];
|
||||
queue.splice(i, 1);
|
||||
try { entry.resolve(false); } catch { /* ignore */ }
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper used by host-api to convert an `enqueueDialog` call into a
|
||||
* Promise the plugin-side `await` can land on.
|
||||
*/
|
||||
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
enqueueDialog({ ...req, resolve });
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { register as registerActive, deregister as deregisterActive } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -125,6 +126,7 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
}
|
||||
removeAllPluginHooks(pluginId);
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
cancelPluginDialogs(pluginId);
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
storeAccessor?.setPluginStatus(pluginId, 'disabled');
|
||||
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
|
||||
|
||||
@@ -38,7 +38,25 @@ export interface SlotInit {
|
||||
slot: SlotName;
|
||||
/** Same bundle code as the background instance. */
|
||||
code: string;
|
||||
/** Initial props the host passes through from `PluginSlot` `extraProps`. */
|
||||
/**
|
||||
* Trimmed manifest (mirrors `BackgroundInit.manifest`). Slot iframes get the
|
||||
* same fields so `api.plugin.settings` and `httpOrigins` work identically
|
||||
* to the background context.
|
||||
*/
|
||||
manifest: {
|
||||
id: string;
|
||||
version: string;
|
||||
permissions: string[];
|
||||
settings: Record<string, unknown>;
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
httpOrigins?: string[];
|
||||
};
|
||||
/**
|
||||
* Initial props the host passes through from `PluginSlot` `extraProps`.
|
||||
* Function values are pre-encoded by the host as
|
||||
* `{ __pluginCallback: '<id>' }` markers and rehydrated to stub functions
|
||||
* by the runtime; the stubs round-trip to the host via 'callback-invoke'.
|
||||
*/
|
||||
extraProps: Record<string, unknown>;
|
||||
locale: string;
|
||||
}
|
||||
@@ -67,6 +85,25 @@ export interface ApiRequestMsg {
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
/** Sandbox → host: invoke a function the host passed in via `extraProps`. */
|
||||
export interface CallbackInvokeMsg {
|
||||
type: 'callback-invoke';
|
||||
/** Round-trip id so the host can return a value if the caller awaits. */
|
||||
id: string;
|
||||
/** The callback marker id (matches `__pluginCallback`). */
|
||||
callbackId: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
/** Host → sandbox: response to a callback-invoke. */
|
||||
export interface CallbackResponseMsg {
|
||||
type: 'callback-response';
|
||||
id: string;
|
||||
ok: boolean;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HookResultMsg {
|
||||
type: 'hook-result';
|
||||
id: string;
|
||||
@@ -91,6 +128,7 @@ export type SandboxToHost =
|
||||
| InitDoneMsg
|
||||
| InitErrorMsg
|
||||
| ApiRequestMsg
|
||||
| CallbackInvokeMsg
|
||||
| HookResultMsg
|
||||
| SlotResizeMsg
|
||||
| SlotShouldShowResultMsg;
|
||||
@@ -128,11 +166,25 @@ export interface SlotShouldShowMsg {
|
||||
export type HostToSandbox =
|
||||
| InitMsg
|
||||
| ApiResponseMsg
|
||||
| CallbackResponseMsg
|
||||
| HookInvokeMsg
|
||||
| LocaleChangeMsg
|
||||
| PropsUpdateMsg
|
||||
| SlotShouldShowMsg;
|
||||
|
||||
/** Marker used in extraProps for function values that the host owns. */
|
||||
export interface PluginCallbackMarker {
|
||||
__pluginCallback: string;
|
||||
}
|
||||
|
||||
export function isCallbackMarker(value: unknown): value is PluginCallbackMarker {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { __pluginCallback?: unknown }).__pluginCallback === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────
|
||||
|
||||
export function isSandboxMessage(value: unknown): value is SandboxToHost {
|
||||
@@ -154,6 +206,7 @@ export const API_METHODS = [
|
||||
'http.post', 'http.fetch',
|
||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
||||
] as const;
|
||||
|
||||
export type ApiMethod = (typeof API_METHODS)[number];
|
||||
|
||||
@@ -46,6 +46,7 @@ let slotName: SlotName | null = null;
|
||||
let bootDone = false;
|
||||
|
||||
const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
|
||||
const hookHandlers: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
|
||||
function sendToHost(msg: SandboxToHost): void {
|
||||
@@ -74,7 +75,46 @@ function callApi(method: string, args: unknown[]): Promise<unknown> {
|
||||
});
|
||||
}
|
||||
|
||||
function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
|
||||
const id = uid();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingCallbacks.set(id, { resolve, reject });
|
||||
sendToHost({ type: 'callback-invoke', id, callbackId, args });
|
||||
setTimeout(() => {
|
||||
const entry = pendingCallbacks.get(id);
|
||||
if (!entry) return;
|
||||
pendingCallbacks.delete(id);
|
||||
entry.reject(new Error('host callback timed out after 30s'));
|
||||
}, 30_000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an object graph received from the host and rehydrates
|
||||
* `{ __pluginCallback: id }` markers into stub functions that round-trip via
|
||||
* the 'callback-invoke' RPC. Mirrors `encodeCallbacks` in host-bridge.ts.
|
||||
*/
|
||||
function decodeCallbacks(value: unknown, depth = 0): unknown {
|
||||
if (depth > 6) return null;
|
||||
if (value === null || value === undefined) return value;
|
||||
const t = typeof value;
|
||||
if (t !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map((v) => decodeCallbacks(v, depth + 1));
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (typeof obj.__pluginCallback === 'string') {
|
||||
const cbId = obj.__pluginCallback;
|
||||
return (...args: unknown[]) => invokeHostCallback(cbId, args);
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = decodeCallbacks(v, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type PluginManifest = BackgroundInit['manifest'];
|
||||
|
||||
function buildPluginApi(manifest: PluginManifest) {
|
||||
return {
|
||||
plugin: {
|
||||
id: manifest.id,
|
||||
@@ -97,6 +137,17 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
info: (m: string) => { void callApi('toast.info', [m]); },
|
||||
warning: (m: string) => { void callApi('toast.warning', [m]); },
|
||||
},
|
||||
ui: {
|
||||
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
|
||||
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
|
||||
callApi('ui.confirm', [opts]) as Promise<boolean>,
|
||||
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
|
||||
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
|
||||
callApi('ui.alert', [opts]) as Promise<void>,
|
||||
/** Opens an http/https URL in a new tab via host `window.open`. */
|
||||
openExternalUrl: (url: string, target?: string) =>
|
||||
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
|
||||
},
|
||||
admin: {
|
||||
getConfig: (key: string) => callApi('admin.getConfig', [key]),
|
||||
getAllConfig: () => callApi('admin.getAllConfig', []),
|
||||
@@ -119,8 +170,11 @@ function buildPluginApi(manifest: BackgroundInit['manifest']) {
|
||||
* bundlers should be configured to externalise React; the runtime provides
|
||||
* those modules here. Anything else is refused — the sandbox has no Node-
|
||||
* compatible module resolution and we don't want plugins probing globals.
|
||||
*
|
||||
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
|
||||
* `const api = require('@plugin-host')` in both background and slot modes.
|
||||
*/
|
||||
function makePluginRequire(): (name: string) => unknown {
|
||||
function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
|
||||
const known: Record<string, unknown> = {
|
||||
'react': React,
|
||||
'react-dom': ReactDOM,
|
||||
@@ -128,15 +182,16 @@ function makePluginRequire(): (name: string) => unknown {
|
||||
'react/jsx-runtime': ReactJSXRuntime,
|
||||
'react/jsx-dev-runtime': ReactJSXRuntime,
|
||||
};
|
||||
if (api) known['@plugin-host'] = api;
|
||||
return (name: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
|
||||
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateBundle(code: string): PluginExports {
|
||||
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
|
||||
const mod: { exports: PluginExports } = { exports: {} };
|
||||
const requireShim = makePluginRequire();
|
||||
const requireShim = makePluginRequire(api);
|
||||
let fn: (...args: unknown[]) => void;
|
||||
try {
|
||||
fn = new Function(
|
||||
@@ -161,7 +216,8 @@ function evaluateBundle(code: string): PluginExports {
|
||||
// ─── Init flow ───────────────────────────────────────────────
|
||||
|
||||
async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
const exports = evaluateBundle(payload.code);
|
||||
const api = buildPluginApi(payload.manifest);
|
||||
const exports = evaluateBundle(payload.code, api);
|
||||
pluginExports = exports;
|
||||
|
||||
// Register hooks (each value must be a function).
|
||||
@@ -189,14 +245,15 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
||||
|
||||
// Side effects.
|
||||
if (typeof exports.activate === 'function') {
|
||||
await Promise.resolve(exports.activate(buildPluginApi(payload.manifest)));
|
||||
await Promise.resolve(exports.activate(api));
|
||||
}
|
||||
|
||||
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo });
|
||||
}
|
||||
|
||||
function bootSlot(payload: SlotInit): void {
|
||||
const exports = evaluateBundle(payload.code);
|
||||
const api = buildPluginApi(payload.manifest);
|
||||
const exports = evaluateBundle(payload.code, api);
|
||||
pluginExports = exports;
|
||||
slotName = payload.slot;
|
||||
|
||||
@@ -208,11 +265,26 @@ function bootSlot(payload: SlotInit): void {
|
||||
const rootEl = document.getElementById('plugin-sandbox-root');
|
||||
if (!rootEl) throw new Error('Sandbox root element missing');
|
||||
|
||||
let currentProps: Record<string, unknown> = payload.extraProps;
|
||||
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
|
||||
const Component = slotDef.component;
|
||||
|
||||
// A trivial pub/sub so host-pushed `props-update` messages re-render the
|
||||
// slot tree without tearing down the iframe.
|
||||
const propsListeners = new Set<(p: Record<string, unknown>) => void>();
|
||||
slotPropsUpdater = (next) => {
|
||||
currentProps = decodeCallbacks(next) as Record<string, unknown>;
|
||||
for (const l of propsListeners) {
|
||||
try { l(currentProps); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const SlotShell = () => {
|
||||
const wrapRef = React.useRef<HTMLDivElement>(null);
|
||||
const [props, setProps] = React.useState(currentProps);
|
||||
React.useEffect(() => {
|
||||
propsListeners.add(setProps);
|
||||
return () => { propsListeners.delete(setProps); };
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!wrapRef.current) return;
|
||||
let lastHeight = -1;
|
||||
@@ -228,7 +300,7 @@ function bootSlot(payload: SlotInit): void {
|
||||
ro.observe(wrapRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, currentProps));
|
||||
return React.createElement('div', { ref: wrapRef }, React.createElement(Component, props));
|
||||
};
|
||||
|
||||
const reactRoot = ReactDOM.createRoot(rootEl);
|
||||
@@ -236,6 +308,9 @@ function bootSlot(payload: SlotInit): void {
|
||||
sendToHost({ type: 'init-done', hooks: [], slots: [] });
|
||||
}
|
||||
|
||||
// Populated by bootSlot — receives `props-update` messages.
|
||||
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
async function handleInit(payload: InitPayload): Promise<void> {
|
||||
if (bootDone) return;
|
||||
bootDone = true;
|
||||
@@ -280,6 +355,15 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'callback-response': {
|
||||
const pending = pendingCallbacks.get(msg.id);
|
||||
if (!pending) return;
|
||||
pendingCallbacks.delete(msg.id);
|
||||
if (msg.ok) pending.resolve(msg.result);
|
||||
else pending.reject(new Error(msg.error ?? 'callback error'));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hook-invoke': {
|
||||
const handler = hookHandlers[msg.hookName];
|
||||
if (!handler) {
|
||||
@@ -318,8 +402,7 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
break;
|
||||
|
||||
case 'props-update':
|
||||
// Phase-2: would push updates into the slot shell. Currently the slot
|
||||
// iframe is torn down and recreated when props change at the host.
|
||||
slotPropsUpdater?.(msg.props ?? {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user