diff --git a/src/backend/app-management/electron/window-management.js b/src/backend/app-management/electron/window-management.js index 619044ee6..69785f46e 100644 --- a/src/backend/app-management/electron/window-management.js +++ b/src/backend/app-management/electron/window-management.js @@ -738,6 +738,7 @@ frontendCommunicator.onAsync("main-window-ready", async () => { if (initialLoadComplete !== true) { const { PluginManager } = require("../../plugins/plugin-manager"); await PluginManager.startPlugins(); + PluginManager.startCommunityPluginUpdateCheck(); const { EventManager } = require("../../events/event-manager"); EventManager.triggerEvent("firebot", "firebot-started", { diff --git a/src/backend/plugins/executors/legacy-startup-script-executor.ts b/src/backend/plugins/executors/legacy-startup-script-executor.ts index 04f95471a..8f4d7b1e3 100644 --- a/src/backend/plugins/executors/legacy-startup-script-executor.ts +++ b/src/backend/plugins/executors/legacy-startup-script-executor.ts @@ -150,8 +150,7 @@ export class LegacyStartUpScript extends IPluginExecutor { author: manifest.author, name: manifest.name, description: manifest.description, - website: manifest.website, - initBeforeShowingParams: manifest.initBeforeShowingParams + website: manifest.website }, parametersSchema: parametersArray }; diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index 650dd939e..437220b76 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -1,12 +1,16 @@ import path from "path"; import { promises as fsp, existsSync, readFileSync } from "fs"; import Module from "module"; -import { randomUUID } from "crypto"; +import { randomUUID, createHash } from "crypto"; +import { app } from "electron"; import type { InstalledPlugin, InstalledPluginConfig, LegacyCustomScript, + ManagedPlugin, + ManagedPluginExtended, + ManagedPluginUpdateRequest, Manifest, PluginBase, PluginContext, @@ -35,6 +39,9 @@ import { PluginExecutionResult } from "./executors/plugin-executor.interface"; import { buildPluginApi, createPluginApiContext } from "./plugin-api"; +import { resolvePluginManifestLinks } from "./plugin-manifest-utils"; +import { compareVersions, parseVersion, UpdateType } from "../../shared/compare-versions"; +import { meetsFirebotVersionRequirement } from "../utils"; type LoadedPlugin = PluginBase | LegacyCustomScript; type AnyPluginExecutor = IPluginExecutor; @@ -72,12 +79,25 @@ type GetPluginDetailsResult = { error: string; }; +type PluginInstallResult = { + success: true; + installedPlugin: InstalledPlugin; +} | { + success: false; + error: string; +}; + +const COMMUNITY_PLUGIN_SERVICE_ROOT_URL = "https://api.crowbar.tools/v1/plugins/"; + class PluginManager { private _logger = LoggerCache.getLogger("Plugins"); private startingPlugins: Map> = new Map(); private activePlugins: Record = {}; + private updateCheckInterval: NodeJS.Timeout; + private pendingUpdates: Record = {}; + private pendingApiInstances: Map = new Map(); private requireInterceptorInstalled = false; @@ -115,9 +135,9 @@ class PluginManager { ); frontendCommunicator.onAsync("plugin-manager:save-config", - async ({ pluginConfig, isNewInstall = false }: { pluginConfig: InstalledPluginConfig, isNewInstall?: boolean }) => { + async ({ pluginConfig }: { pluginConfig: InstalledPluginConfig }) => { const newConfig = PluginConfigManager.saveItem(pluginConfig); - await this.reloadPluginConfig(newConfig, isNewInstall); + await this.reloadPluginConfig(newConfig); return newConfig; } ); @@ -138,13 +158,6 @@ class PluginManager { } ); - frontendCommunicator.onAsync("plugin-manager:cancel-install", - async (data: { fileName: string }) => { - await this.cancelInstall(data?.fileName); - return true; - } - ); - frontendCommunicator.onAsync("plugin-manager:set-enabled", async (data: { id: string, enabled: boolean }) => { await this.setPluginEnabled(data?.id, data?.enabled === true); @@ -159,6 +172,24 @@ class PluginManager { return this.deletePlugin(id, deletePluginFile); } ); + + frontendCommunicator.onAsync("plugin-manager:search-community-plugins", + async (query: string) => { + return await this.searchForCommunityPlugins(query); + } + ); + + frontendCommunicator.onAsync("plugin-manager:install-community-plugin", + async (pluginDetails: ManagedPluginExtended) => { + return await this.installCommunityPlugin(pluginDetails); + } + ); + + frontendCommunicator.onAsync("plugin-manager:update-community-plugin", + async (pluginId: string) => { + return await this.updateCommunityPlugin(pluginId); + } + ); } async migrateLegacyStartUpScriptsToPlugins() { @@ -239,6 +270,7 @@ class PluginManager { async triggerUiRefresh(): Promise { this._logger.debug("Triggering UI refresh"); frontendCommunicator.send("plugin-manager:all-plugins", await this.getInstalledPlugins()); + frontendCommunicator.send("plugin-manager:community-plugin-updates", this.pendingUpdates); } // #region Plugin lifecycle @@ -414,7 +446,7 @@ class PluginManager { * Handle a config change. Starts/stops as needed, and on a still-enabled plugin * either re-loads (if file may have changed) or invokes onParameterUpdate. */ - async reloadPluginConfig(pluginConfig: InstalledPluginConfig, isNewInstall = false): Promise { + async reloadPluginConfig(pluginConfig: InstalledPluginConfig): Promise { const active = this.activePlugins[pluginConfig.id]; // Disabled now -> stop if running @@ -427,7 +459,7 @@ class PluginManager { // Enabled, not yet running -> start if (!active) { - await this.startPlugin(pluginConfig, isNewInstall); + await this.startPlugin(pluginConfig, false); return; } @@ -551,7 +583,7 @@ class PluginManager { async installPluginFromPath( sourcePath: string, overwrite = false - ): Promise { + ): Promise { if (!sourcePath || typeof sourcePath !== "string") { return { success: false, error: "Invalid file path." }; } @@ -568,61 +600,68 @@ class PluginManager { const destFolder = ProfileManager.getPathInProfile("/scripts"); const destPath = path.resolve(destFolder, fileName); + let details: GetPluginDetailsResult; + // If the selected file is already inside the scripts folder, // there's nothing to do - just validate and return details. const sourceIsInScriptsFolder = path.resolve(sourcePath) === destPath; if (sourceIsInScriptsFolder) { - return this.getPluginDetailsByFileName(fileName); - } + details = await this.getPluginDetailsByFileName(fileName); + } else { + if (existsSync(destPath) && !overwrite) { + return { success: false, error: `A plugin named '${fileName}' already exists in the scripts folder.`, conflict: true }; + } - if (existsSync(destPath) && !overwrite) { - return { success: false, error: `A plugin named '${fileName}' already exists in the scripts folder.`, conflict: true }; - } + // copy then load, and if it doesn't validate, remove the copy. + try { + await fsp.mkdir(destFolder, { recursive: true }); + await fsp.copyFile(sourcePath, destPath); + } catch (error) { + return { success: false, error: `Failed to copy plugin: ${(error as Error).message}` }; + } - // copy then load, and if it doesn't validate, remove the copy. - try { - await fsp.mkdir(destFolder, { recursive: true }); - await fsp.copyFile(sourcePath, destPath); - } catch (error) { - return { success: false, error: `Failed to copy plugin: ${(error as Error).message}` }; + details = await this.getPluginDetailsByFileName(fileName); + if (details.success === false) { + try { + await fsp.unlink(destPath); + } catch { + // best-effort + } + return details; + } } - const details = await this.getPluginDetailsByFileName(fileName); - if (details.success === false) { - try { - await fsp.unlink(destPath); - } catch { - // best-effort + if (details.success === true) { + const defaultParams: Record = {}; + for (const param of details.details.parametersSchema ?? []) { + defaultParams[param.name] = param.default; } - return details; - } + const installedPluginConfig: InstalledPluginConfig = { + id: randomUUID(), + fileName, + enabled: true, + parameters: defaultParams + }; - return details; - } + PluginConfigManager.saveItem(installedPluginConfig); - /** - * Delete a copied plugin file that the user cancelled installing, but only - * when no config currently references it. - */ - async cancelInstall(fileName: string): Promise { - if (!fileName) { - return; - } - const referenced = PluginConfigManager.getAllItems().some(c => c.fileName === fileName); - if (referenced) { - return; - } - const filePath = this.getPluginFilePath(fileName); - try { - if (existsSync(filePath)) { - await fsp.unlink(filePath); - } - delete require.cache[require.resolve(filePath)]; - } catch (error) { - this._logger.warn(`Failed to delete cancelled install ${fileName}`, error); + await this.startPlugin(installedPluginConfig, true); + + void this.triggerUiRefresh(); + + return { + success: true, + installedPlugin: { + config: installedPluginConfig, + details: details.details + } + }; } - void this.triggerUiRefresh(); + return { + success: false, + error: "Failed to install plugin. Check the log for more details." + }; } /** @@ -890,6 +929,10 @@ class PluginManager { // #region Internals private getPluginFilePath(fileName: string): string { + if (fileName.startsWith(`plugins${path.sep}`)) { + return path.resolve(ProfileManager.getPathInProfile(fileName)); + } + const scriptsFolder = ProfileManager.getPathInProfile("/scripts"); return path.resolve(scriptsFolder, fileName); } @@ -914,6 +957,7 @@ class PluginManager { this.requireInterceptorInstalled = true; const scriptsFolder = path.resolve(ProfileManager.getPathInProfile("/scripts")); + const pluginsFolder = path.resolve(ProfileManager.getPathInProfile("/plugins")); type LoadFn = (request: string, parent?: NodeJS.Module, isMain?: boolean) => unknown; const nodeModule = Module as unknown as { _load: LoadFn }; @@ -930,12 +974,20 @@ class PluginManager { } const parentPath = parent?.filename ? path.resolve(parent.filename) : null; - if (!parentPath || !parentPath.startsWith(scriptsFolder + path.sep)) { + if (!parentPath + || (!parentPath.startsWith(scriptsFolder + path.sep) + && !parentPath.startsWith(pluginsFolder + path.sep)) + ) { // require("@crowbartools/firebot-types") from something other than a custom script - deny. return {}; } - const fileName = path.basename(parentPath); + let fileName = path.basename(parentPath); + + // Community plugins install to a separate nested file path + if (parentPath.startsWith(pluginsFolder + path.sep)) { + fileName = path.join("plugins", parentPath.replace(pluginsFolder + path.sep, "")); + } const instance = manager.getActivePluginByFileName(fileName)?.apiInstance ?? manager.pendingApiInstances.get(fileName); @@ -1016,6 +1068,380 @@ class PluginManager { } // #endregion + + // #region Managed (Community) Plugins + + private async searchForCommunityPlugins(query: string): Promise { + const plugins: ManagedPluginExtended[] = []; + + try { + const firebotVersionString = app.getVersion(); + const firebotVersion = parseVersion(firebotVersionString); + const body = { + query, + firebotVersion + }; + + const response = await fetch(`${COMMUNITY_PLUGIN_SERVICE_ROOT_URL}search`, { + method: "POST", + body: JSON.stringify(body), + headers: { + "User-Agent": `Firebot/${firebotVersionString}`, + "Content-Type": "application/json" + } + }); + + if (response.ok) { + const pluginSearchResults = await response.json() as ManagedPlugin[]; + + for (const result of pluginSearchResults) { + const installedPlugin = PluginConfigManager.getAllItems().find(c => + c.managedPluginDetails?.author === result.author + && c.managedPluginDetails?.name === result.name + ); + + plugins.push({ + ...result, + installed: installedPlugin?.managedPluginDetails?.version != null, + installedVersion: installedPlugin?.managedPluginDetails?.version + }); + } + } else { + const responseBody = await response.text(); + + this._logger.error(`Failed to search community plugins for "${query}". Response: ${responseBody}`); + frontendCommunicator.send("showToast", { + content: "Failed to search for community plugins. Check the log for more details.", + className: "warning" + }); + } + } catch (error) { + this._logger.error(`Failed to search community plugins for "${query}"`, error); + frontendCommunicator.send("showToast", { + content: "Failed to search for community plugins. Check the log for more details.", + className: "warning" + }); + } + + for (const plugin of plugins) { + plugin.manifest = resolvePluginManifestLinks(plugin.manifest); + } + + return plugins; + } + + private async downloadAndSaveCommunityPlugin( + plugin: ManagedPlugin + ): Promise<{ success: boolean, path?: string }> { + const result = { + success: false, + path: undefined + }; + + try { + // Download the file + const downloadResponse = await fetch(plugin.manifest.downloadUrl); + + if (!downloadResponse.ok) { + const downloadResponseBody = await downloadResponse.text(); + this._logger.error(`Unable to download plugin file. Response: ${downloadResponseBody}`); + return { success: false }; + } + + const fileContents = await downloadResponse.bytes(); + + // Verify the SHA + const hash = createHash("sha256").update(fileContents).digest("hex"); + if (hash.toLowerCase() !== plugin.manifest.sha256.toLowerCase()) { + this._logger.error("Downloaded plugin signature doesn't match plugin manifest"); + return { success: false }; + } + + const pluginRelativePath = path.join( + "plugins", + plugin.author, + plugin.name, + plugin.version + ); + const destFolder = ProfileManager.getPathInProfile(pluginRelativePath); + + switch (plugin.manifest.type) { + case "single-file": { + await fsp.mkdir(destFolder, { recursive: true }); + const destPath = path.resolve(destFolder, "plugin.js"); + + await fsp.writeFile(destPath, fileContents); + result.success = true; + result.path = path.join(pluginRelativePath, "plugin.js"); + break; + } + + case "zip": //TODO + default: + this._logger.warn(`Unknown plugin type "${plugin.manifest.type}"`); + return result; + } + } catch (error) { + this._logger.error("Failed to save community plugin", error); + return result; + } + + return result; + } + + private async installCommunityPlugin( + plugin: ManagedPluginExtended + ): Promise { + // Ensure we have data + if (plugin == null) { + const errorMessage = "No community plugin details provided for install"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + // Make sure it's not already installed + if (plugin.installed === true) { + const errorMessage = `Plugin ${plugin.manifest.name} is already installed (currently v${plugin.installedVersion})`; + this._logger.warn(errorMessage); + return { success: false, error: errorMessage }; + } + + try { + // Check that plugin meets Firebot version spec + const currentFirebotVersion = parseVersion(app.getVersion()); + const versionPassed = meetsFirebotVersionRequirement( + currentFirebotVersion, + plugin.manifest.minimumFirebotVersion, + plugin.manifest.maximumFirebotVersion + ); + + if (versionPassed !== true) { + const errorMessage = "Plugin is not designed to work with this version of Firebot"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + // Download and save the file + const saveResult = await this.downloadAndSaveCommunityPlugin(plugin); + + if (saveResult.success !== true) { + return { success: false, error: "Failed to download or save plugin" }; + } + + // Grab the plugin details + const pluginDetails = await this.getPluginDetailsByFileName(saveResult.path, "plugin"); + if (pluginDetails.success !== true) { + const errorMessage = "Failed to load plugin details"; + this._logger.error(errorMessage); + + try { + await fsp.rm(this.getPluginFilePath(saveResult.path), { force: true }); + } catch { } + + return { success: false, error: errorMessage }; + } + + const defaultParams: Record = {}; + for (const param of pluginDetails.details.parametersSchema ?? []) { + defaultParams[param.name] = param.default; + } + + // And finally, save and start the plugin + const installedPluginConfig: InstalledPluginConfig = { + id: randomUUID(), + enabled: true, + fileName: saveResult.path, + parameters: defaultParams, + managedPluginDetails: { + author: plugin.author, + name: plugin.name, + version: plugin.version + } + }; + PluginConfigManager.saveItem(installedPluginConfig); + + await this.startPlugin(installedPluginConfig, true); + + void this.triggerUiRefresh(); + + return { + success: true, + installedPlugin: { + config: installedPluginConfig, + details: pluginDetails.details + } + }; + } catch (error) { + this._logger.error(`Failed to install community plugin "${plugin.author}:${plugin.name}"`, error); + return { success: false, error: "Installation failed. Check the log for more info." }; + } + } + + private async updateCommunityPlugin( + pluginId: string + ): Promise { + // Make sure existing plugin is valid + const installedPluginConfig = PluginConfigManager.getItem(pluginId); + + if (installedPluginConfig == null) { + const errorMessage = `Plugin ${pluginId} not found`; + this._logger.warn(errorMessage); + return { success: false, error: errorMessage }; + } + + if (installedPluginConfig.managedPluginDetails == null + || !installedPluginConfig.managedPluginDetails.author?.length + || !installedPluginConfig.managedPluginDetails.name?.length + || !installedPluginConfig.managedPluginDetails.version?.length + ) { + const errorMessage = `Plugin ${pluginId} is not a community plugin`; + this._logger.warn(errorMessage); + return { success: false, error: errorMessage }; + } + + // Ensure we have data + const pluginUpdate = this.pendingUpdates[pluginId]; + if (pluginUpdate == null) { + const errorMessage = "No update available for community plugin"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + // Ensure it's the same plugin + if (pluginUpdate.author !== installedPluginConfig.managedPluginDetails.author + || pluginUpdate.name !== installedPluginConfig.managedPluginDetails.name + ) { + const errorMessage = "Update does not match installed plugin"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + // Ensure it's actually an upgrade + const updateType = compareVersions(pluginUpdate.version, installedPluginConfig.managedPluginDetails.version); + if (updateType === UpdateType.NONE || updateType === UpdateType.PREVIOUS_VERSION) { + const errorMessage = "Installed plugin version is already up-to-date"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + try { + // Download and save the update + const saveResult = await this.downloadAndSaveCommunityPlugin(pluginUpdate); + + if (saveResult.success !== true) { + return { success: false, error: "Failed to download or save plugin update" }; + } + + // Grab the update details + const pluginDetails = await this.getPluginDetailsByFileName(saveResult.path, "plugin"); + if (pluginDetails.success !== true) { + const errorMessage = "Failed to load plugin details"; + this._logger.error(errorMessage); + + try { + await fsp.rm(this.getPluginFilePath(saveResult.path), { force: true }); + } catch { } + + return { success: false, error: errorMessage }; + } + + // Stop the plugin, update the config with the new details, and restart it + await this.stopPlugin(pluginId, false); + + installedPluginConfig.fileName = saveResult.path; + installedPluginConfig.managedPluginDetails.version = pluginUpdate.version; + PluginConfigManager.saveItem(installedPluginConfig); + + await this.startPlugin(installedPluginConfig, false); + + delete this.pendingUpdates[pluginId]; + + void this.triggerUiRefresh(); + + return { + success: true, + installedPlugin: { + config: installedPluginConfig, + details: pluginDetails.details + } + }; + } catch (error) { + this._logger.error(`Failed to update community plugin "${pluginUpdate.author}:${pluginUpdate.name}"`, error); + return { success: false, error: "Update failed. Check the log for more info." }; + } + } + + startCommunityPluginUpdateCheck(): void { + void this.checkForCommunityPluginUpdates(); + + if (this.updateCheckInterval == null) { + this.updateCheckInterval = setInterval( + async () => await this.checkForCommunityPluginUpdates(), + 24 * 60 * 60 * 1000 // Every 24 hours + ); + } + } + + private async checkForCommunityPluginUpdates(): Promise { + this._logger.info("Checking for community plugin updates"); + const communityPlugins = PluginConfigManager.getAllItems() + .filter(p => p.managedPluginDetails != null) + .map(p => ({ + id: p.id, + details: p.managedPluginDetails + })); + + const firebotVersionString = app.getVersion(); + const updateRequest: ManagedPluginUpdateRequest = { + firebotVersion: parseVersion(firebotVersionString), + plugins: communityPlugins.map(p => p.details) + }; + + try { + const response = await fetch(`${COMMUNITY_PLUGIN_SERVICE_ROOT_URL}updates`, { + method: "POST", + body: JSON.stringify(updateRequest), + headers: { + "User-Agent": `Firebot/${firebotVersionString}`, + "Content-Type": "application/json" + } + }); + + if (!response.ok) { + const responseBody = await response.text(); + this._logger.error(`Failed to check for community plugin updates. Response: ${responseBody}`); + return; + } + + const availableUpdates = await response.json() as ManagedPlugin[]; + + for (const plugin of communityPlugins) { + const update = availableUpdates.find(p => + p.author === plugin.details.author + && p.name === plugin.details.name + ); + + if (update != null) { + this.pendingUpdates[plugin.id] = update; + } else { + delete this.pendingUpdates[plugin.id]; + } + } + + const updateCount = Object.keys(this.pendingUpdates).length; + if (updateCount > 0) { + this._logger.info(`Update check complete. ${updateCount} community plugin update${updateCount > 1 ? "s" : ""} available`); + } else { + this._logger.info("Update check complete. All community plugins up-to-date."); + } + } catch (error) { + this._logger.error("Unknown error checking for community plugin updates", error); + } + + void this.triggerUiRefresh(); + } + + // #endregion } const pluginManager = new PluginManager(); diff --git a/src/backend/utils/index.ts b/src/backend/utils/index.ts index bb15d2a4f..e2f1db016 100644 --- a/src/backend/utils/index.ts +++ b/src/backend/utils/index.ts @@ -71,4 +71,8 @@ export { export { getUrlRegex -} from "./url"; \ No newline at end of file +} from "./url"; + +export { + meetsFirebotVersionRequirement +} from "./versions"; \ No newline at end of file diff --git a/src/backend/utils/versions.ts b/src/backend/utils/versions.ts new file mode 100644 index 000000000..91db5a13f --- /dev/null +++ b/src/backend/utils/versions.ts @@ -0,0 +1,57 @@ +import type { ManifestFirebotVersion } from "../../types"; + +/** + * Checks whether a given Firebot version meets the provided minimum and/or maximum version spec + * @param current Current Firebot version + * @param min Minimum required Firebot version + * @param max Maximum compatible Firebot version + * @returns `true` if the supplied current version is within the constraints, or `false` if not + */ +export const meetsFirebotVersionRequirement = ( + current: ManifestFirebotVersion, + min?: ManifestFirebotVersion, + max?: ManifestFirebotVersion +): boolean => { + // No limits, all good + if (min == null && max == null) { + return true; + } + + if (min != null) { + if (current.major < min.major) { + return false; + } + + if (current.major === min.major) { + if (min.minor != null && (current.minor ?? 0) < min.minor) { + return false; + } + + if (min.minor != null && (current.minor ?? 0) === min.minor) { + if (min.patch != null && (current.patch ?? 0) < min.patch) { + return false; + } + } + } + } + + if (max != null) { + if (current.major > max.major) { + return false; + } + + if (current.major === max.major) { + if (max.minor != null && (current.minor ?? 0) > max.minor) { + return false; + } + + if (max.minor != null && (current.minor ?? 0) === max.minor) { + if (max.patch != null && (current.patch ?? 0) > max.patch) { + return false; + } + } + } + } + + return true; +}; \ No newline at end of file diff --git a/src/gui/app/directives/controls/plugin-update-indicator.js b/src/gui/app/directives/controls/plugin-update-indicator.js new file mode 100644 index 000000000..25f5cfffe --- /dev/null +++ b/src/gui/app/directives/controls/plugin-update-indicator.js @@ -0,0 +1,42 @@ +"use strict"; +(function() { + angular + .module("firebotApp") + .component("pluginUpdateIndicator", { + bindings: {}, + template: ` +
+ +
+ `, + controller: function(pluginsService, modalService) { + const ctrl = this; + + ctrl.pendingPluginUpdates = () => { + return Object.keys(pluginsService.pendingPluginUpdates).length; + }; + + ctrl.getTooltipText = () => { + const updatesPending = Object.keys(pluginsService.pendingPluginUpdates).length; + return `${updatesPending} plugin${updatesPending > 1 ? "s" : ""} ready to update`; + }; + + ctrl.showPluginManager = () => { + modalService.showModal({ + component: "pluginManagerModal", + size: "lg" + }); + }; + } + }); +}()); \ No newline at end of file diff --git a/src/gui/app/directives/misc/searchbar.js b/src/gui/app/directives/misc/searchbar.js index 5f37b51a1..b3a1b551d 100644 --- a/src/gui/app/directives/misc/searchbar.js +++ b/src/gui/app/directives/misc/searchbar.js @@ -7,11 +7,12 @@ bindings: { searchId: "@", placeholderText: "@", - query: "=" + query: "=", + debounce: "@?" }, template: `
- + - +
@@ -56,13 +56,7 @@ -
- Initializing plugin... -
-
- After installing this plugin, you'll be able to configure its settings. -
-
+
This plugin has no settings.
`, bindings: { @@ -89,52 +83,23 @@ const $ctrl = this; $ctrl.plugin = null; - $ctrl.isNewInstall = false; $ctrl.parameters = []; - $ctrl.initFirst = false; - $ctrl.isInitializing = false; - - function seedParameters() { - const schema = $ctrl.plugin.details && $ctrl.plugin.details.parametersSchema; - $ctrl.parameters = Array.isArray(schema) ? schema : []; - - // Seed any unset values from schema defaults - for (const p of $ctrl.parameters) { - if (!p || !p.name) { - continue; - } - if ($ctrl.plugin.config.parameters[p.name] === undefined && p.default !== undefined) { - $ctrl.plugin.config.parameters[p.name] = p.default; - } - } - } $ctrl.$onInit = function() { $ctrl.plugin = $ctrl.resolve.plugin; - $ctrl.isNewInstall = $ctrl.resolve.isNewInstall === true; if (!$ctrl.plugin.config.parameters) { $ctrl.plugin.config.parameters = {}; } - const manifest = ($ctrl.plugin.details && $ctrl.plugin.details.manifest) || {}; - - $ctrl.initFirst = $ctrl.isNewInstall && manifest.initBeforeShowingParams === true; - - seedParameters(); + const schema = $ctrl.plugin.details && $ctrl.plugin.details.parametersSchema; + $ctrl.parameters = Array.isArray(schema) ? schema : []; }; $ctrl.hasParameters = function() { return Array.isArray($ctrl.parameters) && $ctrl.parameters.length > 0; }; - $ctrl.primaryButtonLabel = function() { - if (!$ctrl.isNewInstall) { - return "Save"; - } - return $ctrl.initFirst ? "Install & Configure" : "Install"; - }; - $ctrl.hasLinks = function() { const manifest = ($ctrl.plugin && $ctrl.plugin.details && $ctrl.plugin.details.manifest) || {}; return !!(manifest.repo || manifest.website || manifest.support); @@ -147,38 +112,10 @@ }; $ctrl.save = function() { - if ($ctrl.isInitializing) { - return; - } - - // install & load the plugin, then show its params to the user - if ($ctrl.initFirst) { - $ctrl.isInitializing = true; - pluginsService.savePluginConfig($ctrl.plugin.config, true) - .then(() => { - const updated = pluginsService.getPluginById($ctrl.plugin.config.id); - if (updated && updated.details) { - $ctrl.plugin.details = updated.details; - } - $ctrl.isNewInstall = false; - $ctrl.initFirst = false; - $ctrl.isInitializing = false; - seedParameters(); - }) - .catch(() => { - $ctrl.isInitializing = false; - }); - return; - } - - pluginsService.savePluginConfig($ctrl.plugin.config, $ctrl.isNewInstall).then(() => { + pluginsService.savePluginConfig($ctrl.plugin.config).then(() => { $ctrl.close({ $value: { saved: true } }); }); }; - - $ctrl.cancel = function() { - $ctrl.dismiss(); - }; } }); -}()); +}()); \ No newline at end of file diff --git a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js new file mode 100644 index 000000000..d8fde0a17 --- /dev/null +++ b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js @@ -0,0 +1,247 @@ +"use strict"; + +(function() { + const { DateTime } = require("luxon"); + + angular + .module("firebotApp") + .component("installCommunityPluginModal", { + template: ` +
+ +
+ +
+
+ + `, + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: function( + $rootScope, + $scope, + pluginsService, + modalFactory, + modalService, + ngToast + ) { + const $ctrl = this; + + $ctrl.openLink = $rootScope.openLinkExternally; + + $ctrl.isSearching = false; + $ctrl.searchPerformed = false; + $ctrl.searchQuery = ""; + $ctrl.searchResults = []; + $ctrl.isInstallingPlugin = false; + + $ctrl.performSearch = async () => { + if (!$ctrl.searchQuery?.length) { + return; + } + + $ctrl.isSearching = true; + + const results = await pluginsService.searchCommunityPlugins($ctrl.searchQuery); + $ctrl.searchResults = results ?? []; + + $ctrl.searchPerformed = true; + $ctrl.isSearching = false; + }; + + $scope.$watch("$ctrl.searchQuery", (newValue, oldValue) => { + if (newValue !== oldValue) { + if (!!newValue?.length) { + $ctrl.performSearch(); + } else if (newValue === "") { + $ctrl.searchPerformed = false; + $ctrl.searchResults = []; + } + } + }); + + $ctrl.getPluginReleaseDate = (plugin) => { + if (plugin?.manifest?.releaseDate) { + const releaseDate = DateTime.fromISO(plugin.manifest.releaseDate); + return releaseDate.toFormat("MMMM d, yyyy"); + } + + return "Unknown"; + }; + + $ctrl.hasPluginLinks = function(plugin) { + const manifest = (plugin && plugin.manifest) || {}; + return !!(manifest.repo || manifest.website || manifest.support); + }; + + $ctrl.installCommunityPlugin = async (pluginDetails) => { + $ctrl.isInstallingPlugin = true; + pluginDetails.installing = true; + + const result = await pluginsService.installCommunityPlugin(pluginDetails); + + if (result.success === true) { + pluginDetails.installed = true; + pluginDetails.installedVersion = result.installedPlugin.config.managedPluginDetails.version; + + ngToast.create({ + className: "success", + content: `${result.installedPlugin.details.manifest.name} plugin installed!` + }); + + if (!!result.installedPlugin.details.parametersSchema?.length) { + modalService.showModal({ + component: "configurePluginModal", + size: "md", + resolveObj: { + plugin: () => result.installedPlugin + } + }); + } + } else { + modalFactory.showErrorModal(result.error); + } + + pluginDetails.installing = false; + $ctrl.isInstallingPlugin = false; + }; + } + }); +}()); \ No newline at end of file diff --git a/src/gui/app/directives/modals/plugins/plugin-manager-modal.js b/src/gui/app/directives/modals/plugins/plugin-manager-modal.js index 42f508456..d2a6612c8 100644 --- a/src/gui/app/directives/modals/plugins/plugin-manager-modal.js +++ b/src/gui/app/directives/modals/plugins/plugin-manager-modal.js @@ -12,9 +12,6 @@ - `, bindings: { resolve: "<", diff --git a/src/gui/app/directives/modals/plugins/removePluginModal.js b/src/gui/app/directives/modals/plugins/remove-plugin-modal.js similarity index 85% rename from src/gui/app/directives/modals/plugins/removePluginModal.js rename to src/gui/app/directives/modals/plugins/remove-plugin-modal.js index ec8a40837..25e85d160 100644 --- a/src/gui/app/directives/modals/plugins/removePluginModal.js +++ b/src/gui/app/directives/modals/plugins/remove-plugin-modal.js @@ -11,10 +11,11 @@
-
+
-
-

Plugins

-
Plugins are scripts loaded at startup that can register new effects, variables, events, and more.
+

Plugins Ready to Update

+
+ +
+
+ + +
+
+ + {{getPluginName(plugin)}} + +
+
+ Installed version: v{{plugin.details.manifest.version}} +
+
+ Available version: v{{pendingUpdateDetails(plugin).version}} +
+
+ +
+ + +
-
+
+
+ +
+
+

Installed Plugins

+
+
@@ -105,6 +165,10 @@ style="font-size: 12px;" ng-if="plugin.details.manifest.author" >by {{plugin.details.manifest.author}} + Community `, - controller: function($rootScope, $scope, settingsService, utilityService, - pluginsService, backendCommunicator, ngToast, $q, profileManager) { + controller: function($rootScope, $scope, settingsService, modalFactory, + modalService, pluginsService, backendCommunicator, ngToast, $q, + profileManager + ) { $scope.openLink = $rootScope.openLinkExternally; $scope.settings = settingsService; + $scope.installingUpdate = false; + pluginsService.loadPlugins(); $scope.getPluginName = (plugin) => { @@ -201,11 +269,22 @@ return pluginsService.getInstalledPlugins(); }; + $scope.getPluginsWithPendingUpdates = function() { + return pluginsService.getInstalledPlugins() + .filter(p => pluginsService.pendingPluginUpdates[p.config.id] != null); + }; + $scope.hasPluginLinks = function(plugin) { const manifest = (plugin && plugin.details && plugin.details.manifest) || {}; return !!(manifest.repo || manifest.website || manifest.support); }; + $scope.pendingUpdateDetails = (plugin) => { + return plugin?.config?.managedPluginDetails != null + ? pluginsService.pendingPluginUpdates[plugin.config.id] + : null; + }; + $scope.togglePluginEnabled = function(plugin) { const next = !(plugin.config.enabled !== false); plugin.config.enabled = next; @@ -213,27 +292,24 @@ }; $scope.configurePlugin = function(plugin) { - utilityService.showModal({ + modalService.showModal({ component: "configurePluginModal", size: "md", - backdrop: true, - keyboard: true, resolveObj: { - plugin: () => angular.copy(plugin), - isNewInstall: () => false - }, - closeCallback: () => pluginsService.loadPlugins() + plugin: () => angular.copy(plugin) + } }); }; $scope.removePlugin = function(plugin) { - utilityService.showModal({ + modalService.showModal({ component: "removePluginModal", size: "sm", backdrop: true, keyboard: true, resolveObj: { - pluginName: () => plugin.details.manifest.name || plugin.config.fileName + pluginName: () => plugin.details.manifest.name || plugin.config.fileName, + isManagedPlugin: () => plugin.config.managedPluginDetails != null }, closeCallback: (response) => { if (response && response.confirmed) { @@ -245,58 +321,35 @@ $scope.pluginMenuOptions = function(plugin) { const isEnabled = plugin.config.enabled !== false; - return [ + const isCommunityPlugin = plugin.config.managedPluginDetails != null; + + const options = [ { html: ` ${isEnabled ? 'Disable' : 'Enable'}`, click: () => { $scope.togglePluginEnabled(plugin); } - }, - { + } + ]; + + if (isCommunityPlugin !== true) { + options.push({ html: ` Update`, click: () => { $scope.updatePlugin(plugin); } - }, - { - html: ` Remove`, - click: () => { - $scope.removePlugin(plugin); - } - } - ]; - }; - - function openConfigureModalForInstall(details, fileName) { - const { randomUUID } = require("crypto"); - const skeleton = { - config: { - id: randomUUID(), - fileName: fileName, - enabled: true, - parameters: {} - }, - details: details - }; + }); + } - utilityService.showModal({ - component: "configurePluginModal", - size: "md", - backdrop: "static", - keyboard: false, - resolveObj: { - plugin: () => skeleton, - isNewInstall: () => true - }, - closeCallback: (result) => { - if (!result || !result.saved) { - // user cancelled - clean up the copied file - pluginsService.cancelInstall(fileName); - } - pluginsService.loadPlugins(); + options.push({ + html: ` Remove`, + click: () => { + $scope.removePlugin(plugin); } }); - } + + return options; + }; function doInstall(filePath, overwrite) { return $q.when(pluginsService.installPluginFromFile(filePath, overwrite)) @@ -306,7 +359,7 @@ return; } if (result.conflict) { - utilityService.showConfirmationModal({ + modalFactory.showConfirmationModal({ title: "Plugin File Already Exists", question: `${result.error} Overwrite it?`, confirmLabel: "Overwrite", @@ -319,14 +372,23 @@ return; } if (result.success === false) { - utilityService.showErrorModal(result.error || "Failed to install plugin."); + modalFactory.showErrorModal(result.error || "Failed to install plugin."); return; } - openConfigureModalForInstall(result.details, result.fileName); + + const pluginName = $scope.getPluginName(result.installedPlugin); + ngToast.create({ + className: "success", + content: `${pluginName} plugin installed!` + }); + + if (!!result.installedPlugin.details.parametersSchema?.length) { + $scope.configurePlugin(result.installedPlugin); + } }); } - $scope.installPlugin = function() { + $scope.installPluginFromFile = function() { $q.when(backendCommunicator.fireEventAsync("open-file-browser", { currentPath: profileManager.getPathInProfile("/scripts"), options: { @@ -342,6 +404,13 @@ }); }; + $scope.showCommunityPluginModal = () => { + modalService.showModal({ + component: "installCommunityPluginModal", + size: "lg" + }); + }; + function doUpdate(plugin, filePath, overwrite) { return $q.when(pluginsService.updatePluginFromFile(plugin.config.id, filePath, overwrite)) .then((result) => { @@ -350,7 +419,7 @@ return; } if (result.conflict) { - utilityService.showConfirmationModal({ + modalFactory.showConfirmationModal({ title: "Plugin File Already Exists", question: `${result.error} Overwrite it?`, confirmLabel: "Overwrite", @@ -363,7 +432,7 @@ return; } if (result.success === false) { - utilityService.showErrorModal(result.error || "Failed to update plugin."); + modalFactory.showErrorModal(result.error || "Failed to update plugin."); return; } pluginsService.loadPlugins(); @@ -392,6 +461,38 @@ doUpdate(plugin, response.path, false); }); }; + + $scope.updateCommunityPlugin = async (plugin) => { + if (!plugin?.config) { + return; + } + + const pluginUpdate = pluginsService.pendingPluginUpdates[plugin.config.id]; + if (!pluginUpdate) { + return; + } + + $scope.installingUpdate = true; + plugin.isInstallingUpdate = true; + + const result = await pluginsService.updateCommunityPlugin(plugin.config.id); + + plugin.isInstallingUpdate = false; + $scope.installingUpdate = false; + + if (result.success !== true) { + ngToast.create({ + className: "warn", + content: `Plugin update failed (${result.error}). Check log for more info.` + }); + return; + } + + ngToast.create({ + className: "success", + content: `${plugin.details.manifest.name} plugin updated to v${pluginUpdate.version}` + }); + }; } }); }()); diff --git a/src/gui/app/index.html b/src/gui/app/index.html index 2c1fb7cd6..9196a3b01 100644 --- a/src/gui/app/index.html +++ b/src/gui/app/index.html @@ -143,6 +143,7 @@

Firebot

" > +
{ installedPlugins = Array.isArray(plugins) ? plugins : []; - return installedPlugins; + }); + + backendCommunicator.on("plugin-manager:community-plugin-updates", (updates) => { + service.pendingPluginUpdates = updates ?? {}; }); service.loadPlugins = function() { @@ -27,13 +31,12 @@ return installedPlugins.find(p => p.config && p.config.id === id); }; - service.savePluginConfig = function(pluginConfig, isNewInstall = false) { + service.savePluginConfig = async (pluginConfig) => { if (!pluginConfig || !pluginConfig.id) { - return $q.resolve(false); + return; } - return $q.when( - backendCommunicator.fireEventAsync("plugin-manager:save-config", { pluginConfig, isNewInstall }) - ); + + return await backendCommunicator.fireEventAsync("plugin-manager:save-config", { pluginConfig }); }; service.deletePlugin = function(pluginId, deletePluginFile = false) { @@ -80,12 +83,16 @@ ); }; - service.cancelInstall = function(fileName) { - if (!fileName) { - return; - } + service.searchCommunityPlugins = async (searchQuery) => { + return await backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", searchQuery); + }; + + service.installCommunityPlugin = async (pluginDetails) => { + return await backendCommunicator.fireEventAsync("plugin-manager:install-community-plugin", pluginDetails); + }; - backendCommunicator.send("plugin-manager:cancel-install", { fileName }); + service.updateCommunityPlugin = async (pluginId) => { + return await backendCommunicator.fireEventAsync("plugin-manager:update-community-plugin", pluginId); }; service.getScriptDetails = function(fileName, expectedScriptType) { diff --git a/src/gui/scss/core/_global.scss b/src/gui/scss/core/_global.scss index c568686ae..4d7f6fbfc 100644 --- a/src/gui/scss/core/_global.scss +++ b/src/gui/scss/core/_global.scss @@ -1387,11 +1387,8 @@ thead th { } } -.reload-indicator-wrapper { - margin: 0 4px 0 0; - position: relative; -} - +.plugin-update-indicator-wrapper, +.reload-indicator-wrapper, .update-indicator-wrapper { margin: 0 4px 0 0; position: relative; diff --git a/src/shared/compare-versions.ts b/src/shared/compare-versions.ts index b95b82d09..80e179b2e 100644 --- a/src/shared/compare-versions.ts +++ b/src/shared/compare-versions.ts @@ -123,4 +123,4 @@ function compareVersions(newVersion: string, currentVersion: string): UpdateType return updateType; } -export { UpdateType, compareVersions }; \ No newline at end of file +export { UpdateType, parseVersion, compareVersions }; \ No newline at end of file diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 902cedef6..c62c7bb2c 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -20,6 +20,29 @@ type NoResult = Awaitable; type GenericParameters = Record; +type FontAwesomeIcon = { + type: "font-awesome"; + /** + * A FontAwesome icon name shown in the UI (eg. "fa-cogs"). + */ + name: `fa-${string}`; + /** + * A css color value (eg. "#FF0000") used for the icon. + */ + color?: string; +}; + +type CustomIcon = { + type: "custom"; + url: string; + /** + * A css color value (eg. "#FF0000") used for the background of the icon. + */ + backgroundColor?: string; +}; + +export type PluginIcon = FontAwesomeIcon | CustomIcon; + export type ManagedPluginManifest = { name: string; author: string; @@ -41,18 +64,23 @@ export type ManagedPluginManifest = { maximumFirebotVersion?: ManifestFirebotVersion; }; -export type ManagedPlugin = { +export type ManagedPluginBase = { author: string; name: string; version: string; }; -export type ManagedPluginWithManifest = ManagedPlugin & { +export type ManagedPlugin = ManagedPluginBase & { manifest: ManagedPluginManifest; }; +export type ManagedPluginExtended = ManagedPlugin & { + installed: boolean; + installedVersion?: string; +}; + export type ManagedPluginUpdateRequest = { - plugins: Array; + plugins: Array; firebotVersion: ManifestFirebotVersion; }; @@ -61,7 +89,7 @@ export type InstalledPluginConfig