import { t, Trans } from "@lingui/macro"; import { remote, shell } from "electron"; import fse from "fs-extra"; import { computed, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; 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"; import { extensionDisplayName, LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension"; import logger from "../../../main/logger"; import { _i18n } from "../../i18n"; import { prevDefault } from "../../utils"; import { Button } from "../button"; import { ConfirmDialog } from "../confirm-dialog"; import { Icon } from "../icon"; import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; import { PageLayout } from "../layout/page-layout"; import { SubTitle } from "../layout/sub-title"; import { Notifications } from "../notifications"; import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; import { ExtensionStateStore } from "./extension-install.store"; import "./extensions.scss"; interface InstallRequest { fileName: string; filePath?: string; data?: Buffer; } interface InstallRequestPreloaded extends InstallRequest { data: Buffer; } interface InstallRequestValidated extends InstallRequestPreloaded { manifest: LensExtensionManifest; 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 return 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 try { await fse.remove(unpackingTempFolder); } catch (err) { // ignore error } 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 try { await fse.remove(unpackingTempFolder); await fse.unlink(tempFile); } catch (err) { // ignore error } } } /** * 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); } 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, "@hackweek"); Notifications.info(`Installing ${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 installPathValidator: InputValidator = { message: Invalid URL or absolute path, validate(value: string) { return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); } }; @observable search = ""; @observable installPath = ""; // True if the preliminary install steps have started, but unpackExtension has not started yet @observable startingInstall = false; /** * Start extension install using the current value of this.installPath */ @autobind() async installFromInstallPath() { if (this.installPath) { installFromUrlOrPath(this.installPath); } } componentDidMount() { disposeOnUnmount(this, reaction(() => this.extensions, () => { removedUninstalling(this.search.toLowerCase()).forEach(({ id, displayName }) => { Notifications.ok(

Extension {displayName} successfully uninstalled!

); ExtensionStateStore.getInstance().extensionState.delete(id); }); addedInstalling(this.search.toLowerCase()).forEach(({ id, displayName }) => { const extension = this.extensions.find(extension => extension.id === id); if (!extension) { throw new Error("Extension not found"); } Notifications.ok(

Extension {displayName} successfully installed!

); ExtensionStateStore.getInstance().extensionState.delete(id); this.installPath = ""; // Enable installed extensions by default. extension.isEnabled = true; }); }) ); } @computed get extensions() { return searchForExtensions(this.search.toLowerCase()); } renderExtensions() { const { extensions, search } = this; if (!extensions.length) { return (
{ search ?

No search results found

:

There are no installed extensions. See list of available extensions.

}
); } return extensions.map(extension => { const { id, isEnabled, manifest } = extension; const { name, description } = manifest; const isUninstalling = ExtensionStateStore.getInstance().extensionState.get(id)?.state === "uninstalling"; return (
{name}
{description}
); }); } /** * True if at least one extension is in installing state */ @computed get isInstalling() { return [...ExtensionStateStore.getInstance().extensionState.values()].some(extension => extension.state === "installing"); } render() { const topHeader =

Manage Lens Extensions

; const { installPath } = this; return (

Lens Extensions

Add new features and functionality via Lens Extensions. Check out documentation to learn more or see the list of available extensions.
Install Extension:}/>
this.installPath = value} onSubmit={this.installFromInstallPath} iconLeft="link" iconRight={ Browse} /> } />

Installed Extensions

this.search = value} /> {extensionDiscovery.isLoaded ? this.renderExtensions() :
}
); } }