Files
logos-basecamp/app/UIPluginManager.cpp
T
Dario Gabriel Lipicar 9c3d023062 feat(shell): put the UI shell behind IShellHost, and order its shutdown
The shell held the host's objects: MainContainer owned MainUIBackend, took a
LogosAPI* and a QtLogosCore*, and connected to backend signals by concrete
type. Nothing stopped it minting identities or reading the token store.

Three headers in app/interfaces/, on the include path both targets share, so
there is exactly one copy and source drift is impossible:

  * IShellHost     -- 8 operations the shell may perform
  * IShellObserver -- 5 notifications the host may deliver
  * IShellView     -- how the host builds and tears down the shell

Only QObject*, QWidget* and Qt value types cross. MainContainer holds one
IShellHost* and nothing else; QML reaches the backend as an opaque QObject*
via backendObject(), resolved through the metaobject, so no host C++ type has
to be nameable by the shell.

Ownership inverts: Window owns MainUIBackend, ShellHostAdapter and
MainShellView; the shell borrows. Teardown stops being a consequence of
construction order and becomes a stated contract:

  1. beginShutdown() unmounts in-process UI plugin widgets WHILE the shell's
     tree is intact -- they are docked inside it
  2. destroyShell() detaches the observer, then deletes the shell
  3. the backend goes, tearing down Package -> UIPlugin -> Core
  4. main() destroys the core facade

Every observer forward is null-guarded: PluginLoader dispatches through
QTimer::singleShot(0, ...) and a 30s ViewModuleHost timeout, so a callback can
land after teardown starts, and QPointer cannot help -- IShellObserver is not
a QObject. UIPluginManager's plugin-widget maps become QPointer for the
mirror-image reason: those widgets are docked inside the shell, so its Qt
parent can destroy them without going through unloadUiModule.

IComponent is untouched and still serves the third-party legacy widget
plugins PluginLoader loads.
2026-08-22 16:42:13 -03:00

881 lines
35 KiB
C++

