feat: allow extensions to expose their own API (#7384)

* feat: allow extensions to expose their own API

and other 3rd party extensions can use these endpoints/API

fixes https://github.com/containers/podman-desktop/issues/5990


Signed-off-by: Florent Benoit <fbenoit@redhat.com>
This commit is contained in:
Florent BENOIT 2024-06-10 09:04:57 +02:00 committed by GitHub
parent 524d288418
commit c822920e1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 188 additions and 1 deletions

View file

@ -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<T> {
/**
* 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<T = any>(extensionId: string): Extension<T> | undefined;
/**
* All extensions currently known to the system.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const all: readonly Extension<any>[];
/**
* An event which fires when `extensions.all` changes. This can happen when extensions are
* installed, uninstalled, enabled or disabled.
*/
export const onDidChange: Event<void>;
}
/**
* 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

View file

@ -86,6 +86,9 @@ class TestExtensionLoader extends ExtensionLoader {
getExtensionState(): Map<string, string> {
return this.extensionState;
}
getActivatedExtensions(): Map<string, ActivatedExtension> {
return this.activatedExtensions;
}
getExtensionStateErrors(): Map<string, unknown> {
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', {

View file

@ -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<void>();
readonly onDidChange: Event<void> = 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<T = any>(
activatedExtension: ActivatedExtension,
): containerDesktopAPI.Extension<T> {
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<T = any>(extensionId: string): containerDesktopAPI.Extension<T> | 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<any>[] {
return Array.from(this.activatedExtensions.values()).map(activatedExtension =>
this.transformActivatedExtensionToExposedExtension(activatedExtension),
);
}
async loadPackagedFile(filePath: string): Promise<void> {
// 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<T = any>(extensionId: string): containerDesktopAPI.Extension<T> | 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();
}
}