1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00
lens/src/renderer/components/+extensions/extensions.tsx
Panu Horsmalahti 83a87978fa Disable Uninstall and Enable/Disable buttons while uninstalling. Add Notification for uninstall.
Signed-off-by: Panu Horsmalahti <phorsmalahti@mirantis.com>
2020-11-27 09:24:50 +02:00

444 lines
16 KiB
TypeScript

import { t, Trans } from "@lingui/macro";
import { remote, shell } from "electron";
import fse from "fs-extra";
import { map, omit } from "lodash";
import { computed, observable, ObservableMap, reaction } from "mobx";
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 { 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 { 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 { TooltipPosition } from "../tooltip";
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
}
interface ExtensionState {
displayName: string;
// Possible states the extension can be
state: "uninstalling";
}
@observer
export class Extensions extends React.Component {
private supportedFormats = [".tar", ".tgz"];
private installPathValidator: InputValidator = {
message: <Trans>Invalid URL or absolute path</Trans>,
validate(value: string) {
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
}
};
@observable
extensionState = observable.map<string, ExtensionState>();
@observable search = "";
@observable installPath = "";
/**
* Extensions that were removed from extensions but are still in "uninstalling" state
*/
@computed get removedUninstalling() {
return Array.from(this.extensionState.entries()).filter(([id, extension]) =>
extension.state === "uninstalling" && !this.extensions.find(extension => extension.id === id)
).map(([id, extension]) => ({ ...extension, id }));
}
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.extensions, (extensions) => {
const removedUninstalling = this.removedUninstalling;
removedUninstalling.forEach(({ displayName }) => {
Notifications.ok(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
);
});
removedUninstalling.forEach(({ id }) => {
this.extensionState.delete(id);
});
})
);
}
@computed get extensions() {
const searchText = this.search.toLowerCase();
return Array.from(extensionLoader.userExtensions.values()).filter(ext => {
const { name, description } = ext.manifest;
return [
name.toLowerCase().includes(searchText),
description?.toLowerCase().includes(searchText),
].some(value => value);
});
}
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: ${this.supportedFormats.join(", ")}), `),
buttonLabel: _i18n._(t`Use configuration`),
filters: [
{ name: "tarball", extensions: this.supportedFormats }
]
});
if (!canceled && filePaths.length) {
this.requestInstall(
filePaths.map(filePath => ({
fileName: path.basename(filePath),
filePath,
}))
);
}
};
installFromUrlOrPath = async () => {
const { installPath } = this;
if (!installPath) return;
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;
this.requestInstall({ fileName, data });
}
// otherwise installing from system path
else if (InputValidators.isPath.validate(installPath)) {
this.requestInstall({ fileName, filePath: installPath });
}
} catch (error) {
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(req => req.data);
await Promise.all(
requests
.filter(req => !req.data && req.filePath)
.map(req => {
return fse.readFile(req.filePath).then(data => {
req.data = data;
preloadedRequests.push(req);
}).catch(error => {
if (showError) {
Notifications.error(`Error while reading "${req.filePath}": ${String(error)}`);
}
});
})
);
return preloadedRequests as InstallRequestPreloaded[];
}
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*"
const rootFolder = tarFiles[0].split("/")[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<LensExtensionManifest>({
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());
requests.forEach(req => {
const tempFile = this.getExtensionPackageTemp(req.fileName);
fse.writeFileSync(tempFile, req.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(
<div className="flex column gaps">
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
<p>Reason: <em>{String(error)}</em></p>
</div>
);
}
}
})
);
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);
validatedRequests.forEach(install => {
const { name, version, description } = install.manifest;
const extensionFolder = this.getExtensionDestFolder(name);
const folderExists = fse.existsSync(extensionFolder);
if (!folderExists) {
// auto-install extension if not yet exists
this.unpackExtension(install);
} else {
// 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 extName = extensionDisplayName(name, version);
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
const extensionFolder = this.getExtensionDestFolder(name);
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 });
Notifications.ok(
<p>Extension <b>{extName}</b> successfully installed!</p>
);
} catch (error) {
Notifications.error(
<p>Installing extension <b>{extName}</b> has failed: <em>{error}</em></p>
);
} finally {
// clean up
fse.remove(unpackingTempFolder).catch(Function);
fse.unlink(tempFile).catch(Function);
}
}
async uninstallExtension(extension: InstalledExtension) {
const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version);
try {
this.extensionState.set(extension.id, {
state: "uninstalling",
displayName
});
await extensionDiscovery.uninstallExtension(extension.absolutePath);
} catch (error) {
Notifications.error(
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>
);
// Remove uninstall state on uninstall failure
this.extensionState.delete(extension.id);
}
}
renderExtensions() {
const { extensions, extensionsPath, search } = this;
if (!extensions.length) {
return (
<div className="no-extensions flex box gaps justify-center">
<Icon material="info"/>
<div>
{search && <p>No search results found</p>}
{!search && <p>There are no extensions in <code>{extensionsPath}</code></p>}
</div>
</div>
);
}
return extensions.map(ext => {
const { id, isEnabled, manifest } = ext;
const { name, description } = manifest;
const isUninstalling = this.extensionState.get(id)?.state === "uninstalling";
return (
<div key={id} className="extension flex gaps align-center">
<div className="box grow">
<div className="name">
Name: <code className="name">{name}</code>
</div>
<div className="description">
Description: <span className="text-secondary">{description}</span>
</div>
</div>
<div className="actions">
{!isEnabled && (
<Button plain active disabled={isUninstalling} onClick={() => ext.isEnabled = true}>Enable</Button>
)}
{isEnabled && (
<Button accent disabled={isUninstalling} onClick={() => ext.isEnabled = false}>Disable</Button>
)}
<Button plain active disabled={isUninstalling} waiting={isUninstalling}onClick={() => {
this.uninstallExtension(ext);
}}>Uninstall</Button>
</div>
</div>
);
});
}
render() {
const topHeader = <h2>Manage Lens Extensions</h2>;
const { installPath } = this;
return (
<DropFileInput onDropFiles={this.installOnDrop}>
<PageLayout showOnTop className="Extensions flex column gaps" header={topHeader} contentGaps={false}>
<h2>Lens Extensions</h2>
<div>
The features that Lens includes out-of-the-box are just the start.
Lens extensions let you add new features to your installation to support your workflow.
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
</div>
<div className="install-extension flex column gaps">
<SubTitle title={<Trans>Install Extension:</Trans>}/>
<div className="extension-input flex box gaps align-center">
<Input
className="box grow"
theme="round-black"
placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? this.installPathValidator : undefined}
value={installPath}
onChange={v => this.installPath = v}
onSubmit={this.installFromUrlOrPath}
iconLeft="link"
iconRight={
<Icon
interactive
material="folder"
onMouseDown={prevDefault(this.installFromSelectFileDialog)}
tooltip={<Trans>Browse</Trans>}
/>
}
/>
</div>
<Button
primary
label="Install"
disabled={!this.installPathValidator.validate(installPath)}
onClick={this.installFromUrlOrPath}
/>
<small className="hint">
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball-file to install</Trans>
</small>
</div>
<h2>Installed Extensions</h2>
<div className="installed-extensions flex column gaps">
<SearchInput
placeholder="Search extensions by name or description"
value={this.search}
onChange={(value) => this.search = value}
/>
{this.renderExtensions()}
</div>
</PageLayout>
</DropFileInput>
);
}
}