diff --git a/README-PLUGINS.md b/README-PLUGINS.md new file mode 100644 index 0000000..b26c0e0 --- /dev/null +++ b/README-PLUGINS.md @@ -0,0 +1,92 @@ +# Nebula Plugins (Early Preview) + +This document explains how to build simple plugins for Nebula. The initial API is intentionally small and will grow with feedback. + +## Overview + +- Plugins live under either of these folders: + - App folder: `/plugins//` + - User folder: `%APPDATA%/Nebula/plugins//` (Windows) – preferred for user-installed plugins. +- Each plugin has a `plugin.json` manifest. Optional `main.js` runs in the main process. Optional `renderer-preload.js` runs in the renderer preload context and can expose safe APIs via `contextBridge`. +- Plugins are loaded on app start. Toggle a plugin by setting `"enabled": false` in its manifest. + +## Manifest (plugin.json) + +Example: + +{ + "id": "my-plugin", + "name": "My Plugin", + "version": "0.1.0", + "description": "What it does", + "main": "main.js", + "rendererPreload": "renderer-preload.js", + "enabled": true +} + +Fields: +- id: Unique id. Defaults to folder name if omitted. +- main: Optional entry for main process integration. +- rendererPreload: Optional file injected into the preload. Use it to expose limited APIs. +- enabled: Defaults to true. + +## Main process API (activate) + +If `main` is present, export an `activate(ctx)` function. The `ctx` contains: +- Electron: `app`, `BrowserWindow`, `ipcMain`, `session`, `Menu`, `dialog`, `shell` +- paths: `{ appPath, userData, pluginDir }` +- log/warn/error: prefix logs with your plugin id +- on(event, cb): subscribe to lifecycle events (experimental) +- registerIPC(channel, handler): quickly expose an `ipcMain.handle` +- registerWebRequest(filter, listener): attach `session.webRequest.onBeforeRequest` + +Example: + +module.exports.activate = (ctx) => { + ctx.log('hello'); + ctx.registerIPC('my-plugin:do', async (_evt, payload) => ({ ok: true })); + ctx.registerWebRequest({ urls: ['*://*/*'] }, (details) => ({ cancel: false })); +}; + +## Renderer preload API + +If `rendererPreload` is present, it will be `require()`-d from the app preload. You can use `contextBridge` to expose a safe surface to the page: + +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('myPlugin', { + hello: () => ipcRenderer.invoke('my-plugin:do'), +}); + +Your exposed API will be available on `window.myPlugin` in `renderer/` code (e.g., `script.js`). + +## Sample plugin + +A working sample is included at `plugins/sample-hello/`: +- Adds menu item "Say Hello (Sample Plugin)" under Help. +- Exposes `window.sampleHello.ping()` and `window.sampleHello.onHello(cb)`. + +Try it from the DevTools console: + +await window.sampleHello.ping(); +window.sampleHello.onHello((m) => console.log('got hello', m)); + +Click Help -> Say Hello (Sample Plugin) to see the message delivered to the page. + +## Loading order and safety + +- Plugins load after the app is ready. Renderer preloads run after Nebula's own preload has exposed its APIs. +- Context isolation stays enabled. Only data explicitly exposed via `contextBridge` is available to pages. +- Avoid long blocking work in plugin activation. + +## Debugging + +- See logs with a `[Plugin:]` prefix in the app console. +- Temporarily disable a plugin by setting `enabled: false` in `plugin.json`. + +## Roadmap + +This is a first pass. Planned next: +- Enable plugin settings UI +- Hot reload/reload button +- More lifecycle hooks (tab events, context menu contributions) +- Theming hooks diff --git a/main.js b/main.js index 7c6ee0a..e3072f4 100644 --- a/main.js +++ b/main.js @@ -6,11 +6,13 @@ const os = require('os'); const PerformanceMonitor = require('./performance-monitor'); const GPUFallback = require('./gpu-fallback'); const GPUConfig = require('./gpu-config'); +const PluginManager = require('./plugin-manager'); // Initialize performance monitoring and GPU management const perfMonitor = new PerformanceMonitor(); const gpuFallback = new GPUFallback(); const gpuConfig = new GPUConfig(); +const pluginManager = new PluginManager(); // Try to enable WebAuthn/platform authenticator features early. // This helps Chromium expose platform authenticators (Touch ID / built-in) where supported. @@ -241,6 +243,9 @@ function createWindow(startUrl) { } catch (e) { console.warn('WebAuthn diagnostic injection skipped:', e); } + + // After the first load, let plugins know a window exists + try { pluginManager.emit('window-created', win); } catch {} }); // Renderer manages history; no main-process recording here @@ -304,6 +309,14 @@ function configureSessionsAsync() { app.whenReady().then(() => { const t0 = performance.now(); createWindow(); + // Initialize user plugins after app ready + try { + pluginManager.ensureUserPluginsDir(); + pluginManager.loadAll(); + pluginManager.emit('app-ready'); + } catch (e) { + console.error('[Plugins] initialization error:', e); + } console.log('[Startup] createWindow invoked in', (performance.now() - t0).toFixed(1), 'ms after app.whenReady'); // Handle GPU process crashes (still register early) @@ -323,6 +336,10 @@ app.whenReady().then(() => { const defSes = session.defaultSession; if (mainSes) registerDownloadHandling(mainSes); if (defSes && defSes !== mainSes) registerDownloadHandling(defSes); + // Allow plugins to attach webRequest hooks + if (mainSes) pluginManager.applyWebRequestHandlers(mainSes); + if (defSes) pluginManager.applyWebRequestHandlers(defSes); + pluginManager.emit('session-configured', { mainSes, defSes }); } catch (e) { console.warn('Failed to register download handlers:', e); } @@ -750,7 +767,9 @@ function buildAndShowContextMenu(sender, params = {}) { } }); - const menu = Menu.buildFromTemplate(template); + // Allow plugins to customize/append context menu + try { pluginManager.applyContextMenuContrib(template, params, sender); } catch {} + const menu = Menu.buildFromTemplate(template); const win = BrowserWindow.fromWebContents(embedder); if (win) menu.popup({ window: win }); } catch (err) { @@ -763,6 +782,24 @@ ipcMain.handle('show-context-menu', (event, params = {}) => { buildAndShowContextMenu(event.sender, params); }); +// Plugins: expose renderer preload list +ipcMain.handle('plugins-get-renderer-preloads', () => { + try { return pluginManager.getRendererPreloads(); } catch { return []; } +}); + +// Plugins: management IPC for settings UI +ipcMain.handle('plugins-list', () => pluginManager.discoverPlugins()); +ipcMain.handle('plugins-set-enabled', async (_e, { id, enabled }) => { + const ok = await pluginManager.setEnabled(id, enabled); + // Reload to apply enable/disable (requires app reload for renderer preloads) + pluginManager.reload(); + return ok; +}); +ipcMain.handle('plugins-reload', (_e, { id } = {}) => { + pluginManager.reload(id); + return true; +}); + // Automatic native context menu for any webContents (windows + webviews) app.on('web-contents-created', (event, contents) => { contents.on('context-menu', (e, params) => { diff --git a/plugin-manager.js b/plugin-manager.js new file mode 100644 index 0000000..f0c9a05 --- /dev/null +++ b/plugin-manager.js @@ -0,0 +1,240 @@ +const fs = require('fs'); +const path = require('path'); +const { app, session, Menu, ipcMain, BrowserWindow, dialog, shell } = require('electron'); + +class PluginManager { + constructor() { + this.plugins = []; // { id, dir, manifest, mod, enabled } + this.rendererPreloads = []; // absolute file paths + this._listeners = { + 'app-ready': [], + 'window-created': [], + 'web-contents-created': [], + 'session-configured': [], + }; + this._webRequestHandlers = []; // { filter, listener } + this._contextMenuContribs = []; // [function(template, params, sender)] + } + + getPluginDirs() { + const appDir = path.join(app.getAppPath(), 'plugins'); + const userDir = path.join(app.getPath('userData'), 'plugins'); + return [appDir, userDir]; + } + + ensureUserPluginsDir() { + try { + const userDir = path.join(app.getPath('userData'), 'plugins'); + fs.mkdirSync(userDir, { recursive: true }); + return userDir; + } catch (_) { return null; } + } + + loadAll() { + this.plugins = []; + this.rendererPreloads = []; + const dirs = this.getPluginDirs(); + for (const root of dirs) { + let entries = []; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } + for (const ent of entries) { + if (!ent.isDirectory()) continue; + const dir = path.join(root, ent.name); + const manifestPath = path.join(dir, 'plugin.json'); + let manifest; + try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { continue; } + const enabled = manifest.enabled !== false; // default true + const id = manifest.id || ent.name; + const record = { id, dir, manifest, enabled, mod: null, mainPath: null }; + if (enabled) { + // Load main module if provided + if (manifest.main) { + const mainPath = path.join(dir, manifest.main); + try { + // eslint-disable-next-line import/no-dynamic-require, global-require + record.mod = require(mainPath); + record.mainPath = mainPath; + } catch (e) { + console.error(`[Plugins] Failed to load main for ${id}:`, e); + } + } + // Collect renderer preload if provided + if (manifest.rendererPreload) { + const rp = path.join(dir, manifest.rendererPreload); + try { + if (fs.existsSync(rp)) this.rendererPreloads.push(rp); + } catch {} + } + } + this.plugins.push(record); + } + } + // Activate plugins with activate(ctx) + for (const p of this.plugins) { + if (!p.enabled || !p.mod) continue; + try { + const ctx = this._buildContext(p); + if (typeof p.mod.activate === 'function') { + p.mod.activate(ctx); + } else if (typeof p.mod === 'function') { + // support default export as function(ctx) + p.mod(ctx); + } + } catch (e) { + console.error(`[Plugins] Error activating ${p.id}:`, e); + } + } + } + + _buildContext(plugin) { + const manager = this; + const logPrefix = `[Plugin:${plugin.id}]`; + return { + app, + BrowserWindow, + ipcMain, + session, + Menu, + dialog, + shell, + paths: { + appPath: app.getAppPath(), + userData: app.getPath('userData'), + pluginDir: plugin.dir, + }, + log: (...args) => console.log(logPrefix, ...args), + warn: (...args) => console.warn(logPrefix, ...args), + error: (...args) => console.error(logPrefix, ...args), + on: (evt, cb) => manager.on(evt, cb), + registerIPC: (channel, handler) => { + try { ipcMain.handle(channel, handler); } catch (e) { console.error(logPrefix, 'registerIPC failed', e); } + }, + registerWebRequest: (filter, listener) => { + try { manager._webRequestHandlers.push({ filter, listener }); } catch (e) { console.error(logPrefix, 'registerWebRequest failed', e); } + }, + contributeContextMenu: (contribFn) => { + try { manager._contextMenuContribs.push(contribFn); } catch (e) { console.error(logPrefix, 'contributeContextMenu failed', e); } + }, + }; + } + + getRendererPreloads() { + return Array.from(new Set(this.rendererPreloads)); + } + + on(evt, cb) { + if (!this._listeners[evt]) this._listeners[evt] = []; + this._listeners[evt].push(cb); + } + + emit(evt, ...args) { + const list = this._listeners[evt] || []; + for (const cb of list) { + try { cb(...args); } catch (e) { console.error('[Plugins] listener error for', evt, e); } + } + } + + applyWebRequestHandlers(ses) { + try { + if (!ses || !ses.webRequest) return; + for (const { filter, listener } of this._webRequestHandlers) { + try { + ses.webRequest.onBeforeRequest(filter || {}, (details, callback) => { + try { + const res = listener(details); + if (res && typeof res === 'object') callback(res); else callback({ cancel: false }); + } catch (e) { + console.error('[Plugins] webRequest handler error:', e); + callback({ cancel: false }); + } + }); + } catch (e) { + console.error('[Plugins] Failed to attach webRequest handler:', e); + } + } + } catch (e) { + console.error('[Plugins] applyWebRequestHandlers error:', e); + } + } + + applyContextMenuContrib(template, params, sender) { + try { + for (const fn of this._contextMenuContribs) { + try { fn(template, params, sender); } catch (e) { console.error('[Plugins] context menu contrib error:', e); } + } + } catch (e) { console.error('[Plugins] applyContextMenuContrib error:', e); } + } + + getPluginsInfo() { + return this.plugins.map(p => ({ + id: p.id, + name: p.manifest.name || p.id, + version: p.manifest.version || '0.0.0', + description: p.manifest.description || '', + enabled: !!p.enabled, + hasMain: !!p.manifest.main, + hasRendererPreload: !!p.manifest.rendererPreload, + dir: p.dir + })); + } + + // Fast discovery that does not activate plugins; always shows disabled items + discoverPlugins() { + const out = []; + for (const root of this.getPluginDirs()) { + let entries = []; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } + for (const ent of entries) { + if (!ent.isDirectory()) continue; + const dir = path.join(root, ent.name); + const manifestPath = path.join(dir, 'plugin.json'); + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + out.push({ + id: manifest.id || ent.name, + name: manifest.name || ent.name, + version: manifest.version || '0.0.0', + description: manifest.description || '', + enabled: manifest.enabled !== false, + hasMain: !!manifest.main, + hasRendererPreload: !!manifest.rendererPreload, + dir + }); + } catch {} + } + } + return out; + } + + async setEnabled(id, enabled) { + const p = this.plugins.find(x => x.id === id) || null; + if (!p) throw new Error('Plugin not found: ' + id); + const manifestPath = path.join(p.dir, 'plugin.json'); + let manifest; + try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (e) { throw new Error('Manifest read failed: ' + e.message); } + manifest.enabled = !!enabled; + await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); + return true; + } + + _clearRequireCache(p) { + try { + if (p && p.mainPath) { + const k = require.resolve(p.mainPath); + if (require.cache[k]) delete require.cache[k]; + } + } catch {} + } + + reload(id) { + if (id) { + const p = this.plugins.find(x => x.id === id); + if (p) this._clearRequireCache(p); + } else { + for (const p of this.plugins) this._clearRequireCache(p); + } + this.loadAll(); + } +} + +module.exports = PluginManager; diff --git a/plugins/sample-hello/main.js b/plugins/sample-hello/main.js new file mode 100644 index 0000000..65da9b8 --- /dev/null +++ b/plugins/sample-hello/main.js @@ -0,0 +1,43 @@ +// Sample main-process side of a plugin +module.exports.activate = function(ctx) { + ctx.log('activating'); + + // Add a simple menu item under Help + try { + const template = ctx.Menu.getApplicationMenu()?.items?.map(mi => mi); + if (template) { + const help = template.find(i => /help/i.test(i.label || '')); + const insertInto = help || template[template.length - 1]; + if (insertInto && insertInto.submenu) { + insertInto.submenu.append(new ctx.Menu.MenuItem({ + label: 'Say Hello (Sample Plugin)', + click: () => { + const win = ctx.BrowserWindow.getFocusedWindow(); + if (win) win.webContents.send('sample-hello', { msg: 'Hello from plugin!' }); + } + })); + ctx.Menu.setApplicationMenu(ctx.Menu.getApplicationMenu()); + } + } + } catch (e) { ctx.warn('menu injection skipped', e); } + + // Simple IPC example + ctx.registerIPC('sample-hello:ping', async () => ({ pong: true })); + + // Optional: intercept a request (no-op demo) + ctx.registerWebRequest({ urls: ['*://*/*'] }, (details) => { + // Could cancel or redirect here, but we let it pass through + return { cancel: false }; + }); + + // Context menu contribution example + ctx.contributeContextMenu?.((template, params, sender) => { + template.push({ type: 'separator' }); + template.push({ + label: 'Sample: Greet Console', + click: () => { + try { (sender.hostWebContents || sender).executeJavaScript("console.log('[Sample Plugin] Hello from context menu')"); } catch {} + } + }); + }); +}; diff --git a/plugins/sample-hello/plugin.json b/plugins/sample-hello/plugin.json new file mode 100644 index 0000000..0498d3e --- /dev/null +++ b/plugins/sample-hello/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "sample-hello", + "name": "Sample Hello Plugin", + "version": "0.1.0", + "description": "Demonstrates Nebula plugin basics: add menu item and renderer API.", + "main": "main.js", + "rendererPreload": "renderer-preload.js", + "enabled": false +} \ No newline at end of file diff --git a/plugins/sample-hello/renderer-preload.js b/plugins/sample-hello/renderer-preload.js new file mode 100644 index 0000000..36f2811 --- /dev/null +++ b/plugins/sample-hello/renderer-preload.js @@ -0,0 +1,8 @@ +// Renderer preload for sample plugin +// You can expose new APIs to the page +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('sampleHello', { + ping: () => ipcRenderer.invoke('sample-hello:ping'), + onHello: (handler) => ipcRenderer.on('sample-hello', (_e, payload) => handler(payload)) +}); diff --git a/preload.js b/preload.js index 6630c05..2ff1789 100644 --- a/preload.js +++ b/preload.js @@ -130,4 +130,27 @@ contextBridge.exposeInMainWorld('downloadsAPI', { onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)), onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)), onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler) -}); \ No newline at end of file +}); + +// ---------------------------------------- +// Plugin renderer preloads +// ---------------------------------------- +// We request a list of absolute file paths from main and require() them here. +// Each file can optionally call contextBridge.exposeInMainWorld to add APIs. +(async () => { + try { + const preloads = await ipcRenderer.invoke('plugins-get-renderer-preloads'); + if (Array.isArray(preloads)) { + for (const p of preloads) { + try { + // eslint-disable-next-line global-require, import/no-dynamic-require + require(p); + } catch (e) { + console.error('[Plugins] Failed to load renderer preload:', p, e); + } + } + } + } catch (e) { + console.warn('[Plugins] No renderer preloads:', e); + } +})(); \ No newline at end of file diff --git a/renderer/script.js b/renderer/script.js index c7ade16..b8b8bf0 100644 --- a/renderer/script.js +++ b/renderer/script.js @@ -1101,6 +1101,15 @@ function updateZoomUI() { function zoomIn() { ipcRenderer.invoke('zoom-in').then(updateZoomUI); } function zoomOut() { ipcRenderer.invoke('zoom-out').then(updateZoomUI); } +// Optional: sample plugin demo hook (safe if plugin missing) +try { + if (window.sampleHello && typeof window.sampleHello.onHello === 'function') { + window.sampleHello.onHello((payload) => { + console.log('[Sample Plugin] Hello message:', payload); + }); + } +} catch {} + // Utility: close the menu when interacting with a given element (e.g., webview) function attachCloseMenuOnInteract(el) { if (!el) return; diff --git a/renderer/settings.css b/renderer/settings.css index ac84193..70bc51b 100644 --- a/renderer/settings.css +++ b/renderer/settings.css @@ -1,3 +1,13 @@ +/* existing styles */ + +/* Plugins panel */ +.plugins-list { display: grid; gap: 10px; } +.plugin-item { display:flex; justify-content:space-between; align-items:center; border:1px solid rgba(255,255,255,0.12); padding:10px; border-radius:8px; background: rgba(255,255,255,0.03); } +.plugin-meta { display:flex; flex-direction:column; gap:2px; min-width:0; } +.plugin-title { font-weight:600; } +.plugin-desc { opacity:.8; font-size:.9em; } +.plugin-actions { display:flex; gap:8px; align-items:center; } +.plugin-actions .spacer { width:8px; } :root { --bg: #121418; --dark-blue: #0B1C2B; diff --git a/renderer/settings.html b/renderer/settings.html index 14f6a9c..4b2249f 100644 --- a/renderer/settings.html +++ b/renderer/settings.html @@ -15,6 +15,7 @@ + @@ -196,6 +197,21 @@ + +
+

