diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index 9385b2516a2..95656c32ca8 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -170,6 +170,82 @@ declare module '@podman-desktop/api' { readonly secrets: SecretStorage; } + /** + * Represents an extension. + * + * To get an instance of an `Extension` use {@link extensions.getExtension getExtension}. + */ + export interface Extension { + /** + * The canonical extension identifier in the form of: `publisher.name`. + */ + readonly id: string; + + /** + * The uri of the directory containing the extension. + */ + readonly extensionUri: Uri; + + /** + * The absolute file path of the directory containing this extension. Shorthand + * notation for {@link Extension.extensionUri Extension.extensionUri.fsPath} (independent of the uri scheme). + */ + readonly extensionPath: string; + + /** + * The parsed contents of the extension's package.json. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly packageJSON: any; + + /** + * The public API exported by this extension (return value of `activate`). + * It is an invalid action to access this field before this extension has been activated. + */ + readonly exports: T; + } + + /** + * Namespace for dealing with installed extensions. Extensions are represented + * by an {@link Extension}-interface which enables reflection on them. + * + * Extension writers can provide APIs to other extensions by returning their API public + * surface from the `activate`-call. + * + * When depending on the API of another extension add an `extensionDependencies`-entry + * to `package.json`, and use the {@link extensions.getExtension getExtension}-function + * and the {@link Extension.exports exports}-property, like below: + * + * ```typescript + * const podmanExtension = extensions.getExtension('podman-desktop.podman'); + * const podmanExtensionAPI = podmanExtension.exports; + * + * podmanExtensionAPI.... + * ``` + */ + export namespace extensions { + /** + * Get an extension by its full identifier in the form of: `publisher.name`. + * + * @param extensionId An extension identifier. + * @returns An extension or `undefined`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export function getExtension(extensionId: string): Extension | undefined; + + /** + * All extensions currently known to the system. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export const all: readonly Extension[]; + + /** + * An event which fires when `extensions.all` changes. This can happen when extensions are + * installed, uninstalled, enabled or disabled. + */ + export const onDidChange: Event; + } + /** * A provider result represents the values a provider, like the {@linkcode ImageCheckerProvider}, * may return. For once this is the actual result type `T`, like `ImageChecks`, or a Promise that resolves diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index 4b8f96971e5..ff4ff16e19d 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -86,6 +86,9 @@ class TestExtensionLoader extends ExtensionLoader { getExtensionState(): Map { return this.extensionState; } + getActivatedExtensions(): Map { + return this.activatedExtensions; + } getExtensionStateErrors(): Map { return this.extensionStateErrors; @@ -1041,6 +1044,57 @@ test('Verify extension uri', async () => { expect(grabUri.fsPath).toBe('dummy'); }); +test('Verify exports and packageJSON', async () => { + const id = 'extension.id'; + const activateMethod = vi.fn(); + activateMethod.mockResolvedValue({ + hello: () => 'world', + }); + + configurationRegistryGetConfigurationMock.mockReturnValue({ get: vi.fn().mockReturnValue(1) }); + + await extensionLoader.activateExtension( + { + id: id, + name: 'id', + path: 'dummy', + api: {} as typeof containerDesktopAPI, + mainPath: '', + removable: false, + manifest: { + foo: 'bar', + }, + subscriptions: [], + readme: '', + dispose: vi.fn(), + }, + { activate: activateMethod }, + ); + + expect(activateMethod).toBeCalled(); + + const myActivatedExtension = extensionLoader.getActivatedExtensions().get(id); + expect(myActivatedExtension).toBeDefined(); + + expect(myActivatedExtension?.exports).toBeDefined(); + expect(myActivatedExtension?.exports.hello()).toBe('world'); + + expect(myActivatedExtension?.packageJSON).toBeDefined(); + expect((myActivatedExtension?.packageJSON as any)?.foo).toBe('bar'); + + const exposed = extensionLoader.getExposedExtension(id); + expect(exposed).toBeDefined(); + expect(exposed?.exports.hello()).toBe('world'); + expect((exposed as any).packageJSON.foo).toBe('bar'); + + const allExtensions = extensionLoader.getAllExposedExtensions(); + expect(allExtensions).toBeDefined(); + // 1 item + expect(allExtensions.length).toBe(1); + expect(allExtensions[0].exports.hello()).toBe('world'); + expect((allExtensions[0] as any).packageJSON.foo).toBe('bar'); +}); + describe('Navigation', async () => { test('navigateToContainers', async () => { const api = extensionLoader.createApi('path', { diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index bab5f2d3908..3fdfbc5917c 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -46,6 +46,7 @@ import type { Context } from './context/context.js'; import type { CustomPickRegistry } from './custompick/custompick-registry.js'; import type { DialogRegistry } from './dialog-registry.js'; import type { Directories } from './directories.js'; +import type { Event } from './events/emitter.js'; import { Emitter } from './events/emitter.js'; import { DEFAULT_TIMEOUT, ExtensionLoaderSettings } from './extension-loader-settings.js'; import type { FilesystemMonitoring } from './filesystem-monitoring.js'; @@ -117,7 +118,10 @@ export interface ActivatedExtension { id: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any deactivateFunction: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exports: any; extensionContext: containerDesktopAPI.ExtensionContext; + packageJSON: unknown; } const EXTENSION_OPTION = '--extension-folder'; @@ -140,6 +144,9 @@ export class ExtensionLoader { protected watchTimeout = 1000; + private readonly _onDidChange = new Emitter(); + readonly onDidChange: Event = this._onDidChange.event; + // Plugins directory location private pluginsDirectory; protected pluginsScanDirectory; @@ -218,6 +225,35 @@ export class ExtensionLoader { })); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformActivatedExtensionToExposedExtension( + activatedExtension: ActivatedExtension, + ): containerDesktopAPI.Extension { + return { + id: activatedExtension.id, + exports: activatedExtension.exports, + extensionUri: activatedExtension.extensionContext.extensionUri, + extensionPath: activatedExtension.extensionContext.extensionUri.fsPath, + packageJSON: activatedExtension.packageJSON, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getExposedExtension(extensionId: string): containerDesktopAPI.Extension | undefined { + // do we have a matching extension? + const activatedExtension = this.activatedExtensions.get(extensionId); + if (activatedExtension) { + return this.transformActivatedExtensionToExposedExtension(activatedExtension); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllExposedExtensions(): containerDesktopAPI.Extension[] { + return Array.from(this.activatedExtensions.values()).map(activatedExtension => + this.transformActivatedExtensionToExposedExtension(activatedExtension), + ); + } + async loadPackagedFile(filePath: string): Promise { // need to unpack the file before load it const filename = path.basename(filePath); @@ -233,6 +269,7 @@ export class ExtensionLoader { if (!extension.error) { await this.loadExtension(extension); this.apiSender.send('extension-started', {}); + this._onDidChange.fire(); } } @@ -1152,6 +1189,19 @@ export class ExtensionLoader { }, }; + const extensions: typeof containerDesktopAPI.extensions = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getExtension(extensionId: string): containerDesktopAPI.Extension | undefined { + return instance.getExposedExtension(extensionId); + }, + get all() { + return instance.getAllExposedExtensions(); + }, + onDidChange: (listener, thisArg, disposables) => { + return instance.onDidChange(listener, thisArg, disposables); + }, + }; + const telemetry = this.telemetry; const env: typeof containerDesktopAPI.env = { get isMac() { @@ -1303,6 +1353,7 @@ export class ExtensionLoader { env, process, registry, + extensions, provider, fs, configuration, @@ -1424,6 +1475,7 @@ export class ExtensionLoader { extensionId: extension.id, extensionVersion: extension.manifest?.version, }; + let exports: unknown; try { if (typeof extensionMain?.['activate'] === 'function') { // maximum time to wait for the extension to activate by reading from configuration @@ -1447,7 +1499,7 @@ export class ExtensionLoader { const activatePromise = extensionMain['activate'].apply(undefined, [extensionContext]); // if extension reach the timeout, do not wait for it to finish and flag as error - await Promise.race([activatePromise, timeoutPromise]); + exports = await Promise.race([activatePromise, timeoutPromise]); const afterActivateTime = performance.now(); // Computing activation duration @@ -1457,10 +1509,13 @@ export class ExtensionLoader { console.log(`Activating extension (${extension.id}) ended in ${Math.round(duration)} milliseconds`); } const id = extension.id; + const packageJSON = extension.manifest; const activatedExtension: ActivatedExtension = { id, + packageJSON, deactivateFunction, extensionContext, + exports, }; this.activatedExtensions.set(extension.id, activatedExtension); this.extensionState.set(extension.id, 'started'); @@ -1527,6 +1582,7 @@ export class ExtensionLoader { this.activatedExtensions.delete(extensionId); this.extensionState.set(extension.id, 'stopped'); this.apiSender.send('extension-stopped'); + this._onDidChange.fire(); this.telemetry.track('deactivateExtension', telemetryOptions); } @@ -1581,6 +1637,7 @@ export class ExtensionLoader { } this.analyzedExtensions.delete(extensionId); this.apiSender.send('extension-removed'); + this._onDidChange.fire(); } }