Add plugin system with sample plugin and settings UI
Introduces a plugin architecture, including a PluginManager, plugin loading in main and renderer processes, and a sample plugin demonstrating menu, IPC, and context menu contributions. Adds a Plugins tab to the settings UI for managing plugins (enable/disable, reload), and updates preload.js to load renderer preloads from plugins. Documentation for plugin development is included in README-PLUGINS.md.
This commit is contained in:
@@ -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: `<app>/plugins/<plugin-id>/`
|
||||||
|
- User folder: `%APPDATA%/Nebula/plugins/<plugin-id>/` (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:<id>]` 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
|
||||||
@@ -6,11 +6,13 @@ const os = require('os');
|
|||||||
const PerformanceMonitor = require('./performance-monitor');
|
const PerformanceMonitor = require('./performance-monitor');
|
||||||
const GPUFallback = require('./gpu-fallback');
|
const GPUFallback = require('./gpu-fallback');
|
||||||
const GPUConfig = require('./gpu-config');
|
const GPUConfig = require('./gpu-config');
|
||||||
|
const PluginManager = require('./plugin-manager');
|
||||||
|
|
||||||
// Initialize performance monitoring and GPU management
|
// Initialize performance monitoring and GPU management
|
||||||
const perfMonitor = new PerformanceMonitor();
|
const perfMonitor = new PerformanceMonitor();
|
||||||
const gpuFallback = new GPUFallback();
|
const gpuFallback = new GPUFallback();
|
||||||
const gpuConfig = new GPUConfig();
|
const gpuConfig = new GPUConfig();
|
||||||
|
const pluginManager = new PluginManager();
|
||||||
|
|
||||||
// Try to enable WebAuthn/platform authenticator features early.
|
// Try to enable WebAuthn/platform authenticator features early.
|
||||||
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
// This helps Chromium expose platform authenticators (Touch ID / built-in) where supported.
|
||||||
@@ -241,6 +243,9 @@ function createWindow(startUrl) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('WebAuthn diagnostic injection skipped:', 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
|
// Renderer manages history; no main-process recording here
|
||||||
@@ -304,6 +309,14 @@ function configureSessionsAsync() {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
createWindow();
|
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');
|
console.log('[Startup] createWindow invoked in', (performance.now() - t0).toFixed(1), 'ms after app.whenReady');
|
||||||
|
|
||||||
// Handle GPU process crashes (still register early)
|
// Handle GPU process crashes (still register early)
|
||||||
@@ -323,6 +336,10 @@ app.whenReady().then(() => {
|
|||||||
const defSes = session.defaultSession;
|
const defSes = session.defaultSession;
|
||||||
if (mainSes) registerDownloadHandling(mainSes);
|
if (mainSes) registerDownloadHandling(mainSes);
|
||||||
if (defSes && defSes !== mainSes) registerDownloadHandling(defSes);
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to register download handlers:', 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);
|
const win = BrowserWindow.fromWebContents(embedder);
|
||||||
if (win) menu.popup({ window: win });
|
if (win) menu.popup({ window: win });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -763,6 +782,24 @@ ipcMain.handle('show-context-menu', (event, params = {}) => {
|
|||||||
buildAndShowContextMenu(event.sender, 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)
|
// Automatic native context menu for any webContents (windows + webviews)
|
||||||
app.on('web-contents-created', (event, contents) => {
|
app.on('web-contents-created', (event, contents) => {
|
||||||
contents.on('context-menu', (e, params) => {
|
contents.on('context-menu', (e, params) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
});
|
||||||
+23
@@ -131,3 +131,26 @@ contextBridge.exposeInMainWorld('downloadsAPI', {
|
|||||||
onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)),
|
onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)),
|
||||||
onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler)
|
onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1101,6 +1101,15 @@ function updateZoomUI() {
|
|||||||
function zoomIn() { ipcRenderer.invoke('zoom-in').then(updateZoomUI); }
|
function zoomIn() { ipcRenderer.invoke('zoom-in').then(updateZoomUI); }
|
||||||
function zoomOut() { ipcRenderer.invoke('zoom-out').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)
|
// Utility: close the menu when interacting with a given element (e.g., webview)
|
||||||
function attachCloseMenuOnInteract(el) {
|
function attachCloseMenuOnInteract(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -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 {
|
:root {
|
||||||
--bg: #121418;
|
--bg: #121418;
|
||||||
--dark-blue: #0B1C2B;
|
--dark-blue: #0B1C2B;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<button class="tab-link active" role="tab" aria-selected="true" aria-controls="panel-general" id="tab-general" data-tab="general">General</button>
|
<button class="tab-link active" role="tab" aria-selected="true" aria-controls="panel-general" id="tab-general" data-tab="general">General</button>
|
||||||
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-appearance" id="tab-appearance" data-tab="appearance">Appearance</button>
|
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-appearance" id="tab-appearance" data-tab="appearance">Appearance</button>
|
||||||
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-history" id="tab-history" data-tab="history">History</button>
|
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-history" id="tab-history" data-tab="history">History</button>
|
||||||
|
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-plugins" id="tab-plugins" data-tab="plugins">Plugins</button>
|
||||||
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-about" id="tab-about" data-tab="about">About</button>
|
<button class="tab-link" role="tab" aria-selected="false" aria-controls="panel-about" id="tab-about" data-tab="about">About</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -196,6 +197,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Plugins Panel -->
|
||||||
|
<section class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins">
|
||||||
|
<h2>Plugins</h2>
|
||||||
|
<div class="customization-group">
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
<button id="plugins-reload-all">Reload Plugins</button>
|
||||||
|
<span class="note">Changes to renderer preloads may require app restart.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="customization-group">
|
||||||
|
<h3>Installed</h3>
|
||||||
|
<div id="plugins-list" class="plugins-list" role="list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- About Panel -->
|
<!-- About Panel -->
|
||||||
<section class="tab-panel" id="panel-about" role="tabpanel" aria-labelledby="tab-about">
|
<section class="tab-panel" id="panel-about" role="tabpanel" aria-labelledby="tab-about">
|
||||||
<h2>About</h2>
|
<h2>About</h2>
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<div class="plugin-meta">
|
||||||
|
<div class="plugin-title">${escapeHtml(p.name)} <span style="opacity:.7;font-weight:400">v${escapeHtml(p.version)}</span></div>
|
||||||
|
<div class="plugin-desc">${escapeHtml(p.description || '')}</div>
|
||||||
|
<div class="plugin-desc" style="opacity:.6; font-size:.85em;">${escapeHtml(p.dir)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-actions">
|
||||||
|
<label style="display:flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" class="plugin-enable" ${p.enabled ? 'checked' : ''}>
|
||||||
|
<span>${p.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</label>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="plugin-reload">Reload</button>
|
||||||
|
</div>`;
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user