Plugins

+
+
+ + Changes to renderer preloads may require app restart. +
+
+
+

Installed

+
+
+
+

About

diff --git a/renderer/settings.js b/renderer/settings.js index ccd894b..12b1ade 100644 --- a/renderer/settings.js +++ b/renderer/settings.js @@ -332,3 +332,90 @@ window.addEventListener('DOMContentLoaded', () => { }); } }); + +// ----------------------------- +// Plugins management (Settings) +// ----------------------------- +async function loadPluginsUI() { + const listEl = document.getElementById('plugins-list'); + const reloadAllBtn = document.getElementById('plugins-reload-all'); + if (!listEl) return; + // Load list + let items = []; + try { + items = (ipc ? await ipc.invoke('plugins-list') : []) || []; + } catch (e) { + console.warn('plugins-list failed', e); + } + listEl.innerHTML = ''; + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'plugin-item'; + empty.textContent = 'No plugins found'; + listEl.appendChild(empty); + } else { + for (const p of items) { + const row = document.createElement('div'); + row.className = 'plugin-item'; + row.setAttribute('role', 'listitem'); + row.innerHTML = ` +
+
${escapeHtml(p.name)} v${escapeHtml(p.version)}
+
${escapeHtml(p.description || '')}
+
${escapeHtml(p.dir)}
+
+
+ + + +
`; + // Wire actions + const enableInput = row.querySelector('input.plugin-enable'); + const labelSpan = row.querySelector('label span'); + enableInput.addEventListener('change', async () => { + const enabled = enableInput.checked; + try { + if (ipc) await ipc.invoke('plugins-set-enabled', { id: p.id, enabled }); + labelSpan.textContent = enabled ? 'Enabled' : 'Disabled'; + showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`); + } catch (e) { + console.error('Failed to toggle plugin', p.id, e); + enableInput.checked = !enabled; + labelSpan.textContent = enableInput.checked ? 'Enabled' : 'Disabled'; + showStatus('Failed updating plugin'); + } + }); + const reloadBtn = row.querySelector('button.plugin-reload'); + reloadBtn.addEventListener('click', async () => { + try { + if (ipc) await ipc.invoke('plugins-reload', { id: p.id }); + showStatus(`${p.name} reloaded.`); + } catch (e) { + console.error('Plugin reload failed', e); + showStatus('Reload failed'); + } + }); + listEl.appendChild(row); + } + } + if (reloadAllBtn) reloadAllBtn.onclick = async () => { + try { if (ipc) await ipc.invoke('plugins-reload', {}); showStatus('Plugins reloaded.'); } catch { showStatus('Reload failed'); } + }; +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); +} + +// Load when settings page shows Plugins tab for the first time +window.addEventListener('DOMContentLoaded', () => { + const tabBtn = document.getElementById('tab-plugins'); + if (!tabBtn) return; + let loaded = false; + const ensureLoad = () => { if (!loaded) { loaded = true; loadPluginsUI(); } }; + tabBtn.addEventListener('click', ensureLoad); + if (location.hash === '#plugins') ensureLoad(); +});