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>
Signed| @@ -356,11 +356,82 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { | ||
| 356 | 356 | } |
| 357 | 357 | |
| 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 | + */ | |
| 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 | +/** | |
| 359 | 429 | * Enables an extension by name. |
| 360 | 430 | * @param {string} name Extension name |
| 361 | 431 | * @param {boolean} [reload=true] If true, reload the page after enabling the extension |
| 362 | 432 | */ |
| 363 | 433 | export async function enableExtension(name, reload = true) { |
| 434 | + await callExtensionHook(name, 'enable'); | |
| 364 | 435 | extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); |
| 365 | 436 | stateChanged = true; |
| 366 | 437 | await saveSettings(); |
| @@ -377,6 +448,7 @@ export async function enableExtension(name, reload = true) { | ||
| 377 | 448 | * @param {boolean} [reload=true] If true, reload the page after disabling the extension |
| 378 | 449 | */ |
| 379 | 450 | export async function disableExtension(name, reload = true) { |
| 451 | + await callExtensionHook(name, 'disable'); | |
| 380 | 452 | extension_settings.disabledExtensions.push(name); |
| 381 | 453 | stateChanged = true; |
| 382 | 454 | await saveSettings(); |
| @@ -505,7 +577,10 @@ async function activateExtensions() { | ||
| 505 | 577 | Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]), |
| 506 | 578 | ); |
| 507 | 579 | await promise |
| 508 | 580 | .then(() => activeExtensions.add(name)){ |
| 581 | + activeExtensions.add(name); | |
| 582 | + return callExtensionHook(name, 'activate'); | |
| 583 | + }) | |
| 509 | 584 | .catch(err => { |
| 510 | 585 | console.log('Could not activate extension', name, err); |
| 511 | 586 | extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`); |
| @@ -1147,6 +1222,8 @@ async function updateExtension(extensionName, quiet, timeout = null) { | ||
| 1147 | 1222 | toastr.success('Extension is already up to date'); |
| 1148 | 1223 | } |
| 1149 | 1224 | } else { |
| 1225 | + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; | |
| 1226 | + await callExtensionHook(fullExtensionName, 'update'); | |
| 1150 | 1227 | toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`); |
| 1151 | 1228 | } |
| 1152 | 1229 | } catch (error) { |
| @@ -1280,6 +1357,8 @@ async function moveExtension(extensionName, source, destination) { | ||
| 1280 | 1357 | * @param {string} extensionName Extension name to delete |
| 1281 | 1358 | */ |
| 1282 | 1359 | export async function deleteExtension(extensionName) { |
| 1360 | + await callExtensionHook(extensionName, 'delete'); | |
| 1361 | + | |
| 1283 | 1362 | try { |
| 1284 | 1363 | await fetch('/api/extensions/delete', { |
| 1285 | 1364 | method: 'POST', |
| @@ -1428,6 +1507,11 @@ export async function installExtension(url, global, branch = '') { | ||
| 1428 | 1507 | console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); |
| 1429 | 1508 | await loadExtensionSettings({}, false, false); |
| 1430 | 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 | } |
| 1432 | 1516 | |
| 1433 | 1517 | /** |
| @@ -113,8 +113,9 @@ router.post('/install', async (request, response) => { | ||
| 113 | 113 | console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`); |
| 114 | 114 | |
| 115 | 115 | const { version, author, display_name } = await getManifest(extensionPath); |
| 116 | + const folderName = path.basename(extensionPath); | |
| 116 | 117 | |
| 117 | 118 | return response.send({ version, author, display_name, extensionPath, folderName }); |
| 118 | 119 | } catch (error) { |
| 119 | 120 | console.error('Importing custom content failed', error); |
| 120 | 121 | return response.status(500).send(`Server Error: ${error.message}`); |