diff --git a/packages/main/src/plugin/api/extension-info.ts b/packages/main/src/plugin/api/extension-info.ts index 486630dcc45..dee18a2c1c2 100644 --- a/packages/main/src/plugin/api/extension-info.ts +++ b/packages/main/src/plugin/api/extension-info.ts @@ -16,6 +16,11 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +export interface ExtensionError { + message: string; + stack?: string; +} + export interface ExtensionInfo { id: string; name: string; @@ -25,5 +30,6 @@ export interface ExtensionInfo { removable: boolean; version: string; state: string; + error?: ExtensionError; path: string; } diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index aab038e127f..0daf2c0d5af 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -40,6 +40,7 @@ import type { ApiSenderType } from './api'; import type { AuthenticationImpl } from './authentication'; import type { MessageBox } from './message-box'; import type { Telemetry } from './telemetry/telemetry'; +import type * as containerDesktopAPI from '@podman-desktop/api'; class TestExtensionLoader extends ExtensionLoader { public async setupScanningDirectory(): Promise { @@ -53,6 +54,10 @@ class TestExtensionLoader extends ExtensionLoader { setWatchTimeout(timeout: number): void { this.watchTimeout = timeout; } + + getExtensionState() { + return this.extensionState; + } } let extensionLoader: TestExtensionLoader; @@ -67,7 +72,7 @@ const configurationRegistry: ConfigurationRegistry = {} as unknown as Configurat const imageRegistry: ImageRegistry = {} as unknown as ImageRegistry; -const apiSender: ApiSenderType = {} as unknown as ApiSenderType; +const apiSender: ApiSenderType = { send: vi.fn() } as unknown as ApiSenderType; const trayMenuRegistry: TrayMenuRegistry = {} as unknown as TrayMenuRegistry; @@ -91,7 +96,8 @@ const inputQuickPickRegistry: InputQuickPickRegistry = {} as unknown as InputQui const authenticationProviderRegistry: AuthenticationImpl = {} as unknown as AuthenticationImpl; -const telemetry: Telemetry = {} as unknown as Telemetry; +const telemetryTrackMock = vi.fn(); +const telemetry: Telemetry = { track: telemetryTrackMock } as unknown as Telemetry; /* eslint-disable @typescript-eslint/no-empty-function */ beforeAll(() => { @@ -118,6 +124,7 @@ beforeAll(() => { }); beforeEach(() => { + telemetryTrackMock.mockImplementation(() => Promise.resolve()); vi.clearAllMocks(); }); @@ -230,3 +237,23 @@ test('Should load file from watching scanning folder', async () => { // expect to load only one file (other are invalid files/folder) expect(loadPackagedFileMock).toBeCalledWith(path.resolve(rootedFakeDirectory, 'watch.cdix')); }); + +test('Verify extension error leads to failed state', async () => { + const id = 'extension.id'; + await extensionLoader.activateExtension( + { + id: id, + path: 'dummy', + api: {} as typeof containerDesktopAPI, + mainPath: '', + removable: false, + manifest: {}, + }, + { + activate: () => { + throw Error('Failed'); + }, + }, + ); + expect(extensionLoader.getExtensionState().get(id)).toBe('failed'); +}); diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index 31190a20c61..855e2ebf754 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -21,7 +21,7 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import type { CommandRegistry } from './command-registry'; -import type { ExtensionInfo } from './api/extension-info'; +import type { ExtensionError, ExtensionInfo } from './api/extension-info'; import * as zipper from 'zip-local'; import type { TrayMenuRegistry } from './tray-menu-registry'; import { Disposable } from './types/disposable'; @@ -89,7 +89,8 @@ export class ExtensionLoader { private analyzedExtensions = new Map(); private watcherExtensions = new Map(); private reloadInProgressExtensions = new Map(); - private extensionState = new Map(); + protected extensionState = new Map(); + protected extensionStateErrors = new Map(); protected watchTimeout = 1000; @@ -121,6 +122,18 @@ export class ExtensionLoader { private telemetry: Telemetry, ) {} + mapError(err: unknown): ExtensionError | undefined { + if (err) { + if (err instanceof Error) { + return { message: err.message, stack: err.stack }; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { message: (err as any).toString() }; + } + } + return undefined; + } + async listExtensions(): Promise { return Array.from(this.analyzedExtensions.values()).map(extension => ({ name: extension.manifest.name, @@ -129,6 +142,7 @@ export class ExtensionLoader { version: extension.manifest.version, publisher: extension.manifest.publisher, state: this.extensionState.get(extension.id) || 'stopped', + error: this.mapError(this.extensionStateErrors.get(extension.id)), id: extension.id, path: extension.path, removable: extension.removable, @@ -370,25 +384,40 @@ export class ExtensionLoader { } this.analyzedExtensions.set(extension.id, extension); + this.extensionState.delete(extension.id); + this.extensionStateErrors.delete(extension.id); - // in development mode, watch if the extension is updated and reload it - if (import.meta.env.DEV && !this.watcherExtensions.has(extension.id)) { - const extensionWatcher = this.fileSystemMonitoring.createFileSystemWatcher(extensionPath); - extensionWatcher.onDidChange(async () => { - // wait 1 second before trying to reload the extension - // this is to avoid reloading the extension while it is still being updated - setTimeout(() => { - this.reloadExtension(extension, removable).catch((error: unknown) => - console.error('error while reloading extension', error), - ); - }, 1000); + const telemetryOptions = { extensionId: extension.id }; + + try { + // in development mode, watch if the extension is updated and reload it + if (import.meta.env.DEV && !this.watcherExtensions.has(extension.id)) { + const extensionWatcher = this.fileSystemMonitoring.createFileSystemWatcher(extensionPath); + extensionWatcher.onDidChange(async () => { + // wait 1 second before trying to reload the extension + // this is to avoid reloading the extension while it is still being updated + setTimeout(() => { + this.reloadExtension(extension, removable).catch((error: unknown) => + console.error('error while reloading extension', error), + ); + }, 1000); + }); + this.watcherExtensions.set(extension.id, extensionWatcher); + } + + const runtime = this.loadRuntime(extension); + + await this.activateExtension(extension, runtime); + } catch (err) { + this.extensionState.set(extension.id, 'failed'); + this.extensionStateErrors.set(extension.id, err); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (telemetryOptions as any).error = err; + } finally { + this.telemetry.track('loadExtension', telemetryOptions).catch((error: unknown) => { + console.error('error while tracking loadExtension telemetry', error); }); - this.watcherExtensions.set(extension.id, extensionWatcher); } - - const runtime = this.loadRuntime(extension); - - await this.activateExtension(extension, runtime); } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -774,6 +803,7 @@ export class ExtensionLoader { // eslint-disable-next-line @typescript-eslint/no-explicit-any async activateExtension(extension: AnalyzedExtension, extensionMain: any): Promise { this.extensionState.set(extension.id, 'starting'); + this.extensionStateErrors.delete(extension.id); this.apiSender.send('extension-starting', {}); const subscriptions: containerDesktopAPI.Disposable[] = []; @@ -786,21 +816,35 @@ export class ExtensionLoader { if (typeof extensionMain['deactivate'] === 'function') { deactivateFunction = extensionMain['deactivate']; } - if (typeof extensionMain['activate'] === 'function') { - // activate the extension - console.log(`Activating extension (${extension.id})`); - await extensionMain['activate'].apply(undefined, [extensionContext]); - console.log(`Activation extension (${extension.id}) ended`); + + const telemetryOptions = { extensionId: extension.id }; + try { + if (typeof extensionMain['activate'] === 'function') { + // it returns exports + console.log(`Activating extension (${extension.id})`); + await extensionMain['activate'].apply(undefined, [extensionContext]); + console.log(`Activation extension (${extension.id}) ended`); + } + const id = extension.id; + const activatedExtension: ActivatedExtension = { + id, + deactivateFunction, + extensionContext, + }; + this.activatedExtensions.set(extension.id, activatedExtension); + this.extensionState.set(extension.id, 'started'); + this.apiSender.send('extension-started'); + } catch (err) { + console.log(`Activation extension ${extension.id} failed error:${err}`); + this.extensionState.set(extension.id, 'failed'); + this.extensionStateErrors.set(extension.id, err); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (telemetryOptions as any).error = err; + } finally { + this.telemetry + .track('activateExtension', telemetryOptions) + .catch((error: unknown) => console.log(`Failed to track activateExtension telemetry event. Error: ${error}`)); } - const id = extension.id; - const activatedExtension: ActivatedExtension = { - id, - deactivateFunction, - extensionContext, - }; - this.activatedExtensions.set(extension.id, activatedExtension); - this.extensionState.set(extension.id, 'started'); - this.apiSender.send('extension-started'); } async deactivateExtension(extensionId: string): Promise { @@ -809,11 +853,19 @@ export class ExtensionLoader { return; } + const telemetryOptions = { extensionId: extension.id }; + this.extensionState.set(extension.id, 'stopping'); this.apiSender.send('extension-stopping'); if (extension.deactivateFunction) { - await extension.deactivateFunction(); + try { + await extension.deactivateFunction(); + } catch (err) { + console.log(`Deactivation extension ${extension.id} failed error:${err}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (telemetryOptions as any).error = err; + } } // dispose subscriptions @@ -835,6 +887,9 @@ export class ExtensionLoader { this.activatedExtensions.delete(extensionId); this.extensionState.set(extension.id, 'stopped'); this.apiSender.send('extension-stopped'); + this.telemetry + .track('deactivateExtension', telemetryOptions) + .catch((error: unknown) => console.log(`Failed to track deactivateExtension telemetry event. Error: ${error}`)); } async stopAllExtensions(): Promise { diff --git a/packages/renderer/src/lib/preferences/PreferencesExtensionRendering.svelte b/packages/renderer/src/lib/preferences/PreferencesExtensionRendering.svelte index 9d5a289d2ef..a5f8becda3f 100644 --- a/packages/renderer/src/lib/preferences/PreferencesExtensionRendering.svelte +++ b/packages/renderer/src/lib/preferences/PreferencesExtensionRendering.svelte @@ -36,10 +36,10 @@ async function removeExtension() {
- +
- + {#if extensionInfo.removable}
+ {#if extensionInfo.error} +
+
Extension error: {extensionInfo.error.message}
+ {#if extensionInfo.error.stack} +
Stack trace
+
{extensionInfo.error.stack}
+ {/if} +
+ {/if} {/if}
diff --git a/packages/renderer/src/lib/ui/ConnectionStatus.svelte b/packages/renderer/src/lib/ui/ConnectionStatus.svelte index e3baaece8ac..dea11f771b1 100644 --- a/packages/renderer/src/lib/ui/ConnectionStatus.svelte +++ b/packages/renderer/src/lib/ui/ConnectionStatus.svelte @@ -42,6 +42,14 @@ const statusesStyle = new Map([ label: 'STOPPING', }, ], + [ + 'failed', + { + bgColor: 'bg-red-500', + txtColor: 'text-red-500', + label: 'FAILED', + }, + ], ]); $: statusStyle = statusesStyle.get(status) || { bgColor: 'bg-gray-900',