#include "UIPluginManager.h"
#include "AppsModel.h"
#include "CoreModuleManager.h"
#include "PackageCoordinator.h"
#include "PluginLoader.h"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QIcon>
#include <QPixmap>
#include <QPointer>
#include <QQmlContext>
#include <QQuickWidget>
#include <QSet>
#include <QUrl>
#include "LogosQmlBridge.h"
#include <ViewModuleHost.h>
UIPluginManager::UIPluginManager(LogosAPI* logosAPI,
CoreModuleManager* coreModuleManager,
QObject* parent)
: QObject(parent)
, m_logosAPI(logosAPI)
, m_coreModuleManager(coreModuleManager)
, m_packageCoordinator(nullptr)
, m_pluginLoader(nullptr)
, m_currentVisibleApp("")
{
m_pluginLoader = new PluginLoader(m_logosAPI, m_coreModuleManager, this);
connect(m_pluginLoader, &PluginLoader::pluginLoaded,
this, &UIPluginManager::onPluginLoaded);
connect(m_pluginLoader, &PluginLoader::pluginLoadFailed,
this, &UIPluginManager::onPluginLoadFailed);
connect(m_pluginLoader, &PluginLoader::loadingChanged,
this, &UIPluginManager::loadingModulesChanged);
}
UIPluginManager::~UIPluginManager()
{
// Safety net for the path that does not call shutdown() first. Idempotent:
// shutdown() returns immediately if it has already run.
shutdown();
}
void UIPluginManager::shutdown()
{
// Idempotent — MainUIBackend::beginShutdown() normally gets here first,
// while the shell's widget tree is still intact, and the destructor then
// finds nothing left to do.
if (m_shuttingDown) {
return;
}
// Tell unloadUiModule/unloadCoreModule to bypass the cascade-
// confirmation fast-path — there's no user to confirm and no live
// QML layer to drive the dialog. Otherwise the first loaded module
// with loaded dependents would early-return at the
// unloadCascadeConfirmationRequested emit, leaving its widget/host
// leaked AND setting m_pendingUnload.active=true, which lets the
// subsequent iterations' cascade checks succeed vacuously without
// ever returning to finish the aborted one.
m_shuttingDown = true;
// Tear down every in-process UI plugin widget before our members
// disappear. Snapshot the keys from both maps since both legacy and
// ui_qml plugins need to go.
QStringList moduleNames = m_loadedUiModules.keys();
for (const QString& name : m_qmlPluginWidgets.keys()) {
if (!moduleNames.contains(name)) {
moduleNames.append(name);
}
}
for (const QString& name : moduleNames) {
unloadUiModule(name);
}
}
void UIPluginManager::setPackageCoordinator(PackageCoordinator* packageCoordinator)
{
if (m_packageCoordinator == packageCoordinator) return;
m_packageCoordinator = packageCoordinator;
if (!m_packageCoordinator) return;
// Wire the catalog-refresh signal — PackageCoordinator owns the IPC cadence
// (and the event subscriptions that trigger refreshes on install /
// uninstall); we just consume the resulting UI-plugin list to keep our
// load-dispatch cache current.
connect(m_packageCoordinator, &PackageCoordinator::uiPluginsFetched,
this, &UIPluginManager::onUiPluginsFetched);
// When PackageCoordinator's dep-info refresh completes, our uiModules() /
// launcherApps() builders need to re-emit so QML binds to the new
// installType / missing-deps values.
connect(m_packageCoordinator, &PackageCoordinator::uiModulesChanged,
this, &UIPluginManager::uiModulesChanged);
connect(m_packageCoordinator, &PackageCoordinator::launcherAppsChanged,
this, &UIPluginManager::launcherAppsChanged);
connect(m_packageCoordinator, &PackageCoordinator::coreModulesChanged,
this, &UIPluginManager::coreModulesChanged);
}
void UIPluginManager::onUiPluginsFetched(const QVariantList& uiPlugins)
{
m_uiPluginMetadata.clear();
for (const QVariant& item : uiPlugins) {
QVariantMap pluginInfo = item.toMap();
QString name = pluginInfo.value("name").toString();
if (name.isEmpty()) continue;
// ui_qml requires "view" (the QML entry point); "main" is optional.
// Other types require "mainFilePath" (the backend lib).
const QString type = pluginInfo.value("type").toString();
if (type == QStringLiteral("ui_qml")) {
if (pluginInfo.value("view").toString().isEmpty()) continue;
} else {
if (pluginInfo.value("mainFilePath").toString().isEmpty()) continue;
}
m_uiPluginMetadata[name] = pluginInfo;
}
for (auto it = m_qmlPluginWidgets.cbegin(); it != m_qmlPluginWidgets.cend(); ++it)
reloadLoadedPluginIcon(it.key(), it.value());
for (auto it = m_uiModuleWidgets.cbegin(); it != m_uiModuleWidgets.cend(); ++it)
reloadLoadedPluginIcon(it.key(), it.value());
// Immediate emit — the package-state caches (installType, missingDeps)
// on PackageCoordinator may still be refreshing, but the list of UI plugins
// has changed now and QML should reflect that.
emit uiModulesChanged();
emit launcherAppsChanged();
}
void UIPluginManager::reloadLoadedPluginIcon(const QString& name, QWidget* widget) const
{
if (!widget) return;
const QString iconPath = pluginIconUrl(name, /*forWidgetIcon=*/true);
if (iconPath.isEmpty()) return;
// qrc-scheme icons are compiled in and can't have changed on disk.
if (iconPath.startsWith(QLatin1String(":/"))) return;
QFile f(iconPath);
if (!f.open(QIODevice::ReadOnly)) return;
QPixmap pm;
if (!pm.loadFromData(f.readAll())) return;
widget->setWindowIcon(QIcon(pm));
}
QVariantList UIPluginManager::uiModules() const
{
QVariantList modules;
QStringList availablePlugins = findAvailableUiPlugins();
for (const QString& pluginName : availablePlugins) {
const QVariantMap& meta = m_uiPluginMetadata.value(pluginName);
const bool isInstalled = meta.value("isInstalled", true).toBool();
QVariantMap module;
module["name"] = pluginName;
module["isInstalled"] = isInstalled;
module["isLoaded"] = m_loadedUiModules.contains(pluginName) || m_qmlPluginWidgets.contains(pluginName);
module["isMainUi"] = (pluginName == "main_ui");
module["iconPath"] = pluginIconUrl(pluginName);
module["displayName"] = meta.value("displayName");
module["description"] = meta.value("description");
module["repositoryUrl"] = meta.value("repositoryUrl");
module["version"] = meta.value("version");
// Dependency-aware fields come from PackageCoordinator. If it hasn't
// finished its async refresh yet, the accessors return empty values
// which QML treats as "unknown — render safe defaults" (no red-cross,
// no Uninstall button).
const QStringList missing = (isInstalled && m_packageCoordinator)
? m_packageCoordinator->missingDepsOf(pluginName)
: QStringList{};
module["installType"] = (isInstalled && m_packageCoordinator)
? m_packageCoordinator->installType(pluginName)
: QString(); // "" | "embedded" | "user"
module["hasMissingDeps"] = !missing.isEmpty();
module["missingDeps"] = missing;
modules.append(module);
}
return modules;
}
void UIPluginManager::loadUiModule(const QString& moduleName)
{
qDebug() << "Loading UI module:" << moduleName;
if (m_loadedUiModules.contains(moduleName) || m_qmlPluginWidgets.contains(moduleName)) {
qDebug() << "Module" << moduleName << "is already loaded";
activateApp(moduleName);
return;
}
// Gate on missing core dependencies — no point attempting the load
// if liblogos's dependency resolver will refuse it. We show the popup
// instead of letting the user see a cryptic "plugin load failed" error.
const QStringList missing = m_packageCoordinator
? m_packageCoordinator->missingDepsOf(moduleName)
: QStringList{};
if (!missing.isEmpty()) {
qDebug() << "UI module" << moduleName << "has missing deps:" << missing;
emit missingDepsPopupRequested(moduleName, missing);
return;
}
if (isQmlPlugin(moduleName)) {
const QVariantMap& meta = m_uiPluginMetadata.value(moduleName);
PluginLoadRequest request;
request.name = moduleName;
request.type = UIPluginType::UiQml;
request.installDir = meta.value("installDir").toString();
request.qmlViewPath = resolveQmlViewPath(meta);
request.iconPath = pluginIconUrl(moduleName, true);
if (hasBackendPlugin(moduleName))
request.mainFilePath = meta.value("mainFilePath").toString();
request.coreDependencies = meta.value("dependencies").toList();
m_pluginLoader->load(request);
return;
}
loadLegacyUiModule(moduleName);
}
void UIPluginManager::onPluginLoaded(const QString& name, QWidget* widget,
IComponent* component, UIPluginType type,
ViewModuleHost* viewHost)
{
if (component)
m_loadedUiModules[name] = component;
if (type != UIPluginType::Legacy)
m_qmlPluginWidgets[name] = qobject_cast<QQuickWidget*>(widget);
if (viewHost)
m_viewModuleHosts[name] = viewHost;
m_uiModuleWidgets[name] = widget;
m_loadedApps.insert(name);
// TODO - check if this generic enough for extedning different capabilities
// from basecamp to other apps
// For ui_qml view modules, wire up any signals we care about from the
// QtRO replica before QML sees the widget. The replica is already
// created inside LogosQmlBridge at this point (setViewModuleSocket was
// called in PluginLoader::onHostReady before pluginLoaded was emitted);
// it may not yet be Valid, but Qt signal/slot connections work regardless
// of replica state — the connection will fire when the source emits.
if (type == UIPluginType::UiQml) {
QQuickWidget* qw = m_qmlPluginWidgets.value(name);
if (qw) {
auto* bridge = qobject_cast<LogosQmlBridge*>(
qw->rootContext()->contextProperty(QStringLiteral("logos"))
.value<QObject*>());
if (bridge) {
if (name == QStringLiteral("package_manager_ui")) {
QObject* replica = bridge->module(name);
if (replica) {
// String-based connects resolve against the replica's
// metaobject at runtime — a signature drift in the PMU
// .rep would fail silently, so guard each result.
if (!connect(replica, SIGNAL(navigateToRepositoriesRequested()),
this, SIGNAL(navigateToRepositoriesRequested()))) {
qCritical() << "package_manager_ui replica signal"
<< "navigateToRepositoriesRequested() not found"
<< "- signature drift vs package_manager_ui.rep?";
}
if (!connect(replica,
SIGNAL(installationProgressUpdated(int,QString,int,int,bool,QString)),
this,
SLOT(onPmuiInstallProgress(int,QString,int,int,bool,QString)))) {
qCritical() << "package_manager_ui replica signal"
<< "installationProgressUpdated(...) not found"
<< "- signature drift vs package_manager_ui.rep?"
<< "Install failures will NOT surface in the UI.";
}
}
}
}
}
}
emit uiModulesChanged();
emit launcherAppsChanged();
emit pluginWindowRequested(widget, name);
emit navigateToApps();
qDebug() << "Successfully loaded UI module:" << name;
}
void UIPluginManager::onPluginLoadFailed(const QString& name, const QString& error)
{
qWarning() << "Failed to load UI module" << name << ":" << error;
// Surface to the user, not just the log (forwarded to the QML overlay).
emit pluginLoadFailedNotice(name, error);
}
void UIPluginManager::onPmuiInstallProgress(int progressType, const QString& packageName,
int completed, int total, bool success,
const QString& error)
{
Q_UNUSED(progressType);
Q_UNUSED(completed);
Q_UNUSED(total);
// `success` discriminates, not the error string — progress emits reuse it
// for non-error status text ("Upgrading X…").
if (success) {
return;
}
qWarning() << "package_manager_ui install failure for" << packageName << ":" << error;
emit packageInstallFailedNotice(
packageName,
error.isEmpty() ? QStringLiteral("The package manager reported an "
"unspecified installation failure.")
: error);
}
QStringList UIPluginManager::loadingModules() const
{
return m_pluginLoader->loadingPlugins();
}
void UIPluginManager::unloadUiModule(const QString& moduleName)
{
if (m_shuttingDown) {
// Shutdown path: run synchronously — no QML signal handler is on
// the stack and we need the teardown to complete before Qt child
// destruction continues. The cascade guard below skips when
// m_shuttingDown is true, so this goes straight to teardown.
unloadUiModuleImpl(moduleName);
return;
}
// Normal path: defer the whole body — same rationale as loadCoreModule /
// unloadCoreModule. This slot is invoked from a QML Button.onClicked handler
// inside a view delegate (e.g. the Apps Inspector table's "Unload" button).
// Emitting uiModulesChanged() synchronously causes the model reset to fire,
// which calls clear() → setParentItem(nullptr) on every delegate, including
// the button that was just clicked. QQuickItemPrivate::derefWindow then
// crashes trying to walk that button's child tree while the window pointer
// on one of its children is already null.
// Deferring via QueuedConnection lets the click handler fully unwind first;
// by the time the lambda runs the Repeater delegate tree is stable again.
QMetaObject::invokeMethod(this, [this, moduleName]{
unloadUiModuleImpl(moduleName);
}, Qt::QueuedConnection);
}
void UIPluginManager::unloadUiModuleImpl(const QString& moduleName)
{
qDebug() << "Unloading UI module:" << moduleName;
bool isQml = m_qmlPluginWidgets.contains(moduleName);
bool isCpp = m_loadedUiModules.contains(moduleName);
if (!isQml && !isCpp) {
qDebug() << "Module" << moduleName << "is not loaded";
return;
}
// Cascade check: if this UI module exposes a core plugin that other
// loaded plugins depend on, unloading it would silently strand them.
// Emit the confirmation signal and wait for confirmUnloadCascade() or
// cancelUnloadCascade(). We only intercept when there's actually a
// dependent loaded — the common case (leaf plugin) skips the dialog.
//
// Guard against re-entering the flow if we're already in a pending
// cascade for a *different* module; otherwise we could lose state.
// Skip the cascade entirely during destruction — the QML that would
// drive the dialog is gone and we need to actually tear down, not
// await a confirmation that can never arrive.
//
// Scope the skip to a re-entry of the SAME module (the confirm path clears
// m_pendingUnload before re-calling); a cascade pending for another module
// must not suppress this one's dependent check.
const bool reentryForSameModule =
m_pendingUnload.active && m_pendingUnload.name == moduleName;
if (!m_shuttingDown && !reentryForSameModule) {
const QStringList loadedDeps = loadedDependentsOf(moduleName);
if (!loadedDeps.isEmpty()) {
m_pendingUnload = {true, moduleName};
qDebug() << "Unload cascade needed for" << moduleName << "dependents:" << loadedDeps;
emit unloadCascadeConfirmationRequested(moduleName, loadedDeps);
return;
}
}
QWidget* widget = m_uiModuleWidgets.value(moduleName);
IComponent* component = m_loadedUiModules.value(moduleName);
if (widget) {
emit pluginWindowRemoveRequested(widget);
}
if (component && widget) {
component->destroyWidget(widget);
}
if (isQml && widget) {
widget->deleteLater();
}
// Stop view module host process if this was a view module
if (m_viewModuleHosts.contains(moduleName)) {
m_viewModuleHosts[moduleName]->stop();
delete m_viewModuleHosts.take(moduleName);
}
m_loadedUiModules.remove(moduleName);
m_uiModuleWidgets.remove(moduleName);
m_qmlPluginWidgets.remove(moduleName);
m_loadedApps.remove(moduleName);
emit uiModulesChanged();
emit launcherAppsChanged();
qDebug() << "Successfully unloaded UI module:" << moduleName;
}
void UIPluginManager::activateApp(const QString& appName)
{
QWidget* widget = m_uiModuleWidgets.value(appName);
if (widget) {
emit pluginWindowActivateRequested(widget);
emit navigateToApps();
}
}
void UIPluginManager::setCurrentVisibleApp(const QString& pluginName)
{
if (m_currentVisibleApp != pluginName) {
m_currentVisibleApp = pluginName;
emit currentVisibleAppChanged();
emit launcherAppsChanged();
}
}
QString UIPluginManager::currentVisibleApp() const
{
return m_currentVisibleApp;
}
void UIPluginManager::loadCoreModule(const QString& moduleName)
{
// Defer the ENTIRE body — not just the emit. Callers are typically
// QML Button.onClicked handlers, and m_coreModuleManager->loadModule
// ultimately calls logos_core_load_module_with_dependencies, which
// internally spins a nested Qt event loop (via
// QConnectedReplicaImplementation::waitForSource during the
// informModuleToken round-trip, see liblogos_core). Running that
// nested loop while a QML signal handler is still on the stack lets
// Qt deliver a pending DeferredDelete for the firing Button/Repeater
// delegate, and then the destructor trips "Object destroyed while
// one of its QML signal handlers is in progress" → qFatal.
// Queueing through the event loop lets the click handler unwind
// first; the nested loop is then harmless.
QMetaObject::invokeMethod(this, [this, moduleName]{
qDebug() << "Loading core module:" << moduleName;
bool success = m_coreModuleManager
? m_coreModuleManager->loadModule(moduleName)
: false;
if (success) {
qDebug() << "Successfully loaded core module:" << moduleName;
emit coreModulesChanged();
} else {
qDebug() << "Failed to load core module:" << moduleName;
}
}, Qt::QueuedConnection);
}
void UIPluginManager::unloadCoreModule(const QString& moduleName)
{
// Shutdown path runs synchronously (no QML on the stack to worry about)
// and must not defer — we need the teardown to happen before Qt child
// destruction continues past our destructor body.
if (m_shuttingDown) {
qDebug() << "Unloading core module:" << moduleName;
if (m_coreModuleManager) {
bool success = m_coreModuleManager->unloadModule(moduleName);
if (success) qDebug() << "Successfully unloaded core module:" << moduleName;
else qDebug() << "Failed to unload core module:" << moduleName;
}
return;
}
// Normal path: defer the whole body — same rationale as loadCoreModule.
// m_coreModuleManager->unloadModule → logos_core_unload_module spins a
// nested event loop inside the QRemoteObjects teardown handshake, and
// this slot is typically invoked from a QML Button.onClicked. Running
// the nested loop while the click handler is still on the stack is
// what trips QQmlData::destroyed's "Object destroyed while one of its
// QML signal handlers is in progress" qFatal.
QMetaObject::invokeMethod(this, [this, moduleName]{
qDebug() << "Unloading core module:" << moduleName;
// Cascade check — mirror unloadUiModule. Without this, clicking
// "Unload" on a core module with loaded dependents (core or UI)
// silently orphans them. The confirmation dialog path is only
// engaged when there's at least one loaded dependent, so leaf
// unloads still take the fast path.
//
// Scope the re-entry guard to the SAME module (see unloadUiModule).
const bool reentryForSameModule =
m_pendingUnload.active && m_pendingUnload.name == moduleName;
if (!reentryForSameModule) {
const QStringList loadedDeps = loadedDependentsOf(moduleName);
if (!loadedDeps.isEmpty()) {
m_pendingUnload = {true, moduleName};
qDebug() << "Unload cascade needed for core module" << moduleName
<< "dependents:" << loadedDeps;
emit unloadCascadeConfirmationRequested(moduleName, loadedDeps);
return;
}
}
bool success = m_coreModuleManager
? m_coreModuleManager->unloadModule(moduleName)
: false;
if (success) {
qDebug() << "Successfully unloaded core module:" << moduleName;
emit coreModulesChanged();
} else {
qDebug() << "Failed to unload core module:" << moduleName;
}
}, Qt::QueuedConnection);
}
void UIPluginManager::refreshUiModules()
{
// The refresh cadence is owned by PackageCoordinator — it scans the disk
// via the module and pushes results back to us via uiPluginsFetched.
// refreshUiModules() is the user-triggered kick for that scan (wired to
// the Reload button on the UI Modules tab).
qDebug() << "Refreshing UI modules";
if (m_packageCoordinator) {
m_packageCoordinator->refresh();
}
}
QVariantList UIPluginManager::launcherApps() const
{
QVariantList apps;
QStringList availablePlugins = findAvailableUiPlugins();
for (const QString& pluginName : availablePlugins) {
if (pluginName == "main_ui") {
continue;
}
if (pluginName == "package_manager_ui") {
continue;
}
QVariantMap app;
app["name"] = pluginName;
const QString metaDn =
m_uiPluginMetadata.value(pluginName).value("displayName").toString();
app["displayName"] = metaDn;
app["isLoaded"] = m_loadedApps.contains(pluginName);
app["iconPath"] = pluginIconUrl(pluginName);
// Manifest >= 0.4.0 guarantees a validated 256x256 icon, so the
// sidebar tile can render it edge-to-edge; older packages ship a
// small glyph that must stay inset.
app["supportsFullBleedIcon"] = AppsModel::supportsFullBleedIcon(
m_uiPluginMetadata.value(pluginName)
.value("manifestVersion").toString());
// Sidebar red-cross marker source. The SidebarAppDelegate reads
// this field directly; we don't ship the full missingDeps list
// here because the sidebar only draws an indicator — the detailed
// list lives behind the click-triggered popup which reads from
// backend.uiModulesModel.
const QStringList missing = m_packageCoordinator
? m_packageCoordinator->missingDepsOf(pluginName)
: QStringList{};
app["hasMissingDeps"] = !missing.isEmpty();
apps.append(app);
}
return apps;
}
void UIPluginManager::onAppLauncherClicked(const QString& appName)
{
qDebug() << "App launcher clicked:" << appName;
setCurrentVisibleApp(appName);
if (m_loadedApps.contains(appName)) {
activateApp(appName);
} else {
loadUiModule(appName);
}
}
void UIPluginManager::confirmUnloadCascade(const QString& moduleName)
{
if (!m_pendingUnload.active || m_pendingUnload.name != moduleName) {
qWarning() << "confirmUnloadCascade for" << moduleName
<< "but pending unload is" << m_pendingUnload.name;
return;
}
// Clear pending synchronously so a second click on the dialog's Continue
// (or a racing cancel) sees the slot as free. The actual unload work is
// deferred below.
m_pendingUnload = {};
// Defer the cascade body — same rationale as loadCoreModule /
// unloadCoreModule. confirmUnloadCascade is invoked from the cascade
// dialog's "Continue" Button.onClicked, and unloadModuleWithDependents
// spins a nested Qt event loop inside the QRemoteObjects teardown.
// Running that while the click handler is still on the stack would
// trip the QQmlData::destroyed qFatal.
QMetaObject::invokeMethod(this, [this, moduleName]{
// Snapshot the loaded-dependents list BEFORE the cascade runs. Once
// unloadModuleWithDependents returns, the target is off the loaded-
// modules list and loadedDependentsOf would come up empty. UI-plugin
// dependents need teardown here in-process — the core cascade only
// kills core modules (QProcess termination). Without this pass,
// accounts_ui stays wired to a now-dead accounts_module.
const QStringList loadedDeps = loadedDependentsOf(moduleName);
qDebug() << "Cascade-unloading" << moduleName;
bool ok = m_coreModuleManager
? m_coreModuleManager->unloadModuleWithDependents(moduleName)
: false;
if (!ok) {
qWarning() << "unloadModuleWithDependents failed for" << moduleName;
// Don't tear down the UI widget either — the core plugin is
// still running somewhere and the widget would end up orphaned.
return;
}
// Tear down any UI-plugin dependents whose backing core module just
// died. Iterate the cached dependents (even pure-UI ones that the
// core cascade didn't touch) and drop their widgets.
for (const QString& dep : loadedDeps) {
teardownUiPluginWidget(dep);
}
// The UI widget for the target itself still needs to be unloaded.
// We're already inside a QueuedConnection lambda so the original
// click handler has returned — call the impl directly instead of
// scheduling another async hop. m_pendingUnload is inactive so the
// cascade guard in unloadUiModuleImpl won't re-trigger.
unloadUiModuleImpl(moduleName);
// Stats may have shifted; the deferred-emit block that followed
// below handles the QML-notification side.
emit coreModulesChanged();
emit uiModulesChanged();
emit launcherAppsChanged();
}, Qt::QueuedConnection);
}
void UIPluginManager::cancelUnloadCascade(const QString& moduleName)
{
if (!m_pendingUnload.active || m_pendingUnload.name != moduleName) {
// MainUIBackend fans out cancelPendingAction to both managers so one
// of them is always a no-op — don't even warn here.
return;
}
qDebug() << "Cancelling pending unload cascade for" << moduleName;
m_pendingUnload = {};
}
void UIPluginManager::teardownUiPluginWidget(const QString& moduleName)
{
// Idempotent — each of these maps may or may not hold an entry. Nothing
// below cares about insertion order; we just drop every structural
// reference the UI side may hold.
const bool wasLoaded = m_loadedUiModules.contains(moduleName)
|| m_qmlPluginWidgets.contains(moduleName)
|| m_uiModuleWidgets.contains(moduleName)
|| m_viewModuleHosts.contains(moduleName);
if (!wasLoaded) return;
qDebug() << "Tearing down UI plugin widget for" << moduleName;
QWidget* widget = m_uiModuleWidgets.value(moduleName);
IComponent* component = m_loadedUiModules.value(moduleName);
// Order matters here: ask the workspace to drop the dock first so
// the widget isn't reparented to a dying container; then destroy it
// via the component's hook (which may own it) or deleteLater on the
// bare QML host.
if (widget) emit pluginWindowRemoveRequested(widget);
if (component && widget) component->destroyWidget(widget);
if (m_qmlPluginWidgets.contains(moduleName) && widget) widget->deleteLater();
if (m_viewModuleHosts.contains(moduleName)) {
m_viewModuleHosts[moduleName]->stop();
delete m_viewModuleHosts.take(moduleName);
}
m_loadedUiModules.remove(moduleName);
m_uiModuleWidgets.remove(moduleName);
m_qmlPluginWidgets.remove(moduleName);
m_loadedApps.remove(moduleName);
if (m_currentVisibleApp == moduleName) {
m_currentVisibleApp.clear();
emit currentVisibleAppChanged();
}
}
QString UIPluginManager::getPluginType(const QString& name) const
{
const auto it = m_uiPluginMetadata.constFind(name);
if (it != m_uiPluginMetadata.cend()) {
return it->value("type").toString();
}
return QString();
}
bool UIPluginManager::isQmlPlugin(const QString& name) const
{
return getPluginType(name) == QStringLiteral("ui_qml");
}
QString UIPluginManager::resolveQmlViewPath(const QVariantMap& meta) const
{
// ui_qml contract: "view" is the QML entry point, relative to installDir.
const QString installDir = meta.value("installDir").toString();
const QString viewField = meta.value("view").toString();
if (viewField.isEmpty()) return QString();
return QDir(installDir).filePath(viewField);
}
void UIPluginManager::loadLegacyUiModule(const QString& moduleName)
{
if (m_pluginLoader->isLoading(moduleName)) {
qDebug() << "Module" << moduleName << "is already loading";
return;
}
PluginLoadRequest request;
request.name = moduleName;
request.type = UIPluginType::Legacy;
request.pluginPath = getPluginPath(moduleName);
request.iconPath = pluginIconUrl(moduleName, true);
if (m_uiPluginMetadata.contains(moduleName)) {
request.coreDependencies = m_uiPluginMetadata[moduleName].value("dependencies").toList();
}
m_pluginLoader->load(request);
}
bool UIPluginManager::hasBackendPlugin(const QString& name) const
{
// True iff the ui_qml plugin ships a backend Qt plugin lib alongside its
// QML view. For QML-only ui_qml modules, mainFilePath is empty (no
// backend). When a backend is present, mainFilePath points at the .so/.dylib.
if (!isQmlPlugin(name)) return false;
const QString mainPath = m_uiPluginMetadata.value(name).value("mainFilePath").toString();
if (mainPath.isEmpty()) return false;
return mainPath.endsWith(QStringLiteral(".so"), Qt::CaseInsensitive)
|| mainPath.endsWith(QStringLiteral(".dylib"), Qt::CaseInsensitive)
|| mainPath.endsWith(QStringLiteral(".dll"), Qt::CaseInsensitive);
}
QStringList UIPluginManager::findAvailableUiPlugins() const
{
return m_uiPluginMetadata.keys();
}
QStringList UIPluginManager::loadedCoreModules() const
{
return m_coreModuleManager ? m_coreModuleManager->loadedModules() : QStringList{};
}
QStringList UIPluginManager::loadedDependentsOf(const QString& name) const
{
const QStringList dependents = m_packageCoordinator
? m_packageCoordinator->dependentsOf(name)
: QStringList{};
if (dependents.isEmpty()) return {};
// A dependent is "loaded" if it's running as a core module (tracked by
// liblogos) OR currently mounted as a UI plugin in this Basecamp instance
// (tracked by m_loadedUiModules / m_qmlPluginWidgets). Without this
// second source, a UI plugin like wallet_ui that depends on wallet_module
// never registered in `logos_core_get_loaded_modules()` would silently
// disappear from the cascade dialog — making the unload look "safe"
// when in fact wallet_ui is still mounted and would orphan.
const QStringList loadedCore = loadedCoreModules();
QSet<QString> loadedSet(loadedCore.cbegin(), loadedCore.cend());
for (auto it = m_loadedUiModules.cbegin(); it != m_loadedUiModules.cend(); ++it) {
loadedSet.insert(it.key());
}
for (auto it = m_qmlPluginWidgets.cbegin(); it != m_qmlPluginWidgets.cend(); ++it) {
loadedSet.insert(it.key());
}
QStringList result;
for (const QString& dep : dependents) {
if (loadedSet.contains(dep)) result << dep;
}
return result;
}
QString UIPluginManager::getPluginPath(const QString& name) const
{
// Only used by loadLegacyUiModule (type "ui"); ui_qml uses installDir + view.
const auto it = m_uiPluginMetadata.constFind(name);
if (it != m_uiPluginMetadata.cend()) {
return it->value("mainFilePath").toString();
}
return QString();
}
QString UIPluginManager::pluginIconUrl(const QString& pluginName, bool forWidgetIcon) const
{
if (!m_uiPluginMetadata.contains(pluginName)) {
return "";
}
const QVariantMap& meta = m_uiPluginMetadata[pluginName];
QString iconPath = meta.value("icon").toString();
QString installDir = meta.value("installDir").toString();
if (iconPath.isEmpty()) {
return "";
}
QDir pluginDir(installDir);
QString filePath = pluginDir.filePath(iconPath.startsWith(":/") ? iconPath.mid(2) : iconPath);
bool exists = QFile::exists(filePath);
if (forWidgetIcon) {
if (exists) {
return filePath;
}
if (iconPath.startsWith(":/")) {
qWarning() << "Plugin icon not on disk, using resource path; expected:" << filePath;
return iconPath;
}
qWarning() << "Plugin icon not found, expected:" << filePath;
return QString();
}
if (!exists) {
return iconPath.startsWith(":/") ? "qrc" + iconPath : QString();
}
const qint64 mtimeMs = QFileInfo(filePath).lastModified().toMSecsSinceEpoch();
return QUrl::fromLocalFile(filePath).toString()
+ QStringLiteral("?v=") + QString::number(mtimeMs);
}
QStringList UIPluginManager::intersectWithLoaded(const QStringList& moduleNames) const
{
if (moduleNames.isEmpty()) return {};
// Build the same "loaded set" used by loadedDependentsOf: core modules
// reported by liblogos plus UI-plugin widgets mounted in this process.
// A UI-only plugin (ui_qml) won't show up in loadedCoreModules, so we
// must merge both sources or we'd under-report what's actually loaded.
const QStringList loadedCore = loadedCoreModules();
QSet<QString> loadedSet(loadedCore.cbegin(), loadedCore.cend());
for (auto it = m_loadedUiModules.cbegin(); it != m_loadedUiModules.cend(); ++it) {
loadedSet.insert(it.key());
}
for (auto it = m_qmlPluginWidgets.cbegin(); it != m_qmlPluginWidgets.cend(); ++it) {
loadedSet.insert(it.key());
}
QStringList result;
for (const QString& name : moduleNames) {
if (loadedSet.contains(name)) result << name;
}
return result;
}