From cf90044979c61a70e44be6db5aae796fd45dde65 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 8 Dec 2020 17:03:07 -0500 Subject: [PATCH] cleanup and added a router for installing extensions Signed-off-by: Sebastian Malton --- .eslintrc.js | 3 + package.json | 337 ++++---- src/extensions/extension-installer.ts | 14 - src/renderer/api/protocol-endpoints/index.ts | 12 + .../protocol-endpoints/install-extension.ts | 17 + src/renderer/bootstrap.tsx | 2 + .../+extensions/extension-install.store.ts | 1 + .../components/+extensions/extensions.tsx | 747 +++++++++--------- yarn.lock | 5 - 9 files changed, 573 insertions(+), 565 deletions(-) create mode 100644 src/renderer/api/protocol-endpoints/index.ts create mode 100644 src/renderer/api/protocol-endpoints/install-extension.ts diff --git a/.eslintrc.js b/.eslintrc.js index 3fd52c2465..823c5bdfee 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,6 +33,7 @@ module.exports = { "indent": ["error", 2, { "SwitchCase": 1, }], + "no-invalid-this": "error", "no-unused-vars": "off", "unused-imports/no-unused-imports": "error", "unused-imports/no-unused-vars": [ @@ -95,6 +96,7 @@ module.exports = { "indent": ["error", 2, { "SwitchCase": 1, }], + "no-invalid-this": "error", "quotes": ["error", "double", { "avoidEscape": true, "allowTemplateLiterals": true, @@ -160,6 +162,7 @@ module.exports = { "avoidEscape": true, "allowTemplateLiterals": true, }], + "no-invalid-this": "error", "semi": "off", "@typescript-eslint/semi": ["error"], "object-shorthand": "error", diff --git a/package.json b/package.json index 6df5f405c1..5ce56fd0e8 100644 --- a/package.json +++ b/package.json @@ -1,79 +1,52 @@ { "name": "kontena-lens", - "productName": "Lens", - "description": "Lens - The Kubernetes IDE", "version": "4.0.0-rc.3", - "main": "static/build/main.js", - "copyright": "© 2020, Mirantis, Inc.", + "description": "Lens - The Kubernetes IDE", "license": "MIT", "author": { "name": "Mirantis, Inc.", "email": "info@k8slens.dev" }, + "main": "static/build/main.js", "scripts": { + "build:linux": "yarn run compile && electron-builder --linux --config electron-builder.yml --dir -c.productName=Lens", + "build:mac": "yarn run compile && electron-builder --mac --config electron-builder.yml --dir -c.productName=Lens", + "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", + "build:win": "yarn run compile && electron-builder --win --config electron-builder.yml --dir -c.productName=Lens", + "compile": "env NODE_ENV=production concurrently yarn:compile:*", + "compile:extension-types": "yarn run webpack --config webpack.extensions.ts", + "compile:i18n": "yarn run lingui compile", + "compile:main": "yarn run webpack --config webpack.main.ts", + "compile:renderer": "yarn run webpack --config webpack.renderer.ts", "dev": "concurrently -k \"yarn run dev-run -C\" yarn:dev:*", "dev-build": "concurrently yarn:compile:*", "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", + "dev:extension-types": "yarn run compile:extension-types --watch", "dev:main": "yarn run compile:main --watch", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", - "dev:extension-types": "yarn run compile:extension-types --watch", - "compile": "env NODE_ENV=production concurrently yarn:compile:*", - "compile:main": "yarn run webpack --config webpack.main.ts", - "compile:renderer": "yarn run webpack --config webpack.renderer.ts", - "compile:i18n": "yarn run lingui compile", - "compile:extension-types": "yarn run webpack --config webpack.extensions.ts", - "npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts", - "build:linux": "yarn run compile && electron-builder --linux --config electron-builder.yml --dir -c.productName=Lens", - "build:mac": "yarn run compile && electron-builder --mac --config electron-builder.yml --dir -c.productName=Lens", - "build:win": "yarn run compile && electron-builder --win --config electron-builder.yml --dir -c.productName=Lens", - "test": "jest --env=jsdom src $@", - "integration": "jest --coverage integration $@", "dist": "yarn run compile && electron-builder --publish onTag", - "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", - "postinstall": "patch-package", - "i18n:extract": "yarn run lingui extract", + "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "download-bins": "concurrently yarn:download:*", - "download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:helm": "yarn run ts-node build/download_helm.ts", - "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", + "download:kubectl": "yarn run ts-node build/download_kubectl.ts", + "i18n:extract": "yarn run lingui extract", + "postinstall": "patch-package", + "integration": "jest --coverage integration $@", "lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 .", "lint:fix": "yarn run lint --fix", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", - "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", - "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts" + "npm:fix-package-version": "yarn run ts-node build/set_npm_version.ts", + "test": "jest --env=jsdom src $@", + "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts", + "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict" }, "config": { - "bundledKubectlVersion": "1.17.11", - "bundledHelmVersion": "3.3.4" - }, - "engines": { - "node": ">=12 <13" - }, - "lingui": { - "locales": [ - "en", - "ru", - "fi" - ], - "format": "po", - "sourceLocale": "en", - "fallbackLocale": "en", - "compileNamespace": "cjs", - "catalogs": [ - { - "path": "./locales/{locale}/messages", - "include": "./src/renderer" - } - ] + "bundledHelmVersion": "3.3.4", + "bundledKubectlVersion": "1.17.11" }, "jest": { "collectCoverage": false, - "verbose": true, - "testEnvironment": "node", - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", "^@lingui/macro$": "/__mocks__/@linguiMacro.ts" @@ -84,7 +57,15 @@ ], "setupFiles": [ "/src/jest.setup.ts" - ] + ], + "testEnvironment": "node", + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "verbose": true + }, + "engines": { + "node": ">=12 <13" }, "build": { "files": [ @@ -184,6 +165,7 @@ "confinement": "classic" } }, + "copyright": "© 2020, Mirantis, Inc.", "lens": { "extensions": [ "telemetry", @@ -194,104 +176,46 @@ "kube-object-event-status" ] }, + "lingui": { + "locales": [ + "en", + "ru", + "fi" + ], + "format": "po", + "sourceLocale": "en", + "fallbackLocale": "en", + "compileNamespace": "cjs", + "catalogs": [ + { + "path": "./locales/{locale}/messages", + "include": "./src/renderer" + } + ] + }, + "productName": "Lens", "dependencies": { "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", + "@lingui/loader": "^3.0.0-13", + "@lingui/macro": "^3.0.0-13", + "@lingui/react": "^3.0.0-13", + "@material-ui/core": "^4.10.1", + "@types/chart.js": "^2.9.21", + "@types/color": "^3.0.1", "@types/crypto-js": "^3.1.47", - "@types/electron-window-state": "^2.0.34", + "@types/dompurify": "^2.0.2", "@types/fs-extra": "^9.0.1", + "@types/hapi": "^18.0.3", "@types/http-proxy": "^1.17.4", "@types/js-yaml": "^3.12.4", "@types/jsdom": "^16.2.4", "@types/jsonpath": "^0.2.0", "@types/lodash": "^4.14.155", "@types/marked": "^0.7.4", - "@types/mock-fs": "^4.10.0", - "@types/node": "^12.12.45", - "@types/proper-lockfile": "^4.1.1", - "@types/react-beautiful-dnd": "^13.0.0", - "@types/tar": "^4.0.4", - "@types/url-parse": "^1.4.3", - "array-move": "^3.0.0", - "await-lock": "^2.1.0", - "chalk": "^4.1.0", - "chokidar": "^3.4.3", - "command-exists": "1.2.9", - "conf": "^7.0.1", - "crypto-js": "^4.0.0", - "electron-devtools-installer": "^3.1.1", - "electron-updater": "^4.3.1", - "electron-window-state": "^5.0.3", - "filenamify": "^4.1.0", - "fs-extra": "^9.0.1", - "handlebars": "^4.7.6", - "http-proxy": "^1.18.1", - "js-yaml": "^3.14.0", - "jsdom": "^16.4.0", - "jsonpath": "^1.0.2", - "lodash": "^4.17.15", - "mac-ca": "^1.0.4", - "marked": "^1.1.0", - "md5-file": "^5.0.0", - "mobx": "^5.15.7", - "mobx-observable-history": "^1.0.3", - "mock-fs": "^4.12.0", - "node-pty": "^0.9.0", - "npm": "^6.14.8", - "openid-client": "^3.15.2", - "path-to-regexp": "^6.1.0", - "proper-lockfile": "^4.1.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.8", - "semver": "^7.3.2", - "serializr": "^2.0.3", - "shell-env": "^3.0.0", - "spdy": "^4.0.2", - "tar": "^6.0.5", - "tcp-port-used": "^1.0.1", - "tempy": "^0.5.0", - "url-parse": "^1.4.7", - "uuid": "^8.1.0", - "win-ca": "^3.2.0", - "winston": "^3.2.1", - "winston-transport-browserconsole": "^1.0.5", - "ws": "^7.3.0" - }, - "devDependencies": { - "@babel/core": "^7.10.2", - "@babel/plugin-syntax-dynamic-import": "^7.2.0", - "@babel/plugin-transform-runtime": "^7.6.2", - "@babel/preset-env": "^7.10.2", - "@babel/preset-react": "^7.10.1", - "@babel/preset-typescript": "^7.10.1", - "@emeraldpay/hashicon-react": "^0.4.0", - "@lingui/babel-preset-react": "^2.9.1", - "@lingui/cli": "^3.0.0-13", - "@lingui/loader": "^3.0.0-13", - "@lingui/macro": "^3.0.0-13", - "@lingui/react": "^3.0.0-13", - "@material-ui/core": "^4.10.1", - "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", - "@testing-library/jest-dom": "^5.11.5", - "@testing-library/react": "^11.1.0", - "@types/chart.js": "^2.9.21", - "@types/circular-dependency-plugin": "^5.0.1", - "@types/color": "^3.0.1", - "@types/crypto-js": "^3.1.47", - "@types/dompurify": "^2.0.2", "@types/electron-devtools-installer": "^2.2.0", "@types/electron-window-state": "^2.0.34", - "@types/fs-extra": "^9.0.1", - "@types/hapi": "^18.0.3", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/html-webpack-plugin": "^3.2.3", - "@types/http-proxy": "^1.17.4", - "@types/jest": "^25.2.3", - "@types/js-yaml": "^3.12.4", - "@types/jsonpath": "^0.2.0", - "@types/lodash": "^4.14.155", - "@types/marked": "^0.7.4", "@types/material-ui": "^0.21.7", "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^0.9.1", @@ -312,58 +236,54 @@ "@types/sharp": "^0.26.0", "@types/shelljs": "^0.8.8", "@types/spdy": "^3.4.4", + "@types/tar": "^4.0.4", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", - "@types/terser-webpack-plugin": "^3.0.0", - "@types/universal-analytics": "^0.4.4", + "@types/url-parse": "^1.4.3", "@types/uuid": "^8.0.0", - "@types/webdriverio": "^4.13.0", - "@types/webpack": "^4.41.17", - "@types/webpack-dev-server": "^3.11.1", - "@types/webpack-env": "^1.15.2", - "@types/webpack-node-externals": "^1.7.1", - "@typescript-eslint/eslint-plugin": "^4.0.0", - "@typescript-eslint/parser": "^4.0.0", "ace-builds": "^1.4.11", "ansi_up": "^4.0.4", - "babel-core": "^7.0.0-beta.3", - "babel-loader": "^8.1.0", - "babel-plugin-macros": "^2.8.0", - "babel-runtime": "^6.26.0", + "array-move": "^3.0.0", + "await-lock": "^2.1.0", + "chalk": "^4.1.0", "chart.js": "^2.9.3", - "circular-dependency-plugin": "^5.2.0", + "chokidar": "^3.4.3", "color": "^3.1.2", + "command-exists": "1.2.9", "concurrently": "^5.2.0", + "conf": "^7.0.1", + "crypto-js": "^4.0.0", "css-element-queries": "^1.2.3", "css-loader": "^3.5.3", "dompurify": "^2.0.11", - "electron": "^9.3.5", - "electron-builder": "^22.7.0", - "electron-notarize": "^0.3.0", - "eslint": "^7.7.0", - "eslint-plugin-react": "^7.21.5", - "eslint-plugin-unused-imports": "^1.0.1", + "electron-devtools-installer": "^3.1.1", + "electron-updater": "^4.3.1", + "electron-window-state": "^5.0.3", "file-loader": "^6.0.0", + "filenamify": "^4.1.0", "flex.box": "^3.4.4", - "fork-ts-checker-webpack-plugin": "^5.0.0", - "hoist-non-react-statics": "^3.3.2", - "html-webpack-plugin": "^4.3.0", - "identity-obj-proxy": "^3.0.0", - "include-media": "^1.4.9", - "jest": "^26.0.1", - "jest-fetch-mock": "^3.0.3", - "jest-mock-extended": "^1.0.10", + "fs-extra": "^9.0.1", + "handlebars": "^4.7.6", + "http-proxy": "^1.18.1", + "js-yaml": "^3.14.0", + "jsdom": "^16.4.0", + "jsonpath": "^1.0.2", + "lodash": "^4.17.15", + "mac-ca": "^1.0.4", "make-plural": "^6.2.1", + "marked": "^1.1.0", + "md5-file": "^5.0.0", "mini-css-extract-plugin": "^0.9.0", + "mobx": "^5.15.7", + "mobx-observable-history": "^1.0.3", "mobx-react": "^6.2.2", + "mock-fs": "^4.12.0", "moment": "^2.26.0", - "node-loader": "^0.6.0", - "node-sass": "^4.14.1", - "nodemon": "^2.0.4", - "patch-package": "^6.2.2", - "postinstall-postinstall": "^2.1.0", - "prettier": "^2.2.0", - "progress-bar-webpack-plugin": "^2.1.0", + "node-pty": "^0.9.0", + "npm": "^6.14.8", + "openid-client": "^3.15.2", + "path-to-regexp": "^6.1.0", + "proper-lockfile": "^4.1.1", "raw-loader": "^4.0.1", "react": "^16.14.0", "react-beautiful-dnd": "^13.0.0", @@ -373,6 +293,77 @@ "react-router-dom": "^5.2.0", "react-select": "^3.1.0", "react-window": "^1.8.5", + "request": "^2.88.2", + "request-promise-native": "^1.0.8", + "semver": "^7.3.2", + "serializr": "^2.0.3", + "shell-env": "^3.0.0", + "spdy": "^4.0.2", + "tar": "^6.0.5", + "tcp-port-used": "^1.0.1", + "tempy": "^0.5.0", + "url-loader": "^4.1.0", + "url-parse": "^1.4.7", + "uuid": "^8.1.0", + "what-input": "^5.2.10", + "win-ca": "^3.2.0", + "winston": "^3.2.1", + "winston-transport-browserconsole": "^1.0.5", + "ws": "^7.3.0", + "xterm": "^4.6.0", + "xterm-addon-fit": "^0.4.0" + }, + "devDependencies": { + "@babel/core": "^7.10.2", + "@babel/plugin-syntax-dynamic-import": "^7.2.0", + "@babel/plugin-transform-runtime": "^7.6.2", + "@babel/preset-env": "^7.10.2", + "@babel/preset-react": "^7.10.1", + "@babel/preset-typescript": "^7.10.1", + "@emeraldpay/hashicon-react": "^0.4.0", + "@lingui/babel-preset-react": "^2.9.1", + "@lingui/cli": "^3.0.0-13", + "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", + "@testing-library/jest-dom": "^5.11.5", + "@testing-library/react": "^11.1.0", + "@types/circular-dependency-plugin": "^5.0.1", + "@types/hoist-non-react-statics": "^3.3.1", + "@types/html-webpack-plugin": "^3.2.3", + "@types/jest": "^25.2.3", + "@types/terser-webpack-plugin": "^3.0.0", + "@types/universal-analytics": "^0.4.4", + "@types/webdriverio": "^4.13.0", + "@types/webpack": "^4.41.17", + "@types/webpack-dev-server": "^3.11.1", + "@types/webpack-env": "^1.15.2", + "@types/webpack-node-externals": "^1.7.1", + "@typescript-eslint/eslint-plugin": "^4.0.0", + "@typescript-eslint/parser": "^4.0.0", + "babel-core": "^7.0.0-beta.3", + "babel-loader": "^8.1.0", + "babel-plugin-macros": "^2.8.0", + "babel-runtime": "^6.26.0", + "circular-dependency-plugin": "^5.2.0", + "electron": "^9.3.5", + "electron-builder": "^22.7.0", + "electron-notarize": "^0.3.0", + "eslint": "^7.7.0", + "eslint-plugin-react": "^7.21.5", + "eslint-plugin-unused-imports": "^1.0.1", + "fork-ts-checker-webpack-plugin": "^5.0.0", + "hoist-non-react-statics": "^3.3.2", + "html-webpack-plugin": "^4.3.0", + "identity-obj-proxy": "^3.0.0", + "include-media": "^1.4.9", + "jest": "^26.0.1", + "jest-fetch-mock": "^3.0.3", + "jest-mock-extended": "^1.0.10", + "node-loader": "^0.6.0", + "node-sass": "^4.14.1", + "nodemon": "^2.0.4", + "patch-package": "^6.2.2", + "postinstall-postinstall": "^2.1.0", + "progress-bar-webpack-plugin": "^2.1.0", "sass-loader": "^8.0.2", "sharp": "^0.26.1", "spectron": "11.0.0", @@ -386,13 +377,9 @@ "typedoc-plugin-markdown": "^2.4.0", "typeface-roboto": "^0.0.75", "typescript": "^4.0.2", - "url-loader": "^4.1.0", "webpack": "^4.44.2", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.11.0", - "webpack-node-externals": "^1.7.2", - "what-input": "^5.2.10", - "xterm": "^4.6.0", - "xterm-addon-fit": "^0.4.0" + "webpack-node-externals": "^1.7.2" } } diff --git a/src/extensions/extension-installer.ts b/src/extensions/extension-installer.ts index b4cbf9e2bd..2143c62287 100644 --- a/src/extensions/extension-installer.ts +++ b/src/extensions/extension-installer.ts @@ -2,9 +2,7 @@ import AwaitLock from "await-lock"; import child_process from "child_process"; import fs from "fs-extra"; import path from "path"; -import { autobind } from "../common/utils"; import logger from "../main/logger"; -import { LensProtocolRouter } from "../main/protocol-handler"; import { extensionPackagesRoot } from "./extension-loader"; const logModule = "[EXTENSION-INSTALLER]"; @@ -22,13 +20,6 @@ export type PackageJson = { * Installs dependencies for extensions */ export class ExtensionInstaller { - - constructor() { - const lpr = LensProtocolRouter.getInstance(); - - lpr.on("/install-extension", this.protocolHandlerInstall); - } - private installLock = new AwaitLock(); get extensionPackagesRoot() { @@ -39,11 +30,6 @@ export class ExtensionInstaller { return __non_webpack_require__.resolve("npm/bin/npm-cli"); } - @autobind() - protocolHandlerInstall(): void { - console.log("Installing"); - } - installDependencies(): Promise { return new Promise((resolve, reject) => { logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`); diff --git a/src/renderer/api/protocol-endpoints/index.ts b/src/renderer/api/protocol-endpoints/index.ts new file mode 100644 index 0000000000..10efce0b52 --- /dev/null +++ b/src/renderer/api/protocol-endpoints/index.ts @@ -0,0 +1,12 @@ +import { LensProtocolRouter } from "../../../main/protocol-handler"; +import { installExtension } from "./install-extension"; + +export function registerHandlers() { + const lpr: LensProtocolRouter = LensProtocolRouter.getInstance(); + + lpr.on("/install-extension", installExtension); +} + +export default { + registerHandlers +}; diff --git a/src/renderer/api/protocol-endpoints/install-extension.ts b/src/renderer/api/protocol-endpoints/install-extension.ts new file mode 100644 index 0000000000..a2b29f134f --- /dev/null +++ b/src/renderer/api/protocol-endpoints/install-extension.ts @@ -0,0 +1,17 @@ +import logger from "../../../main/logger"; +import { RouteParams } from "../../../main/protocol-handler"; +import { installFromNpm } from "../../components/+extensions"; + +export async function installExtension(params: RouteParams): Promise { + const { extId } = params.search; + + if (!extId) { + return void logger.info("installExtension handler: missing 'extId' from search params"); + } + + try { + await installFromNpm(extId); + } catch (error) { + logger.error("[PH - Install Extension]: failed to install from NPM", error); + } +} diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f15625a881..fa80cdc035 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -17,6 +17,7 @@ import { App } from "./components/app"; import { i18nStore } from "./i18n"; import { LensApp } from "./lens-app"; import { themeStore } from "./theme.store"; +import protocolEndpoints from "./api/protocol-endpoints"; type AppComponent = React.ComponentType & { init?(): Promise; @@ -36,6 +37,7 @@ export async function bootstrap(App: AppComponent) { extensionLoader.init(); extensionDiscovery.init(); + protocolEndpoints.registerHandlers(); // preload common stores await Promise.all([ diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts index c4a8ed6690..30867573ba 100644 --- a/src/renderer/components/+extensions/extension-install.store.ts +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -10,4 +10,5 @@ interface ExtensionState { @autobind() export class ExtensionStateStore extends Singleton { extensionState = observable.map(); + @observable startingInstall = false; } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index b0c7e43702..84b9f5104f 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -6,7 +6,7 @@ import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; -import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; +import { autobind, downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; import { docsUrl } from "../../../common/vars"; import { extensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; import { extensionLoader } from "../../../extensions/extension-loader"; @@ -41,10 +41,349 @@ interface InstallRequestValidated extends InstallRequestPreloaded { tempFile: string; // temp system path to packed extension for unpacking } +function searchForExtensions(searchText = "") { + return Array.from(extensionLoader.userExtensions.values()) + .filter(({ manifest: { name, description } }) => ( + name.toLowerCase().includes(searchText) + || description?.toLowerCase().includes(searchText) + )); +} + +async function validatePackage(filePath: string): Promise < LensExtensionManifest > { + const tarFiles = await listTarEntries(filePath); + + // tarball from npm contains single root folder "package/*" + const firstFile = tarFiles[0]; + + if(!firstFile) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const rootFolder = path.normalize(firstFile).split(path.sep)[0]; + const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); + const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; + + if(!tarFiles.includes(manifestLocation)) { + throw new Error(`invalid extension bundle, ${manifestFilename} not found`); + } + + const manifest = await readFileFromTar({ + tarPath: filePath, + filePath: manifestLocation, + parseJson: true, + }); + + if (!manifest.lens && !manifest.renderer) { + throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); + } + + return manifest; +} + +async function 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) { + if (showError) { + Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); + } + } + }) + ); + + return preloadedRequests as InstallRequestPreloaded[]; +} + +async function createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { + const validatedRequests: InstallRequestValidated[] = []; + + // copy files to temp + await fse.ensureDir(getExtensionPackageTemp()); + + for (const request of requests) { + const tempFile = getExtensionPackageTemp(request.fileName); + + await fse.writeFile(tempFile, request.data); + } + + // validate packages + await Promise.all( + requests.map(async req => { + const tempFile = getExtensionPackageTemp(req.fileName); + + try { + const manifest = await validatePackage(tempFile); + + validatedRequests.push({ + ...req, + manifest, + tempFile, + }); + } catch (error) { + fse.unlink(tempFile).catch(() => null); // remove invalid temp package + + if (showErrors) { + Notifications.error( +
+

Installing {req.fileName} has failed, skipping.

+

Reason: {String(error)}

+
+ ); + } + } + }) + ); + + return validatedRequests; +} + +async function requestInstall(init: InstallRequest | InstallRequest[]) { + const requests = Array.isArray(init) ? init : [init]; + const preloadedRequests = await preloadExtensions(requests); + const validatedRequests = await createTempFilesAndValidate(preloadedRequests); + + // If there are no requests for installing, reset startingInstall state + if (validatedRequests.length === 0) { + ExtensionStateStore.getInstance().startingInstall = false; + } + + for (const install of validatedRequests) { + const { name, version, description } = install.manifest; + const extensionFolder = getExtensionDestFolder(name); + const folderExists = await fse.pathExists(extensionFolder); + + if (!folderExists) { + // auto-install extension if not yet exists + unpackExtension(install); + } else { + // If we show the confirmation dialog, we stop the install spinner until user clicks ok + // and the install continues + ExtensionStateStore.getInstance().startingInstall = false; + + // otherwise confirmation required (re-install / update) + const removeNotification = Notifications.info( +
+
+

Install extension {name}@{version}?

+

Description: {description}

+
shell.openPath(extensionFolder)}> + Warning: {extensionFolder} will be removed before installation. +
+
+
+ ); + } + } +} + +async function unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { + const displayName = extensionDisplayName(name, version); + const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json"); + + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + ExtensionStateStore.getInstance().extensionState.set(extensionId, { + state: "installing", + displayName + }); + ExtensionStateStore.getInstance().startingInstall = false; + + const extensionFolder = getExtensionDestFolder(name); + const unpackingTempFolder = path.join(path.dirname(tempFile), `${path.basename(tempFile)}-unpacked`); + + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + try { + // extract to temp folder first + await fse.remove(unpackingTempFolder).catch(Function); + await fse.ensureDir(unpackingTempFolder); + await extractTar(tempFile, { cwd: unpackingTempFolder }); + + // move contents to extensions folder + const unpackedFiles = await fse.readdir(unpackingTempFolder); + let unpackedRootFolder = unpackingTempFolder; + + if (unpackedFiles.length === 1) { + // check if %extension.tgz was packed with single top folder, + // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball + unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); + } + + await fse.ensureDir(extensionFolder); + await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); + } catch (error) { + Notifications.error( +

Installing extension {displayName} has failed: {error}

+ ); + + // Remove install state on install failure + if (ExtensionStateStore.getInstance().extensionState.get(extensionId)?.state === "installing") { + ExtensionStateStore.getInstance().extensionState.delete(extensionId); + } + } finally { + // clean up + fse.remove(unpackingTempFolder).catch(Function); + fse.unlink(tempFile).catch(Function); + } +} + +/** + * Extensions that were removed from extensions but are still in "uninstalling" state + */ +function removedUninstalling(searchText = "") { + const extensions = searchForExtensions(searchText); + + return Array.from(ExtensionStateStore.getInstance().extensionState.entries()) + .filter(([id, extension]) => + extension.state === "uninstalling" + && !extensions.find(extension => extension.id === id) + ) + .map(([id, extension]) => ({ ...extension, id })); +} + + +/** + * Extensions that were added to extensions but are still in "installing" state + */ +function addedInstalling(searchText = "") { + const extensions = searchForExtensions(searchText); + + return Array.from(ExtensionStateStore.getInstance().extensionState.entries()) + .filter(([id, extension]) => + extension.state === "installing" + && extensions.find(extension => extension.id === id) + ) + .map(([id, extension]) => ({ ...extension, id })); +} + +function getExtensionPackageTemp(fileName = "") { + return path.join(os.tmpdir(), "lens-extensions", fileName); +} + +function getExtensionDestFolder(name: string) { + return path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); +} + +async function uninstallExtension(extension: InstalledExtension) { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + + try { + ExtensionStateStore.getInstance().extensionState.set(extension.id, { + state: "uninstalling", + displayName + }); + + await extensionDiscovery.uninstallExtension(extension.absolutePath); + } catch (error) { + Notifications.error( +

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

+ ); + + // Remove uninstall state on uninstall failure + if (ExtensionStateStore.getInstance().extensionState.get(extension.id)?.state === "uninstalling") { + ExtensionStateStore.getInstance().extensionState.delete(extension.id); + } + } +} + +function confirmUninstallExtension(extension: InstalledExtension) { + const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); + + ConfirmDialog.open({ + message:

Are you sure you want to uninstall extension {displayName}?

, + labelOk: Yes, + labelCancel: No, + ok: () => uninstallExtension(extension) + }); +} + +function installOnDrop(files: File[]) { + logger.info("Install from D&D"); + + return requestInstall( + files.map(file => ({ + fileName: path.basename(file.path), + filePath: file.path, + })) + ); +} + +async function installFromSelectFileDialog() { + const { dialog, BrowserWindow, app } = remote; + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { + defaultPath: app.getPath("downloads"), + properties: ["openFile", "multiSelections"], + message: _i18n._(t`Select extensions to install (formats: ${SupportedFormats.join(", ")}), `), + buttonLabel: _i18n._(t`Use configuration`), + filters: [ + { name: "tarball", extensions: [...SupportedFormats] } + ] + }); + + if (!canceled && filePaths.length) { + requestInstall( + filePaths.map(filePath => ({ + fileName: path.basename(filePath), + filePath, + })) + ); + } +} + +/** + * Start extension install using a package name, which is resolved to a tarball url using the npm registry. + * @param packageName e.g. "@publisher/extension-name" + */ +export async function installFromNpm(packageName: string) { + const tarballUrl = await extensionLoader.getNpmPackageTarballUrl(packageName); + + return installFromUrlOrPath(tarballUrl); +} + +async function installFromUrlOrPath(installPath: string) { + ExtensionStateStore.getInstance().startingInstall = true; + const fileName = path.basename(installPath); + + try { + // install via url + // 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 requestInstall({ fileName, data }); + } + // otherwise installing from system path + else if (InputValidators.isPath.validate(installPath)) { + await requestInstall({ fileName, filePath: installPath }); + } + } catch (error) { + ExtensionStateStore.getInstance().startingInstall = false; + Notifications.error( +

Installation has failed: {String(error)}

+ ); + } +} + +const SupportedFormats = Object.freeze(["tar", "tgz"]); + @observer export class Extensions extends React.Component { - private static supportedFormats = ["tar", "tgz"]; - private static installPathValidator: InputValidator = { message: Invalid URL or absolute path, validate(value: string) { @@ -52,10 +391,6 @@ export class Extensions extends React.Component { } }; - get extensionStateStore() { - return ExtensionStateStore.getInstance(); - } - @observable search = ""; @observable installPath = ""; @@ -63,40 +398,26 @@ export class Extensions extends React.Component { @observable startingInstall = false; /** - * Extensions that were removed from extensions but are still in "uninstalling" state + * Start extension install using the current value of this.installPath */ - @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 })); + @autobind() + async installFromInstallPath() { + if (this.installPath) { + installFromUrlOrPath(this.installPath); + } } componentDidMount() { disposeOnUnmount(this, reaction(() => this.extensions, () => { - this.removedUninstalling.forEach(({ id, displayName }) => { + removedUninstalling(this.search.toLowerCase()).forEach(({ id, displayName }) => { Notifications.ok(

Extension {displayName} successfully uninstalled!

); - this.extensionStateStore.extensionState.delete(id); + ExtensionStateStore.getInstance().extensionState.delete(id); }); - this.addedInstalling.forEach(({ id, displayName }) => { + addedInstalling(this.search.toLowerCase()).forEach(({ id, displayName }) => { const extension = this.extensions.find(extension => extension.id === id); if (!extension) { @@ -106,7 +427,7 @@ export class Extensions extends React.Component { Notifications.ok(

Extension {displayName} successfully installed!

); - this.extensionStateStore.extensionState.delete(id); + ExtensionStateStore.getInstance().extensionState.delete(id); this.installPath = ""; // Enable installed extensions by default. @@ -117,328 +438,7 @@ export class Extensions extends React.Component { } @computed get extensions() { - 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); - } - - getExtensionDestFolder(name: string) { - 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: _i18n._(t`Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `), - buttonLabel: _i18n._(t`Use configuration`), - filters: [ - { name: "tarball", extensions: Extensions.supportedFormats } - ] - }); - - if (!canceled && filePaths.length) { - this.requestInstall( - filePaths.map(filePath => ({ - fileName: path.basename(filePath), - filePath, - })) - ); - } - }; - - /** - * Start extension install using a package name, which is resolved to a tarball url using the npm registry. - * @param packageName e.g. "@publisher/extension-name" - */ - async installFromNpm(packageName: string) { - const tarballUrl = await extensionLoader.getNpmPackageTarballUrl(packageName); - - return this.installFromUrlOrPath(tarballUrl); - } - - /** - * Start extension install using the current value of this.installPath - */ - installFromInstallPath = async () => { - if (this.installPath) { - this.installFromUrlOrPath(this.installPath); - } - }; - - async installFromUrlOrPath(installPath: string) { - this.startingInstall = true; - const fileName = path.basename(installPath); - - try { - // install via url - // 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( -

Installation has failed: {String(error)}

- ); - } - } - - 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) { - if (showError) { - Notifications.error(`Error while reading "${request.filePath}": ${String(error)}`); - } - } - }) - ); - - return preloadedRequests as InstallRequestPreloaded[]; - } - - async validatePackage(filePath: string): Promise { - const tarFiles = await listTarEntries(filePath); - - // tarball from npm contains single root folder "package/*" - const firstFile = tarFiles[0]; - - if (!firstFile) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const rootFolder = path.normalize(firstFile).split(path.sep)[0]; - const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder)); - const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename; - - if (!tarFiles.includes(manifestLocation)) { - throw new Error(`invalid extension bundle, ${manifestFilename} not found`); - } - - const manifest = await readFileFromTar({ - tarPath: filePath, - filePath: manifestLocation, - parseJson: true, - }); - - if (!manifest.lens && !manifest.renderer) { - throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); - } - - return manifest; - } - - async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) { - const validatedRequests: InstallRequestValidated[] = []; - - // copy files to temp - await fse.ensureDir(this.getExtensionPackageTemp()); - - for (const request of requests) { - const tempFile = this.getExtensionPackageTemp(request.fileName); - - await fse.writeFile(tempFile, request.data); - } - - // validate packages - await Promise.all( - requests.map(async req => { - const tempFile = this.getExtensionPackageTemp(req.fileName); - - try { - const manifest = await this.validatePackage(tempFile); - - validatedRequests.push({ - ...req, - manifest, - tempFile, - }); - } catch (error) { - fse.unlink(tempFile).catch(() => null); // remove invalid temp package - - if (showErrors) { - Notifications.error( -
-

Installing {req.fileName} has failed, skipping.

-

Reason: {String(error)}

-
- ); - } - } - }) - ); - - return validatedRequests; - } - - async requestInstall(init: InstallRequest | InstallRequest[]) { - const requests = Array.isArray(init) ? init : [init]; - const preloadedRequests = await this.preloadExtensions(requests); - const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); - - // If there are no requests for installing, reset startingInstall state - if (validatedRequests.length === 0) { - 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( -
-
-

