#include "PackageCoordinator.h" #include "InstallRegistry.h" #include "AppsModel.h" #include "CoreModuleManager.h" #include "UIPluginManager.h" #include "LogosBasecampPaths.h" #include #include #include #include #include #include #include #include #include #include #include #include "logos_sdk.h" PackageCoordinator::PackageCoordinator(LogosAPI* logosAPI, CoreModuleManager* coreModuleManager, UIPluginManager* uiPluginManager, AppsModel* appsModel, QObject* parent) : QObject(parent) , m_logosAPI(logosAPI) , m_coreModuleManager(coreModuleManager) , m_uiPluginManager(uiPluginManager) , m_appsModel(appsModel) , m_installRegistry(new InstallRegistry(this)) { subscribeToPackageInstallationEvents(); subscribeToPackageDownloaderEvents(); // A module absent at startup may be installed and loaded later in the // session. Without this the guards above would turn a 6-minute stall into a // silently non-functional Modules view, which is a worse bug. Both // subscribe functions are idempotent, so re-running them is free once armed. if (m_coreModuleManager) { connect(m_coreModuleManager, &CoreModuleManager::coreModulesChanged, this, [this]() { subscribeToPackageInstallationEvents(); subscribeToPackageDownloaderEvents(); }); } // NB: initial metadata fetch is deferred until MainUIBackend calls // refresh() — the uiPluginsFetched signal would otherwise fire before // UIPluginManager's setPackageCoordinator runs and the slot connection // lands, causing the first-paint UI-plugin list to be empty until the // next file-install event triggers a re-scan. } PackageCoordinator::~PackageCoordinator() = default; namespace { // Is a module actually loaded in THIS process? // // The obvious guard -- `client->isConnected()` -- was dead code: QtRO's // connectToNode() only validates the URL scheme and never contacts a peer, so // it reported "connected" for modules that were never loaded. Every call past // it then blocked 20 s in waitForSource, twice over (the token handshake tries // capability_module first). Measured cost with package_manager absent: ~417 s // of blocked GUI thread on macOS and 361 s on Linux before Basecamp's window // appeared, because all of this runs inside the Window constructor. // // logos_core_get_loaded_modules answers the same question in-process, with no // IPC and no timeout. The same guard already ships in // logos-logoscore-cli/src/daemon/daemon.cpp, which skips its identical // setEmbeddedModulesDirectory block and reports that package commands are // unavailable for the session. bool moduleIsLoaded(CoreModuleManager* core, const QString& name) { if (!core) { // No oracle available: keep the previous behaviour rather than silently // disabling package management. return true; } return core->loadedModules().contains(name); } } // namespace void PackageCoordinator::subscribeToPackageInstallationEvents() { if (!m_logosAPI) { return; } if (m_packageManagerSubscribed) { return; } if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) { if (!m_warnedPackageManagerMissing) { m_warnedPackageManagerMissing = true; qWarning() << "PackageCoordinator: package_manager is not loaded -- skipping its " "directory setup and event subscriptions. Package management is " "unavailable until it loads; this will be retried automatically."; } return; } m_packageManagerSubscribed = true; LogosModules logos(m_logosAPI); // Configure the package_manager module's directories so it knows where // to install. logos.package_manager.setEmbeddedModulesDirectory(LogosBasecampPaths::embeddedModulesDirectory()); logos.package_manager.setUserModulesDirectory(LogosBasecampPaths::modulesDirectory()); logos.package_manager.setEmbeddedUiPluginsDirectory(LogosBasecampPaths::embeddedPluginsDirectory()); logos.package_manager.setUserUiPluginsDirectory(LogosBasecampPaths::pluginsDirectory()); logos.package_manager.on("corePluginFileInstalled", [this](const QVariantList& data) { if (data.isEmpty()) return; qDebug() << "Core module file installed:" << data[0].toString(); QTimer::singleShot(100, this, [this]() { if (m_coreModuleManager) m_coreModuleManager->refresh(); // Also rescan UI plugin metadata — a newly installed core module // could satisfy a dependency that previously left a UI module // marked with missing deps, so the sidebar red-cross needs to clear. fetchUiPluginMetadata(); }); }); logos.package_manager.on("uiPluginFileInstalled", [this](const QVariantList& data) { if (data.isEmpty()) return; qDebug() << "UI plugin file installed:" << data[0].toString(); QTimer::singleShot(100, this, [this]() { fetchUiPluginMetadata(); }); }); // Uninstall events — mirror the install handlers. We rescan UI plugin // metadata in both cases because a core uninstall can make a previously // satisfied UI dep go missing, and a UI uninstall flat-out removes the // plugin from UIPluginManager's metadata. The 100ms settle matches // install to absorb rapid batched events. logos.package_manager.on("corePluginUninstalled", [this](const QVariantList& data) { if (data.isEmpty()) return; qDebug() << "Core module uninstalled:" << data[0].toString(); QTimer::singleShot(100, this, [this]() { if (m_coreModuleManager) m_coreModuleManager->refresh(); fetchUiPluginMetadata(); }); }); logos.package_manager.on("uiPluginUninstalled", [this](const QVariantList& data) { if (data.isEmpty()) return; qDebug() << "UI plugin uninstalled:" << data[0].toString(); QTimer::singleShot(100, this, [this]() { fetchUiPluginMetadata(); }); }); // Clear any pending action left over from a prior session that crashed // mid-dialog. The module retains m_pendingAction across Basecamp restarts // (it's non-persistent but survives our process death since it lives in // package_manager's process); without this reset, the first request after // a crash would get rejected with "another X is in progress". logos.package_manager.resetPendingActionAsync([](QVariantMap){}); // Gated uninstall/upgrade events. package_manager emits these BEFORE any // destructive work, with a 3s ack timer running; onBeforeUninstall / // onBeforeUpgrade acks synchronously and — if the ack landed in time — // drives the cascade confirmation dialog. See PackageCoordinator.h for the // ack-gated protocol rationale. logos.package_manager.on("beforeUninstall", [this](const QVariantList& data) { if (data.isEmpty()) return; const QByteArray payload = data.first().toString().toUtf8(); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(payload, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "beforeUninstall payload parse error:" << err.errorString(); return; } const QJsonObject obj = doc.object(); const QString name = obj.value("name").toString(); QStringList installedDeps; for (const QJsonValue& v : obj.value("installedDependents").toArray()) { if (v.isString()) installedDeps.append(v.toString()); } onBeforeUninstall(name, installedDeps); }); logos.package_manager.on("beforeUpgrade", [this](const QVariantList& data) { if (data.isEmpty()) return; const QByteArray payload = data.first().toString().toUtf8(); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(payload, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "beforeUpgrade payload parse error:" << err.errorString(); return; } const QJsonObject obj = doc.object(); const QString name = obj.value("name").toString(); const QString releaseTag = obj.value("releaseTag").toString(); const int mode = obj.value("mode").toInt(); QStringList installedDeps; for (const QJsonValue& v : obj.value("installedDependents").toArray()) { if (v.isString()) installedDeps.append(v.toString()); } // Transitive dep changes the initiator (PMU) resolved for this swap — // opaque display data the dialog lists. Absent/empty on a bare upgrade. const QVariantList depChanges = obj.value("depChanges").toArray().toVariantList(); onBeforeUpgrade(name, releaseTag, mode, installedDeps, depChanges); }); // beforeInstall — the catalog-install gate. Same ack-then-dialog shape as // beforeUpgrade, but with no dependents (a fresh install unloads nothing). logos.package_manager.on("beforeInstall", [this](const QVariantList& data) { if (data.isEmpty()) return; const QByteArray payload = data.first().toString().toUtf8(); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(payload, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "beforeInstall payload parse error:" << err.errorString(); return; } const QJsonObject obj = doc.object(); const QString name = obj.value("name").toString(); const QString releaseTag = obj.value("releaseTag").toString(); const QVariantList depChanges = obj.value("depChanges").toArray().toVariantList(); onBeforeInstall(name, releaseTag, depChanges); }); // Multi-uninstall is a separate event so existing single-uninstall handlers // don't have to peek at the payload shape to disambiguate. logos.package_manager.on("beforeMultiUninstall", [this](const QVariantList& data) { if (data.isEmpty()) return; const QByteArray payload = data.first().toString().toUtf8(); QJsonParseError err{}; const QJsonDocument doc = QJsonDocument::fromJson(payload, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "beforeMultiUninstall payload parse error:" << err.errorString(); return; } const QJsonObject obj = doc.object(); QStringList names; for (const QJsonValue& v : obj.value("names").toArray()) { if (v.isString()) names.append(v.toString()); } QStringList installedDeps; for (const QJsonValue& v : obj.value("installedDependents").toArray()) { if (v.isString()) installedDeps.append(v.toString()); } onBeforeMultiUninstall(names, installedDeps); }); // The multi-uninstall cancellation counterpart. package_manager_ui // subscribes to uninstallCancelled and toasts it, so the single-uninstall // path is already covered on that surface — but nothing anywhere listens // for the multi variant, and the App Manager now runs exclusively through // it. Without this a 3s ack timeout (or a cancel that raced the dialog) // is completely silent. logos.package_manager.on("multiUninstallCancelled", [this](const QVariantList& data) { if (data.isEmpty()) return; const QJsonDocument doc = QJsonDocument::fromJson(data.first().toString().toUtf8()); if (!doc.isObject()) return; const QString reason = doc.object().value("reason").toString(); // A user-driven cancel already had a visible dialog; only surface the // ones the user didn't ask for. m_lastRequestedTargets.clear(); if (reason.contains(QStringLiteral("user cancelled"))) return; qWarning() << "multiUninstallCancelled:" << reason; }); } void PackageCoordinator::subscribeToPackageDownloaderEvents() { if (!m_logosAPI) return; if (m_packageDownloaderSubscribed) { return; } if (!moduleIsLoaded(m_coreModuleManager, "package_downloader")) { if (!m_warnedPackageDownloaderMissing) { m_warnedPackageDownloaderMissing = true; qWarning() << "PackageCoordinator: package_downloader is not loaded -- skipping its " "event subscriptions; this will be retried automatically."; } return; } m_packageDownloaderSubscribed = true; LogosModules logos(m_logosAPI); logos.package_downloader.on("catalogChanged", [this](const QVariantList&) { refreshRepositories(); refresh(); }); } // --------------------------------------------------------------------------- // Read-only accessors over the caches. // --------------------------------------------------------------------------- QString PackageCoordinator::installType(const QString& name) const { return m_installTypeByModule.value(name); } QStringList PackageCoordinator::missingDepsOf(const QString& name) const { return m_missingDepsByModule.value(name); } QStringList PackageCoordinator::dependentsOf(const QString& name) const { return m_dependentsByModule.value(name); } QString PackageCoordinator::displayNameFor(const QString& name) const { const QString dn = m_displayNameByModule.value(name); if (!dn.isEmpty()) return dn; return name; } // --------------------------------------------------------------------------- // Gated uninstall — entry points // --------------------------------------------------------------------------- void PackageCoordinator::uninstallUiModule(const QString& moduleName) { qDebug() << "uninstallUiModule:" << moduleName; // Main UI is protected — uninstalling it would brick Basecamp. Every // other "is this uninstallable?" check (embedded-refusal, unknown-module) // now lives in the package_manager module itself, so there's no duplicate // gating here. This guard stays local because "don't kill your own UI" // is a Basecamp concern, not a package-lifecycle concern. if (moduleName == QStringLiteral("main_ui")) { qWarning() << "Refusing to uninstall main_ui"; return; } // Kick off the gated request. The module: // 1. Sets its pending slot, emits "beforeUninstall" with the installed- // dependents list, and starts the 3s ack timer. // 2. We catch the event in onBeforeUninstall, ack, and show the cascade // dialog. Reentry protection lives in the module (global single-slot // pending) so a concurrent second click gets rejected synchronously. if (!m_logosAPI) return; LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.requestUninstallAsync( moduleName, [self, moduleName](QVariantMap result) { if (!self) return; if (result.value("success", false).toBool()) return; const QString error = result.value("error").toString(); qWarning() << "requestUninstall rejected for" << moduleName << ":" << error; }); } void PackageCoordinator::uninstallApp(const QString& name, const QString& repositoryUrl) { Q_UNUSED(repositoryUrl); if (name.isEmpty()) return; if (name == QStringLiteral("main_ui")) { qWarning() << "Refusing to uninstall main_ui"; return; } if (m_dependencyDataReady) { performUninstallApp(name); return; } // Cache still populating — open the dialog in a loading state and defer // the real dispatch until dependencyDataReadyChanged fires. Drop any // prior deferred connection first so re-clicking during the cold-boot // window doesn't stack callbacks. QObject::disconnect(m_pendingUninstallAppConn); m_pendingUninstallAppName = name; emit uninstallPlanRequested( buildLoadingPayload(name, QStringLiteral("app"))); QPointer self(this); m_pendingUninstallAppConn = connect( this, &PackageCoordinator::dependencyDataReadyChanged, this, [self, name]() { if (!self) return; if (self->m_pendingUninstallAppName != name) return; // cancelled self->m_pendingUninstallAppName.clear(); QObject::disconnect(self->m_pendingUninstallAppConn); self->performUninstallApp(name); }); } void PackageCoordinator::performUninstallApp(const QString& name) { // Compose: target + orphaned deps. requestMultiUninstall refuses the // whole batch if any member is embedded, so filter here. const uninstallplan::Plan plan = uninstallplan::composeFrom(planInput({name})); if (plan.batch.isEmpty()) { qWarning() << "performUninstallApp:" << name << "is not installed — ignoring"; return; } qDebug() << "performUninstallApp:" << name << "batch=" << plan.batch; // Remembered only so the explain pass can flag which rows the user // actually picked; the batch itself is recomputed on arrival. m_lastRequestedTargets = {name}; m_lastRequestKind = QStringLiteral("app"); if (!m_logosAPI) return; LogosModules logos(m_logosAPI); QPointer self(this); const QStringList batch = plan.batch; logos.package_manager.requestMultiUninstallAsync( batch, [self, batch](QVariantMap result) { if (!self) return; if (result.value("success", false).toBool()) return; const QString error = result.value("error").toString(); qWarning() << "requestMultiUninstall rejected for" << batch << ":" << error; self->m_lastRequestedTargets.clear(); }); } void PackageCoordinator::cancelPendingUninstallApp(const QString& name) { if (m_pendingUninstallAppName != name) return; m_pendingUninstallAppName.clear(); QObject::disconnect(m_pendingUninstallAppConn); } void PackageCoordinator::uninstallCoreModule(const QString& moduleName) { // Same flow as uninstallUiModule — requestUninstall is type-agnostic. // The module's pending state is global so there's no type-specific // bookkeeping to do here. qDebug() << "uninstallCoreModule:" << moduleName; if (!m_logosAPI) return; LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.requestUninstallAsync( moduleName, [self, moduleName](QVariantMap result) { if (!self) return; if (result.value("success", false).toBool()) return; const QString error = result.value("error").toString(); qWarning() << "requestUninstall rejected for" << moduleName << ":" << error; }); } // --------------------------------------------------------------------------- // Uninstall plan — compose (what to remove) and explain (why the rest stays) // --------------------------------------------------------------------------- QSet PackageCoordinator::loadedNames() const { QSet loaded; if (m_coreModuleManager) { const QStringList core = m_coreModuleManager->loadedModules(); loaded = QSet(core.cbegin(), core.cend()); } if (m_uiPluginManager) { // intersectWithLoaded already merges core + in-process UI widgets, so // asking it about every installed name yields the full loaded set — // including ui_qml plugins, which never appear in loadedModules(). const QStringList all = m_installTypeByModule.keys(); for (const QString& n : m_uiPluginManager->intersectWithLoaded(all)) loaded.insert(n); } return loaded; } uninstallplan::Input PackageCoordinator::planInput(const QStringList& targets) const { uninstallplan::Input in; in.targets = targets; for (auto it = m_dependenciesByModule.cbegin(); it != m_dependenciesByModule.cend(); ++it) { in.dependencies.insert(it.key(), it.value()); } for (auto it = m_dependentsByModule.cbegin(); it != m_dependentsByModule.cend(); ++it) { in.dependents.insert(it.key(), it.value()); } // m_installTypeByModule is keyed on every installed package (UI + core), // which makes its key list the installed set — and its values the // embedded/user split the plan needs. for (auto it = m_installTypeByModule.cbegin(); it != m_installTypeByModule.cend(); ++it) { in.installed << it.key(); if (it.value() == QLatin1String("embedded")) in.embedded.insert(it.key()); } in.protectedNames = {QStringLiteral("main_ui")}; for (auto it = m_displayNameByModule.cbegin(); it != m_displayNameByModule.cend(); ++it) { in.displayNames.insert(it.key(), it.value()); } in.versions = m_installedVersionByName; in.loaded = loadedNames(); return in; } namespace { QVariantMap removableRowToMap(const uninstallplan::Row& r) { return { {QStringLiteral("name"), r.name}, {QStringLiteral("displayName"), r.displayName}, {QStringLiteral("version"), r.version}, {QStringLiteral("isTarget"), r.isTarget}, {QStringLiteral("isLoaded"), r.isLoaded}, }; } } // namespace QVariantMap PackageCoordinator::buildLoadingPayload(const QString& name, const QString& kind) const { // Stub payload; UninstallDialog reads `loading` to swap in the spinner. // `multi: false` sends cancel through cancelPendingUninstallApp (no // module IPC to unwind). QVariantMap payload; payload.insert(QStringLiteral("loading"), true); payload.insert(QStringLiteral("kind"), kind); payload.insert(QStringLiteral("multi"), false); payload.insert(QStringLiteral("batch"), QVariantList{name}); payload.insert(QStringLiteral("targetName"), name); payload.insert(QStringLiteral("removable"), QVariantList{}); payload.insert(QStringLiteral("kept"), QVariantList{}); payload.insert(QStringLiteral("dependents"), QVariantList{}); return payload; } QVariantMap PackageCoordinator::buildPlanPayload(const QStringList& batch, const QStringList& installedDependents, const QString& kind, bool multi) const { // Explain pass: the batch is already fixed, so it IS the target list and // no orphan expansion runs. Everything left in the closure lands in // `kept` with the reason it survived. const uninstallplan::Plan plan = uninstallplan::explainOf(planInput(batch)); // Which of those rows the user actually picked. Empty (PMUI / Settings) // means "all of them", which is the honest reading there. const QSet picked(m_lastRequestedTargets.cbegin(), m_lastRequestedTargets.cend()); QVariantList removable; removable.reserve(plan.removable.size()); for (const uninstallplan::Row& r : plan.removable) { uninstallplan::Row row = r; row.isTarget = picked.isEmpty() || picked.contains(r.name); removable.append(removableRowToMap(row)); } QVariantList kept; kept.reserve(plan.kept.size()); for (const uninstallplan::KeptRow& k : plan.kept) { kept.append(QVariantMap{ {QStringLiteral("name"), k.name}, {QStringLiteral("displayName"), k.displayName}, // Enum → string for the QML payload. See uninstallplan::reasonName. {QStringLiteral("reason"), uninstallplan::reasonName(k.reason)}, {QStringLiteral("requiredBy"), k.requiredBy}, }); } // The module's dependents list is authoritative (it walked the same graph // at request time, under its own lock). Fall back to the locally computed // one only when the gate didn't carry it. QStringList dependentNames = installedDependents; if (dependentNames.isEmpty()) { for (const uninstallplan::Row& r : plan.dependents) dependentNames << r.name; } const QSet loaded = loadedNames(); const QSet batchSet(batch.cbegin(), batch.cend()); QVariantList dependents; for (const QString& n : dependentNames) { if (n.isEmpty() || batchSet.contains(n)) continue; dependents.append(QVariantMap{ {QStringLiteral("name"), n}, {QStringLiteral("displayName"), displayNameFor(n)}, {QStringLiteral("isLoaded"), loaded.contains(n)}, }); } return { {QStringLiteral("kind"), kind}, {QStringLiteral("multi"), multi}, {QStringLiteral("batch"), batch}, {QStringLiteral("removable"), removable}, {QStringLiteral("kept"), kept}, {QStringLiteral("dependents"), dependents}, }; } // --------------------------------------------------------------------------- // Cascade confirmation — triggered from QML once the user OKs the dialog. // --------------------------------------------------------------------------- void PackageCoordinator::cascadeUnloadForPackage(const QString& moduleName) { // Snapshot the loaded-dependents list BEFORE the cascade — once // unloadModuleWithDependents returns, the target is off the loaded- // modules list and the filter would come up empty. UI-plugin dependents // need teardown in-process because the core cascade only kills core // modules (QProcess termination). Without this pass, e.g. accounts_ui // stays wired to a now-dead accounts_module. QStringList loadedDeps; if (m_uiPluginManager) { loadedDeps = m_uiPluginManager->intersectWithLoaded( m_dependentsByModule.value(moduleName)); } const QStringList loadedCore = m_coreModuleManager ? m_coreModuleManager->loadedModules() : QStringList{}; // Core cascade: terminate the target process (if it's a loaded core // module) plus any loaded core-module dependents. Local-mode / pure-UI // targets aren't in loadedModules and the function will return // non-zero; we tolerate that and proceed to the UI teardown and module- // side confirm call — the user-visible action (deleting the package / // swapping versions) is what we must preserve. if (loadedCore.contains(moduleName) || !loadedDeps.isEmpty()) { qDebug() << "Cascade-unloading before uninstall:" << moduleName; bool ok = m_coreModuleManager ? m_coreModuleManager->unloadModuleWithDependents(moduleName) : false; if (!ok) { qWarning() << "Cascade unload failed during uninstall of" << moduleName << "— proceeding with confirm anyway"; } } // UI plugins are in-process widgets managed by UIPluginManager, not core // processes. teardownUiPluginWidget is idempotent, so calling it for // names that aren't loaded UI plugins is harmless. if (m_uiPluginManager) { for (const QString& dep : loadedDeps) { m_uiPluginManager->teardownUiPluginWidget(dep); } m_uiPluginManager->teardownUiPluginWidget(moduleName); } } void PackageCoordinator::confirmUninstallCascade(const QString& moduleName) { if ((m_pendingAction.op != PendingOp::UninstallCascade && m_pendingAction.op != PendingOp::UpgradeCascade) || m_pendingAction.name != moduleName) { qWarning() << "confirmUninstallCascade for" << moduleName << "but pending action is" << m_pendingAction.name; return; } // Snapshot before clearing — the callbacks below capture by value. const bool isUpgrade = (m_pendingAction.op == PendingOp::UpgradeCascade); const QString releaseTag = m_pendingAction.releaseTag; m_pendingAction = {}; QPointer selfDefer(this); QMetaObject::invokeMethod(this, [this, selfDefer, moduleName, isUpgrade, releaseTag]() { if (!selfDefer) return; cascadeUnloadForPackage(moduleName); // Hand the actual package-lifecycle work back to the module. if (!m_logosAPI) return; LogosModules logos(m_logosAPI); if (isUpgrade) { // Upgrade — the module does the uninstall step + emits // upgradeUninstallDone for PMU to drive the install of the new // version (a catalog download, or the local .lgx it stashed). logos.package_manager.confirmUpgradeAsync(moduleName, releaseTag, [moduleName](QVariantMap r) { if (!r.value("success", false).toBool()) { qWarning() << "confirmUpgrade rejected for" << moduleName << ":" << r.value("error").toString(); } }); } else { // Plain uninstall. logos.package_manager.confirmUninstallAsync(moduleName, [moduleName](QVariantMap r) { if (!r.value("success", false).toBool()) { qWarning() << "confirmUninstall rejected for" << moduleName << ":" << r.value("error").toString(); } }); } emit coreModulesChanged(); emit uiModulesChanged(); emit launcherAppsChanged(); }, Qt::QueuedConnection); // run the cascade off the click stack } void PackageCoordinator::refresh() { fetchUiPluginMetadata(); refreshRepositories(); } void PackageCoordinator::remoteRefresh() { if (!m_appsLoading) { m_appsLoading = true; emit appsLoadingChanged(); } LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (!dlClient || !dlClient->isConnected()) { // Downloader unreachable — fall back to a local re-sync refresh(); return; } LogosModules logos(m_logosAPI); QPointer self(this); logos.package_downloader.refreshCatalogAsync([self](QVariantMap r) { if (!self) return; const QString err = r.value(QStringLiteral("error")).toString(); if (!err.isEmpty()) qWarning() << "package_downloader.refreshCatalog reported:" << err; self->refresh(); }); } void PackageCoordinator::cancelPendingAction(const QString& moduleName) { if (m_pendingAction.op == PendingOp::None || m_pendingAction.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 package action for" << moduleName; const PendingOp op = m_pendingAction.op; const QString releaseTag = m_pendingAction.releaseTag; m_pendingAction = {}; // Uninstall / Upgrade are gated by the module — tell it we bailed; // otherwise its pending slot stays set and the next request is // rejected with "another is in progress". if (!m_logosAPI) return; LogosModules logos(m_logosAPI); if (op == PendingOp::UpgradeCascade) { logos.package_manager.cancelUpgradeAsync(moduleName, releaseTag, [](QVariantMap){}); } else { logos.package_manager.cancelUninstallAsync(moduleName, [](QVariantMap){}); } } // --------------------------------------------------------------------------- // Ack-gated cascade event handlers // // package_manager emits beforeUninstall / beforeUpgrade BEFORE any destructive // work, then starts a short (3s) ack timer. Our contract: // // 1. Call ackPendingActionAsync IMMEDIATELY — before any UI work — to // cancel the module's ack timer and claim the pending slot. // 2. Only emit the cascade dialog if the ack SUCCEEDED. If it failed, the // module already cancelled the request (timer fired, or another listener // got there first); emitting the dialog would let the user "Continue" // into a dead request. // // Once we own the slot, the user has unlimited time to decide. confirm* / // cancel* on the module ends the flow. // --------------------------------------------------------------------------- void PackageCoordinator::onBeforeUninstall(const QString& name, const QStringList& installedDeps) { if (!m_logosAPI) return; // Last-line defence. The module now rejects empty names at requestUninstall // (and PMU + QML filter them too), so this branch shouldn't fire in // practice. Kept because an empty name here would open a cascade dialog // titled "Uninstall ''?" — the user-reported symptom — and because it's // cheaper than re-debugging it if a future caller bypasses the gate. if (name.isEmpty()) { qWarning() << "PackageCoordinator::onBeforeUninstall received empty name — ignoring"; return; } LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.ackPendingActionAsync(name, [self, name, installedDeps](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) { // The module already rejected us (ack timer fired, or the // request was cancelled by another path). Do NOT show a // dialog — package_manager already emitted uninstallCancelled // to its listeners. qWarning() << "ackPendingAction rejected for" << name << ":" << result.value("error").toString(); return; } self->m_pendingAction = {PendingOp::UninstallCascade, name, QString{}, 0}; // A batch of one. The single-uninstall gate is what // package_manager_ui's trash icon and Settings → Modules use, so // confirm/cancel route to the single slots — hence multi=false. // Everything else (the Kept section, the dependent warning) is // identical to the multi path. const QVariantMap payload = self->buildPlanPayload( {name}, installedDeps, QStringLiteral("packages"), /*multi=*/false); self->m_lastRequestedTargets.clear(); emit self->uninstallPlanRequested(payload); }); } void PackageCoordinator::onBeforeUpgrade(const QString& name, const QString& releaseTag, int mode, const QStringList& installedDeps, const QVariantList& depChanges) { if (!m_logosAPI) return; // Mirror of onBeforeUninstall — see rationale there. if (name.isEmpty()) { qWarning() << "PackageCoordinator::onBeforeUpgrade received empty name — ignoring"; return; } LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.ackPendingActionAsync(name, [self, name, releaseTag, mode, installedDeps, depChanges](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) { qWarning() << "ackPendingAction rejected for" << name << ":" << result.value("error").toString(); return; } const QStringList loadedDeps = self->m_uiPluginManager ? self->m_uiPluginManager->intersectWithLoaded(installedDeps) : QStringList{}; self->m_pendingAction = {PendingOp::UpgradeCascade, name, releaseTag, mode, {}}; // Distinct cascade signal for upgrade/downgrade/reinstall: same // dependent-impact lists as the uninstall variant (the // package_manager performs an uninstall step first), but the // dialog needs the target version + UpgradeMode so it can lead // with "Upgrade to vX.Y.Z" / "Downgrade to vX.Y.Z" / // "Reinstall vX.Y.Z" instead of bare "Uninstall and Unload // Dependents?" — which previously caused user confusion on // downgrades that looked like a pure uninstall. emit self->upgradeCascadeConfirmationRequested( name, releaseTag, mode, installedDeps, loadedDeps, depChanges); }); } void PackageCoordinator::onBeforeInstall(const QString& name, const QString& releaseTag, const QVariantList& depChanges) { if (!m_logosAPI) return; if (name.isEmpty()) { qWarning() << "PackageCoordinator::onBeforeInstall received empty name — ignoring"; return; } LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.ackPendingActionAsync(name, [self, name, releaseTag, depChanges](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) { qWarning() << "ackPendingAction rejected for" << name << ":" << result.value("error").toString(); return; } // No pending-slot / cascade work — a fresh install unloads nothing. // The dialog's confirm/cancel forward straight to the module gate. emit self->installGateConfirmationRequested(name, releaseTag, depChanges); }); } void PackageCoordinator::confirmInstallGate(const QString& name) { if (!m_logosAPI || name.isEmpty()) return; LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.confirmInstallAsync(name, [self, name](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) qWarning() << "confirmInstallGate rejected for" << name << ":" << result.value("error").toString(); }); } void PackageCoordinator::cancelInstallGate(const QString& name) { if (!m_logosAPI || name.isEmpty()) return; LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.cancelInstallAsync(name, [self, name](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) qWarning() << "cancelInstallGate rejected for" << name << ":" << result.value("error").toString(); }); } void PackageCoordinator::onBeforeMultiUninstall(const QStringList& names, const QStringList& installedDeps) { if (!m_logosAPI) return; if (names.isEmpty()) { qWarning() << "PackageCoordinator::onBeforeMultiUninstall received empty name list — ignoring"; return; } // Ack with any name from the batch — the module's ackPendingAction accepts // any member of the pending batch's names for MultiUninstall (single-op // ack still requires exact-match against m_pendingAction.name). Picking // names.first() is convention; one ack closes the 3s timer for the whole // batch. LogosModules logos(m_logosAPI); QPointer self(this); const QString ackName = names.first(); logos.package_manager.ackPendingActionAsync(ackName, [self, names, installedDeps, ackName](QVariantMap result) { if (!self) return; if (!result.value("success", false).toBool()) { qWarning() << "ackPendingAction (multi) rejected for" << ackName << ":" << result.value("error").toString(); // The module already cancelled; drop the remembered targets so // they can't mislabel the next batch that comes through. self->m_lastRequestedTargets.clear(); return; } self->m_pendingAction = {PendingOp::MultiUninstallCascade, QString{}, QString{}, 0, names}; // `kind` is "app" only when WE composed this batch from a single // App Manager row; a batch we didn't originate (package_manager_ui // bulk selection) is always "packages", and a stale target list // that isn't part of the arriving batch is dropped rather than // used to mislabel someone else's request. QString kind = QStringLiteral("packages"); const QSet batchSet(names.cbegin(), names.cend()); bool targetsBelong = !self->m_lastRequestedTargets.isEmpty(); for (const QString& t : self->m_lastRequestedTargets) if (!batchSet.contains(t)) targetsBelong = false; if (targetsBelong) kind = self->m_lastRequestKind; else self->m_lastRequestedTargets.clear(); const QVariantMap payload = self->buildPlanPayload(names, installedDeps, kind, /*multi=*/true); self->m_lastRequestedTargets.clear(); emit self->uninstallPlanRequested(payload); }); } void PackageCoordinator::confirmUninstallMultiCascade(const QStringList& moduleNames) { if (m_pendingAction.op != PendingOp::MultiUninstallCascade || m_pendingAction.names != moduleNames) { qWarning() << "confirmUninstallMultiCascade: no matching pending action — ignoring"; return; } m_pendingAction = {}; // Mirror of confirmUninstallCascade's cascade-unload, looped over each // batch member. Snapshot loaded UI dependents per name BEFORE the core // unload so the UI teardown pass can find them. Per-name dependents are // already deduped within each list, but a single dep could appear under // multiple targets — that's harmless because both teardownUiPluginWidget // and unloadModuleWithDependents are idempotent on already-gone modules. QStringList loadedCore = m_coreModuleManager ? m_coreModuleManager->loadedModules() : QStringList{}; for (const QString& moduleName : moduleNames) { QStringList loadedDeps; if (m_uiPluginManager) { loadedDeps = m_uiPluginManager->intersectWithLoaded( m_dependentsByModule.value(moduleName)); } if (loadedCore.contains(moduleName) || !loadedDeps.isEmpty()) { qDebug() << "Cascade-unloading before multi-uninstall:" << moduleName; bool ok = m_coreModuleManager ? m_coreModuleManager->unloadModuleWithDependents(moduleName) : false; if (!ok) { qWarning() << "Cascade unload failed during multi-uninstall of" << moduleName << "— proceeding"; } // Refresh the loaded snapshot so the next iteration sees the // post-cascade state (dependents that were also batch members // are already gone after their own cascade ran). loadedCore = m_coreModuleManager ? m_coreModuleManager->loadedModules() : QStringList{}; } if (m_uiPluginManager) { for (const QString& dep : loadedDeps) { m_uiPluginManager->teardownUiPluginWidget(dep); } m_uiPluginManager->teardownUiPluginWidget(moduleName); } } // Hand the destructive work back to the module under one confirm call. // No rollback path on rejection: at this point the cascade-unload above // has already run, so a `success: false` here means the modules are // unloaded but their packages remain on disk. Rejection is rare in // normal flow (we just acked, the module's pending state is ours) — most // commonly it'd indicate a name-list mismatch we should never construct. // The user can re-load via the Modules tab if they hit this. if (!m_logosAPI) return; LogosModules logos(m_logosAPI); logos.package_manager.confirmMultiUninstallAsync(moduleNames, [moduleNames](QVariantMap r) { if (!r.value("success", false).toBool()) { qWarning() << "confirmMultiUninstall rejected:" << r.value("error").toString(); } }); emit coreModulesChanged(); emit uiModulesChanged(); emit launcherAppsChanged(); } void PackageCoordinator::cancelMultiUninstall(const QStringList& moduleNames) { if (m_pendingAction.op != PendingOp::MultiUninstallCascade || m_pendingAction.names != moduleNames) { // Cancel was fanned out from MainUIBackend or arrived after another // path already cleared the slot — treat as no-op rather than warning. return; } m_pendingAction = {}; if (!m_logosAPI) return; LogosModules logos(m_logosAPI); logos.package_manager.cancelMultiUninstallAsync(moduleNames, [](QVariantMap){}); } // --------------------------------------------------------------------------- // Metadata refresh chain // --------------------------------------------------------------------------- void PackageCoordinator::fetchUiPluginMetadata() { // The !m_logosAPI branch below already does the right thing when package // metadata is unavailable -- clear the loading state and tell the UI -- so // an absent package_manager takes the same path rather than blocking 20 s // acquiring a token for a module that is not there. Without this the app // would show its window and then sit in a loading state for the timeout. if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) { if (m_appsLoading) { m_appsLoading = false; emit appsLoadingChanged(); } emit uiModulesChanged(); return; } if (!m_logosAPI) { if (m_appsLoading) { m_appsLoading = false; emit appsLoadingChanged(); } emit uiModulesChanged(); return; } LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.getInstalledUiPluginsAsync([self](QVariantList uiPlugins) { if (!self) return; // Seed installType for the UI-plugin subset. refreshDependencyInfo's // full-scan pass will overwrite this with the core-inclusive version; // we do this first so QML has a non-empty map to key on during the // window between the two async calls. self->m_installTypeByModule.clear(); QHash installedByName; // name → installed version for (const QVariant& item : uiPlugins) { QVariantMap pluginInfo = item.toMap(); const QString name = pluginInfo.value("name").toString(); if (name.isEmpty()) continue; self->m_installTypeByModule[name] = pluginInfo.value("installType").toString(); const QString version = pluginInfo.value("version").toString(); installedByName.insert(name, version); const QString rootHash = pluginInfo.value("hashes").toMap().value("root").toString(); self->m_installedHashByName.insert(name, rootHash); } // Push the raw UI-plugin list to UIPluginManager — that's where the // UI-specific cache (used for widget loading) lives. emit self->uiPluginsFetched(uiPlugins); emit self->uiModulesChanged(); emit self->launcherAppsChanged(); self->refreshDependencyInfo(); // Kick off the App-Manager catalog fetch. self->tryFetchCatalog(installedByName, /*retriesLeft=*/10); }); } void PackageCoordinator::tryFetchCatalog(const QHash& installedByName, int retriesLeft) { LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (dlClient && dlClient->isConnected()) { QPointer self(this); dlClient->invokeRemoteMethodAsync( "package_downloader", "getCatalog", QVariantList{}, [self, installedByName](QVariant catalogVar) { if (!self) return; const QVariantList catalog = catalogVar.toList(); self->buildCatalogIndexes(catalog); self->populateAppsModel(catalog, installedByName); }); return; } if (retriesLeft <= 0) { // Give up — drop the App Manager overlay so Reload doesn't stick. if (m_appsLoading) { m_appsLoading = false; emit appsLoadingChanged(); } return; } QPointer self(this); QTimer::singleShot(200, this, [self, installedByName, retriesLeft]() { if (!self) return; self->tryFetchCatalog(installedByName, retriesLeft - 1); }); } void PackageCoordinator::buildCatalogIndexes(const QVariantList& catalog) { QHash versionsByRepoAndName; QHash repoByName; versionsByRepoAndName.reserve(catalog.size()); repoByName.reserve(catalog.size()); for (const QVariant& v : catalog) { const QVariantMap row = v.toMap(); const QString name = row.value("name").toString(); if (name.isEmpty()) continue; const QString repo = row.value("repositoryUrl").toString(); versionsByRepoAndName.insert(catalogKey(repo, name), row.value("versions").toList()); repoByName.insert(name, repo); } m_versionsByRepoAndName = std::move(versionsByRepoAndName); m_repoByName = std::move(repoByName); } void PackageCoordinator::populateAppsModel( const QVariantList& catalog, const QHash& installedByName) { if (!m_appsModel) return; m_appsModel->replaceCatalog(catalog); m_appsModel->mergeLocalOnlyInstalled(m_installedPackagesCache); const QHash& fullInstalled = m_installedVersionByName.isEmpty() ? installedByName : m_installedVersionByName; m_appsModel->beginBulkInstalledUpdate(); for (auto it = fullInstalled.cbegin(); it != fullInstalled.cend(); ++it) { const QString& name = it.key(); const QString& ver = it.value(); const QString hash = m_installedHashByName.value(name); m_appsModel->markInstalled(name, ver, hash); const QString installType = m_installTypeByModule.value(name); if (!installType.isEmpty()) m_appsModel->setInstallType(name, installType); if (m_uiPluginManager) { const QString iconUrl = m_uiPluginManager->pluginIconUrl(name); if (!iconUrl.isEmpty()) m_appsModel->setIconUrl(name, iconUrl); } } for (auto it = m_missingDepsByModule.cbegin(); it != m_missingDepsByModule.cend(); ++it) { m_appsModel->setMissingDeps(it.key(), it.value()); } m_appsModel->endBulkInstalledUpdate(); if (m_appsLoading) { m_appsLoading = false; emit appsLoadingChanged(); } } // ── Package repository management ────────────────────────────────────────── void PackageCoordinator::refreshRepositories() { LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (!dlClient || !dlClient->isConnected()) return; const bool wasLoading = m_repositoriesLoadingCount > 0; ++m_repositoriesLoadingCount; if (!wasLoading) emit repositoriesLoadingChanged(); QPointer self(this); dlClient->invokeRemoteMethodAsync( "package_downloader", "listRepositories", QVariantList{}, [self](QVariant result) { if (!self) return; self->m_repositories = result.toList(); const int remaining = --self->m_repositoriesLoadingCount; emit self->repositoriesChanged(); if (remaining == 0) emit self->repositoriesLoadingChanged(); }); } // add/remove/setEnabled share a {success, error} result shape. The // post-success refresh happens via catalogChanged in // subscribeToPackageDownloaderEvents, so this only forwards the outcome. void invokeRepositoryMutation(PackageCoordinator* self, LogosAPIClient* dlClient, const QString& methodName, const QString& operation, const QString& url, const QVariantList& args) { QPointer selfPtr(self); dlClient->invokeRemoteMethodAsync( "package_downloader", methodName, args, [selfPtr, operation, url](QVariant result) { if (!selfPtr) return; const QVariantMap r = result.toMap(); const bool ok = r.value("success").toBool(); emit selfPtr->repositoryOperationCompleted(operation, url, ok, r.value("error").toString()); if (ok) selfPtr->refreshRepositories(); }); } void PackageCoordinator::addRepository(const QString& url) { LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (!dlClient || !dlClient->isConnected()) { emit repositoryOperationCompleted(QStringLiteral("add"), url, false, QStringLiteral("package_downloader not connected")); return; } invokeRepositoryMutation(this, dlClient, QStringLiteral("addRepository"), QStringLiteral("add"), url, QVariantList{url}); } void PackageCoordinator::removeRepository(const QString& url) { LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (!dlClient || !dlClient->isConnected()) { emit repositoryOperationCompleted(QStringLiteral("remove"), url, false, QStringLiteral("package_downloader not connected")); return; } invokeRepositoryMutation(this, dlClient, QStringLiteral("removeRepository"), QStringLiteral("remove"), url, QVariantList{url}); } void PackageCoordinator::setRepositoryEnabled(const QString& url, bool enabled) { LogosAPIClient* dlClient = m_logosAPI ? m_logosAPI->getClient("package_downloader") : nullptr; if (!dlClient || !dlClient->isConnected()) { emit repositoryOperationCompleted(QStringLiteral("setEnabled"), url, false, QStringLiteral("package_downloader not connected")); return; } invokeRepositoryMutation(this, dlClient, QStringLiteral("setRepositoryEnabled"), QStringLiteral("setEnabled"), url, QVariantList{url, enabled}); } void PackageCoordinator::refreshDependencyInfo() { if (!m_logosAPI) return; // Same reasoning as fetchUiPluginMetadata: no package_manager, no // dependency info to fetch, and no reason to block on discovering that. if (!moduleIsLoaded(m_coreModuleManager, "package_manager")) return; LogosModules logos(m_logosAPI); QPointer self(this); // First: refresh installType for every installed package (UI + core). // We want the Uninstall button on both tabs to gate correctly, so the // map must cover everything — not just UI plugins. fetchUiPluginMetadata // already filled installType for its subset; we overwrite with this // full-scan so core-only modules pick up their installType too. logos.package_manager.getInstalledPackagesAsync( [self](QVariantList packages) { if (!self) return; self->m_installedPackagesCache = packages; QMap typeMap; QSet nameSet; QHash versionByName; QHash hashByName; nameSet.reserve(packages.size()); versionByName.reserve(packages.size()); hashByName.reserve(packages.size()); QMap displayNameMap; for (const QVariant& v : packages) { const QVariantMap pkg = v.toMap(); const QString name = pkg.value("name").toString(); if (name.isEmpty()) continue; typeMap[name] = pkg.value("installType").toString(); const QString dn = pkg.value("displayName").toString(); if (!dn.isEmpty()) displayNameMap[name] = dn; // moduleName is the key openApp / runResolverAndOpenDialog // use; fall back to name when the field is absent. const QString lookupName = pkg.value("moduleName").toString().isEmpty() ? name : pkg.value("moduleName").toString(); const QString version = pkg.value("version").toString(); const QString rootHash = pkg.value("hashes").toMap().value("root").toString(); nameSet.insert(lookupName); if (!version.isEmpty()) versionByName.insert(lookupName, version); if (!rootHash.isEmpty()) hashByName.insert(lookupName, rootHash); } self->m_installTypeByModule = std::move(typeMap); self->m_displayNameByModule = std::move(displayNameMap); self->m_installedNameSet = std::move(nameSet); self->m_installedVersionByName = std::move(versionByName); self->m_installedHashByName = std::move(hashByName); if (self->m_appsModel) { self->m_appsModel->mergeLocalOnlyInstalled(self->m_installedPackagesCache); self->m_appsModel->replaceInstalledSet( self->m_installedVersionByName, self->m_installedHashByName); } // Second pass — per-module missing/dependents queries. Dispatched // for every entry in the full installed-packages list (both UI and // core) so that QML lookups work uniformly regardless of which tab // is surfacing the button. `typeMap` was moved into // m_installTypeByModule above, so we read from the destination. QStringList names = self->m_installTypeByModule.keys(); if (names.isEmpty()) { self->m_missingDepsByModule.clear(); self->m_dependentsByModule.clear(); self->m_dependenciesByModule.clear(); // Nothing installed is a complete answer, not an unfinished one — // flip the gate so the UI doesn't wait forever on an empty box. if (!self->m_dependencyDataReady) { self->m_dependencyDataReady = true; emit self->dependencyDataReadyChanged(); } emit self->uiModulesChanged(); emit self->coreModulesChanged(); // Sidebar reads from launcherApps (not uiModules) so it needs its // own kick whenever hasMissingDeps may have flipped. emit self->launcherAppsChanged(); return; } LogosModules inner(self->m_logosAPI); auto remaining = std::make_shared(names.size() * 2); auto missingMap = std::make_shared>(); auto dependenciesMap = std::make_shared>(); auto dependentsMap = std::make_shared>(); QPointer selfCopy(self.data()); auto maybeFinish = [selfCopy, missingMap, dependenciesMap, dependentsMap, remaining]() { if (!selfCopy) return; if (--(*remaining) > 0) return; selfCopy->m_missingDepsByModule = *missingMap; selfCopy->m_dependenciesByModule = *dependenciesMap; selfCopy->m_dependentsByModule = *dependentsMap; if (!selfCopy->m_dependencyDataReady) { selfCopy->m_dependencyDataReady = true; emit selfCopy->dependencyDataReadyChanged(); } if (selfCopy->m_appsModel) { for (auto it = missingMap->cbegin(); it != missingMap->cend(); ++it) selfCopy->m_appsModel->setMissingDeps(it.key(), it.value()); } emit selfCopy->uiModulesChanged(); emit selfCopy->coreModulesChanged(); // Critical for the sidebar red-cross marker — without this, the // SidebarPanel's launcherApps binding doesn't re-evaluate and the // marker only appears on the next side-effect that triggers a // launcher refresh (e.g. clicking the plugin to load it). emit selfCopy->launcherAppsChanged(); }; for (const QString& name : names) { // One call, two caches. The missing-deps list is the subset with // status == "not_installed"; the full closure (minus those) is // what the uninstall plan walks. Keeping both here is why the // dependency cleanup needs no extra IPC. inner.package_manager.resolveFlatDependenciesAsync( name, true, [missingMap, dependenciesMap, name, maybeFinish](QVariantList deps) { QStringList missing; QStringList installed; for (const QVariant& v : deps) { const QVariantMap m = v.toMap(); const QString s = m.value("name").toString(); if (s.isEmpty()) continue; if (m.value("status").toString() == "not_installed") missing << s; else installed << s; } missingMap->insert(name, missing); dependenciesMap->insert(name, installed); maybeFinish(); }); inner.package_manager.resolveFlatDependentsAsync( name, true, [dependentsMap, name, maybeFinish](QVariantList deps) { QStringList out; for (const QVariant& v : deps) { const QVariantMap m = v.toMap(); const QString s = m.value("name").toString(); if (!s.isEmpty()) out << s; } dependentsMap->insert(name, out); maybeFinish(); }); } }); } // --------------------------------------------------------------------------- // App-Manager catalog install pipeline (ported from PMUI's // PackageManagerBackend, adapted to PackageCoordinator's session model). // --------------------------------------------------------------------------- QString PackageCoordinator::buildResolverDepsJson(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins) const { QJsonArray arr; QSet seenDeps; auto append = [&arr, &seenDeps](const QString& n, const QString& repo, const QString& ver) { if (n.isEmpty() || seenDeps.contains(n)) return; seenDeps.insert(n); QJsonObject obj; obj.insert(QStringLiteral("name"), n); if (!repo.isEmpty()) obj.insert(QStringLiteral("repositoryUrl"), repo); if (!ver.isEmpty()) obj.insert(QStringLiteral("version"), ver); arr.append(obj); }; append(name, repositoryUrl, versionPins.value(name).toString()); if (!repositoryUrl.isEmpty() && m_appsModel) { QStringList queue; queue << name; for (int head = 0; head < queue.size(); ++head) { const QString cur = queue[head]; const QVariantMap row = m_appsModel->rowDataByName(cur, repositoryUrl); if (row.isEmpty()) continue; const QVariantList deps = row.value("dependencies").toList(); for (const QVariant& d : deps) { const QString depName = d.toMap().value("name").toString(); if (depName.isEmpty() || seenDeps.contains(depName)) continue; const QVariantMap depRow = m_appsModel->rowDataByName(depName, repositoryUrl); if (depRow.isEmpty()) continue; // not in this repo — leave unpinned append(depName, repositoryUrl, versionPins.value(depName).toString()); queue << depName; } } } for (auto it = versionPins.cbegin(); it != versionPins.cend(); ++it) { const QString pinName = it.key(); if (pinName == name || pinName.isEmpty()) continue; if (seenDeps.contains(pinName)) continue; const QString pinVersion = it.value().toString(); if (pinVersion.isEmpty()) continue; append(pinName, m_repoByName.value(pinName), pinVersion); } return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)); } QString PackageCoordinator::buildInstalledPackagesJson() const { QJsonArray arr; for (const QVariant& v : m_installedPackagesCache) { const QVariantMap m = v.toMap(); // package_manager rows expose both `name` and `moduleName`; the // resolver wants the module name. Fall back to `name` when the // module-name field is empty (older index shape). const QString name = m.value("moduleName").toString().isEmpty() ? m.value("name").toString() : m.value("moduleName").toString(); const QString version = m.value("version").toString(); if (name.isEmpty() || version.isEmpty()) continue; QJsonObject o; o.insert(QStringLiteral("name"), name); o.insert(QStringLiteral("version"), version); const QString rootHash = m.value("hashes").toMap().value("root").toString(); if (!rootHash.isEmpty()) o.insert(QStringLiteral("rootHash"), rootHash); arr.append(o); } return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)); } QVariantMap nameAndRepo(const QString& name, const QString& repo) { return { {QStringLiteral("name"), name}, {QStringLiteral("repositoryUrl"), repo}, }; } QVariantList PackageCoordinator::collectCatalogRequired(const QString& name, const QString& repositoryUrl) const { QVariantList out; QSet seen; out.append(nameAndRepo(name, repositoryUrl)); seen.insert(name); if (repositoryUrl.isEmpty() || !m_appsModel) return out; QStringList queue; queue << name; for (int head = 0; head < queue.size(); ++head) { const QVariantMap row = m_appsModel->rowDataByName(queue[head], repositoryUrl); if (row.isEmpty()) continue; const QVariantList deps = row.value("dependencies").toList(); for (const QVariant& d : deps) { const QString depName = d.toMap().value("name").toString(); if (depName.isEmpty() || seen.contains(depName)) continue; if (m_appsModel->rowDataByName(depName, repositoryUrl).isEmpty()) continue; seen.insert(depName); out.append(nameAndRepo(depName, repositoryUrl)); queue << depName; } } return out; } QString PackageCoordinator::depAction(const QString& installedVersion, const QString& resolvedVersion, const QString& installedHash, const QString& resolvedHash) { if (installedVersion.isEmpty()) return QStringLiteral("install"); if (installedVersion == resolvedVersion) { const bool hashKnown = !installedHash.isEmpty() && !resolvedHash.isEmpty(); if (hashKnown && installedHash != resolvedHash) return QStringLiteral("reinstall"); return QStringLiteral("installed"); } return QStringLiteral("upgrade"); } bool PackageCoordinator::installPluginSucceeded(const QVariantMap& installResult) { return installResult.value(QStringLiteral("error")).toString().isEmpty(); } QVariantMap PackageCoordinator::changeFromResolverEntry(const QVariantMap& entry, const QString& installedVersion, const QString& installedHash) { if (entry.contains("error")) { return { {QStringLiteral("name"), entry.value("name")}, {QStringLiteral("action"), QStringLiteral("error")}, {QStringLiteral("error"), entry.value("error")}, }; } const QString to = entry.value("version").toString(); const QString toH = entry.value("rootHash").toString(); return { {QStringLiteral("name"), entry.value("name").toString()}, {QStringLiteral("toVersion"), to}, {QStringLiteral("fromVersion"), installedVersion}, {QStringLiteral("repositoryUrl"), entry.value("repositoryUrl").toString()}, {QStringLiteral("description"), entry.value("description").toString()}, {QStringLiteral("action"), depAction(installedVersion, to, installedHash, toH)}, {QStringLiteral("isTopLevel"), entry.value("topLevel").toBool()}, }; } QVariantList PackageCoordinator::computeDepChanges( const QVariantList& resolved, const QHash& installedByName) const { QVariantList out; for (const QVariant& v : resolved) { const QVariantMap m = v.toMap(); const QString name = m.value("name").toString(); QVariantMap c = changeFromResolverEntry( m, installedByName.value(name), m_installedHashByName.value(name)); if (c.value("action").toString() == QStringLiteral("error")) { out.append(c); continue; } const QString repoUrl = c.value("repositoryUrl").toString(); c.insert(QStringLiteral("versions"), m_versionsByRepoAndName.value(catalogKey(repoUrl, name))); if (c.value("isTopLevel").toBool()) out.prepend(c); else out.append(c); } return out; } void PackageCoordinator::setOpStage(const QString& name, InstallStage::Value stage) { if (!m_installRegistry->has(name)) return; if (m_installRegistry->stage(name) == static_cast(stage)) return; m_installRegistry->setStage(name, stage); emit catalogInstallStageChanged(name, stage); } void PackageCoordinator::openApp(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins, bool allowFastLaunch) { if (!m_logosAPI || name.isEmpty()) return; // Fast-launch only for the tile whose repo's rootHash matches what's // on disk. installStatus is already missing-deps-aware: AppsModel's // recomputeInstallStatus demotes a row with non-empty missingDeps to // NotInstalled, so tileStatus == Installed implies healthy deps. int tileStatus = InstallStatus::NotInstalled; if (m_appsModel) { const QVariantMap row = m_appsModel->rowDataByName(name, repositoryUrl); tileStatus = row.value("installStatus").toInt(); } if (allowFastLaunch && tileStatus == InstallStatus::Installed) { qDebug() << "openApp fast-path: installed (v=" << m_installedVersionByName.value(name) << "), emitting launchAppRequested"; emit launchAppRequested(name); return; } runResolverAndOpenDialog(name, repositoryUrl, versionPins); } void PackageCoordinator::notifyAddApplicationDialogClosed() { if (m_activeAddDialogName.isEmpty()) return; ++m_dialogResolveEpoch[m_activeAddDialogName]; m_activeAddDialogName.clear(); } void PackageCoordinator::runResolverAndOpenDialog(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins) { QVariantMap catalogRow = m_appsModel ? m_appsModel->rowDataByName(name, repositoryUrl) : QVariantMap{}; const QString targetVersion = versionPins.value(name).toString(); const int epoch = ++m_dialogResolveEpoch[name]; m_activeAddDialogName = name; const QString depsJson = buildResolverDepsJson(name, repositoryUrl, versionPins); qDebug() << "PackageCoordinator::runResolverAndOpenDialog" << name << "repo=" << repositoryUrl << "targetVersion=" << targetVersion << "pins=" << versionPins.size() << "epoch=" << epoch; QVariantList initialChanges; if (m_installRegistry->isInFlight(name)) initialChanges = m_lastResolvedChangesByName.value(name); // Sync stack frame only — QML may open the modal from this signal. emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, initialChanges, /*requestOpen=*/true); LogosModules logos(m_logosAPI); QPointer self(this); logos.package_downloader.resolveDependenciesAsync(depsJson, QString(), [self, name, repositoryUrl, targetVersion, catalogRow, epoch] (QVariantList resolved) { if (!self) return; if (self->m_dialogResolveEpoch.value(name) != epoch) { qDebug() << "runResolverAndOpenDialog: dropping superseded epoch" << epoch << "for" << name; return; } const QVariantList changes = self->computeDepChanges(resolved, self->m_installedVersionByName); if (!resolved.isEmpty()) self->m_lastResolvedRawByName.insert(name, resolved); if (!changes.isEmpty()) self->m_lastResolvedChangesByName.insert(name, changes); // Async refresh only — never reopens the modal. self->emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, changes, /*requestOpen=*/false); }); } void PackageCoordinator::emitDialogMetadata(const QString& name, const QString& repositoryUrl, const QString& targetVersion, const QVariantMap& catalogRow, const QVariantList& changes, bool requestOpen) { if (name != m_activeAddDialogName) return; QVariantMap metadata; metadata["name"] = name; metadata["repositoryUrl"] = repositoryUrl; metadata["selectedVersion"] = targetVersion; metadata["displayName"] = catalogRow.value("displayName").toString().isEmpty() ? name : catalogRow.value("displayName"); metadata["description"] = catalogRow.value("description"); metadata["icon"] = catalogRow.value("iconUrl"); metadata["category"] = catalogRow.value("category"); metadata["versions"] = catalogRow.value("versions").toList(); const QString installedVersion = m_installedVersionByName.value(name); // Per-tile installStatus from the CLICKED row. Already missing-deps-aware // (AppsModel::recomputeInstallStatus returns NotInstalled when missingDeps // is non-empty), so the dialog reads Install instead of Launch for a // partial install. int tileStatus = InstallStatus::NotInstalled; if (m_appsModel) { const QVariantMap clickedRow = m_appsModel->rowDataByName(name, repositoryUrl); tileStatus = clickedRow.value("installStatus").toInt(); } metadata["installStatus"] = tileStatus; metadata["isInstalled"] = tileStatus == InstallStatus::Installed; metadata["installedVersion"] = installedVersion; // Needed by the dialog's Uninstall affordance: "embedded" packages ship // inside the bundle and package_manager refuses to remove them, so the // button has to hide rather than offer a call that always fails. metadata["installType"] = m_installTypeByModule.value(name); const QVariantList versionsList = catalogRow.value("versions").toList(); metadata["latestVersion"] = versionsList.isEmpty() ? QString() : versionsList.first().toMap().value("manifest").toMap().value("version").toString(); metadata["installStage"] = m_installRegistry->stage(name); // {name, repo} entries so the filter pins each row to the resolver's // chosen repo and multi-repo names don't duplicate. Always at least the // top-level entry so the dialog has something to render before the // async resolver callback arrives. QVariantList requiredEntries; QSet seen; requiredEntries.reserve(changes.size() + 1); requiredEntries.append(nameAndRepo(name, repositoryUrl)); seen.insert(name); if (m_appsModel) { QList overlay; overlay.reserve(changes.size()); for (const QVariant& v : changes) { const QVariantMap c = v.toMap(); AppsModel::ResolverRow rr; rr.name = c.value("name").toString(); rr.repositoryUrl = c.value("repositoryUrl").toString(); rr.action = c.value("action").toString(); rr.toVersion = c.value("toVersion").toString(); rr.isTopLevel = c.value("isTopLevel").toBool(); rr.resolverError = c.value("error").toString(); overlay.append(rr); if (!rr.name.isEmpty() && !seen.contains(rr.name)) { seen.insert(rr.name); requiredEntries.append( nameAndRepo(rr.name, c.value("repositoryUrl").toString())); } } m_appsModel->setResolverOverlay(overlay); } // Union in the catalog-derived dependency set. for (const QVariant& v : collectCatalogRequired(name, repositoryUrl)) { const QString depName = v.toMap().value("name").toString(); if (depName.isEmpty() || seen.contains(depName)) continue; seen.insert(depName); requiredEntries.append(v); } emit requiredPackagesResolved(requiredEntries); if (requestOpen) emit requestOpenAddApplicationDialog(metadata); else emit addApplicationDataUpdated(metadata); } void PackageCoordinator::refreshOverlayAfterInstall(const QString& topLevelName) { if (!m_appsModel || topLevelName.isEmpty()) return; const QVariantList raw = m_lastResolvedRawByName.value(topLevelName); if (raw.isEmpty()) return; const QVariantList changes = computeDepChanges(raw, m_installedVersionByName); if (!changes.isEmpty()) m_lastResolvedChangesByName.insert(topLevelName, changes); // Only push UI updates while this app's dialog is still the active session. if (topLevelName != m_activeAddDialogName) return; const QString repositoryUrl = m_repoByName.value(topLevelName); const QVariantMap catalogRow = m_appsModel->rowDataByName(topLevelName, repositoryUrl); emitDialogMetadata(topLevelName, repositoryUrl, QString(), catalogRow, changes, /*requestOpen=*/false); } void PackageCoordinator::confirmCatalogInstall(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins) { if (!m_logosAPI || name.isEmpty()) return; if (m_installRegistry->has(name)) { qDebug() << "confirmCatalogInstall: session for" << name << "already in progress, ignoring"; return; } m_installRegistry->begin(name, /*targetVersion=*/{}, /*targetHash=*/{}, /*startedByTopLevel=*/name); emit catalogInstallStageChanged(name, InstallStage::Downloading); const QString depsJson = buildResolverDepsJson(name, repositoryUrl, versionPins); LogosModules logos(m_logosAPI); QPointer self(this); // Default IPC deadline (20s) is too tight when the catalog blob is many // MB or the user is on a slow connection constexpr int kDownloadIpcDeadlineMs = 5 * 60 * 1000; logos.package_downloader.downloadResolvedDependenciesAsync(depsJson, QString(), [self, name](QVariantList results) { if (!self) return; if (!results.isEmpty()) self->m_lastResolvedRawByName.insert(name, results); QVariantList toInstall; for (const QVariant& v : results) { const QVariantMap m = v.toMap(); const QString rowName = m.value("name").toString(); if (!m.value("error").toString().isEmpty()) { toInstall.append(v); continue; } const QString resolvedVersion = m.value("version").toString(); const QString resolvedHash = m.value("rootHash").toString(); const QString installedVersion = self->m_installedVersionByName.value(rowName); const QString installedHash = self->m_installedHashByName.value(rowName); const bool versionMatches = !installedVersion.isEmpty() && (resolvedVersion.isEmpty() || resolvedVersion == installedVersion); const bool hashMatches = resolvedHash.isEmpty() || installedHash.isEmpty() || resolvedHash == installedHash; if (versionMatches && hashMatches) { self->m_installRegistry->beginOrTrack(rowName, resolvedVersion, resolvedHash, name); self->m_installRegistry->setStage(rowName, InstallStage::Installed); continue; } toInstall.append(v); } if (toInstall.isEmpty()) { // Nothing left to do after the skip-already-installed // filter; treat as a successful no-op rather than Failed. self->setOpStage(name, InstallStage::Installed); emit self->catalogInstallFinished(name); self->refreshOverlayAfterInstall(name); QTimer::singleShot(1500, self.data(), [self, name]() { if (!self) return; self->m_installRegistry->clearByTopLevel(name); }); return; } for (const QVariant& v : toInstall) { const QVariantMap m = v.toMap(); const QString rowName = m.value("name").toString(); if (rowName.isEmpty()) continue; self->m_installRegistry->beginOrTrack(rowName, m.value("version").toString(), m.value("rootHash").toString(), name); self->m_installRegistry->setStage(rowName, InstallStage::Queued); } self->setOpStage(name, InstallStage::Installing); self->installResultsSequential(toInstall, name, 0); }, Timeout(kDownloadIpcDeadlineMs)); } void PackageCoordinator::installOnePackage(const QVariantMap& dl, std::function onDone) { const QString packageName = dl.value("name").toString(); const QString filePath = dl.value("path").toString(); const QString downloadError = dl.value("error").toString(); if (filePath.isEmpty()) { if (onDone) onDone(false, downloadError.isEmpty() ? QStringLiteral("Download failed") : downloadError); return; } if (!m_logosAPI) { if (onDone) onDone(false, QStringLiteral("package_manager not connected")); return; } const bool alreadyInstalled = m_installedNameSet.contains(packageName); const bool isEmbedded = m_installTypeByModule.value(packageName) == QLatin1String("embedded"); // Never tear down or remove our own UI — same guard uninstallUiModule // carries, for the same reason: it would brick Basecamp mid-install. // AppsFilterProxy::excludeMainUi only hides it from the list; it is not a // safety gate, and a resolver result can name it as a transitive entry. // Falling through installs over it, which is the old merge behaviour — // strictly better than deleting the running UI. const bool isSelf = (packageName == QStringLiteral("main_ui")); if (isSelf && alreadyInstalled) { qWarning() << "Refusing to remove main_ui before install; " "installing over it instead"; } if (alreadyInstalled && !isEmbedded && !isSelf) { cascadeUnloadForPackage(packageName); LogosModules logos(m_logosAPI); QPointer self(this); logos.package_manager.uninstallPackageAsync(packageName, [self, dl, packageName, onDone](QVariantMap uninstallResult) { if (!self) return; if (!uninstallResult.value("success", false).toBool()) { const QString err = uninstallResult.value("error").toString(); qWarning() << "Pre-install removal of" << packageName << "failed, aborting install:" << err; if (onDone) onDone(false, err.isEmpty() ? QStringLiteral("Could not remove the installed version") : err); return; } self->installDownloadedFile(dl, onDone); }); return; } installDownloadedFile(dl, onDone); } void PackageCoordinator::installDownloadedFile(const QVariantMap& dl, std::function onDone) { const QString packageName = dl.value("name").toString(); const QString filePath = dl.value("path").toString(); LogosModules logos(m_logosAPI); QPointer self(this); // Installing was left on the default 20 s IPC deadline while DOWNLOADING // got five minutes -- backwards. Downloading is network-bound and can be // retried; installing is disk-bound over a payload that package_manager // reads, gunzips and tar-parses three times and Merkle-hashes twice, then // extracts and copies. It is ~1 s on a warm dev box and unbounded on a slow // disk, a large package, or a machine where the antivirus scans every DLL // as it lands. Blowing the deadline does not cancel any of that work: the // files still install and the reply is simply abandoned, so the user is // told the install failed when it did not. Match the download budget. constexpr int kInstallIpcDeadlineMs = 5 * 60 * 1000; // `installPluginAsyncResult`, not `installPluginAsync`: the plain async // wrapper hands the callback a bare QVariantMap, so a transport failure is // indistinguishable from a provider that legitimately returned an empty // one. AsyncResult carries the value and the error together, which is // the whole reason it exists. logos.package_manager.installPluginAsyncResult(filePath, false, [self, packageName, onDone](logos::AsyncResult r) { if (!self) return; // Transport-level failure FIRST -- a timeout leaves `value` // default-constructed, and reading it as an install verdict is // exactly the mistake this channel removes. The message names the // module and the deadline (logos::callErrorTimeout builds it), and // says the package may in fact be installed: blowing the deadline // cancels nothing, so the files may well be on disk. if (!r.ok()) { const QString detail = QString::fromStdString(r.error.message); if (onDone) onDone(false, QStringLiteral("%1 — the package may in fact be installed; check before retrying") .arg(detail.isEmpty() ? QStringLiteral("package_manager did not reply") : detail)); return; } const bool success = installPluginSucceeded(r.value); const QString err = r.value.value("error").toString(); if (onDone) onDone(success, success ? QString() : err); }, Timeout(kInstallIpcDeadlineMs)); } void PackageCoordinator::installResultsSequential(const QVariantList& results, const QString& topLevelName, int index, QStringList failures) { if (index >= results.size()) return; const QVariantMap dl = results[index].toMap(); const QString rowName = dl.value("name").toString(); qDebug() << "installResultsSequential index=" << index << "of" << results.size() << "rowName=" << rowName << "topLevel=" << topLevelName; if (!rowName.isEmpty()) { m_installRegistry->beginOrTrack(rowName, dl.value("version").toString(), dl.value("rootHash").toString(), topLevelName); m_installRegistry->setStage(rowName, InstallStage::Installing); } QPointer self(this); installOnePackage(dl, [self, results, topLevelName, rowName, index, failures, dl] (bool success, const QString& err) mutable { qDebug() << "installOnePackage callback rowName=" << rowName << "success=" << success << "err=" << err; if (!self) return; if (!rowName.isEmpty()) { if (success) { const QString ver = dl.value("version").toString(); const QString hash = dl.value("rootHash").toString(); if (!ver.isEmpty()) self->m_installedVersionByName.insert(rowName, ver); if (!hash.isEmpty()) self->m_installedHashByName.insert(rowName, hash); if (self->m_appsModel) self->m_appsModel->markInstalled(rowName, ver, hash); self->m_installRegistry->finish(rowName); } else { self->m_installRegistry->fail(rowName, err); } } if (!success) { failures.append(rowName.isEmpty() ? err : (rowName + ": " + err)); } // Stop on the first failure rather than growing a half-installed // set; report it now. (Rollback of installed packages is follow-up.) const bool isLast = (index + 1) >= results.size(); if (!isLast && success) { self->installResultsSequential( results, topLevelName, index + 1, failures); return; } if (!failures.isEmpty()) { qDebug() << " install loop complete with failures for" << topLevelName << ":" << failures.size(); self->setOpStage(topLevelName, InstallStage::Failed); emit self->catalogInstallFailed( topLevelName, failures.join(QStringLiteral("; "))); QTimer::singleShot(2500, self.data(), [self, topLevelName]() { if (!self) return; self->m_installRegistry->clearByTopLevel(topLevelName); }); return; } self->setOpStage(topLevelName, InstallStage::Installed); emit self->catalogInstallFinished(topLevelName); self->refreshOverlayAfterInstall(topLevelName); QTimer::singleShot(1500, self.data(), [self, topLevelName]() { if (!self) return; self->m_installRegistry->clearByTopLevel(topLevelName); }); }); }