mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Allow to install packed extensions from URL or local file (#1456)
* Option to install an extension from filesystem/url #1227 -- part 1 (UI) Signed-off-by: Roman <ixrock@gmail.com> * DropFileInput: common component to handle droped files (replaced also in add-cluster-page) Signed-off-by: Roman <ixrock@gmail.com> * fix: install via url-string on input.submit Signed-off-by: Roman <ixrock@gmail.com> * ui tweaks & minor fixes Signed-off-by: Roman <ixrock@gmail.com> * more ui/ux tweaks & fixes Signed-off-by: Roman <ixrock@gmail.com> * layout fixes Signed-off-by: Roman <ixrock@gmail.com> * component renaming: `copy-to-click` => `copy-to-clipboard` => `clipboard` Signed-off-by: Roman <ixrock@gmail.com> * reworks -- part 1 Signed-off-by: Roman <ixrock@gmail.com> * fix downloading file, added common/utils/downloadFile Signed-off-by: Roman <ixrock@gmail.com> * confirm before install, unpack tar first steps Signed-off-by: Roman <ixrock@gmail.com> * installation flow, extracting .tgz Signed-off-by: Roman <ixrock@gmail.com> * clean up, fix lint issues Signed-off-by: Roman <ixrock@gmail.com> * update .azure-pipelines.yml Signed-off-by: Roman <ixrock@gmail.com> * fixes & refactoring Signed-off-by: Roman <ixrock@gmail.com> * fix lint harder :/ Signed-off-by: Roman <ixrock@gmail.com> * fix validation Signed-off-by: Roman <ixrock@gmail.com> * fix validation harder Signed-off-by: Roman <ixrock@gmail.com> * responding to comments, fixed package validation Signed-off-by: Roman <ixrock@gmail.com> * common/utils/tar.ts: reject with Error-type Signed-off-by: Roman <ixrock@gmail.com> * fix: unit-tests Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
1 parent
2da598b66e
commit
77ae31550a
36 files changed
+799
-193
No files matched your search
@@ -1,22 +1,54 @@
|
||||
.Extensions {
|
||||
$spacing: $padding * 2;
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
|
||||
.extension-list {
|
||||
.extensions-list {
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $padding * 2;
|
||||
padding: $padding $spacing;
|
||||
background: $colorVague;
|
||||
border-radius: $radius;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: $padding * 2;
|
||||
margin-top: $spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-info {
|
||||
--flex-gap: #{$spacing};
|
||||
|
||||
> .flex.gaps {
|
||||
--flex-gap: #{$padding};
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-path {
|
||||
word-break: break-all;
|
||||
|
||||
&:hover code {
|
||||
color: $textColorSecondary;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.Clipboard {
|
||||
display: inline;
|
||||
vertical-align: baseline;
|
||||
font-size: $font-size-small;
|
||||
|
||||
&:hover {
|
||||
color: $textColorSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
--spacing: #{$padding};
|
||||
}
|
||||
|
||||
.SubTitle {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
@@ -27,15 +59,28 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
margin-top: $margin / 2;
|
||||
margin-bottom: $margin * 2;
|
||||
max-width: none;
|
||||
.InstallingExtensionNotification {
|
||||
.remove-folder-warning {
|
||||
font-size: $font-size-small;
|
||||
font-style: italic;
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
|
||||
> label {
|
||||
padding: $padding $padding * 2;
|
||||
border-radius: $radius;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
background-color: unset;
|
||||
border: 1px solid currentColor;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import "./extensions.scss";
|
||||
import { shell } from "electron";
|
||||
import { remote, shell } from "electron";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fse from "fs-extra";
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -7,15 +10,39 @@ import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { Input } from "../input";
|
||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import { Clipboard } from "../clipboard";
|
||||
import logger from "../../../main/logger";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDiscovery } from "../../../extensions/extension-discovery";
|
||||
import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { Notifications } from "../notifications";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private supportedFormats = [".tar", ".tgz"];
|
||||
@observable search = "";
|
||||
@observable downloadUrl = "";
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
@@ -32,27 +59,265 @@ export class Extensions extends React.Component {
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
addExtensions = () => {
|
||||
const { downloadUrl } = this;
|
||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
||||
this.installFromUrl(downloadUrl);
|
||||
} else {
|
||||
this.installFromSelectFileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrl = async (url: string) => {
|
||||
try {
|
||||
const { promise: filePromise } = downloadFile({ url });
|
||||
this.requestInstall([{
|
||||
fileName: path.basename(url),
|
||||
data: await filePromise,
|
||||
}]);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<p>Installation via URL has failed: <b>{String(err)}</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(err => {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
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 (err) {
|
||||
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(err)}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return validatedRequests;
|
||||
}
|
||||
|
||||
async requestInstall(requests: InstallRequest[]) {
|
||||
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 = `${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 (err) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
|
||||
);
|
||||
} finally {
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(Function);
|
||||
fse.unlink(tempFile).catch(Function);
|
||||
}
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<div className="flex column gaps">
|
||||
<div className="extensions-info flex column gaps">
|
||||
<h2>Lens Extension API</h2>
|
||||
<div>
|
||||
The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core
|
||||
features of Lens are built as extensions and use the same Extension API.
|
||||
</div>
|
||||
<div>
|
||||
Extensions loaded from:
|
||||
<div className="extensions-path flex inline">
|
||||
<SubTitle title="Extensions loaded from:"/>
|
||||
<div className="extensions-path flex inline" onClick={() => shell.openPath(this.extensionsPath)}>
|
||||
<Icon material="folder" tooltip={{ children: "Open folder", preferredPositions: "bottom" }}/>
|
||||
<code>{this.extensionsPath}</code>
|
||||
<Icon
|
||||
material="folder"
|
||||
tooltip="Open folder"
|
||||
onClick={() => shell.openPath(this.extensionsPath)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
|
||||
<div className="install-extension flex column gaps">
|
||||
<SubTitle title="Install extensions:"/>
|
||||
<Input
|
||||
showErrorsAsTooltip={true}
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
iconLeft="link"
|
||||
placeholder={`URL to packed extension (${this.supportedFormats.join(", ")})`}
|
||||
validators={InputValidators.isUrl}
|
||||
value={this.downloadUrl}
|
||||
onChange={v => this.downloadUrl = v}
|
||||
onSubmit={this.addExtensions}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
label="Add extensions"
|
||||
onClick={this.addExtensions}
|
||||
/>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip 1</b>: you can obtain package tarball from NPM via</Trans>{" "}
|
||||
<Clipboard showNotification>
|
||||
<code>npm view %package dist.tarball</code>
|
||||
</Clipboard>
|
||||
<span> or download package first with </span>
|
||||
<Clipboard showNotification>
|
||||
<code>npm pack %package</code>
|
||||
</Clipboard>
|
||||
</p>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip 2</b>: you can drag & drop extension's tarball here to request installation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="more-info flex inline gaps align-center">
|
||||
<Icon material="local_fire_department"/>
|
||||
<p>
|
||||
Check out documentation to <a href={docsUrl} target="_blank">learn more</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -95,19 +360,18 @@ export class Extensions extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<Input
|
||||
autoFocus
|
||||
theme="round-black"
|
||||
className="SearchInput"
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extension-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<SearchInput
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extensions-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user