Install extension {name}@{version}?

-

Description: {description}

-
shell.openPath(extensionFolder)}> - Warning: {extensionFolder} will be removed before installation. -
-
-
- ); - } - } - } - - async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) { - const displayName = extensionDisplayName(name, version); - const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json"); - - 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`); - - logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); - - try { - // extract to temp folder first - await fse.remove(unpackingTempFolder).catch(Function); - await fse.ensureDir(unpackingTempFolder); - await extractTar(tempFile, { cwd: unpackingTempFolder }); - - // move contents to extensions folder - const unpackedFiles = await fse.readdir(unpackingTempFolder); - let unpackedRootFolder = unpackingTempFolder; - - if (unpackedFiles.length === 1) { - // check if %extension.tgz was packed with single top folder, - // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball - unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); - } - - await fse.ensureDir(extensionFolder); - await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); - } catch (error) { - Notifications.error( -

Installing extension {displayName} has failed: {error}

- ); - - // Remove install state on install failure - if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") { - this.extensionStateStore.extensionState.delete(extensionId); - } - } finally { - // clean up - fse.remove(unpackingTempFolder).catch(Function); - fse.unlink(tempFile).catch(Function); - } - } - - confirmUninstallExtension = (extension: InstalledExtension) => { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - ConfirmDialog.open({ - message:

Are you sure you want to uninstall extension {displayName}?

, - labelOk: Yes, - labelCancel: No, - ok: () => this.uninstallExtension(extension) - }); - }; - - async uninstallExtension(extension: InstalledExtension) { - const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); - - try { - this.extensionStateStore.extensionState.set(extension.id, { - state: "uninstalling", - displayName - }); - - await extensionDiscovery.uninstallExtension(extension); - } catch (error) { - Notifications.error( -

Uninstalling extension {displayName} has failed: {error?.message ?? ""}

- ); - - // Remove uninstall state on uninstall failure - if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") { - this.extensionStateStore.extensionState.delete(extension.id); - } - } + return searchForExtensions(this.search.toLowerCase()); } renderExtensions() { @@ -462,7 +462,7 @@ export class Extensions extends React.Component { return extensions.map(extension => { const { id, isEnabled, manifest } = extension; const { name, description } = manifest; - const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling"; + const isUninstalling = ExtensionStateStore.getInstance().extensionState.get(id)?.state === "uninstalling"; return (
@@ -475,19 +475,24 @@ export class Extensions extends React.Component {
- {!isEnabled && ( - - )} - {isEnabled && ( - - )} - + +
); @@ -498,7 +503,7 @@ export class Extensions extends React.Component { * True if at least one extension is in installing state */ @computed get isInstalling() { - return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing"); + return [...ExtensionStateStore.getInstance().extensionState.values()].some(extension => extension.state === "installing"); } render() { @@ -506,7 +511,7 @@ export class Extensions extends React.Component { const { installPath } = this; return ( - +

Lens Extensions

@@ -521,7 +526,7 @@ export class Extensions extends React.Component { className="box grow" theme="round-black" disabled={this.isInstalling} - placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`} + placeholder={`Path or URL to an extension package (${SupportedFormats.join(", ")})`} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} validators={installPath ? Extensions.installPathValidator : undefined} value={installPath} @@ -532,7 +537,7 @@ export class Extensions extends React.Component { Browse} /> } diff --git a/yarn.lock b/yarn.lock index 7b8202941b..8a18542583 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11763,11 +11763,6 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.2.0.tgz#8a03c7777883b29b37fb2c4348c66a78e980418b" - integrity sha512-yYerpkvseM4iKD/BXLYUkQV5aKt4tQPqaGW6EsZjzyu0r7sVZZNPJW4Y8MyKmicp6t42XUPcBVA+H6sB3gqndw== - pretty-error@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"