/* ObsiGate — Plugin Manager (Frontend) * Handles plugin lifecycle, UI, and sandboxed execution via Web Worker. */ import { api } from './auth.js'; import { showToast } from './ui.js'; import { t } from './i18n.js'; import { safeCreateIcons } from './utils.js'; // ── Plugin Registry (in-memory cache) ───────────────────────────────────── let _pluginsCache = new Map(); let _worker = null; let _pendingCalls = new Map(); let _callId = 0; // ── Worker Management ──────────────────────────────────────────────────── function getOrCreateWorker() { if (!_worker) { // Create worker from blob to avoid separate file (simpler deployment) // Using string concatenation to avoid nested template literal issues const workerCode = "const pluginSandbox = {};\n" + "const handlers = {};\n\n" + "// Safe globals for plugins\n" + "const safeGlobals = {\n" + " console: {\n" + " log: (...args) => self.postMessage({ type: 'log', args }),\n" + " warn: (...args) => self.postMessage({ type: 'warn', args }),\n" + " error: (...args) => self.postMessage({ type: 'error', args })\n" + " },\n" + " fetch: async (url, options) => {\n" + " const response = await fetch(url, options);\n" + " const data = await response.json();\n" + " return data;\n" + " },\n" + " setTimeout,\n" + " clearTimeout,\n" + " Promise,\n" + " JSON,\n" + " Math,\n" + " Date,\n" + " Object,\n" + " Array,\n" + " String,\n" + " Number,\n" + " Boolean,\n" + " RegExp,\n" + " Error,\n" + " Map,\n" + " Set,\n" + " WeakMap,\n" + " WeakSet,\n" + " URL,\n" + " URLSearchParams,\n" + " Headers,\n" + " Request,\n" + " Response,\n" + " FormData,\n" + " Blob,\n" + " File,\n" + " atob,\n" + " btoa,\n" + " encodeURIComponent,\n" + " decodeURIComponent,\n" + " parseInt,\n" + " parseFloat,\n" + " isNaN,\n" + " isFinite\n" + "};\n\n" + "// Message handler\n" + "self.onmessage = async (e) => {\n" + " const { type, payload, callId } = e.data;\n\n" + " try {\n" + " if (type === 'loadPlugin') {\n" + " const { code, name, permissions } = payload;\n\n" + " const sandbox = { ...safeGlobals };\n" + " const pluginModule = { exports: {} };\n\n" + " const fn = new Function(\n" + " 'module', 'exports', 'require', 'globals',\n" + " code\n" + " );\n" + " fn(pluginModule, pluginModule.exports, (mod) => {\n" + " throw new Error('require() not allowed in plugins');\n" + " }, sandbox);\n\n" + " for (const [hook, handlerName] of Object.entries(sandbox.handlers || {})) {\n" + " if (!handlers[hook]) handlers[hook] = [];\n" + " handlers[hook].push({ name, handler: sandbox[handlerName], permissions });\n" + " }\n\n" + " self.postMessage({ type: 'loaded', callId, plugin: name });\n" + " }\n" + " else if (type === 'executeHook') {\n" + " const { hook, context, plugins: pluginList } = payload;\n" + " const results = [];\n\n" + " for (const p of pluginList) {\n" + " const hookHandlers = handlers[hook]?.filter(h => h.name === p.name) || [];\n" + " for (const h of hookHandlers) {\n" + " try {\n" + " const missing = h.permissions?.filter(p => !payload.permissions?.includes(p)) || [];\n" + " if (missing.length > 0) {\n" + " results.push({ plugin: p.name, success: false, error: 'Missing permissions: ' + missing.join(', ') });\n" + " continue;\n" + " }\n\n" + " const result = await h.handler(context);\n" + " results.push({ plugin: p.name, success: true, data: result });\n" + " } catch (err) {\n" + " results.push({ plugin: p.name, success: false, error: err.message });\n" + " }\n" + " }\n" + " }\n\n" + " self.postMessage({ type: 'hookResult', callId, results });\n" + " }\n" + " else if (type === 'unloadPlugin') {\n" + " const { name } = payload;\n" + " for (const hook of Object.keys(handlers)) {\n" + " handlers[hook] = handlers[hook].filter(h => h.name !== name);\n" + " }\n" + " self.postMessage({ type: 'unloaded', callId });\n" + " }\n" + " } catch (err) {\n" + " self.postMessage({ type: 'error', callId, error: err.message });\n" + " }\n" + "};\n"; const blob = new Blob([workerCode], { type: 'application/javascript' }); _worker = new Worker(URL.createObjectURL(blob)); _worker.onmessage = (e) => { const { type, callId, plugin, result, results, error } = e.data; const pending = _pendingCalls.get(callId); if (!pending) return; clearTimeout(pending.timeout); _pendingCalls.delete(callId); if (type === 'error' || type === 'loaded' || type === 'unloaded') { if (error) pending.reject(new Error(error)); else pending.resolve({ success: true }); } else if (type === 'hookResult') { pending.resolve(results); } }; _worker.onerror = (err) => { console.error('[PluginWorker] Error:', err); for (const [, pending] of _pendingCalls) { clearTimeout(pending.timeout); pending.reject(new Error('Worker error: ' + err.message)); } _pendingCalls.clear(); _worker = null; }; } return _worker; } function callWorker(type, payload, timeoutMs = 30000) { return new Promise((resolve, reject) => { const worker = getOrCreateWorker(); const id = ++_callId; const timeout = setTimeout(() => { _pendingCalls.delete(id); reject(new Error('Plugin worker timeout')); }, timeoutMs); _pendingCalls.set(id, { resolve, reject, timeout }); worker.postMessage({ type, payload, callId: id }); }); } // ── Public API ──────────────────────────────────────────────────────────── export async function loadPlugins(vault) { try { const plugins = await api('/api/plugins?vault=' + encodeURIComponent(vault)); _pluginsCache.set(vault, plugins); return plugins; } catch (err) { console.error('Failed to load plugins:', err); return []; } } export function getCachedPlugins(vault) { return _pluginsCache.get(vault) || []; } export async function installPlugin(vault, manifest, code) { const plugin = await api('/api/plugins?vault=' + encodeURIComponent(vault), { method: 'POST', body: JSON.stringify({ manifest, code }) }); await loadPlugins(vault); return plugin; } export async function uninstallPlugin(vault, name) { await api('/api/plugins/' + encodeURIComponent(name) + '?vault=' + encodeURIComponent(vault), { method: 'DELETE' }); await loadPlugins(vault); } export async function enablePlugin(vault, name) { const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/enable?vault=' + encodeURIComponent(vault), { method: 'POST' }); await loadPlugins(vault); return plugin; } export async function disablePlugin(vault, name) { const plugin = await api('/api/plugins/' + encodeURIComponent(name) + '/disable?vault=' + encodeURIComponent(vault), { method: 'POST' }); await loadPlugins(vault); return plugin; } export async function getPluginCode(vault, name) { const { code } = await api('/api/plugins/' + encodeURIComponent(name) + '/code?vault=' + encodeURIComponent(vault)); return code; } export async function validateManifest(manifest) { return api('/api/plugins/validate-manifest', { method: 'POST', body: JSON.stringify(manifest) }); } export async function getPluginTemplate() { return api('/api/plugins/template'); } // ── Hook Execution (for other modules to call) ──────────────────────────── export async function executeHook(vault, hook, context) { const plugins = getCachedPlugins(vault).filter(p => p.enabled && p.hooks?.includes(hook)); if (plugins.length === 0) return []; const pluginList = plugins.map(p => ({ name: p.name, handler: p.hooks?.[hook] || '', permissions: p.permissions || [] })).filter(p => p.handler); if (pluginList.length === 0) return []; try { const results = await callWorker('executeHook', { hook, context, plugins: pluginList, permissions: pluginList.flatMap(p => p.permissions) }); return results; } catch (err) { console.error('Hook ' + hook + ' execution failed:', err); return pluginList.map(p => ({ plugin: p.name, success: false, error: err.message })); } } // Convenience methods for specific hooks export async function onFileRender(vault, fileContent, filePath) { const results = await executeHook(vault, 'onFileRender', { fileContent, filePath, vault }); let replaced = null; const append = []; for (const r of results) { if (!r.success) continue; const d = r.data; if (typeof d === 'string') { replaced = d; } else if (d && typeof d === 'object') { if (typeof d.content === 'string') replaced = d.content; if (typeof d.append === 'string') append.push(d.append); if (typeof d.html === 'string') append.push(d.html); } } return { replace: replaced, append }; } export async function onSearchFilter(vault, query, results) { const hookResults = await executeHook(vault, 'onSearchFilter', { query, results, vault }); for (const r of hookResults) { if (r.success && Array.isArray(r.data)) return r.data; } return results; } export async function onEditorAction(vault, action, editorState) { const results = await executeHook(vault, 'onEditorAction', { action, editorState, vault }); return results.some(r => r.success && r.data?.handled === true); } export async function onSidebarItem(vault, itemType, itemPath) { const results = await executeHook(vault, 'onSidebarItem', { itemType, itemPath, vault }); const items = []; for (const r of results) { if (r.success && Array.isArray(r.data)) items.push(...r.data); } return items; } export async function onFileCreate(vault, filePath, content) { await executeHook(vault, 'onFileCreate', { filePath, content, vault }); } export async function onFileDelete(vault, filePath) { const results = await executeHook(vault, 'onFileDelete', { filePath, vault }); return results.every(r => r.success && r.data?.allow !== false); } export async function onVaultMount(vault) { await executeHook(vault, 'onVaultMount', { vault }); } // ── UI: Plugins Settings Page ──────────────────────────────────────────── export async function renderPluginsSettings(vault, container) { const plugins = await loadPlugins(vault); container.innerHTML = '
' + 'Extend ObsiGate with custom renderers, search filters, and editor actions.' + '
' + '' + JSON.stringify(template.manifest, null, 2) + '' + '
' + template.code + '' + '
' + escapeHtml(code) + '' + '