Extensions loading (#795)

Signed-off-by: Roman <ixrock@gmail.com>

Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
authored and GitHub committed 2020-09-09 13:00:25 +03:00
1 parent 4250523fe4
commit f1b03990ea
31 files changed
+564 -66

No files matched your search

+2 -3
View File
@@ -1,4 +1,4 @@
import { WorkspaceId, workspaceStore } from "./workspace-store";
import type { WorkspaceId } from "./workspace-store";
import path from "path";
import { app, ipcRenderer, remote } from "electron";
import { unlink } from "fs-extra";
@@ -13,7 +13,6 @@ import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import _ from "lodash";
import move from "array-move";
import { is } from "immer/dist/internal";
export interface ClusterIconUpload {
clusterId: string;
@@ -64,7 +63,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents, { mode: 0o600});
saveToAppFiles(filePath, fileContents, { mode: 0o600 });
return filePath;
}
+8
View File
@@ -2,6 +2,7 @@
import path from "path";
import packageInfo from "../../package.json"
import { defineGlobal } from "./utils/defineGlobal";
import { addAlias } from "module-alias";
export const isMac = process.platform === "darwin"
export const isWindows = process.platform === "win32"
@@ -21,6 +22,13 @@ export const rendererDir = path.join(contextDir, "src/renderer");
export const htmlTemplate = path.resolve(rendererDir, "template.html");
export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
// Extensions
export const extensionsLibName = `${appName}-extensions.api`
export const extensionsDir = path.join(contextDir, "src/extensions");
// Special dynamic module aliases
addAlias("@lens/extensions", path.resolve(buildDir, `${extensionsLibName}.js`)); // fixme: provide path in prod
// Special runtime paths
defineGlobal("__static", {
get() {
@@ -0,0 +1,3 @@
# Lens Example Extension
*TODO*: add more info
@@ -0,0 +1,17 @@
import { LensExtension, Icon, LensRuntimeRendererEnv } from "@lens/extensions"; // fixme: map to generated types from "extension-api.d.ts"
// todo: register custom icon in cluster-menu
// todo: register custom view by clicking the item
export default class ExampleExtension extends LensExtension {
async enable(runtime: /*LensRuntimeRendererEnv*/ any): Promise<any> {
try {
super.enable(runtime);
runtime.logger.info('EXAMPLE EXTENSION: ENABLE() override');
} catch (err){
console.error(err)
}
}
}
// <Icon material="camera" onClick={() => console.log("done")}/>
@@ -0,0 +1,11 @@
{
"name": "extension-example",
"version": "1.0.0",
"description": "Example extension",
"main": "example-extension.ts",
"lens": {
"metadata": {}
},
"dependencies": {
}
}
@@ -0,0 +1,13 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": ".",
"module": "CommonJS",
"sourceMap": false,
"declaration": false
},
"include": [
"../../../types",
"./example-extension.ts"
]
}
+22
View File
@@ -0,0 +1,22 @@
// Lens-extensions api developer's kit
export type { LensRuntimeRendererEnv } from "./lens-runtime";
// APIs
export * from "./extension"
// Common UI components
export * from "../renderer/components/icon"
export * from "../renderer/components/badge"
export * from "../renderer/components/tooltip"
export * from "../renderer/components/button"
export * from "../renderer/components/input"
export * from "../renderer/components/select"
export * from "../renderer/components/checkbox"
export * from "../renderer/components/radio"
export * from "../renderer/components/slider"
export * from "../renderer/components/spinner"
export * from "../renderer/components/tabs"
export * from "../renderer/components/line-progress"
// Utils
export * from "../main/logger";
+174
View File
@@ -0,0 +1,174 @@
import type { LensRuntimeRendererEnv } from "./lens-runtime";
import path from "path";
import fs from "fs-extra";
import { action, observable, reaction, toJS, } from "mobx";
import { BaseStore } from "../common/base-store";
import { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./extension";
import { isDevelopment, isProduction, isTestEnv } from "../common/vars";
import logger from "../main/logger";
export interface ExtensionStoreModel {
version: ExtensionVersion;
extensions: Record<ExtensionId, ExtensionModel>
}
export interface ExtensionModel {
id?: ExtensionId; // available in lens-extension instance
version: ExtensionVersion;
name: string;
manifestPath: string;
description?: string;
enabled?: boolean;
updateUrl?: string;
}
export interface InstalledExtension<T extends ExtensionModel = any> {
manifestPath: string;
manifest: ExtensionManifest;
extensionModule: {
[name: string]: any;
default: new (model: ExtensionModel, manifest?: ExtensionManifest) => LensExtension
}
}
export class ExtensionStore extends BaseStore<ExtensionStoreModel> {
private constructor() {
super({
configName: "lens-extension-store",
syncEnabled: false,
});
}
@observable version: ExtensionVersion = "0.0.0";
@observable extensions = observable.map<ExtensionId, LensExtension>();
@observable removed = observable.map<ExtensionId, LensExtension>();
@observable installed = observable.map<ExtensionId, InstalledExtension>([], { deep: false });
get folderPath(): string {
if (isDevelopment) {
return path.resolve(__static, "../src/extensions");
}
return path.resolve(__static, "../extensions"); //todo figure out prod
}
async load() {
await this.loadExtensions();
return super.load();
}
autoEnableOnLoad(getLensRuntimeEnv: () => LensRuntimeRendererEnv, { delay = 0 } = {}) {
logger.info('[EXTENSIONS-STORE]: auto-activation loaded extensions: ON');
return reaction(() => this.installed.toJS(), installedExtensions => {
installedExtensions.forEach(({ extensionModule, manifest, manifestPath }) => {
let instance = this.getById(manifestPath);
if (!instance) {
const LensExtensionClass = extensionModule.default;
instance = new LensExtensionClass({ ...manifest, manifestPath, id: manifestPath }, manifest);
instance.enable(getLensRuntimeEnv());
this.extensions.set(manifestPath, instance); // save
}
})
}, {
fireImmediately: true,
delay: delay,
})
}
getExtensionByManifest(manifestPath: string): InstalledExtension {
let manifestJson: ExtensionManifest;
let mainJs: string;
try {
manifestJson = __non_webpack_require__(manifestPath); // "__non_webpack_require__" converts to native node's require()-call
mainJs = path.resolve(path.dirname(manifestPath), manifestJson.main);
mainJs = mainJs.replace(/\.ts$/i, ".js"); // todo: compile *.ts on the fly?
const extensionModule = __non_webpack_require__(mainJs);
return {
manifestPath: manifestPath,
manifest: manifestJson,
extensionModule: extensionModule,
}
} catch (err) {
console.error(`[EXTENSION-STORE]: can't load extension at ${manifestPath}: ${err}`, { manifestJson, mainJs });
}
}
@action
async loadExtensions() {
const extensions = await this.loadFromFolder(this.folderPath);
const extManifestMap = new Map(extensions.map(ext => [ext.manifestPath, ext]));
this.installed.replace(extManifestMap);
}
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
const paths = await fs.readdir(folderPath);
const manifestsLoading = paths.map(fileName => {
const absPath = path.resolve(folderPath, fileName);
const manifestPath = path.resolve(absPath, "package.json");
return fs.access(manifestPath, fs.constants.F_OK)
.then(() => this.getExtensionByManifest(manifestPath))
.catch(() => null)
});
let extensions = await Promise.all(manifestsLoading);
extensions = extensions.filter(v => !!v); // filter out files and invalid folders (without manifest.json)
console.info(`[EXTENSION-STORE]: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions;
}
getById(id: ExtensionId): LensExtension {
return this.extensions.get(id);
}
async removeById(id: ExtensionId) {
const extension = this.getById(id);
if (extension) {
await extension.uninstall();
this.extensions.delete(id);
}
}
@action
protected fromStore({ extensions, version }: ExtensionStoreModel) {
if (version) {
this.version = version;
}
if (extensions) {
const currentExtensions = new Map(Object.entries(extensions));
this.extensions.forEach(extension => {
if (!currentExtensions.has(extension.id)) {
this.removed.set(extension.id, extension);
}
})
currentExtensions.forEach(model => {
const extensionId = model.id || model.manifestPath;
const manifest = this.installed.get(extensionId);
if (!manifest) {
console.error(`[EXTENSION-STORE]: can't load extension manifest at ${model.manifestPath}`, { model })
return;
}
const extensionInstance = this.getById(extensionId)
if (!extensionInstance) {
try {
const { manifest: manifestJson, extensionModule } = manifest;
const LensExtensionClass = extensionModule.default;
this.extensions.set(model.id, new LensExtensionClass(model, manifestJson));
} catch (err) {
console.error(`[EXTENSION-STORE]: init extension failed: ${err}`, { model, manifest })
}
} else {
extensionInstance.importModel(model);
}
})
}
}
toJSON(): ExtensionStoreModel {
return toJS({
version: this.version,
extensions: this.extensions.toJSON(),
}, {
recurseEverything: true,
})
}
}
export const extensionStore = ExtensionStore.getInstance<ExtensionStore>()
+89
View File
@@ -0,0 +1,89 @@
import type { ExtensionModel } from "./extension-store";
import type { LensRuntimeRendererEnv } from "./lens-runtime";
import { readJsonSync } from "fs-extra";
import { action, observable, toJS } from "mobx";
import extensionManifest from "./example-extension/package.json"
import logger from "../main/logger";
export type ExtensionId = string; // instance-id or abs path to "%lens-extension/manifest.json"
export type ExtensionVersion = string | number;
export type ExtensionManifest = typeof extensionManifest & ExtensionModel;
export class LensExtension implements ExtensionModel {
public id: ExtensionId;
public updateUrl: string;
@observable name = "";
@observable description = "";
@observable version: ExtensionVersion = "0.0.0";
@observable manifest: ExtensionManifest;
@observable manifestPath: string;
@observable isEnabled = false;
@observable.ref runtime: Partial<LensRuntimeRendererEnv> = {};
constructor(model: ExtensionModel, manifest: ExtensionManifest) {
this.importModel(model, manifest);
}
@action
async importModel({ enabled, manifestPath, ...model }: ExtensionModel, manifest?: ExtensionManifest) {
try {
this.manifest = manifest || await readJsonSync(manifestPath, { throws: true })
this.manifestPath = manifestPath;
Object.assign(this, model);
} catch (err) {
logger.error(`[EXTENSION]: cannot read manifest at ${manifestPath}`, { ...model, err: String(err) })
this.disable();
}
}
async enable(runtime: LensRuntimeRendererEnv) {
this.isEnabled = true;
this.runtime = runtime;
console.log(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta());
}
async disable() {
this.isEnabled = false;
this.runtime = {};
console.log(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta());
}
// todo
async install(downloadUrl?: string) {
return;
}
// todo
async uninstall() {
return;
}
async hasNewVersion(): Promise<Partial<ExtensionModel>> {
return;
}
getMeta() {
return toJS({
id: this.id,
manifest: this.manifest,
manifestPath: this.manifestPath,
enabled: this.isEnabled,
runtime: this.runtime,
}, {
recurseEverything: true
})
}
toJSON(): ExtensionModel {
return {
id: this.id,
name: this.name,
version: this.version,
description: this.description,
manifestPath: this.manifestPath,
enabled: this.isEnabled,
updateUrl: this.updateUrl,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
// Lens runtime for injecting to extension on activation
import { apiManager } from "../renderer/api/api-manager";
import logger from "../main/logger";
import { dynamicPages } from "../renderer/components/cluster-manager/register-page";
export interface LensRuntimeRendererEnv {
apiManager: typeof apiManager;
logger: typeof logger;
dynamicPages: typeof dynamicPages
}
// todo: expose more public runtime apis: stores, managers, etc.
export function getLensRuntime(): LensRuntimeRendererEnv {
return {
apiManager,
logger,
dynamicPages,
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "AMD",
"declaration": true,
"emitDeclarationOnly": true,
"outFile": "./../../static/types/extension-api.d.ts"
},
"include": [
"../../types",
"./extension-api.ts"
]
}
+9 -1
View File
@@ -6,6 +6,7 @@ import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.r
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
import logger from "./logger";
export function initMenu(windowManager: WindowManager) {
@@ -67,11 +68,18 @@ export function buildMenu(windowManager: WindowManager) {
{ type: 'separator' },
{
label: 'Preferences',
accelerator: 'Cmd+,',
accelerator: 'CmdOrCtrl+,',
click() {
navigate(preferencesURL())
}
},
{
label: 'Extensions',
accelerator: 'CmdOrCtrl+E',
click() {
navigate(extensionsURL())
}
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
+2
View File
@@ -4,6 +4,7 @@ import { render } from "react-dom";
import { isMac } from "../common/vars";
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { extensionStore } from "../extensions/extension-store";
import { clusterStore, getHostedClusterId } from "../common/cluster-store";
import { i18nStore } from "./i18n";
import { themeStore } from "./theme.store";
@@ -23,6 +24,7 @@ export async function bootstrap(App: AppComponent) {
userStore.load(),
workspaceStore.load(),
clusterStore.load(),
extensionStore.load(),
i18nStore.init(),
themeStore.init(),
]);
@@ -0,0 +1,11 @@
import { RouteProps } from "react-router";
import { buildURL } from "../../navigation";
export const extensionsRoute: RouteProps = {
path: "/extensions"
}
export interface IExtensionsRouteParams {
}
export const extensionsURL = buildURL<IExtensionsRouteParams>(extensionsRoute.path);
@@ -0,0 +1,4 @@
.Extensions {
$spacing: $padding * 2;
padding: $spacing;
}
@@ -0,0 +1,31 @@
import "./extensions.scss"
import React from "react";
import { observer } from "mobx-react";
import { extensionStore } from "../../../extensions/extension-store";
import { WizardLayout } from "../layout/wizard-layout";
import { Icon } from "../icon";
@observer
export class Extensions extends React.Component {
// todo: add input-select to customize extensions loading folder(s)
renderInfoPanel() {
return (
<div className="info-panel flex gaps align-center">
<Icon material="info"/>
<p>Extensions available to install</p>
</div>
);
}
render() {
const { installed: installedExtensions } = extensionStore;
return (
<WizardLayout className="Extensions" infoPanel={this.renderInfoPanel()}>
<h2>Extensions</h2>
<pre>
{JSON.stringify(installedExtensions.toJSON(), null, 2)}
</pre>
</WizardLayout>
);
}
}
@@ -0,0 +1,2 @@
export * from "./extensions.route"
export * from "./extensions"
@@ -4,7 +4,6 @@
position: relative;
border-radius: $radius;
padding: $radius;
margin-bottom: $padding * 2;
user-select: none;
cursor: pointer;
@@ -11,6 +11,7 @@ import { Workspaces, workspacesRoute } from "../+workspaces";
import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { Extensions, extensionsRoute } from "../+extensions";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
@@ -60,6 +61,7 @@ export class ClusterManager extends React.Component {
<Route component={AddCluster} {...addClusterRoute}/>
<Route component={ClusterView} {...clusterViewRoute}/>
<Route component={ClusterSettings} {...clusterSettingsRoute}/>
<Route component={Extensions} {...extensionsRoute}/>
<Redirect exact to={this.startUrl}/>
</Switch>
</main>
@@ -16,10 +16,10 @@
.clusters {
@include hidden-scrollbar;
padding: 0 $spacing; // extra spacing for cluster-icon's badge
margin-bottom: $spacing;
margin-bottom: $margin;
> :last-child {
margin-bottom: $margin;
.ClusterIcon {
margin-bottom: $margin * 1.5;
}
&:empty {
@@ -67,4 +67,14 @@
pointer-events: none;
}
}
> .dynamic-pages {
&:not(:empty) {
padding-top: $spacing;
}
.Icon {
--size: 40px;
}
}
}
@@ -21,8 +21,7 @@ import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL, getMatchedClusterId } from "./cluster-view.route";
import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd";
// fixme: allow to rearrange clusters with drag&drop
import { dynamicPages } from "./register-page";
interface Props {
className?: IClassName;
@@ -149,6 +148,12 @@ export class ClustersMenu extends React.Component<Props> {
<Badge className="counter" label={newContexts.size} tooltip={<Trans>new</Trans>} />
)}
</div>
<div className="dynamic-pages">
{Array.from(dynamicPages.all).map(([path, { MenuIcon }]) => {
if (!MenuIcon) return;
return <MenuIcon onClick={() => navigate(path)}/>
})}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
// Dynamic pages
import React from "react";
import { observable } from "mobx";
import type { IconProps } from "../icon";
export interface PageComponents {
Main: React.ComponentType<any>;
MenuIcon: React.ComponentType<IconProps>;
}
export class PagesStore {
all = observable.map<string, PageComponents>();
getComponents(path: string): PageComponents | null {
return this.all.get(path);
}
register(path: string, components: PageComponents) {
this.all.set(path, components);
}
unregister(path: string) {
this.all.delete(path);
}
}
export const dynamicPages = new PagesStore();
+6
View File
@@ -11,9 +11,15 @@ import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionStore } from "../extensions/extension-store";
import { getLensRuntime } from "../extensions/lens-runtime";
@observer
export class LensApp extends React.Component {
componentDidMount() {
extensionStore.autoEnableOnLoad(getLensRuntime);
}
render() {
return (
<I18nProvider i18n={_i18n}>