Browse Source

frontend: Provide UI for phase 1 of plugin manager

For phase 1 of the plugin manager, the ability to toggle off/on plugins
to be loaded at launch is provided.

This commit adds a new Plugin Manager dialog which can be accessed from
the Tools menu, which shows a list of all installed 3rd party plugins
with a checkbox to toggle them off or on.  If a change is made, the user
is prompted to restart OBS. To allow this, the plugin manager uses a
json based config file stored in the OBS config directory. Additionally
for sources in the source tree, a sample UI has been provided that
indicates any sources whose parent module is disabled, by turning its
title red.
FiniteSingularity 1 month ago
parent
commit
23b67268e7

+ 1 - 0
frontend/CMakeLists.txt

@@ -46,6 +46,7 @@ include(cmake/ui-components.cmake)
 include(cmake/ui-dialogs.cmake)
 include(cmake/ui-docks.cmake)
 include(cmake/feature-importers.cmake)
+include(cmake/feature-plugin-manager.cmake)
 include(cmake/ui-models.cmake)
 include(cmake/ui-oauth.cmake)
 include(cmake/feature-browserpanels.cmake)

+ 41 - 0
frontend/OBSApp.cpp

@@ -19,6 +19,7 @@
 
 #include <components/Multiview.hpp>
 #include <dialogs/LogUploadDialog.hpp>
+#include <plugin-manager/PluginManager.hpp>
 #include <utility/CrashHandler.hpp>
 #include <utility/OBSEventFilter.hpp>
 #include <utility/OBSProxyStyle.hpp>
@@ -323,6 +324,7 @@ bool OBSApp::InitGlobalLocationDefaults()
 	config_set_default_string(appConfig, "Locations", "Configuration", path);
 	config_set_default_string(appConfig, "Locations", "SceneCollections", path);
 	config_set_default_string(appConfig, "Locations", "Profiles", path);
+	config_set_default_string(appConfig, "Locations", "PluginManagerSettings", path);
 
 	return true;
 }
@@ -422,6 +424,7 @@ static bool MakeUserDirs()
 
 constexpr std::string_view OBSProfileSubDirectory = "obs-studio/basic/profiles";
 constexpr std::string_view OBSScenesSubDirectory = "obs-studio/basic/scenes";
+constexpr std::string_view OBSPluginManagerSubDirectory = "obs-studio/plugin_manager";
 
 static bool MakeUserProfileDirs()
 {
@@ -429,6 +432,8 @@ static bool MakeUserProfileDirs()
 		App()->userProfilesLocation / std::filesystem::u8path(OBSProfileSubDirectory);
 	const std::filesystem::path userScenesPath =
 		App()->userScenesLocation / std::filesystem::u8path(OBSScenesSubDirectory);
+	const std::filesystem::path userPluginManagerPath =
+		App()->userPluginManagerSettingsLocation / std::filesystem::u8path(OBSPluginManagerSubDirectory);
 
 	if (!std::filesystem::exists(userProfilePath)) {
 		try {
@@ -450,6 +455,16 @@ static bool MakeUserProfileDirs()
 		}
 	}
 
+	if (!std::filesystem::exists(userPluginManagerPath)) {
+		try {
+			std::filesystem::create_directories(userPluginManagerPath);
+		} catch (const std::filesystem::filesystem_error &error) {
+			blog(LOG_ERROR, "Failed to create user plugin manager directory '%s'\n%s",
+			     userPluginManagerPath.u8string().c_str(), error.what());
+			return false;
+		}
+	}
+
 	return true;
 }
 
@@ -522,11 +537,14 @@ bool OBSApp::InitGlobalConfig()
 		std::filesystem::u8path(config_get_default_string(appConfig, "Locations", "SceneCollections"));
 	std::filesystem::path defaultUserProfilesLocation =
 		std::filesystem::u8path(config_get_default_string(appConfig, "Locations", "Profiles"));
