Add unpacked extension support

Adds an extension manager that loads unpacked extensions from the user data extensions directory, persists enabled state, and injects matching content scripts on page load. The settings Plugins page now shows installed extensions, supports reload/toggle actions, and includes an example Hello Nebula extension.
This commit is contained in:
Andrew Zambazos
2026-07-12 20:21:32 +12:00
parent 487c5fc1ae
commit 924529807f
15 changed files with 861 additions and 16 deletions
+64 -14
View File
@@ -919,22 +919,42 @@ window.addEventListener('DOMContentLoaded', () => {
// -----------------------------
// Plugins management (Settings)
// -----------------------------
function postExtensionCommand(command, payload) {
if (hasNebulaNativeBridge()) {
window.nebulaNative.postMessage(command, payload == null ? '' : String(payload));
return true;
}
return false;
}
async function loadPluginsUI() {
const listEl = document.getElementById('plugins-list');
const reloadAllBtn = document.getElementById('plugins-reload-all');
const hintEl = document.getElementById('plugins-install-hint');
if (!listEl) return;
// Load list
let items = [];
try {
items = (ipc ? await ipc.invoke('plugins-list') : []) || [];
} catch (e) {
console.warn('plugins-list failed', e);
if (hintEl && window.__nebulaExtensionsPath) {
hintEl.innerHTML =
'Install extensions by copying an unpacked folder into:<br><code style="word-break:break-all;">' +
escapeHtml(window.__nebulaExtensionsPath) +
'</code><br>Each folder needs a <code>manifest.json</code>. Then click Reload Plugins.';
}
let items = Array.isArray(window.__nebulaExtensions) ? window.__nebulaExtensions : null;
if (!items) {
try {
items = (ipc ? await ipc.invoke('plugins-list') : []) || [];
} catch (e) {
console.warn('plugins-list failed', e);
items = [];
}
}
listEl.innerHTML = '';
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'plugin-item';
empty.textContent = 'No plugins found';
empty.textContent = 'No plugins found. Drop an extension folder into the path above.';
listEl.appendChild(empty);
} else {
for (const p of items) {
@@ -942,6 +962,9 @@ async function loadPluginsUI() {
const authors = Array.isArray(p.authors) ? p.authors.filter(x => x && typeof x === 'string') : [];
const tagsHtml = categories.length ? `<div class="plugin-tags">${categories.map(c => `<span class=\"plugin-tag\">${escapeHtml(c)}</span>`).join('')}</div>` : '';
const authorsHtml = authors.length ? `<div class=\"plugin-authors\"><span class=\"muted\">Authors:</span> ${authors.map(a => `<span class=\"plugin-author\">${escapeHtml(a)}</span>`).join(', ')}</div>` : '';
const errorHtml = p.loadError
? `<div class="plugin-desc" style="color:#c62828;">Load error: ${escapeHtml(p.loadErrorMessage || 'unknown')}</div>`
: '';
const row = document.createElement('div');
row.className = 'plugin-item';
row.setAttribute('role', 'listitem');
@@ -951,11 +974,12 @@ async function loadPluginsUI() {
<div class="plugin-desc">${escapeHtml(p.description || '')}</div>
${tagsHtml}
${authorsHtml}
${errorHtml}
<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' : ''}>
<input type="checkbox" class="plugin-enable" ${p.enabled ? 'checked' : ''} ${p.loadError ? 'disabled' : ''}>
<span>${p.enabled ? 'Enabled' : 'Disabled'}</span>
</label>
<span class="spacer"></span>
@@ -967,9 +991,16 @@ async function loadPluginsUI() {
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'}.`);
if (postExtensionCommand('extensions-set-enabled', JSON.stringify({ id: p.id, enabled }))) {
labelSpan.textContent = enabled ? 'Enabled' : 'Disabled';
showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`);
} else if (ipc) {
await ipc.invoke('plugins-set-enabled', { id: p.id, enabled });
labelSpan.textContent = enabled ? 'Enabled' : 'Disabled';
showStatus(`${p.name}: ${enabled ? 'Enabled' : 'Disabled'}.`);
} else {
throw new Error('No extension bridge');
}
} catch (e) {
console.error('Failed to toggle plugin', p.id, e);
enableInput.checked = !enabled;
@@ -980,8 +1011,14 @@ async function loadPluginsUI() {
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.`);
if (postExtensionCommand('extensions-reload', JSON.stringify({ id: p.id }))) {
showStatus(`${p.name} reloaded.`);
} else if (ipc) {
await ipc.invoke('plugins-reload', { id: p.id });
showStatus(`${p.name} reloaded.`);
} else {
throw new Error('No extension bridge');
}
} catch (e) {
console.error('Plugin reload failed', e);
showStatus('Reload failed');
@@ -991,10 +1028,23 @@ async function loadPluginsUI() {
}
}
if (reloadAllBtn) reloadAllBtn.onclick = async () => {
try { if (ipc) await ipc.invoke('plugins-reload', {}); showStatus('Plugins reloaded.'); } catch { showStatus('Reload failed'); }
try {
if (postExtensionCommand('extensions-reload', '{}')) {
showStatus('Plugins reloaded.');
} else if (ipc) {
await ipc.invoke('plugins-reload', {});
showStatus('Plugins reloaded.');
} else {
throw new Error('No extension bridge');
}
} catch {
showStatus('Reload failed');
}
};
}
window.loadPluginsUI = loadPluginsUI;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c]));
}
+5 -1
View File
@@ -250,9 +250,13 @@
<section class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins">
<h2>Plugins</h2>
<div class="customization-group">
<p class="note" id="plugins-install-hint">
Install extensions by copying an unpacked folder into Nebula's extensions directory.
Each folder needs a <code>manifest.json</code>. Then click Reload Plugins.
</p>
<div class="button-row">
<button id="plugins-reload-all">Reload Plugins</button>
<span class="note">Changes to renderer preloads may require app restart.</span>
<span class="note">Content scripts apply on the next page load.</span>
</div>
</div>
<div class="customization-group">