/** * Renderer process entry point for the plugin-manager Electron test harness. * * Uses a dynamic import() to load the ESM @paarrot/plugin-manager dist, * then loads the example plugin from disk and demonstrates every API surface. */ (async () => { const testApi = window.electronTestApi; if (!testApi) { throw new Error('Electron test preload API is unavailable.'); } const { examplePluginCode, examplePluginPath, pluginManagerEntryUrl, } = testApi.getHarnessBootstrap(); const { PluginMarketplaceClient, PluginMarketplaceManager, PluginRegistry, createPluginContext, } = await import(pluginManagerEntryUrl); // --------------------------------------------------------------------------- // Module loader — supports both CJS (module.exports) and ESM (export default) // --------------------------------------------------------------------------- /** * Imports arbitrary module source through a blob URL so CSP can remain free * of unsafe-eval while still supporting test plugins loaded from strings. * @param {string} source * @returns {Promise} */ async function importModuleFromSource(source) { const blob = new Blob([source], { type: 'text/javascript' }); const blobUrl = URL.createObjectURL(blob); try { return await import(blobUrl); } finally { URL.revokeObjectURL(blobUrl); } } /** * Evaluates plugin source code and returns the raw module exports. * Tries CommonJS first by wrapping the code as ESM, then falls back to native * ESM import when the source already uses module syntax. * @param {string} code * @returns {Promise} */ async function evalPluginCode(code) { try { const commonJsModule = await importModuleFromSource( `const module = { exports: {} };\nconst exports = module.exports;\n${code}\nexport default module.exports;\n` ); const result = commonJsModule.default; if (result && typeof result === 'object') { return result; } } catch (err) { if (!(err instanceof SyntaxError)) { throw err; } } const esmModule = await importModuleFromSource(code); return esmModule.default ?? esmModule; } /** * Infers a plugin id from an index.js path without relying on Node path APIs. * @param {string} filePath * @returns {string} */ function inferPluginId(filePath) { const segments = filePath.split(/[\\/]+/).filter(Boolean); if (segments.length < 2) { return 'plugin'; } return segments[segments.length - 2]; } /** * Normalises a raw plugin module to our Plugin interface. * * Handles two formats: * - Native: `{ onLoad(ctx), onUnload() }` — used as-is * - Legacy: named exports `activate(ctx)` / `deactivate(ctx)` — wrapped into * onLoad/onUnload with a stub context that no-ops unsupported calls so the * plugin doesn't crash on missing methods (registerHook, runHook, getConfig). * * @param {object} raw Raw module exports * @returns {object} Plugin-interface-compatible object */ function normalisePlugin(raw) { if (typeof raw.onLoad === 'function') return raw; // already native if (typeof raw.activate === 'function') { return { name: raw.name ?? undefined, version: raw.version ?? undefined, onLoad: async (ctx) => { // Extend ctx with legacy methods so the plugin doesn't throw const legacyCtx = Object.assign(Object.create(ctx), { registerHook: (name, fn) => ctx.log(`[compat] registerHook("${name}") — not supported`), runHook: (name, data) => { ctx.log(`[compat] runHook("${name}")`); return Promise.resolve([]); }, getConfig: (key) => ctx.settings.get(key), on: (event, handler) => ctx.events.on(event, handler), }); await raw.activate(legacyCtx); }, onUnload: raw.deactivate ? async (ctx) => raw.deactivate(ctx) : undefined, exports: raw, }; } throw new Error('Plugin does not export onLoad or activate — cannot load'); } /** * Fetches plugin JS source from a URL. * If the URL points to a ZIP archive, derives the raw index.js URL from the * repository field in the plugin metadata (Gitea/GitHub raw file convention). * * @param {string} downloadUrl * @param {string} [repositoryUrl] Fallback repo root for ZIP-only entries * @returns {Promise} JS source code */ async function fetchPluginCode(downloadUrl, repositoryUrl) { const isZip = /\.zip($|\?)/.test(downloadUrl); if (!isZip) { const res = await fetch(downloadUrl); if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${downloadUrl}`); return res.text(); } // ZIP URL — try to derive a raw index.js URL from the repository root if (!repositoryUrl) throw new Error('Download URL is a ZIP and no repository URL is available'); // Supports Gitea (/raw/branch/main/) and GitHub (/raw/main/) conventions const giteaRaw = `${repositoryUrl.replace(/\/$/, '')}/raw/branch/main/index.js`; const res = await fetch(giteaRaw); if (!res.ok) { const ghRaw = `${repositoryUrl.replace(/\/$/, '')}/raw/main/index.js`; const res2 = await fetch(ghRaw); if (!res2.ok) throw new Error(`Could not fetch index.js from ${repositoryUrl} (tried Gitea and GitHub raw URL patterns)`); return res2.text(); } return res.text(); } // --------------------------------------------------------------------------- // Logging helper — writes to the on-screen log panel // --------------------------------------------------------------------------- const logEl = document.getElementById('log'); /** @param {'log'|'info'|'success'|'error'|'warn'} type */ function addLog(message, type = 'log') { const entry = document.createElement('div'); entry.className = `log-entry log-${type}`; entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; logEl.appendChild(entry); logEl.scrollTop = logEl.scrollHeight; } // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- const registry = new PluginRegistry({ storage: localStorage, onThemeRegistered: (themeId, _className, css) => { const el = document.createElement('style'); el.id = `plugin-theme-${themeId}`; el.textContent = css; document.head.appendChild(el); addLog(`Theme registered: ${themeId}`, 'info'); }, onThemeUnregistered: (themeId) => { document.getElementById(`plugin-theme-${themeId}`)?.remove(); addLog(`Theme unregistered: ${themeId}`, 'info'); }, }); const marketplaceClient = new PluginMarketplaceClient({ indexUrl: 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json', baseUrl: 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/', }); // --------------------------------------------------------------------------- // Load the example plugin from disk (CJS eval — same pattern as cinny) // --------------------------------------------------------------------------- const pluginPath = examplePluginPath; const plugin = await evalPluginCode(examplePluginCode); // --------------------------------------------------------------------------- // Simple in-process event emitter (stands in for a real client like Matrix) // --------------------------------------------------------------------------- const eventEmitter = { _handlers: {}, on(event, handler) { (this._handlers[event] ??= []).push(handler); }, off(event, handler) { this._handlers[event] = (this._handlers[event] ?? []).filter(h => h !== handler); }, emit(event, data) { (this._handlers[event] ?? []).forEach(h => h(data)); }, }; // --------------------------------------------------------------------------- // Register + load // --------------------------------------------------------------------------- /** * Persistent record of every plugin we have ever seen, keyed by plugin id. * Survives unload so the Installed list can show a Reload button. * * @type {Map} */ const knownPlugins = new Map(); /** * Creates a plugin context wired to the shared registry and event emitter, * registers and loads the plugin, then records it in knownPlugins. * @param {string} id * @param {object} pluginModule * @param {{ * filePath?: string, * downloadUrl?: string, * name?: string, * version?: string, * description?: string, * author?: string, * repository?: string, * thumbnail?: string, * homepage?: string, * tags?: string[], * addedDate?: string, * }} [source] */ async function registerAndLoad(id, pluginModule, source = {}) { const ctx = createPluginContext( { pluginId: id, eventClient: eventEmitter, onNotify: (opts) => addLog(`[${opts.type?.toUpperCase() ?? 'NOTIFY'}] ${opts.title}: ${opts.body}`, 'info'), }, registry ); ctx.log = (...args) => { console.log(`[Plugin ${id}]`, ...args); registry.addLog(id, 'log', args); addLog(args.map(String).join(' '), 'log'); }; registry.registerPlugin(id, pluginModule, ctx); await pluginModule.onLoad(ctx); knownPlugins.set(id, { name: source.name ?? pluginModule.name ?? id, version: source.version ?? pluginModule.version ?? '?', pluginModule, ...source, }); } const marketplace = new PluginMarketplaceManager({ client: marketplaceClient, storage: localStorage, registry, host: { listInstalledPlugins: async () => Array.from(knownPlugins.entries()).map(([id, known]) => ({ id, name: known.name ?? id, version: known.version ?? '', description: known.description ?? '', author: known.author ?? '', repository: known.repository ?? '', thumbnail: known.thumbnail ?? '', downloadUrl: known.downloadUrl ?? '', homepage: known.homepage ?? '', tags: Array.isArray(known.tags) ? known.tags : [], addedDate: known.addedDate ?? '', })), installPlugin: async (pluginMetadata) => { if (!pluginMetadata.downloadUrl) { throw new Error(`No download URL for plugin ${pluginMetadata.id}`); } addLog(`Downloading plugin ${pluginMetadata.id}…`, 'info'); const code = await fetchPluginCode(pluginMetadata.downloadUrl, pluginMetadata.repository); const raw = await evalPluginCode(code); const pluginModule = normalisePlugin(raw); await registerAndLoad(pluginMetadata.id, pluginModule, { downloadUrl: pluginMetadata.downloadUrl, name: pluginMetadata.name, version: pluginMetadata.version, description: pluginMetadata.description, author: pluginMetadata.author, repository: pluginMetadata.repository, thumbnail: pluginMetadata.thumbnail, homepage: pluginMetadata.homepage, tags: pluginMetadata.tags, addedDate: pluginMetadata.addedDate, }); }, uninstallPlugin: async (pluginId) => { knownPlugins.delete(pluginId); }, }, }); await registerAndLoad('example-plugin', plugin, { filePath: pluginPath, }); document.getElementById('status-badge').textContent = 'example-plugin loaded'; document.getElementById('status-badge').classList.add('loaded'); addLog('example-plugin loaded successfully', 'success'); // --------------------------------------------------------------------------- // Plugin Manager panel // --------------------------------------------------------------------------- /** Renders the installed-plugins card list, including unloaded entries. */ function refreshPluginList() { const listEl = document.getElementById('plugin-list'); const loadedMap = new Map(registry.getRegisteredPlugins().map(p => [p.id, p])); if (!knownPlugins.size) { listEl.innerHTML = '
No plugins loaded
'; return; } listEl.innerHTML = Array.from(knownPlugins.entries()).map(([id, known]) => { const live = loadedMap.get(id); const loaded = !!live; const statusBadge = loaded ? 'loaded' : 'unloaded'; const featureBadges = live ? ` ${live.commandCount ? `${live.commandCount} cmd${live.commandCount !== 1 ? 's' : ''}` : ''} ${live.interceptorCount ? `${live.interceptorCount} interceptor${live.interceptorCount !== 1 ? 's' : ''}` : ''} ${live.themeCount ? `${live.themeCount} theme${live.themeCount !== 1 ? 's' : ''}` : ''} ${live.hasSettings ? 'has settings' : ''} ` : ''; const actionBtn = loaded ? `` : ``; return `
${known.name} v${known.version}
${id}
${statusBadge}${featureBadges}
${actionBtn}
`; }).join(''); listEl.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', async () => { const id = btn.dataset.id; const action = btn.dataset.action; if (action === 'unload') { await registry.unregisterPlugin(id); addLog(`Plugin unloaded: ${id}`, 'warn'); } else if (action === 'reload') { const known = knownPlugins.get(id); if (!known) return; try { await registerAndLoad(id, known.pluginModule, { filePath: known.filePath, downloadUrl: known.downloadUrl, }); addLog(`Plugin reloaded: ${id}`, 'success'); } catch (err) { addLog(`Failed to reload ${id}: ${err.message}`, 'error'); } } refreshPluginList(); refreshCommandList(); renderSettings(); }); }); } /** * Loads a plugin from an arbitrary file path entered in the Load input. * Supports CJS modules (same eval pattern as cinny-desktop). * @param {string} filePath */ async function loadPluginFromPath(filePath) { try { const code = await testApi.readTextFile(filePath); const raw = await evalPluginCode(code); const p = normalisePlugin(raw); if (!p || typeof p.onLoad !== 'function') { throw new Error('File does not export a valid plugin (needs an onLoad function)'); } const id = p.id ?? inferPluginId(filePath); if (knownPlugins.has(id) && registry.getRegisteredPlugins().some(r => r.id === id)) { throw new Error(`Plugin with id "${id}" is already loaded`); } await registerAndLoad(id, p, { filePath }); addLog(`Plugin loaded from path: ${id}`, 'success'); refreshPluginList(); refreshCommandList(); renderSettings(); } catch (err) { addLog(`Failed to load plugin: ${err.message}`, 'error'); } } document.getElementById('load-plugin-btn').addEventListener('click', () => { const filePath = document.getElementById('load-plugin-path').value.trim(); if (!filePath) { addLog('Enter a path to a plugin index.js', 'error'); return; } loadPluginFromPath(filePath); document.getElementById('load-plugin-path').value = ''; }); document.getElementById('load-plugin-path').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('load-plugin-btn').click(); }); refreshPluginList(); // --------------------------------------------------------------------------- // Plugin Manager — tab switching // --------------------------------------------------------------------------- const tabInstalled = document.getElementById('tab-installed'); const tabDirectory = document.getElementById('tab-directory'); const paneInstalled = document.getElementById('pane-installed'); const paneDirectory = document.getElementById('pane-directory'); tabInstalled.addEventListener('click', () => { tabInstalled.classList.add('active'); tabDirectory.classList.remove('active'); paneInstalled.classList.add('active'); paneDirectory.classList.remove('active'); }); tabDirectory.addEventListener('click', () => { tabDirectory.classList.add('active'); tabInstalled.classList.remove('active'); paneDirectory.classList.add('active'); paneInstalled.classList.remove('active'); }); // --------------------------------------------------------------------------- // Plugin Directory — fetch from remote index // --------------------------------------------------------------------------- let directoryPlugins = []; const dirStatusEl = document.getElementById('dir-status'); const dirListEl = document.getElementById('dir-list'); const dirSearchEl = document.getElementById('dir-search'); /** Renders marketplace cards, optionally filtered by a search query. */ function renderDirectoryList(query = '') { const q = query.toLowerCase(); const filtered = directoryPlugins.filter(p => !q || p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q) || (p.author ?? '').toLowerCase().includes(q) || (p.tags ?? []).some(t => t.toLowerCase().includes(q)) ); if (!filtered.length) { dirListEl.innerHTML = '
No plugins found.
'; return; } const loadedIds = new Set(registry.getRegisteredPlugins().map(p => p.id)); dirListEl.innerHTML = filtered.map(p => { const isLoaded = loadedIds.has(p.id); const initial = (p.name ?? '?').charAt(0).toUpperCase(); return `
${p.name}
${p.name}
by ${p.author ?? 'unknown'}  ·  v${p.version ?? '?'}
${p.description ?? ''}
`; }).join(''); dirListEl.querySelectorAll('.market-install-btn:not([disabled])').forEach(btn => { btn.addEventListener('click', () => downloadAndLoadPlugin(btn.dataset.id)); }); } /** Downloads a plugin's index.js from the marketplace and loads it into the registry. */ async function downloadAndLoadPlugin(pluginId) { const pluginMetadata = directoryPlugins.find((entry) => entry.id === pluginId); if (!pluginMetadata) { addLog(`Plugin ${pluginId} is not present in directory cache`, 'error'); return; } try { await marketplace.installPlugin(pluginMetadata); addLog(`Plugin installed from directory: ${pluginId}`, 'success'); refreshPluginList(); refreshCommandList(); renderSettings(); renderDirectoryList(dirSearchEl.value); } catch (err) { addLog(`Failed to install ${pluginId}: ${err.message}`, 'error'); } } /** Fetches the plugin index and all plugin metadata from the remote directory. */ async function fetchDirectory() { dirStatusEl.textContent = 'Fetching plugin directory…'; dirListEl.innerHTML = ''; try { directoryPlugins = await marketplace.fetchMarketplacePlugins(); dirStatusEl.textContent = `${directoryPlugins.length} plugin${directoryPlugins.length !== 1 ? 's' : ''} in directory.`; addLog(`Plugin directory fetched: ${directoryPlugins.length} entries`, 'success'); renderDirectoryList(dirSearchEl.value); } catch (err) { dirStatusEl.textContent = `Failed to fetch directory: ${err.message}`; addLog(`Directory fetch failed: ${err.message}`, 'error'); } } document.getElementById('dir-refresh').addEventListener('click', fetchDirectory); dirSearchEl.addEventListener('input', () => renderDirectoryList(dirSearchEl.value)); // Populate registered command list function refreshCommandList() { const cmdListEl = document.getElementById('cmd-list'); cmdListEl.innerHTML = registry .getCommands() .map(c => `
  • /${c.name} — ${c.command.description ?? ''}
  • `) .join('') || '
  • No commands registered
  • '; } const cmdListEl = document.getElementById('cmd-list'); cmdListEl.innerHTML = registry .getCommands() .map(c => `
  • /${c.name} — ${c.command.description ?? ''}
  • `) .join(''); // Render settings form renderSettings(); // --------------------------------------------------------------------------- // UI — Commands // --------------------------------------------------------------------------- const cmdInput = document.getElementById('cmd-input'); const cmdResult = document.getElementById('cmd-result'); document.getElementById('cmd-run').addEventListener('click', async () => { const raw = cmdInput.value.trim(); if (!raw.startsWith('/')) { addLog('Commands must start with /', 'error'); return; } const [name, ...rest] = raw.slice(1).split(' '); try { const result = await registry.executeCommand(name, rest.join(' ')); cmdResult.textContent = result ?? '(no output)'; addLog(`/${name}: ${result ?? '(no output)'}`, 'success'); } catch (err) { cmdResult.textContent = err.message; addLog(err.message, 'error'); } cmdInput.value = ''; }); cmdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('cmd-run').click(); }); // --------------------------------------------------------------------------- // UI — Message Interceptor // --------------------------------------------------------------------------- document.getElementById('msg-send').addEventListener('click', async () => { const content = document.getElementById('msg-input').value; const msg = { content, roomId: 'test-room', eventType: 'm.room.message' }; const processed = await registry.processBeforeSend(msg); document.getElementById('msg-result').textContent = `Original: "${content}"\nProcessed: "${processed.content}"`; addLog(`Intercepted: "${content}" → "${processed.content}"`, 'log'); }); // --------------------------------------------------------------------------- // UI — Theme // --------------------------------------------------------------------------- document.getElementById('theme-apply').addEventListener('click', () => { const themes = registry.getPluginThemes(); if (!themes.length) { addLog('No plugin themes registered', 'error'); return; } const theme = themes[0]; document.body.className = theme.className; addLog(`Applied theme: ${theme.name}`, 'success'); }); document.getElementById('theme-reset').addEventListener('click', () => { document.body.className = ''; addLog('Theme reset', 'log'); }); // --------------------------------------------------------------------------- // UI — Custom Events // --------------------------------------------------------------------------- document.getElementById('event-fire').addEventListener('click', () => { const payload = { from: 'UI button', timestamp: Date.now() }; eventEmitter.emit('test-event', payload); addLog(`Fired test-event with payload: ${JSON.stringify(payload)}`, 'log'); }); // --------------------------------------------------------------------------- // Settings renderer // --------------------------------------------------------------------------- function renderSettings() { const schema = registry.getPluginSettingsSchema('example-plugin'); if (!schema) return; const container = document.getElementById('settings-container'); container.innerHTML = ''; for (const [key, def] of Object.entries(schema)) { const value = registry.getPluginSetting('example-plugin', key); const row = document.createElement('div'); row.className = 'setting-row'; const label = document.createElement('label'); label.textContent = def.label ?? key; row.appendChild(label); let input; if (def.type === 'boolean') { input = document.createElement('input'); input.type = 'checkbox'; input.checked = !!value; input.addEventListener('change', () => { registry.setPluginSetting('example-plugin', key, input.checked); addLog(`Setting "${key}" = ${input.checked}`, 'log'); }); } else if (def.type === 'select') { input = document.createElement('select'); (def.options ?? []).forEach((opt) => { const o = document.createElement('option'); o.value = opt.value; o.textContent = opt.label; if (opt.value === value) o.selected = true; input.appendChild(o); }); input.addEventListener('change', () => { registry.setPluginSetting('example-plugin', key, input.value); addLog(`Setting "${key}" = ${input.value}`, 'log'); }); } else { input = document.createElement('input'); input.type = 'text'; input.value = value ?? ''; input.addEventListener('change', () => { registry.setPluginSetting('example-plugin', key, input.value); addLog(`Setting "${key}" = "${input.value}"`, 'log'); }); } row.appendChild(input); container.appendChild(row); } } })();