+	std::filesystem::path defaultPluginManagerLocation =
+		std::filesystem::u8path(config_get_default_string(appConfig, "Locations", "PluginManagerSettings"));
 
 	if (IsPortableMode()) {
 		userConfigLocation = std::move(defaultUserConfigLocation);
 		userScenesLocation = std::move(defaultUserScenesLocation);
 		userProfilesLocation = std::move(defaultUserProfilesLocation);
+		userPluginManagerSettingsLocation = std::move(defaultPluginManagerLocation);
 	} else {
 		std::filesystem::path currentUserConfigLocation =
 			std::filesystem::u8path(config_get_string(appConfig, "Locations", "Configuration"));
@@ -534,6 +552,8 @@ bool OBSApp::InitGlobalConfig()
 			std::filesystem::u8path(config_get_string(appConfig, "Locations", "SceneCollections"));
 		std::filesystem::path currentUserProfilesLocation =
 			std::filesystem::u8path(config_get_string(appConfig, "Locations", "Profiles"));
+		std::filesystem::path currentUserPluginManagerLocation =
+			std::filesystem::u8path(config_get_string(appConfig, "Locations", "PluginManagerSettings"));
 
 		userConfigLocation = (std::filesystem::exists(currentUserConfigLocation))
 					     ? std::move(currentUserConfigLocation)
@@ -544,6 +564,9 @@ bool OBSApp::InitGlobalConfig()
 		userProfilesLocation = (std::filesystem::exists(currentUserProfilesLocation))
 					       ? std::move(currentUserProfilesLocation)
 					       : std::move(defaultUserProfilesLocation);
+		userPluginManagerSettingsLocation = (std::filesystem::exists(currentUserPluginManagerLocation))
+							    ? std::move(currentUserPluginManagerLocation)
+							    : std::move(defaultPluginManagerLocation);
 	}
 
 	bool userConfigResult = InitUserConfig(userConfigLocation, lastVersion);
@@ -867,6 +890,7 @@ OBSApp::OBSApp(int &argc, char **argv, profiler_name_store_t *store)
 #endif
 
 	setDesktopFileName("com.obsproject.Studio");
+	pluginManager_ = std::make_unique<OBS::PluginManager>();
 }
 
 OBSApp::~OBSApp()
@@ -1769,3 +1793,20 @@ void OBSApp::addLogLine(int logLevel, const QString &message)
 {
 	emit logLineAdded(logLevel, message);
 }
+
+void OBSApp::loadAppModules(struct obs_module_failure_info &mfi)
+{
+	pluginManager_->preLoad();
+	blog(LOG_INFO, "---------------------------------");
+	obs_load_all_modules2(&mfi);
+	blog(LOG_INFO, "---------------------------------");
+	obs_log_loaded_modules();
+	blog(LOG_INFO, "---------------------------------");
+	obs_post_load_modules();
+	pluginManager_->postLoad();
+}
+
+void OBSApp::pluginManagerOpenDialog()
+{
+	pluginManager_->open();
+}

+ 9 - 0
frontend/OBSApp.hpp

@@ -47,6 +47,7 @@ class CrashHandler;
 
 enum class LogFileType { NoType, CurrentAppLog, LastAppLog, CrashLog };
 enum class LogFileState { NoState, New, Uploaded };
