mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Refactor the Extensions settings page (#2221)
This commit is contained in:
parent
96258c369f
commit
86205b365c
@ -23,8 +23,8 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
|
|||||||
* Though under the current (2021/01/18) implementation, these are never matched
|
* Though under the current (2021/01/18) implementation, these are never matched
|
||||||
* against in the final matching so their names are less of a concern.
|
* against in the final matching so their names are less of a concern.
|
||||||
*/
|
*/
|
||||||
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
|
||||||
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
|
||||||
|
|
||||||
export abstract class LensProtocolRouter extends Singleton {
|
export abstract class LensProtocolRouter extends Singleton {
|
||||||
// Map between path schemas and the handlers
|
// Map between path schemas and the handlers
|
||||||
@ -32,7 +32,7 @@ export abstract class LensProtocolRouter extends Singleton {
|
|||||||
|
|
||||||
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
|
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
|
||||||
|
|
||||||
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
|||||||
20
src/common/utils/disposer.ts
Normal file
20
src/common/utils/disposer.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export type Disposer = () => void;
|
||||||
|
|
||||||
|
interface Extendable<T> {
|
||||||
|
push(...vals: T[]): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtendableDisposer = Disposer & Extendable<Disposer>;
|
||||||
|
|
||||||
|
export function disposer(...args: Disposer[]): ExtendableDisposer {
|
||||||
|
const res = () => {
|
||||||
|
args.forEach(dispose => dispose?.());
|
||||||
|
args.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
res.push = (...vals: Disposer[]) => {
|
||||||
|
args.push(...vals);
|
||||||
|
};
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@ -6,13 +6,13 @@ export interface DownloadFileOptions {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadFileTicket {
|
export interface DownloadFileTicket<T> {
|
||||||
url: string;
|
url: string;
|
||||||
promise: Promise<Buffer>;
|
promise: Promise<T>;
|
||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
|
||||||
const fileChunks: Buffer[] = [];
|
const fileChunks: Buffer[] = [];
|
||||||
const req = request(url, { gzip, timeout });
|
const req = request(url, { gzip, timeout });
|
||||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||||
@ -35,3 +35,12 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<any> {
|
||||||
|
const { promise, ...rest } = downloadFile(args);
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise: promise.then(res => JSON.parse(res.toString())),
|
||||||
|
...rest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -19,3 +19,4 @@ export * from "./downloadFile";
|
|||||||
export * from "./escapeRegExp";
|
export * from "./escapeRegExp";
|
||||||
export * from "./tar";
|
export * from "./tar";
|
||||||
export * from "./type-narrowing";
|
export * from "./type-narrowing";
|
||||||
|
export * from "./disposer";
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
|
import mockFs from "mock-fs";
|
||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { join, normalize } from "path";
|
import path from "path";
|
||||||
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
|
import { ExtensionDiscovery } from "../extension-discovery";
|
||||||
|
import os from "os";
|
||||||
|
import { Console } from "console";
|
||||||
|
|
||||||
jest.mock("../../common/ipc");
|
jest.mock("../../common/ipc");
|
||||||
jest.mock("fs-extra");
|
|
||||||
jest.mock("chokidar", () => ({
|
jest.mock("chokidar", () => ({
|
||||||
watch: jest.fn()
|
watch: jest.fn()
|
||||||
}));
|
}));
|
||||||
@ -14,13 +16,24 @@ jest.mock("../extension-installer", () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console = new Console(process.stdout, process.stderr); // fix mockFS
|
||||||
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
|
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
|
||||||
|
|
||||||
describe("ExtensionDiscovery", () => {
|
describe("ExtensionDiscovery", () => {
|
||||||
it("emits add for added extension", async done => {
|
describe("with mockFs", () => {
|
||||||
globalThis.__non_webpack_require__.mockImplementation(() => ({
|
beforeEach(() => {
|
||||||
|
mockFs({
|
||||||
|
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]: JSON.stringify({
|
||||||
name: "my-extension"
|
name: "my-extension"
|
||||||
}));
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockFs.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits add for added extension", async (done) => {
|
||||||
let addHandler: (filePath: string) => void;
|
let addHandler: (filePath: string) => void;
|
||||||
|
|
||||||
const mockWatchInstance: any = {
|
const mockWatchInstance: any = {
|
||||||
@ -43,21 +56,22 @@ describe("ExtensionDiscovery", () => {
|
|||||||
|
|
||||||
await extensionDiscovery.watchExtensions();
|
await extensionDiscovery.watchExtensions();
|
||||||
|
|
||||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
extensionDiscovery.events.on("add", extension => {
|
||||||
expect(extension).toEqual({
|
expect(extension).toEqual({
|
||||||
absolutePath: expect.any(String),
|
absolutePath: expect.any(String),
|
||||||
id: normalize("node_modules/my-extension/package.json"),
|
id: path.normalize("node_modules/my-extension/package.json"),
|
||||||
isBundled: false,
|
isBundled: false,
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "my-extension",
|
name: "my-extension",
|
||||||
},
|
},
|
||||||
manifestPath: normalize("node_modules/my-extension/package.json"),
|
manifestPath: path.normalize("node_modules/my-extension/package.json"),
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("doesn't emit add for added file under extension", async done => {
|
it("doesn't emit add for added file under extension", async done => {
|
||||||
@ -87,7 +101,7 @@ describe("ExtensionDiscovery", () => {
|
|||||||
|
|
||||||
extensionDiscovery.events.on("add", onAdd);
|
extensionDiscovery.events.on("add", onAdd);
|
||||||
|
|
||||||
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
|
addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect(onAdd).not.toHaveBeenCalled();
|
expect(onAdd).not.toHaveBeenCalled();
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { watch } from "chokidar";
|
import { watch } from "chokidar";
|
||||||
import { ipcRenderer } from "electron";
|
import { ipcRenderer } from "electron";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import fs from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import { observable, reaction, toJS, when } from "mobx";
|
import { observable, reaction, toJS, when } from "mobx";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
||||||
import { getBundledExtensions } from "../common/utils/app-version";
|
import { getBundledExtensions } from "../common/utils/app-version";
|
||||||
import logger from "../main/logger";
|
import logger from "../main/logger";
|
||||||
|
import { ExtensionInstallationStateStore } from "../renderer/components/+extensions/extension-install.store";
|
||||||
import { extensionInstaller, PackageJson } from "./extension-installer";
|
import { extensionInstaller, PackageJson } from "./extension-installer";
|
||||||
|
import { extensionLoader } from "./extension-loader";
|
||||||
import { extensionsStore } from "./extensions-store";
|
import { extensionsStore } from "./extensions-store";
|
||||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||||
|
|
||||||
@ -39,7 +41,7 @@ interface ExtensionDiscoveryChannelMessage {
|
|||||||
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
|
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
|
||||||
* @param lstat the stats to compare
|
* @param lstat the stats to compare
|
||||||
*/
|
*/
|
||||||
const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
|
const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovers installed bundled and local extensions from the filesystem.
|
* Discovers installed bundled and local extensions from the filesystem.
|
||||||
@ -64,11 +66,7 @@ export class ExtensionDiscovery {
|
|||||||
// IPC channel to broadcast changes to extension-discovery from main
|
// IPC channel to broadcast changes to extension-discovery from main
|
||||||
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
|
protected static readonly extensionDiscoveryChannel = "extension-discovery:main";
|
||||||
|
|
||||||
public events: EventEmitter;
|
public events = new EventEmitter();
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.events = new EventEmitter();
|
|
||||||
}
|
|
||||||
|
|
||||||
get localFolderPath(): string {
|
get localFolderPath(): string {
|
||||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
return path.join(os.homedir(), ".k8slens", "extensions");
|
||||||
@ -136,7 +134,7 @@ export class ExtensionDiscovery {
|
|||||||
depth: 1,
|
depth: 1,
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
// Try to wait until the file has been completely copied.
|
// Try to wait until the file has been completely copied.
|
||||||
// The OS might emit an event for added file even it's not completely written to the filesysten.
|
// The OS might emit an event for added file even it's not completely written to the filesystem.
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
// Wait 300ms until the file size doesn't change to consider the file written.
|
// Wait 300ms until the file size doesn't change to consider the file written.
|
||||||
// For a small file like package.json this should be plenty of time.
|
// For a small file like package.json this should be plenty of time.
|
||||||
@ -145,8 +143,10 @@ export class ExtensionDiscovery {
|
|||||||
})
|
})
|
||||||
// Extension add is detected by watching "<extensionDir>/package.json" add
|
// Extension add is detected by watching "<extensionDir>/package.json" add
|
||||||
.on("add", this.handleWatchFileAdd)
|
.on("add", this.handleWatchFileAdd)
|
||||||
// Extension remove is detected by watching <extensionDir>" unlink
|
// Extension remove is detected by watching "<extensionDir>" unlink
|
||||||
.on("unlinkDir", this.handleWatchUnlinkDir);
|
.on("unlinkDir", this.handleWatchUnlinkEvent)
|
||||||
|
// Extension remove is detected by watching "<extensionSymLink>" unlink
|
||||||
|
.on("unlink", this.handleWatchUnlinkEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleWatchFileAdd = async (manifestPath: string) => {
|
handleWatchFileAdd = async (manifestPath: string) => {
|
||||||
@ -160,6 +160,7 @@ export class ExtensionDiscovery {
|
|||||||
|
|
||||||
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
|
||||||
try {
|
try {
|
||||||
|
ExtensionInstallationStateStore.setInstallingFromMain(manifestPath);
|
||||||
const absPath = path.dirname(manifestPath);
|
const absPath = path.dirname(manifestPath);
|
||||||
|
|
||||||
// this.loadExtensionFromPath updates this.packagesJson
|
// this.loadExtensionFromPath updates this.packagesJson
|
||||||
@ -167,7 +168,7 @@ export class ExtensionDiscovery {
|
|||||||
|
|
||||||
if (extension) {
|
if (extension) {
|
||||||
// Remove a broken symlink left by a previous installation if it exists.
|
// Remove a broken symlink left by a previous installation if it exists.
|
||||||
await this.removeSymlinkByManifestPath(manifestPath);
|
await fse.remove(extension.manifestPath);
|
||||||
|
|
||||||
// Install dependencies for the new extension
|
// Install dependencies for the new extension
|
||||||
await this.installPackage(extension.absolutePath);
|
await this.installPackage(extension.absolutePath);
|
||||||
@ -177,40 +178,46 @@ export class ExtensionDiscovery {
|
|||||||
this.events.emit("add", extension);
|
this.events.emit("add", extension);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(`${logModule}: failed to add extension: ${error}`, { error });
|
||||||
|
} finally {
|
||||||
|
ExtensionInstallationStateStore.clearInstallingFromMain(manifestPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleWatchUnlinkDir = async (filePath: string) => {
|
/**
|
||||||
// filePath is the non-symlinked path to the extension folder
|
* Handle any unlink event, filtering out non-package.json links so the delete code
|
||||||
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
|
* only happens once per extension.
|
||||||
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
|
* @param filePath The absolute path to either a folder or file in the extensions folder
|
||||||
|
*/
|
||||||
|
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
|
||||||
// Check that the removed path is directly under this.localFolderPath
|
// Check that the removed path is directly under this.localFolderPath
|
||||||
// Note that the watcher can create unlink events for subdirectories of the extension
|
// Note that the watcher can create unlink events for subdirectories of the extension
|
||||||
const extensionFolderName = path.basename(filePath);
|
const extensionFolderName = path.basename(filePath);
|
||||||
|
const expectedPath = path.relative(this.localFolderPath, filePath);
|
||||||
|
|
||||||
|
if (expectedPath !== extensionFolderName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
|
|
||||||
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
const extension = Array.from(this.extensions.values()).find((extension) => extension.absolutePath === filePath);
|
||||||
|
|
||||||
if (extension) {
|
if (!extension) {
|
||||||
|
return void logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||||
|
}
|
||||||
|
|
||||||
const extensionName = extension.manifest.name;
|
const extensionName = extension.manifest.name;
|
||||||
|
|
||||||
// If the extension is deleted manually while the application is running, also remove the symlink
|
// If the extension is deleted manually while the application is running, also remove the symlink
|
||||||
await this.removeSymlinkByPackageName(extensionName);
|
await this.removeSymlinkByPackageName(extensionName);
|
||||||
|
|
||||||
// The path to the manifest file is the lens extension id
|
// The path to the manifest file is the lens extension id
|
||||||
// Note that we need to use the symlinked path
|
// Note: that we need to use the symlinked path
|
||||||
const lensExtensionId = extension.manifestPath;
|
const lensExtensionId = extension.manifestPath;
|
||||||
|
|
||||||
this.extensions.delete(extension.id);
|
this.extensions.delete(extension.id);
|
||||||
logger.info(`${logModule} removed extension ${extensionName}`);
|
logger.info(`${logModule} removed extension ${extensionName}`);
|
||||||
this.events.emit("remove", lensExtensionId as LensExtensionId);
|
this.events.emit("remove", lensExtensionId);
|
||||||
} else {
|
|
||||||
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -220,31 +227,23 @@ export class ExtensionDiscovery {
|
|||||||
* @param name e.g. "@mirantis/lens-extension-cc"
|
* @param name e.g. "@mirantis/lens-extension-cc"
|
||||||
*/
|
*/
|
||||||
removeSymlinkByPackageName(name: string) {
|
removeSymlinkByPackageName(name: string) {
|
||||||
return fs.remove(this.getInstalledPath(name));
|
return fse.remove(this.getInstalledPath(name));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the symlink under node_modules if it exists.
|
|
||||||
* @param manifestPath Path to package.json
|
|
||||||
*/
|
|
||||||
removeSymlinkByManifestPath(manifestPath: string) {
|
|
||||||
const manifestJson = __non_webpack_require__(manifestPath);
|
|
||||||
|
|
||||||
return this.removeSymlinkByPackageName(manifestJson.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uninstalls extension.
|
* Uninstalls extension.
|
||||||
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
* The application will detect the folder unlink and remove the extension from the UI automatically.
|
||||||
* @param extension Extension to unistall.
|
* @param extensionId The ID of the extension to uninstall.
|
||||||
*/
|
*/
|
||||||
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
|
async uninstallExtension(extensionId: LensExtensionId) {
|
||||||
|
const { manifest, absolutePath } = this.extensions.get(extensionId) ?? extensionLoader.getExtension(extensionId);
|
||||||
|
|
||||||
logger.info(`${logModule} Uninstalling ${manifest.name}`);
|
logger.info(`${logModule} Uninstalling ${manifest.name}`);
|
||||||
|
|
||||||
await this.removeSymlinkByPackageName(manifest.name);
|
await this.removeSymlinkByPackageName(manifest.name);
|
||||||
|
|
||||||
// fs.remove does nothing if the path doesn't exist anymore
|
// fs.remove does nothing if the path doesn't exist anymore
|
||||||
await fs.remove(absolutePath);
|
await fse.remove(absolutePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||||
@ -258,12 +257,11 @@ export class ExtensionDiscovery {
|
|||||||
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
|
logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`);
|
||||||
|
|
||||||
// fs.remove won't throw if path is missing
|
// fs.remove won't throw if path is missing
|
||||||
await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
|
await fse.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"));
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify write access to static/extensions, which is needed for symlinking
|
// Verify write access to static/extensions, which is needed for symlinking
|
||||||
await fs.access(this.inTreeFolderPath, fs.constants.W_OK);
|
await fse.access(this.inTreeFolderPath, fse.constants.W_OK);
|
||||||
|
|
||||||
// Set bundled folder path to static/extensions
|
// Set bundled folder path to static/extensions
|
||||||
this.bundledFolderPath = this.inTreeFolderPath;
|
this.bundledFolderPath = this.inTreeFolderPath;
|
||||||
@ -272,20 +270,20 @@ export class ExtensionDiscovery {
|
|||||||
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
|
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
|
||||||
|
|
||||||
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||||
await fs.remove(this.inTreeTargetPath);
|
await fse.remove(this.inTreeTargetPath);
|
||||||
|
|
||||||
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||||
await fs.ensureDir(this.inTreeTargetPath);
|
await fse.ensureDir(this.inTreeTargetPath);
|
||||||
|
|
||||||
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||||
await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath);
|
await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath);
|
||||||
|
|
||||||
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
|
||||||
this.bundledFolderPath = this.inTreeTargetPath;
|
this.bundledFolderPath = this.inTreeTargetPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.ensureDir(this.nodeModulesPath);
|
await fse.ensureDir(this.nodeModulesPath);
|
||||||
await fs.ensureDir(this.localFolderPath);
|
await fse.ensureDir(this.localFolderPath);
|
||||||
|
|
||||||
const extensions = await this.ensureExtensions();
|
const extensions = await this.ensureExtensions();
|
||||||
|
|
||||||
@ -314,30 +312,22 @@ export class ExtensionDiscovery {
|
|||||||
* Returns InstalledExtension from path to package.json file.
|
* Returns InstalledExtension from path to package.json file.
|
||||||
* Also updates this.packagesJson.
|
* Also updates this.packagesJson.
|
||||||
*/
|
*/
|
||||||
protected async getByManifest(manifestPath: string, { isBundled = false }: {
|
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension | null> {
|
||||||
isBundled?: boolean;
|
|
||||||
} = {}): Promise<InstalledExtension | null> {
|
|
||||||
let manifestJson: LensExtensionManifest;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// check manifest file for existence
|
const manifest = await fse.readJson(manifestPath);
|
||||||
fs.accessSync(manifestPath, fs.constants.F_OK);
|
const installedManifestPath = this.getInstalledManifestPath(manifest.name);
|
||||||
|
|
||||||
manifestJson = __non_webpack_require__(manifestPath);
|
|
||||||
const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
|
|
||||||
|
|
||||||
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: installedManifestPath,
|
id: installedManifestPath,
|
||||||
absolutePath: path.dirname(manifestPath),
|
absolutePath: path.dirname(manifestPath),
|
||||||
manifestPath: installedManifestPath,
|
manifestPath: installedManifestPath,
|
||||||
manifest: manifestJson,
|
manifest,
|
||||||
isBundled,
|
isBundled,
|
||||||
isEnabled
|
isEnabled
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`, { manifestJson });
|
logger.error(`${logModule}: can't load extension manifest at ${manifestPath}: ${error}`);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -351,7 +341,7 @@ export class ExtensionDiscovery {
|
|||||||
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
const userExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||||
|
|
||||||
for (const extension of userExtensions) {
|
for (const extension of userExtensions) {
|
||||||
if (await fs.pathExists(extension.manifestPath) === false) {
|
if (await fse.pathExists(extension.manifestPath) === false) {
|
||||||
await this.installPackage(extension.absolutePath);
|
await this.installPackage(extension.absolutePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -383,7 +373,7 @@ export class ExtensionDiscovery {
|
|||||||
const extensions: InstalledExtension[] = [];
|
const extensions: InstalledExtension[] = [];
|
||||||
const folderPath = this.bundledFolderPath;
|
const folderPath = this.bundledFolderPath;
|
||||||
const bundledExtensions = getBundledExtensions();
|
const bundledExtensions = getBundledExtensions();
|
||||||
const paths = await fs.readdir(folderPath);
|
const paths = await fse.readdir(folderPath);
|
||||||
|
|
||||||
for (const fileName of paths) {
|
for (const fileName of paths) {
|
||||||
if (!bundledExtensions.includes(fileName)) {
|
if (!bundledExtensions.includes(fileName)) {
|
||||||
@ -405,7 +395,7 @@ export class ExtensionDiscovery {
|
|||||||
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
||||||
const bundledExtensions = getBundledExtensions();
|
const bundledExtensions = getBundledExtensions();
|
||||||
const extensions: InstalledExtension[] = [];
|
const extensions: InstalledExtension[] = [];
|
||||||
const paths = await fs.readdir(folderPath);
|
const paths = await fse.readdir(folderPath);
|
||||||
|
|
||||||
for (const fileName of paths) {
|
for (const fileName of paths) {
|
||||||
// do not allow to override bundled extensions
|
// do not allow to override bundled extensions
|
||||||
@ -415,11 +405,11 @@ export class ExtensionDiscovery {
|
|||||||
|
|
||||||
const absPath = path.resolve(folderPath, fileName);
|
const absPath = path.resolve(folderPath, fileName);
|
||||||
|
|
||||||
if (!fs.existsSync(absPath)) {
|
if (!fse.existsSync(absPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lstat = await fs.lstat(absPath);
|
const lstat = await fse.lstat(absPath);
|
||||||
|
|
||||||
// skip non-directories
|
// skip non-directories
|
||||||
if (!isDirectoryLike(lstat)) {
|
if (!isDirectoryLike(lstat)) {
|
||||||
|
|||||||
@ -12,8 +12,6 @@ import type { LensExtension, LensExtensionConstructor, LensExtensionId } from ".
|
|||||||
import type { LensMainExtension } from "./lens-main-extension";
|
import type { LensMainExtension } from "./lens-main-extension";
|
||||||
import type { LensRendererExtension } from "./lens-renderer-extension";
|
import type { LensRendererExtension } from "./lens-renderer-extension";
|
||||||
import * as registries from "./registries";
|
import * as registries from "./registries";
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
|
|
||||||
export function extensionPackagesRoot() {
|
export function extensionPackagesRoot() {
|
||||||
return path.join((app || remote.app).getPath("userData"));
|
return path.join((app || remote.app).getPath("userData"));
|
||||||
@ -290,28 +288,20 @@ export class ExtensionLoader {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
|
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor | null {
|
||||||
let extEntrypoint = "";
|
const entryPointName = ipcRenderer ? "renderer" : "main";
|
||||||
|
const extRelativePath = extension.manifest[entryPointName];
|
||||||
|
|
||||||
|
if (!extRelativePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extAbsolutePath = path.resolve(path.join(path.dirname(extension.manifestPath), extRelativePath));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ipcRenderer && extension.manifest.renderer) {
|
return __non_webpack_require__(extAbsolutePath).default;
|
||||||
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
|
} catch (error) {
|
||||||
} else if (!ipcRenderer && extension.manifest.main) {
|
logger.error(`${logModule}: can't load extension main at ${extAbsolutePath}: ${error}`, { extension, error });
|
||||||
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extEntrypoint !== "") {
|
|
||||||
if (!fs.existsSync(extEntrypoint)) {
|
|
||||||
console.log(`${logModule}: entrypoint ${extEntrypoint} not found, skipping ...`);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return __non_webpack_require__(extEntrypoint).default;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
|
|
||||||
console.trace(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { filesystemProvisionerStore } from "../main/extension-filesystem";
|
|||||||
import { App } from "./components/app";
|
import { App } from "./components/app";
|
||||||
import { LensApp } from "./lens-app";
|
import { LensApp } from "./lens-app";
|
||||||
import { themeStore } from "./theme.store";
|
import { themeStore } from "./theme.store";
|
||||||
|
import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If this is a development buid, wait a second to attach
|
* If this is a development buid, wait a second to attach
|
||||||
@ -50,6 +51,7 @@ export async function bootstrap(App: AppComponent) {
|
|||||||
await attachChromeDebugger();
|
await attachChromeDebugger();
|
||||||
rootElem.classList.toggle("is-mac", isMac);
|
rootElem.classList.toggle("is-mac", isMac);
|
||||||
|
|
||||||
|
ExtensionInstallationStateStore.bindIpcListeners();
|
||||||
extensionLoader.init();
|
extensionLoader.init();
|
||||||
extensionDiscovery.init();
|
extensionDiscovery.init();
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,15 @@
|
|||||||
import "@testing-library/jest-dom/extend-expect";
|
import "@testing-library/jest-dom/extend-expect";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { extensionDiscovery } from "../../../../extensions/extension-discovery";
|
import { extensionDiscovery } from "../../../../extensions/extension-discovery";
|
||||||
import { ConfirmDialog } from "../../confirm-dialog";
|
import { ConfirmDialog } from "../../confirm-dialog";
|
||||||
import { Notifications } from "../../notifications";
|
import { ExtensionInstallationStateStore } from "../extension-install.store";
|
||||||
import { ExtensionStateStore } from "../extension-install.store";
|
|
||||||
import { Extensions } from "../extensions";
|
import { Extensions } from "../extensions";
|
||||||
|
|
||||||
|
jest.setTimeout(30000);
|
||||||
jest.mock("fs-extra");
|
jest.mock("fs-extra");
|
||||||
|
jest.mock("../../notifications");
|
||||||
|
|
||||||
jest.mock("../../../../common/utils", () => ({
|
jest.mock("../../../../common/utils", () => ({
|
||||||
...jest.requireActual("../../../../common/utils"),
|
...jest.requireActual("../../../../common/utils"),
|
||||||
@ -42,62 +43,42 @@ jest.mock("../../../../extensions/extension-loader", () => ({
|
|||||||
isBundled: false,
|
isBundled: false,
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
}]
|
}]
|
||||||
])
|
]),
|
||||||
|
getExtension: jest.fn(() => ({ manifest: {} })),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("../../notifications", () => ({
|
|
||||||
ok: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
info: jest.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Extensions", () => {
|
describe("Extensions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ExtensionStateStore.resetInstance();
|
ExtensionInstallationStateStore.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables uninstall and disable buttons while uninstalling", async () => {
|
it("disables uninstall and disable buttons while uninstalling", async () => {
|
||||||
render(<><Extensions /><ConfirmDialog/></>);
|
const res = render(<><Extensions /><ConfirmDialog /></>);
|
||||||
|
|
||||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
expect(res.getByText("Disable").closest("button")).not.toBeDisabled();
|
||||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
expect(res.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Uninstall"));
|
fireEvent.click(res.getByText("Uninstall"));
|
||||||
|
|
||||||
// Approve confirm dialog
|
// Approve confirm dialog
|
||||||
fireEvent.click(screen.getByText("Yes"));
|
fireEvent.click(res.getByText("Yes"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
|
||||||
expect(screen.getByText("Disable").closest("button")).toBeDisabled();
|
expect(res.getByText("Disable").closest("button")).toBeDisabled();
|
||||||
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
|
expect(res.getByText("Uninstall").closest("button")).toBeDisabled();
|
||||||
});
|
}, {
|
||||||
|
timeout: 30000,
|
||||||
it("displays error notification on uninstall error", () => {
|
|
||||||
(extensionDiscovery.uninstallExtension as any).mockImplementationOnce(() =>
|
|
||||||
Promise.reject()
|
|
||||||
);
|
|
||||||
render(<><Extensions /><ConfirmDialog/></>);
|
|
||||||
|
|
||||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
|
||||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Uninstall"));
|
|
||||||
|
|
||||||
// Approve confirm dialog
|
|
||||||
fireEvent.click(screen.getByText("Yes"));
|
|
||||||
|
|
||||||
waitFor(() => {
|
|
||||||
expect(screen.getByText("Disable").closest("button")).not.toBeDisabled();
|
|
||||||
expect(screen.getByText("Uninstall").closest("button")).not.toBeDisabled();
|
|
||||||
expect(Notifications.error).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables install button while installing", () => {
|
it("disables install button while installing", async () => {
|
||||||
render(<Extensions />);
|
const res = render(<Extensions />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("Path or URL to an extension package", {
|
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve() as any);
|
||||||
|
|
||||||
|
fireEvent.change(res.getByPlaceholderText("Path or URL to an extension package", {
|
||||||
exact: false
|
exact: false
|
||||||
}), {
|
}), {
|
||||||
target: {
|
target: {
|
||||||
@ -105,25 +86,21 @@ describe("Extensions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Install"));
|
fireEvent.click(res.getByText("Install"));
|
||||||
|
expect(res.getByText("Install").closest("button")).toBeDisabled();
|
||||||
waitFor(() => {
|
|
||||||
expect(screen.getByText("Install").closest("button")).toBeDisabled();
|
|
||||||
expect(fse.move).toHaveBeenCalledWith("");
|
|
||||||
expect(Notifications.error).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays spinner while extensions are loading", () => {
|
it("displays spinner while extensions are loading", async () => {
|
||||||
extensionDiscovery.isLoaded = false;
|
extensionDiscovery.isLoaded = false;
|
||||||
const { container } = render(<Extensions />);
|
const res = render(<Extensions />);
|
||||||
|
|
||||||
expect(container.querySelector(".Spinner")).toBeInTheDocument();
|
expect(res.container.querySelector(".Spinner")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display the spinner while extensions are not loading", async () => {
|
||||||
extensionDiscovery.isLoaded = true;
|
extensionDiscovery.isLoaded = true;
|
||||||
|
const res = render(<Extensions />);
|
||||||
|
|
||||||
waitFor(() =>
|
expect(res.container.querySelector(".Spinner")).not.toBeInTheDocument();
|
||||||
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,218 @@
|
|||||||
import { observable } from "mobx";
|
import { action, computed, observable } from "mobx";
|
||||||
import { autobind, Singleton } from "../../utils";
|
import logger from "../../../main/logger";
|
||||||
|
import { disposer, ExtendableDisposer } from "../../utils";
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
import { broadcastMessage } from "../../../common/ipc";
|
||||||
|
import { ipcRenderer } from "electron";
|
||||||
|
|
||||||
interface ExtensionState {
|
export enum ExtensionInstallationState {
|
||||||
displayName: string;
|
INSTALLING = "installing",
|
||||||
// Possible states the extension can be
|
UNINSTALLING = "uninstalling",
|
||||||
state: "installing" | "uninstalling";
|
IDLE = "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
@autobind()
|
const Prefix = "[ExtensionInstallationStore]";
|
||||||
export class ExtensionStateStore extends Singleton {
|
|
||||||
extensionState = observable.map<string, ExtensionState>();
|
export class ExtensionInstallationStateStore {
|
||||||
|
private static InstallingFromMainChannel = "extension-installation-state-store:install";
|
||||||
|
private static ClearInstallingFromMainChannel = "extension-installation-state-store:clear-install";
|
||||||
|
private static PreInstallIds = observable.set<string>();
|
||||||
|
private static UninstallingExtensions = observable.set<string>();
|
||||||
|
private static InstallingExtensions = observable.set<string>();
|
||||||
|
|
||||||
|
static bindIpcListeners() {
|
||||||
|
ipcRenderer
|
||||||
|
.on(ExtensionInstallationStateStore.InstallingFromMainChannel, (event, extId) => {
|
||||||
|
ExtensionInstallationStateStore.setInstalling(extId);
|
||||||
|
})
|
||||||
|
.on(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, (event, extId) => {
|
||||||
|
ExtensionInstallationStateStore.clearInstalling(extId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action static reset() {
|
||||||
|
logger.warn(`${Prefix}: resetting, may throw errors`);
|
||||||
|
ExtensionInstallationStateStore.InstallingExtensions.clear();
|
||||||
|
ExtensionInstallationStateStore.UninstallingExtensions.clear();
|
||||||
|
ExtensionInstallationStateStore.PreInstallIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly transitions an extension from not installing to installing
|
||||||
|
* @param extId the ID of the extension
|
||||||
|
* @throws if state is not IDLE
|
||||||
|
*/
|
||||||
|
@action static setInstalling(extId: string): void {
|
||||||
|
logger.debug(`${Prefix}: trying to set ${extId} as installing`);
|
||||||
|
|
||||||
|
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||||
|
|
||||||
|
if (curState !== ExtensionInstallationState.IDLE) {
|
||||||
|
throw new Error(`${Prefix}: cannot set ${extId} as installing. Is currently ${curState}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionInstallationStateStore.InstallingExtensions.add(extId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts that an extension is being installed by the main process
|
||||||
|
* @param extId the ID of the extension
|
||||||
|
*/
|
||||||
|
static setInstallingFromMain(extId: string): void {
|
||||||
|
broadcastMessage(ExtensionInstallationStateStore.InstallingFromMainChannel, extId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts that an extension is no longer being installed by the main process
|
||||||
|
* @param extId the ID of the extension
|
||||||
|
*/
|
||||||
|
static clearInstallingFromMain(extId: string): void {
|
||||||
|
broadcastMessage(ExtensionInstallationStateStore.ClearInstallingFromMainChannel, extId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the start of a pre-install phase of an extension installation. The
|
||||||
|
* part of the installation before the tarball has been unpacked and the ID
|
||||||
|
* determined.
|
||||||
|
* @returns a disposer which should be called to mark the end of the install phase
|
||||||
|
*/
|
||||||
|
@action static startPreInstall(): ExtendableDisposer {
|
||||||
|
const preInstallStepId = uuid.v4();
|
||||||
|
|
||||||
|
logger.debug(`${Prefix}: starting a new preinstall phase: ${preInstallStepId}`);
|
||||||
|
ExtensionInstallationStateStore.PreInstallIds.add(preInstallStepId);
|
||||||
|
|
||||||
|
return disposer(() => {
|
||||||
|
ExtensionInstallationStateStore.PreInstallIds.delete(preInstallStepId);
|
||||||
|
logger.debug(`${Prefix}: ending a preinstall phase: ${preInstallStepId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly transitions an extension from not uninstalling to uninstalling
|
||||||
|
* @param extId the ID of the extension
|
||||||
|
* @throws if state is not IDLE
|
||||||
|
*/
|
||||||
|
@action static setUninstalling(extId: string): void {
|
||||||
|
logger.debug(`${Prefix}: trying to set ${extId} as uninstalling`);
|
||||||
|
|
||||||
|
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||||
|
|
||||||
|
if (curState !== ExtensionInstallationState.IDLE) {
|
||||||
|
throw new Error(`${Prefix}: cannot set ${extId} as uninstalling. Is currently ${curState}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionInstallationStateStore.UninstallingExtensions.add(extId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly clears the INSTALLING state of an extension
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
* @throws if state is not INSTALLING
|
||||||
|
*/
|
||||||
|
@action static clearInstalling(extId: string): void {
|
||||||
|
logger.debug(`${Prefix}: trying to clear ${extId} as installing`);
|
||||||
|
|
||||||
|
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||||
|
|
||||||
|
switch (curState) {
|
||||||
|
case ExtensionInstallationState.INSTALLING:
|
||||||
|
return void ExtensionInstallationStateStore.InstallingExtensions.delete(extId);
|
||||||
|
default:
|
||||||
|
throw new Error(`${Prefix}: cannot clear INSTALLING state for ${extId}, it is currently ${curState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly clears the UNINSTALLING state of an extension
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
* @throws if state is not UNINSTALLING
|
||||||
|
*/
|
||||||
|
@action static clearUninstalling(extId: string): void {
|
||||||
|
logger.debug(`${Prefix}: trying to clear ${extId} as uninstalling`);
|
||||||
|
|
||||||
|
const curState = ExtensionInstallationStateStore.getInstallationState(extId);
|
||||||
|
|
||||||
|
switch (curState) {
|
||||||
|
case ExtensionInstallationState.UNINSTALLING:
|
||||||
|
return void ExtensionInstallationStateStore.UninstallingExtensions.delete(extId);
|
||||||
|
default:
|
||||||
|
throw new Error(`${Prefix}: cannot clear UNINSTALLING state for ${extId}, it is currently ${curState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current state of the extension. IDLE is default value.
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
*/
|
||||||
|
static getInstallationState(extId: string): ExtensionInstallationState {
|
||||||
|
if (ExtensionInstallationStateStore.InstallingExtensions.has(extId)) {
|
||||||
|
return ExtensionInstallationState.INSTALLING;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ExtensionInstallationStateStore.UninstallingExtensions.has(extId)) {
|
||||||
|
return ExtensionInstallationState.UNINSTALLING;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtensionInstallationState.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the extension is currently INSTALLING
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
*/
|
||||||
|
static isExtensionInstalling(extId: string): boolean {
|
||||||
|
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.INSTALLING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the extension is currently UNINSTALLING
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
*/
|
||||||
|
static isExtensionUninstalling(extId: string): boolean {
|
||||||
|
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.UNINSTALLING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the extension is currently IDLE
|
||||||
|
* @param extId The ID of the extension
|
||||||
|
*/
|
||||||
|
static isExtensionIdle(extId: string): boolean {
|
||||||
|
return ExtensionInstallationStateStore.getInstallationState(extId) === ExtensionInstallationState.IDLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current number of extensions installing
|
||||||
|
*/
|
||||||
|
@computed static get installing(): number {
|
||||||
|
return ExtensionInstallationStateStore.InstallingExtensions.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is at least one extension currently installing
|
||||||
|
*/
|
||||||
|
@computed static get anyInstalling(): boolean {
|
||||||
|
return ExtensionInstallationStateStore.installing > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current number of extensions preinstalling
|
||||||
|
*/
|
||||||
|
@computed static get preinstalling(): number {
|
||||||
|
return ExtensionInstallationStateStore.PreInstallIds.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is at least one extension currently downloading
|
||||||
|
*/
|
||||||
|
@computed static get anyPreinstalling(): boolean {
|
||||||
|
return ExtensionInstallationStateStore.preinstalling > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is at least one installing or preinstalling step taking place
|
||||||
|
*/
|
||||||
|
@computed static get anyPreInstallingOrInstalling(): boolean {
|
||||||
|
return ExtensionInstallationStateStore.anyInstalling || ExtensionInstallationStateStore.anyPreinstalling;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
|
import "./extensions.scss";
|
||||||
import { remote, shell } from "electron";
|
import { remote, shell } from "electron";
|
||||||
import fse from "fs-extra";
|
import fse from "fs-extra";
|
||||||
import { computed, observable, reaction } from "mobx";
|
import { computed, observable, reaction, when } from "mobx";
|
||||||
import { disposeOnUnmount, observer } from "mobx-react";
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
import { autobind, disposer, Disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar } from "../../../common/utils";
|
||||||
import { docsUrl } from "../../../common/vars";
|
import { docsUrl } from "../../../common/vars";
|
||||||
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
|
import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery";
|
||||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||||
import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||||
import logger from "../../../main/logger";
|
import logger from "../../../main/logger";
|
||||||
import { prevDefault } from "../../utils";
|
import { prevDefault } from "../../utils";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -21,209 +22,120 @@ import { SubTitle } from "../layout/sub-title";
|
|||||||
import { Notifications } from "../notifications";
|
import { Notifications } from "../notifications";
|
||||||
import { Spinner } from "../spinner/spinner";
|
import { Spinner } from "../spinner/spinner";
|
||||||
import { TooltipPosition } from "../tooltip";
|
import { TooltipPosition } from "../tooltip";
|
||||||
import { ExtensionStateStore } from "./extension-install.store";
|
import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store";
|
||||||
import "./extensions.scss";
|
import URLParse from "url-parse";
|
||||||
|
import { SemVer } from "semver";
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
function getMessageFromError(error: any): string {
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return "an error has occured";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
return String(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.err) {
|
||||||
|
return String(error.err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessage = String(error);
|
||||||
|
|
||||||
|
if (rawMessage === String({})) {
|
||||||
|
return "an error has occured";
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionInfo {
|
||||||
|
name: string;
|
||||||
|
version?: string;
|
||||||
|
requireConfirmation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface InstallRequest {
|
interface InstallRequest {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
filePath?: string;
|
dataP: Promise<Buffer | null>;
|
||||||
data?: Buffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstallRequestPreloaded extends InstallRequest {
|
interface InstallRequestValidated {
|
||||||
|
fileName: string;
|
||||||
data: Buffer;
|
data: Buffer;
|
||||||
}
|
id: LensExtensionId;
|
||||||
|
|
||||||
interface InstallRequestValidated extends InstallRequestPreloaded {
|
|
||||||
manifest: LensExtensionManifest;
|
manifest: LensExtensionManifest;
|
||||||
tempFile: string; // temp system path to packed extension for unpacking
|
tempFile: string; // temp system path to packed extension for unpacking
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
async function uninstallExtension(extensionId: LensExtensionId): Promise<boolean> {
|
||||||
export class Extensions extends React.Component {
|
const { manifest } = extensionLoader.getExtension(extensionId);
|
||||||
private static supportedFormats = ["tar", "tgz"];
|
const displayName = extensionDisplayName(manifest.name, manifest.version);
|
||||||
|
|
||||||
private static installPathValidator: InputValidator = {
|
try {
|
||||||
message: "Invalid URL or absolute path",
|
logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`);
|
||||||
validate(value: string) {
|
ExtensionInstallationStateStore.setUninstalling(extensionId);
|
||||||
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
get extensionStateStore() {
|
await extensionDiscovery.uninstallExtension(extensionId);
|
||||||
return ExtensionStateStore.getInstance<ExtensionStateStore>();
|
|
||||||
}
|
|
||||||
|
|
||||||
@observable search = "";
|
// wait for the extensionLoader to actually uninstall the extension
|
||||||
@observable installPath = "";
|
await when(() => !extensionLoader.userExtensions.has(extensionId));
|
||||||
|
|
||||||
// True if the preliminary install steps have started, but unpackExtension has not started yet
|
|
||||||
@observable startingInstall = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extensions that were removed from extensions but are still in "uninstalling" state
|
|
||||||
*/
|
|
||||||
@computed get removedUninstalling() {
|
|
||||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
|
||||||
.filter(([id, extension]) =>
|
|
||||||
extension.state === "uninstalling"
|
|
||||||
&& !this.extensions.find(extension => extension.id === id)
|
|
||||||
)
|
|
||||||
.map(([id, extension]) => ({ ...extension, id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extensions that were added to extensions but are still in "installing" state
|
|
||||||
*/
|
|
||||||
@computed get addedInstalling() {
|
|
||||||
return Array.from(this.extensionStateStore.extensionState.entries())
|
|
||||||
.filter(([id, extension]) =>
|
|
||||||
extension.state === "installing"
|
|
||||||
&& this.extensions.find(extension => extension.id === id)
|
|
||||||
)
|
|
||||||
.map(([id, extension]) => ({ ...extension, id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
disposeOnUnmount(this,
|
|
||||||
reaction(() => this.extensions, () => {
|
|
||||||
this.removedUninstalling.forEach(({ id, displayName }) => {
|
|
||||||
Notifications.ok(
|
Notifications.ok(
|
||||||
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
|
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
|
||||||
);
|
);
|
||||||
this.extensionStateStore.extensionState.delete(id);
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
|
logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error });
|
||||||
|
Notifications.error(<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
// Remove uninstall state on uninstall failure
|
||||||
|
ExtensionInstallationStateStore.clearUninstalling(extensionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUninstallExtension(extension: InstalledExtension): Promise<void> {
|
||||||
|
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
||||||
|
const confirmed = await ConfirmDialog.confirm({
|
||||||
|
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
||||||
|
labelOk: "Yes",
|
||||||
|
labelCancel: "No",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addedInstalling.forEach(({ id, displayName }) => {
|
if (confirmed) {
|
||||||
const extension = this.extensions.find(extension => extension.id === id);
|
await uninstallExtension(extension.id);
|
||||||
|
}
|
||||||
if (!extension) {
|
|
||||||
throw new Error("Extension not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.ok(
|
function getExtensionDestFolder(name: string) {
|
||||||
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
|
||||||
);
|
|
||||||
this.extensionStateStore.extensionState.delete(id);
|
|
||||||
this.installPath = "";
|
|
||||||
|
|
||||||
// Enable installed extensions by default.
|
|
||||||
extension.isEnabled = true;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed get extensions() {
|
function getExtensionPackageTemp(fileName = "") {
|
||||||
const searchText = this.search.toLowerCase();
|
|
||||||
|
|
||||||
return Array.from(extensionLoader.userExtensions.values())
|
|
||||||
.filter(({ manifest: { name, description }}) => (
|
|
||||||
name.toLowerCase().includes(searchText)
|
|
||||||
|| description?.toLowerCase().includes(searchText)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
get extensionsPath() {
|
|
||||||
return extensionDiscovery.localFolderPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
getExtensionPackageTemp(fileName = "") {
|
|
||||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
getExtensionDestFolder(name: string) {
|
async function readFileNotify(filePath: string, showError = true): Promise<Buffer | null> {
|
||||||
return path.join(this.extensionsPath, sanitizeExtensionName(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
installFromSelectFileDialog = async () => {
|
|
||||||
const { dialog, BrowserWindow, app } = remote;
|
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
|
||||||
defaultPath: app.getPath("downloads"),
|
|
||||||
properties: ["openFile", "multiSelections"],
|
|
||||||
message: `Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `,
|
|
||||||
buttonLabel: `Use configuration`,
|
|
||||||
filters: [
|
|
||||||
{ name: "tarball", extensions: Extensions.supportedFormats }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!canceled && filePaths.length) {
|
|
||||||
this.requestInstall(
|
|
||||||
filePaths.map(filePath => ({
|
|
||||||
fileName: path.basename(filePath),
|
|
||||||
filePath,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
installFromUrlOrPath = async () => {
|
|
||||||
const { installPath } = this;
|
|
||||||
|
|
||||||
if (!installPath) return;
|
|
||||||
|
|
||||||
this.startingInstall = true;
|
|
||||||
const fileName = path.basename(installPath);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// install via url
|
return await fse.readFile(filePath);
|
||||||
// fixme: improve error messages for non-tar-file URLs
|
|
||||||
if (InputValidators.isUrl.validate(installPath)) {
|
|
||||||
const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ });
|
|
||||||
const data = await filePromise;
|
|
||||||
|
|
||||||
await this.requestInstall({ fileName, data });
|
|
||||||
}
|
|
||||||
// otherwise installing from system path
|
|
||||||
else if (InputValidators.isPath.validate(installPath)) {
|
|
||||||
await this.requestInstall({ fileName, filePath: installPath });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.startingInstall = false;
|
|
||||||
Notifications.error(
|
|
||||||
<p>Installation has failed: <b>{String(error)}</b></p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
installOnDrop = (files: File[]) => {
|
|
||||||
logger.info("Install from D&D");
|
|
||||||
|
|
||||||
return this.requestInstall(
|
|
||||||
files.map(file => ({
|
|
||||||
fileName: path.basename(file.path),
|
|
||||||
filePath: file.path,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
|
||||||
const preloadedRequests = requests.filter(request => request.data);
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
requests
|
|
||||||
.filter(request => !request.data && request.filePath)
|
|
||||||
.map(async request => {
|
|
||||||
try {
|
|
||||||
const data = await fse.readFile(request.filePath);
|
|
||||||
|
|
||||||
request.data = data;
|
|
||||||
preloadedRequests.push(request);
|
|
||||||
|
|
||||||
return request;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (showError) {
|
if (showError) {
|
||||||
Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`);
|
const message = getMessageFromError(error);
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return preloadedRequests as InstallRequestPreloaded[];
|
logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error });
|
||||||
|
Notifications.error(`Error while reading "${filePath}": ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||||
const tarFiles = await listTarEntries(filePath);
|
const tarFiles = await listTarEntries(filePath);
|
||||||
|
|
||||||
// tarball from npm contains single root folder "package/*"
|
// tarball from npm contains single root folder "package/*"
|
||||||
@ -247,119 +159,70 @@ export class Extensions extends React.Component {
|
|||||||
parseJson: true,
|
parseJson: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!manifest.lens && !manifest.renderer) {
|
if (!manifest.main && !manifest.renderer) {
|
||||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
|
async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest, disposer: ExtendableDisposer): Promise<InstallRequestValidated | null> {
|
||||||
const validatedRequests: InstallRequestValidated[] = [];
|
|
||||||
|
|
||||||
// copy files to temp
|
// copy files to temp
|
||||||
await fse.ensureDir(this.getExtensionPackageTemp());
|
await fse.ensureDir(getExtensionPackageTemp());
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
const tempFile = this.getExtensionPackageTemp(request.fileName);
|
|
||||||
|
|
||||||
await fse.writeFile(tempFile, request.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate packages
|
// validate packages
|
||||||
await Promise.all(
|
const tempFile = getExtensionPackageTemp(fileName);
|
||||||
requests.map(async req => {
|
|
||||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
disposer.push(() => fse.unlink(tempFile));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const manifest = await this.validatePackage(tempFile);
|
const data = await dataP;
|
||||||
|
|
||||||
validatedRequests.push({
|
if (!data) {
|
||||||
...req,
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fse.writeFile(tempFile, data);
|
||||||
|
const manifest = await validatePackage(tempFile);
|
||||||
|
const id = path.join(extensionDiscovery.nodeModulesPath, manifest.name, "package.json");
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
data,
|
||||||
manifest,
|
manifest,
|
||||||
tempFile,
|
tempFile,
|
||||||
});
|
id,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
if (showErrors) {
|
logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error });
|
||||||
Notifications.error(
|
Notifications.error(
|
||||||
<div className="flex column gaps">
|
<div className="flex column gaps">
|
||||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
<p>Installing <em>{fileName}</em> has failed, skipping.</p>
|
||||||
<p>Reason: <em>{String(error)}</em></p>
|
<p>Reason: <em>{message}</em></p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return validatedRequests;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestInstall(init: InstallRequest | InstallRequest[]) {
|
async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) {
|
||||||
const requests = Array.isArray(init) ? init : [init];
|
const { id, fileName, tempFile, manifest: { name, version } } = request;
|
||||||
const preloadedRequests = await this.preloadExtensions(requests);
|
|
||||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
|
||||||
|
|
||||||
// If there are no requests for installing, reset startingInstall state
|
ExtensionInstallationStateStore.setInstalling(id);
|
||||||
if (validatedRequests.length === 0) {
|
disposeDownloading?.();
|
||||||
this.startingInstall = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const install of validatedRequests) {
|
|
||||||
const { name, version, description } = install.manifest;
|
|
||||||
const extensionFolder = this.getExtensionDestFolder(name);
|
|
||||||
const folderExists = await fse.pathExists(extensionFolder);
|
|
||||||
|
|
||||||
if (!folderExists) {
|
|
||||||
// auto-install extension if not yet exists
|
|
||||||
this.unpackExtension(install);
|
|
||||||
} else {
|
|
||||||
// If we show the confirmation dialog, we stop the install spinner until user clicks ok
|
|
||||||
// and the install continues
|
|
||||||
this.startingInstall = false;
|
|
||||||
|
|
||||||
// otherwise confirmation required (re-install / update)
|
|
||||||
const removeNotification = Notifications.info(
|
|
||||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
|
||||||
<div className="flex column gaps">
|
|
||||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
|
||||||
<p>Description: <em>{description}</em></p>
|
|
||||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
|
||||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button autoFocus label="Install" onClick={() => {
|
|
||||||
removeNotification();
|
|
||||||
this.unpackExtension(install);
|
|
||||||
}}/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
|
||||||
const displayName = extensionDisplayName(name, version);
|
const displayName = extensionDisplayName(name, version);
|
||||||
const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json");
|
const extensionFolder = getExtensionDestFolder(name);
|
||||||
|
|
||||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
|
||||||
|
|
||||||
this.extensionStateStore.extensionState.set(extensionId, {
|
|
||||||
state: "installing",
|
|
||||||
displayName
|
|
||||||
});
|
|
||||||
this.startingInstall = false;
|
|
||||||
|
|
||||||
const extensionFolder = this.getExtensionDestFolder(name);
|
|
||||||
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
|
const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`);
|
||||||
|
|
||||||
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// extract to temp folder first
|
// extract to temp folder first
|
||||||
await fse.remove(unpackingTempFolder).catch(Function);
|
await fse.remove(unpackingTempFolder).catch(noop);
|
||||||
await fse.ensureDir(unpackingTempFolder);
|
await fse.ensureDir(unpackingTempFolder);
|
||||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||||
|
|
||||||
@ -375,77 +238,286 @@ export class Extensions extends React.Component {
|
|||||||
|
|
||||||
await fse.ensureDir(extensionFolder);
|
await fse.ensureDir(extensionFolder);
|
||||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||||
} catch (error) {
|
|
||||||
Notifications.error(
|
// wait for the loader has actually install it
|
||||||
<p>Installing extension <b>{displayName}</b> has failed: <em>{error}</em></p>
|
await when(() => extensionLoader.userExtensions.has(id));
|
||||||
|
|
||||||
|
// Enable installed extensions by default.
|
||||||
|
extensionLoader.userExtensions.get(id).isEnabled = true;
|
||||||
|
|
||||||
|
Notifications.ok(
|
||||||
|
<p>Extension <b>{displayName}</b> successfully installed!</p>
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
// Remove install state on install failure
|
logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error });
|
||||||
if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") {
|
Notifications.error(<p>Installing extension <b>{displayName}</b> has failed: <em>{message}</em></p>);
|
||||||
this.extensionStateStore.extensionState.delete(extensionId);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
|
// Remove install state once finished
|
||||||
|
ExtensionInstallationStateStore.clearInstalling(id);
|
||||||
|
|
||||||
// clean up
|
// clean up
|
||||||
fse.remove(unpackingTempFolder).catch(Function);
|
fse.remove(unpackingTempFolder).catch(noop);
|
||||||
fse.unlink(tempFile).catch(Function);
|
fse.unlink(tempFile).catch(noop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmUninstallExtension = (extension: InstalledExtension) => {
|
export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) {
|
||||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
const disposer = ExtensionInstallationStateStore.startPreInstall();
|
||||||
|
const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString();
|
||||||
|
const { promise } = downloadJson({ url: registryUrl });
|
||||||
|
const json = await promise.catch(console.error);
|
||||||
|
|
||||||
ConfirmDialog.open({
|
if (!json || json.error || typeof json.versions !== "object" || !json.versions) {
|
||||||
message: <p>Are you sure you want to uninstall extension <b>{displayName}</b>?</p>,
|
const message = json?.error ? `: ${json.error}` : "";
|
||||||
labelOk: "Yes",
|
|
||||||
labelCancel: "No",
|
Notifications.error(`Failed to get registry information for that extension${message}`);
|
||||||
ok: () => this.uninstallExtension(extension)
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version) {
|
||||||
|
if (!json.versions[version]) {
|
||||||
|
Notifications.error(<p>The <em>{name}</em> extension does not have a v{version}.</p>);
|
||||||
|
|
||||||
|
return disposer();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const versions = Object.keys(json.versions)
|
||||||
|
.map(version => new SemVer(version, { loose: true, includePrerelease: true }))
|
||||||
|
// ignore pre-releases for auto picking the version
|
||||||
|
.filter(version => version.prerelease.length === 0);
|
||||||
|
|
||||||
|
version = _.reduce(versions, (prev, curr) => (
|
||||||
|
prev.compareMain(curr) === -1
|
||||||
|
? curr
|
||||||
|
: prev
|
||||||
|
)).format();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireConfirmation) {
|
||||||
|
const proceed = await ConfirmDialog.confirm({
|
||||||
|
message: <p>Are you sure you want to install <b>{name}@{version}</b>?</p>,
|
||||||
|
labelCancel: "Cancel",
|
||||||
|
labelOk: "Install",
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
async uninstallExtension(extension: InstalledExtension) {
|
if (!proceed) {
|
||||||
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
|
return disposer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = json.versions[version].dist.tarball;
|
||||||
|
const fileName = path.basename(url);
|
||||||
|
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
|
||||||
|
|
||||||
|
return attemptInstall({ fileName, dataP }, disposer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptInstall(request: InstallRequest, d?: ExtendableDisposer): Promise<void> {
|
||||||
|
const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d);
|
||||||
|
const validatedRequest = await createTempFilesAndValidate(request, dispose);
|
||||||
|
|
||||||
|
if (!validatedRequest) {
|
||||||
|
return dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, version, description } = validatedRequest.manifest;
|
||||||
|
const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id);
|
||||||
|
|
||||||
|
if (curState !== ExtensionInstallationState.IDLE) {
|
||||||
|
dispose();
|
||||||
|
|
||||||
|
return Notifications.error(
|
||||||
|
<div className="flex column gaps">
|
||||||
|
<b>Extension Install Collision:</b>
|
||||||
|
<p>The <em>{name}</em> extension is currently {curState.toLowerCase()}.</p>
|
||||||
|
<p>Will not proceed with this current install request.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionFolder = getExtensionDestFolder(name);
|
||||||
|
const folderExists = await fse.pathExists(extensionFolder);
|
||||||
|
|
||||||
|
if (!folderExists) {
|
||||||
|
// install extension if not yet exists
|
||||||
|
await unpackExtension(validatedRequest, dispose);
|
||||||
|
} else {
|
||||||
|
const { manifest: { version: oldVersion } } = extensionLoader.getExtension(validatedRequest.id);
|
||||||
|
|
||||||
|
// otherwise confirmation required (re-install / update)
|
||||||
|
const removeNotification = Notifications.info(
|
||||||
|
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||||
|
<div className="flex column gaps">
|
||||||
|
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||||
|
<p>Description: <em>{description}</em></p>
|
||||||
|
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||||
|
<b>Warning:</b> {name}@{oldVersion} will be removed before installation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button autoFocus label="Install" onClick={async () => {
|
||||||
|
removeNotification();
|
||||||
|
|
||||||
|
if (await uninstallExtension(validatedRequest.id)) {
|
||||||
|
await unpackExtension(validatedRequest, dispose);
|
||||||
|
} else {
|
||||||
|
dispose();
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
onClose: dispose,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attemptInstalls(filePaths: string[]): Promise<void> {
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const filePath of filePaths) {
|
||||||
|
promises.push(attemptInstall({
|
||||||
|
fileName: path.basename(filePath),
|
||||||
|
dataP: readFileNotify(filePath),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installOnDrop(files: File[]) {
|
||||||
|
logger.info("Install from D&D");
|
||||||
|
await attemptInstalls(files.map(({ path }) => path));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installFromInput(input: string) {
|
||||||
|
let disposer: ExtendableDisposer | undefined = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.extensionStateStore.extensionState.set(extension.id, {
|
// fixme: improve error messages for non-tar-file URLs
|
||||||
state: "uninstalling",
|
if (InputValidators.isUrl.validate(input)) {
|
||||||
displayName
|
// install via url
|
||||||
|
disposer = ExtensionInstallationStateStore.startPreInstall();
|
||||||
|
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 });
|
||||||
|
const fileName = path.basename(input);
|
||||||
|
|
||||||
|
await attemptInstall({ fileName, dataP: promise }, disposer);
|
||||||
|
} else if (InputValidators.isPath.validate(input)) {
|
||||||
|
// install from system path
|
||||||
|
const fileName = path.basename(input);
|
||||||
|
|
||||||
|
await attemptInstall({ fileName, dataP: readFileNotify(input) });
|
||||||
|
} else if (InputValidators.isExtensionNameInstall.validate(input)) {
|
||||||
|
const [{ groups: { name, version }}] = [...input.matchAll(InputValidators.isExtensionNameInstallRegex)];
|
||||||
|
|
||||||
|
await attemptInstallByInfo({ name, version });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = getMessageFromError(error);
|
||||||
|
|
||||||
|
logger.info(`[EXTENSION-INSTALL]: installation has failed: ${message}`, { error, installPath: input });
|
||||||
|
Notifications.error(<p>Installation has failed: <b>{message}</b></p>);
|
||||||
|
} finally {
|
||||||
|
disposer?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportedFormats = ["tar", "tgz"];
|
||||||
|
|
||||||
|
async function installFromSelectFileDialog() {
|
||||||
|
const { dialog, BrowserWindow, app } = remote;
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||||
|
defaultPath: app.getPath("downloads"),
|
||||||
|
properties: ["openFile", "multiSelections"],
|
||||||
|
message: `Select extensions to install (formats: ${supportedFormats.join(", ")}), `,
|
||||||
|
buttonLabel: "Use configuration",
|
||||||
|
filters: [
|
||||||
|
{ name: "tarball", extensions: supportedFormats }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
await extensionDiscovery.uninstallExtension(extension);
|
if (!canceled) {
|
||||||
} catch (error) {
|
await attemptInstalls(filePaths);
|
||||||
Notifications.error(
|
}
|
||||||
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class Extensions extends React.Component {
|
||||||
|
private static installInputValidators = [
|
||||||
|
InputValidators.isUrl,
|
||||||
|
InputValidators.isPath,
|
||||||
|
InputValidators.isExtensionNameInstall,
|
||||||
|
];
|
||||||
|
|
||||||
|
private static installInputValidator: InputValidator = {
|
||||||
|
message: "Invalid URL, absolute path, or extension name",
|
||||||
|
validate: (value: string) => (
|
||||||
|
Extensions.installInputValidators.some(({ validate }) => validate(value))
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
@observable search = "";
|
||||||
|
@observable installPath = "";
|
||||||
|
|
||||||
|
@computed get searchedForExtensions() {
|
||||||
|
const searchText = this.search.toLowerCase();
|
||||||
|
|
||||||
|
return Array.from(extensionLoader.userExtensions.values())
|
||||||
|
.filter(({ manifest: { name, description }}) => (
|
||||||
|
name.toLowerCase().includes(searchText)
|
||||||
|
|| description?.toLowerCase().includes(searchText)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality
|
||||||
|
let prevSize = extensionLoader.userExtensions.size;
|
||||||
|
|
||||||
|
disposeOnUnmount(this, [
|
||||||
|
reaction(() => extensionLoader.userExtensions.size, curSize => {
|
||||||
|
try {
|
||||||
|
if (curSize > prevSize) {
|
||||||
|
when(() => !ExtensionInstallationStateStore.anyInstalling)
|
||||||
|
.then(() => this.installPath = "");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
prevSize = curSize;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoExtensionsHelpText() {
|
||||||
|
if (this.search) {
|
||||||
|
return <p>No search results found</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
There are no installed extensions.
|
||||||
|
See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove uninstall state on uninstall failure
|
|
||||||
if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") {
|
|
||||||
this.extensionStateStore.extensionState.delete(extension.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderExtensions() {
|
renderNoExtensions() {
|
||||||
const { extensions, search } = this;
|
|
||||||
|
|
||||||
if (!extensions.length) {
|
|
||||||
return (
|
return (
|
||||||
<div className="no-extensions flex box gaps justify-center">
|
<div className="no-extensions flex box gaps justify-center">
|
||||||
<Icon material="info" />
|
<Icon material="info" />
|
||||||
<div>
|
<div>
|
||||||
{
|
{this.renderNoExtensionsHelpText()}
|
||||||
search
|
|
||||||
? <p>No search results found</p>
|
|
||||||
: <p>There are no installed extensions. See list of <a href="https://github.com/lensapp/lens-extensions/blob/main/README.md" target="_blank" rel="noreferrer">available extensions</a>.</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return extensions.map(extension => {
|
@autobind()
|
||||||
|
renderExtension(extension: InstalledExtension) {
|
||||||
const { id, isEnabled, manifest } = extension;
|
const { id, isEnabled, manifest } = extension;
|
||||||
const { name, description, version } = manifest;
|
const { name, description, version } = manifest;
|
||||||
const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling";
|
const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="extension flex gaps align-center">
|
<div key={id} className="extension flex gaps align-center">
|
||||||
@ -455,30 +527,41 @@ export class Extensions extends React.Component {
|
|||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{!isEnabled && (
|
{
|
||||||
<Button plain active disabled={isUninstalling} onClick={() => {
|
isEnabled
|
||||||
extension.isEnabled = true;
|
? <Button accent disabled={isUninstalling} onClick={() => extension.isEnabled = false}>Disable</Button>
|
||||||
}}>Enable</Button>
|
: <Button plain active disabled={isUninstalling} onClick={() => extension.isEnabled = true}>Enable</Button>
|
||||||
)}
|
}
|
||||||
{isEnabled && (
|
<Button
|
||||||
<Button accent disabled={isUninstalling} onClick={() => {
|
plain
|
||||||
extension.isEnabled = false;
|
active
|
||||||
}}>Disable</Button>
|
disabled={isUninstalling}
|
||||||
)}
|
waiting={isUninstalling}
|
||||||
<Button plain active disabled={isUninstalling} waiting={isUninstalling} onClick={() => {
|
onClick={() => confirmUninstallExtension(extension)}
|
||||||
this.confirmUninstallExtension(extension);
|
>
|
||||||
}}>Uninstall</Button>
|
Uninstall
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
renderExtensions() {
|
||||||
* True if at least one extension is in installing state
|
if (!extensionDiscovery.isLoaded) {
|
||||||
*/
|
return <div className="spinner-wrapper"><Spinner /></div>;
|
||||||
@computed get isInstalling() {
|
}
|
||||||
return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing");
|
|
||||||
|
const { searchedForExtensions } = this;
|
||||||
|
|
||||||
|
if (!searchedForExtensions.length) {
|
||||||
|
return this.renderNoExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{...searchedForExtensions.map(this.renderExtension)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -486,7 +569,7 @@ export class Extensions extends React.Component {
|
|||||||
const { installPath } = this;
|
const { installPath } = this;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
<DropFileInput onDropFiles={installOnDrop}>
|
||||||
<PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}>
|
<PageLayout showOnTop className="Extensions" header={topHeader} contentGaps={false}>
|
||||||
<h2>Lens Extensions</h2>
|
<h2>Lens Extensions</h2>
|
||||||
<div>
|
<div>
|
||||||
@ -500,19 +583,19 @@ export class Extensions extends React.Component {
|
|||||||
<Input
|
<Input
|
||||||
className="box grow"
|
className="box grow"
|
||||||
theme="round-black"
|
theme="round-black"
|
||||||
disabled={this.isInstalling}
|
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||||
placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`}
|
placeholder={`Name or file path or URL to an extension package (${supportedFormats.join(", ")})`}
|
||||||
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
|
||||||
validators={installPath ? Extensions.installPathValidator : undefined}
|
validators={installPath ? Extensions.installInputValidator : undefined}
|
||||||
value={installPath}
|
value={installPath}
|
||||||
onChange={value => this.installPath = value}
|
onChange={value => this.installPath = value}
|
||||||
onSubmit={this.installFromUrlOrPath}
|
onSubmit={() => installFromInput(this.installPath)}
|
||||||
iconLeft="link"
|
iconLeft="link"
|
||||||
iconRight={
|
iconRight={
|
||||||
<Icon
|
<Icon
|
||||||
interactive
|
interactive
|
||||||
material="folder"
|
material="folder"
|
||||||
onClick={prevDefault(this.installFromSelectFileDialog)}
|
onClick={prevDefault(installFromSelectFileDialog)}
|
||||||
tooltip="Browse"
|
tooltip="Browse"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -521,9 +604,9 @@ export class Extensions extends React.Component {
|
|||||||
<Button
|
<Button
|
||||||
primary
|
primary
|
||||||
label="Install"
|
label="Install"
|
||||||
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
|
disabled={ExtensionInstallationStateStore.anyPreInstallingOrInstalling || !Extensions.installInputValidator.validate(installPath)}
|
||||||
waiting={this.isInstalling}
|
waiting={ExtensionInstallationStateStore.anyPreInstallingOrInstalling}
|
||||||
onClick={this.installFromUrlOrPath}
|
onClick={() => installFromInput(this.installPath)}
|
||||||
/>
|
/>
|
||||||
<small className="hint">
|
<small className="hint">
|
||||||
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
|
<b>Pro-Tip</b>: you can also drag-n-drop tarball-file to this area
|
||||||
@ -537,7 +620,7 @@ export class Extensions extends React.Component {
|
|||||||
value={this.search}
|
value={this.search}
|
||||||
onChange={(value) => this.search = value}
|
onChange={(value) => this.search = value}
|
||||||
/>
|
/>
|
||||||
{extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>}
|
{this.renderExtensions()}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</DropFileInput>
|
</DropFileInput>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "./button.scss";
|
import "./button.scss";
|
||||||
import React, { ButtonHTMLAttributes, ReactNode } from "react";
|
import React, { ButtonHTMLAttributes } from "react";
|
||||||
import { cssNames } from "../../utils";
|
import { cssNames } from "../../utils";
|
||||||
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
|
import { TooltipDecoratorProps, withTooltip } from "../tooltip";
|
||||||
|
|
||||||
@ -26,29 +26,22 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
className, waiting, label, primary, accent, plain, hidden, active, big,
|
waiting, label, primary, accent, plain, hidden, active, big,
|
||||||
round, outlined, tooltip, light, children, ...props
|
round, outlined, tooltip, light, children, ...btnProps
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const btnProps: Partial<ButtonProps> = props;
|
|
||||||
|
|
||||||
if (hidden) return null;
|
if (hidden) return null;
|
||||||
|
|
||||||
btnProps.className = cssNames("Button", className, {
|
btnProps.className = cssNames("Button", btnProps.className, {
|
||||||
waiting, primary, accent, plain, active, big, round, outlined, light,
|
waiting, primary, accent, plain, active, big, round, outlined, light,
|
||||||
});
|
});
|
||||||
|
|
||||||
const btnContent: ReactNode = (
|
|
||||||
<>
|
|
||||||
{label}
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// render as link
|
// render as link
|
||||||
if (this.props.href) {
|
if (this.props.href) {
|
||||||
return (
|
return (
|
||||||
<a {...btnProps} ref={e => this.link = e}>
|
<a {...btnProps} ref={e => this.link = e}>
|
||||||
{btnContent}
|
{label}
|
||||||
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -56,7 +49,8 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
|||||||
// render as button
|
// render as button
|
||||||
return (
|
return (
|
||||||
<button type="button" {...btnProps} ref={e => this.button = e}>
|
<button type="button" {...btnProps} ref={e => this.button = e}>
|
||||||
{btnContent}
|
{label}
|
||||||
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,18 @@ import { Icon } from "../icon";
|
|||||||
export interface ConfirmDialogProps extends Partial<DialogProps> {
|
export interface ConfirmDialogProps extends Partial<DialogProps> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmDialogParams {
|
export interface ConfirmDialogParams extends ConfirmDialogBooleanParams {
|
||||||
ok?: () => void;
|
ok?: () => any | Promise<any>;
|
||||||
|
cancel?: () => any | Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmDialogBooleanParams {
|
||||||
labelOk?: ReactNode;
|
labelOk?: ReactNode;
|
||||||
labelCancel?: ReactNode;
|
labelCancel?: ReactNode;
|
||||||
message?: ReactNode;
|
message: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
okButtonProps?: Partial<ButtonProps>
|
okButtonProps?: Partial<ButtonProps>;
|
||||||
cancelButtonProps?: Partial<ButtonProps>
|
cancelButtonProps?: Partial<ButtonProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -33,19 +37,26 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
|||||||
ConfirmDialog.params = params;
|
ConfirmDialog.params = params;
|
||||||
}
|
}
|
||||||
|
|
||||||
static close() {
|
static confirm(params: ConfirmDialogBooleanParams): Promise<boolean> {
|
||||||
ConfirmDialog.isOpen = false;
|
return new Promise(resolve => {
|
||||||
|
ConfirmDialog.open({
|
||||||
|
ok: () => resolve(true),
|
||||||
|
cancel: () => resolve(false),
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public defaultParams: ConfirmDialogParams = {
|
static defaultParams: Partial<ConfirmDialogParams> = {
|
||||||
ok: noop,
|
ok: noop,
|
||||||
|
cancel: noop,
|
||||||
labelOk: "Ok",
|
labelOk: "Ok",
|
||||||
labelCancel: "Cancel",
|
labelCancel: "Cancel",
|
||||||
icon: <Icon big material="warning"/>,
|
icon: <Icon big material="warning"/>,
|
||||||
};
|
};
|
||||||
|
|
||||||
get params(): ConfirmDialogParams {
|
get params(): ConfirmDialogParams {
|
||||||
return Object.assign({}, this.defaultParams, ConfirmDialog.params);
|
return Object.assign({}, ConfirmDialog.defaultParams, ConfirmDialog.params);
|
||||||
}
|
}
|
||||||
|
|
||||||
ok = async () => {
|
ok = async () => {
|
||||||
@ -54,16 +65,21 @@ export class ConfirmDialog extends React.Component<ConfirmDialogProps> {
|
|||||||
await Promise.resolve(this.params.ok()).catch(noop);
|
await Promise.resolve(this.params.ok()).catch(noop);
|
||||||
} finally {
|
} finally {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
|
ConfirmDialog.isOpen = false;
|
||||||
}
|
}
|
||||||
this.close();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onClose = () => {
|
onClose = () => {
|
||||||
this.isSaving = false;
|
this.isSaving = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
close = () => {
|
close = async () => {
|
||||||
ConfirmDialog.close();
|
try {
|
||||||
|
await Promise.resolve(this.params.cancel()).catch(noop);
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false;
|
||||||
|
ConfirmDialog.isOpen = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@ -315,6 +315,7 @@ export class Input extends React.Component<InputProps, State> {
|
|||||||
rows: multiLine ? (rows || 1) : null,
|
rows: multiLine ? (rows || 1) : null,
|
||||||
ref: this.bindRef,
|
ref: this.bindRef,
|
||||||
spellCheck: "false",
|
spellCheck: "false",
|
||||||
|
disabled,
|
||||||
});
|
});
|
||||||
const showErrors = errors.length > 0 && !valid && dirty;
|
const showErrors = errors.length > 0 && !valid && dirty;
|
||||||
const errorsInfo = (
|
const errorsInfo = (
|
||||||
|
|||||||
@ -47,6 +47,14 @@ export const isUrl: InputValidator = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isExtensionNameInstallRegex = /^(?<name>(@[-\w]+\/)?[-\w]+)(@(?<version>\d\.\d\.\d(-\w+)?))?$/gi;
|
||||||
|
|
||||||
|
export const isExtensionNameInstall: InputValidator = {
|
||||||
|
condition: ({ type }) => type === "text",
|
||||||
|
message: () => "Not an extension name with optional version",
|
||||||
|
validate: value => value.match(isExtensionNameInstallRegex) !== null,
|
||||||
|
};
|
||||||
|
|
||||||
export const isPath: InputValidator = {
|
export const isPath: InputValidator = {
|
||||||
condition: ({ type }) => type === "text",
|
condition: ({ type }) => type === "text",
|
||||||
message: () => `This field must be a valid path`,
|
message: () => `This field must be a valid path`,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { addClusterURL } from "../components/+add-cluster";
|
import { addClusterURL } from "../components/+add-cluster";
|
||||||
import { clusterSettingsURL } from "../components/+cluster-settings";
|
import { clusterSettingsURL } from "../components/+cluster-settings";
|
||||||
import { extensionsURL } from "../components/+extensions";
|
import { attemptInstallByInfo, extensionsURL } from "../components/+extensions";
|
||||||
import { landingURL } from "../components/+landing-page";
|
import { landingURL } from "../components/+landing-page";
|
||||||
import { preferencesURL } from "../components/+preferences";
|
import { preferencesURL } from "../components/+preferences";
|
||||||
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
|
import { clusterViewURL } from "../components/cluster-manager/cluster-view.route";
|
||||||
@ -8,6 +8,7 @@ import { LensProtocolRouterRenderer } from "./router";
|
|||||||
import { navigate } from "../navigation/helpers";
|
import { navigate } from "../navigation/helpers";
|
||||||
import { clusterStore } from "../../common/cluster-store";
|
import { clusterStore } from "../../common/cluster-store";
|
||||||
import { workspaceStore } from "../../common/workspace-store";
|
import { workspaceStore } from "../../common/workspace-store";
|
||||||
|
import { EXTENSION_NAME_MATCH, EXTENSION_PUBLISHER_MATCH, LensProtocolRouter } from "../../common/protocol-handler";
|
||||||
|
|
||||||
export function bindProtocolAddRouteHandlers() {
|
export function bindProtocolAddRouteHandlers() {
|
||||||
LensProtocolRouterRenderer
|
LensProtocolRouterRenderer
|
||||||
@ -54,5 +55,15 @@ export function bindProtocolAddRouteHandlers() {
|
|||||||
})
|
})
|
||||||
.addInternalHandler("/extensions", () => {
|
.addInternalHandler("/extensions", () => {
|
||||||
navigate(extensionsURL());
|
navigate(extensionsURL());
|
||||||
|
})
|
||||||
|
.addInternalHandler(`/extensions/install${LensProtocolRouter.ExtensionUrlSchema}`, ({ pathname, search: { version } }) => {
|
||||||
|
const name = [
|
||||||
|
pathname[EXTENSION_PUBLISHER_MATCH],
|
||||||
|
pathname[EXTENSION_NAME_MATCH],
|
||||||
|
].filter(Boolean)
|
||||||
|
.join("/");
|
||||||
|
|
||||||
|
navigate(extensionsURL());
|
||||||
|
attemptInstallByInfo({ name, version, requireConfirmation: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user