Implement extension manifest hooks for lifecycle events (#5261) * Initial plan * Implement extension manifest hooks for install, delete, enable, disable Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert unrelated package-lock.json changes Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Address review: use Object.hasOwn, add activate hook, simplify await, return folderName from backend Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Add 'update' hook that triggers on extension update Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert package-lock * Add 5-second timeout for extension hook calls using delay and Promise.race Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert unintended package-lock.json changes Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Add timeout warning log when extension hook exceeds 5 seconds Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Refactor extension hook call to handle synchronous results * Refactor callExtensionHook to use constants for timeout results --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

3ad9b05e274b0d822363a79d0c80e0e828d98e1a

Copilot <198982749+Copilot@users.noreply.github.com>

Signed
2 files changed, +87 -2Ignore whitespace
public/scripts/extensions.js+85 -1
@@ -356,11 +356,82 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
356}356}
357357
358/**358/**
359 * Calls a manifest hook for an extension.
360 * Hooks are optional function names exported from the extension's JS entry point module.
361 * The hook function can optionally return a Promise that will be awaited.
362 * @param {string} name Extension name
363 * @param {'install' | 'update' | 'delete' | 'enable' | 'disable' | 'activate'} hookName The hook to call
364 * @returns {Promise<void>}
365 */
366async function callExtensionHook(name, hookName) {
367 const manifest = manifests[name];
368
369 if (!manifest) {
370 console.debug(`callExtensionHook: Extension "${name}" has no manifest, skipping hook "${hookName}"`);
371 return;
372 }
373
374 if (!manifest.hooks || typeof manifest.hooks !== 'object') {
375 return;
376 }
377
378 if (!Object.hasOwn(manifest.hooks, hookName)) {
379 return;
380 }
381
382 const hookFunctionName = manifest.hooks[hookName];
383
384 if (typeof hookFunctionName !== 'string' || !hookFunctionName) {
385 console.warn(`callExtensionHook: Extension "${name}" hook "${hookName}" is not a valid string`);
386 return;
387 }
388
389 if (!manifest.js) {
390 console.warn(`callExtensionHook: Extension "${name}" has hook "${hookName}" but no JS entry point defined in manifest`);
391 return;
392 }
393
394 const url = `/scripts/extensions/${name}/${manifest.js}`;
395 console.debug(`callExtensionHook: Calling hook "${hookName}" (function "${hookFunctionName}") for extension "${name}"`);
396
397 try {
398 const module = await import(url);
399
400 if (typeof module[hookFunctionName] !== 'function') {
401 console.warn(`callExtensionHook: Extension "${name}" hook "${hookName}" references "${hookFunctionName}" which is not an exported function`);
402 return;
403 }
404
405 const hookCallResult = module[hookFunctionName]();
406
407 const HOOK_TIMEOUT = 5000;
408 const HOOK_RESULT = {
409 OK: 'ok',
410 TIMEOUT: 'timeout',
411 };
412
413 const result = await Promise.race([
414 (hookCallResult instanceof Promise ? hookCallResult : Promise.resolve(hookCallResult)).then(() => HOOK_RESULT.OK),
415 delay(HOOK_TIMEOUT).then(() => HOOK_RESULT.TIMEOUT),
416 ]);
417
418 if (result === HOOK_RESULT.TIMEOUT) {
419 console.warn(`callExtensionHook: Hook "${hookName}" for extension "${name}" timed out after ${HOOK_TIMEOUT}ms`);
420 } else {
421 console.debug(`callExtensionHook: Hook "${hookName}" completed for extension "${name}"`);
422 }
423 } catch (error) {
424 console.error(`callExtensionHook: Error calling hook "${hookName}" for extension "${name}":`, error);
425 }
426}
427
428/**
359 * Enables an extension by name.429 * Enables an extension by name.
360 * @param {string} name Extension name430 * @param {string} name Extension name
361 * @param {boolean} [reload=true] If true, reload the page after enabling the extension431 * @param {boolean} [reload=true] If true, reload the page after enabling the extension
362 */432 */
363export async function enableExtension(name, reload = true) {433export async function enableExtension(name, reload = true) {
434 await callExtensionHook(name, 'enable');
364 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);435 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
365 stateChanged = true;436 stateChanged = true;
366 await saveSettings();437 await saveSettings();
@@ -377,6 +448,7 @@ export async function enableExtension(name, reload = true) {
377 * @param {boolean} [reload=true] If true, reload the page after disabling the extension448 * @param {boolean} [reload=true] If true, reload the page after disabling the extension
378 */449 */
379export async function disableExtension(name, reload = true) {450export async function disableExtension(name, reload = true) {
451 await callExtensionHook(name, 'disable');
380 extension_settings.disabledExtensions.push(name);452 extension_settings.disabledExtensions.push(name);
381 stateChanged = true;453 stateChanged = true;
382 await saveSettings();454 await saveSettings();
@@ -505,7 +577,10 @@ async function activateExtensions() {
505 Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]),577 Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]),
506 );578 );
507 await promise579 await promise
508 .then(() => activeExtensions.add(name))580 .then(() => {
581 activeExtensions.add(name);
582 return callExtensionHook(name, 'activate');
583 })
509 .catch(err => {584 .catch(err => {
510 console.log('Could not activate extension', name, err);585 console.log('Could not activate extension', name, err);
511 extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`);586 extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`);
@@ -1147,6 +1222,8 @@ async function updateExtension(extensionName, quiet, timeout = null) {
1147 toastr.success('Extension is already up to date');1222 toastr.success('Extension is already up to date');
1148 }1223 }
1149 } else {1224 } else {
1225 const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`;
1226 await callExtensionHook(fullExtensionName, 'update');
1150 toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`);1227 toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`);
1151 }1228 }
1152 } catch (error) {1229 } catch (error) {
@@ -1280,6 +1357,8 @@ async function moveExtension(extensionName, source, destination) {
1280 * @param {string} extensionName Extension name to delete1357 * @param {string} extensionName Extension name to delete
1281 */1358 */
1282export async function deleteExtension(extensionName) {1359export async function deleteExtension(extensionName) {
1360 await callExtensionHook(extensionName, 'delete');
1361
1283 try {1362 try {
1284 await fetch('/api/extensions/delete', {1363 await fetch('/api/extensions/delete', {
1285 method: 'POST',1364 method: 'POST',
@@ -1428,6 +1507,11 @@ export async function installExtension(url, global, branch = '') {
1428 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);1507 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
1429 await loadExtensionSettings({}, false, false);1508 await loadExtensionSettings({}, false, false);
1430 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);1509 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
1510
1511 if (response.folderName) {
1512 const extensionName = `third-party/${response.folderName}`;
1513 await callExtensionHook(extensionName, 'install');
1514 }
1431}1515}
14321516
1433/**1517/**
src/endpoints/extensions.js+2 -1
@@ -113,8 +113,9 @@ router.post('/install', async (request, response) => {
113 console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);113 console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
114114
115 const { version, author, display_name } = await getManifest(extensionPath);115 const { version, author, display_name } = await getManifest(extensionPath);
116 const folderName = path.basename(extensionPath);
116117
117 return response.send({ version, author, display_name, extensionPath });118 return response.send({ version, author, display_name, extensionPath, folderName });
118 } catch (error) {119 } catch (error) {
119 console.error('Importing custom content failed', error);120 console.error('Importing custom content failed', error);
120 return response.status(500).send(`Server Error: ${error.message}`);121 return response.status(500).send(`Server Error: ${error.message}`);