mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Allow extensions to have external dependencies (#1018)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
1 parent
de3849d22c
commit
2b6f283e1b
21 files changed
+1861
-305
No files matched your search
@@ -0,0 +1,110 @@
|
||||
import type { ExtensionId, LensExtension, ExtensionManifest, ExtensionModel } from "./lens-extension"
|
||||
import { broadcastIpc } from "../common/ipc"
|
||||
import type { LensRuntimeRendererEnv } from "./lens-runtime"
|
||||
import path from "path"
|
||||
import { observable, reaction, toJS, } from "mobx"
|
||||
import logger from "../main/logger"
|
||||
import { app, remote, ipcRenderer } from "electron"
|
||||
|
||||
export interface InstalledExtension extends ExtensionModel {
|
||||
manifestPath: string;
|
||||
manifest: ExtensionManifest;
|
||||
}
|
||||
|
||||
// lazy load so that we get correct userData
|
||||
export function extensionPackagesRoot() {
|
||||
return path.join((app || remote.app).getPath("userData"))
|
||||
}
|
||||
|
||||
export function withExtensionPackagesRoot(callback: Function) {
|
||||
const cwd = process.cwd()
|
||||
try {
|
||||
process.chdir(extensionPackagesRoot())
|
||||
return callback()
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
export class ExtensionLoader {
|
||||
@observable extensions = observable.map<ExtensionId, InstalledExtension>([], { deep: false });
|
||||
@observable instances = observable.map<ExtensionId, LensExtension>([], { deep: false })
|
||||
|
||||
constructor() {
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.on("extensions:loaded", (event, extensions: InstalledExtension[]) => {
|
||||
extensions.forEach((ext) => {
|
||||
if (!this.getById(ext.manifestPath)) {
|
||||
this.extensions.set(ext.manifestPath, ext)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
autoEnableOnLoad(getLensRuntimeEnv: () => LensRuntimeRendererEnv, { delay = 0 } = {}) {
|
||||
logger.info('[EXTENSIONS-LOADER]: auto-activation loaded extensions: ON');
|
||||
return reaction(() => this.extensions.toJS(), installedExtensions => {
|
||||
installedExtensions.forEach((ext) => {
|
||||
let instance = this.instances.get(ext.manifestPath)
|
||||
if (!instance) {
|
||||
const extensionModule = this.requireExtension(ext)
|
||||
if (!extensionModule) {
|
||||
logger.error("[EXTENSION-LOADER] failed to load extension " + ext.manifestPath)
|
||||
return
|
||||
}
|
||||
const LensExtensionClass = extensionModule.default;
|
||||
instance = new LensExtensionClass({ ...ext.manifest, manifestPath: ext.manifestPath, id: ext.manifestPath }, ext.manifest);
|
||||
instance.enable(getLensRuntimeEnv());
|
||||
this.instances.set(ext.id, instance)
|
||||
}
|
||||
})
|
||||
}, {
|
||||
fireImmediately: true,
|
||||
delay: delay,
|
||||
})
|
||||
}
|
||||
|
||||
protected requireExtension(extension: InstalledExtension) {
|
||||
return withExtensionPackagesRoot(() => {
|
||||
try {
|
||||
const extMain = path.join(path.dirname(extension.manifestPath), extension.manifest.main)
|
||||
return __non_webpack_require__(extMain)
|
||||
} catch (err) {
|
||||
console.error(`[EXTENSION-LOADER]: can't load extension main at ${extension.manifestPath}: ${err}`, { extension });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getById(id: ExtensionId): InstalledExtension {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
async removeById(id: ExtensionId) {
|
||||
const extension = this.getById(id);
|
||||
if (extension) {
|
||||
const instance = this.instances.get(extension.id)
|
||||
if (instance) { await instance.uninstall() }
|
||||
this.extensions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastExtensions(frameId?: number) {
|
||||
broadcastIpc({
|
||||
channel: "extensions:loaded",
|
||||
frameId: frameId,
|
||||
frameOnly: !!frameId,
|
||||
args: [this.toJSON().extensions],
|
||||
})
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return toJS({
|
||||
extensions: Array.from(this.extensions).map(([id, instance]) => instance),
|
||||
}, {
|
||||
recurseEverything: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionLoader = new ExtensionLoader()
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ExtensionManifest } from "./lens-extension"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
import logger from "../main/logger"
|
||||
import { withExtensionPackagesRoot, extensionPackagesRoot, InstalledExtension } from "./extension-loader"
|
||||
import npm from "npm"
|
||||
|
||||
export class ExtensionManager {
|
||||
get extensionPackagesRoot() {
|
||||
return extensionPackagesRoot()
|
||||
}
|
||||
|
||||
get folderPath(): string {
|
||||
return path.resolve(__static, "../extensions");
|
||||
}
|
||||
|
||||
async load() {
|
||||
logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot)
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async getExtensionByManifest(manifestPath: string): Promise<InstalledExtension> {
|
||||
let manifestJson: ExtensionManifest;
|
||||
try {
|
||||
manifestJson = __non_webpack_require__(manifestPath)
|
||||
withExtensionPackagesRoot(() => {
|
||||
this.installPackageFromPath(path.dirname(manifestPath))
|
||||
})
|
||||
|
||||
logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name)
|
||||
return {
|
||||
id: manifestJson.name,
|
||||
version: manifestJson.version,
|
||||
name: manifestJson.name,
|
||||
manifestPath: path.join(this.extensionPackagesRoot, "node_modules", manifestJson.name, "package.json"),
|
||||
manifest: manifestJson
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson });
|
||||
}
|
||||
}
|
||||
|
||||
protected installPackageFromPath(path: string): Promise<void> {
|
||||
const origLogger = console.log
|
||||
return new Promise((resolve, reject) => {
|
||||
npm.load({
|
||||
production: true,
|
||||
global: false,
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async loadExtensions() {
|
||||
const extensions = await this.loadFromFolder(this.folderPath);
|
||||
return new Map(extensions.map(ext => [ext.id, ext]));
|
||||
}
|
||||
|
||||
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(async () => await 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)
|
||||
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
|
||||
return extensions;
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionManager = new ExtensionManager()
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { ExtensionId, ExtensionManifest, ExtensionVersion, LensExtension } from "./lens-extension";
|
||||
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 logger from "../main/logger";
|
||||
|
||||
export interface ExtensionStoreModel {
|
||||
version: ExtensionVersion;
|
||||
extensions: [ExtensionId, ExtensionModel][]
|
||||
}
|
||||
|
||||
export interface ExtensionModel {
|
||||
id: ExtensionId;
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@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 {
|
||||
return path.resolve(__static, "../extensions");
|
||||
}
|
||||
|
||||
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);
|
||||
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(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: Array.from(this.extensions).map(([id, instance]) => [id, instance.toJSON()]),
|
||||
}, {
|
||||
recurseEverything: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionStore = ExtensionStore.getInstance<ExtensionStore>()
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ExtensionModel } from "./extension-store";
|
||||
import type { LensRuntimeRendererEnv } from "./lens-runtime";
|
||||
import type { PageRegistration } from "./register-page";
|
||||
import { readJsonSync } from "fs-extra";
|
||||
@@ -9,6 +8,16 @@ export type ExtensionId = string | ExtensionPackageJsonPath;
|
||||
export type ExtensionPackageJsonPath = string;
|
||||
export type ExtensionVersion = string | number;
|
||||
|
||||
export interface ExtensionModel {
|
||||
id: ExtensionId;
|
||||
version: ExtensionVersion;
|
||||
name: string;
|
||||
manifestPath: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
updateUrl?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionManifest extends ExtensionModel {
|
||||
main: string;
|
||||
description?: string; // todo: add more fields similar to package.json + some extra
|
||||
|
||||
Reference in new issue
Block a user