diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 1d13b0aea4..635763c6f6 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -14,14 +14,6 @@ const subFrames = createTypedInvoker({ verifier: isEmptyArgs, }); -export function handleRequest(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) { - ipcMain.handle(channel, listener); -} - -export async function requestMain(channel: string, ...args: any[]) { - return ipcRenderer.invoke(channel, ...args); -} - function getSubFrames(): ClusterFrameInfo[] { return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true }); } diff --git a/src/common/ipc/update-available.ipc.ts b/src/common/ipc/update-available.ipc.ts index e8aea9cf2a..3e15bdd42b 100644 --- a/src/common/ipc/update-available.ipc.ts +++ b/src/common/ipc/update-available.ipc.ts @@ -1,6 +1,6 @@ import { UpdateInfo } from "electron-updater"; import { UpdateFileInfo, ReleaseNoteInfo } from "builder-util-runtime"; -import { bindTypeGuard, createUnionGuard, hasOptionalProperty, hasTypedProperty, isNull, isObject, isString, isTypedArray, isNumber, isBoolean } from "../utils/type-narrowing"; +import { bindTypeGuard, unionTypeGuard, hasOptionalProperty, hasTypedProperty, isNull, isObject, isString, isTypedArray, isNumber, isBoolean } from "../utils/type-narrowing"; import { createTypedSender } from "./type-enforced-ipc"; export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]"; @@ -23,7 +23,7 @@ function isUpdateFileInfo(src: unknown): src is UpdateFileInfo { function isReleaseNoteInfo(src: unknown): src is ReleaseNoteInfo { return isObject(src) && hasTypedProperty(src, "version", isString) - && hasTypedProperty(src, "note", createUnionGuard(isString, isNull)); + && hasTypedProperty(src, "note", unionTypeGuard(isString, isNull)); } function isUpdateInfo(src: unknown): src is UpdateInfo { @@ -31,8 +31,8 @@ function isUpdateInfo(src: unknown): src is UpdateInfo { && hasTypedProperty(src, "version", isString) && hasTypedProperty(src, "releaseDate", isString) && hasTypedProperty(src, "files", bindTypeGuard(isTypedArray, isUpdateFileInfo)) - && hasOptionalProperty(src, "releaseName", createUnionGuard(isString, isNull)) - && hasOptionalProperty(src, "releaseNotes", createUnionGuard(isString, isReleaseNoteInfo, isNull)) + && hasOptionalProperty(src, "releaseName", unionTypeGuard(isString, isNull)) + && hasOptionalProperty(src, "releaseNotes", unionTypeGuard(isString, isReleaseNoteInfo, isNull)) && hasOptionalProperty(src, "stagingPercentage", isNumber); } diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts index 57c8b79184..7458586f77 100644 --- a/src/common/utils/type-narrowing.ts +++ b/src/common/utils/type-narrowing.ts @@ -1,3 +1,12 @@ +type TypeGuard = (arg: unknown) => arg is T; +type Rest = T extends [any, ...infer R] ? R : any; +type First = T extends [infer R, ...any[]] ? R : any; +type TypeGuardReturnType src is any> = T extends (src: unknown) => src is infer R ? R : any; +type UnionTypeGuardReturnType[]> = TypeGuardReturnType> | (T extends [any] ? never : UnionTypeGuardReturnType>); +type TupleReturnType[]> = { + [K in keyof T]: T[K] extends TypeGuard ? T : never +}; + /** * Narrows `val` to include the property `key` (if true is returned) * @param val The object to be tested @@ -60,6 +69,17 @@ export function isTypedArray(val: unknown, isEntry: (entry: unknown) => entry return Array.isArray(val) && val.every(isEntry); } +/** + * checks to see if `src` is a tuple with elements matching each of the type guards + * @param src The value to be checked + * @param typeguards the list of type-guards to check each element + */ +export function isTuple[]>(src: unknown, ...typeguards: T): src is TupleReturnType { + return Array.isArray(src) + && (src.length <= typeguards.length) + && typeguards.every((typeguard, i) => typeguard(src[i])); +} + /** * checks if val is of type string * @param val the value to be checked @@ -112,30 +132,28 @@ export function isNull(val: unknown): val is null { * ``` * bindTypeGuard(isTypedArray, isString); // Predicate * bindTypeGuard(isRecord, isString, isBoolean); // Predicate> + * bindTypeGuard(isTuple, isString, isBoolean); // Predicate<[string, boolean]> + * + * Note: this function does not currently nest as a direct argument to itself. + * It needs to be extracted to a variable for typescript's type checker to work. * ``` */ -export function bindTypeGuard(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): Predicate { +export function bindTypeGuard(fn: (arg1: unknown, ...args: FnArgs) => arg1 is T, ...boundArgs: FnArgs): TypeGuard { return (arg1: unknown): arg1 is T => fn(arg1, ...boundArgs); } -type Predicate = (arg: unknown) => arg is T; -type Rest = T extends [any, ...infer R] ? R : any; -type First = T extends [infer R, ...any[]] ? R : any; -type ReturnPredicateType src is any> = T extends (src: unknown) => src is infer R ? R : any; -type OrReturnPredicateType[]> = ReturnPredicateType> | (T extends [any] ? never : OrReturnPredicateType>); - /** * Create a new type-guard for the union of the types that each of the * predicates are type-guarding for - * @param predicates a list of predicates that should be executed in order + * @param typeGuards a list of predicates that should be executed in order * * Example: * ``` * createUnionGuard(isString, isBoolean); // Predicate * ``` */ -export function createUnionGuard[]>(...predicates: Predicates): Predicate> { - return (arg: unknown): arg is OrReturnPredicateType => { - return predicates.some(predicate => predicate(arg)); +export function unionTypeGuard[]>(...typeGuards: TypeGuards): TypeGuard> { + return (arg: unknown): arg is UnionTypeGuardReturnType => { + return typeGuards.some(typeguard => typeguard(arg)); }; } diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 49bafabe3f..11cd8746f4 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -7,26 +7,36 @@ import os from "os"; import path from "path"; import { createTypedInvoker, createTypedSender, isEmptyArgs } from "../common/ipc"; import { getBundledExtensions } from "../common/utils/app-version"; -import { hasTypedProperty, isBoolean } from "../common/utils/type-narrowing"; +import { bindTypeGuard, hasTypedProperty, isBoolean, isObject, isString, isTuple } from "../common/utils/type-narrowing"; import logger from "../main/logger"; import { extensionInstaller, PackageJson } from "./extension-installer"; import { extensionsStore } from "./extensions-store"; -import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; +import { LensExtensionId, LensExtensionManifest, isLensExtensionManifest } from "./lens-extension"; export interface InstalledExtension { - id: LensExtensionId; + id: LensExtensionId; - readonly manifest: LensExtensionManifest; + readonly manifest: LensExtensionManifest; - // Absolute path to the non-symlinked source folder, - // e.g. "/Users/user/.k8slens/extensions/helloworld" - readonly absolutePath: string; + // Absolute path to the non-symlinked source folder, + // e.g. "/Users/user/.k8slens/extensions/helloworld" + readonly absolutePath: string; - // Absolute to the symlinked package.json file - readonly manifestPath: string; - readonly isBundled: boolean; // defined in project root's package.json - isEnabled: boolean; - } + // Absolute to the symlinked package.json file + readonly manifestPath: string; + readonly isBundled: boolean; // defined in project root's package.json + isEnabled: boolean; +} + +export function isInstalledExtension(src: unknown): src is InstalledExtension { + return isObject(src) + && hasTypedProperty(src, "id", isString) + && hasTypedProperty(src, "manifest", isLensExtensionManifest) + && hasTypedProperty(src, "absolutePath", isString) + && hasTypedProperty(src, "manifestPath", isString) + && hasTypedProperty(src, "isBundled", isBoolean) + && hasTypedProperty(src, "isEnabled", isBoolean); +} const logModule = "[EXTENSION-DISCOVERY]"; @@ -34,11 +44,6 @@ export const manifestFilename = "package.json"; type DiscoveryLoadingState = [isLoaded: boolean]; -function isExtensionDiscoveryChannelMessage(args: unknown[]): args is DiscoveryLoadingState { - return hasTypedProperty(args, 0, isBoolean) - && args.length === 0; -} - /** * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * @param lstat the stats to compare @@ -49,7 +54,7 @@ function isDirectoryLike(lstat: fs.Stats): boolean { const extensionDiscoveryState = createTypedSender({ channel: "extension-discovery:state", - verifier: isExtensionDiscoveryChannelMessage, + verifier: bindTypeGuard(isTuple, isBoolean), }); function ExtensionDiscoveryInitState(): boolean { diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 98697d252c..5ac35146b1 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -4,7 +4,7 @@ import { isEqual } from "lodash"; import { action, computed, observable, reaction, toJS, when } from "mobx"; import path from "path"; import { getHostedCluster } from "../common/cluster-store"; -import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; +import { createTypedInvoker, createTypedSender, isEmptyArgs } from "../common/ipc"; import logger from "../main/logger"; import type { InstalledExtension } from "./extension-discovery"; import { extensionsStore } from "./extensions-store"; @@ -13,6 +13,7 @@ import type { LensMainExtension } from "./lens-main-extension"; import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; import fs from "fs"; +import { bindTypeGuard, isString, isTuple, isTypedArray } from "../common/utils/type-narrowing"; // lazy load so that we get correct userData export function extensionPackagesRoot() { @@ -21,6 +22,23 @@ export function extensionPackagesRoot() { const logModule = "[EXTENSIONS-LOADER]"; +type InstalledExtensions = [extId: string, metadata: InstalledExtension][]; + +function isInstalledExtensions(args: unknown[]): args is InstalledExtensions { + return isTypedArray(args, bindTypeGuard(isTuple, isString, isInstalledExtensions)); +} + +const installedExtensions = createTypedSender({ + channel: "extensions:installed", + verifier: bindTypeGuard(isTuple, isInstalledExtensions), +}); + +const initialInstalledExtensions = createTypedInvoker({ + channel: "extensions:initial-installed", + verifier: isEmptyArgs, + handler: () => extensionLoader.toBroadcastData(), +}); + /** * Loads installed extensions to the Lens application */ @@ -28,12 +46,6 @@ export class ExtensionLoader { protected extensions = observable.map(); protected instances = observable.map(); - // IPC channel to broadcast changes to extensions from main - protected static readonly extensionsMainChannel = "extensions:main"; - - // IPC channel to broadcast changes to extensions from renderer - protected static readonly extensionsRendererChannel = "extensions:renderer"; - // emits event "remove" of type LensExtension when the extension is removed private events = new EventEmitter(); @@ -55,20 +67,24 @@ export class ExtensionLoader { // Transform userExtensions to a state object for storing into ExtensionsStore @computed get storeState() { return Object.fromEntries( - Array.from(this.userExtensions) - .map(([extId, extension]) => [extId, { - enabled: extension.isEnabled, - name: extension.manifest.name, - }]) + Array.from(this.userExtensions, ([extId, extension]) => [extId, { + enabled: extension.isEnabled, + name: extension.manifest.name, + }]) ); } @action async init() { + installedExtensions.on((event, extensions) => { + this.isLoaded = true; + this.syncExtensions(extensions); + }); + + reaction(() => this.toBroadcastData(), installedExtensions.broadcast); + if (ipcRenderer) { await this.initRenderer(); - } else { - await this.initMain(); } await Promise.all([this.whenLoaded, extensionsStore.whenLoaded]); @@ -113,54 +129,33 @@ export class ExtensionLoader { } } - protected async initMain() { - this.isLoaded = true; - this.loadOnMain(); - - reaction(() => this.toJSON(), () => { - this.broadcastExtensions(); - }); - - handleRequest(ExtensionLoader.extensionsMainChannel, () => { - return Array.from(this.toJSON()); - }); - - subscribeToBroadcast(ExtensionLoader.extensionsRendererChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => { - this.syncExtensions(extensions); - }); - } - protected async initRenderer() { - const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { - this.isLoaded = true; - this.syncExtensions(extensions); + const initial = await initialInstalledExtensions.invoke(); - const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); - - // Remove deleted extensions in renderer side only - this.extensions.forEach((_, lensExtensionId) => { - if (!receivedExtensionIds.includes(lensExtensionId)) { - this.removeExtension(lensExtensionId); - } - }); - }; - - reaction(() => this.toJSON(), () => { - this.broadcastExtensions(false); - }); - - requestMain(ExtensionLoader.extensionsMainChannel).then(extensionListHandler); - subscribeToBroadcast(ExtensionLoader.extensionsMainChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => { - extensionListHandler(extensions); - }); + this.isLoaded = true; + this.syncExtensions(initial); } - syncExtensions(extensions: [LensExtensionId, InstalledExtension][]) { - extensions.forEach(([lensExtensionId, extension]) => { - if (!isEqual(this.extensions.get(lensExtensionId), extension)) { - this.extensions.set(lensExtensionId, extension); + @action + protected syncExtensions(extensions: InstalledExtensions) { + const receivedExtIds = new Set(); + + for (const [extId, metadata] of extensions) { + receivedExtIds.add(extId); + + if (!isEqual(this.extensions.get(extId), metadata)) { + this.extensions.set(extId, metadata); } - }); + } + + if (ipcRenderer) { + // Remove deleted extensions in renderer side only + for (const extId of this.extensions.keys()) { + if (!receivedExtIds.has(extId)) { + this.removeExtension(extId); + } + } + } } loadOnMain() { @@ -236,7 +231,7 @@ export class ExtensionLoader { } protected autoInitExtensions(register: (ext: LensExtension) => Promise) { - return reaction(() => this.toJSON(), installedExtensions => { + return reaction(() => this.toBroadcastData(), installedExtensions => { for (const [extId, extension] of installedExtensions) { const alreadyInit = this.instances.has(extId); @@ -294,16 +289,12 @@ export class ExtensionLoader { return this.extensions.get(extId); } - toJSON(): Map { - return toJS(this.extensions, { + toBroadcastData(): [LensExtensionId, InstalledExtension][] { + return toJS(Array.from(this.extensions.entries()), { exportMapsAsObjects: false, recurseEverything: true, }); } - - broadcastExtensions(main = true) { - broadcastMessage(main ? ExtensionLoader.extensionsMainChannel : ExtensionLoader.extensionsRendererChannel, Array.from(this.toJSON())); - } } export const extensionLoader = new ExtensionLoader(); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index aaa6f60ac5..6f4281a1e1 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery"; import { action, observable, reaction } from "mobx"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; +import { hasOptionalProperty, hasTypedProperty, isObject, isString } from "../common/utils/type-narrowing"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -15,6 +16,15 @@ export interface LensExtensionManifest { lens?: object; // fixme: add more required fields for validation } +export function isLensExtensionManifest(src: unknown): src is LensExtensionManifest { + return isObject(src) + && hasTypedProperty(src, "version", isString) + && hasOptionalProperty(src, "description", isString) + && hasOptionalProperty(src, "main", isString) + && hasOptionalProperty(src, "renderer", isString) + && hasOptionalProperty(src, "lens", isObject); +} + export class LensExtension { readonly id: LensExtensionId; readonly manifest: LensExtensionManifest;