+class PluginManager;
 } // namespace OBS
 
 struct UpdateBranch {
@@ -84,6 +85,8 @@ private:
 
 	std::deque<obs_frontend_translate_ui_cb> translatorHooks;
 
+	std::unique_ptr<OBS::PluginManager> pluginManager_;
+
 	bool UpdatePre22MultiviewLayout(const char *layout);
 
 	bool InitGlobalConfig();
@@ -144,6 +147,7 @@ public:
 	std::filesystem::path userConfigLocation;
 	std::filesystem::path userScenesLocation;
 	std::filesystem::path userProfilesLocation;
+	std::filesystem::path userPluginManagerSettingsLocation;
 
 	inline const char *GetLocale() const { return locale.c_str(); }
 
@@ -210,6 +214,11 @@ public:
 	static void SigIntSignalHandler(int);
 #endif
 
+	void loadAppModules(struct obs_module_failure_info &mfi);
+
+	// Plugin Manager Accessors
+	void pluginManagerOpenDialog();
+
 public slots:
 	void Exec(VoidFunc func);
 	void ProcessSigInt();

+ 8 - 0
frontend/cmake/feature-plugin-manager.cmake

@@ -0,0 +1,8 @@
+target_sources(
+  obs-studio
+  PRIVATE
+    plugin-manager/PluginManager.cpp
+    plugin-manager/PluginManager.hpp
+    plugin-manager/PluginManagerWindow.cpp
+    plugin-manager/PluginManagerWindow.hpp
+)

+ 1 - 0
frontend/cmake/ui-qt.cmake

@@ -47,6 +47,7 @@ target_sources(
     forms/OBSRemux.ui
     forms/StatusBarWidget.ui
     forms/obs.qrc
+    forms/PluginManagerWindow.ui
     forms/source-toolbar/browser-source-toolbar.ui
     forms/source-toolbar/color-source-toolbar.ui
     forms/source-toolbar/device-select-toolbar.ui

+ 12 - 0
frontend/components/SourceTreeItem.cpp

@@ -9,6 +9,8 @@
 #include <QLineEdit>
 #include <QPainter>
 
+#include "plugin-manager/PluginManager.hpp"
+
 #include "moc_SourceTreeItem.cpp"
 
 static inline OBSScene GetCurrentScene()
@@ -84,6 +86,16 @@ SourceTreeItem::SourceTreeItem(SourceTree *tree_, OBSSceneItem sceneitem_) : tre
 	label->setAttribute(Qt::WA_TranslucentBackground);
 	label->setEnabled(sourceVisible);
 
+	const char *sourceId = obs_source_get_unversioned_id(source);
+	switch (obs_source_load_state(sourceId)) {
+	case OBS_MODULE_DISABLED:
+	case OBS_MODULE_MISSING:
+		label->setStyleSheet("QLabel {color: #CC0000;}");
+		break;
+	default:
+		break;
+	}
+
 #ifdef __APPLE__
 	vis->setAttribute(Qt::WA_LayoutUsesWidgetRect);
 	lock->setAttribute(Qt::WA_LayoutUsesWidgetRect);

+ 8 - 0
frontend/data/locale/en-US.ini

@@ -1621,3 +1621,11 @@ MultitrackVideo.IncompatibleSettings.UpdateAndStartStreaming="Update Settings an
 MultitrackVideo.IncompatibleSettings.AudioChannels="%1 is not currently compatible with [Audio → General → Channels] set to '%2', %3"
 MultitrackVideo.IncompatibleSettings.AudioChannelsSingle="[Audio → General → Channels] needs to be set to '%1'"
 MultitrackVideo.IncompatibleSettings.AudioChannelsMultiple="%1 requires multiple different settings for [Audio → General → Channels]"
+
+#Plugin ManagerW
+Basic.OpenPluginManager="Plugin Manager"
+PluginManager="Plugin Manager"
+PluginManager.HelpText="Plugin Manager"
+PluginManager.Restart="Restart OBS?"
+PluginManager.NeedsRestart="To apply these changes, OBS needs to restart. Do you want to restart now?"
+PluginManager.MissingPlugin="[PLUGIN NOT FOUND]"

+ 6 - 0
frontend/forms/OBSBasic.ui

@@ -938,6 +938,7 @@
     </property>
     <addaction name="autoConfigure"/>
     <addaction name="idianPlayground"/>
+    <addaction name="actionOpenPluginManager"/>
     <addaction name="separator"/>
    </widget>
    <widget class="QMenu" name="menuDocks">
@@ -2181,6 +2182,11 @@
     <string>Basic.AutoConfig</string>
    </property>
   </action>
+  <action name="actionOpenPluginManager">
+   <property name="text">
+    <string>Basic.OpenPluginManager</string>
+   </property>
+  </action>
   <action name="stats">
    <property name="text">
     <string>Basic.Stats</string>

+ 48 - 0
frontend/forms/PluginManagerWindow.ui

@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>PluginManagerWindow</class>
+ <widget class="QDialog" name="PluginManagerWindow">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>850</width>
+    <height>400</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>PluginManager</string>
+  </property>
+  <property name="sizeGripEnabled">
+   <bool>true</bool>
+  </property>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="2" column="0">
+    <layout class="QHBoxLayout" name="horizontalLayout_4">
+     <property name="spacing">
+      <number>6</number>
+     </property>
+     <item>
+      <widget class="QDialogButtonBox" name="buttonBox">
+       <property name="standardButtons">
+        <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+   <item row="0" column="0">
+    <widget class="QLabel" name="label">
+     <property name="text">
+      <string>PluginManager.HelpText</string>
+     </property>
+    </widget>
+   </item>
+   <item row="1" column="0">
+    <widget class="QListWidget" name="modulesList"/>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>

+ 303 - 0
frontend/plugin-manager/PluginManager.cpp

@@ -0,0 +1,303 @@
+/******************************************************************************
+    Copyright (C) 2025 by FiniteSingularity <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "PluginManager.hpp"
+#include "PluginManagerWindow.hpp"
+
+#include <OBSApp.hpp>
+#include <qt-wrappers.hpp>
+#include <widgets/OBSBasic.hpp>
+
+#include <QMessageBox>
+
+#include <nlohmann/json.hpp>
+
+#include <algorithm>
+#include <fstream>
+
+extern bool restart;
+
+namespace OBS {
+
+void addModuleToPluginManagerImpl(void *param, obs_module_t *newModule)
+{
+	auto &instance = *static_cast<OBS::PluginManager *>(param);
+	std::string moduleName = obs_get_module_file_name(newModule);
+	moduleName = moduleName.substr(0, moduleName.rfind("."));
+
+	if (!obs_get_module_allow_disable(moduleName.c_str()))
+		return;
+
+	const char *display_name = obs_get_module_name(newModule);
+	std::string module_name = moduleName;
+	const char *id = obs_get_module_id(newModule);
+	const char *version = obs_get_module_version(newModule);
+
+	auto it = std::find_if(instance.modules_.begin(), instance.modules_.end(),
+			       [&](OBS::ModuleInfo module) { return module.module_name == moduleName; });
+
+	if (it == instance.modules_.end()) {
+		instance.modules_.push_back({display_name ? display_name : "", module_name, id ? id : "",
+					     version ? version : "", true, true});
+	} else {
+		it->display_name = display_name ? display_name : "";
+		it->module_name = module_name;
+		it->id = id ? id : "";
+		it->version = version ? version : "";
+	}
+}
+
+constexpr std::string_view OBSPluginManagerPath = "obs-studio/plugin_manager";
+constexpr std::string_view OBSPluginManagerModulesFile = "modules.json";
+
+void PluginManager::preLoad()
+{
+	loadModules_();
+	disableModules_();
+}
+
+void PluginManager::postLoad()
+{
+	// Find any new modules and add to Plugin Manager.
+	obs_enum_modules(addModuleToPluginManager, this);
+	// Get list of valid module types.
+	addModuleTypes_();
+	saveModules_();
+	// Add provided features from any unloaded modules
+	linkUnloadedModules_();
+}
+
+std::filesystem::path PluginManager::getConfigFilePath_()
+{
+	std::filesystem::path path = App()->userPluginManagerSettingsLocation /
+				     std::filesystem::u8path(OBSPluginManagerPath) /
+				     std::filesystem::u8path(OBSPluginManagerModulesFile);
+	return path;
+}
+
+void PluginManager::loadModules_()
+{
+	auto modulesFile = getConfigFilePath_();
+	if (std::filesystem::exists(modulesFile)) {
+		std::ifstream jsonFile(modulesFile);
+		nlohmann::json data = nlohmann::json::parse(jsonFile);
+		modules_.clear();
+		for (auto it : data) {
+			ModuleInfo obsModule;
+			try {
+				obsModule = {it.at("display_name"),
+					     it.at("module_name"),
+					     it.at("id"),
+					     it.at("version"),
+					     it.at("enabled"),
+					     it.at("enabled"),
+					     it.at("sources"),
+					     it.at("outputs"),
+					     it.at("encoders"),
+					     it.at("services"),
+					     {},
+					     {},
+					     {},
+					     {}};
+			} catch (const nlohmann::json::out_of_range &error) {
+				blog(LOG_WARNING, "Error loading module info: %s", error.what());
+				continue;
+			}
+			modules_.push_back(obsModule);
+		}
+	}
+}
+
+void PluginManager::linkUnloadedModules_()
+{
+	for (const auto &moduleInfo : modules_) {
+		if (!moduleInfo.enabled) {
+			auto obsModule = obs_get_disabled_module(moduleInfo.module_name.c_str());
+			if (!obsModule) {
+				continue;
+			}
+			for (const auto &source : moduleInfo.sources) {
+				obs_module_add_source(obsModule, source.c_str());
+			}
+			for (const auto &output : moduleInfo.outputs) {
+				obs_module_add_output(obsModule, output.c_str());
+			}
+			for (const auto &encoder : moduleInfo.encoders) {
+				obs_module_add_encoder(obsModule, encoder.c_str());
+			}
+			for (const auto &service : moduleInfo.services) {
+				obs_module_add_service(obsModule, service.c_str());
+			}
+		}
+	}
+}
+
+void PluginManager::saveModules_()
+{
+	auto modulesFile = getConfigFilePath_();
+	std::ofstream outFile(modulesFile);
+	nlohmann::json data = nlohmann::json::array();
+
+	for (auto const &moduleInfo : modules_) {
+		nlohmann::json modData;
+		modData["display_name"] = moduleInfo.display_name;
+		modData["module_name"] = moduleInfo.module_name;
+		modData["id"] = moduleInfo.id;
+		modData["version"] = moduleInfo.version;
+		modData["enabled"] = moduleInfo.enabled;
+		modData["sources"] = moduleInfo.sources;
+		modData["outputs"] = moduleInfo.outputs;
+		modData["encoders"] = moduleInfo.encoders;
+		modData["services"] = moduleInfo.services;
+		data.push_back(modData);
+	}
+	outFile << std::setw(4) << data << std::endl;
+}
+
+void PluginManager::addModuleTypes_()
+{
+	const char *source_id;
+	int i = 0;
+	while (obs_enum_source_types(i, &source_id)) {
+		i += 1;
+		obs_module_t *obsModule = obs_source_get_module(source_id);
+		if (!obsModule) {
+			continue;
+		}
+		std::string moduleName = obs_get_module_file_name(obsModule);
+		moduleName = moduleName.substr(0, moduleName.rfind("."));
+		auto it = std::find_if(modules_.begin(), modules_.end(),
+				       [moduleName](ModuleInfo const &m) { return m.module_name == moduleName; });
+		if (it != modules_.end()) {
+			it->sourcesLoaded.push_back(source_id);
+		}
+	}
+
+	const char *output_id;
+	i = 0;
+	while (obs_enum_output_types(i, &output_id)) {
+		i += 1;
+		obs_module_t *obsModule = obs_source_get_module(output_id);
+		if (!obsModule) {
+			continue;
+		}
+		std::string moduleName = obs_get_module_file_name(obsModule);
+		moduleName = moduleName.substr(0, moduleName.rfind("."));
+		auto it = std::find_if(modules_.begin(), modules_.end(),
+				       [moduleName](ModuleInfo const &m) { return m.module_name == moduleName; });
+		if (it != modules_.end()) {
+			it->outputsLoaded.push_back(output_id);
+		}
+	}
+
+	const char *encoder_id;
+	i = 0;
+	while (obs_enum_encoder_types(i, &encoder_id)) {
+		i += 1;
+		obs_module_t *obsModule = obs_source_get_module(encoder_id);
+		if (!obsModule) {
+			continue;
+		}
+		std::string moduleName = obs_get_module_file_name(obsModule);
+		moduleName = moduleName.substr(0, moduleName.rfind("."));
+		auto it = std::find_if(modules_.begin(), modules_.end(),
+				       [moduleName](ModuleInfo const &m) { return m.module_name == moduleName; });
+		if (it != modules_.end()) {
+			it->encodersLoaded.push_back(encoder_id);
+		}
+	}
+
+	const char *service_id;
+	i = 0;
+	while (obs_enum_service_types(i, &service_id)) {
+		i += 1;
+		obs_module_t *obsModule = obs_source_get_module(service_id);
+		if (!obsModule) {
+			continue;
+		}
+		std::string moduleName = obs_get_module_file_name(obsModule);
+		moduleName = moduleName.substr(0, moduleName.rfind("."));
+		auto it = std::find_if(modules_.begin(), modules_.end(),
+				       [moduleName](ModuleInfo const &m) { return m.module_name == moduleName; });
+		if (it != modules_.end()) {
+			it->servicesLoaded.push_back(service_id);
+		}
+	}
+
+	for (auto &moduleInfo : modules_) {
+		if (moduleInfo.enabledAtLaunch) {
+			moduleInfo.sources = moduleInfo.sourcesLoaded;
+			moduleInfo.encoders = moduleInfo.encodersLoaded;
+			moduleInfo.outputs = moduleInfo.outputsLoaded;
+			moduleInfo.services = moduleInfo.servicesLoaded;
+		} else {
+			for (auto const &source : moduleInfo.sources) {
+				disabledSources_.push_back(source);
+			}
+			for (auto const &output : moduleInfo.outputs) {
+				disabledOutputs_.push_back(output);
+			}
+			for (auto const &encoder : moduleInfo.encoders) {
+				disabledEncoders_.push_back(encoder);
+			}
+			for (auto const &service : moduleInfo.services) {
+				disabledServices_.push_back(service);
+			}
+		}
+	}
+}
+
+void PluginManager::disableModules_()
+{
+	for (const auto &moduleInfo : modules_) {
+		if (!moduleInfo.enabled) {
+			obs_add_disabled_module(moduleInfo.module_name.c_str());
+		}
+	}
+}
+
+void PluginManager::open()
+{
+	auto main = OBSBasic::Get();
+	PluginManagerWindow pluginManagerWindow(modules_, main);
+	auto result = pluginManagerWindow.exec();
+	if (result == QDialog::Accepted) {
+		modules_ = pluginManagerWindow.result();
+		saveModules_();
+
+		bool changed = false;
+
+		for (auto const &moduleInfo : modules_) {
+			if (moduleInfo.enabled != moduleInfo.enabledAtLaunch) {
+				changed = true;
+				break;
+			}
+		}
+
+		if (changed) {
+			QMessageBox::StandardButton button = OBSMessageBox::question(
+				main, QTStr("PluginManager.Restart"), QTStr("PluginManager.NeedsRestart"));
+
+			if (button == QMessageBox::Yes) {
+				restart = true;
+				main->close();
+			}
+		}
+	}
+}
+
+}; // namespace OBS

+ 78 - 0
frontend/plugin-manager/PluginManager.hpp

@@ -0,0 +1,78 @@
+/******************************************************************************
+    Copyright (C) 2025 by FiniteSingularity <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include <obs-module.h>
+
+#include <filesystem>
+#include <string>
+#include <vector>
+
+namespace OBS {
+
+struct ModuleInfo {
+	std::string display_name;
+	std::string module_name;
+	std::string id;
+	std::string version;
+	bool enabled;
+	bool enabledAtLaunch;
+	std::vector<std::string> sources;
+	std::vector<std::string> outputs;
+	std::vector<std::string> encoders;
+	std::vector<std::string> services;
+	std::vector<std::string> sourcesLoaded;
+	std::vector<std::string> outputsLoaded;
+	std::vector<std::string> encodersLoaded;
+	std::vector<std::string> servicesLoaded;
+};
+
+class PluginManager {
+private:
+	std::vector<ModuleInfo> modules_ = {};
+	std::vector<std::string> disabledSources_ = {};
+	std::vector<std::string> disabledOutputs_ = {};
+	std::vector<std::string> disabledServices_ = {};
+	std::vector<std::string> disabledEncoders_ = {};
+	std::filesystem::path getConfigFilePath_();
+	void loadModules_();
+	void saveModules_();
+	void disableModules_();
+	void addModuleTypes_();
+	void linkUnloadedModules_();
+
+public:
+	void preLoad();
+	void postLoad();
+	void open();
+
+	friend void addModuleToPluginManagerImpl(void *param, obs_module_t *newModule);
+};
+
+void addModuleToPluginManagerImpl(void *param, obs_module_t *newModule);
+
+}; // namespace OBS
+
+// Anonymous namespace function to add module to plugin manager
+// via libobs's module enumeration.
+namespace {
+inline void addModuleToPluginManager(void *param, obs_module_t *newModule)
+{
+	OBS::addModuleToPluginManagerImpl(param, newModule);
+}
+} // namespace

+ 79 - 0
frontend/plugin-manager/PluginManagerWindow.cpp

@@ -0,0 +1,79 @@
+/******************************************************************************
+    Copyright (C) 2025 by FiniteSingularity <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#include "PluginManagerWindow.hpp"
+
+#include <OBSApp.hpp>
+
+#include <QCheckBox>
+#include <QDialogButtonBox>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QScrollArea>
+#include <QString>
+#include <QVBoxLayout>
+
+#include "moc_PluginManagerWindow.cpp"
+
+namespace OBS {
+
+PluginManagerWindow::PluginManagerWindow(std::vector<ModuleInfo> const &modules, QWidget *parent)
+	: QDialog(parent),
+	  modules_(modules),
+	  ui(new Ui::PluginManagerWindow)
+{
+	setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+	ui->setupUi(this);
+
+	std::sort(modules_.begin(), modules_.end(), [](const ModuleInfo &a, const ModuleInfo &b) {
+		std::string aName = !a.display_name.empty() ? a.display_name : a.module_name;
+		std::string bName = !b.display_name.empty() ? b.display_name : b.module_name;
+		return aName < bName;
+	});
+
+	for (auto &metadata : modules_) {
+		std::string id = metadata.module_name;
+		// Check if the module is missing:
+		bool missing = !obs_get_module(id.c_str()) && !obs_get_disabled_module(id.c_str());
+
+		QString name = !metadata.display_name.empty() ? metadata.display_name.c_str()
+							      : metadata.module_name.c_str();
+		if (missing) {
+			name += " " + QTStr("PluginManager.MissingPlugin");
+		}
+		auto item = new QListWidgetItem(name);
+		item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
+		item->setCheckState(metadata.enabled ? Qt::Checked : Qt::Unchecked);
+
+		if (missing) {
+			item->setFlags(item->flags() & ~Qt::ItemIsEnabled);
+		}
+		ui->modulesList->addItem(item);
+	}
+
+	connect(ui->modulesList, &QListWidget::itemChanged, this, [this](QListWidgetItem *item) {
+		auto row = ui->modulesList->row(item);
+		bool checked = item->checkState() == Qt::Checked;
+		modules_[row].enabled = checked;
+	});
+
+	connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
+	connect(ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+}; // namespace OBS

+ 40 - 0
frontend/plugin-manager/PluginManagerWindow.hpp

@@ -0,0 +1,40 @@
+/******************************************************************************
+    Copyright (C) 2025 by FiniteSingularity <[email protected]>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+******************************************************************************/
+
+#pragma once
+
+#include "ui_PluginManagerWindow.h"
+#include "PluginManager.hpp"
+
+#include <QDialog>
+#include <QWidget>
+
+namespace OBS {
+
+class PluginManagerWindow : public QDialog {
+	Q_OBJECT
+	std::unique_ptr<Ui::PluginManagerWindow> ui;
+
+public:
+	explicit PluginManagerWindow(std::vector<ModuleInfo> const &modules, QWidget *parent = nullptr);
+	inline std::vector<ModuleInfo> const result() { return modules_; }
+
+private:
+	std::vector<ModuleInfo> modules_;
+};
+
+}; // namespace OBS

+ 33 - 10
frontend/widgets/OBSBasic.cpp

@@ -22,8 +22,11 @@
 #include "ColorSelect.hpp"
 #include "OBSBasicControls.hpp"
 #include "OBSBasicStats.hpp"
+#include "plugin-manager/PluginManager.hpp"
 #include "VolControl.hpp"
 
+#include <obs-module.h>
+
 #ifdef YOUTUBE_ENABLED
 #include <docks/YouTubeAppDock.hpp>
 #endif
@@ -184,9 +187,9 @@ static void SetSafeModuleNames()
 	return;
 #else
 	string module;
-	stringstream modules(SAFE_MODULES);
+	stringstream modules_(SAFE_MODULES);
 
-	while (getline(modules, module, '|')) {
+	while (getline(modules_, module, '|')) {
 		/* When only disallowing third-party plugins, still add
 		 * "unsafe" bundled modules to the safe list. */
 		if (disable_3p_plugins || !unsafe_modules.count(module))
@@ -195,6 +198,24 @@ static void SetSafeModuleNames()
 #endif
 }
 
+static void SetCoreModuleNames()
+{
+#ifndef SAFE_MODULES
+	throw "SAFE_MODULES not defined";
+#else
+	std::string safeModules = SAFE_MODULES;
+	if (safeModules.empty()) {
+		throw "SAFE_MODULES is empty";
+	}
+	string module;
+	stringstream modules_(SAFE_MODULES);
+
+	while (getline(modules_, module, '|')) {
+		obs_add_core_module(module.c_str());
+	}
+#endif
+}
+
 extern void setupDockAction(QDockWidget *dock);
 
 OBSBasic::OBSBasic(QWidget *parent) : OBSMainWindow(parent), undo_s(ui), ui(new Ui::OBSBasic)
@@ -996,26 +1017,23 @@ void OBSBasic::OBSInit()
 #endif
 	struct obs_module_failure_info mfi;
 
-	/* Safe Mode disables third-party plugins so we don't need to add earch
-	 * paths outside the OBS bundle/installation. */
+	// Safe Mode disables third-party plugins so we don't need to add each path outside the OBS bundle/installation.
 	if (safe_mode || disable_3p_plugins) {
 		SetSafeModuleNames();
 	} else {
 		AddExtraModulePaths();
 	}
 
+	// Core modules are not allowed to be disabled by the user via plugin manager.
+	SetCoreModuleNames();
+
 	/* Modules can access frontend information (i.e. profile and scene collection data) during their initialization, and some modules (e.g. obs-websockets) are known to use the filesystem location of the current profile in their own code.
 
      Thus the profile and scene collection discovery needs to happen before any access to that information (but after intializing global settings) to ensure legacy code gets valid path information.
      */
 	RefreshSceneCollections(true);
 
-	blog(LOG_INFO, "---------------------------------");
-	obs_load_all_modules2(&mfi);
-	blog(LOG_INFO, "---------------------------------");
-	obs_log_loaded_modules();
-	blog(LOG_INFO, "---------------------------------");
-	obs_post_load_modules();
+	App()->loadAppModules(mfi);
 
 	BPtr<char *> failed_modules = mfi.failed_modules;
 
@@ -2070,3 +2088,8 @@ OBSPromptResult OBSBasic::PromptForName(const OBSPromptRequest &request, const O
 
 	return result;
 }
+
+void OBSBasic::on_actionOpenPluginManager_triggered()
+{
+	App()->pluginManagerOpenDialog();
+}

+ 3 - 0
frontend/widgets/OBSBasic.hpp

@@ -762,6 +762,9 @@ public:
 private slots:
 	void ResizeOutputSizeOfSource();
 
+private slots:
+	void on_actionOpenPluginManager_triggered();
+
 	/* -------------------------------------
 	 * MARK: - OBSBasic_Preview
 	 * -------------------------------------