diff --git a/background.ts b/background.ts index 8bbf6525c08..92dfe589848 100644 --- a/background.ts +++ b/background.ts @@ -608,14 +608,9 @@ Electron.app.on('before-quit', async(event) => { httpCredentialHelperServer.closeServer(); try { + await mainEvents.tryInvoke('extensions/shutdown'); await k8smanager?.stop(); - try { - await mainEvents.invoke('shutdown-integrations'); - } catch (ex) { - if (!`${ ex }`.includes('No handlers registered')) { - throw ex; - } - } + await mainEvents.tryInvoke('shutdown-integrations'); console.log(`2: Child exited cleanly.`); } catch (ex: any) { diff --git a/package.json b/package.json index 042478456d7..ea321c5ee9d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "email": "containers@suse.com" }, "engines": { - "node": "^20.17" + "node": "20.16.0" }, "packageManager": "yarn@1.22.21", "repository": { diff --git a/pkg/rancher-desktop/main/extensions/extensions.ts b/pkg/rancher-desktop/main/extensions/extensions.ts index e26eb7d9c08..fd51cfed6b7 100644 --- a/pkg/rancher-desktop/main/extensions/extensions.ts +++ b/pkg/rancher-desktop/main/extensions/extensions.ts @@ -1,4 +1,4 @@ -import { ChildProcessByStdio } from 'child_process'; +import { ChildProcessByStdio, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { Readable } from 'stream'; @@ -46,6 +46,11 @@ type ComposeFile = { volumes?: Record; }; +// ScriptType is any key in ExtensionMetadata.host that starts with `x-rd-`. +type ScriptType = keyof { + [K in keyof Required['host'] as K extends `x-rd-${ infer _U }` ? K : never]: 1; +}; + const console = Logging.extensions; export class ExtensionErrorImpl extends Error implements ExtensionError { @@ -224,7 +229,7 @@ export class ExtensionImpl implements Extension { * Returns the script executable plus arguments; the executable path is always * absolute. */ - protected getScriptArgs(metadata: ExtensionMetadata, key: 'x-rd-install' | 'x-rd-uninstall'): string[] | undefined { + protected getScriptArgs(metadata: ExtensionMetadata, key: ScriptType): string[] | undefined { const scriptData = metadata.host?.[key]?.[this.platform]; if (!scriptData) { @@ -235,6 +240,7 @@ export class ExtensionImpl implements Extension { const description = { 'x-rd-install': 'Post-install', 'x-rd-uninstall': 'Pre-uninstall', + 'x-rd-shutdown': 'Shutdown', }[key]; const binDir = path.join(this.dir, 'bin'); const scriptPath = path.normalize(path.resolve(binDir, scriptName)); @@ -632,4 +638,29 @@ export class ExtensionImpl implements Extension { async readFile(sourcePath: string): Promise { return await this.client.readFile(this.image, sourcePath, { namespace: this.extensionNamespace }); } + + async shutdown() { + // Don't trigger downloading the extension if it hasn't been installed. + const metadata = await this._metadata; + + if (!metadata) { + return; + } + try { + const [scriptPath, ...scriptArgs] = this.getScriptArgs(metadata, 'x-rd-shutdown') ?? []; + + if (scriptPath) { + console.log(`Running ${ this.id } shutdown script: ${ scriptPath } ${ scriptArgs.join(' ') }...`); + // No need to wait for the script to finish here. + const stream = await console.fdStream; + const process = spawn(scriptPath, scriptArgs, { + detached: true, stdio: ['ignore', stream, stream], cwd: path.dirname(scriptPath), windowsHide: true, + }); + + process.unref(); + } + } catch (ex) { + console.error(`Ignoring error running ${ this.id } post-install script: ${ ex }`); + } + } } diff --git a/pkg/rancher-desktop/main/extensions/manager.ts b/pkg/rancher-desktop/main/extensions/manager.ts index 6248d4f6a1a..f83ddf7e66c 100644 --- a/pkg/rancher-desktop/main/extensions/manager.ts +++ b/pkg/rancher-desktop/main/extensions/manager.ts @@ -267,6 +267,9 @@ export class ExtensionManagerImpl implements ExtensionManager { })(repo, tag)); } await Promise.all(tasks); + + // Register a listener to shut down extensions on quit + mainEvents.handle('extensions/shutdown', this.triggerExtensionShutdown); } /** @@ -573,7 +576,15 @@ export class ExtensionManagerImpl implements ExtensionManager { await Promise.allSettled(Object.values(this.processes).map((proc) => { proc.deref()?.kill(); })); + + mainEvents.handle('extensions/shutdown', undefined); } + + triggerExtensionShutdown = async() => { + await Promise.all((await this.getInstalledExtensions()).map((extension) => { + return extension.shutdown(); + })); + }; } function getExtensionManager(): Promise { diff --git a/pkg/rancher-desktop/main/extensions/types.ts b/pkg/rancher-desktop/main/extensions/types.ts index 80c8c75715a..8679ef8a9dd 100644 --- a/pkg/rancher-desktop/main/extensions/types.ts +++ b/pkg/rancher-desktop/main/extensions/types.ts @@ -50,6 +50,13 @@ export type ExtensionMetadata = { * `binaries`. Errors will be ignored. */ 'x-rd-uninstall'?: PlatformSpecific, + /** + * Rancher Desktop extension: this will be executed when the application + * quits. The application may exit before the process completes. It is not + * defined what the container engine / Kubernetes cluster may be doing at + * the time this is called. + */ + 'x-rd-shutdown'?: PlatformSpecific, }; }; diff --git a/pkg/rancher-desktop/main/mainEvents.ts b/pkg/rancher-desktop/main/mainEvents.ts index dcb7c1a0666..1979f247114 100644 --- a/pkg/rancher-desktop/main/mainEvents.ts +++ b/pkg/rancher-desktop/main/mainEvents.ts @@ -11,6 +11,12 @@ import type { TransientSettings } from '@pkg/config/transientSettings'; import { DiagnosticsCheckerResult } from '@pkg/main/diagnostics/types'; import { RecursivePartial, RecursiveReadonly } from '@pkg/utils/typeUtils'; +export class NoMainEventsHandlerError extends Error { + constructor(eventName: string) { + super(`No handlers registered for mainEvents::${ eventName }`); + } +} + /** * MainEventNames describes the events available over the MainEvents event * emitter. All normal events are described as methods returning void, with @@ -119,6 +125,11 @@ interface MainEventNames { */ 'extensions/ui/uninstall'(id: string): void; + /** + * Emitted on application quit; this is used to shut down extensions. + */ + 'extensions/shutdown'(): Promise; + /** * Emitted on application quit, used to shut down any integrations. This * requires feedback from the handler to know when all tasks are complete. @@ -223,17 +234,26 @@ interface MainEvents extends EventEmitter { ...args: HandlerParams): Promise>; /** - * Register a handler that will handle invoke() callers. + * Invoke a handler that returns a promise of a result. Unlike `invoke`, this + * does not raise an exception if the event handler is not registered. + */ + tryInvoke( + event: IsHandler extends true ? eventName : never, + ...args: HandlerParams): Promise | undefined>; + + /** + * Register a handler that will handle invoke() callers. If the given handler + * is `undefined`, unregister it instead. */ handle( event: IsHandler extends true ? eventName : never, - handler: HandlerType + handler: HandlerType | undefined, ): void; } class MainEventsImpl extends EventEmitter implements MainEvents { handlers: { - [eventName in keyof MainEventNames]?: HandlerType | undefined; + [eventName in keyof MainEventNames]?: IsHandler extends true ? HandlerType : never; } = {}; emit( @@ -281,14 +301,27 @@ class MainEventsImpl extends EventEmitter implements MainEvents { if (handler) { return await handler(...args); } - throw new Error(`No handlers registered for mainEvents::${ event }`); + throw new NoMainEventsHandlerError(event); + } + + async tryInvoke( + event: IsHandler extends true ? eventName : never, + ...args: HandlerParams + ): Promise | undefined> { + const handler: HandlerType | undefined = this.handlers[event]; + + return await handler?.(...args); } handle( event: IsHandler extends true ? eventName : never, - handler: HandlerType, + handler: HandlerType | undefined, ): void { - this.handlers[event] = handler as any; + if (handler) { + this.handlers[event] = handler as any; + } else { + delete this.handlers[event]; + } } } const mainEvents: MainEvents = new MainEventsImpl();