mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-05-24 10:18:53 +00:00
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:
parent
524d288418
commit
c822920e1f
3 changed files with 188 additions and 1 deletions
76
packages/extension-api/src/extension-api.d.ts
vendored
76
packages/extension-api/src/extension-api.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue