681b087f7a
Replaces all occurrences of the 'browser://' internal URL scheme with 'nebula://' across main process, plugin manager, and renderer files. This change standardizes the internal page scheme to 'nebula://' for improved clarity and branding.
286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { pathToFileURL } = require('url');
|
|
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.rendererPages = []; // { id, file, pluginId }
|
|
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 = [];
|
|
this.rendererPages = [];
|
|
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'));
|
|
// Normalize optional fields
|
|
const cats = manifest.categories;
|
|
if (typeof cats === 'string') manifest.categories = [cats];
|
|
else if (Array.isArray(cats)) manifest.categories = cats.filter(x => typeof x === 'string');
|
|
else if (cats == null) manifest.categories = [];
|
|
|
|
const au = manifest.authors;
|
|
if (typeof au === 'string') manifest.authors = [au];
|
|
else if (Array.isArray(au)) manifest.authors = au.filter(x => (typeof x === 'string') || (x && typeof x === 'object' && typeof x.name === 'string'));
|
|
else if (au == null) manifest.authors = [];
|
|
} 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); }
|
|
},
|
|
// Register a dedicated internal page (shown via nebula://<id>)
|
|
registerRendererPage: ({ id, html }) => {
|
|
try {
|
|
if (!id || !html) return;
|
|
let fileUrl = null;
|
|
try { fileUrl = pathToFileURL(html).href; } catch {}
|
|
manager.rendererPages.push({ id, file: html, fileUrl, pluginId: plugin.id });
|
|
console.log('[Plugins] Registered page:', id, '->', html, 'fileUrl:', fileUrl);
|
|
manager.log('registered page:', id, '->', html);
|
|
} catch (e) { manager.error('registerRendererPage failed', e); }
|
|
}
|
|
};
|
|
}
|
|
|
|
getRendererPreloads() {
|
|
return Array.from(new Set(this.rendererPreloads));
|
|
}
|
|
|
|
getRendererPages() {
|
|
// Return a shallow copy so callers can't mutate internal array
|
|
return this.rendererPages.map(p => ({ ...p }));
|
|
}
|
|
|
|
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 || '',
|
|
categories: Array.isArray(p.manifest.categories) ? p.manifest.categories : [],
|
|
authors: Array.isArray(p.manifest.authors)
|
|
? p.manifest.authors.map(x => (typeof x === 'string' ? x : (x && x.name) || '')).filter(Boolean)
|
|
: [],
|
|
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'));
|
|
const cats = manifest.categories;
|
|
const categories = typeof cats === 'string' ? [cats] : Array.isArray(cats) ? cats.filter(x => typeof x === 'string') : [];
|
|
const au = manifest.authors;
|
|
const authors = typeof au === 'string'
|
|
? [au]
|
|
: Array.isArray(au)
|
|
? au.map(x => (typeof x === 'string' ? x : (x && x.name) || null)).filter(Boolean)
|
|
: [];
|
|
out.push({
|
|
id: manifest.id || ent.name,
|
|
name: manifest.name || ent.name,
|
|
version: manifest.version || '0.0.0',
|
|
description: manifest.description || '',
|
|
categories,
|
|
authors,
|
|
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;
|