Files
ObsiGate/frontend/js/plugins.js
T

569 lines
25 KiB
JavaScript

/* 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 =
'<div class="plugins-settings">' +
'<div class="plugins-header">' +
'<h2 data-i18n="plugins.title">🧩 Plugins</h2>' +
'<p class="config-description" data-i18n="plugins.description">' +
'Extend ObsiGate with custom renderers, search filters, and editor actions.' +
'</p>' +
'</div>' +
'<div class="plugins-toolbar">' +
'<button class="btn btn-primary" id="plugin-install-btn" data-i18n="plugins.install">Install Plugin</button>' +
'<button class="btn btn-secondary" id="plugin-template-btn" data-i18n="plugins.template">View Template</button>' +
'</div>' +
'<div id="plugins-list" class="plugins-list">' +
(plugins.length === 0
? '<div class="config-description" data-i18n="plugins.none">No plugins installed.</div>'
: plugins.map(p =>
'<div class="plugin-item ' + (p.enabled ? '' : 'disabled') + '" data-name="' + p.name + '">' +
'<div class="plugin-info">' +
'<div class="plugin-name">' +
'<strong>' + p.name + '</strong> v' + p.version +
'<span class="plugin-author">by ' + p.author + '</span>' +
'</div>' +
'<div class="plugin-description">' + p.description + '</div>' +
'<div class="plugin-meta">' +
'<span class="plugin-hooks">' + (p.hooks?.join(', ') || 'no hooks') + '</span>' +
'<span class="plugin-perms">' + (p.permissions?.join(', ') || 'no permissions') + '</span>' +
'</div>' +
'</div>' +
'<div class="plugin-actions">' +
'<button class="btn btn-sm ' + (p.enabled ? 'btn-warning' : 'btn-success') + '"' +
' data-action="' + (p.enabled ? 'disable' : 'enable') + '"' +
' data-name="' + p.name + '"' +
' data-i18n="' + (p.enabled ? 'plugins.disable' : 'plugins.enable') + '">' +
(p.enabled ? 'Disable' : 'Enable') +
'</button>' +
'<button class="btn btn-sm btn-secondary" data-action="code" data-name="' + p.name + '" data-i18n="plugins.view_code">Code</button>' +
'<button class="btn btn-sm btn-danger" data-action="uninstall" data-name="' + p.name + '" data-i18n="plugins.uninstall">Uninstall</button>' +
'</div>' +
'</div>'
).join('')) +
'</div>' +
'</div>';
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 =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.install">Install Plugin</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest (plugin.json)</label>' +
'<textarea id="plugin-manifest-json" class="modal-textarea" rows="15" spellcheck="false"' +
' placeholder=\'{"name":"my-plugin","version":"1.0.0","description":"...","author":"You","main":"index.js","hooks":{"onFileRender":"renderFile"},"permissions":["read_files"]}\'></textarea>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Plugin Code (main file)</label>' +
'<textarea id="plugin-code" class="modal-textarea" rows="20" spellcheck="false"' +
' placeholder="// Your plugin code here\\nexport async function renderFile(context) { ... }"></textarea>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-secondary modal-cancel" data-i18n="common.cancel">Cancel</button>' +
'<button class="btn btn-primary" id="plugin-install-confirm" data-i18n="plugins.install">Install</button>' +
'</div>' +
'</div>';
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 =
'<div class="modal" style="max-width: 700px;">' +
'<div class="modal-header">' +
'<h3 data-i18n="plugins.template">Plugin Template</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="form-group">' +
'<label data-i18n="plugins.manifest_json">Manifest</label>' +
'<pre class="code-block" style="max-height: 200px; overflow: auto;">' + JSON.stringify(template.manifest, null, 2) + '</pre>' +
'</div>' +
'<div class="form-group">' +
'<label data-i18n="plugins.code">Code</label>' +
'<pre class="code-block" style="max-height: 300px; overflow: auto;">' + template.code + '</pre>' +
'</div>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
'<button class="btn btn-secondary btn-copy-template">Copy to Clipboard</button>' +
'</div>' +
'</div>';
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 =
'<div class="modal" style="max-width: 800px;">' +
'<div class="modal-header">' +
'<h3>' + name + ' — Source Code</h3>' +
'<button class="modal-close" data-i18n="common.close">✕</button>' +
'</div>' +
'<div class="modal-body">' +
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
'</div>' +
'<div class="modal-footer">' +
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
'</div>' +
'</div>';
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, '>').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);
}
}