diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 5261c604d6..b2ba812b0a 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -2,7 +2,7 @@ import path from "path" import Config from "conf" import { Options as ConfOptions } from "conf/dist/source/types" import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron" -import { action, observable, reaction, runInAction, toJS, when } from "mobx"; +import { action, IReactionOptions, observable, reaction, runInAction, toJS, when } from "mobx"; import Singleton from "./utils/singleton"; import { getAppVersion } from "./utils/app-version"; import logger from "../main/logger"; @@ -12,6 +12,7 @@ import isEqual from "lodash/isEqual"; export interface BaseStoreParams extends ConfOptions { autoLoad?: boolean; syncEnabled?: boolean; + syncOptions?: IReactionOptions; } export class BaseStore extends Singleton { @@ -20,7 +21,7 @@ export class BaseStore extends Singleton { whenLoaded = when(() => this.isLoaded); @observable isLoaded = false; - @observable protected data: T; + @observable data = {} as T; protected constructor(protected params: BaseStoreParams) { super(); @@ -36,8 +37,12 @@ export class BaseStore extends Singleton { return path.basename(this.storeConfig.path); } + get path() { + return this.storeConfig.path; + } + get syncChannel() { - return `store-sync:${this.name}` + return `STORE-SYNC:${this.path}` } protected async init() { @@ -56,19 +61,19 @@ export class BaseStore extends Singleton { ...confOptions, projectName: "lens", projectVersion: getAppVersion(), - cwd: this.storePath(), + cwd: this.cwd(), }); - logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`); + logger.info(`[STORE]: LOADED from ${this.path}`); this.fromStore(this.storeConfig.store); this.isLoaded = true; } - protected storePath() { + protected cwd() { return (app || remote.app).getPath("userData") } protected async saveToFile(model: T) { - logger.info(`[STORE]: SAVING ${this.name}`); + logger.info(`[STORE]: SAVING ${this.path}`); // todo: update when fixed https://github.com/sindresorhus/conf/issues/114 Object.entries(model).forEach(([key, value]) => { this.storeConfig.set(key, value); @@ -77,7 +82,7 @@ export class BaseStore extends Singleton { enableSync() { this.syncDisposers.push( - reaction(() => this.toJSON(), model => this.onModelChange(model)), + reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions), ); if (ipcMain) { const callback = (event: IpcMainEvent, model: T) => { @@ -169,6 +174,7 @@ export class BaseStore extends Singleton { @action protected fromStore(data: T) { + if (!data) return; this.data = data; } diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 963b235b73..64c4270439 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -1,42 +1,28 @@ -import type { LensExtension, LensExtensionConstructor, LensExtensionId, LensExtensionManifest, LensExtensionStoreModel } from "./lens-extension" +import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension" import type { LensMainExtension } from "./lens-main-extension" import type { LensRendererExtension } from "./lens-renderer-extension" +import type { InstalledExtension } from "./extension-manager"; import path from "path" import { broadcastIpc } from "../common/ipc" -import { action, autorun, computed, observable, reaction, toJS } from "mobx" +import { computed, observable, reaction, when } from "mobx" import logger from "../main/logger" import { app, ipcRenderer, remote } from "electron" -import { BaseStore } from "../common/base-store"; import * as registries from "./registries"; -export interface ExtensionLoaderStoreModel { - extensions: LensExtensionStoreModel[] -} - -export interface InstalledExtension { - manifest: LensExtensionManifest; - manifestPath: string; - isBundled?: boolean; // defined in package.json -} - // lazy load so that we get correct userData export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")) } -export class ExtensionLoader extends BaseStore { - protected disposers = new Map(); - - @observable extensions = observable.map([], { deep: false }); - @observable instances = observable.map([], { deep: false }) - @observable state = observable.map(); +export class ExtensionLoader { + @observable isLoaded = false; + protected extensions = observable.map([], { deep: false }); + protected instances = observable.map([], { deep: false }) constructor() { - super({ - configName: "lens-extensions", - }); if (ipcRenderer) { ipcRenderer.on("extensions:loaded", (event, extensions: InstalledExtension[]) => { + this.isLoaded = true; extensions.forEach((ext) => { if (!this.extensions.has(ext.manifestPath)) { this.extensions.set(ext.manifestPath, ext) @@ -50,53 +36,41 @@ export class ExtensionLoader extends BaseStore { return [...this.instances.values()].filter(ext => !ext.isBundled) } + async init() { + const { extensionManager } = await import("./extension-manager"); + const installedExtensions = await extensionManager.load(); + this.extensions.replace(installedExtensions); + this.isLoaded = true; + this.loadOnMain(); + } + loadOnMain() { logger.info('[EXTENSIONS-LOADER]: load on main') - this.autoInitExtensions(); - this.autoEnableExtensions((extension: LensMainExtension) => [ + this.autoInitExtensions((extension: LensMainExtension) => [ registries.menuRegistry.add(...extension.appMenus) - ]) + ]); } loadOnClusterManagerRenderer() { logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)') - this.autoInitExtensions(); - this.autoEnableExtensions((extension: LensRendererExtension) => [ + this.autoInitExtensions((extension: LensRendererExtension) => [ registries.globalPageRegistry.add(...extension.globalPages), registries.appPreferenceRegistry.add(...extension.appPreferences), registries.clusterFeatureRegistry.add(...extension.clusterFeatures), registries.statusBarRegistry.add(...extension.statusBarItems), - ]) + ]); } loadOnClusterRenderer() { logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)') - this.autoInitExtensions(); - this.autoEnableExtensions((extension: LensRendererExtension) => [ + this.autoInitExtensions((extension: LensRendererExtension) => [ registries.clusterPageRegistry.add(...extension.clusterPages), registries.kubeObjectMenuRegistry.add(...extension.kubeObjectMenuItems), registries.kubeObjectDetailRegistry.add(...extension.kubeObjectDetailItems), - ]) + ]); } - protected autoEnableExtensions(register: (ext: LensExtension) => Function[]) { - return autorun(() => { - this.instances.forEach(ext => { - const extensionState = this.state.get(ext.id); - const isEnabled = !extensionState /*enabled by default*/ || extensionState.isEnabled; - const isRegistered = this.disposers.has(ext.id); - if (isEnabled && !isRegistered) { - this.disposers.set(ext.id, register(ext)) - } else if (!isEnabled && isRegistered) { - this.disposers.get(ext.id).forEach(dispose => dispose()) - this.disposers.delete(ext.id) - } - ext.toggle(isEnabled); - }) - }) - } - - protected autoInitExtensions() { + protected autoInitExtensions(register: (ext: LensExtension) => Function[]) { return reaction(() => this.extensions.toJS(), (installedExtensions) => { for (const [id, ext] of installedExtensions) { let instance = this.instances.get(ext.manifestPath) @@ -108,6 +82,7 @@ export class ExtensionLoader extends BaseStore { try { const LensExtensionClass: LensExtensionConstructor = extensionModule.default; instance = new LensExtensionClass(ext); + instance.whenEnabled(() => register(instance)); this.instances.set(ext.manifestPath, instance); } catch (err) { logger.error(`[EXTENSIONS-LOADER]: init extension instance error`, { ext, err }) @@ -136,7 +111,8 @@ export class ExtensionLoader extends BaseStore { } } - broadcastExtensions(frameId?: number) { + async broadcastExtensions(frameId?: number) { + await when(() => this.isLoaded); broadcastIpc({ channel: "extensions:loaded", frameId: frameId, @@ -146,21 +122,6 @@ export class ExtensionLoader extends BaseStore { ], }) } - - @action - protected fromStore({ extensions = [] }: ExtensionLoaderStoreModel) { - extensions.forEach(ext => { - this.state.set(ext.id, ext); - }) - } - - toJSON(): ExtensionLoaderStoreModel { - return toJS({ - extensions: this.userExtensions.map(ext => ext.toJSON()) - }, { - recurseEverything: true, - }) - } } -export const extensionLoader: ExtensionLoader = ExtensionLoader.getInstance(); +export const extensionLoader = new ExtensionLoader(); diff --git a/src/extensions/extension-manager.ts b/src/extensions/extension-manager.ts index 6a26bf6f8a..1d37707596 100644 --- a/src/extensions/extension-manager.ts +++ b/src/extensions/extension-manager.ts @@ -1,12 +1,18 @@ -import type { LensExtensionManifest } from "./lens-extension" +import type { LensExtensionId, LensExtensionManifest } from "./lens-extension" import path from "path" import os from "os" import fs from "fs-extra" +import child_process from "child_process"; import logger from "../main/logger" -import { extensionPackagesRoot, InstalledExtension } from "./extension-loader" -import * as child_process from 'child_process'; +import { extensionPackagesRoot } from "./extension-loader" import { getBundledExtensions } from "../common/utils/app-version" +export interface InstalledExtension { + manifest: LensExtensionManifest; + manifestPath: string; + isBundled?: boolean; // defined in package.json +} + type Dependencies = { [name: string]: string; } @@ -51,7 +57,7 @@ export class ExtensionManager { return path.join(this.extensionPackagesRoot, "package.json") } - async load(): Promise> { + async load(): Promise> { logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot) if (fs.existsSync(path.join(this.extensionPackagesRoot, "package-lock.json"))) { await fs.remove(path.join(this.extensionPackagesRoot, "package-lock.json")) @@ -71,7 +77,7 @@ export class ExtensionManager { return await this.loadExtensions(); } - async getByManifest(manifestPath: string): Promise { + protected async getByManifest(manifestPath: string): Promise { let manifestJson: LensExtensionManifest; try { fs.accessSync(manifestPath, fs.constants.F_OK); // check manifest file for existence @@ -81,7 +87,7 @@ export class ExtensionManager { logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name) return { manifestPath: path.join(this.nodeModulesPath, manifestJson.name, "package.json"), - manifest: manifestJson + manifest: manifestJson, } } catch (err) { logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson }); diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 5331420cd6..d5372eceff 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -15,7 +15,7 @@ export class ExtensionStore extends BaseStore { await super.load() } - protected storePath() { - return path.join(super.storePath(), "extension-store", this.extension.name) + protected cwd() { + return path.join(super.cwd(), "extension-store", this.extension.name) } } diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index f6579ed1f3..246edfe110 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -1,15 +1,10 @@ -import { action, observable, toJS } from "mobx"; +import type { InstalledExtension } from "./extension-manager"; +import { action, reaction } from "mobx"; import logger from "../main/logger"; -import type { InstalledExtension } from "./extension-loader"; +import { ExtensionStore } from "./extension-store"; export type LensExtensionId = string; // path to manifest (package.json) -export type LensExtensionConstructor = new (init: InstalledExtension) => LensExtension; - -export interface LensExtensionStoreModel { - id: LensExtensionId; - name: string; - isEnabled?: boolean; -} +export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; export interface LensExtensionManifest { name: string; @@ -19,17 +14,35 @@ export interface LensExtensionManifest { renderer?: string; // path to %ext/dist/renderer.js } -export class LensExtension { - public manifest: LensExtensionManifest; - public manifestPath: string; - public isBundled: boolean; +export interface LensExtensionStoreModel { + isEnabled: boolean; +} - @observable isEnabled = false; +export class LensExtension = any> { + protected store: S; + readonly manifest: LensExtensionManifest; + readonly manifestPath: string; + readonly isBundled: boolean; constructor({ manifest, manifestPath, isBundled }: InstalledExtension) { this.manifest = manifest this.manifestPath = manifestPath this.isBundled = !!isBundled + this.init(); + } + + protected async init(store: S = createBaseStore().getInstance()) { + this.store = store; + await this.store.loadExtension(this); + reaction(() => this.store.data.isEnabled, (isEnabled = true) => { + this.toggle(isEnabled); // handle activation & deactivation + }, { + fireImmediately: true + }); + } + + get isEnabled() { + return !!this.store.data.isEnabled; } get id(): LensExtensionId { @@ -51,7 +64,7 @@ export class LensExtension { @action async enable() { if (this.isEnabled) return; - this.isEnabled = true; + this.store.data.isEnabled = true; this.onActivate(); logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`); } @@ -59,7 +72,7 @@ export class LensExtension { @action async disable() { if (!this.isEnabled) return; - this.isEnabled = false; + this.store.data.isEnabled = false; this.onDeactivate(); logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`); } @@ -72,6 +85,27 @@ export class LensExtension { } } + async whenEnabled(handlers: () => Function[]) { + const disposers: Function[] = []; + const unregisterHandlers = () => { + disposers.forEach(unregister => unregister()) + disposers.length = 0; + } + const cancelReaction = reaction(() => this.isEnabled, isEnabled => { + if (isEnabled) { + disposers.push(...handlers()); + } else { + unregisterHandlers(); + } + }, { + fireImmediately: true + }) + return () => { + unregisterHandlers(); + cancelReaction(); + } + } + protected onActivate() { // mock } @@ -79,14 +113,14 @@ export class LensExtension { protected onDeactivate() { // mock } +} - toJSON(): LensExtensionStoreModel { - return toJS({ - id: this.id, - name: this.name, - isEnabled: this.isEnabled, - }, { - recurseEverything: true, - }) +function createBaseStore() { + return class extends ExtensionStore { + constructor() { + super({ + configName: "state" + }); + } } } diff --git a/src/main/index.ts b/src/main/index.ts index dc54a5f20b..762c166bc4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,13 +15,12 @@ import { shellSync } from "./shell-sync" import { getFreePort } from "./port" import { mangleProxyEnv } from "./proxy-env" import { registerFileProtocol } from "../common/register-protocol"; +import logger from "./logger" import { clusterStore } from "../common/cluster-store" import { userStore } from "../common/user-store"; import { workspaceStore } from "../common/workspace-store"; import { appEventBus } from "../common/event-bus" -import { extensionManager } from "../extensions/extension-manager"; import { extensionLoader } from "../extensions/extension-loader"; -import logger from "./logger" const workingDir = path.join(app.getPath("appData"), appName); let proxyPort: number; @@ -48,12 +47,11 @@ app.on("ready", async () => { registerFileProtocol("static", __static); - // preload isomorphic stores + // preload await Promise.all([ userStore.load(), clusterStore.load(), workspaceStore.load(), - extensionLoader.load(), ]); // find free port @@ -77,12 +75,8 @@ app.on("ready", async () => { app.exit(); } - windowManager = new WindowManager(proxyPort); - - LensExtensionsApi.windowManager = windowManager; // expose to extensions - extensionLoader.loadOnMain() - extensionLoader.extensions.replace(await extensionManager.load()) - extensionLoader.broadcastExtensions() + LensExtensionsApi.windowManager = windowManager = new WindowManager(proxyPort); + extensionLoader.init(); // call after windowManager to see splash earlier setTimeout(() => { appEventBus.emit({ name: "app", action: "start" }) diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index be5cfc4848..7311e0e0cf 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -13,7 +13,6 @@ import { i18nStore } from "./i18n"; import { themeStore } from "./theme.store"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; -import { extensionLoader } from "../extensions/extension-loader"; type AppComponent = React.ComponentType & { init?(): Promise; @@ -35,7 +34,6 @@ export async function bootstrap(App: AppComponent) { userStore.load(), workspaceStore.load(), clusterStore.load(), - extensionLoader.load(), i18nStore.init(), themeStore.init(), ]);