// 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, setHookDialogGate, } 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, snapshotRegionalSettings, type InitDoneInfo, type RegionalSnapshot } from './host-bridge'; import { resolvePluginTier } from './tier'; import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive, type SlotOffer } from './registry'; import { cancelPluginDialogs } from './host-api'; import { hasDialogFor, whenNoDialogFor } from './host-dialog'; 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'; let currentRegionalKey = ''; export function setSandboxLocale(locale: string, regional?: RegionalSnapshot): 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 reg = regional ?? (typeof snapshotRegionalSettings === 'function' ? snapshotRegionalSettings(locale) : undefined); const regKey = reg ? JSON.stringify(reg) : ''; const changed = locale !== currentLocale || regKey !== currentRegionalKey; currentLocale = locale; currentRegionalKey = regKey; // 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, reg)); 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<{ code: string; hash: string }> { // 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 { return { code: cached, hash: await verifyBundle(cached, plugin.bundleHash) }; } 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', }); const hash = 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, hash }; } // ─── Slot offers from the last start ───────────────────────── // A plugin's slots are fixed exports of its bundle. They used to be known // only once its background frame had booted and run activate(), so a slot // frame (a sidebar section, say) started ~1 s after the plugin did, behind // it. The offers are kept per bundle hash, and a plugin started with the // same verified bundle offers them right away; its frames then boot next to // the background one. The real list replaces them when init is done. The // record only says where to draw: the same permission filter applies and // the frames run the same verified code. A changed bundle has another hash. const SLOT_CACHE_PREFIX = 'plugin-slot-offers:'; function readSlotOffers(pluginId: string, hash: string): InitDoneInfo['slots'] | null { try { const raw = localStorage.getItem(SLOT_CACHE_PREFIX + pluginId); if (!raw) return null; const rec = JSON.parse(raw) as { hash?: unknown; slots?: unknown }; if (rec.hash !== hash || !Array.isArray(rec.slots)) return null; return rec.slots.filter((o): o is InitDoneInfo['slots'][number] => !!o && typeof o === 'object' && typeof (o as { name?: unknown }).name === 'string'); } catch { return null; } } function writeSlotOffers(pluginId: string, hash: string, slots: InitDoneInfo['slots']): void { try { localStorage.setItem(SLOT_CACHE_PREFIX + pluginId, JSON.stringify({ hash, slots })); } catch { // Storage blocked or full: the next start just waits for init again. } } /** Forget a plugin's remembered slots (it was removed). */ export function forgetSlotOffers(pluginId: string): void { try { localStorage.removeItem(SLOT_CACHE_PREFIX + pluginId); } catch { /* ignore */ } } function permittedOffers(plugin: InstalledPlugin, slots: InitDoneInfo['slots'], warn: boolean): SlotOffer[] { return slots.filter((offer) => { if (mayOfferSlot(plugin, offer.name)) return true; if (warn) console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`); return false; }); } // ─── 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(); // Before the registry: a plugin offering remembered slots is registered // while it is still starting. const pending = loading.get(plugin.id); if (pending) return pending; if (getActive(plugin.id)) return Promise.resolve(); 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, hash } = await getBundleCode(plugin); background = createBackgroundInstance({ plugin, code, locale: currentLocale, tier, }); const remembered = readSlotOffers(plugin.id, hash); if (remembered && remembered.length > 0) { registerActive({ plugin, code, tier, background, slotOffers: permittedOffers(plugin, remembered, false), hookDisposables: [] }); } // 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 = permittedOffers(plugin, info.slots, true); writeSlotOffers(plugin.id, hash, info.slots); 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) { // Turned off while it was starting: unload has set the status. if (background?.closed && getActive(plugin.id)?.background !== background) return; // Offered from the last start: take the slots back down. if (background && getActive(plugin.id)?.background === background) deregisterActive(plugin.id); 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 { setHookDialogGate({ hasOpenDialog: hasDialogFor, whenDialogsClosed: whenNoDialogFor }); 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';