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 -2Showing whitespace changes
public/scripts/extensions.js+85 -1
@@ -356,11 +356,82 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
356356}
357357
358358/**
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+ */
366+async 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+/**
359429 * Enables an extension by name.
360430 * @param {string} name Extension name
361431 * @param {boolean} [reload=true] If true, reload the page after enabling the extension
362432 */
363433export async function enableExtension(name, reload = true) {
434+ await callExtensionHook(name, 'enable');
364435 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
365436 stateChanged = true;
366437 await saveSettings();
@@ -377,6 +448,7 @@ export async function enableExtension(name, reload = true) {
377448 * @param {boolean} [reload=true] If true, reload the page after disabling the extension
378449 */
379450export async function disableExtension(name, reload = true) {
451+ await callExtensionHook(name, 'disable');
380452 extension_settings.disabledExtensions.push(name);
381453 stateChanged = true;
382454 await saveSettings();
@@ -505,7 +577,10 @@ async function activateExtensions() {
505577 Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]),
506578 );
507579 await promise
508580 .then(() => activeExtensions.add(name)){
581+ activeExtensions.add(name);
582+ return callExtensionHook(name, 'activate');
583+ })
509584 .catch(err => {
510585 console.log('Could not activate extension', name, err);
511586 extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`);
@@ -1147,6 +1222,8 @@ async function updateExtension(extensionName, quiet, timeout = null) {
11471222 toastr.success('Extension is already up to date');
11481223 }
11491224 } else {
1225+ const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`;
1226+ await callExtensionHook(fullExtensionName, 'update');
11501227 toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`);
11511228 }
11521229 } catch (error) {
@@ -1280,6 +1357,8 @@ async function moveExtension(extensionName, source, destination) {
12801357 * @param {string} extensionName Extension name to delete
12811358 */
12821359export async function deleteExtension(extensionName) {
1360+ await callExtensionHook(extensionName, 'delete');
1361+
12831362 try {
12841363 await fetch('/api/extensions/delete', {
12851364 method: 'POST',
@@ -1428,6 +1507,11 @@ export async function installExtension(url, global, branch = '') {
14281507 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
14291508 await loadExtensionSettings({}, false, false);
14301509 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+ }
14311515}
14321516
14331517/**
src/endpoints/extensions.js+2 -1
@@ -113,8 +113,9 @@ router.post('/install', async (request, response) => {
113113 console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`);
114114
115115 const { version, author, display_name } = await getManifest(extensionPath);
116+ const folderName = path.basename(extensionPath);
116117
117118 return response.send({ version, author, display_name, extensionPath, folderName });
118119 } catch (error) {
119120 console.error('Importing custom content failed', error);
120121 return response.status(500).send(`Server Error: ${error.message}`);