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 | * Enables an extension by name. | 429 | * Enables an extension by name. |
| 360 | * @param {string} name Extension name | 430 | * @param {string} name Extension name |
| 361 | * @param {boolean} [reload=true] If true, reload the page after enabling the extension | 431 | * @param {boolean} [reload=true] If true, reload the page after enabling the extension |
| 362 | */ | 432 | */ |
| 363 | export async function enableExtension(name, reload = true) { | 433 | export 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 extension | 448 | * @param {boolean} [reload=true] If true, reload the page after disabling the extension |
| 378 | */ | 449 | */ |
| 379 | export async function disableExtension(name, reload = true) { | 450 | export 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 promise | 579 | 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 delete | 1357 | * @param {string} extensionName Extension name to delete |
| 1281 | */ | 1358 | */ |
| 1282 | export async function deleteExtension(extensionName) { | 1359 | export 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 | } |
| 1432 | 1516 | ||
| 1433 | /** | 1517 | /** |
| @@ -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`); |
| 114 | 114 | ||
| 115 | const { version, author, display_name } = await getManifest(extensionPath); | 115 | const { version, author, display_name } = await getManifest(extensionPath); |
| 116 | const folderName = path.basename(extensionPath); | ||
| 116 | 117 | ||
| 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}`); |