/* 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 = '
' + '
' + '

🧩 Plugins

' + '

' + 'Extend ObsiGate with custom renderers, search filters, and editor actions.' + '

' + '
' + '
' + '' + '' + '
' + '
' + (plugins.length === 0 ? '
No plugins installed.
' : plugins.map(p => '
' + '
' + '
' + '' + p.name + ' v' + p.version + 'by ' + p.author + '' + '
' + '
' + p.description + '
' + '
' + '' + (p.hooks?.join(', ') || 'no hooks') + '' + '' + (p.permissions?.join(', ') || 'no permissions') + '' + '
' + '
' + '
' + '' + '' + '' + '
' + '
' ).join('')) + '
' + '
'; container.querySelector('#plugin-install-btn')?.addEventListener('click', () => openInstallModal(vault)); container.querySelector('#plugin-template-btn')?.addEventListener('click', () => openTemplateModal()); container.querySelectorAll('.plugin-actions button').forEach(btn => { btn.addEventListener('click', async (e) => { const action = btn.dataset.action; const name = btn.dataset.name; try { if (action === 'enable') { await enablePlugin(vault, name); showToast(t('plugins.enabled', { name }), 'success'); } else if (action === 'disable') { await disablePlugin(vault, name); showToast(t('plugins.disabled', { name }), 'success'); } else if (action === 'uninstall') { if (confirm(t('plugins.confirm_uninstall', { name }))) { await uninstallPlugin(vault, name); showToast(t('plugins.uninstalled', { name }), 'success'); } } else if (action === 'code') { const code = await getPluginCode(vault, name); openCodeModal(name, code); } await renderPluginsSettings(vault, container); } catch (err) { showToast(err.message, 'error'); } }); }); safeCreateIcons(); } function openInstallModal(vault) { const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ''; document.body.appendChild(modal); modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove()); modal.querySelector('.modal-cancel')?.addEventListener('click', () => modal.remove()); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); modal.querySelector('#plugin-install-confirm')?.addEventListener('click', async () => { const manifestText = modal.querySelector('#plugin-manifest-json').value; const code = modal.querySelector('#plugin-code').value; try { const manifest = JSON.parse(manifestText); const validation = await validateManifest(manifest); if (!validation.valid) throw new Error(validation.error || 'Invalid manifest'); await installPlugin(vault, manifest, code); showToast(t('plugins.installed', { name: manifest.name }), 'success'); modal.remove(); const container = document.getElementById('cfg-plugins'); if (container) await renderPluginsSettings(vault, container); } catch (err) { showToast(err.message, 'error'); } }); } function openTemplateModal() { getPluginTemplate().then(template => { const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ''; document.body.appendChild(modal); modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove()); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove()); modal.querySelector('.btn-copy-template')?.addEventListener('click', () => { navigator.clipboard.writeText(JSON.stringify(template, null, 2)); showToast('Template copied to clipboard', 'success'); }); }); } function openCodeModal(name, code) { const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ''; document.body.appendChild(modal); modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove()); modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove()); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } function escapeHtml(text) { return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Auto-init when settings page loads ──────────────────────────────────── document.addEventListener('click', (e) => { const link = e.target.closest('[href="#cfg-plugins"]'); if (link) { setTimeout(() => { const container = document.getElementById('cfg-plugins'); if (container && !container.dataset.pluginsLoaded) { container.dataset.pluginsLoaded = 'true'; const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault; if (vault) renderPluginsSettings(vault, container); } }, 100); } }); // Export for other modules export const PluginManager = { loadPlugins, getCachedPlugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, getPluginCode, validateManifest, getPluginTemplate, executeHook, onFileRender, onSearchFilter, onEditorAction, onSidebarItem, onFileCreate, onFileDelete, onVaultMount, renderPluginsSettings, }; // Initialize plugins system export async function initPlugins() { // Pre-load plugins for current vault if available const vault = document.querySelector('[data-current-vault]')?.dataset.currentVault; if (vault) { await loadPlugins(vault); } }