From 3cf8125db8e2ca004d58ab7a01e86fc302cf1722 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Sun, 28 Jun 2026 01:10:00 -0400 Subject: [PATCH 1/9] feat(plugins): install community plugins --- src/backend/plugins/plugin-manager.ts | 376 +++++++++++++++--- src/backend/utils/index.ts | 6 +- src/backend/utils/versions.ts | 57 +++ src/gui/app/directives/misc/searchbar.js | 11 +- ...uginModal.js => configure-plugin-modal.js} | 81 +--- .../plugins/install-community-plugin-modal.js | 225 +++++++++++ .../modals/plugins/plugin-manager-modal.js | 3 - ...ePluginModal.js => remove-plugin-modal.js} | 5 +- .../settings/categories/plugin-settings.js | 94 ++--- src/gui/app/services/plugins.service.js | 17 +- src/shared/compare-versions.ts | 2 +- src/types/plugins.ts | 59 +-- 12 files changed, 703 insertions(+), 233 deletions(-) create mode 100644 src/backend/utils/versions.ts rename src/gui/app/directives/modals/plugins/{configurePluginModal.js => configure-plugin-modal.js} (60%) create mode 100644 src/gui/app/directives/modals/plugins/install-community-plugin-modal.js rename src/gui/app/directives/modals/plugins/{removePluginModal.js => remove-plugin-modal.js} (85%) diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index 650dd939e..c9b573ce5 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -1,12 +1,15 @@ 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, Manifest, PluginBase, PluginContext, @@ -35,6 +38,9 @@ import { PluginExecutionResult } from "./executors/plugin-executor.interface"; import { buildPluginApi, createPluginApiContext } from "./plugin-api"; +import { resolvePluginManifestLinks } from "./plugin-manifest-utils"; +import { parseVersion } from "../../shared/compare-versions"; +import { meetsFirebotVersionRequirement } from "../utils"; type LoadedPlugin = PluginBase | LegacyCustomScript; type AnyPluginExecutor = IPluginExecutor; @@ -72,6 +78,16 @@ 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"); @@ -115,9 +131,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 +154,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 +168,18 @@ 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); + } + ); } async migrateLegacyStartUpScriptsToPlugins() { @@ -414,7 +435,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 +448,7 @@ class PluginManager { // Enabled, not yet running -> start if (!active) { - await this.startPlugin(pluginConfig, isNewInstall); + await this.startPlugin(pluginConfig, false); return; } @@ -551,7 +572,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 +589,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 +918,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 +946,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 +963,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 +1057,227 @@ 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 saveCommunityPlugin( + plugin: ManagedPluginExtended, + downloadedPlugin: Uint8Array, + overwrite = false + ): Promise<{ success: boolean, path?: string }> { + const result = { + success: false, + path: undefined + }; + + try { + 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"); + const exists = existsSync(destPath); + + if (exists !== true || overwrite === true) { + await fsp.writeFile(destPath, downloadedPlugin); + result.success = true; + result.path = path.join(pluginRelativePath, "plugin.js"); + } else { + this._logger.error(`Community plugin file "${path.join(pluginRelativePath, "plugin.js")}" already exists`); + return result; + } + 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, + overwrite = false + ): 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 the file + const downloadResponse = await fetch(plugin.manifest.downloadUrl); + + if (!downloadResponse.ok) { + const errorMessage = "Unable to download plugin file"; + const downloadResponseBody = await downloadResponse.text(); + this._logger.error(`${errorMessage}. Response: ${downloadResponseBody}`); + return { success: false, error: errorMessage }; + } + + const fileContents = await downloadResponse.bytes(); + + // Verify the SHA + const hash = createHash("sha256").update(fileContents).digest("hex"); + if (hash.toLowerCase() !== plugin.manifest.sha256.toLowerCase()) { + const errorMessage = "Downloaded plugin signature doesn't match plugin manifest"; + this._logger.error(errorMessage); + return { success: false, error: errorMessage }; + } + + // Save the file + const saveResult = await this.saveCommunityPlugin(plugin, fileContents, overwrite); + + if (saveResult.success !== true) { + return { success: false, error: "Failed to save downloaded 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." }; + } + } + + // #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/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..fea61ec29 --- /dev/null +++ b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js @@ -0,0 +1,225 @@ +"use strict"; + +(function() { + angular + .module("firebotApp") + .component("installCommunityPluginModal", { + template: ` +
+ +
+ +
+
+ + `, + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: function( + $rootScope, + $scope, + pluginsService, + backendCommunicator, + modalFactory, + modalService + ) { + 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 backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", $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.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 backendCommunicator.fireEventAsync("plugin-manager:install-community-plugin", pluginDetails); + + if (result.success === true) { + pluginDetails.installed = true; + pluginDetails.installedVersion = result.installedPlugin.config.managedPluginDetails.version; + + modalService.showModal({ + component: "configurePluginModal", + size: "md", + backdrop: true, + keyboard: true, + 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.
-
-
+

Installed Plugins

+
+
@@ -178,8 +183,10 @@
`, - 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; @@ -213,27 +220,26 @@ }; $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) { @@ -267,37 +273,6 @@ ]; }; - 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(); - } - }); - } - function doInstall(filePath, overwrite) { return $q.when(pluginsService.installPluginFromFile(filePath, overwrite)) .then((result) => { @@ -306,7 +281,7 @@ return; } if (result.conflict) { - utilityService.showConfirmationModal({ + modalFactory.showConfirmationModal({ title: "Plugin File Already Exists", question: `${result.error} Overwrite it?`, confirmLabel: "Overwrite", @@ -319,14 +294,14 @@ 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); + $scope.configurePlugin(result.installedPlugin); }); } - $scope.installPlugin = function() { + $scope.installPluginFromFile = function() { $q.when(backendCommunicator.fireEventAsync("open-file-browser", { currentPath: profileManager.getPathInProfile("/scripts"), options: { @@ -342,6 +317,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 +332,7 @@ return; } if (result.conflict) { - utilityService.showConfirmationModal({ + modalFactory.showConfirmationModal({ title: "Plugin File Already Exists", question: `${result.error} Overwrite it?`, confirmLabel: "Overwrite", @@ -363,7 +345,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(); diff --git a/src/gui/app/services/plugins.service.js b/src/gui/app/services/plugins.service.js index 9b3274744..4c282a986 100644 --- a/src/gui/app/services/plugins.service.js +++ b/src/gui/app/services/plugins.service.js @@ -27,13 +27,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,14 +79,6 @@ ); }; - service.cancelInstall = function(fileName) { - if (!fileName) { - return; - } - - backendCommunicator.send("plugin-manager:cancel-install", { fileName }); - }; - service.getScriptDetails = function(fileName, expectedScriptType) { return $q.when( backendCommunicator.fireEventAsync("plugin-manager:get-plugin-details", { fileName, expectedScriptType }) 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..5ebe85ced 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 Date: Sun, 28 Jun 2026 01:13:16 -0400 Subject: [PATCH 2/9] chore(plugins): remove initBeforeShowingParams --- .../plugins/executors/legacy-startup-script-executor.ts | 3 +-- src/types/plugins.ts | 7 ------- 2 files changed, 1 insertion(+), 9 deletions(-) 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/types/plugins.ts b/src/types/plugins.ts index 5ebe85ced..c62c7bb2c 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -148,12 +148,6 @@ export interface Manifest { * The icon to be displayed for the plugin. */ icon?: PluginIcon; - - /** - * If true, the plugin will be initialized before parameters are shown to the user, - * allowing the plugin to provide custom parameter types that can be used in its own parametersSchema. - */ - initBeforeShowingParams?: boolean; } @@ -257,7 +251,6 @@ type LegacyCustomScriptManifest = { author: string; website?: string; startupOnly?: boolean; - initBeforeShowingParams?: boolean; firebotVersion?: "5"; }; From d50cdb57af1fc6e6d06dde953dae4a95631ee26a Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 29 Jun 2026 00:18:31 -0400 Subject: [PATCH 3/9] feat(plugins): add Community badge --- .../modals/plugins/install-community-plugin-modal.js | 5 ++--- .../app/directives/settings/categories/plugin-settings.js | 4 ++++ src/gui/app/services/plugins.service.js | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) 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 index fea61ec29..15485a1cf 100644 --- a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js +++ b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js @@ -150,7 +150,6 @@ $rootScope, $scope, pluginsService, - backendCommunicator, modalFactory, modalService ) { @@ -171,7 +170,7 @@ $ctrl.isSearching = true; - const results = await backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", $ctrl.searchQuery); + const results = await pluginsService.searchCommunityPlugins($ctrl.searchQuery); $ctrl.searchResults = results ?? []; $ctrl.searchPerformed = true; @@ -198,7 +197,7 @@ $ctrl.isInstallingPlugin = true; pluginDetails.installing = true; - const result = await backendCommunicator.fireEventAsync("plugin-manager:install-community-plugin", pluginDetails); + const result = await pluginsService.installCommunityPlugin(pluginDetails); if (result.success === true) { pluginDetails.installed = true; diff --git a/src/gui/app/directives/settings/categories/plugin-settings.js b/src/gui/app/directives/settings/categories/plugin-settings.js index 6f4a9eb35..b9aa9b136 100644 --- a/src/gui/app/directives/settings/categories/plugin-settings.js +++ b/src/gui/app/directives/settings/categories/plugin-settings.js @@ -110,6 +110,10 @@ style="font-size: 12px;" ng-if="plugin.details.manifest.author" >by {{plugin.details.manifest.author}} + Community { + return await backendCommunicator.fireEventAsync("plugin-manager:search-community-plugins", searchQuery); + }; + + service.installCommunityPlugin = async (pluginDetails) => { + return await backendCommunicator.fireEventAsync("plugin-manager:install-community-plugin", pluginDetails); + }; + service.getScriptDetails = function(fileName, expectedScriptType) { return $q.when( backendCommunicator.fireEventAsync("plugin-manager:get-plugin-details", { fileName, expectedScriptType }) From 94c981d6c38ac82ac4e0bf9721045e02e5ede2c1 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Mon, 29 Jun 2026 01:24:30 -0400 Subject: [PATCH 4/9] feat(plugins): Last updated field in community search results --- .../plugins/install-community-plugin-modal.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 15485a1cf..24c72116b 100644 --- a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js +++ b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js @@ -1,6 +1,8 @@ "use strict"; (function() { + const { DateTime } = require("luxon"); + angular .module("firebotApp") .component("installCommunityPluginModal", { @@ -76,6 +78,12 @@ > {{plugin.manifest.description}}
+
+ Last updated: {{$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); From 122c1add3235bb89460d26410b914a5d2609cffc Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Tue, 30 Jun 2026 02:11:36 -0400 Subject: [PATCH 5/9] feat(plugins): community updates backend --- .../electron/window-management.js | 1 + src/backend/plugins/plugin-manager.ts | 221 +++++++++++++++--- 2 files changed, 194 insertions(+), 28 deletions(-) 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/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index c9b573ce5..175e23137 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -10,6 +10,7 @@ import type { LegacyCustomScript, ManagedPlugin, ManagedPluginExtended, + ManagedPluginUpdateRequest, Manifest, PluginBase, PluginContext, @@ -39,7 +40,7 @@ import { } from "./executors/plugin-executor.interface"; import { buildPluginApi, createPluginApiContext } from "./plugin-api"; import { resolvePluginManifestLinks } from "./plugin-manifest-utils"; -import { parseVersion } from "../../shared/compare-versions"; +import { compareVersions, parseVersion, UpdateType } from "../../shared/compare-versions"; import { meetsFirebotVersionRequirement } from "../utils"; type LoadedPlugin = PluginBase | LegacyCustomScript; @@ -94,6 +95,9 @@ class PluginManager { private startingPlugins: Map> = new Map(); private activePlugins: Record = {}; + private updateCheckInterval: NodeJS.Timeout; + private pendingUpdates: Record = {}; + private pendingApiInstances: Map = new Map(); private requireInterceptorInstalled = false; @@ -180,6 +184,12 @@ class PluginManager { return await this.installCommunityPlugin(pluginDetails); } ); + + frontendCommunicator.onAsync("plugin-manager:update-community-plugin", + async (data: { pluginId: string, pluginUpdate: ManagedPluginExtended }) => { + return await this.updateCommunityPlugin(data.pluginId, data.pluginUpdate); + } + ); } async migrateLegacyStartUpScriptsToPlugins() { @@ -260,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 @@ -1119,9 +1130,8 @@ class PluginManager { return plugins; } - private async saveCommunityPlugin( - plugin: ManagedPluginExtended, - downloadedPlugin: Uint8Array, + private async downloadAndSaveCommunityPlugin( + plugin: ManagedPlugin, overwrite = false ): Promise<{ success: boolean, path?: string }> { const result = { @@ -1130,6 +1140,24 @@ class PluginManager { }; 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, @@ -1145,7 +1173,7 @@ class PluginManager { const exists = existsSync(destPath); if (exists !== true || overwrite === true) { - await fsp.writeFile(destPath, downloadedPlugin); + await fsp.writeFile(destPath, fileContents); result.success = true; result.path = path.join(pluginRelativePath, "plugin.js"); } else { @@ -1201,31 +1229,11 @@ class PluginManager { return { success: false, error: errorMessage }; } - // Download the file - const downloadResponse = await fetch(plugin.manifest.downloadUrl); - - if (!downloadResponse.ok) { - const errorMessage = "Unable to download plugin file"; - const downloadResponseBody = await downloadResponse.text(); - this._logger.error(`${errorMessage}. Response: ${downloadResponseBody}`); - return { success: false, error: errorMessage }; - } - - const fileContents = await downloadResponse.bytes(); - - // Verify the SHA - const hash = createHash("sha256").update(fileContents).digest("hex"); - if (hash.toLowerCase() !== plugin.manifest.sha256.toLowerCase()) { - const errorMessage = "Downloaded plugin signature doesn't match plugin manifest"; - this._logger.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Save the file - const saveResult = await this.saveCommunityPlugin(plugin, fileContents, overwrite); + // Download and save the file + const saveResult = await this.downloadAndSaveCommunityPlugin(plugin, overwrite); if (saveResult.success !== true) { - return { success: false, error: "Failed to save downloaded plugin" }; + return { success: false, error: "Failed to download or save plugin" }; } // Grab the plugin details @@ -1277,6 +1285,163 @@ class PluginManager { } } + private async updateCommunityPlugin( + pluginId: string, + pluginUpdate: ManagedPlugin + ): 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 + if (pluginUpdate == null) { + const errorMessage = "No community plugin details provided for update"; + 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); + + 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 { + if (this.updateCheckInterval == null) { + this.updateCheckInterval = setInterval( + async () => await this.checkForCommunityPluginUpdates(), + 24 * 60 * 60 * 1000 // Every 24 hours + ); + } + } + + private async checkForCommunityPluginUpdates(): Promise { + 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); + } + } + // #endregion } From 6e25812246981c94de58973d1511f68b0b6cc4b0 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 1 Jul 2026 01:03:30 -0400 Subject: [PATCH 6/9] feat(plugins): add update indicator icon --- src/backend/plugins/plugin-manager.ts | 5 +++ .../controls/plugin-update-indicator.js | 42 +++++++++++++++++++ src/gui/app/index.html | 1 + src/gui/app/services/plugins.service.js | 6 ++- src/gui/scss/core/_global.scss | 7 +--- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 src/gui/app/directives/controls/plugin-update-indicator.js diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index 175e23137..95b38b670 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -1378,6 +1378,8 @@ class PluginManager { } startCommunityPluginUpdateCheck(): void { + void this.checkForCommunityPluginUpdates(); + if (this.updateCheckInterval == null) { this.updateCheckInterval = setInterval( async () => await this.checkForCommunityPluginUpdates(), @@ -1387,6 +1389,7 @@ class PluginManager { } private async checkForCommunityPluginUpdates(): Promise { + this._logger.info("Checking for community plugin updates"); const communityPlugins = PluginConfigManager.getAllItems() .filter(p => p.managedPluginDetails != null) .map(p => ({ @@ -1440,6 +1443,8 @@ class PluginManager { } catch (error) { this._logger.error("Unknown error checking for community plugin updates", error); } + + void this.triggerUiRefresh(); } // #endregion 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/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() { 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; From acc6ff96b2355fbb595c8ee6259d61fbe4e64734 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Wed, 1 Jul 2026 22:19:46 -0400 Subject: [PATCH 7/9] feat(plugins): community updates frontend --- src/backend/plugins/plugin-manager.ts | 10 +-- .../settings/categories/plugin-settings.js | 75 +++++++++++++++++-- src/gui/app/services/plugins.service.js | 4 + 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index 95b38b670..cbebed803 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -186,8 +186,8 @@ class PluginManager { ); frontendCommunicator.onAsync("plugin-manager:update-community-plugin", - async (data: { pluginId: string, pluginUpdate: ManagedPluginExtended }) => { - return await this.updateCommunityPlugin(data.pluginId, data.pluginUpdate); + async (pluginId: string) => { + return await this.updateCommunityPlugin(pluginId); } ); } @@ -1286,8 +1286,7 @@ class PluginManager { } private async updateCommunityPlugin( - pluginId: string, - pluginUpdate: ManagedPlugin + pluginId: string ): Promise { // Make sure existing plugin is valid const installedPluginConfig = PluginConfigManager.getItem(pluginId); @@ -1309,8 +1308,9 @@ class PluginManager { } // Ensure we have data + const pluginUpdate = this.pendingUpdates[pluginId]; if (pluginUpdate == null) { - const errorMessage = "No community plugin details provided for update"; + const errorMessage = "No update available for community plugin"; this._logger.error(errorMessage); return { success: false, error: errorMessage }; } diff --git a/src/gui/app/directives/settings/categories/plugin-settings.js b/src/gui/app/directives/settings/categories/plugin-settings.js index b9aa9b136..6d7aecd0d 100644 --- a/src/gui/app/directives/settings/categories/plugin-settings.js +++ b/src/gui/app/directives/settings/categories/plugin-settings.js @@ -119,6 +119,15 @@ style="font-size: 11px; padding: 1px 8px; border-radius: 10px; background: rgba(217,146,17,0.18); color: #d99211;" >Disabled
+
+ v{{pendingUpdateDetails(plugin).version}} update ready to download +
{ + 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; @@ -255,26 +270,44 @@ $scope.pluginMenuOptions = function(plugin) { const isEnabled = plugin.config.enabled !== false; - return [ + const isCommunityPlugin = plugin.config.managedPluginDetails != null; + const pendingUpdate = isCommunityPlugin + ? pluginsService.pendingPluginUpdates[plugin.config.id] + : null; + + const options = [ { html: ` ${isEnabled ? 'Disable' : 'Enable'}`, click: () => { $scope.togglePluginEnabled(plugin); } - }, - { + } + ]; + + if (isCommunityPlugin !== true) { + options.push({ html: ` Update`, click: () => { $scope.updatePlugin(plugin); } - }, - { - html: ` Remove`, + }); + } else if (pendingUpdate != null) { + options.push({ + html: ` Download and install v${pendingUpdate.version}`, click: () => { - $scope.removePlugin(plugin); + $scope.updateCommunityPlugin(plugin); } + }); + } + + options.push({ + html: ` Remove`, + click: () => { + $scope.removePlugin(plugin); } - ]; + }); + + return options; }; function doInstall(filePath, overwrite) { @@ -378,6 +411,32 @@ doUpdate(plugin, response.path, false); }); }; + + $scope.updateCommunityPlugin = async (plugin) => { + if (!plugin?.config) { + return; + } + + const pluginUpdate = pluginsService.pendingPluginUpdates[plugin.config.id]; + if (!pluginUpdate) { + return; + } + + const result = await pluginsService.updateCommunityPlugin(plugin.config.id); + + 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 '${plugin.details.name}' updated to v${pluginUpdate.version}` + }); + }; } }); }()); diff --git a/src/gui/app/services/plugins.service.js b/src/gui/app/services/plugins.service.js index 0b95bc2a8..b78b3eeaa 100644 --- a/src/gui/app/services/plugins.service.js +++ b/src/gui/app/services/plugins.service.js @@ -91,6 +91,10 @@ return await backendCommunicator.fireEventAsync("plugin-manager:install-community-plugin", pluginDetails); }; + service.updateCommunityPlugin = async (pluginId) => { + return await backendCommunicator.fireEventAsync("plugin-manager:update-community-plugin", pluginId); + }; + service.getScriptDetails = function(fileName, expectedScriptType) { return $q.when( backendCommunicator.fireEventAsync("plugin-manager:get-plugin-details", { fileName, expectedScriptType }) From c3453606f0b714282d6eb575e303bab0f4dc0ef0 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 2 Jul 2026 01:25:39 -0400 Subject: [PATCH 8/9] feat(plugins): ready to update section --- src/backend/plugins/plugin-manager.ts | 24 ++--- .../settings/categories/plugin-settings.js | 89 ++++++++++++++----- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/backend/plugins/plugin-manager.ts b/src/backend/plugins/plugin-manager.ts index cbebed803..437220b76 100644 --- a/src/backend/plugins/plugin-manager.ts +++ b/src/backend/plugins/plugin-manager.ts @@ -1131,8 +1131,7 @@ class PluginManager { } private async downloadAndSaveCommunityPlugin( - plugin: ManagedPlugin, - overwrite = false + plugin: ManagedPlugin ): Promise<{ success: boolean, path?: string }> { const result = { success: false, @@ -1170,16 +1169,10 @@ class PluginManager { case "single-file": { await fsp.mkdir(destFolder, { recursive: true }); const destPath = path.resolve(destFolder, "plugin.js"); - const exists = existsSync(destPath); - - if (exists !== true || overwrite === true) { - await fsp.writeFile(destPath, fileContents); - result.success = true; - result.path = path.join(pluginRelativePath, "plugin.js"); - } else { - this._logger.error(`Community plugin file "${path.join(pluginRelativePath, "plugin.js")}" already exists`); - return result; - } + + await fsp.writeFile(destPath, fileContents); + result.success = true; + result.path = path.join(pluginRelativePath, "plugin.js"); break; } @@ -1197,8 +1190,7 @@ class PluginManager { } private async installCommunityPlugin( - plugin: ManagedPluginExtended, - overwrite = false + plugin: ManagedPluginExtended ): Promise { // Ensure we have data if (plugin == null) { @@ -1230,7 +1222,7 @@ class PluginManager { } // Download and save the file - const saveResult = await this.downloadAndSaveCommunityPlugin(plugin, overwrite); + const saveResult = await this.downloadAndSaveCommunityPlugin(plugin); if (saveResult.success !== true) { return { success: false, error: "Failed to download or save plugin" }; @@ -1362,6 +1354,8 @@ class PluginManager { await this.startPlugin(installedPluginConfig, false); + delete this.pendingUpdates[pluginId]; + void this.triggerUiRefresh(); return { diff --git a/src/gui/app/directives/settings/categories/plugin-settings.js b/src/gui/app/directives/settings/categories/plugin-settings.js index 6d7aecd0d..f8a0bede5 100644 --- a/src/gui/app/directives/settings/categories/plugin-settings.js +++ b/src/gui/app/directives/settings/categories/plugin-settings.js @@ -63,6 +63,61 @@ >here.

+
+
+

Plugins Ready to Update

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

Installed Plugins

@@ -119,15 +174,6 @@ style="font-size: 11px; padding: 1px 8px; border-radius: 10px; background: rgba(217,146,17,0.18); color: #d99211;" >Disabled
-
- v{{pendingUpdateDetails(plugin).version}} update ready to download -
{ @@ -221,6 +269,11 @@ 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); @@ -271,9 +324,6 @@ $scope.pluginMenuOptions = function(plugin) { const isEnabled = plugin.config.enabled !== false; const isCommunityPlugin = plugin.config.managedPluginDetails != null; - const pendingUpdate = isCommunityPlugin - ? pluginsService.pendingPluginUpdates[plugin.config.id] - : null; const options = [ { @@ -291,13 +341,6 @@ $scope.updatePlugin(plugin); } }); - } else if (pendingUpdate != null) { - options.push({ - html: ` Download and install v${pendingUpdate.version}`, - click: () => { - $scope.updateCommunityPlugin(plugin); - } - }); } options.push({ @@ -422,8 +465,14 @@ 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", @@ -434,7 +483,7 @@ ngToast.create({ className: "success", - content: `Plugin '${plugin.details.name}' updated to v${pluginUpdate.version}` + content: `${plugin.details.manifest.name} plugin updated to v${pluginUpdate.version}` }); }; } From 9303fad1f6af55d0e73230a13c970bc3596f12c3 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 2 Jul 2026 22:08:34 -0400 Subject: [PATCH 9/9] chore(plugins): only show config dialog on install if params exist --- .../plugins/install-community-plugin-modal.js | 24 ++++++++++++------- .../settings/categories/plugin-settings.js | 13 +++++++--- 2 files changed, 25 insertions(+), 12 deletions(-) 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 index 24c72116b..d8fde0a17 100644 --- a/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js +++ b/src/gui/app/directives/modals/plugins/install-community-plugin-modal.js @@ -159,7 +159,8 @@ $scope, pluginsService, modalFactory, - modalService + modalService, + ngToast ) { const $ctrl = this; @@ -220,15 +221,20 @@ pluginDetails.installed = true; pluginDetails.installedVersion = result.installedPlugin.config.managedPluginDetails.version; - modalService.showModal({ - component: "configurePluginModal", - size: "md", - backdrop: true, - keyboard: true, - resolveObj: { - plugin: () => result.installedPlugin - } + 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); } diff --git a/src/gui/app/directives/settings/categories/plugin-settings.js b/src/gui/app/directives/settings/categories/plugin-settings.js index f8a0bede5..a9ac3f86b 100644 --- a/src/gui/app/directives/settings/categories/plugin-settings.js +++ b/src/gui/app/directives/settings/categories/plugin-settings.js @@ -295,8 +295,6 @@ modalService.showModal({ component: "configurePluginModal", size: "md", - backdrop: true, - keyboard: true, resolveObj: { plugin: () => angular.copy(plugin) } @@ -377,7 +375,16 @@ modalFactory.showErrorModal(result.error || "Failed to install plugin."); return; } - $scope.configurePlugin(result.installedPlugin); + + const pluginName = $scope.getPluginName(result.installedPlugin); + ngToast.create({ + className: "success", + content: `${pluginName} plugin installed!` + }); + + if (!!result.installedPlugin.details.parametersSchema?.length) { + $scope.configurePlugin(result.installedPlugin); + } }); }