diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index d45c8bde80..eb98168e25 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -51,6 +51,10 @@ export interface InstalledExtension { readonly isBundled: boolean; // defined in project root's package.json readonly isCompatible: boolean; isEnabled: boolean; + availableUpdate?: { + version: string; + input: string; + } } const logModule = "[EXTENSION-DISCOVERY]"; @@ -371,6 +375,7 @@ export class ExtensionDiscovery extends Singleton { isBundled, isEnabled, isCompatible, + availableUpdate: null, }; } catch (error) { if (error.code === "ENOTDIR") { diff --git a/src/extensions/extension-loader/extension-loader.ts b/src/extensions/extension-loader/extension-loader.ts index af5f05c100..9e0f00c967 100644 --- a/src/extensions/extension-loader/extension-loader.ts +++ b/src/extensions/extension-loader/extension-loader.ts @@ -21,12 +21,13 @@ import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; +import _ from "lodash"; import { isEqual } from "lodash"; import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; import { AppPaths } from "../../common/app-paths"; import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../../common/ipc"; -import { Disposer, toJS } from "../../common/utils"; +import { Disposer, downloadJson, toJS } from "../../common/utils"; import logger from "../../main/logger"; import type { KubernetesCluster } from "../common-api/catalog"; import type { InstalledExtension } from "../extension-discovery"; @@ -34,6 +35,8 @@ import { ExtensionsStore } from "../extensions-store"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensRendererExtension } from "../lens-renderer-extension"; import * as registries from "../registries"; +import { SemVer } from "semver"; +import URLParse from "url-parse"; export function extensionPackagesRoot() { return path.join(AppPaths.get("userData")); @@ -219,7 +222,6 @@ export class ExtensionLoader { const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); - // Remove deleted extensions in renderer side only this.extensions.forEach((_, lensExtensionId) => { if (!receivedExtensionIds.includes(lensExtensionId)) { this.removeExtension(lensExtensionId); @@ -249,6 +251,79 @@ export class ExtensionLoader { }); } + async getAvailableExtensionUpdates(): Promise<{ name: string; version: string }[]> { + const availableUpdates: { name: string; version: string }[] = []; + + // eslint-disable-next-line unused-imports/no-unused-vars-ts + for (const [_, extension] of this.extensions) { + console.log(`Check for update: ${extension.manifest.name}`); + + const availableUpdate = await this.getLatestVersionFromNpmJs(extension) || await this.getLatestVersionFromGithub(extension); + + if (availableUpdate) { + if (new SemVer(extension.manifest.version, { loose: true, includePrerelease: true }).compare(availableUpdate.version) === -1) { + extension.availableUpdate = { + version: availableUpdate.version, + input: availableUpdate.updateInput, + }; + availableUpdates.push({ name: extension.manifest.name, version: availableUpdate.version }); + } + } + } + + return availableUpdates; + } + + protected async getLatestVersionFromNpmJs(extension: InstalledExtension) { + const name = extension.manifest.name; + const registryUrl = new URLParse("https://registry.npmjs.com").set("pathname", name).toString(); + const { promise } = downloadJson({ url: registryUrl }); + const json = await promise.catch(() => { + // do nothing + }); + + if (!json || json.error || typeof json.versions !== "object" || !json.versions) { + return null; + } + + const versions = Object.keys(json.versions) + .map(version => new SemVer(version, { loose: true, includePrerelease: true })) + // ignore pre-releases for auto picking the version + .filter(version => version.prerelease.length === 0); + + const version = _.reduce(versions, (prev, curr) => ( + prev.compareMain(curr) === -1 + ? curr + : prev + )).format(); + + return { + updateInput: name, + version, + }; + } + + protected async getLatestVersionFromGithub(extension: InstalledExtension) { + + const repo = extension.manifest.homepage?.replace("https://github.com/", ""); + + const registryUrl = `https://api.github.com/repos/${repo}/releases/latest`; + + const { promise } = downloadJson({ url: registryUrl }); + const json = await promise.catch(() => { + // do nothing + }); + + if (!json || json.error || json.prerelease || !json.tag_name) { + return null; + } + + return { + updateInput: json.assets[0].browser_download_url, + version: new SemVer(json.tag_name).version, + }; + } + loadOnMain() { this.autoInitExtensions(() => Promise.resolve([])); } diff --git a/src/main/index.ts b/src/main/index.ts index 1f7b26e773..778e3d34bc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -281,6 +281,7 @@ app.on("ready", async () => { }); extensionLoader.initExtensions(extensions); + extensionLoader.getAvailableExtensionUpdates(); } catch (error) { dialog.showErrorBox("Lens Error", `Could not load extensions${error?.message ? `: ${error.message}` : ""}`); console.error(error); diff --git a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx index e692ace784..fa32c9fe97 100644 --- a/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx +++ b/src/renderer/components/+extensions/attempt-install/create-temp-files-and-validate/create-temp-files-and-validate.tsx @@ -32,6 +32,7 @@ import type { LensExtensionManifest, } from "../../../../../extensions/lens-extension"; import type { InstallRequest } from "../install-request"; +import { isCompatibleExtension } from "../../../../../extensions/extension-compatibility"; export interface InstallRequestValidated { fileName: string; @@ -60,6 +61,11 @@ export async function createTempFilesAndValidate({ await fse.writeFile(tempFile, data); const manifest = await validatePackage(tempFile); + + if (!isCompatibleExtension(manifest)){ + throw new Error("Incompatible extension"); + } + const id = path.join( ExtensionDiscovery.getInstance().nodeModulesPath, manifest.name, diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 0ba9deba65..11d9a2bbe8 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -112,6 +112,7 @@ class NonInjectedExtensions extends React.Component { enable={this.props.enableExtension} disable={this.props.disableExtension} uninstall={this.props.confirmUninstallExtension} + upgrade={this.props.installFromInput} /> diff --git a/src/renderer/components/+extensions/installed-extensions.tsx b/src/renderer/components/+extensions/installed-extensions.tsx index 83c12b8603..2d013a14aa 100644 --- a/src/renderer/components/+extensions/installed-extensions.tsx +++ b/src/renderer/components/+extensions/installed-extensions.tsx @@ -37,6 +37,7 @@ interface Props { enable: (id: LensExtensionId) => void; disable: (id: LensExtensionId) => void; uninstall: (extension: InstalledExtension) => void; + upgrade: (input: string) => void; } function getStatus(extension: InstalledExtension) { @@ -47,7 +48,7 @@ function getStatus(extension: InstalledExtension) { return extension.isEnabled ? "Enabled" : "Disabled"; } -export const InstalledExtensions = observer(({ extensions, uninstall, enable, disable }: Props) => { +export const InstalledExtensions = observer(({ extensions, uninstall, enable, disable, upgrade }: Props) => { const filters = [ (extension: InstalledExtension) => extension.manifest.name, (extension: InstalledExtension) => getStatus(extension), @@ -91,7 +92,7 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di const data = useMemo( () => { return extensions.map(extension => { - const { id, isEnabled, isCompatible, manifest } = extension; + const { id, isEnabled, isCompatible, manifest, availableUpdate } = extension; const { name, description, version } = manifest; const isUninstalling = ExtensionInstallationStateStore.isExtensionUninstalling(id); @@ -104,7 +105,15 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di ), - version, + version: ( +
+ {version} + { availableUpdate ?( + + ) : "" + } +
+ ), status: (
{getStatus(extension)} @@ -134,6 +143,16 @@ export const InstalledExtensions = observer(({ extensions, uninstall, enable, di )} + { availableUpdate && ( + upgrade(availableUpdate.input)} + > + + Upgrade + + )} + uninstall(extension)}