mirror of
https://github.com/ModOrganizer2/modorganizer.git
synced 2026-07-27 13:58:24 -07:00
1246 lines
37 KiB
C++
1246 lines
37 KiB
C++
#include "plugincontainer.h"
|
|
#include "iuserinterface.h"
|
|
#include "organizercore.h"
|
|
#include "organizerproxy.h"
|
|
#include "report.h"
|
|
#include "shared/appconfig.h"
|
|
#include <QAction>
|
|
#include <QCoreApplication>
|
|
#include <QDirIterator>
|
|
#include <QMessageBox>
|
|
#include <QToolButton>
|
|
#include <boost/fusion/algorithm/iteration/for_each.hpp>
|
|
#include <boost/fusion/include/at_key.hpp>
|
|
#include <boost/fusion/include/for_each.hpp>
|
|
#include <boost/fusion/sequence/intrinsic/at_key.hpp>
|
|
#include <idownloadmanager.h>
|
|
#include <ipluginproxy.h>
|
|
|
|
using namespace MOBase;
|
|
using namespace MOShared;
|
|
|
|
namespace bf = boost::fusion;
|
|
|
|
// Welcome to the wonderful world of MO2 plugin management!
|
|
//
|
|
// We'll start by the C++ side.
|
|
//
|
|
// There are 9 types of MO2 plugins, two of which cannot be standalone: IPluginDiagnose
|
|
// and IPluginFileMapper. This means that you can have a class implementing IPluginGame,
|
|
// IPluginDiagnose and IPluginFileMapper. It is not possible for a class to implement
|
|
// two full plugin types (e.g. IPluginPreview and IPluginTool).
|
|
//
|
|
// Plugins are fetch as QObject initially and must be "qobject-casted" to the right
|
|
// type.
|
|
//
|
|
// Plugins are stored in the PluginContainer class in various C++ containers: there is a
|
|
// vector that stores all the plugin as QObject, multiple vectors that stores the plugin
|
|
// of each types, a map to find IPlugin object from their names or from IPluginDiagnose
|
|
// or IFileMapper (since these do not inherit IPlugin, they cannot be downcasted).
|
|
//
|
|
// Requirements for plugins are stored in m_Requirements:
|
|
// - IPluginGame cannot be enabled by user. A game plugin is considered enable only if
|
|
// it is
|
|
// the one corresponding to the currently managed games.
|
|
// - If a plugin has a master plugin (IPlugin::master()), it cannot be enabled/disabled
|
|
// by users,
|
|
// and will follow the enabled/disabled state of its parent.
|
|
// - Each plugin has an "enabled" setting stored in persistence. If the setting does
|
|
// not exist,
|
|
// the plugin's enabledByDefault is used instead.
|
|
// - A plugin is considered disabled if the setting is false.
|
|
// - If the setting is true, a plugin is considered disabled if one of its
|
|
// requirements is not met.
|
|
// - Users cannot enable a plugin if one of its requirements is not met.
|
|
//
|
|
// Now let's move to the Proxy side... Or the as of now, the Python side.
|
|
//
|
|
// Proxied plugins are much more annoying because they can implement all interfaces, and
|
|
// are given to MO2 as separate plugins... A Python class implementing IPluginGame and
|
|
// IPluginDiagnose will be seen by MO2 as two separate QObject, and they will all have
|
|
// the same name.
|
|
//
|
|
// When a proxied plugin is registered, a few things must be taken care of:
|
|
// - There can only be one plugin mapped to a name in the PluginContainer class, so we
|
|
// keep the
|
|
// plugin corresponding to the most relevant class (see PluginTypeOrder), e.g. if the
|
|
// class inherits both IPluginGame and IPluginFileMapper, we map the name to the C++
|
|
// QObject corresponding to the IPluginGame.
|
|
// - When a proxied plugin implements multiple interfaces, the IPlugin corresponding to
|
|
// the most
|
|
// important interface is set as the parent (hidden) of the other IPlugin through
|
|
// PluginRequirements. This way, the plugin are managed together (enabled/disabled
|
|
// state). The "fake" children plugins will not be returned by
|
|
// PluginRequirements::children().
|
|
// - Since each interface corresponds to a different QObject, we need to take care not
|
|
// to call
|
|
// IPlugin::init() on each QObject, but only on the first one.
|
|
//
|
|
// All the proxied plugins are linked to the proxy plugin by PluginRequirements. If the
|
|
// proxy plugin is disabled, the proxied plugins are not even loaded so not visible in
|
|
// the plugin management tab.
|
|
|
|
template <class T>
|
|
struct PluginTypeName;
|
|
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPlugin>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Plugin"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginDiagnose>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Diagnose"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginGame>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Game"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginInstaller>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Installer"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginModPage>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Mod Page"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginPreview>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Preview"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginTool>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Tool"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginProxy>
|
|
{
|
|
static QString value() { return PluginContainer::tr("Proxy"); }
|
|
};
|
|
template <>
|
|
struct PluginTypeName<MOBase::IPluginFileMapper>
|
|
{
|
|
static QString value() { return PluginContainer::tr("File Mapper"); }
|
|
};
|
|
|
|
QStringList PluginContainer::pluginInterfaces()
|
|
{
|
|
// Find all the names:
|
|
QStringList names;
|
|
boost::mp11::mp_for_each<PluginTypeOrder>([&names](const auto* p) {
|
|
using plugin_type = std::decay_t<decltype(*p)>;
|
|
auto name = PluginTypeName<plugin_type>::value();
|
|
if (!name.isEmpty()) {
|
|
names.append(name);
|
|
}
|
|
});
|
|
|
|
return names;
|
|
}
|
|
|
|
// PluginRequirementProxy
|
|
|
|
const std::set<QString> PluginRequirements::s_CorePlugins{"INI Bakery"};
|
|
|
|
PluginRequirements::PluginRequirements(PluginContainer* pluginContainer,
|
|
MOBase::IPlugin* plugin, OrganizerProxy* proxy,
|
|
MOBase::IPluginProxy* pluginProxy)
|
|
: m_PluginContainer(pluginContainer), m_Plugin(plugin), m_PluginProxy(pluginProxy),
|
|
m_Master(nullptr), m_Organizer(proxy)
|
|
{
|
|
// There are a lots of things we cannot set here (e.g. m_Master) because we do not
|
|
// know the order plugins are loaded.
|
|
}
|
|
|
|
void PluginRequirements::fetchRequirements()
|
|
{
|
|
m_Requirements = m_Plugin->requirements();
|
|
}
|
|
|
|
IPluginProxy* PluginRequirements::proxy() const
|
|
{
|
|
return m_PluginProxy;
|
|
}
|
|
|
|
std::vector<IPlugin*> PluginRequirements::proxied() const
|
|
{
|
|
std::vector<IPlugin*> children;
|
|
if (dynamic_cast<IPluginProxy*>(m_Plugin)) {
|
|
for (auto* obj : m_PluginContainer->plugins<QObject>()) {
|
|
auto* plugin = qobject_cast<IPlugin*>(obj);
|
|
if (plugin && m_PluginContainer->requirements(plugin).proxy() == m_Plugin) {
|
|
children.push_back(plugin);
|
|
}
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
|
|
IPlugin* PluginRequirements::master() const
|
|
{
|
|
// If we have a m_Master, it was forced and thus override the default master().
|
|
if (m_Master) {
|
|
return m_Master;
|
|
}
|
|
|
|
if (m_Plugin->master().isEmpty()) {
|
|
return nullptr;
|
|
}
|
|
|
|
return m_PluginContainer->plugin(m_Plugin->master());
|
|
}
|
|
|
|
void PluginRequirements::setMaster(IPlugin* master)
|
|
{
|
|
m_Master = master;
|
|
}
|
|
|
|
std::vector<IPlugin*> PluginRequirements::children() const
|
|
{
|
|
std::vector<IPlugin*> children;
|
|
for (auto* obj : m_PluginContainer->plugins<QObject>()) {
|
|
auto* plugin = qobject_cast<IPlugin*>(obj);
|
|
|
|
// Not checking master() but requirements().master() due to "hidden"
|
|
// masters.
|
|
// If the master has the same name as the plugin, this is a "hidden"
|
|
// master, we do not add it here.
|
|
if (plugin && m_PluginContainer->requirements(plugin).master() == m_Plugin &&
|
|
plugin->name() != m_Plugin->name()) {
|
|
children.push_back(plugin);
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
|
|
std::vector<IPluginRequirement::Problem> PluginRequirements::problems() const
|
|
{
|
|
std::vector<IPluginRequirement::Problem> result;
|
|
for (auto& requirement : m_Requirements) {
|
|
if (auto p = requirement->check(m_Organizer)) {
|
|
result.push_back(*p);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
bool PluginRequirements::canEnable() const
|
|
{
|
|
return problems().empty();
|
|
}
|
|
|
|
bool PluginRequirements::isCorePlugin() const
|
|
{
|
|
// Let's consider game plugins as "core":
|
|
if (m_PluginContainer->implementInterface<IPluginGame>(m_Plugin)) {
|
|
return true;
|
|
}
|
|
|
|
return s_CorePlugins.contains(m_Plugin->name());
|
|
}
|
|
|
|
bool PluginRequirements::hasRequirements() const
|
|
{
|
|
return !m_Requirements.empty();
|
|
}
|
|
|
|
QStringList PluginRequirements::requiredGames() const
|
|
{
|
|
// We look for a "GameDependencyRequirement" - There can be only one since otherwise
|
|
// it'd mean that the plugin requires two games at once.
|
|
for (auto& requirement : m_Requirements) {
|
|
if (auto* gdep =
|
|
dynamic_cast<const GameDependencyRequirement*>(requirement.get())) {
|
|
return gdep->gameNames();
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
std::vector<MOBase::IPlugin*> PluginRequirements::requiredFor() const
|
|
{
|
|
std::vector<MOBase::IPlugin*> required;
|
|
std::set<MOBase::IPlugin*> visited;
|
|
requiredFor(required, visited);
|
|
return required;
|
|
}
|
|
|
|
void PluginRequirements::requiredFor(std::vector<MOBase::IPlugin*>& required,
|
|
std::set<MOBase::IPlugin*>& visited) const
|
|
{
|
|
// Handle cyclic dependencies.
|
|
if (visited.contains(m_Plugin)) {
|
|
return;
|
|
}
|
|
visited.insert(m_Plugin);
|
|
|
|
for (auto& [plugin, requirements] : m_PluginContainer->m_Requirements) {
|
|
|
|
// If the plugin is not enabled, discard:
|
|
if (!m_PluginContainer->isEnabled(plugin)) {
|
|
continue;
|
|
}
|
|
|
|
// Check the requirements:
|
|
for (auto& requirement : requirements.m_Requirements) {
|
|
|
|
// We check for plugin dependency. Game dependency are not checked this way.
|
|
if (auto* pdep =
|
|
dynamic_cast<const PluginDependencyRequirement*>(requirement.get())) {
|
|
|
|
// Check if at least one of the plugin in the requirements is enabled (except
|
|
// this one):
|
|
bool oneEnabled = false;
|
|
for (auto& pluginName : pdep->pluginNames()) {
|
|
if (pluginName != m_Plugin->name() &&
|
|
m_PluginContainer->isEnabled(pluginName)) {
|
|
oneEnabled = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// No plugin enabled found, so the plugin requires this plugin:
|
|
if (!oneEnabled) {
|
|
required.push_back(plugin);
|
|
requirements.requiredFor(required, visited);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// PluginContainer
|
|
|
|
PluginContainer::PluginContainer(OrganizerCore* organizer)
|
|
: m_Organizer(organizer), m_UserInterface(nullptr),
|
|
m_GameFeatures(std::make_unique<GameFeatures>(organizer, this)),
|
|
m_PreviewGenerator(*this)
|
|
{}
|
|
|
|
PluginContainer::~PluginContainer()
|
|
{
|
|
m_Organizer = nullptr;
|
|
unloadPlugins();
|
|
}
|
|
|
|
void PluginContainer::startPlugins(IUserInterface* userInterface)
|
|
{
|
|
m_UserInterface = userInterface;
|
|
startPluginsImpl(plugins<QObject>());
|
|
}
|
|
|
|
QStringList PluginContainer::implementedInterfaces(IPlugin* plugin) const
|
|
{
|
|
// We need a QObject to be able to qobject_cast<> to the plugin types:
|
|
QObject* oPlugin = as_qobject(plugin);
|
|
|
|
if (!oPlugin) {
|
|
return {};
|
|
}
|
|
|
|
return implementedInterfaces(oPlugin);
|
|
}
|
|
|
|
QStringList PluginContainer::implementedInterfaces(QObject* oPlugin) const
|
|
{
|
|
// Find all the names:
|
|
QStringList names;
|
|
boost::mp11::mp_for_each<PluginTypeOrder>([oPlugin, &names](const auto* p) {
|
|
using plugin_type = std::decay_t<decltype(*p)>;
|
|
if (qobject_cast<plugin_type*>(oPlugin)) {
|
|
auto name = PluginTypeName<plugin_type>::value();
|
|
if (!name.isEmpty()) {
|
|
names.append(name);
|
|
}
|
|
}
|
|
});
|
|
|
|
// If the plugin implements at least one interface other than IPlugin, remove IPlugin:
|
|
if (names.size() > 1) {
|
|
names.removeAll(PluginTypeName<IPlugin>::value());
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
QString PluginContainer::topImplementedInterface(IPlugin* plugin) const
|
|
{
|
|
auto interfaces = implementedInterfaces(plugin);
|
|
return interfaces.isEmpty() ? "" : interfaces[0];
|
|
}
|
|
|
|
bool PluginContainer::isBetterInterface(QObject* lhs, QObject* rhs) const
|
|
{
|
|
int count = 0, lhsIdx = -1, rhsIdx = -1;
|
|
boost::mp11::mp_for_each<PluginTypeOrder>([&](const auto* p) {
|
|
using plugin_type = std::decay_t<decltype(*p)>;
|
|
if (lhsIdx < 0 && qobject_cast<plugin_type*>(lhs)) {
|
|
lhsIdx = count;
|
|
}
|
|
if (rhsIdx < 0 && qobject_cast<plugin_type*>(rhs)) {
|
|
rhsIdx = count;
|
|
}
|
|
++count;
|
|
});
|
|
return lhsIdx < rhsIdx;
|
|
}
|
|
|
|
QStringList PluginContainer::pluginFileNames() const
|
|
{
|
|
QStringList result;
|
|
for (QPluginLoader* loader : m_PluginLoaders) {
|
|
result.append(loader->fileName());
|
|
}
|
|
std::vector<IPluginProxy*> proxyList = bf::at_key<IPluginProxy>(m_Plugins);
|
|
for (IPluginProxy* proxy : proxyList) {
|
|
QStringList proxiedPlugins =
|
|
proxy->pluginList(QCoreApplication::applicationDirPath() + "/" +
|
|
ToQString(AppConfig::pluginPath()));
|
|
result.append(proxiedPlugins);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QObject* PluginContainer::as_qobject(MOBase::IPlugin* plugin) const
|
|
{
|
|
// Find the correspond QObject - Can this be done safely with a cast?
|
|
auto& objects = bf::at_key<QObject>(m_Plugins);
|
|
auto it =
|
|
std::find_if(std::begin(objects), std::end(objects), [plugin](QObject* obj) {
|
|
return qobject_cast<IPlugin*>(obj) == plugin;
|
|
});
|
|
|
|
if (it == std::end(objects)) {
|
|
return nullptr;
|
|
}
|
|
|
|
return *it;
|
|
}
|
|
|
|
bool PluginContainer::initPlugin(IPlugin* plugin, IPluginProxy* pluginProxy,
|
|
bool skipInit)
|
|
{
|
|
// when MO has no instance loaded, init() is not called on plugins, except
|
|
// for proxy plugins, where init() is called with a null IOrganizer
|
|
//
|
|
// after proxies are initialized, instantiate() is called for all the plugins
|
|
// they've discovered, but as for regular plugins, init() won't be
|
|
// called on them if m_OrganizerCore is null
|
|
|
|
if (plugin == nullptr) {
|
|
return false;
|
|
}
|
|
|
|
OrganizerProxy* proxy = nullptr;
|
|
if (m_Organizer) {
|
|
proxy = new OrganizerProxy(m_Organizer, this, plugin);
|
|
proxy->setParent(as_qobject(plugin));
|
|
}
|
|
|
|
// Check if it is a proxy plugin:
|
|
bool isProxy = dynamic_cast<IPluginProxy*>(plugin);
|
|
|
|
auto [it, bl] = m_Requirements.emplace(
|
|
plugin, PluginRequirements(this, plugin, proxy, pluginProxy));
|
|
|
|
if (!m_Organizer && !isProxy) {
|
|
return true;
|
|
}
|
|
|
|
if (skipInit) {
|
|
return true;
|
|
}
|
|
|
|
if (!plugin->init(proxy)) {
|
|
log::warn("plugin failed to initialize");
|
|
return false;
|
|
}
|
|
|
|
// Update requirements:
|
|
it->second.fetchRequirements();
|
|
|
|
return true;
|
|
}
|
|
|
|
void PluginContainer::registerGame(IPluginGame* game)
|
|
{
|
|
m_SupportedGames.insert({game->gameName(), game});
|
|
}
|
|
|
|
void PluginContainer::unregisterGame(MOBase::IPluginGame* game)
|
|
{
|
|
m_SupportedGames.erase(game->gameName());
|
|
}
|
|
|
|
IPlugin* PluginContainer::registerPlugin(QObject* plugin, const QString& filepath,
|
|
MOBase::IPluginProxy* pluginProxy)
|
|
{
|
|
|
|
// generic treatment for all plugins
|
|
IPlugin* pluginObj = qobject_cast<IPlugin*>(plugin);
|
|
if (pluginObj == nullptr) {
|
|
log::debug("PluginContainer::registerPlugin() called with a non IPlugin QObject.");
|
|
return nullptr;
|
|
}
|
|
|
|
// If we already a plugin with this name:
|
|
bool skipInit = false;
|
|
auto& mapNames = bf::at_key<QString>(m_AccessPlugins);
|
|
if (mapNames.contains(pluginObj->name())) {
|
|
|
|
IPlugin* other = mapNames[pluginObj->name()];
|
|
|
|
// If both plugins are from the same proxy and the same file, this is usually
|
|
// ok (in theory some one could write two different classes from the same Python
|
|
// file/module):
|
|
if (pluginProxy && m_Requirements.at(other).proxy() == pluginProxy &&
|
|
this->filepath(other) == QDir::cleanPath(filepath)) {
|
|
|
|
// Plugin has already been initialized:
|
|
skipInit = true;
|
|
|
|
if (isBetterInterface(plugin, as_qobject(other))) {
|
|
log::debug(
|
|
"replacing plugin '{}' with interfaces [{}] by one with interfaces [{}]",
|
|
pluginObj->name(), implementedInterfaces(other).join(", "),
|
|
implementedInterfaces(plugin).join(", "));
|
|
bf::at_key<QString>(m_AccessPlugins)[pluginObj->name()] = pluginObj;
|
|
}
|
|
} else {
|
|
log::warn("Trying to register two plugins with the name '{}' (from {} and {}), "
|
|
"the second one will not be registered.",
|
|
pluginObj->name(), this->filepath(other), QDir::cleanPath(filepath));
|
|
return nullptr;
|
|
}
|
|
} else {
|
|
bf::at_key<QString>(m_AccessPlugins)[pluginObj->name()] = pluginObj;
|
|
}
|
|
|
|
// Storing the original QObject* is a bit of a hack as I couldn't figure out any
|
|
// way to cast directly between IPlugin* and IPluginDiagnose*
|
|
bf::at_key<QObject>(m_Plugins).push_back(plugin);
|
|
|
|
plugin->setProperty("filepath", QDir::cleanPath(filepath));
|
|
plugin->setParent(this);
|
|
|
|
if (m_Organizer) {
|
|
m_Organizer->settings().plugins().registerPlugin(pluginObj);
|
|
}
|
|
|
|
{ // diagnosis plugin
|
|
IPluginDiagnose* diagnose = qobject_cast<IPluginDiagnose*>(plugin);
|
|
if (diagnose != nullptr) {
|
|
bf::at_key<IPluginDiagnose>(m_Plugins).push_back(diagnose);
|
|
bf::at_key<IPluginDiagnose>(m_AccessPlugins)[diagnose] = pluginObj;
|
|
diagnose->onInvalidated([&]() {
|
|
emit diagnosisUpdate();
|
|
});
|
|
}
|
|
}
|
|
{ // file mapper plugin
|
|
IPluginFileMapper* mapper = qobject_cast<IPluginFileMapper*>(plugin);
|
|
if (mapper != nullptr) {
|
|
bf::at_key<IPluginFileMapper>(m_Plugins).push_back(mapper);
|
|
bf::at_key<IPluginFileMapper>(m_AccessPlugins)[mapper] = pluginObj;
|
|
}
|
|
}
|
|
{ // mod page plugin
|
|
IPluginModPage* modPage = qobject_cast<IPluginModPage*>(plugin);
|
|
if (initPlugin(modPage, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginModPage>(m_Plugins).push_back(modPage);
|
|
emit pluginRegistered(modPage);
|
|
return modPage;
|
|
}
|
|
}
|
|
{ // game plugin
|
|
IPluginGame* game = qobject_cast<IPluginGame*>(plugin);
|
|
if (game) {
|
|
game->detectGame();
|
|
if (initPlugin(game, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginGame>(m_Plugins).push_back(game);
|
|
registerGame(game);
|
|
emit pluginRegistered(game);
|
|
return game;
|
|
}
|
|
}
|
|
}
|
|
{ // tool plugins
|
|
IPluginTool* tool = qobject_cast<IPluginTool*>(plugin);
|
|
if (initPlugin(tool, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginTool>(m_Plugins).push_back(tool);
|
|
emit pluginRegistered(tool);
|
|
return tool;
|
|
}
|
|
}
|
|
{ // installer plugins
|
|
IPluginInstaller* installer = qobject_cast<IPluginInstaller*>(plugin);
|
|
if (initPlugin(installer, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginInstaller>(m_Plugins).push_back(installer);
|
|
if (m_Organizer) {
|
|
installer->setInstallationManager(m_Organizer->installationManager());
|
|
}
|
|
emit pluginRegistered(installer);
|
|
return installer;
|
|
}
|
|
}
|
|
{ // preview plugins
|
|
IPluginPreview* preview = qobject_cast<IPluginPreview*>(plugin);
|
|
if (initPlugin(preview, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginPreview>(m_Plugins).push_back(preview);
|
|
return preview;
|
|
}
|
|
}
|
|
{ // proxy plugins
|
|
IPluginProxy* proxy = qobject_cast<IPluginProxy*>(plugin);
|
|
if (initPlugin(proxy, pluginProxy, skipInit)) {
|
|
bf::at_key<IPluginProxy>(m_Plugins).push_back(proxy);
|
|
emit pluginRegistered(proxy);
|
|
|
|
QStringList filepaths =
|
|
proxy->pluginList(QCoreApplication::applicationDirPath() + "/" +
|
|
ToQString(AppConfig::pluginPath()));
|
|
for (const QString& filepath : filepaths) {
|
|
loadProxied(filepath, proxy);
|
|
}
|
|
return proxy;
|
|
}
|
|
}
|
|
|
|
{ // dummy plugins
|
|
// only initialize these, no processing otherwise
|
|
IPlugin* dummy = qobject_cast<IPlugin*>(plugin);
|
|
if (initPlugin(dummy, pluginProxy, skipInit)) {
|
|
bf::at_key<IPlugin>(m_Plugins).push_back(dummy);
|
|
emit pluginRegistered(dummy);
|
|
return dummy;
|
|
}
|
|
}
|
|
|
|
return nullptr;
|
|
}
|
|
|
|
IPluginGame* PluginContainer::managedGame() const
|
|
{
|
|
// TODO: This const_cast is safe but ugly. Most methods require a IPlugin*, so
|
|
// returning a const-version if painful. This should be fixed by making methods accept
|
|
// a const IPlugin* instead, but there are a few tricks with qobject_cast and const.
|
|
return m_Organizer ? const_cast<IPluginGame*>(m_Organizer->managedGame()) : nullptr;
|
|
}
|
|
|
|
bool PluginContainer::isEnabled(IPlugin* plugin) const
|
|
{
|
|
// Check if it's a game plugin:
|
|
if (implementInterface<IPluginGame>(plugin)) {
|
|
return plugin == m_Organizer->managedGame();
|
|
}
|
|
|
|
// Check the master, if any:
|
|
auto& requirements = m_Requirements.at(plugin);
|
|
|
|
if (requirements.master()) {
|
|
return isEnabled(requirements.master());
|
|
}
|
|
|
|
// Check if the plugin is enabled:
|
|
if (!m_Organizer->persistent(plugin->name(), "enabled", plugin->enabledByDefault())
|
|
.toBool()) {
|
|
return false;
|
|
}
|
|
|
|
// Check the requirements:
|
|
return m_Requirements.at(plugin).canEnable();
|
|
}
|
|
|
|
void PluginContainer::setEnabled(MOBase::IPlugin* plugin, bool enable,
|
|
bool dependencies)
|
|
{
|
|
// If required, disable dependencies:
|
|
if (!enable && dependencies) {
|
|
for (auto* p : requirements(plugin).requiredFor()) {
|
|
// No need to "recurse" here since requiredFor already does it.
|
|
setEnabled(p, false, false);
|
|
}
|
|
}
|
|
|
|
// Always disable/enable child plugins:
|
|
for (auto* p : requirements(plugin).children()) {
|
|
// "Child" plugin should have no dependencies.
|
|
setEnabled(p, enable, false);
|
|
}
|
|
|
|
m_Organizer->setPersistent(plugin->name(), "enabled", enable, true);
|
|
|
|
if (enable) {
|
|
emit pluginEnabled(plugin);
|
|
} else {
|
|
emit pluginDisabled(plugin);
|
|
}
|
|
}
|
|
|
|
MOBase::IPlugin* PluginContainer::plugin(QString const& pluginName) const
|
|
{
|
|
auto& map = bf::at_key<QString>(m_AccessPlugins);
|
|
auto it = map.find(pluginName);
|
|
if (it == std::end(map)) {
|
|
return nullptr;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginDiagnose* diagnose) const
|
|
{
|
|
auto& map = bf::at_key<IPluginDiagnose>(m_AccessPlugins);
|
|
auto it = map.find(diagnose);
|
|
if (it == std::end(map)) {
|
|
return nullptr;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
MOBase::IPlugin* PluginContainer::plugin(MOBase::IPluginFileMapper* mapper) const
|
|
{
|
|
auto& map = bf::at_key<IPluginFileMapper>(m_AccessPlugins);
|
|
auto it = map.find(mapper);
|
|
if (it == std::end(map)) {
|
|
return nullptr;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
bool PluginContainer::isEnabled(QString const& pluginName) const
|
|
{
|
|
IPlugin* p = plugin(pluginName);
|
|
return p ? isEnabled(p) : false;
|
|
}
|
|
bool PluginContainer::isEnabled(MOBase::IPluginDiagnose* diagnose) const
|
|
{
|
|
IPlugin* p = plugin(diagnose);
|
|
return p ? isEnabled(p) : false;
|
|
}
|
|
bool PluginContainer::isEnabled(MOBase::IPluginFileMapper* mapper) const
|
|
{
|
|
IPlugin* p = plugin(mapper);
|
|
return p ? isEnabled(p) : false;
|
|
}
|
|
|
|
const PluginRequirements& PluginContainer::requirements(IPlugin* plugin) const
|
|
{
|
|
return m_Requirements.at(plugin);
|
|
}
|
|
|
|
OrganizerProxy* PluginContainer::organizerProxy(MOBase::IPlugin* plugin) const
|
|
{
|
|
return requirements(plugin).m_Organizer;
|
|
}
|
|
|
|
MOBase::IPluginProxy* PluginContainer::pluginProxy(MOBase::IPlugin* plugin) const
|
|
{
|
|
return requirements(plugin).proxy();
|
|
}
|
|
|
|
QString PluginContainer::filepath(MOBase::IPlugin* plugin) const
|
|
{
|
|
return as_qobject(plugin)->property("filepath").toString();
|
|
}
|
|
|
|
IPluginGame* PluginContainer::game(const QString& name) const
|
|
{
|
|
auto iter = m_SupportedGames.find(name);
|
|
if (iter != m_SupportedGames.end()) {
|
|
return iter->second;
|
|
} else {
|
|
return nullptr;
|
|
}
|
|
}
|
|
|
|
void PluginContainer::startPluginsImpl(const std::vector<QObject*>& plugins) const
|
|
{
|
|
// setUserInterface()
|
|
if (m_UserInterface) {
|
|
for (auto* plugin : plugins) {
|
|
if (auto* proxy = qobject_cast<IPluginProxy*>(plugin)) {
|
|
proxy->setParentWidget(m_UserInterface->mainWindow());
|
|
}
|
|
if (auto* modPage = qobject_cast<IPluginModPage*>(plugin)) {
|
|
modPage->setParentWidget(m_UserInterface->mainWindow());
|
|
}
|
|
if (auto* tool = qobject_cast<IPluginTool*>(plugin)) {
|
|
tool->setParentWidget(m_UserInterface->mainWindow());
|
|
}
|
|
if (auto* installer = qobject_cast<IPluginInstaller*>(plugin)) {
|
|
installer->setParentWidget(m_UserInterface->mainWindow());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trigger initial callbacks, e.g. onUserInterfaceInitialized and onProfileChanged.
|
|
if (m_Organizer) {
|
|
for (auto* object : plugins) {
|
|
auto* plugin = qobject_cast<IPlugin*>(object);
|
|
auto* oproxy = organizerProxy(plugin);
|
|
oproxy->connectSignals();
|
|
oproxy->m_ProfileChanged(nullptr, m_Organizer->currentProfile().get());
|
|
|
|
if (m_UserInterface) {
|
|
oproxy->m_UserInterfaceInitialized(m_UserInterface->mainWindow());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
std::vector<QObject*> PluginContainer::loadProxied(const QString& filepath,
|
|
IPluginProxy* proxy)
|
|
{
|
|
std::vector<QObject*> proxiedPlugins;
|
|
|
|
try {
|
|
// We get a list of matching plugins as proxies can return multiple plugins
|
|
// per file and do not have a good way of supporting multiple inheritance.
|
|
QList<QObject*> matchingPlugins = proxy->load(filepath);
|
|
|
|
// We are going to group plugin by names and "fix" them later:
|
|
std::map<QString, std::vector<IPlugin*>> proxiedByNames;
|
|
|
|
for (QObject* proxiedPlugin : matchingPlugins) {
|
|
if (proxiedPlugin != nullptr) {
|
|
|
|
if (IPlugin* proxied = registerPlugin(proxiedPlugin, filepath, proxy);
|
|
proxied) {
|
|
log::debug("loaded plugin '{}@{}' from '{}' - [{}]", proxied->name(),
|
|
proxied->version().canonicalString(),
|
|
QFileInfo(filepath).fileName(),
|
|
implementedInterfaces(proxied).join(", "));
|
|
|
|
// Store the plugin for later:
|
|
proxiedPlugins.push_back(proxiedPlugin);
|
|
proxiedByNames[proxied->name()].push_back(proxied);
|
|
} else {
|
|
log::warn("plugin \"{}\" failed to load. If this plugin is for an older "
|
|
"version of MO "
|
|
"you have to update it or delete it if no update exists.",
|
|
filepath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fake masters:
|
|
for (auto& [name, proxiedPlugins] : proxiedByNames) {
|
|
if (proxiedPlugins.size() > 1) {
|
|
auto it = std::min_element(std::begin(proxiedPlugins), std::end(proxiedPlugins),
|
|
[&](auto const& lhs, auto const& rhs) {
|
|
return isBetterInterface(as_qobject(lhs),
|
|
as_qobject(rhs));
|
|
});
|
|
|
|
for (auto& proxiedPlugin : proxiedPlugins) {
|
|
if (proxiedPlugin != *it) {
|
|
m_Requirements.at(proxiedPlugin).setMaster(*it);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (const std::exception& e) {
|
|
reportError(
|
|
QObject::tr("failed to initialize plugin %1: %2").arg(filepath).arg(e.what()));
|
|
}
|
|
|
|
return proxiedPlugins;
|
|
}
|
|
|
|
QObject* PluginContainer::loadQtPlugin(const QString& filepath)
|
|
{
|
|
std::unique_ptr<QPluginLoader> pluginLoader(new QPluginLoader(filepath, this));
|
|
if (pluginLoader->instance() == nullptr) {
|
|
m_FailedPlugins.push_back(filepath);
|
|
log::error("failed to load plugin {}: {}", filepath, pluginLoader->errorString());
|
|
} else {
|
|
QObject* object = pluginLoader->instance();
|
|
if (IPlugin* plugin = registerPlugin(object, filepath, nullptr); plugin) {
|
|
log::debug("loaded plugin '{}@{}' from '{}' - [{}]", plugin->name(),
|
|
plugin->version().canonicalString(), QFileInfo(filepath).fileName(),
|
|
implementedInterfaces(plugin).join(", "));
|
|
m_PluginLoaders.push_back(pluginLoader.release());
|
|
return object;
|
|
} else {
|
|
m_FailedPlugins.push_back(filepath);
|
|
log::warn("plugin '{}' failed to load (may be outdated)", filepath);
|
|
}
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
std::optional<QString> PluginContainer::isQtPluginFolder(const QString& filepath) const
|
|
{
|
|
|
|
if (!QFileInfo(filepath).isDir()) {
|
|
return {};
|
|
}
|
|
|
|
QDirIterator iter(filepath, QDir::Files | QDir::NoDotAndDotDot);
|
|
while (iter.hasNext()) {
|
|
iter.next();
|
|
const auto filePath = iter.filePath();
|
|
|
|
// not a library, skip
|
|
if (!QLibrary::isLibrary(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
// check if we have proper metadata - this does not load the plugin (metaData()
|
|
// should be very lightweight)
|
|
const QPluginLoader loader(filePath);
|
|
if (!loader.metaData().isEmpty()) {
|
|
return filePath;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
void PluginContainer::loadPlugin(QString const& filepath)
|
|
{
|
|
std::vector<QObject*> plugins;
|
|
if (QFileInfo(filepath).isFile() && QLibrary::isLibrary(filepath)) {
|
|
QObject* plugin = loadQtPlugin(filepath);
|
|
if (plugin) {
|
|
plugins.push_back(plugin);
|
|
}
|
|
} else if (auto p = isQtPluginFolder(filepath)) {
|
|
QObject* plugin = loadQtPlugin(*p);
|
|
if (plugin) {
|
|
plugins.push_back(plugin);
|
|
}
|
|
} else {
|
|
// We need to check if this can be handled by a proxy.
|
|
for (auto* proxy : this->plugins<IPluginProxy>()) {
|
|
auto filepaths = proxy->pluginList(QCoreApplication::applicationDirPath() + "/" +
|
|
ToQString(AppConfig::pluginPath()));
|
|
if (filepaths.contains(filepath)) {
|
|
plugins = loadProxied(filepath, proxy);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (auto* plugin : plugins) {
|
|
emit pluginRegistered(qobject_cast<IPlugin*>(plugin));
|
|
}
|
|
|
|
startPluginsImpl(plugins);
|
|
}
|
|
|
|
void PluginContainer::unloadPlugin(MOBase::IPlugin* plugin, QObject* object)
|
|
{
|
|
if (auto* game = qobject_cast<IPluginGame*>(object)) {
|
|
|
|
if (game == managedGame()) {
|
|
throw Exception("cannot unload the plugin for the currently managed game");
|
|
}
|
|
|
|
unregisterGame(game);
|
|
}
|
|
|
|
// We need to remove from the m_Plugins maps BEFORE unloading from the proxy
|
|
// otherwise the qobject_cast to check the plugin type will not work.
|
|
bf::for_each(m_Plugins, [object](auto& t) {
|
|
using type = typename std::decay_t<decltype(t.second)>::value_type;
|
|
|
|
// We do not want to remove from QObject since we are iterating over them.
|
|
if constexpr (!std::is_same<type, QObject*>{}) {
|
|
auto itp =
|
|
std::find(t.second.begin(), t.second.end(), qobject_cast<type>(object));
|
|
if (itp != t.second.end()) {
|
|
t.second.erase(itp);
|
|
}
|
|
}
|
|
});
|
|
|
|
emit pluginUnregistered(plugin);
|
|
|
|
// Remove from the members.
|
|
if (auto* diagnose = qobject_cast<IPluginDiagnose*>(object)) {
|
|
bf::at_key<IPluginDiagnose>(m_AccessPlugins).erase(diagnose);
|
|
}
|
|
if (auto* mapper = qobject_cast<IPluginFileMapper*>(object)) {
|
|
bf::at_key<IPluginFileMapper>(m_AccessPlugins).erase(mapper);
|
|
}
|
|
|
|
auto& mapNames = bf::at_key<QString>(m_AccessPlugins);
|
|
if (mapNames.contains(plugin->name())) {
|
|
mapNames.erase(plugin->name());
|
|
}
|
|
|
|
m_Organizer->settings().plugins().unregisterPlugin(plugin);
|
|
|
|
// Force disconnection of the signals from the proxies. This is a safety
|
|
// operations since those signals should be disconnected when the proxies
|
|
// are destroyed anyway.
|
|
organizerProxy(plugin)->disconnectSignals();
|
|
|
|
// Is this a proxied plugin?
|
|
auto* proxy = pluginProxy(plugin);
|
|
|
|
if (proxy) {
|
|
proxy->unload(filepath(plugin));
|
|
} else {
|
|
// We need to find the loader.
|
|
auto it = std::find_if(m_PluginLoaders.begin(), m_PluginLoaders.end(),
|
|
[object](auto* loader) {
|
|
return loader->instance() == object;
|
|
});
|
|
|
|
if (it != m_PluginLoaders.end()) {
|
|
if (!(*it)->unload()) {
|
|
log::error("failed to unload {}: {}", (*it)->fileName(), (*it)->errorString());
|
|
}
|
|
delete *it;
|
|
m_PluginLoaders.erase(it);
|
|
} else {
|
|
log::error("loader for plugin {} does not exist, cannot unload", plugin->name());
|
|
}
|
|
}
|
|
|
|
object->deleteLater();
|
|
|
|
// Do this at the end.
|
|
m_Requirements.erase(plugin);
|
|
}
|
|
|
|
void PluginContainer::unloadPlugin(QString const& filepath)
|
|
{
|
|
// We need to find all the plugins from the given path and
|
|
// unload them:
|
|
QString cleanPath = QDir::cleanPath(filepath);
|
|
auto& objects = bf::at_key<QObject>(m_Plugins);
|
|
for (auto it = objects.begin(); it != objects.end();) {
|
|
auto* plugin = qobject_cast<IPlugin*>(*it);
|
|
if (this->filepath(plugin) == filepath) {
|
|
unloadPlugin(plugin, *it);
|
|
it = objects.erase(it);
|
|
} else {
|
|
++it;
|
|
}
|
|
}
|
|
}
|
|
|
|
void PluginContainer::reloadPlugin(QString const& filepath)
|
|
{
|
|
unloadPlugin(filepath);
|
|
loadPlugin(filepath);
|
|
}
|
|
|
|
void PluginContainer::unloadPlugins()
|
|
{
|
|
if (m_Organizer) {
|
|
// this will clear several structures that can hold on to pointers to
|
|
// plugins, as well as read the plugin blacklist from the ini file, which
|
|
// is used in loadPlugins() below to skip plugins
|
|
//
|
|
// note that the first thing loadPlugins() does is call unloadPlugins(),
|
|
// so this makes sure the blacklist is always available
|
|
m_Organizer->settings().plugins().clearPlugins();
|
|
}
|
|
|
|
bf::for_each(m_Plugins, [](auto& t) {
|
|
t.second.clear();
|
|
});
|
|
bf::for_each(m_AccessPlugins, [](auto& t) {
|
|
t.second.clear();
|
|
});
|
|
m_Requirements.clear();
|
|
|
|
while (!m_PluginLoaders.empty()) {
|
|
QPluginLoader* loader = m_PluginLoaders.back();
|
|
m_PluginLoaders.pop_back();
|
|
if ((loader != nullptr) && !loader->unload()) {
|
|
log::debug("failed to unload {}: {}", loader->fileName(), loader->errorString());
|
|
}
|
|
delete loader;
|
|
}
|
|
}
|
|
|
|
void PluginContainer::loadPlugins()
|
|
{
|
|
TimeThis tt("PluginContainer::loadPlugins()");
|
|
|
|
unloadPlugins();
|
|
|
|
for (QObject* plugin : QPluginLoader::staticInstances()) {
|
|
registerPlugin(plugin, "", nullptr);
|
|
}
|
|
|
|
QFile loadCheck;
|
|
QString skipPlugin;
|
|
|
|
if (m_Organizer) {
|
|
loadCheck.setFileName(qApp->property("dataPath").toString() +
|
|
"/plugin_loadcheck.tmp");
|
|
|
|
if (loadCheck.exists() && loadCheck.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
// oh, there was a failed plugin load last time. Find out which plugin was loaded
|
|
// last
|
|
const auto contents = loadCheck.readAll();
|
|
loadCheck.close();
|
|
|
|
const auto fileName =
|
|
QString::fromUtf8(contents).split('\n', Qt::SkipEmptyParts).last().trimmed();
|
|
|
|
log::warn("loadcheck file found for plugin '{}'", fileName);
|
|
|
|
MOBase::TaskDialog dlg;
|
|
|
|
const auto Skip = QMessageBox::Ignore;
|
|
const auto Blacklist = QMessageBox::Cancel;
|
|
const auto Load = QMessageBox::Ok;
|
|
|
|
const auto r =
|
|
dlg.title(tr("Plugin error"))
|
|
.main(tr("Mod Organizer failed to load the plugin '%1' last time it was "
|
|
"started.")
|
|
.arg(fileName))
|
|
.content(tr(
|
|
"The plugin can be skipped for this session, blacklisted, "
|
|
"or loaded normally, in which case it might fail again. Blacklisted "
|
|
"plugins can be re-enabled later in the settings."))
|
|
.icon(QMessageBox::Warning)
|
|
.button({tr("Skip this plugin"), Skip})
|
|
.button({tr("Blacklist this plugin"), Blacklist})
|
|
.button({tr("Load this plugin"), Load})
|
|
.exec();
|
|
|
|
switch (r) {
|
|
case Skip:
|
|
log::warn("user wants to skip plugin '{}'", fileName);
|
|
skipPlugin = fileName;
|
|
break;
|
|
|
|
case Blacklist:
|
|
log::warn("user wants to blacklist plugin '{}'", fileName);
|
|
m_Organizer->settings().plugins().addBlacklist(fileName);
|
|
break;
|
|
|
|
case Load:
|
|
log::warn("user wants to load plugin '{}' anyway", fileName);
|
|
break;
|
|
}
|
|
}
|
|
|
|
loadCheck.open(QIODevice::WriteOnly);
|
|
}
|
|
|
|
QString pluginPath =
|
|
qApp->applicationDirPath() + "/" + ToQString(AppConfig::pluginPath());
|
|
log::debug("looking for plugins in {}", QDir::toNativeSeparators(pluginPath));
|
|
QDirIterator iter(pluginPath, QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
|
|
|
|
while (iter.hasNext()) {
|
|
iter.next();
|
|
|
|
if (skipPlugin == iter.fileName()) {
|
|
log::debug("plugin \"{}\" skipped for this session", iter.fileName());
|
|
continue;
|
|
}
|
|
|
|
if (m_Organizer) {
|
|
if (m_Organizer->settings().plugins().blacklisted(iter.fileName())) {
|
|
log::debug("plugin \"{}\" blacklisted", iter.fileName());
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (loadCheck.isOpen()) {
|
|
loadCheck.write(iter.fileName().toUtf8());
|
|
loadCheck.write("\n");
|
|
loadCheck.flush();
|
|
}
|
|
|
|
QString filepath = iter.filePath();
|
|
if (QLibrary::isLibrary(filepath)) {
|
|
loadQtPlugin(filepath);
|
|
} else if (auto p = isQtPluginFolder(filepath)) {
|
|
loadQtPlugin(*p);
|
|
}
|
|
}
|
|
|
|
if (skipPlugin.isEmpty()) {
|
|
// remove the load check file on success
|
|
if (loadCheck.isOpen()) {
|
|
loadCheck.remove();
|
|
}
|
|
} else {
|
|
// remember the plugin for next time
|
|
if (loadCheck.isOpen()) {
|
|
loadCheck.close();
|
|
}
|
|
|
|
log::warn("user skipped plugin '{}', remembering in loadcheck", skipPlugin);
|
|
loadCheck.open(QIODevice::WriteOnly);
|
|
loadCheck.write(skipPlugin.toUtf8());
|
|
loadCheck.write("\n");
|
|
loadCheck.flush();
|
|
}
|
|
|
|
bf::at_key<IPluginDiagnose>(m_Plugins).push_back(this);
|
|
|
|
if (m_Organizer) {
|
|
bf::at_key<IPluginDiagnose>(m_Plugins).push_back(m_Organizer);
|
|
m_Organizer->connectPlugins(this);
|
|
}
|
|
}
|
|
|
|
std::vector<unsigned int> PluginContainer::activeProblems() const
|
|
{
|
|
std::vector<unsigned int> problems;
|
|
if (m_FailedPlugins.size()) {
|
|
problems.push_back(PROBLEM_PLUGINSNOTLOADED);
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
QString PluginContainer::shortDescription(unsigned int key) const
|
|
{
|
|
switch (key) {
|
|
case PROBLEM_PLUGINSNOTLOADED: {
|
|
return tr("Some plugins could not be loaded");
|
|
} break;
|
|
default: {
|
|
return tr("Description missing");
|
|
} break;
|
|
}
|
|
}
|
|
|
|
QString PluginContainer::fullDescription(unsigned int key) const
|
|
{
|
|
switch (key) {
|
|
case PROBLEM_PLUGINSNOTLOADED: {
|
|
QString result =
|
|
tr("The following plugins could not be loaded. The reason may be missing "
|
|
"dependencies (i.e. python) or an outdated version:") +
|
|
"<ul>";
|
|
for (const QString& plugin : m_FailedPlugins) {
|
|
result += "<li>" + plugin + "</li>";
|
|
}
|
|
result += "<ul>";
|
|
return result;
|
|
} break;
|
|
default: {
|
|
return tr("Description missing");
|
|
} break;
|
|
}
|
|
}
|
|
|
|
bool PluginContainer::hasGuidedFix(unsigned int) const
|
|
{
|
|
return false;
|
|
}
|
|
|
|
void PluginContainer::startGuidedFix(unsigned int) const {}
|