diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 06bbbe9e3c..012794a39e 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -24,6 +24,7 @@ Each guide or code sample includes the following: | [KubeObjectListLayout](kube-object-list-layout.md) | | | [Working with mobx](working-with-mobx.md) | | | [Protocol Handlers](protocol-handlers.md) | | +| [Sending Data between main and renderer](ipc.md) | | ## Samples diff --git a/docs/extensions/guides/ipc.md b/docs/extensions/guides/ipc.md new file mode 100644 index 0000000000..e0014768ea --- /dev/null +++ b/docs/extensions/guides/ipc.md @@ -0,0 +1,92 @@ +# Inter Process Communication + +A Lens Extension can utilise IPC to send information between its `LensRendererExtension` and its `LensMainExtension`. +This is useful when wanting to communicate directly within your extension. +For example, if a user logs into a service that your extension is a facade for and `main` needs to know some information so that you can start syncing items to the `Catalog`, this would be a good way to send that information along. + +IPC channels are blocked off per extension. +Meaning that each extension can only communicate with itself. + +## Types of IPC + +There are two flavours of IPC that are provided: + +- Event based +- Request based + +### Event Based IPC + +This is the same as an [Event Emitter](https://nodejs.org/api/events.html#events_class_eventemitter) but is not limited to just one Javascript process. +This is a good option when you need to report that something has happened but you don't need a response. + +This is a fully two-way form of communication. +Both `LensMainExtension` and `LensRendererExtension` can do this sort of IPC. + +### Request Based IPC + +This is more like a Remote Procedure Call (RPC). +With this sort of IPC the caller waits for the result from the other side. +This is accomplished by returning a `Promise` which needs to be `await`-ed. + +This is a unidirectional form of communication. +Only `LensRendererExtension` can initiate this kind of request, and only `LensMainExtension` and respond this this kind of request. + +## Registering IPC Handlers and Listeners + +The general terminology is as follows: + +- A "handler" is the function that responds to a "Request Based IPC" event. +- A "listener" is the function that is called when a "Event Based IPC" event is emitted. + +To register either a handler or a listener, you should do something like the following: + +```typescript +import { LensMainExtension, Interface, Types } from "@k8slens/extensions"; + +export class ExampleExtensionMain extends LensMainExtension { + onActivate() { + this.disposers.push( + this.listenIpc({ + channel: "initialize", + listener: this.initializeListener, + verifier: this.initializeVerifier, + }) + ); + } + + initializeListener = (event: Types.IpcMainEvent, uid: string) => { + console.log(`starting to initialize: ${uid}`); + }; + + initializeVerifier = (args: unknown[]): args is [uid: string] => { + return args.length === 1 && typeof args[0] === "string"; + } +} +``` + +If you want to register a "handler" you would call `this.handleIpc(...)` instead. + +The `LensExtension.prototype.disopsers` is a list of `() => void`'s (or `Utils.Disposer`'s). +Those functions are sort of like destructors in that they should clean up some state. +Some functions return them to indicate that it will clean up the state which was just registered. + +As an extension developer you do not need to run them yourself. +Lens will run them when your extension gets deactivated or uninstalled. + +### Note about verification + +We require that extension developers provide a verification function when registering a listener or handler. +This is done as a preventative measure to help separate issues that can happen at runtime. +While it is possible to use the unary truth function, this is highly discouraged. + +The verification function should do some cursory validation on the values send along the channel. +Your handler or listener will not be called if it fails this validation. +Instead an error or log message will occur. +This should help with debugging because you are notified immediately that there is a mismatch between what you are expecting and what was sent. + +## Using IPC + +Calling IPC is very simple. +If you are meaning to do an event based call, merely call `this.sendIpc(, ...)` from within your extension. + +If you are meaning to do a request based call from `renderer`, you should do `const res = await this.invokeIpc(, ...));` instead. diff --git a/mkdocs.yml b/mkdocs.yml index 0496f3001e..fe42cbccf5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -35,6 +35,7 @@ nav: - Stores: extensions/guides/stores.md - Working with MobX: extensions/guides/working-with-mobx.md - Protocol Handlers: extensions/guides/protocol-handlers.md + - IPC: extensions/guides/ipc.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md - Publishing Extensions: extensions/testing-and-publishing/publishing.md diff --git a/src/common/ipc/type-enforced-ipc.ts b/src/common/ipc/type-enforced-ipc.ts index 1c4c7d301d..035ffcd77e 100644 --- a/src/common/ipc/type-enforced-ipc.ts +++ b/src/common/ipc/type-enforced-ipc.ts @@ -19,10 +19,12 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { ipcMain } from "electron"; import { EventEmitter } from "events"; import logger from "../../main/logger"; +import { Disposer } from "../utils"; -export type HandlerEvent = Parameters[1]>[0]; +export type ListenerEvent = Parameters[1]>[0]; export type ListVerifier = (args: unknown[]) => args is T; export type Rest = T extends [any, ...infer R] ? R : []; @@ -34,22 +36,22 @@ export type Rest = T extends [any, ...infer R] ? R : []; * @param verifier The function to be called to verify that the args are the correct type */ export function onceCorrect< - EM extends EventEmitter, - L extends (event: HandlerEvent, ...args: any[]) => any + IPC extends EventEmitter, + Listener extends (event: ListenerEvent, ...args: any[]) => any >({ source, channel, listener, verifier, }: { - source: EM, - channel: string | symbol, - listener: L, - verifier: ListVerifier>>, + source: IPC, + channel: string, + listener: Listener, + verifier: ListVerifier>>, }): void { - function handler(event: HandlerEvent, ...args: unknown[]): void { + function wrappedListener(event: ListenerEvent, ...args: unknown[]): void { if (verifier(args)) { - source.removeListener(channel, handler); // remove immediately + source.removeListener(channel, wrappedListener); // remove immediately (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject .catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error })); @@ -58,7 +60,7 @@ export function onceCorrect< } } - source.on(channel, handler); + source.on(channel, wrappedListener); } /** @@ -68,25 +70,53 @@ export function onceCorrect< * @param verifier The function to be called to verify that the args are the correct type */ export function onCorrect< - EM extends EventEmitter, - L extends (event: HandlerEvent, ...args: any[]) => any + IPC extends EventEmitter, + Listener extends (event: ListenerEvent, ...args: any[]) => any >({ source, channel, listener, verifier, }: { - source: EM, - channel: string | symbol, - listener: L, - verifier: ListVerifier>>, -}): void { - source.on(channel, (event, ...args: unknown[]) => { + source: IPC, + channel: string, + listener: Listener, + verifier: ListVerifier>>, +}): Disposer { + function wrappedListener(event: ListenerEvent, ...args: unknown[]) { if (verifier(args)) { (async () => (listener(event, ...args)))() // might return a promise, or throw, or reject .catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error })); } else { logger.error("[IPC]: channel was emitted with invalid data", { channel, args }); } - }); + } + + source.on(channel, wrappedListener); + + return () => source.off(channel, wrappedListener); +} + +export function handleCorrect< + Handler extends (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, +>({ + channel, + handler, + verifier, +}: { + channel: string, + handler: Handler, + verifier: ListVerifier>>, +}): Disposer { + function wrappedHandler(event: Electron.IpcMainInvokeEvent, ...args: unknown[]): ReturnType { + if (verifier(args)) { + return handler(event, ...args); + } + + throw new TypeError(`Invalid args for invoke on channel: ${channel}`); + } + + ipcMain.handle(channel, wrappedHandler); + + return () => ipcMain.removeHandler(channel); } diff --git a/src/extensions/core-api/index.ts b/src/extensions/core-api/index.ts index 258fdbb6af..4fdca344d9 100644 --- a/src/extensions/core-api/index.ts +++ b/src/extensions/core-api/index.ts @@ -31,6 +31,7 @@ import * as Util from "./utils"; import * as ClusterFeature from "./cluster-feature"; import * as Interface from "../interfaces"; import * as Catalog from "./catalog"; +import * as Types from "./types"; export { App, @@ -39,5 +40,6 @@ export { ClusterFeature, Interface, Store, + Types, Util, }; diff --git a/src/extensions/core-api/types.ts b/src/extensions/core-api/types.ts new file mode 100644 index 0000000000..ed78b5c017 --- /dev/null +++ b/src/extensions/core-api/types.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export type IpcMainInvokeEvent = Electron.IpcMainInvokeEvent; +export type IpcRendererEvent = Electron.IpcRendererEvent; +export type IpcMainEvent = Electron.IpcMainEvent; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index cf9077209c..4b0266332b 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -23,7 +23,10 @@ import type { InstalledExtension } from "./extension-discovery"; import { action, observable, reaction } from "mobx"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; -import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry"; +import { ProtocolHandlerRegistration } from "./registries"; +import { disposer } from "../common/utils"; +import { createHash } from "crypto"; +import { broadcastMessage } from "../common/ipc"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -37,21 +40,26 @@ export interface LensExtensionManifest { lens?: object; // fixme: add more required fields for validation } +export const IpcPrefix = Symbol(); + export class LensExtension { readonly id: LensExtensionId; readonly manifest: LensExtensionManifest; readonly manifestPath: string; readonly isBundled: boolean; + readonly [IpcPrefix]: string; protocolHandlers: ProtocolHandlerRegistration[] = []; @observable private isEnabled = false; + disposers = disposer(); constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { this.id = id; this.manifest = manifest; this.manifestPath = manifestPath; this.isBundled = !!isBundled; + this[IpcPrefix] = createHash("sha256").update(this.id).digest("hex"); } get name() { @@ -62,6 +70,10 @@ export class LensExtension { return this.manifest.version; } + get description() { + return this.manifest.description; + } + /** * getExtensionFileFolder returns the path to an already created folder. This * folder is for the sole use of this extension. @@ -73,15 +85,11 @@ export class LensExtension { return FilesystemProvisionerStore.getInstance().requestDirectory(this.id); } - get description() { - return this.manifest.description; - } - @action async enable() { if (this.isEnabled) return; this.isEnabled = true; - this.onActivate(); + this.onActivate?.(); logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); } @@ -89,7 +97,8 @@ export class LensExtension { async disable() { if (!this.isEnabled) return; this.isEnabled = false; - this.onDeactivate(); + this.onDeactivate?.(); + this.disposers(); logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); } @@ -125,13 +134,12 @@ export class LensExtension { }; } - protected onActivate() { - // mock + sendIpc(channel: string, ...args: any[]): void { + broadcastMessage(`extensions@${this[IpcPrefix]}:${channel}`, ...args); } - protected onDeactivate() { - // mock - } + protected onActivate?: () => void; + protected onDeactivate?: () => void; } export function sanitizeExtensionName(name: string) { diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 7ae2f06693..e5decc8a04 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -19,12 +19,15 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { MenuRegistration } from "./registries/menu-registry"; -import { LensExtension } from "./lens-extension"; +import { IpcPrefix, LensExtension } from "./lens-extension"; import { WindowManager } from "../main/window-manager"; import { getExtensionPageUrl } from "./registries/page-registry"; import { CatalogEntity, catalogEntityRegistry } from "../common/catalog"; import { IObservableArray } from "mobx"; +import { IpcHandlerRegistration, MenuRegistration } from "./registries"; +import type { Disposer } from "../common/utils"; +import { handleCorrect, ListenerEvent, ListVerifier, onCorrect, Rest } from "../common/ipc"; +import { ipcMain, IpcMain } from "electron"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; @@ -47,4 +50,31 @@ export class LensMainExtension extends LensExtension { removeCatalogSource(id: string) { catalogEntityRegistry.removeSource(`${this.name}:${id}`); } + + handleIpc< + Handler extends (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, + >({ channel, ...reg }: IpcHandlerRegistration): Disposer { + // This is to have a uniform length prefix so that two extensions cannot talk to each other's IPCs accidentally + return handleCorrect({ + channel: `extensions@${this[IpcPrefix]}:${channel}`, + ...reg, + }); + } + + listenIpc< + Listener extends (event: ListenerEvent, ...args: any[]) => any + >({ + channel, + ...reg + }: { + channel: string, + listener: Listener, + verifier: ListVerifier>>, + }): Disposer { + return onCorrect({ + source: ipcMain, + channel: `extensions@${this[IpcPrefix]}:${channel}`, + ...reg, + }); + } } diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 232040ac9f..0a90ed63a4 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -24,10 +24,13 @@ import type { KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; -import { LensExtension } from "./lens-extension"; +import { IpcPrefix, LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; import { CommandRegistration } from "./registries/command-registry"; import { EntitySettingRegistration } from "./registries/entity-setting-registry"; +import { ipcRenderer, IpcRenderer } from "electron"; +import { ListenerEvent, ListVerifier, onCorrect, Rest } from "../common/ipc"; +import { Disposer } from "../common/utils"; export class LensRendererExtension extends LensExtension { globalPages: PageRegistration[] = []; @@ -60,4 +63,25 @@ export class LensRendererExtension extends LensExtension { async isEnabledForCluster(cluster: Cluster): Promise { return (void cluster) || true; } + + listenIpc< + Listener extends (event: ListenerEvent, ...args: any[]) => any + >({ + channel, + ...reg + }: { + channel: string, + listener: Listener, + verifier: ListVerifier>>, + }): Disposer { + return onCorrect({ + source: ipcRenderer, + channel: `extensions@${this[IpcPrefix]}:${channel}`, + ...reg, + }); + } + + invokeIpc(channel: string, ...args: any[]): Promise { + return ipcRenderer.invoke(`extensions@${this[IpcPrefix]}:${channel}`, ...args); + } } diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 13d07b2249..cde196f92a 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -32,3 +32,5 @@ export * from "./kube-object-status-registry"; export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./welcome-menu-registry"; +export * from "./protocol-handler-registry"; +export * from "./ipc"; diff --git a/src/extensions/registries/ipc.ts b/src/extensions/registries/ipc.ts new file mode 100644 index 0000000000..4195de4976 --- /dev/null +++ b/src/extensions/registries/ipc.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { ListVerifier, Rest } from "../../common/ipc"; + +export interface IpcHandlerRegistration< + Handler extends (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any, +> { + channel: string; + handler: Handler, + verifier: ListVerifier>>, +} + +export interface IpcListenerRegisration { + +}