Plugin & Theme System: - Add plugin type definitions, permissions (30+), and validation constants - Add IndexedDB storage layer for plugin code, theme CSS, and previews - Add theme CSS sanitization, injection, and safety validation - Add HookBus event system with 130+ hooks across 20 domains - Add plugin ZIP extraction and manifest validation with JS security checks - Add sandboxed PluginAPI factory with scoped storage, logging, and permission gating - Add plugin loader with blob URL dynamic import and auto-disable circuit breaker - Add 3 built-in themes (Nord, Catppuccin, Solarized) - Add Zustand plugin store with install/uninstall/enable/disable lifecycle - Add PluginSlot, PluginSlotRenderer, and PluginErrorBoundary components - Add plugins and themes settings UI panels - Integrate plugin slots into email viewer, composer, navigation rail, sidebar, and context menu - Extend theme store with custom theme installation and activation Admin Dashboard: - Add admin authentication with scrypt password hashing and AES-256-GCM sessions - Add rate-limited login (5 attempts/15min per IP) - Add config manager with admin override > env var > default priority - Add settings policy system with feature gates and per-setting restrictions - Add audit logging with rotation - Add admin API routes (login, logout, config, policy, audit, password change) - Add admin UI pages (login, dashboard, config, policy, audit) - Add policy store for client-side feature gate enforcement - Wire admin password initialization into server instrumentation Tests: - Add 139 tests across 10 test files covering all plugin/theme modules
161 lines
6.6 KiB
TypeScript
161 lines
6.6 KiB
TypeScript
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
|
|
|
import type { InstalledPlugin, Disposable } from './plugin-types';
|
|
import { pluginStorage } from './plugin-storage';
|
|
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
|
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
|
import React from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import * as ReactJSX from 'react/jsx-runtime';
|
|
|
|
// ─── Shared React (window.__PLUGIN_EXTERNALS__) ─────────────
|
|
|
|
export function exposePluginExternals(): void {
|
|
if (typeof window === 'undefined') return;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).__PLUGIN_EXTERNALS__ = {
|
|
React,
|
|
ReactDOM,
|
|
ReactJSX,
|
|
};
|
|
}
|
|
|
|
// ─── Active plugin tracking ──────────────────────────────────
|
|
|
|
interface ActivePlugin {
|
|
id: string;
|
|
api: PluginAPI;
|
|
disposable?: Disposable;
|
|
deactivate?: () => void;
|
|
}
|
|
|
|
const activePlugins = new Map<string, ActivePlugin>();
|
|
|
|
// ─── Load a single plugin ────────────────────────────────────
|
|
|
|
type PluginStoreAccessor = {
|
|
setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void;
|
|
};
|
|
|
|
let storeAccessor: PluginStoreAccessor | null = null;
|
|
|
|
export function setPluginStoreAccessor(accessor: PluginStoreAccessor): void {
|
|
storeAccessor = accessor;
|
|
}
|
|
|
|
export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
|
if (activePlugins.has(plugin.id)) {
|
|
console.warn(`[plugin-loader] Plugin "${plugin.id}" is already loaded`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1. Read bundle from IndexedDB
|
|
const code = await pluginStorage.getCode(plugin.id);
|
|
if (!code) {
|
|
throw new Error(`No code found in storage for plugin "${plugin.id}"`);
|
|
}
|
|
|
|
// 2. Create scoped module via blob URL
|
|
const blob = new Blob([code], { type: 'application/javascript' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// 3. Dynamic import (webpackIgnore prevents bundler processing)
|
|
let mod: { activate?: (api: PluginAPI) => void | Disposable; deactivate?: () => void };
|
|
try {
|
|
mod = await import(/* webpackIgnore: true */ url);
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
if (typeof mod.activate !== 'function') {
|
|
throw new Error(`Plugin "${plugin.id}" has no activate() export`);
|
|
}
|
|
|
|
// 4. Build sandboxed API
|
|
const api = createPluginAPI(plugin);
|
|
|
|
// 5. Call activate
|
|
const disposable = await mod.activate(api);
|
|
|
|
// 6. Track active plugin
|
|
activePlugins.set(plugin.id, {
|
|
id: plugin.id,
|
|
api,
|
|
disposable: disposable && typeof disposable === 'object' && 'dispose' in disposable
|
|
? disposable as Disposable
|
|
: undefined,
|
|
deactivate: mod.deactivate,
|
|
});
|
|
|
|
// 7. Mark running
|
|
storeAccessor?.setPluginStatus(plugin.id, 'running');
|
|
console.info(`[plugin-loader] Plugin "${plugin.id}" activated`);
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
storeAccessor?.setPluginStatus(plugin.id, 'error', errorMsg);
|
|
console.error(`[plugin-loader] Plugin "${plugin.id}" failed to load:`, err);
|
|
}
|
|
}
|
|
|
|
// ─── Deactivate a single plugin ──────────────────────────────
|
|
|
|
export function deactivatePlugin(pluginId: string): void {
|
|
const active = activePlugins.get(pluginId);
|
|
if (!active) return;
|
|
|
|
try {
|
|
// Call deactivate() if provided
|
|
active.deactivate?.();
|
|
// Dispose the disposable returned from activate()
|
|
active.disposable?.dispose();
|
|
} catch (err) {
|
|
console.error(`[plugin-loader] Error deactivating plugin "${pluginId}":`, err);
|
|
}
|
|
|
|
// Remove all hook subscriptions for this plugin
|
|
removeAllPluginHooks(pluginId);
|
|
|
|
// Reset error tracker
|
|
pluginErrorTracker.reset(pluginId);
|
|
|
|
activePlugins.delete(pluginId);
|
|
storeAccessor?.setPluginStatus(pluginId, 'disabled');
|
|
console.info(`[plugin-loader] Plugin "${pluginId}" deactivated`);
|
|
}
|
|
|
|
// ─── Activate all enabled plugins ────────────────────────────
|
|
|
|
export async function activateAllPlugins(plugins: InstalledPlugin[]): Promise<void> {
|
|
// Ensure externals are exposed
|
|
exposePluginExternals();
|
|
|
|
const enabledPlugins = plugins.filter(p => p.enabled && p.status !== 'error');
|
|
for (const plugin of enabledPlugins) {
|
|
await loadPlugin(plugin);
|
|
}
|
|
}
|
|
|
|
// ─── Deactivate all plugins ─────────────────────────────────
|
|
|
|
export function deactivateAllPlugins(): void {
|
|
for (const pluginId of [...activePlugins.keys()]) {
|
|
deactivatePlugin(pluginId);
|
|
}
|
|
}
|
|
|
|
// ─── Check if a plugin is active ─────────────────────────────
|
|
|
|
export function isPluginActive(pluginId: string): boolean {
|
|
return activePlugins.has(pluginId);
|
|
}
|
|
|
|
// ─── Setup auto-disable callback ─────────────────────────────
|
|
|
|
export function setupAutoDisable(): void {
|
|
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
|
deactivatePlugin(pluginId);
|
|
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
|
|
});
|
|
}
|