// Iframe-based plugin loader. Replaces the blob-URL `import()` flow in // `lib/plugin-loader.ts` with a postMessage-isolated sandbox. import type { Disposable, InstalledPlugin } from '../plugin-types'; import { pluginStorage } from '../plugin-storage'; import { emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, removeAllPluginHooks, pluginErrorTracker, setHookTimeoutListener, } from '../plugin-hooks'; import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; import { useEmailStore } from '@/stores/email-store'; import { emailToReadView } from '../plugin-projection'; import { verifyBundle } from './bundle-integrity'; import { downloadManagedBundle } from './bundle-fetch'; import { createBackgroundInstance, forEachInstance } from './host-bridge'; import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions'; import { clearPluginActions, setActionDispatcher } from './actions'; import { clearDayLabels } from './day-labels'; import { clearPluginPages } from './pages'; import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable }; const HOOK_BUSES: Record = Object.assign({}, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, ) as Record; // ─── Store accessor (status updates flow through the existing store) ── type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void; /** Turn a failing plugin off for good (until the user turns it on again). */ markAutoDisabled?: (id: string) => void; }; let storeAccessor: StoreAccessor | null = null; export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; } // ─── Locale (kept in step with the app locale) ──────────────── let currentLocale = 'en'; export function setSandboxLocale(locale: string): void { // Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a // good locale back to '' - the initial 'en' default stands until the real // locale arrives via the store subscription. if (!locale) return; const changed = locale !== currentLocale; currentLocale = locale; // Background instances read `currentLocale` at load time; the slot-iframe // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep // both in step from one place. (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale; if (!changed) return; // Running plugins switch too: their slots re-render in the new language // and their onLocaleChange handlers run. forEachInstance(null, (instance) => instance.setLocale(locale)); void themeHooks.onLocaleChange.emit({ locale }); } // ─── Bundle fetch ───────────────────────────────────────────── async function readCachedBundle(plugin: InstalledPlugin): Promise { try { return await pluginStorage.getCode(plugin.id); } catch (err) { // IndexedDB itself is unusable (some private modes, storage disabled). A // managed bundle can still be fetched and run from memory; a user upload // has no other copy, so surface the storage error for it. if (!plugin.managed) throw err; console.warn(`[plugin-sandbox] Could not read cached bundle for "${plugin.id}":`, err); return null; } } async function getBundleCode(plugin: InstalledPlugin): Promise { // IndexedDB is the fast path: the store's server sync (managed plugins) and // the upload flow (user plugins) both populate it. It is per-browser though, // while a managed plugin's record travels with the server registry to every // user - so the record can exist without a local copy of the bundle: another // browser or profile, a private window, cleared site data, an evicted // IndexedDB (#636). Managed bundles are re-fetched from the server in that // case, signature- and hash-checked, and cached again. User-uploaded // bundles only ever existed in this browser, so reinstalling is the only // way back for those. const cached = await readCachedBundle(plugin); if (cached) { try { await verifyBundle(cached, plugin.bundleHash); return cached; } catch (err) { if (!plugin.managed) throw err; // Stale (sync updated the record but not the bytes) or corrupt copy; the // server has the canonical bytes, so try those before giving up. console.warn(`[plugin-sandbox] Cached bundle for "${plugin.id}" failed verification, re-downloading:`, err); } } if (!plugin.managed) { throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`); } const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, { requireSignature: plugin.tier === 'privileged', }); await verifyBundle(code, plugin.bundleHash); try { await pluginStorage.saveCode(plugin.id, code); } catch (err) { // Caching is best effort - the plugin runs from the fetched bytes either way. console.warn(`[plugin-sandbox] Could not cache bundle for "${plugin.id}":`, err); } return code; } // ─── Load ───────────────────────────────────────────────────── // Bound on how long the sandbox iframe may take to send back init-done. // Without this a single misbehaving plugin can hang the whole load loop. // 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on // slower machines, while still catching truly stuck plugins. const INIT_TIMEOUT_MS = 30_000; function withTimeout(promise: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`${label} timed out after ${ms}ms`)); }, ms); promise.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); }, ); }); } const loading = new Map>(); /** * Start a plugin. Calling it again for a plugin that is running or still * starting does nothing: start-up and a force-enable sync used to both load * a plugin, leaving two background iframes whose hooks all fired twice. */ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { if (typeof window === 'undefined') return Promise.resolve(); if (getActive(plugin.id)) return Promise.resolve(); const pending = loading.get(plugin.id); if (pending) return pending; const run = startSandboxedPlugin(plugin).finally(() => loading.delete(plugin.id)); loading.set(plugin.id, run); return run; } async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { let background: ReturnType | null = null; try { // Decide the execution tier BEFORE creating any iframe. A refused privileged // request is a hard error (never silently downgraded to null-origin). const resolution = resolvePluginTier(plugin); if (resolution.tier === null) { storeAccessor?.setPluginStatus(plugin.id, 'error', resolution.error); console.error(`[plugin-sandbox] "${plugin.id}" tier refused: ${resolution.error}`); return; } const tier = resolution.tier; const code = await getBundleCode(plugin); background = createBackgroundInstance({ plugin, code, locale: currentLocale, tier, }); // Wait for the background runtime to evaluate the bundle, register hooks, // and enumerate slots. Bounded so a stuck iframe doesn't hang activation. const bg = background; const info = await withTimeout( bg.initPromise, INIT_TIMEOUT_MS, `[plugin-sandbox] "${plugin.id}" init`, ); // Wire hook proxies: every hookName the plugin registered gets a HookBus // entry whose handler dispatches into the sandbox. `shortcut:` hooks // are dispatched by the keyboard module separately and don't have a bus. // A hook hands the plugin data or a say in what the host sends, so it // needs the same manifest permission as the matching API call. const hookDisposables: Disposable[] = []; let openHandler: ((...args: unknown[]) => unknown) | null = null; for (const hookName of info.hooks) { if (hookName.startsWith('shortcut:')) continue; // Not a bus: the host calls it directly for clicks on this plugin's own // actions (see setActionDispatcher below). if (hookName === 'onAction') continue; const bus = HOOK_BUSES[hookName]; if (!bus) { console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`); continue; } if (!mayRegisterHook(plugin, hookName)) { console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for hook "${hookName}"; not registered`); continue; } // The bus records a failure once; recording it here too made the // three-strikes breaker trip on the second error. const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); if (hookName === 'onEmailOpen') openHandler = proxy; } // Install plugin-declared keyboard shortcuts. A shortcut sees every // key press outside text fields, so it needs ui:keyboard like the hooks. if ((info.shortcuts ?? []).length > 0) { if (pluginHasPermission(plugin, 'ui:keyboard')) { const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []); hookDisposables.push({ dispose: shortcutDispose }); } else { console.warn(`[plugin-sandbox] Plugin "${plugin.id}" declares shortcuts without the ui:keyboard permission; not registered`); } } const slotOffers = info.slots.filter((offer) => { if (mayOfferSlot(plugin, offer.name)) return true; console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`); return false; }); registerActive({ plugin, code, tier, background: bg, slotOffers, hookDisposables, }); storeAccessor?.setPluginStatus(plugin.id, 'running'); // onEmailOpen fires when a message is selected. A plugin that starts // while one is already open (page load on a message URL, a plugin turned // on later) would otherwise not see it until the next one. const openEmail = openHandler ? useEmailStore.getState().selectedEmail : null; if (openHandler && openEmail) { Promise.resolve(openHandler(emailToReadView(openEmail))).catch((err) => pluginErrorTracker.record(plugin.id, err)); } console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`); } catch (err) { const msg = (err as Error).message ?? String(err); storeAccessor?.setPluginStatus(plugin.id, 'error', msg); console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err); // Tear down the hung/failed iframe so it can't keep posting messages or // occupy DOM and resources after we've given up on it. if (background) { try { background.destroy(); } catch { /* ignore */ } } } } // ─── Action clicks ──────────────────────────────────────────── // A click on a button a plugin declared (api.ui.setActions) goes to that // plugin's background instance as its `onAction` hook, with the surface's // context cut down to what the plugin may see. setActionDispatcher(async (pluginId, surface, actionId, context) => { const entry = allActiveEntries().find((e) => e.plugin.id === pluginId); if (!entry) return; const visible = slotPropsFor(entry.plugin, surface as SlotName, context); try { await entry.background.invokeHook('onAction', [{ surface, actionId, context: visible }]); } catch (err) { pluginErrorTracker.record(pluginId, err); } }); // ─── Unload ─────────────────────────────────────────────────── export function unloadSandboxedPlugin(pluginId: string): void { const entry = deregisterActive(pluginId); if (!entry) return; for (const d of entry.hookDisposables) { try { d.dispose(); } catch { /* ignore */ } } removeAllPluginHooks(pluginId); // Drop any message-list category tabs the plugin registered so the strip // disappears (and the inbox unfilters) the moment the plugin is disabled. try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ } // Same for the buttons, day labels and pages it declared, and anything // still running through the proxy for it. clearPluginActions(pluginId); clearDayLabels(pluginId); clearPluginPages(pluginId); clearSectionTitle(pluginId); cancelPluginProxyRequests(pluginId); cancelConnectFor(pluginId); try { entry.background.destroy(); } catch { /* ignore */ } cancelPluginDialogs(pluginId); pluginErrorTracker.reset(pluginId); storeAccessor?.setPluginStatus(pluginId, 'disabled'); console.info(`[plugin-sandbox] "${pluginId}" deactivated`); } // ─── Bulk ───────────────────────────────────────────────────── export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise { const enabled = plugins.filter(p => p.enabled && p.status !== 'error'); for (const p of enabled) await loadSandboxedPlugin(p); } export function deactivateAllSandboxed(): void { // all() returns a fresh array copy, so iterating while unload -> deregister // mutates the underlying registry map is safe. (No circular import: registry // only pulls in types, so a static import is fine and works under ESM.) for (const e of allActiveEntries()) unloadSandboxedPlugin(e.plugin.id); } // ─── Auto-disable ───────────────────────────────────────────── export function setupSandboxAutoDisable(): void { setHookTimeoutListener((pluginId) => cancelPluginDialogs(pluginId)); pluginErrorTracker.setAutoDisableCallback((pluginId) => { unloadSandboxedPlugin(pluginId); if (storeAccessor?.markAutoDisabled) storeAccessor.markAutoDisabled(pluginId); else storeAccessor?.setPluginStatus(pluginId, 'error', 'auto-disabled'); // PluginDialogHost turns this into a toast in the user's language. if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('plugin:auto-disabled', { detail: { pluginId } })); } }); } // ─── Re-export for compat with the existing loader name ─────── export { SandboxInstance } from './host-bridge';