Fix ZIP download URL, add activate/deactivate compat shim

This commit is contained in:
2026-04-18 20:46:25 +10:00
parent 9bd1dd325f
commit 8e650e94b1

View File

@@ -21,7 +21,7 @@
// ---------------------------------------------------------------------------
/**
* Evaluates plugin source code and returns the plugin object.
* Evaluates plugin source code and returns the raw module exports.
* Tries CJS eval first; on SyntaxError falls back to ESM data-URL import.
* @param {string} code
* @returns {Promise<object>}
@@ -45,6 +45,78 @@
return mod.default ?? mod;
}
/**
* 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<string>} 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
// ---------------------------------------------------------------------------
@@ -234,7 +306,8 @@
async function loadPluginFromPath(filePath) {
try {
const code = fs.readFileSync(filePath, 'utf-8');
const p = await evalPluginCode(code);
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)');
@@ -344,6 +417,7 @@
style="font-size:10px;color:#7986cb;text-decoration:none;margin-left:auto;margin-right:6px;">↗ Homepage</a>` : ''}
<button class="market-install-btn"
data-url="${p.downloadUrl ?? ''}"
data-repo="${p.repository ?? ''}"
data-id="${p.id}"
${isLoaded ? 'disabled' : ''}>
${isLoaded ? 'Loaded' : 'Download'}
@@ -354,27 +428,21 @@
}).join('');
dirListEl.querySelectorAll('.market-install-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', () => downloadAndLoadPlugin(btn.dataset.id, btn.dataset.url));
btn.addEventListener('click', () => downloadAndLoadPlugin(btn.dataset.id, btn.dataset.url, btn.dataset.repo));
});
}
/** Downloads a plugin's index.js from the marketplace and loads it into the registry. */
async function downloadAndLoadPlugin(pluginId, downloadUrl) {
async function downloadAndLoadPlugin(pluginId, downloadUrl, repositoryUrl) {
if (!downloadUrl) {
addLog(`No download URL for plugin ${pluginId}`, 'error');
return;
}
try {
addLog(`Downloading plugin ${pluginId}`, 'info');
const res = await fetch(downloadUrl);
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${downloadUrl}`);
const code = await res.text();
const p = await evalPluginCode(code);
if (!p || typeof p.onLoad !== 'function') {
throw new Error(`Plugin ${pluginId} does not export a valid plugin object`);
}
const code = await fetchPluginCode(downloadUrl, repositoryUrl);
const raw = await evalPluginCode(code);
const p = normalisePlugin(raw);
await registerAndLoad(pluginId, p, { downloadUrl });