1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Change notification when extension is not found (#5184)

* Change notification when extension is not found

- Removes legacy downloadFile and downloadJson in favour of using
  `node-fetch`

- A notification will be displayed if the URL provided results in a
  status of 404

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Resolve PR comments

Signed-off-by: Sebastian Malton <sebastian@malton.name>

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-10-11 10:40:02 -04:00 committed by GitHub
parent da91347121
commit f021d9d0db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 735 additions and 602 deletions

View File

@ -0,0 +1,56 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { RequestInit, Response } from "node-fetch";
import type { AsyncResult } from "../utils/async-result";
import fetchInjectable from "./fetch.injectable";
export interface DownloadBinaryOptions {
signal?: AbortSignal | null | undefined;
}
export type DownloadBinary = (url: string, opts?: DownloadBinaryOptions) => Promise<AsyncResult<Buffer, string>>;
const downloadBinaryInjectable = getInjectable({
id: "download-binary",
instantiate: (di): DownloadBinary => {
const fetch = di.inject(fetchInjectable);
return async (url, opts) => {
let result: Response;
try {
// TODO: upgrade node-fetch once we switch to ESM
result = await fetch(url, opts as RequestInit);
} catch (error) {
return {
callWasSuccessful: false,
error: String(error),
};
}
if (result.status < 200 || 300 <= result.status) {
return {
callWasSuccessful: false,
error: result.statusText,
};
}
try {
return {
callWasSuccessful: true,
response: await result.buffer(),
};
} catch (error) {
return {
callWasSuccessful: false,
error: String(error),
};
}
};
},
});
export default downloadBinaryInjectable;

View File

@ -0,0 +1,56 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { RequestInit, Response } from "node-fetch";
import type { JsonValue } from "type-fest";
import type { AsyncResult } from "../utils/async-result";
import fetchInjectable from "./fetch.injectable";
export interface DownloadJsonOptions {
signal?: AbortSignal | null | undefined;
}
export type DownloadJson = (url: string, opts?: DownloadJsonOptions) => Promise<AsyncResult<JsonValue, string>>;
const downloadJsonInjectable = getInjectable({
id: "download-json",
instantiate: (di): DownloadJson => {
const fetch = di.inject(fetchInjectable);
return async (url, opts) => {
let result: Response;
try {
result = await fetch(url, opts as RequestInit);
} catch (error) {
return {
callWasSuccessful: false,
error: String(error),
};
}
if (result.status < 200 || 300 <= result.status) {
return {
callWasSuccessful: false,
error: result.statusText,
};
}
try {
return {
callWasSuccessful: true,
response: await result.json(),
};
} catch (error) {
return {
callWasSuccessful: false,
error: String(error),
};
}
};
},
});
export default downloadJsonInjectable;

View File

@ -3,10 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import type { RequestInfo, RequestInit, Response } from "node-fetch";
import fetch from "node-fetch"; import fetch from "node-fetch";
import type { RequestInit, Response } from "node-fetch";
export type Fetch = (url: RequestInfo, init?: RequestInit) => Promise<Response>; export type Fetch = (url: string, init?: RequestInit) => Promise<Response>;
const fetchInjectable = getInjectable({ const fetchInjectable = getInjectable({
id: "fetch", id: "fetch",

View File

@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* Creates an AbortController with an associated timeout
* @param timeout The number of milliseconds before this controller will auto abort
*/
export function withTimeout(timeout: number): AbortController {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
controller.signal.addEventListener("abort", () => clearTimeout(id));
return controller;
}

View File

@ -6,7 +6,8 @@
// Convert object's keys to camelCase format // Convert object's keys to camelCase format
import { camelCase } from "lodash"; import { camelCase } from "lodash";
import type { SingleOrMany } from "./types"; import type { SingleOrMany } from "./types";
import { isObject } from "./type-narrowing"; import { isObject, isString } from "./type-narrowing";
import * as object from "./objects";
export function toCamelCase<T extends Record<string, unknown>[]>(obj: T): T; export function toCamelCase<T extends Record<string, unknown>[]>(obj: T): T;
export function toCamelCase<T extends Record<string, unknown>>(obj: T): T; export function toCamelCase<T extends Record<string, unknown>>(obj: T): T;
@ -17,11 +18,10 @@ export function toCamelCase(obj: SingleOrMany<Record<string, unknown> | unknown>
} }
if (isObject(obj)) { if (isObject(obj)) {
return Object.fromEntries( return object.fromEntries(
Object.entries(obj) object.entries(obj)
.map(([key, value]) => { .filter((pair): pair is [string, unknown] => isString(pair[0]))
return [camelCase(key), toCamelCase(value)]; .map(([key, value]) => [camelCase(key), isObject(value) ? toCamelCase(value) : value]),
}),
); );
} }

View File

@ -1,53 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import request from "request";
import type { JsonValue } from "type-fest";
import { parse } from "./json";
export interface DownloadFileOptions {
url: string;
gzip?: boolean;
timeout?: number;
}
export interface DownloadFileTicket<T> {
url: string;
promise: Promise<T>;
cancel(): void;
}
export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket<Buffer> {
const fileChunks: Buffer[] = [];
const req = request(url, { gzip, timeout });
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
req.on("data", (chunk: Buffer) => {
fileChunks.push(chunk);
});
req.once("error", err => {
reject({ url, err });
});
req.once("complete", () => {
resolve(Buffer.concat(fileChunks));
});
});
return {
url,
promise,
cancel() {
req.abort();
},
};
}
export function downloadJson(args: DownloadFileOptions): DownloadFileTicket<JsonValue> {
const { promise, ...rest } = downloadFile(args);
return {
promise: promise.then(res => parse(res.toString())),
...rest,
};
}

View File

@ -14,7 +14,6 @@ export * from "./convertMemory";
export * from "./debouncePromise"; export * from "./debouncePromise";
export * from "./delay"; export * from "./delay";
export * from "./disposer"; export * from "./disposer";
export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./formatDuration"; export * from "./formatDuration";
export * from "./getRandId"; export * from "./getRandId";

View File

@ -103,7 +103,7 @@ export function isBoolean(val: unknown): val is boolean {
* checks if val is of type object and isn't null * checks if val is of type object and isn't null
* @param val the value to be checked * @param val the value to be checked
*/ */
export function isObject(val: unknown): val is object { export function isObject(val: unknown): val is Record<string | symbol | number, unknown> {
return typeof val === "object" && val !== null; return typeof val === "object" && val !== null;
} }

View File

@ -6,6 +6,8 @@
import type { RenderResult } from "@testing-library/react"; import type { RenderResult } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder"; import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import downloadBinaryInjectable, { type DownloadBinary } from "../../common/fetch/download-binary.injectable";
import downloadJsonInjectable, { type DownloadJson } from "../../common/fetch/download-json.injectable";
import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable"; import focusWindowInjectable from "../../renderer/navigation/focus-window.injectable";
// TODO: Make components free of side effects by making them deterministic // TODO: Make components free of side effects by making them deterministic
@ -15,14 +17,20 @@ describe("extensions - navigation using application menu", () => {
let builder: ApplicationBuilder; let builder: ApplicationBuilder;
let rendered: RenderResult; let rendered: RenderResult;
let focusWindowMock: jest.Mock; let focusWindowMock: jest.Mock;
let downloadJson: jest.MockedFunction<DownloadJson>;
let downloadBinary: jest.MockedFunction<DownloadBinary>;
beforeEach(async () => { beforeEach(async () => {
builder = getApplicationBuilder(); builder = getApplicationBuilder();
builder.beforeWindowStart((windowDi) => { builder.beforeWindowStart((windowDi) => {
focusWindowMock = jest.fn(); focusWindowMock = jest.fn();
downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
windowDi.override(focusWindowInjectable, () => focusWindowMock); windowDi.override(focusWindowInjectable, () => focusWindowMock);
windowDi.override(downloadJsonInjectable, () => downloadJson);
windowDi.override(downloadBinaryInjectable, () => downloadBinary);
}); });
rendered = await builder.render(); rendered = await builder.render();

View File

@ -25,23 +25,10 @@ import extensionInstallationStateStoreInjectable from "../../../../extensions/ex
import { observable, when } from "mobx"; import { observable, when } from "mobx";
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable"; import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable"; import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
import type { DownloadJson } from "../../../../common/fetch/download-json.injectable";
jest.mock("../../notifications"); import type { DownloadBinary } from "../../../../common/fetch/download-binary.injectable";
import downloadJsonInjectable from "../../../../common/fetch/download-json.injectable";
jest.mock("../../../../common/utils/downloadFile", () => ({ import downloadBinaryInjectable from "../../../../common/fetch/download-binary.injectable";
downloadFile: jest.fn(({ url }) => ({
promise: Promise.resolve(),
url,
cancel: () => {},
})),
downloadJson: jest.fn(({ url }) => ({
promise: Promise.resolve({}),
url,
cancel: () => { },
})),
}));
jest.mock("../../../../common/utils/tar");
describe("Extensions", () => { describe("Extensions", () => {
let extensionLoader: ExtensionLoader; let extensionLoader: ExtensionLoader;
@ -50,6 +37,8 @@ describe("Extensions", () => {
let extensionInstallationStateStore: ExtensionInstallationStateStore; let extensionInstallationStateStore: ExtensionInstallationStateStore;
let render: DiRender; let render: DiRender;
let deleteFileMock: jest.MockedFunction<DeleteFile>; let deleteFileMock: jest.MockedFunction<DeleteFile>;
let downloadJson: jest.MockedFunction<DownloadJson>;
let downloadBinary: jest.MockedFunction<DownloadBinary>;
beforeEach(() => { beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true }); const di = getDiForUnitTesting({ doGeneralOverrides: true });
@ -65,6 +54,12 @@ describe("Extensions", () => {
deleteFileMock = jest.fn(); deleteFileMock = jest.fn();
di.override(deleteFileInjectable, () => deleteFileMock); di.override(deleteFileInjectable, () => deleteFileMock);
downloadJson = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
di.override(downloadJsonInjectable, () => downloadJson);
downloadBinary = jest.fn().mockImplementation((url) => { throw new Error(`Unexpected call to downloadJson for url=${url}`); });
di.override(downloadBinaryInjectable, () => downloadBinary);
extensionLoader = di.inject(extensionLoaderInjectable); extensionLoader = di.inject(extensionLoaderInjectable);
extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionDiscovery = di.inject(extensionDiscoveryInjectable);
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
@ -110,7 +105,7 @@ describe("Extensions", () => {
// Approve confirm dialog // Approve confirm dialog
fireEvent.click(await screen.findByText("Yes")); fireEvent.click(await screen.findByText("Yes"));
await waitFor(() => { await waitFor(async () => {
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled(); expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
fireEvent.click(menuTrigger); fireEvent.click(menuTrigger);
expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("Disable")).toHaveAttribute("aria-disabled", "true");
@ -124,6 +119,7 @@ describe("Extensions", () => {
render(<Extensions />); render(<Extensions />);
const resolveInstall = observable.box(false); const resolveInstall = observable.box(false);
const url = "https://test.extensionurl/package.tgz";
deleteFileMock.mockReturnValue(Promise.resolve()); deleteFileMock.mockReturnValue(Promise.resolve());
installExtensionFromInput.mockImplementation(async (input) => { installExtensionFromInput.mockImplementation(async (input) => {
@ -139,13 +135,26 @@ describe("Extensions", () => {
exact: false, exact: false,
}), { }), {
target: { target: {
value: "https://test.extensionurl/package.tgz", value: url,
}, },
}); });
const doResolve = observable.box(false);
downloadBinary.mockImplementation(async (targetUrl) => {
expect(targetUrl).toBe(url);
await when(() => doResolve.get());
return {
callWasSuccessful: false,
error: "unknown location",
};
});
fireEvent.click(await screen.findByText("Install")); fireEvent.click(await screen.findByText("Install"));
expect((await screen.findByText("Install")).closest("button")).toBeDisabled(); expect((await screen.findByText("Install")).closest("button")).toBeDisabled();
resolveInstall.set(true); doResolve.set(true);
}); });
it("displays spinner while extensions are loading", () => { it("displays spinner while extensions are loading", () => {

View File

@ -2,8 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { downloadFile, downloadJson } from "../../../common/utils"; import { isObject } from "../../../common/utils";
import { Notifications } from "../notifications";
import React from "react"; import React from "react";
import { SemVer } from "semver"; import { SemVer } from "semver";
import URLParse from "url-parse"; import URLParse from "url-parse";
@ -14,6 +13,12 @@ import extensionInstallationStateStoreInjectable from "../../../extensions/exten
import confirmInjectable from "../confirm-dialog/confirm.injectable"; import confirmInjectable from "../confirm-dialog/confirm.injectable";
import { reduce } from "lodash"; import { reduce } from "lodash";
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable"; import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
import { withTimeout } from "../../../common/fetch/timeout-controller";
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
import downloadJsonInjectable from "../../../common/fetch/download-json.injectable";
import type { PackageJson } from "type-fest";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../common/logger.injectable";
export interface ExtensionInfo { export interface ExtensionInfo {
name: string; name: string;
@ -21,6 +26,19 @@ export interface ExtensionInfo {
requireConfirmation?: boolean; requireConfirmation?: boolean;
} }
interface NpmPackageVersionDescriptor extends PackageJson {
dist: {
integrity: string;
shasum: string;
tarball: string;
};
}
interface NpmRegistryPackageDescriptor {
versions: Partial<Record<string, NpmPackageVersionDescriptor>>;
"dist-tags"?: Partial<Record<string, string>>;
}
export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>; export type AttemptInstallByInfo = (info: ExtensionInfo) => Promise<void>;
const attemptInstallByInfoInjectable = getInjectable({ const attemptInstallByInfoInjectable = getInjectable({
@ -31,55 +49,95 @@ const attemptInstallByInfoInjectable = getInjectable({
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const confirm = di.inject(confirmInjectable); const confirm = di.inject(confirmInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const downloadJson = di.inject(downloadJsonInjectable);
const downloadBinary = di.inject(downloadBinaryInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const logger = di.inject(loggerInjectable);
return async (info) => { return async (info) => {
const { name, version, requireConfirmation = false } = info; const { name, version: versionOrTagName, requireConfirmation = false } = info;
const disposer = extensionInstallationStateStore.startPreInstall(); const disposer = extensionInstallationStateStore.startPreInstall();
const baseUrl = await getBaseRegistryUrl(); const baseUrl = await getBaseRegistryUrl();
const registryUrl = new URLParse(baseUrl).set("pathname", name).toString(); const registryUrl = new URLParse(baseUrl).set("pathname", name).toString();
let json: any; let json: NpmRegistryPackageDescriptor;
let finalVersion = version;
try { try {
json = await downloadJson({ url: registryUrl }).promise; const result = await downloadJson(registryUrl);
if (!json || json.error || typeof json.versions !== "object" || !json.versions) { if (!result.callWasSuccessful) {
const message = json?.error ? `: ${json.error}` : ""; showErrorNotification(`Failed to get registry information for extension: ${result.error}`);
Notifications.error(`Failed to get registry information for that extension${message}`);
return disposer(); return disposer();
} }
if (!isObject(result.response) || Array.isArray(result.response)) {
showErrorNotification("Failed to get registry information for extension");
return disposer();
}
if (result.response.error || !isObject(result.response.versions)) {
const message = result.response.error ? `: ${result.response.error}` : "";
showErrorNotification(`Failed to get registry information for extension${message}`);
return disposer();
}
json = result.response as unknown as NpmRegistryPackageDescriptor;
} catch (error) { } catch (error) {
if (error instanceof SyntaxError) { if (error instanceof SyntaxError) {
// assume invalid JSON // assume invalid JSON
console.warn("Set registry has invalid json", { url: baseUrl }, error); logger.warn("Set registry has invalid json", { url: baseUrl }, error);
Notifications.error("Failed to get valid registry information for that extension. Registry did not return valid JSON"); showErrorNotification("Failed to get valid registry information for extension. Registry did not return valid JSON");
} else { } else {
console.error("Failed to download registry information", error); logger.error("Failed to download registry information", error);
Notifications.error(`Failed to get valid registry information for that extension. ${error}`); showErrorNotification(`Failed to get valid registry information for extension. ${error}`);
} }
return disposer(); return disposer();
} }
if (version) { let version = versionOrTagName;
if (!json.versions[version]) {
if (json["dist-tags"][version]) { if (versionOrTagName) {
finalVersion = json["dist-tags"][version]; validDistTagName:
} else { if (!json.versions[versionOrTagName]) {
Notifications.error(( if (json["dist-tags"]) {
const potentialVersion = json["dist-tags"][versionOrTagName];
if (potentialVersion) {
if (!json.versions[potentialVersion]) {
showErrorNotification((
<p> <p>
{"The "} Configured registry claims to have tag
<em>{name}</em> {" "}
{" extension does not have a version or tag "} <code>{versionOrTagName}</code>
<code>{version}</code>
. .
{" "}
But does not have version infomation for the reference.
</p> </p>
)); ));
return disposer(); return disposer();
} }
version = potentialVersion;
break validDistTagName;
}
}
showErrorNotification((
<p>
{"The "}
<em>{name}</em>
{" extension does not have a version or tag "}
<code>{versionOrTagName}</code>
.
</p>
));
return disposer();
} }
} else { } else {
const versions = Object.keys(json.versions) const versions = Object.keys(json.versions)
@ -89,26 +147,33 @@ const attemptInstallByInfoInjectable = getInjectable({
const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev); const latestVersion = reduce(versions, (prev, curr) => prev.compareMain(curr) === -1 ? curr : prev);
if (!latestVersion) { version = latestVersion?.format();
console.error("No versions supplied for that extension", { name }); }
Notifications.error(`No versions found for ${name}`);
if (!version) {
logger.error("No versions supplied for extension", { name });
showErrorNotification(`No versions found for ${name}`);
return disposer(); return disposer();
} }
finalVersion = latestVersion.format(); const versionInfo = json.versions[version];
const tarballUrl = versionInfo?.dist.tarball;
if (!tarballUrl) {
showErrorNotification("Configured registry has invalid data model. Please verify that it is like NPM's.");
logger.warn(`[ATTEMPT-INSTALL-BY-INFO]: registry returned unexpected data, final version is ${version} but the versions object is missing .dist.tarball as a string`, versionInfo);
return disposer();
} }
if (requireConfirmation) { if (requireConfirmation) {
const proceed = await confirm({ const proceed = await confirm({
message: ( message: (
<p> <p>
Are you sure you want to install {"Are you sure you want to install "}
{" "}
<b> <b>
{name} {`${name}@${version}`}
@
{finalVersion}
</b> </b>
? ?
</p> </p>
@ -122,12 +187,17 @@ const attemptInstallByInfoInjectable = getInjectable({
} }
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion const fileName = getBasenameOfPath(tarballUrl);
const url = json.versions[finalVersion!].dist.tarball; const { signal } = withTimeout(10 * 60 * 1000);
const fileName = getBasenameOfPath(url); const request = await downloadBinary(tarballUrl, { signal });
const { promise: dataP } = downloadFile({ url, timeout: 10 * 60 * 1000 });
return attemptInstall({ fileName, dataP }, disposer); if (!request.callWasSuccessful) {
showErrorNotification(`Failed to download extension: ${request.error}`);
return disposer();
}
return attemptInstall({ fileName, data: request.response }, disposer);
}; };
}, },
}); });

View File

@ -1,30 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
import { attemptInstall } from "./attempt-install";
import unpackExtensionInjectable from "./unpack-extension/unpack-extension.injectable";
import getExtensionDestFolderInjectable
from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import extensionInstallationStateStoreInjectable
from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
const attemptInstallInjectable = getInjectable({
id: "attempt-install",
instantiate: (di) =>
attemptInstall({
extensionLoader: di.inject(extensionLoaderInjectable),
uninstallExtension: di.inject(uninstallExtensionInjectable),
unpackExtension: di.inject(unpackExtensionInjectable),
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
extensionInstallationStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default attemptInstallInjectable;

View File

@ -0,0 +1,148 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import uninstallExtensionInjectable from "../uninstall-extension/uninstall-extension.injectable";
import type { UnpackExtension } from "./unpack-extension.injectable";
import unpackExtensionInjectable from "./unpack-extension.injectable";
import type { GetExtensionDestFolder } from "./get-extension-dest-folder.injectable";
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate.injectable";
import createTempFilesAndValidateInjectable from "./create-temp-files-and-validate.injectable";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { Disposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import { Notifications } from "../../notifications";
import { Button } from "../../button";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
import type { LensExtensionId } from "../../../../extensions/lens-extension";
import React from "react";
import { remove as removeDir } from "fs-extra";
import { shell } from "electron";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import { ExtensionInstallationState } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
export interface InstallRequest {
fileName: string;
data: Buffer;
}
interface Dependencies {
extensionLoader: ExtensionLoader;
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
unpackExtension: UnpackExtension;
createTempFilesAndValidate: CreateTempFilesAndValidate;
getExtensionDestFolder: GetExtensionDestFolder;
installStateStore: ExtensionInstallationStateStore;
}
export type AttemptInstall = (request: InstallRequest, cleanup?: Disposer) => Promise<void>;
const attemptInstall = ({
extensionLoader,
uninstallExtension,
unpackExtension,
createTempFilesAndValidate,
getExtensionDestFolder,
installStateStore,
}: Dependencies): AttemptInstall =>
async (request, cleanup) => {
const dispose = disposer(
installStateStore.startPreInstall(),
cleanup,
);
const validatedRequest = await createTempFilesAndValidate(request);
if (!validatedRequest) {
return dispose();
}
const { name, version, description } = validatedRequest.manifest;
const curState = installStateStore.getInstallationState(validatedRequest.id);
if (curState !== ExtensionInstallationState.IDLE) {
dispose();
return void Notifications.error(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>
{"The "}
<em>{name}</em>
{` extension is currently ${curState.toLowerCase()}.`}
</p>
<p>Will not proceed with this current install request.</p>
</div>,
);
}
const extensionFolder = getExtensionDestFolder(name);
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
if (installedExtension) {
const { version: oldVersion } = installedExtension.manifest;
// confirm to uninstall old version before installing new version
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>
{` ${name}@${oldVersion} will be removed before installation.`}
</div>
</div>
<Button
autoFocus
label="Install"
onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}}
/>
</div>,
{
onClose: dispose,
},
);
} else {
// clean up old data if still around
await removeDir(extensionFolder);
// install extension if not yet exists
await unpackExtension(validatedRequest, dispose);
}
};
const attemptInstallInjectable = getInjectable({
id: "attempt-install",
instantiate: (di) => attemptInstall({
extensionLoader: di.inject(extensionLoaderInjectable),
uninstallExtension: di.inject(uninstallExtensionInjectable),
unpackExtension: di.inject(unpackExtensionInjectable),
createTempFilesAndValidate: di.inject(createTempFilesAndValidateInjectable),
getExtensionDestFolder: di.inject(getExtensionDestFolderInjectable),
installStateStore: di.inject(extensionInstallationStateStoreInjectable),
}),
});
export default attemptInstallInjectable;

View File

@ -1,126 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ExtendableDisposer } from "../../../../common/utils";
import { disposer } from "../../../../common/utils";
import { Notifications } from "../../notifications";
import { Button } from "../../button";
import type { ExtensionLoader } from "../../../../extensions/extension-loader";
import type { LensExtensionId } from "../../../../extensions/lens-extension";
import React from "react";
import { remove as removeDir } from "fs-extra";
import { shell } from "electron";
import type { InstallRequest } from "./install-request";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import { ExtensionInstallationState } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import type { UnpackExtension } from "./unpack-extension/unpack-extension.injectable";
import type { CreateTempFilesAndValidate } from "./create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import type { GetExtensionDestFolder } from "./get-extension-dest-folder/get-extension-dest-folder.injectable";
interface Dependencies {
extensionLoader: ExtensionLoader;
uninstallExtension: (id: LensExtensionId) => Promise<boolean>;
unpackExtension: UnpackExtension;
createTempFilesAndValidate: CreateTempFilesAndValidate;
getExtensionDestFolder: GetExtensionDestFolder;
extensionInstallationStateStore: ExtensionInstallationStateStore;
}
export const attemptInstall =
({
extensionLoader,
uninstallExtension,
unpackExtension,
createTempFilesAndValidate,
getExtensionDestFolder,
extensionInstallationStateStore,
}: Dependencies) =>
async (request: InstallRequest, d?: ExtendableDisposer): Promise<void> => {
console.log("Attempting to install extension");
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 void Notifications.error(
<div className="flex column gaps">
<b>Extension Install Collision:</b>
<p>
{"The "}
<em>{name}</em>
{` extension is currently ${curState.toLowerCase()}.`}
</p>
<p>Will not proceed with this current install request.</p>
</div>,
);
}
const extensionFolder = getExtensionDestFolder(name);
const installedExtension = extensionLoader.getExtension(validatedRequest.id);
if (installedExtension) {
const { version: oldVersion } = installedExtension.manifest;
// confirm to uninstall old version before installing new version
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>
{` ${name}@${oldVersion} will be removed before installation.`}
</div>
</div>
<Button
autoFocus
label="Install"
onClick={async () => {
removeNotification();
if (await uninstallExtension(validatedRequest.id)) {
await unpackExtension(validatedRequest, dispose);
} else {
dispose();
}
}}
/>
</div>,
{
onClose: dispose,
},
);
} else {
// clean up old data if still around
await removeDir(extensionFolder);
// install extension if not yet exists
await unpackExtension(validatedRequest, dispose);
}
};

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import { validatePackage } from "./validate-package";
import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery";
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
import logger from "../../../../main/logger";
import { Notifications } from "../../notifications";
import path from "path";
import fse from "fs-extra";
import React from "react";
import os from "os";
import type { LensExtensionId, LensExtensionManifest } from "../../../../extensions/lens-extension";
import type { InstallRequest } from "./attempt-install.injectable";
export interface InstallRequestValidated {
fileName: string;
data: Buffer;
id: LensExtensionId;
manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking
}
interface Dependencies {
extensionDiscovery: ExtensionDiscovery;
}
export type CreateTempFilesAndValidate = (request: InstallRequest) => Promise<InstallRequestValidated | null>;
const createTempFilesAndValidate = ({
extensionDiscovery,
}: Dependencies): CreateTempFilesAndValidate => (
async ({ fileName, data }) => {
// copy files to temp
await fse.ensureDir(getExtensionPackageTemp());
// validate packages
const tempFile = getExtensionPackageTemp(fileName);
try {
await fse.writeFile(tempFile, data);
const manifest = await validatePackage(tempFile);
const id = path.join(
extensionDiscovery.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((
<div className="flex column gaps">
<p>
{"Installing "}
<em>{fileName}</em>
{" has failed, skipping."}
</p>
<p>
{"Reason: "}
<em>{message}</em>
</p>
</div>
));
}
return null;
}
);
function getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
const createTempFilesAndValidateInjectable = getInjectable({
id: "create-temp-files-and-validate",
instantiate: (di) => createTempFilesAndValidate({
extensionDiscovery: di.inject(extensionDiscoveryInjectable),
}),
});
export default createTempFilesAndValidateInjectable;

View File

@ -1,104 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable";
import React from "react";
import type { LensExtensionId, LensExtensionManifest } from "../../../../../extensions/lens-extension";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import type { InstallRequest } from "../install-request";
import { validatePackage } from "../validate-package/validate-package";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
import tempDirectoryPathInjectable from "../../../../../common/os/temp-directory-path.injectable";
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
import writeFileInjectable from "../../../../../common/fs/write-file.injectable";
import loggerInjectable from "../../../../../common/logger.injectable";
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
export interface InstallRequestValidated {
fileName: string;
data: Buffer;
id: LensExtensionId;
manifest: LensExtensionManifest;
tempFile: string; // temp system path to packed extension for unpacking
}
export type CreateTempFilesAndValidate = (req: InstallRequest) => Promise<InstallRequestValidated | null>;
const createTempFilesAndValidateInjectable = getInjectable({
id: "create-temp-files-and-validate",
instantiate: (di) => {
const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const tempDirectoryPath = di.inject(tempDirectoryPathInjectable);
const ensureDir = di.inject(ensureDirInjectable);
const writeFile = di.inject(writeFileInjectable);
const logger = di.inject(loggerInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const baseTempExtensionsDirectory = joinPaths(tempDirectoryPath, "lens-extensions");
const getExtensionPackageTemp = (fileName: string) => joinPaths(baseTempExtensionsDirectory, fileName);
return async ({
fileName,
dataP,
}: InstallRequest): Promise<InstallRequestValidated | null> => {
// copy files to temp
await ensureDir(baseTempExtensionsDirectory);
// validate packages
const tempFile = getExtensionPackageTemp(fileName);
try {
const data = await dataP;
if (!data) {
return null;
}
await writeFile(tempFile, data);
logger.info("validating package", tempFile);
const manifest = await validatePackage(tempFile);
const id = joinPaths(
extensionDiscovery.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 },
);
showErrorNotification(
<div className="flex column gaps">
<p>
{"Installing "}
<em>{fileName}</em>
{" has failed, skipping."}
</p>
<p>
{"Reason: "}
<em>{message}</em>
</p>
</div>,
);
}
return null;
};
},
});
export default createTempFilesAndValidateInjectable;

View File

@ -3,20 +3,19 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import extensionDiscoveryInjectable from "../../../../../extensions/extension-discovery/extension-discovery.injectable"; import { sanitizeExtensionName } from "../../../../extensions/lens-extension";
import { sanitizeExtensionName } from "../../../../../extensions/lens-extension"; import path from "path";
export type GetExtensionDestFolder = (extensionName: string) => string; export type GetExtensionDestFolder = (name: string) => string;
const getExtensionDestFolderInjectable = getInjectable({ const getExtensionDestFolderInjectable = getInjectable({
id: "get-extension-dest-folder", id: "get-extension-dest-folder",
instantiate: (di): GetExtensionDestFolder => { instantiate: (di): GetExtensionDestFolder => {
const extensionDiscovery = di.inject(extensionDiscoveryInjectable); const extensionDiscovery = di.inject(extensionDiscoveryInjectable);
const joinPaths = di.inject(joinPathsInjectable);
return (name) => joinPaths(extensionDiscovery.localFolderPath, sanitizeExtensionName(name)); return (name) => path.join(extensionDiscovery.localFolderPath, sanitizeExtensionName(name));
}, },
}); });

View File

@ -1,8 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export interface InstallRequest {
fileName: string;
dataP: Promise<Buffer | null>;
}

View File

@ -0,0 +1,115 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import getExtensionDestFolderInjectable from "./get-extension-dest-folder.injectable";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { Disposer } from "../../../../common/utils";
import { noop } from "../../../../common/utils";
import { extensionDisplayName } from "../../../../extensions/lens-extension";
import { getMessageFromError } from "../get-message-from-error/get-message-from-error";
import path from "path";
import fse from "fs-extra";
import { when } from "mobx";
import React from "react";
import type { InstallRequestValidated } from "./create-temp-files-and-validate.injectable";
import extractTarInjectable from "../../../../common/fs/extract-tar.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import showInfoNotificationInjectable from "../../notifications/show-info-notification.injectable";
import showErrorNotificationInjectable from "../../notifications/show-error-notification.injectable";
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
const unpackExtensionInjectable = getInjectable({
id: "unpack-extension",
instantiate: (di): UnpackExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const extractTar = di.inject(extractTarInjectable);
const logger = di.inject(loggerInjectable);
const showInfoNotification = di.inject(showInfoNotificationInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
return async (request, disposeDownloading) => {
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.userExtensions.has(id));
// Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true);
showInfoNotification((
<p>
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>
));
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
{ error },
);
showErrorNotification((
<p>
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>
));
} finally {
// Remove install state once finished
extensionInstallationStateStore.clearInstalling(id);
// clean up
fse.remove(unpackingTempFolder).catch(noop);
fse.unlink(tempFile).catch(noop);
}
};
},
});
export default unpackExtensionInjectable;

View File

@ -1,130 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../../../../extensions/extension-loader/extension-loader.injectable";
import getExtensionDestFolderInjectable from "../get-extension-dest-folder/get-extension-dest-folder.injectable";
import extensionInstallationStateStoreInjectable from "../../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { InstallRequestValidated } from "../create-temp-files-and-validate/create-temp-files-and-validate.injectable";
import type { Disposer } from "../../../../utils";
import { noop } from "../../../../utils";
import { extensionDisplayName } from "../../../../../extensions/lens-extension";
import joinPathsInjectable from "../../../../../common/path/join-paths.injectable";
import loggerInjectable from "../../../../../common/logger.injectable";
import { when } from "mobx";
import { getMessageFromError } from "../../get-message-from-error/get-message-from-error";
import showSuccessNotificationInjectable from "../../../notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
import getDirnameOfPathInjectable from "../../../../../common/path/get-dirname.injectable";
import getBasenameOfPathInjectable from "../../../../../common/path/get-basename.injectable";
import extractTarInjectable from "../../../../../common/fs/extract-tar.injectable";
import ensureDirInjectable from "../../../../../common/fs/ensure-dir.injectable";
import removePathInjectable from "../../../../../common/fs/remove-path.injectable";
import deleteFileInjectable from "../../../../../common/fs/delete-file.injectable";
import readDirectoryInjectable from "../../../../../common/fs/read-directory.injectable";
import moveInjectable from "../../../../../common/fs/move.injectable";
export type UnpackExtension = (request: InstallRequestValidated, disposeDownloading?: Disposer) => Promise<void>;
const unpackExtensionInjectable = getInjectable({
id: "unpack-extension",
instantiate: (di): UnpackExtension => {
const extensionLoader = di.inject(extensionLoaderInjectable);
const getExtensionDestFolder = di.inject(getExtensionDestFolderInjectable);
const extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const getDirnameOfPath = di.inject(getDirnameOfPathInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const logger = di.inject(loggerInjectable);
const showOkNotification = di.inject(showSuccessNotificationInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const extractTar = di.inject(extractTarInjectable);
const ensureDir = di.inject(ensureDirInjectable);
const removePath = di.inject(removePathInjectable);
const deleteFile = di.inject(deleteFileInjectable);
const readDirectory = di.inject(readDirectoryInjectable);
const move = di.inject(moveInjectable);
return async (request, disposeDownloading) => {
const {
id,
fileName,
tempFile,
manifest: { name, version },
} = request;
extensionInstallationStateStore.setInstalling(id);
disposeDownloading?.();
const displayName = extensionDisplayName(name, version);
const extensionFolder = getExtensionDestFolder(name);
const unpackingTempFolder = joinPaths(
getDirnameOfPath(tempFile),
`${getBasenameOfPath(tempFile)}-unpacked`,
);
logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile });
try {
// extract to temp folder first
await removePath(unpackingTempFolder).catch(noop);
await ensureDir(unpackingTempFolder);
await extractTar(tempFile, { cwd: unpackingTempFolder });
// move contents to extensions folder
const unpackedFiles = await readDirectory(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 = joinPaths(unpackingTempFolder, unpackedFiles[0]);
}
await ensureDir(extensionFolder);
await move(unpackedRootFolder, extensionFolder, { overwrite: true });
// wait for the loader has actually install it
await when(() => extensionLoader.userExtensions.has(id));
// Enable installed extensions by default.
extensionLoader.setIsEnabled(id, true);
showOkNotification(
<p>
{"Extension "}
<b>{displayName}</b>
{" successfully installed!"}
</p>,
);
} catch (error) {
const message = getMessageFromError(error);
logger.info(
`[EXTENSION-INSTALLATION]: installing ${request.fileName} has failed: ${message}`,
{ error },
);
showErrorNotification(
<p>
{"Installing extension "}
<b>{displayName}</b>
{" has failed: "}
<em>{message}</em>
</p>,
);
} finally {
// Remove install state once finished
extensionInstallationStateStore.clearInstalling(id);
// clean up
removePath(unpackingTempFolder).catch(noop);
deleteFile(tempFile).catch(noop);
}
};
},
});
export default unpackExtensionInjectable;

View File

@ -2,14 +2,12 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import type { LensExtensionManifest } from "../../../../../extensions/lens-extension"; import type { LensExtensionManifest } from "../../../../extensions/lens-extension";
import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../../common/utils"; import { hasTypedProperty, isObject, isString, listTarEntries, readFileFromTar } from "../../../../common/utils";
import { manifestFilename } from "../../../../../extensions/extension-discovery/extension-discovery"; import { manifestFilename } from "../../../../extensions/extension-discovery/extension-discovery";
import path from "path"; import path from "path";
export const validatePackage = async ( export async function validatePackage(filePath: string): Promise<LensExtensionManifest> {
filePath: string,
): Promise<LensExtensionManifest> => {
const tarFiles = await listTarEntries(filePath); const tarFiles = await listTarEntries(filePath);
// tarball from npm contains single root folder "package/*" // tarball from npm contains single root folder "package/*"
@ -20,9 +18,7 @@ export const validatePackage = async (
} }
const rootFolder = path.normalize(firstFile).split(path.sep)[0]; const rootFolder = path.normalize(firstFile).split(path.sep)[0];
const packedInRootFolder = tarFiles.every(entry => const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
entry.startsWith(rootFolder),
);
const manifestLocation = packedInRootFolder const manifestLocation = packedInRootFolder
? path.join(rootFolder, manifestFilename) ? path.join(rootFolder, manifestFilename)
: manifestFilename; : manifestFilename;
@ -48,4 +44,4 @@ export const validatePackage = async (
} }
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`); throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
}; }

View File

@ -3,9 +3,9 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import getBasenameOfPathInjectable from "../../../../common/path/get-basename.injectable"; import attemptInstallInjectable from "./attempt-install/attempt-install.injectable";
import attemptInstallInjectable from "../attempt-install/attempt-install.injectable"; import path from "path";
import readFileNotifyInjectable from "../read-file-notify/read-file-notify.injectable"; import readFileNotifyInjectable from "./read-file-notify/read-file-notify.injectable";
export type AttemptInstalls = (filePaths: string[]) => Promise<void>; export type AttemptInstalls = (filePaths: string[]) => Promise<void>;
@ -14,16 +14,23 @@ const attemptInstallsInjectable = getInjectable({
instantiate: (di): AttemptInstalls => { instantiate: (di): AttemptInstalls => {
const attemptInstall = di.inject(attemptInstallInjectable); const attemptInstall = di.inject(attemptInstallInjectable);
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const readFileNotify = di.inject(readFileNotifyInjectable); const readFileNotify = di.inject(readFileNotifyInjectable);
return async (filePaths) => { return async (filePaths) => {
await Promise.allSettled(filePaths.map(filePath => ( await Promise.allSettled(
attemptInstall({ filePaths.map(async filePath => {
fileName: getBasenameOfPath(filePath), const data = await readFileNotify(filePath);
dataP: readFileNotify(filePath),
}) if (!data) {
))); return;
}
return attemptInstall({
fileName: path.basename(filePath),
data,
});
}),
);
}; };
}, },
}); });

View File

@ -32,7 +32,8 @@ import type { InstallExtensionFromInput } from "./install-extension-from-input.i
import installExtensionFromInputInjectable from "./install-extension-from-input.injectable"; import installExtensionFromInputInjectable from "./install-extension-from-input.injectable";
import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable"; import installFromSelectFileDialogInjectable from "./install-from-select-file-dialog.injectable";
import type { LensExtensionId } from "../../../extensions/lens-extension"; import type { LensExtensionId } from "../../../extensions/lens-extension";
import installOnDropInjectable from "./install-on-drop/install-on-drop.injectable"; import type { InstallOnDrop } from "./install-on-drop.injectable";
import installOnDropInjectable from "./install-on-drop.injectable";
import { supportedExtensionFormats } from "./supported-extension-formats"; import { supportedExtensionFormats } from "./supported-extension-formats";
import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store";
@ -44,7 +45,7 @@ interface Dependencies {
confirmUninstallExtension: ConfirmUninstallExtension; confirmUninstallExtension: ConfirmUninstallExtension;
installExtensionFromInput: InstallExtensionFromInput; installExtensionFromInput: InstallExtensionFromInput;
installFromSelectFileDialog: () => Promise<void>; installFromSelectFileDialog: () => Promise<void>;
installOnDrop: (files: File[]) => Promise<void>; installOnDrop: InstallOnDrop;
extensionInstallationStateStore: ExtensionInstallationStateStore; extensionInstallationStateStore: ExtensionInstallationStateStore;
} }

View File

@ -4,7 +4,6 @@
*/ */
import React from "react"; import React from "react";
import type { ExtendableDisposer } from "../../../common/utils"; import type { ExtendableDisposer } from "../../../common/utils";
import { downloadFile } from "../../../common/utils";
import { InputValidators } from "../input"; import { InputValidators } from "../input";
import { getMessageFromError } from "./get-message-from-error/get-message-from-error"; import { getMessageFromError } from "./get-message-from-error/get-message-from-error";
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
@ -15,6 +14,8 @@ import readFileNotifyInjectable from "./read-file-notify/read-file-notify.inject
import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable"; import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable"; import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
import loggerInjectable from "../../../common/logger.injectable"; import loggerInjectable from "../../../common/logger.injectable";
import downloadBinaryInjectable from "../../../common/fetch/download-binary.injectable";
import { withTimeout } from "../../../common/fetch/timeout-controller";
export type InstallExtensionFromInput = (input: string) => Promise<void>; export type InstallExtensionFromInput = (input: string) => Promise<void>;
@ -29,6 +30,7 @@ const installExtensionFromInputInjectable = getInjectable({
const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); const getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable); const showErrorNotification = di.inject(showErrorNotificationInjectable);
const logger = di.inject(loggerInjectable); const logger = di.inject(loggerInjectable);
const downloadBinary = di.inject(downloadBinaryInjectable);
return async (input) => { return async (input) => {
let disposer: ExtendableDisposer | undefined = undefined; let disposer: ExtendableDisposer | undefined = undefined;
@ -38,10 +40,18 @@ const installExtensionFromInputInjectable = getInjectable({
if (InputValidators.isUrl.validate(input)) { if (InputValidators.isUrl.validate(input)) {
// install via url // install via url
disposer = extensionInstallationStateStore.startPreInstall(); disposer = extensionInstallationStateStore.startPreInstall();
const { promise } = downloadFile({ url: input, timeout: 10 * 60 * 1000 }); const { signal } = withTimeout(10 * 60 * 1000);
const result = await downloadBinary(input, { signal });
if (!result.callWasSuccessful) {
showErrorNotification(`Failed to download extension: ${result.error}`);
return disposer();
}
const fileName = getBasenameOfPath(input); const fileName = getBasenameOfPath(input);
return await attemptInstall({ fileName, dataP: promise }, disposer); return await attemptInstall({ fileName, data: result.response }, disposer);
} }
try { try {
@ -49,8 +59,13 @@ const installExtensionFromInputInjectable = getInjectable({
// install from system path // install from system path
const fileName = getBasenameOfPath(input); const fileName = getBasenameOfPath(input);
const data = await readFileNotify(input);
return await attemptInstall({ fileName, dataP: readFileNotify(input) }); if (!data) {
return;
}
return await attemptInstall({ fileName, data });
} catch (error) { } catch (error) {
const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input); const extNameCaptures = InputValidators.isExtensionNameInstallRegex.captures(input);

View File

@ -5,7 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import { requestOpenFilePickingDialog } from "../../ipc"; import { requestOpenFilePickingDialog } from "../../ipc";
import { supportedExtensionFormats } from "./supported-extension-formats"; import { supportedExtensionFormats } from "./supported-extension-formats";
import attemptInstallsInjectable from "./attempt-installs/attempt-installs.injectable"; import attemptInstallsInjectable from "./attempt-installs.injectable";
import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; import directoryForDownloadsInjectable from "../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
interface Dependencies { interface Dependencies {

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../../common/logger.injectable";
import attemptInstallsInjectable from "./attempt-installs.injectable";
export type InstallOnDrop = (files: File[]) => Promise<void>;
const installOnDropInjectable = getInjectable({
id: "install-on-drop",
instantiate: (di): InstallOnDrop => {
const attemptInstalls = di.inject(attemptInstallsInjectable);
const logger = di.inject(loggerInjectable);
return (files) => {
logger.info("Install from D&D");
return attemptInstalls(files.map(({ path }) => path));
};
},
});
export default installOnDropInjectable;

View File

@ -1,18 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { installOnDrop } from "./install-on-drop";
import attemptInstallsInjectable from "../attempt-installs/attempt-installs.injectable";
const installOnDropInjectable = getInjectable({
id: "install-on-drop",
instantiate: (di) =>
installOnDrop({
attemptInstalls: di.inject(attemptInstallsInjectable),
}),
});
export default installOnDropInjectable;

View File

@ -1,16 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import logger from "../../../../main/logger";
interface Dependencies {
attemptInstalls: (filePaths: string[]) => Promise<void>;
}
export const installOnDrop =
({ attemptInstalls }: Dependencies) =>
async (files: File[]) => {
logger.info("Install from D&D");
await attemptInstalls(files.map(({ path }) => path));
};

View File

@ -56,9 +56,7 @@ const NonInjectedInstall: React.FC<Dependencies & InstallProps> = ({
<div> <div>
<Input <Input
theme="round-black" theme="round-black"
disabled={ disabled={extensionInstallationStateStore.anyPreInstallingOrInstalling}
extensionInstallationStateStore.anyPreInstallingOrInstalling
}
placeholder={"Name or file path or URL"} placeholder={"Name or file path or URL"}
showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }}
validators={installPath ? installInputValidator : undefined} validators={installPath ? installInputValidator : undefined}

View File

@ -7,6 +7,7 @@ import "./input.scss";
import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import type { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import React from "react"; import React from "react";
import type { SingleOrMany } from "../../utils";
import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils"; import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import type { TooltipProps } from "../tooltip"; import type { TooltipProps } from "../tooltip";
@ -72,7 +73,7 @@ export type InputProps = Omit<InputElementProps, "onChange" | "onSubmit"> & {
iconLeft?: IconData; iconLeft?: IconData;
iconRight?: IconData; iconRight?: IconData;
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
validators?: InputValidator<boolean> | InputValidator<boolean>[]; validators?: SingleOrMany<InputValidator>;
blurOnEnter?: boolean; blurOnEnter?: boolean;
onChange?(value: string, evt: React.ChangeEvent<InputElement>): void; onChange?(value: string, evt: React.ChangeEvent<InputElement>): void;
onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void; onSubmit?(value: string, evt: React.KeyboardEvent<InputElement>): void;