/** * Copyright (c) 2021 OpenLens Authors * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import "./extensions.scss"; import { remote, shell } from "electron"; import fse from "fs-extra"; import _ from "lodash"; import { observable, reaction, when } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import os from "os"; import path from "path"; import React from "react"; import { SemVer } from "semver"; import URLParse from "url-parse"; import { Disposer, disposer, downloadFile, downloadJson, ExtendableDisposer, extractTar, listTarEntries, noop, readFileFromTar, } from "../../../common/utils"; import { ExtensionDiscovery, InstalledExtension, manifestFilename } from "../../../extensions/extension-discovery"; import { ExtensionLoader } from "../../../extensions/extension-loader"; import { extensionDisplayName, LensExtensionId, LensExtensionManifest, sanitizeExtensionName, } from "../../../extensions/lens-extension"; import logger from "../../../main/logger"; import { Button } from "../button"; import { ConfirmDialog } from "../confirm-dialog"; import { DropFileInput, InputValidators } from "../input"; import { PageLayout } from "../layout/page-layout"; import { Notifications } from "../notifications"; import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; import { Install } from "./install"; import { InstalledExtensions } from "./installed-extensions"; import { Notice } from "./notice"; 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 { fileName: string; dataP: Promise; } interface InstallRequestValidated { fileName: string; data: Buffer; id: LensExtensionId; manifest: LensExtensionManifest; tempFile: string; // temp system path to packed extension for unpacking } function setExtensionEnabled(id: LensExtensionId, isEnabled: boolean): void { const extension = ExtensionLoader.getInstance().getExtension(id); if (extension) { extension.isEnabled = isEnabled; } } function enableExtension(id: LensExtensionId) { setExtensionEnabled(id, true); } function disableExtension(id: LensExtensionId) { setExtensionEnabled(id, false); } async function uninstallExtension(extensionId: LensExtensionId): Promise { const loader = ExtensionLoader.getInstance(); const { manifest } = loader.getExtension(extensionId); const displayName = extensionDisplayName(manifest.name, manifest.version); try { logger.debug(`[EXTENSIONS]: trying to uninstall ${extensionId}`); ExtensionInstallationStateStore.setUninstalling(extensionId); await ExtensionDiscovery.getInstance().uninstallExtension(extensionId); // wait for the ExtensionLoader to actually uninstall the extension await when(() => !loader.userExtensions.has(extensionId)); Notifications.ok(

Extension {displayName} successfully uninstalled!

); return true; } catch (error) { const message = getMessageFromError(error); logger.info(`[EXTENSION-UNINSTALL]: uninstalling ${displayName} has failed: ${error}`, { error }); Notifications.error(

Uninstalling extension {displayName} has failed: {message}

); return false; } finally { // Remove uninstall state on uninstall failure ExtensionInstallationStateStore.clearUninstalling(extensionId); } } async function confirmUninstallExtension(extension: InstalledExtension): Promise { const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); const confirmed = await ConfirmDialog.confirm({ message:

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

, labelOk: "Yes", labelCancel: "No", }); if (confirmed) { await uninstallExtension(extension.id); } } function getExtensionDestFolder(name: string) { return path.join(ExtensionDiscovery.getInstance().localFolderPath, sanitizeExtensionName(name)); } function getExtensionPackageTemp(fileName = "") { return path.join(os.tmpdir(), "lens-extensions", fileName); } async function readFileNotify(filePath: string, showError = true): Promise { try { return await fse.readFile(filePath); } catch (error) { if (showError) { const message = getMessageFromError(error); logger.info(`[EXTENSION-INSTALL]: preloading ${filePath} has failed: ${message}`, { error }); Notifications.error(`Error while reading "${filePath}": ${message}`); } } return null; } async function 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.main && !manifest.renderer) { throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); } return manifest; } async function createTempFilesAndValidate({ fileName, dataP }: InstallRequest): Promise { // copy files to temp await fse.ensureDir(getExtensionPackageTemp()); // validate packages const tempFile = getExtensionPackageTemp(fileName); try { const data = await dataP; if (!data) { return null; } await fse.writeFile(tempFile, data); const manifest = await validatePackage(tempFile); const id = path.join(ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, "package.json"); return { fileName, data, manifest, tempFile, id, }; } catch (error) { const message = getMessageFromError(error); logger.info(`[EXTENSION-INSTALLATION]: installing ${fileName} has failed: ${message}`, { error }); Notifications.error(

Installing {fileName} has failed, skipping.

Reason: {message}

); } return null; } async function unpackExtension(request: InstallRequestValidated, disposeDownloading?: Disposer) { const { id, fileName, tempFile, manifest: { name, version } } = request; ExtensionInstallationStateStore.setInstalling(id); disposeDownloading?.(); const displayName = extensionDisplayName(name, version); 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(noop); 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 }); // wait for the loader has actually install it await when(() => ExtensionLoader.getInstance().userExtensions.has(id)); // Enable installed extensions by default. ExtensionLoader.getInstance().userExtensions.get(id).isEnabled = true; Notifications.ok(

Extension {displayName} successfully installed!

); } catch (error) { const message = getMessageFromError(error); logger.info(`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`, { error }); Notifications.error(

Installing extension {displayName} has failed: {message}

); } finally { // Remove install state once finished ExtensionInstallationStateStore.clearInstalling(id); // clean up fse.remove(unpackingTempFolder).catch(noop); fse.unlink(tempFile).catch(noop); } } export async function attemptInstallByInfo({ name, version, requireConfirmation = false }: ExtensionInfo) { 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); if (!json || json.error || typeof json.versions !== "object" || !json.versions) { const message = json?.error ? `: ${json.error}` : ""; Notifications.error(`Failed to get registry information for that extension${message}`); return disposer(); } if (version) { if (!json.versions[version]) { Notifications.error(

The {name} extension does not have a v{version}.

); 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:

Are you sure you want to install {name}@{version}?

, labelCancel: "Cancel", labelOk: "Install", }); if (!proceed) { 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 { const dispose = disposer(ExtensionInstallationStateStore.startPreInstall(), d); const validatedRequest = await createTempFilesAndValidate(request); if (!validatedRequest) { return dispose(); } const { name, version, description } = validatedRequest.manifest; const curState = ExtensionInstallationStateStore.getInstallationState(validatedRequest.id); if (curState !== ExtensionInstallationState.IDLE) { dispose(); return Notifications.error(
Extension Install Collision:

The {name} extension is currently {curState.toLowerCase()}.

Will not proceed with this current install request.

); } 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.getInstance().getExtension(validatedRequest.id); // otherwise confirmation required (re-install / update) const removeNotification = Notifications.info(

Install extension {name}@{version}?

Description: {description}

shell.openPath(extensionFolder)}> Warning: {name}@{oldVersion} will be removed before installation.
, { onClose: dispose, } ); } } async function attemptInstalls(filePaths: string[]): Promise { const promises: Promise[] = []; 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 { // fixme: improve error messages for non-tar-file URLs if (InputValidators.isUrl.validate(input)) { // 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(

Installation has failed: {message}

); } 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 } ] }); if (!canceled) { await attemptInstalls(filePaths); } } @observer export class Extensions extends React.Component { @observable installPath = ""; componentDidMount() { // TODO: change this after upgrading to mobx6 as that versions' reactions have this functionality let prevSize = ExtensionLoader.getInstance().userExtensions.size; disposeOnUnmount(this, [ reaction(() => ExtensionLoader.getInstance().userExtensions.size, curSize => { try { if (curSize > prevSize) { when(() => !ExtensionInstallationStateStore.anyInstalling) .then(() => this.installPath = ""); } } finally { prevSize = curSize; } }) ]); } render() { const extensions = Array.from(ExtensionLoader.getInstance().userExtensions.values()); return (

Extensions

this.installPath = value} installFromInput={() => installFromInput(this.installPath)} installFromSelectFileDialog={installFromSelectFileDialog} installPath={this.installPath} /> {extensions.length > 0 &&
}
); } }