diff --git a/app/.gitignore b/app/.gitignore index 4b78ba544e7..7ccc4a756f4 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -7,4 +7,5 @@ /electron/i18n-helper.js /electron/preload.js /electron/windowSize.js -electron/windowSize.test.js \ No newline at end of file +/electron/plugin-management.js +electron/windowSize.test.js diff --git a/app/electron/main.ts b/app/electron/main.ts index 46bc4ac4914..f4370ae282f 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -20,8 +20,8 @@ import { userInfo } from 'node:os'; import path from 'path'; import url from 'url'; import yargs from 'yargs'; -import PluginManager from '../../plugins/headlamp-plugin/plugin-management/plugin-management'; import i18n from './i18next.config'; +import { PluginManager } from './plugin-management'; import windowSize from './windowSize'; dotenv.config({ path: path.join(process.resourcesPath, '.env') }); diff --git a/app/electron/plugin-management.ts b/app/electron/plugin-management.ts new file mode 100644 index 00000000000..2c9d18c7d41 --- /dev/null +++ b/app/electron/plugin-management.ts @@ -0,0 +1,551 @@ +/** + * plugin-management-utils.js has the core logic for managing plugins in Headlamp. + * + * Provides methods for installing, updating, listing and uninstalling plugins. + * + * Used by: + * - plugins/headlamp-plugin/bin/headlamp-plugin.js cli + * - app/ to manage plugins. + */ +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import semver from 'semver'; +import stream from 'stream'; +import tar from 'tar'; +import zlib from 'zlib'; + +// comment out for testing +// function sleep(ms) { +// // console.log(ms) +// // return new Promise(function (resolve) { +// // setTimeout(resolve, ms+2000); +// // }); +// } + +/** + * `ProgressResp` is an interface for progress response. + * + * @interface + * @property {string} type - The type of the progress response. + * @property {string} message - The message of the progress response. + * @property {Record} data - Additional data for the progress response. Optional. + */ +interface ProgressResp { + type: string; + message: string; + data?: Record; +} + +type ProgressCallback = (progress: ProgressResp) => void; + +interface PluginData { + pluginName: string; + pluginTitle: string; + pluginVersion: string; + folderName: string; + artifacthubURL: string; + repoName: string; + author: string; + artifacthubVersion: string; +} + +class PluginManager { + /** + * Installs a plugin from the specified URL. + * @param {string} URL - The URL of the plugin to install. + * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin will be installed. + * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. + * @param {function} [progressCallback=null] - Optional callback for progress updates. + * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. + * @returns {Promise} A promise that resolves when the installation is complete. + */ + static async install( + URL, + destinationFolder = defaultPluginsDir(), + headlampVersion = '', + progressCallback: null | ProgressCallback = null, + signal: AbortSignal | null = null, + ) { + try { + const [name, tempFolder] = await downloadExtractPlugin( + URL, + headlampVersion, + progressCallback, + signal, + ); + + // sleep(2000); // comment out for testing + + // create the destination folder if it doesn't exist + if (!fs.existsSync(destinationFolder)) { + fs.mkdirSync(destinationFolder, { recursive: true }); + } + // move the plugin to the destination folder + fs.renameSync(tempFolder, path.join(destinationFolder, path.basename(name))); + if (progressCallback) { + progressCallback({ type: 'success', message: 'Plugin Installed' }); + } + } catch (e) { + if (progressCallback) { + progressCallback({ type: 'error', message: e.message }); + } else { + throw e; + } + } + } + + // progress function type that takes ProgressResp as argument and returns void + // type ProgressCallback = (progress: ProgressResp) => void; + /** + * Updates an installed plugin to the latest version. + * @param {string} pluginName - The name of the plugin to update. + * @param {string} [destinationFolder=defaultPluginsDir()] - The folder where the plugin is installed. + * @param {string} [headlampVersion=""] - The version of Headlamp for compatibility checking. + * @param {null | ProgressCallback} [progressCallback=null] - Optional callback for progress updates. + * @param {AbortSignal} [signal=null] - Optional AbortSignal for cancellation. + * @returns {Promise} A promise that resolves when the update is complete. + */ + static async update( + pluginName, + destinationFolder = defaultPluginsDir(), + headlampVersion = '', + progressCallback: null | ProgressCallback = null, + signal: AbortSignal | null = null, + ): Promise { + try { + // @todo: should list call take progressCallback? + const installedPlugins = PluginManager.list(destinationFolder); + if (!installedPlugins) { + throw new Error('InstalledPlugins not found'); + } + const plugin = installedPlugins.find(p => p.pluginName === pluginName); + if (!plugin) { + throw new Error('Plugin not found'); + } + + const pluginDir = path.join(destinationFolder, plugin.folderName); + // read the package.json of the plugin + const packageJsonPath = path.join(pluginDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + const pluginData = await fetchPluginInfo(plugin.artifacthubURL, progressCallback, signal); + + const latestVersion = pluginData.version; + const currentVersion = packageJson.artifacthub.version; + + if (semver.lte(latestVersion, currentVersion)) { + throw new Error('No updates available'); + } + + // eslint-disable-next-line no-unused-vars + const [_, tempFolder] = await downloadExtractPlugin( + plugin.artifacthubURL, + headlampVersion, + progressCallback, + signal, + ); + + // sleep(2000); // comment out for testing + + // create the destination folder if it doesn't exist + if (!fs.existsSync(destinationFolder)) { + fs.mkdirSync(destinationFolder, { recursive: true }); + } + + // remove the existing plugin folder + fs.rmdirSync(pluginDir, { recursive: true }); + + // create the plugin folder + fs.mkdirSync(pluginDir, { recursive: true }); + + // move the plugin to the destination folder + fs.renameSync(tempFolder, pluginDir); + if (progressCallback) { + progressCallback({ type: 'success', message: 'Plugin Updated' }); + } + } catch (e) { + if (progressCallback) { + progressCallback({ type: 'error', message: e.message }); + } else { + throw e; + } + } + } + + /** + * Uninstalls a plugin from the specified folder. + * @param {string} name - The name of the plugin to uninstall. + * @param {string} [folder=defaultPluginsDir()] - The folder where the plugin is installed. + * @param {function} [progressCallback=null] - Optional callback for progress updates. + * @returns {void} + */ + static uninstall( + name, + folder = defaultPluginsDir(), + progressCallback: null | ProgressCallback = null, + ) { + try { + // @todo: should list call take progressCallback? + const installedPlugins = PluginManager.list(folder); + if (!installedPlugins) { + throw new Error('InstalledPlugins not found'); + } + const plugin = installedPlugins.find(p => p.pluginName === name); + if (!plugin) { + throw new Error('Plugin not found'); + } + + const pluginDir = path.join(folder, plugin.folderName); + if (!checkValidPluginFolder(pluginDir)) { + throw new Error('Invalid plugin folder'); + } + + if (fs.existsSync(pluginDir)) { + fs.rmdirSync(pluginDir, { recursive: true }); + } else { + throw new Error('Plugin not found'); + } + if (progressCallback) { + progressCallback({ type: 'success', message: 'Plugin Uninstalled' }); + } + } catch (e) { + if (progressCallback) { + progressCallback({ type: 'error', message: e.message }); + } else { + throw e; + } + } + } + + /** + * Lists all valid plugins in the specified folder. + * @param {string} [folder=defaultPluginsDir()] - The folder to list plugins from. + * @param {function} [progressCallback=null] - Optional callback for progress updates. + * @returns {Array} An array of objects representing valid plugins. + */ + static list(folder = defaultPluginsDir(), progressCallback: null | ProgressCallback = null) { + try { + const pluginsData: PluginData[] = []; + + // Read all entries in the specified folder + const entries = fs.readdirSync(folder, { withFileTypes: true }); + + // Filter out directories (plugins) + const pluginFolders = entries.filter(entry => entry.isDirectory()); + + // Iterate through each plugin folder + for (const pluginFolder of pluginFolders) { + const pluginDir = path.join(folder, pluginFolder.name); + + if (checkValidPluginFolder(pluginDir)) { + // Read package.json to get the plugin name and version + const packageJsonPath = path.join(pluginDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const pluginName = packageJson.name || pluginFolder.name; + const pluginTitle = packageJson.artifacthub.title; + const pluginVersion = packageJson.version || null; + const artifacthubURL = packageJson.artifacthub ? packageJson.artifacthub.url : null; + const repoName = packageJson.artifacthub ? packageJson.artifacthub.repoName : null; + const author = packageJson.artifacthub ? packageJson.artifacthub.author : null; + const artifacthubVersion = packageJson.artifacthub + ? packageJson.artifacthub.version + : null; + // Store plugin data (folder name and plugin name) + pluginsData.push({ + pluginName, + pluginTitle, + pluginVersion, + folderName: pluginFolder.name, + artifacthubURL: artifacthubURL, + repoName: repoName, + author: author, + artifacthubVersion: artifacthubVersion, + }); + } + } + + if (progressCallback) { + progressCallback({ type: 'success', message: 'Plugins Listed', data: pluginsData }); + } else { + return pluginsData; + } + } catch (e) { + if (progressCallback) { + progressCallback({ type: 'error', message: e.message }); + } else { + throw e; + } + } + } +} + +/** + * Checks the plugin name is a valid one. + * + * Look for "..", "/", or "\" in the plugin name. + * + * @param {string} pluginName + * + * @returns true if the name is valid. + */ +function validatePluginName(pluginName) { + const invalidPattern = /[\/\\]|(\.\.)/; + return !invalidPattern.test(pluginName); +} + +/** + * @param {string} archiveURL - the one to validate + * @returns true if the archiveURL looks good. + */ +function validateArchiveURL(archiveURL) { + return ( + archiveURL.startsWith('https://artifacthub.io/packages/') || + archiveURL.startsWith('https://github.com/yolossn/headlamp-plugins/') + ); +} + +/** + * Downloads and extracts a plugin from the specified URL. + * @param {string} URL - The URL of the plugin to download and extract. + * @param {string} headlampVersion - The version of Headlamp for compatibility checking. + * @param {function} progressCallback - A callback function for reporting progress. + * @param {AbortSignal} signal - An optional AbortSignal for cancellation. + * @returns {Promise<[string, string]>} A promise that resolves to an array containing the plugin name and temporary folder path. + */ +async function downloadExtractPlugin(URL, headlampVersion, progressCallback, signal) { + // fetch plugin metadata + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + const pluginInfo = await fetchPluginInfo(URL, progressCallback, signal); + // await sleep(4000); // comment out for testing + + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + if (progressCallback) { + progressCallback({ type: 'info', message: 'Plugin Metadata Fetched' }); + } + const pluginName = pluginInfo.name; + if (!validatePluginName(pluginName)) { + throw new Error('Invalid plugin name'); + } + + const archiveURL = pluginInfo.data['headlamp/plugin/archive-url']; + if (!validateArchiveURL(archiveURL)) { + throw new Error('Invalid plugin/archive-url'); + } + + let checksum = pluginInfo.data['headlamp/plugin/archive-checksum']; + if (!archiveURL || !checksum) { + throw new Error('Invalid plugin metadata. Please check the plugin details.'); + } + if (checksum.startsWith('sha256:') || checksum.startsWith('SHA256:')) { + checksum = checksum.replace('sha256:', ''); + checksum = checksum.replace('SHA256:', ''); + } + + // check if the plugin is compatible with the current Headlamp version + if (headlampVersion) { + if (progressCallback) { + progressCallback({ type: 'info', message: 'Checking compatibility with Headlamp version' }); + } + if (semver.satisfies(headlampVersion, pluginInfo.data['headlamp/plugin/version-compat'])) { + if (progressCallback) { + progressCallback({ type: 'info', message: 'Headlamp version is compatible' }); + } + } else { + throw new Error('Headlamp version is not compatible with the plugin'); + } + } + + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + + const tempDir = await fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-plugin-temp-')); + const tempFolder = fs.mkdirSync(path.join(tempDir, pluginName), { recursive: true }); + + if (progressCallback) { + progressCallback({ type: 'info', message: 'Downloading Plugin' }); + } + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + + // await sleep(4000); // comment out for testing + const archResponse = await fetch(archiveURL, { redirect: 'follow', signal }); + if (!archResponse.ok) { + throw new Error(`Failed to download tarball. Status code: ${archResponse.status}`); + } + + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + + if (progressCallback) { + progressCallback({ type: 'info', message: 'Plugin Downloaded' }); + } + + const archChunks: Uint8Array[] = []; + let archBufferLengeth = 0; + + if (!archResponse.body) { + throw new Error('Download empty'); + } + + for await (const chunk of archResponse.body) { + archChunks.push(chunk); + archBufferLengeth += chunk.length; + } + + const archBuffer = Buffer.concat(archChunks, archBufferLengeth); + + const archiveChecksum = crypto.createHash('sha256').update(archBuffer).digest('hex'); + + if (archiveChecksum !== checksum) { + throw new Error('Checksum mismatch.'); + } + + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + + if (progressCallback) { + progressCallback({ type: 'info', message: 'Extracting Plugin' }); + } + const archStream = new stream.PassThrough(); + archStream.end(archBuffer); + + const extractStream: stream.Writable = archStream.pipe(zlib.createGunzip()).pipe( + tar.extract({ + cwd: tempFolder, + strip: 1, + sync: true, + }) as unknown as stream.Writable, + ); + + await new Promise((resolve, reject) => { + extractStream.on('finish', () => { + resolve(); + }); + extractStream.on('error', err => { + reject(err); + }); + }); + + if (signal && signal.aborted) { + throw new Error('Download cancelled'); + } + + if (progressCallback) { + progressCallback({ type: 'info', message: 'Plugin Extracted' }); + } + // add artifacthub metadata to the plugin + const packageJSON = JSON.parse(fs.readFileSync(`${tempFolder}/package.json`, 'utf8')); + packageJSON.artifacthub = { + name: pluginName, + title: pluginInfo.display_name, + url: `https://artifacthub.io/packages/headlamp/${pluginInfo.repository.name}/${pluginName}`, + version: pluginInfo.version, + repoName: pluginInfo.repository.name, + author: pluginInfo.repository.user_alias, + }; + packageJSON.isManagedByHeadlampPlugin = true; + fs.writeFileSync(`${tempFolder}/package.json`, JSON.stringify(packageJSON, null, 2)); + return [pluginName, tempFolder]; +} + +/** + * Fetches plugin metadata from the specified URL. + * @param {string} URL - The URL to fetch plugin metadata from. + * @param {function} progressCallback - A callback function for reporting progress. + * @param {AbortSignal} signal - An optional AbortSignal for cancellation. + * @returns {Promise} A promise that resolves to the fetched plugin metadata. + */ +async function fetchPluginInfo(URL, progressCallback, signal) { + try { + if (!URL.startsWith('https://artifacthub.io/packages/headlamp/')) { + throw new Error('Invalid URL. Please provide a valid URL from ArtifactHub.'); + } + + const apiURL = URL.replace( + 'https://artifacthub.io/packages/headlamp/', + 'https://artifacthub.io/api/v1/packages/headlamp/', + ); + + if (progressCallback) { + progressCallback({ type: 'info', message: 'Fetching Plugin Metadata' }); + } + const response = await fetch(apiURL, { redirect: 'follow', signal }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.json(); + } catch (e) { + if (progressCallback) { + progressCallback({ type: 'error', message: e.message }); + } else { + throw e; + } + } +} + +/** + * Checks if a given folder is a valid Headlamp plugin folder. + * A valid plugin folder must exist, contain 'main.js' and 'package.json' files, + * and the 'package.json' file must have 'isManagedByHeadlampPlugin' set to true. + * + * @param {string} folder - The path to the folder to check. + * @returns {boolean} True if the folder is a valid Headlamp plugin folder, false otherwise. + */ +function checkValidPluginFolder(folder) { + if (!fs.existsSync(folder)) { + return false; + } + // Check if the folder contains main.js and package.json + const mainJsPath = path.join(folder, 'main.js'); + const packageJsonPath = path.join(folder, 'package.json'); + if (!fs.existsSync(mainJsPath) || !fs.existsSync(packageJsonPath)) { + return false; + } + + // Read package.json and check isManagedByHeadlampPlugin is set to true + const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (packageJSON.isManagedByHeadlampPlugin) { + return true; + } + return false; +} + +/** + * Returns the default directory where Headlamp plugins are installed. + * If the data path exists, it is used as the base directory. + * Otherwise, the config path is used as the base directory. + * The 'plugins' subdirectory of the base directory is returned. + * + * @returns {string} The path to the default plugins directory. + */ +function defaultPluginsDir() { + let baseDir; + + switch (process.platform) { + case 'win32': + baseDir = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + break; + case 'darwin': + baseDir = path.join(os.homedir(), 'Library', 'Application Support'); + break; + default: + baseDir = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + } + + const configDirPath = path.join(baseDir, 'Headlamp'); + const dataDir = fs.existsSync(configDirPath) ? configDirPath : baseDir; + + return path.join(dataDir, 'plugins'); +} + +export { PluginManager }; diff --git a/app/package-lock.json b/app/package-lock.json index deafb0199e6..6f5dcbf9c88 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -16,6 +16,7 @@ "i18next": "^23.12.1", "i18next-fs-backend": "^2.3.1", "mkdirp": "^3.0.1", + "tar": "^7.4.0", "yargs": "^17.7.2" }, "devDependencies": { @@ -2123,6 +2124,15 @@ "node": ">=8" } }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/@electron/get/node_modules/got": { "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", @@ -6286,15 +6296,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/eol": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", @@ -12170,6 +12171,12 @@ "responselike": "^2.0.0" } }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true + }, "got": { "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", @@ -15288,12 +15295,6 @@ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", "dev": true }, - "env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true - }, "eol": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", diff --git a/app/package.json b/app/package.json index 20ecaecfe73..110ea019932 100644 --- a/app/package.json +++ b/app/package.json @@ -118,7 +118,8 @@ "electron/locales/", "electron/i18next.config.js", "electron/i18n-helper.js", - "electron/windowSize.js" + "electron/windowSize.js", + "electron/plugin-management.js" ], "extraResources": [ { @@ -171,6 +172,7 @@ "i18next": "^23.12.1", "i18next-fs-backend": "^2.3.1", "mkdirp": "^3.0.1", + "tar": "^7.4.0", "yargs": "^17.7.2" } }