1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Support extensions in main process (#1032)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2020-10-08 11:52:45 +03:00 committed by GitHub
parent c78b071da3
commit aa864fc199
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 356 additions and 204 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ src/extensions/*/*.js
src/extensions/*/*.d.ts src/extensions/*/*.d.ts
src/extensions/example-extension/src/** src/extensions/example-extension/src/**
types/extension-api.d.ts types/extension-api.d.ts
types/extension-renderer-api.d.ts

View File

@ -1,48 +0,0 @@
import { Button, Icon, IconProps, LensExtension, React, DynamicPageType } from "@lens/extensions";
import { CoffeeDoodle } from "react-open-doodles";
import path from "path";
export default class ExampleExtension extends LensExtension {
onActivate() {
console.log('EXAMPLE EXTENSION: ACTIVATED', this.getMeta());
this.registerPage({
type: DynamicPageType.CLUSTER,
path: "/extension-example",
title: "Example Extension",
components: {
Page: () => <ExtensionPage extension={this}/>,
MenuIcon: ExtensionIcon,
}
})
}
onDeactivate() {
console.log('EXAMPLE EXTENSION: DEACTIVATED', this.getMeta());
}
}
export function ExtensionIcon(props: IconProps) {
return <Icon {...props} material="pages" tooltip={path.basename(__filename)}/>
}
export class ExtensionPage extends React.Component<{ extension: ExampleExtension }> {
deactivate = () => {
const { extension } = this.props;
extension.runtime.navigate("/")
extension.disable();
}
render() {
const doodleStyle = {
width: "200px"
}
return (
<div className="ExampleExtension flex column gaps align-flex-start">
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce"/></div>
<p>Hello from Example extension!</p>
<p>File: <i>{__filename}</i></p>
<Button accent label="Deactivate" onClick={this.deactivate}/>
</div>
)
}
}

View File

@ -0,0 +1,11 @@
import { LensMainExtension } from "@lens/extensions";
export default class ExampleExtensionMain extends LensMainExtension {
onActivate() {
console.log('EXAMPLE EXTENSION MAIN: ACTIVATED', this.getMeta());
}
onDeactivate() {
console.log('EXAMPLE EXTENSION MAIN: DEACTIVATED', this.getMeta());
}
}

View File

@ -2,7 +2,8 @@
"name": "extension-example", "name": "extension-example",
"version": "1.0.0", "version": "1.0.0",
"description": "Example extension", "description": "Example extension",
"main": "dist/index.js", "main": "dist/main.js",
"renderer": "dist/renderer.js",
"lens": { "lens": {
"metadata": {}, "metadata": {},
"styles": [] "styles": []

View File

@ -0,0 +1,32 @@
import { Button, Icon, IconProps, LensRendererExtension, React } from "@lens/ui-extensions";
import { CoffeeDoodle } from "react-open-doodles";
import path from "path";
export function ExtensionIcon(props: IconProps) {
return <Icon {...props} material="pages" tooltip={path.basename(__filename)}/>
}
export class ExtensionPage extends React.Component<{ extension: LensRendererExtension }> {
deactivate = () => {
const { extension } = this.props;
extension.disable();
}
render() {
const doodleStyle = {
width: "200px"
}
return (
<div className="flex column gaps align-flex-start">
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce" /></div>
<p>Hello from Example extension!</p>
<p>File: <i>{__filename}</i></p>
<Button accent label="Deactivate" onClick={this.deactivate}/>
</div>
)
}
}
export function examplePage(ext: LensRendererExtension) {
return () => <ExtensionPage extension={ext} />
}

View File

@ -0,0 +1,24 @@
import { DynamicPageType, LensRendererExtension, PageStore } from "@lens/ui-extensions";
import { examplePage, ExtensionIcon } from "./page"
export default class ExampleExtension extends LensRendererExtension {
onActivate() {
console.log('EXAMPLE EXTENSION RENDERER: ACTIVATED', this.getMeta());
}
registerPages(pageStore: PageStore) {
this.disposers.push(pageStore.register({
type: DynamicPageType.CLUSTER,
path: "/extension-example",
title: "Example Extension",
components: {
Page: examplePage(this),
MenuIcon: ExtensionIcon,
}
}))
}
onDeactivate() {
console.log('EXAMPLE EXTENSION RENDERER: DEACTIVATED', this.getMeta());
}
}

View File

@ -12,11 +12,13 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"jsx": "react" "jsx": "react"
}, },
"include": [ "include": [
"../../types", "../../types",
"./index.tsx" "./renderer.ts",
"./main.ts"
], ],
"exclude": [ "exclude": [
"node_modules", "node_modules",

View File

@ -12,15 +12,18 @@
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn dev-run -C\" yarn:dev:*", "dev": "concurrently -k \"yarn dev-run -C\" yarn:dev:*",
"dev-build": "concurrently yarn:compile:*",
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"",
"dev:main": "yarn compile:main --watch", "dev:main": "yarn compile:main --watch",
"dev:renderer": "yarn compile:renderer --watch", "dev:renderer": "yarn compile:renderer --watch",
"dev:extensions": "yarn compile:extensions --watch", "dev:extension-rollup": "yarn compile:extension-rollup --watch",
"dev:extension-api": "yarn compile:extension-api --watch",
"compile": "env NODE_ENV=production concurrently yarn:compile:*", "compile": "env NODE_ENV=production concurrently yarn:compile:*",
"compile:main": "webpack --config webpack.main.ts", "compile:main": "webpack --config webpack.main.ts",
"compile:renderer": "webpack --config webpack.renderer.ts", "compile:renderer": "webpack --config webpack.renderer.ts",
"compile:i18n": "lingui compile", "compile:i18n": "lingui compile",
"compile:extensions": "rollup --config src/extensions/rollup.config.js", "compile:extension-rollup": "rollup --config src/extensions/rollup.config.js",
"compile:extension-api": "webpack --config webpack.extensions.ts",
"build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens", "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens", "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens", "build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens",
@ -320,8 +323,8 @@
"progress-bar-webpack-plugin": "^2.1.0", "progress-bar-webpack-plugin": "^2.1.0",
"raw-loader": "^4.0.1", "raw-loader": "^4.0.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1",
"react-beautiful-dnd": "^13.0.0", "react-beautiful-dnd": "^13.0.0",
"react-dom": "^16.13.1",
"react-router": "^5.2.0", "react-router": "^5.2.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-select": "^3.1.0", "react-select": "^3.1.0",

View File

@ -24,6 +24,7 @@ export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss");
// Extensions // Extensions
export const extensionsLibName = `${appName}-extensions.api` export const extensionsLibName = `${appName}-extensions.api`
export const extensionsRendererLibName = `${appName}-extensions-renderer.api`
export const extensionsDir = path.join(contextDir, "src/extensions"); export const extensionsDir = path.join(contextDir, "src/extensions");
// Special runtime paths // Special runtime paths
@ -39,8 +40,10 @@ defineGlobal("__static", {
// Special dynamic module aliases // Special dynamic module aliases
if (isProduction && process.resourcesPath) { if (isProduction && process.resourcesPath) {
addAlias("@lens/extensions", path.join(process.resourcesPath, "static", `build/${extensionsLibName}.js`)) addAlias("@lens/extensions", path.join(process.resourcesPath, "static", `build/${extensionsLibName}.js`))
addAlias("@lens/ui-extensions", path.join(process.resourcesPath, "static", `build/${extensionsRendererLibName}.js`))
} else { } else {
addAlias("@lens/extensions", path.join(contextDir, "static", `build/${extensionsLibName}.js`)) addAlias("@lens/extensions", path.join(contextDir, "static", `build/${extensionsLibName}.js`))
addAlias("@lens/ui-extensions", path.join(contextDir, "static", `build/${extensionsRendererLibName}.js`))
} }
// Apis // Apis

View File

@ -0,0 +1,15 @@
import React from "react";
import { cssNames } from "../renderer/utils";
import { TabLayout } from "../renderer/components/layout/tab-layout";
import { PageRegistration } from "./page-store"
export class DynamicPage extends React.Component<{ page: PageRegistration }> {
render() {
const { className, components: { Page }, subPages = [] } = this.props.page;
return (
<TabLayout className={cssNames("ExtensionPage", className)} tabs={subPages}>
<Page/>
</TabLayout>
)
}
}

View File

@ -1,15 +1,5 @@
// Lens-extensions api developer's kit // Lens-extensions api developer's kit
// TODO: add more common re-usable UI components + refactor interfaces (Props -> ComponentProps) export type { LensExtensionRuntimeEnv } from "./lens-runtime";
// TODO: figure out how to import as normal npm-package // APIs
export { default as React } from "react" export * from "./lens-main-extension"
export * from "./lens-extension"
export { LensRuntimeRendererEnv } from "./lens-runtime";
export { DynamicPageType } from "./register-page";
export * from "../renderer/components/icon"
export * from "../renderer/components/tooltip"
export * from "../renderer/components/button"
export * from "../renderer/components/tabs"
export * from "../renderer/components/badge"

View File

@ -1,10 +1,12 @@
import type { ExtensionId, LensExtension, ExtensionManifest, ExtensionModel } from "./lens-extension" import type { ExtensionId, LensExtension, ExtensionManifest, ExtensionModel } from "./lens-extension"
import type { LensRendererExtension } from "./lens-renderer-extension"
import { broadcastIpc } from "../common/ipc" import { broadcastIpc } from "../common/ipc"
import type { LensRuntimeRendererEnv } from "./lens-runtime" import type { LensExtensionRuntimeEnv } from "./lens-runtime"
import path from "path" import path from "path"
import { observable, reaction, toJS, } from "mobx" import { observable, reaction, toJS, } from "mobx"
import logger from "../main/logger" import logger from "../main/logger"
import { app, remote, ipcRenderer } from "electron" import { app, remote, ipcRenderer } from "electron"
import { pageStore } from "./page-store";
export interface InstalledExtension extends ExtensionModel { export interface InstalledExtension extends ExtensionModel {
manifestPath: string; manifestPath: string;
@ -42,36 +44,64 @@ export class ExtensionLoader {
} }
} }
autoEnableOnLoad(getLensRuntimeEnv: () => LensRuntimeRendererEnv, { delay = 0 } = {}) { loadOnClusterRenderer(getLensRuntimeEnv: () => LensExtensionRuntimeEnv) {
logger.info('[EXTENSIONS-LOADER]: auto-activation loaded extensions: ON'); logger.info('[EXTENSIONS-LOADER]: load on cluster renderer')
return reaction(() => this.extensions.toJS(), installedExtensions => { this.autoloadExtensions(getLensRuntimeEnv, (instance: LensRendererExtension) => {
installedExtensions.forEach((ext) => { instance.registerPages(pageStore)
})
}
loadOnMainRenderer(getLensRuntimeEnv: () => LensExtensionRuntimeEnv) {
logger.info('[EXTENSIONS-LOADER]: load on main renderer')
this.autoloadExtensions(getLensRuntimeEnv, (instance: LensRendererExtension) => {
instance.registerPages(pageStore)
})
}
loadOnMain(getLensRuntimeEnv: () => LensExtensionRuntimeEnv) {
logger.info('[EXTENSIONS-LOADER]: load on main')
this.autoloadExtensions(getLensRuntimeEnv, (instance: LensExtension) => {
// todo
})
}
protected autoloadExtensions(getLensRuntimeEnv: () => LensExtensionRuntimeEnv, callback: (instance: LensExtension) => void) {
return reaction(() => this.extensions.toJS(), (installedExtensions) => {
for(const [id, ext] of installedExtensions) {
let instance = this.instances.get(ext.manifestPath) let instance = this.instances.get(ext.manifestPath)
if (!instance) { if (!instance) {
const extensionModule = this.requireExtension(ext) const extensionModule = this.requireExtension(ext)
if (!extensionModule) { if (!extensionModule) {
logger.error("[EXTENSION-LOADER] failed to load extension " + ext.manifestPath) continue
return
} }
const LensExtensionClass = extensionModule.default; const LensExtensionClass = extensionModule.default;
instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest); instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest);
instance.enable(getLensRuntimeEnv()); instance.enable(getLensRuntimeEnv())
callback(instance)
this.instances.set(ext.id, instance) this.instances.set(ext.id, instance)
} }
}) }
}, { }, {
fireImmediately: true, fireImmediately: true,
delay: delay, delay: 0,
}) })
} }
protected requireExtension(extension: InstalledExtension) { protected requireExtension(extension: InstalledExtension) {
let extEntrypoint = ""
return withExtensionPackagesRoot(() => { return withExtensionPackagesRoot(() => {
try { try {
const extMain = path.join(path.dirname(extension.manifestPath), extension.manifest.main) if (ipcRenderer && extension.manifest.renderer) {
return __non_webpack_require__(extMain) extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer))
} else if (extension.manifest.main) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main))
}
if (extEntrypoint !== "") {
return __non_webpack_require__(extEntrypoint)
}
} catch (err) { } catch (err) {
console.error(`[EXTENSION-LOADER]: can't load extension main at ${extension.manifestPath}: ${err}`, { extension }); console.error(`[EXTENSION-LOADER]: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
console.trace(err)
} }
}) })
} }
@ -84,7 +114,7 @@ export class ExtensionLoader {
const extension = this.getById(id); const extension = this.getById(id);
if (extension) { if (extension) {
const instance = this.instances.get(extension.id) const instance = this.instances.get(extension.id)
if (instance) { await instance.uninstall() } if (instance) { await instance.disable() }
this.extensions.delete(id); this.extensions.delete(id);
} }
} }

View File

@ -2,10 +2,23 @@ import type { ExtensionManifest } from "./lens-extension"
import path from "path" import path from "path"
import fs from "fs-extra" import fs from "fs-extra"
import logger from "../main/logger" import logger from "../main/logger"
import { withExtensionPackagesRoot, extensionPackagesRoot, InstalledExtension } from "./extension-loader" import { extensionPackagesRoot, InstalledExtension } from "./extension-loader"
import npm from "npm" import * as child_process from 'child_process';
type Dependencies = {
[name: string]: string;
}
type PackageJson = {
dependencies: Dependencies;
}
export class ExtensionManager { export class ExtensionManager {
protected packagesJson: PackageJson = {
dependencies: {}
}
get extensionPackagesRoot() { get extensionPackagesRoot() {
return extensionPackagesRoot() return extensionPackagesRoot()
} }
@ -14,11 +27,13 @@ export class ExtensionManager {
return path.resolve(__static, "../extensions"); return path.resolve(__static, "../extensions");
} }
get npmPath() {
return __non_webpack_require__.resolve('npm/bin/npm-cli')
}
async load() { async load() {
logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot) logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot)
await fs.ensureDir(path.join(this.extensionPackagesRoot, "node_modules")) await fs.ensureDir(path.join(this.extensionPackagesRoot, "node_modules"))
await fs.writeFile(path.join(this.extensionPackagesRoot, "package.json"), `{"dependencies": []}`, {mode: 0o600})
return await this.loadExtensions(); return await this.loadExtensions();
} }
@ -27,9 +42,7 @@ export class ExtensionManager {
let manifestJson: ExtensionManifest; let manifestJson: ExtensionManifest;
try { try {
manifestJson = __non_webpack_require__(manifestPath) manifestJson = __non_webpack_require__(manifestPath)
withExtensionPackagesRoot(() => { this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath)
this.installPackageFromPath(path.dirname(manifestPath))
})
logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name) logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name)
return { return {
@ -44,31 +57,17 @@ export class ExtensionManager {
} }
} }
protected installPackageFromPath(path: string): Promise<void> { protected installPackages(): Promise<void> {
const origLogger = console.log
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
npm.load({ const child = child_process.fork(this.npmPath, ["install", "--silent"], {
production: true, cwd: extensionPackagesRoot(),
global: false, silent: true
prefix: this.extensionPackagesRoot,
dev: false,
spin: false,
"ignore-scripts": true,
loglevel: "silent"
}, (err) => {
console.log = function() {
// just to ignore ts empty function error
}
npm.commands.install([
path
], (err) => {
console.log = origLogger
if (err) {
reject(err)
} else {
resolve()
}
}) })
child.on("close", () => {
resolve()
})
child.on("error", (err) => {
reject(err)
}) })
}) })
} }
@ -80,15 +79,19 @@ export class ExtensionManager {
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> { async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
const paths = await fs.readdir(folderPath); const paths = await fs.readdir(folderPath);
const manifestsLoading = paths.map(fileName => { const extensions: InstalledExtension[] = []
for (const fileName of paths) {
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
const manifestPath = path.resolve(absPath, "package.json"); const manifestPath = path.resolve(absPath, "package.json");
return fs.access(manifestPath, fs.constants.F_OK) await fs.access(manifestPath, fs.constants.F_OK)
.then(async () => await this.getExtensionByManifest(manifestPath)) const ext = await this.getExtensionByManifest(manifestPath).catch(() => null)
.catch(() => null) if (ext) {
}); extensions.push(ext)
let extensions = await Promise.all(manifestsLoading); }
extensions = extensions.filter(v => !!v); // filter out files and invalid folders (without manifest.json) }
await fs.writeFile(path.join(this.extensionPackagesRoot, "package.json"), JSON.stringify(this.packagesJson), {mode: 0o600})
await this.installPackages()
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions }); logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions; return extensions;
} }

View File

@ -0,0 +1,15 @@
// Lens-extensions api developer's kit
export type { LensExtensionRuntimeEnv } from "./lens-renderer-runtime"
export type { PageStore } from "./page-store"
// APIs
export * from "./lens-renderer-extension"
export { DynamicPageType } from "./page-store"
// TODO: add more common re-usable UI components + refactor interfaces (Props -> ComponentProps)
export { default as React } from "react"
export * from "../renderer/components/icon"
export * from "../renderer/components/tooltip"
export * from "../renderer/components/button"
export * from "../renderer/components/tabs"
export * from "../renderer/components/badge"

View File

@ -1,5 +1,4 @@
import type { LensRuntimeRendererEnv } from "./lens-runtime"; import type { LensExtensionRuntimeEnv } from "./lens-runtime";
import type { PageRegistration } from "./register-page";
import { readJsonSync } from "fs-extra"; import { readJsonSync } from "fs-extra";
import { action, observable, toJS } from "mobx"; import { action, observable, toJS } from "mobx";
import logger from "../main/logger"; import logger from "../main/logger";
@ -19,7 +18,8 @@ export interface ExtensionModel {
} }
export interface ExtensionManifest extends ExtensionModel { export interface ExtensionManifest extends ExtensionModel {
main: string; main?: string;
renderer?: string;
description?: string; // todo: add more fields similar to package.json + some extra description?: string; // todo: add more fields similar to package.json + some extra
} }
@ -34,7 +34,7 @@ export class LensExtension implements ExtensionModel {
@observable manifest: ExtensionManifest; @observable manifest: ExtensionManifest;
@observable manifestPath: string; @observable manifestPath: string;
@observable isEnabled = false; @observable isEnabled = false;
@observable.ref runtime: LensRuntimeRendererEnv; @observable.ref runtime: LensExtensionRuntimeEnv;
constructor(model: ExtensionModel, manifest: ExtensionManifest) { constructor(model: ExtensionModel, manifest: ExtensionManifest) {
this.importModel(model, manifest); this.importModel(model, manifest);
@ -52,10 +52,14 @@ export class LensExtension implements ExtensionModel {
} }
} }
async enable(runtime: LensRuntimeRendererEnv) { async migrate(appVersion: string) {
// mock
}
async enable(runtime: LensExtensionRuntimeEnv) {
this.isEnabled = true; this.isEnabled = true;
this.runtime = runtime; this.runtime = runtime;
console.log(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta()); logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`, this.getMeta());
this.onActivate(); this.onActivate();
} }
@ -65,7 +69,7 @@ export class LensExtension implements ExtensionModel {
this.runtime = null; this.runtime = null;
this.disposers.forEach(cleanUp => cleanUp()); this.disposers.forEach(cleanUp => cleanUp());
this.disposers.length = 0; this.disposers.length = 0;
console.log(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta()); logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`, this.getMeta());
} }
// todo: add more hooks // todo: add more hooks
@ -77,27 +81,12 @@ export class LensExtension implements ExtensionModel {
// mock // mock
} }
// todo
async install(downloadUrl?: string) {
return;
}
// todo
async uninstall() {
return;
}
async hasNewVersion(): Promise<Partial<ExtensionModel>> {
return;
}
getMeta() { getMeta() {
return toJS({ return toJS({
id: this.id, id: this.id,
manifest: this.manifest, manifest: this.manifest,
manifestPath: this.manifestPath, manifestPath: this.manifestPath,
enabled: this.isEnabled, enabled: this.isEnabled
runtime: this.runtime,
}, { }, {
recurseEverything: true recurseEverything: true
}) })
@ -116,12 +105,4 @@ export class LensExtension implements ExtensionModel {
recurseEverything: true, recurseEverything: true,
}) })
} }
// Runtime helpers
protected registerPage(params: PageRegistration, autoDisable = true) {
const dispose = this.runtime.dynamicPages.register(params);
if (autoDisable) {
this.disposers.push(dispose);
}
}
} }

View File

@ -0,0 +1,11 @@
import { LensExtension } from "./lens-extension"
export class LensMainExtension extends LensExtension {
async registerAppMenus() {
//
}
async registerPrometheusProviders(registry: any) {
//
}
}

View File

@ -0,0 +1,15 @@
import type { PageStore } from "./extension-renderer-api"
import type { PageRegistration } from "./page-store"
import { LensExtension } from "./lens-extension"
export abstract class LensRendererExtension extends LensExtension {
registerPages(pageStore: PageStore) {
return
}
// Runtime helpers
protected registerPage(pageStore: PageStore, params: PageRegistration) {
const dispose = pageStore.register(params);
this.disposers.push(dispose)
}
}

View File

@ -0,0 +1,16 @@
// Lens extension runtime params available to renderer extensions after activation
import logger from "../main/logger";
import { navigate } from "../renderer/navigation";
export interface LensExtensionRuntimeEnv {
logger: typeof logger;
navigate: typeof navigate;
}
export function getLensRuntime(): LensExtensionRuntimeEnv {
return {
logger,
navigate
}
}

View File

@ -1,19 +1,13 @@
// Lens renderer runtime apis exposed to extensions once activated // Lens extension runtime params available to extensions after activation
import logger from "../main/logger"; import logger from "../main/logger";
import { dynamicPages } from "./register-page";
import { navigate } from "../renderer/navigation";
export interface LensRuntimeRendererEnv { export interface LensExtensionRuntimeEnv {
navigate: typeof navigate;
logger: typeof logger; logger: typeof logger;
dynamicPages: typeof dynamicPages
} }
export function getLensRuntime(): LensRuntimeRendererEnv { export function getLensRuntime(): LensExtensionRuntimeEnv {
return { return {
logger, logger
navigate,
dynamicPages,
} }
} }

View File

@ -4,8 +4,8 @@ import { computed, observable } from "mobx";
import React from "react"; import React from "react";
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { IconProps } from "../renderer/components/icon"; import { IconProps } from "../renderer/components/icon";
import { cssNames, IClassName } from "../renderer/utils"; import { IClassName } from "../renderer/utils";
import { TabLayout, TabRoute } from "../renderer/components/layout/tab-layout"; import { TabRoute } from "../renderer/components/layout/tab-layout";
export enum DynamicPageType { export enum DynamicPageType {
GLOBAL = "lens-scope", GLOBAL = "lens-scope",
@ -27,7 +27,7 @@ export interface PageComponents {
MenuIcon: React.ComponentType<IconProps>; MenuIcon: React.ComponentType<IconProps>;
} }
export class PagesStore { export class PageStore {
protected pages = observable.array<PageRegistration>([], { deep: false }); protected pages = observable.array<PageRegistration>([], { deep: false });
@computed get globalPages() { @computed get globalPages() {
@ -49,15 +49,4 @@ export class PagesStore {
} }
} }
export class DynamicPage extends React.Component<{ page: PageRegistration }> { export const pageStore = new PageStore();
render() {
const { className, components: { Page }, subPages = [] } = this.props.page;
return (
<TabLayout className={cssNames("ExtensionPage", className)} tabs={subPages}>
<Page/>
</TabLayout>
)
}
}
export const dynamicPages = new PagesStore();

View File

@ -22,6 +22,19 @@ const config: RollupOptions = {
], ],
}; };
const rendererConfig: RollupOptions = {
input: "src/extensions/extension-renderer-api.ts",
output: [
{ file: "types/extension-renderer-api.d.ts", format: "es", }
],
plugins: [
dts(),
dtsModuleWrap({ name: "@lens/ui-extensions" }),
ignoreImport({ extensions: ['.scss'] }),
json(),
],
};
function dtsModuleWrap({ name }: { name: string }): Plugin { function dtsModuleWrap({ name }: { name: string }): Plugin {
return { return {
name, name,
@ -56,4 +69,4 @@ function dtsModuleWrap({ name }: { name: string }): Plugin {
} }
} }
export default config; export default [config, rendererConfig];

View File

@ -19,6 +19,7 @@ import { workspaceStore } from "../common/workspace-store";
import { tracker } from "../common/tracker"; import { tracker } from "../common/tracker";
import { extensionManager } from "../extensions/extension-manager"; import { extensionManager } from "../extensions/extension-manager";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { getLensRuntime } from "../extensions/lens-runtime";
import logger from "./logger" import logger from "./logger"
const workingDir = path.join(app.getPath("appData"), appName); const workingDir = path.join(app.getPath("appData"), appName);
@ -78,7 +79,9 @@ async function main() {
// create window manager and open app // create window manager and open app
windowManager = new WindowManager(proxyPort); windowManager = new WindowManager(proxyPort);
extensionLoader.loadOnMain(getLensRuntime)
extensionLoader.extensions.replace(await extensionManager.load()) extensionLoader.extensions.replace(await extensionManager.load())
extensionLoader.broadcastExtensions()
} }
app.on("ready", main); app.on("ready", main);

View File

@ -4,6 +4,7 @@ import { BrowserWindow, dialog, ipcMain, shell, webContents } from "electron"
import windowStateKeeper from "electron-window-state" import windowStateKeeper from "electron-window-state"
import { observable } from "mobx"; import { observable } from "mobx";
import { initMenu } from "./menu"; import { initMenu } from "./menu";
import { extensionLoader } from "../extensions/extension-loader";
export class WindowManager { export class WindowManager {
protected mainView: BrowserWindow; protected mainView: BrowserWindow;
@ -40,6 +41,9 @@ export class WindowManager {
event.preventDefault(); event.preventDefault();
shell.openExternal(url); shell.openExternal(url);
}); });
this.mainView.webContents.on("dom-ready", () => {
extensionLoader.broadcastExtensions()
})
// track visible cluster from ui // track visible cluster from ui
ipcMain.on("cluster-view:current-id", (event, clusterId: ClusterId) => { ipcMain.on("cluster-view:current-id", (event, clusterId: ClusterId) => {
@ -72,8 +76,8 @@ export class WindowManager {
try { try {
await this.showSplash(); await this.showSplash();
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`) await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
this.mainView.show(); this.mainView.show()
this.splashWindow.close(); this.splashWindow.close()
} catch (err) { } catch (err) {
dialog.showErrorBox("ERROR!", err.toString()) dialog.showErrorBox("ERROR!", err.toString())
} }

View File

@ -4,13 +4,11 @@ import { render, unmountComponentAtNode } from "react-dom";
import { isMac } from "../common/vars"; import { isMac } from "../common/vars";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store"; import { workspaceStore } from "../common/workspace-store";
import { extensionLoader } from "../extensions/extension-loader";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { i18nStore } from "./i18n"; import { i18nStore } from "./i18n";
import { themeStore } from "./theme.store"; import { themeStore } from "./theme.store";
import { App } from "./components/app"; import { App } from "./components/app";
import { LensApp } from "./lens-app"; import { LensApp } from "./lens-app";
import { getLensRuntime } from "../extensions/lens-runtime";
type AppComponent = React.ComponentType & { type AppComponent = React.ComponentType & {
init?(): void; init?(): void;
@ -31,7 +29,6 @@ export async function bootstrap(App: AppComponent) {
// Register additional store listeners // Register additional store listeners
clusterStore.registerIpcListener(); clusterStore.registerIpcListener();
extensionLoader.autoEnableOnLoad(getLensRuntime);
// init app's dependencies if any // init app's dependencies if any
if (App.init) { if (App.init) {

View File

@ -36,7 +36,10 @@ import { getHostedCluster, getHostedClusterId } from "../../common/cluster-store
import logger from "../../main/logger"; import logger from "../../main/logger";
import { clusterIpc } from "../../common/cluster-ipc"; import { clusterIpc } from "../../common/cluster-ipc";
import { webFrame } from "electron"; import { webFrame } from "electron";
import { DynamicPage, dynamicPages } from "../../extensions/register-page"; import { pageStore } from "../../extensions/page-store";
import { DynamicPage } from "../../extensions/dynamic-page";
import { extensionLoader } from "../../extensions/extension-loader";
import { getLensRuntime } from "../../extensions/lens-runtime";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -47,6 +50,7 @@ export class App extends React.Component {
await Terminal.preloadFonts() await Terminal.preloadFonts()
await clusterIpc.activate.invokeFromRenderer(clusterId, frameId); await clusterIpc.activate.invokeFromRenderer(clusterId, frameId);
await getHostedCluster().whenReady; // cluster.refresh() is done at this point await getHostedCluster().whenReady; // cluster.refresh() is done at this point
extensionLoader.loadOnClusterRenderer(getLensRuntime)
} }
get startURL() { get startURL() {
@ -74,7 +78,7 @@ export class App extends React.Component {
<Route component={CustomResources} {...crdRoute}/> <Route component={CustomResources} {...crdRoute}/>
<Route component={UserManagement} {...usersManagementRoute}/> <Route component={UserManagement} {...usersManagementRoute}/>
<Route component={Apps} {...appsRoute}/> <Route component={Apps} {...appsRoute}/>
{dynamicPages.clusterPages.map(page => { {pageStore.clusterPages.map(page => {
return <Route {...page} key={page.path} render={() => <DynamicPage page={page}/>}/> return <Route {...page} key={page.path} render={() => <DynamicPage page={page}/>}/>
})} })}
<Redirect exact from="/" to={this.startURL}/> <Redirect exact from="/" to={this.startURL}/>

View File

@ -14,7 +14,7 @@ import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route"; import { clusterViewRoute, clusterViewURL, getMatchedCluster, getMatchedClusterId } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views"; import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
import { dynamicPages } from "../../../extensions/register-page"; import { pageStore } from "../../../extensions/page-store";
@observer @observer
export class ClusterManager extends React.Component { export class ClusterManager extends React.Component {
@ -63,7 +63,7 @@ export class ClusterManager extends React.Component {
<Route component={AddCluster} {...addClusterRoute} /> <Route component={AddCluster} {...addClusterRoute} />
<Route component={ClusterView} {...clusterViewRoute} /> <Route component={ClusterView} {...clusterViewRoute} />
<Route component={ClusterSettings} {...clusterSettingsRoute} /> <Route component={ClusterSettings} {...clusterSettingsRoute} />
{dynamicPages.globalPages.map(({ path, components: { Page } }) => { {pageStore.globalPages.map(({ path, components: { Page } }) => {
return <Route key={path} path={path} component={Page}/> return <Route key={path} path={path} component={Page}/>
})} })}
<Redirect exact to={this.startUrl} /> <Redirect exact to={this.startUrl} />

View File

@ -22,7 +22,7 @@ import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc"; import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterViewURL } from "./cluster-view.route"; import { clusterViewURL } from "./cluster-view.route";
import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd"; import { DragDropContext, Droppable, Draggable, DropResult, DroppableProvided, DraggableProvided } from "react-beautiful-dnd";
import { dynamicPages } from "../../../extensions/register-page"; import { pageStore } from "../../../extensions/page-store";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -156,7 +156,7 @@ export class ClustersMenu extends React.Component<Props> {
)} )}
</div> </div>
<div className="dynamic-pages"> <div className="dynamic-pages">
{dynamicPages.globalPages.map(({ path, components: { MenuIcon } }) => { {pageStore.globalPages.map(({ path, components: { MenuIcon } }) => {
return <MenuIcon key={path} onClick={() => navigate(path)}/> return <MenuIcon key={path} onClick={() => navigate(path)}/>
})} })}
</div> </div>

View File

@ -28,7 +28,7 @@ import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resourc
import { CustomResources } from "../+custom-resources/custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { isAllowedResource } from "../../../common/rbac" import { isAllowedResource } from "../../../common/rbac"
import { dynamicPages } from "../../../extensions/register-page"; import { pageStore } from "../../../extensions/page-store";
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false }); const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
type SidebarContextValue = { type SidebarContextValue = {
@ -184,7 +184,7 @@ export class Sidebar extends React.Component<Props> {
> >
{this.renderCustomResources()} {this.renderCustomResources()}
</SidebarNavItem> </SidebarNavItem>
{dynamicPages.clusterPages.map(({ path, title, components: { MenuIcon } }) => { {pageStore.clusterPages.map(({ path, title, components: { MenuIcon } }) => {
return ( return (
<SidebarNavItem <SidebarNavItem
key={path} key={path}

View File

@ -11,9 +11,15 @@ import { ErrorBoundary } from "./components/error-boundary";
import { WhatsNew, whatsNewRoute } from "./components/+whats-new"; import { WhatsNew, whatsNewRoute } from "./components/+whats-new";
import { Notifications } from "./components/notifications"; import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog"; import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionLoader } from "../extensions/extension-loader";
import { getLensRuntime } from "../extensions/lens-runtime";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
static async init() {
extensionLoader.loadOnMainRenderer(getLensRuntime)
}
render() { render() {
return ( return (
<I18nProvider i18n={_i18n}> <I18nProvider i18n={_i18n}>

50
webpack.extensions.ts Executable file
View File

@ -0,0 +1,50 @@
import { extensionsDir, extensionsLibName, extensionsRendererLibName } from "./src/common/vars";
import path from "path";
import webpack from "webpack";
import nodeExternals from "webpack-node-externals";
import { webpackLensRenderer } from "./webpack.renderer";
import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin";
import ProgressBarPlugin from "progress-bar-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
export default [
webpackExtensionsApi,
webpackExtensionsRendererApi
]
// todo: use common chunks/externals for "react", "react-dom", etc.
export function webpackExtensionsApi(): webpack.Configuration {
const config = webpackLensRenderer({ showVars: false })
config.name = "extensions-api"
config.entry = {
[extensionsLibName]: path.resolve(extensionsDir, "extension-api.ts")
}
config.externals = [
nodeExternals()
]
config.plugins = [
new ProgressBarPlugin(),
new ForkTsCheckerPlugin(),
]
config.output.libraryTarget = "commonjs2"
config.devtool = "nosources-source-map"
return config
}
export function webpackExtensionsRendererApi(): webpack.Configuration {
const config = webpackLensRenderer({ showVars: false })
config.name = "extensions-renderer-api"
config.entry = {
[extensionsRendererLibName]: path.resolve(extensionsDir, "extension-renderer-api.ts")
}
config.plugins = [
new ProgressBarPlugin(),
new ForkTsCheckerPlugin(),
new MiniCssExtractPlugin({
filename: "[name].css",
})
]
config.output.libraryTarget = "commonjs2"
config.devtool = "nosources-source-map"
return config
}

View File

@ -1,4 +1,4 @@
import { appName, buildDir, extensionsDir, extensionsLibName, htmlTemplate, isDevelopment, isProduction, publicPath, rendererDir, sassCommonVars } from "./src/common/vars"; import { appName, buildDir, extensionsDir, extensionsLibName, extensionsRendererLibName, htmlTemplate, isDevelopment, isProduction, publicPath, rendererDir, sassCommonVars } from "./src/common/vars";
import path from "path"; import path from "path";
import webpack from "webpack"; import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin";
@ -8,22 +8,9 @@ import ForkTsCheckerPlugin from "fork-ts-checker-webpack-plugin"
import ProgressBarPlugin from "progress-bar-webpack-plugin"; import ProgressBarPlugin from "progress-bar-webpack-plugin";
export default [ export default [
webpackLensRenderer, webpackLensRenderer
webpackExtensionsApi,
] ]
// todo: use common chunks/externals for "react", "react-dom", etc.
export function webpackExtensionsApi(): webpack.Configuration {
const config = webpackLensRenderer({ showVars: false });
config.name = "extensions-api"
config.entry = {
[extensionsLibName]: path.resolve(extensionsDir, "extension-api.ts")
};
config.output.libraryTarget = "commonjs2"
config.devtool = "nosources-source-map";
return config;
}
export function webpackLensRenderer({ showVars = true } = {}): webpack.Configuration { export function webpackLensRenderer({ showVars = true } = {}): webpack.Configuration {
if (showVars) { if (showVars) {
console.info('WEBPACK:renderer', require("./src/common/vars")); console.info('WEBPACK:renderer', require("./src/common/vars"));