From cb02b9c3337efe8a42defbab36c2df56c7efd4e9 Mon Sep 17 00:00:00 2001 From: Khushboo Mehta Date: Tue, 23 Jun 2026 16:29:46 +0200 Subject: [PATCH] fix: add application modal state is wrong when clicked in between an ongoing download --- src/AppsFilterProxy.cpp | 11 + src/AppsFilterProxy.h | 2 + src/AppsModel.cpp | 84 +++++-- src/AppsModel.h | 21 +- .../AppManager/AddApplicationDialog.qml | 9 + .../AppManager/PackageRowDelegate.qml | 8 +- src/Basecamp/Shell/OverlayDialogs.qml | 17 +- src/CMakeLists.txt | 1 + src/InstallRegistry.cpp | 125 ++++++++++ src/InstallRegistry.h | 56 +++++ src/MainUIBackend.cpp | 9 +- src/MainUIBackend.h | 4 +- src/PackageCoordinator.cpp | 223 ++++++++++++------ src/PackageCoordinator.h | 42 +++- 14 files changed, 488 insertions(+), 124 deletions(-) create mode 100644 src/InstallRegistry.cpp create mode 100644 src/InstallRegistry.h diff --git a/src/AppsFilterProxy.cpp b/src/AppsFilterProxy.cpp index 8c170b5..f23d295 100644 --- a/src/AppsFilterProxy.cpp +++ b/src/AppsFilterProxy.cpp @@ -99,6 +99,17 @@ int AppsFilterProxy::alreadyInstalledCount() const return c; } +int AppsFilterProxy::installingCount() const +{ + int c = 0; + const int n = rowCount(); + for (int i = 0; i < n; ++i) { + if (data(index(i, 0), AppsModel::ActionRole).toString() + == QStringLiteral("installing")) ++c; + } + return c; +} + int AppsFilterProxy::errorCount() const { int c = 0; diff --git a/src/AppsFilterProxy.h b/src/AppsFilterProxy.h index c2b8204..404d102 100644 --- a/src/AppsFilterProxy.h +++ b/src/AppsFilterProxy.h @@ -23,6 +23,7 @@ class AppsFilterProxy : public QSortFilterProxyModel { Q_PROPERTY(int upgradeCount READ upgradeCount NOTIFY breakdownChanged) Q_PROPERTY(int reinstallCount READ reinstallCount NOTIFY breakdownChanged) Q_PROPERTY(int alreadyInstalledCount READ alreadyInstalledCount NOTIFY breakdownChanged) + Q_PROPERTY(int installingCount READ installingCount NOTIFY breakdownChanged) Q_PROPERTY(int errorCount READ errorCount NOTIFY breakdownChanged) Q_PROPERTY(bool hasResolutionErrors READ hasResolutionErrors NOTIFY hasResolutionErrorsChanged) Q_PROPERTY(int visibleCount READ visibleCount NOTIFY visibleCountChanged) @@ -52,6 +53,7 @@ public: int upgradeCount() const; int reinstallCount() const; int alreadyInstalledCount() const; + int installingCount() const; int errorCount() const; bool hasResolutionErrors() const; int visibleCount() const { return rowCount(); } diff --git a/src/AppsModel.cpp b/src/AppsModel.cpp index be2c2da..c0694c7 100644 --- a/src/AppsModel.cpp +++ b/src/AppsModel.cpp @@ -1,5 +1,7 @@ #include "AppsModel.h" +#include "InstallRegistry.h" + #include namespace { @@ -46,12 +48,23 @@ QVariant AppsModel::data(const QModelIndex& index, int role) const case MissingDepsRole: return r.missingDeps; case InstallStatusRole: return static_cast(r.installStatus); case InstallTypeRole: return r.installType; - case ActionRole: return r.action; + case ActionRole: { + if (m_installRegistry) { + if (m_installRegistry->isInFlight(r.name)) + return QStringLiteral("installing"); + if (m_installRegistry->stage(r.name) == InstallStage::Installed) + return QStringLiteral("installed"); + } + return r.action; + } case ToVersionRole: return r.toVersion; case IsTopLevelRole: return r.isTopLevel; case ResolverErrorRole: return r.resolverError; - case InstallStageRole: return static_cast(r.installStage); - case InstallErrorRole: return r.installError; + case InstallStageRole: + return m_installRegistry ? m_installRegistry->stage(r.name) + : static_cast(InstallStage::None); + case InstallErrorRole: + return m_installRegistry ? m_installRegistry->error(r.name) : QString(); } return {}; } @@ -302,6 +315,30 @@ void AppsModel::markInstalled(const QString& name, } } +void AppsModel::replaceInstalledSet(const QHash& versionByName, + const QHash& hashByName) +{ + const bool wasBulk = m_inBulkInstalledUpdate; + if (!wasBulk) beginBulkInstalledUpdate(); + + for (int idx = 0; idx < m_rows.size(); ++idx) { + Row& r = m_rows[idx]; + const auto it = versionByName.find(r.name); + const bool isInstalled = (it != versionByName.end()); + const QString newVersion = isInstalled ? it.value() : QString(); + const QString newHash = isInstalled ? hashByName.value(r.name) : QString(); + if (r.installedVersion == newVersion && r.installedHash == newHash) continue; + r.installedVersion = newVersion; + r.installedHash = newHash; + recomputeInstallStatus(r); + const QModelIndex mi = index(idx); + emit dataChanged(mi, mi, {InstalledVersionRole, HasUpdateRole, + IsInstalledRole, InstallStatusRole}); + } + + if (!wasBulk) endBulkInstalledUpdate(); +} + void AppsModel::beginBulkInstalledUpdate() { m_inBulkInstalledUpdate = true; @@ -357,20 +394,26 @@ void AppsModel::setMissingDeps(const QString& name, const QStringList& missing) } } -// ── Mutation: live install stage ────────────────────────────────────────── +// ── Wiring: live install state ──────────────────────────────────────────── -void AppsModel::setInstallStage(const QString& name, - InstallStage::Value stage, - const QString& error) +void AppsModel::setInstallRegistry(InstallRegistry* installRegistry) { - for (int idx : m_indicesByName.values(name)) { - Row& r = m_rows[idx]; - if (r.installStage == stage && r.installError == error) continue; - r.installStage = stage; - r.installError = stage == InstallStage::Failed ? error : QString(); - const QModelIndex mi = index(idx); - emit dataChanged(mi, mi, {InstallStageRole, InstallErrorRole}); - } + if (m_installRegistry == installRegistry) return; + if (m_installRegistry) m_installRegistry->disconnect(this); + m_installRegistry = installRegistry; + if (!m_installRegistry) return; + + auto refresh = [this](const QString& name) { + const QList roles{InstallStageRole, InstallErrorRole, ActionRole}; + for (int idx : m_indicesByName.values(name)) { + const QModelIndex mi = index(idx); + emit dataChanged(mi, mi, roles); + } + }; + connect(m_installRegistry, &InstallRegistry::stageChanged, this, + [refresh](const QString& name, InstallStage::Value) { refresh(name); }); + connect(m_installRegistry, &InstallRegistry::errorChanged, this, + [refresh](const QString& name, const QString&) { refresh(name); }); } // ── Mutation: resolver overlay ───────────────────────────────────────────── @@ -386,16 +429,9 @@ void AppsModel::setResolverOverlay(const QList& rows) r.toVersion = src.toVersion; r.isTopLevel = src.isTopLevel; r.resolverError = src.resolverError; - // Wipe any sticky pipeline state from a previous session - QList changedRoles{ActionRole, ToVersionRole, IsTopLevelRole, ResolverErrorRole}; - if (r.installStage != InstallStage::None - || !r.installError.isEmpty()) { - r.installStage = InstallStage::None; - r.installError.clear(); - changedRoles << InstallStageRole << InstallErrorRole; - } const QModelIndex mi = index(idx); - emit dataChanged(mi, mi, changedRoles); + emit dataChanged(mi, mi, + {ActionRole, ToVersionRole, IsTopLevelRole, ResolverErrorRole}); } } diff --git a/src/AppsModel.h b/src/AppsModel.h index c12a93b..f11ee7e 100644 --- a/src/AppsModel.h +++ b/src/AppsModel.h @@ -4,10 +4,13 @@ #include #include +#include #include #include #include +class InstallRegistry; + // AppsModel — the single source of truth for every package the App Manager // (and Modules tab) cares about. class AppsModel : public QAbstractListModel { @@ -65,12 +68,13 @@ public: void markInstalled(const QString& name, const QString& installedVersion, const QString& installedHash = {}); + void replaceInstalledSet(const QHash& versionByName, + const QHash& hashByName); + void setInstallType(const QString& name, const QString& installType); void setIconUrl(const QString& name, const QString& iconUrl); void setMissingDeps(const QString& name, const QStringList& missing); - void setInstallStage(const QString& name, - InstallStage::Value stage, - const QString& error = {}); + void setInstallRegistry(InstallRegistry* installRegistry); void beginBulkInstalledUpdate(); void endBulkInstalledUpdate(); @@ -116,11 +120,9 @@ private: QStringList missingDeps; InstallStatus::Value installStatus = InstallStatus::NotInstalled; - // Live install pipeline state - InstallStage::Value installStage = InstallStage::None; - QString installError; - - // Resolver overlay (per dialog session) + // Resolver overlay (per dialog session). Live install state lives + // on m_installRegistry — see setInstallRegistry. Per-row InstallStageRole / + // InstallErrorRole / ActionRole derive from there at read time. QString action; QString toVersion; bool isTopLevel = false; @@ -136,4 +138,7 @@ private: QHash m_indexByKey; // (repo + "\n" + name) → row index QMultiHash m_indicesByName; bool m_inBulkInstalledUpdate = false; + + // Source of truth for in-flight install state + QPointer m_installRegistry; }; diff --git a/src/Basecamp/AppManager/AddApplicationDialog.qml b/src/Basecamp/AppManager/AddApplicationDialog.qml index e76d626..b79c1c6 100644 --- a/src/Basecamp/AppManager/AddApplicationDialog.qml +++ b/src/Basecamp/AppManager/AddApplicationDialog.qml @@ -112,6 +112,7 @@ Dialog { readonly property bool actionEnabled: d.targetName.length > 0 && !d.installing + && d.installingBuckets === 0 && (d.actionMode === "launch" || !d.hasResolutionErrors) readonly property int totalDeps: @@ -131,6 +132,8 @@ Dialog { root.requiredPackagesModel ? root.requiredPackagesModel.reinstallCount : 0 readonly property int alreadyInstalledBuckets: root.requiredPackagesModel ? root.requiredPackagesModel.alreadyInstalledCount : 0 + readonly property int installingBuckets: + root.requiredPackagesModel ? root.requiredPackagesModel.installingCount : 0 readonly property int errorBuckets: root.requiredPackagesModel ? root.requiredPackagesModel.errorCount : 0 readonly property int changingBuckets: @@ -144,6 +147,12 @@ Dialog { return d.stageLabel } + if (d.installingBuckets > 0 && d.changingBuckets === 0) { + return qsTr("%1 of %2's required package(s) are currently being installed. " + + "Wait for the active install to finish before launching %2.") + .arg(d.installingBuckets).arg(d.targetDisplayName) + } + // Top-level + deps all match disk → nothing to do. The action // button reads "Launch"; hide the footer entirely. if (d.changingBuckets === 0) return "" diff --git a/src/Basecamp/AppManager/PackageRowDelegate.qml b/src/Basecamp/AppManager/PackageRowDelegate.qml index c6a48b4..05fde3b 100644 --- a/src/Basecamp/AppManager/PackageRowDelegate.qml +++ b/src/Basecamp/AppManager/PackageRowDelegate.qml @@ -24,6 +24,8 @@ ItemDelegate { readonly property string action: root.appRow ? (root.appRow.action || "") : "" readonly property string toVersion: root.appRow ? (root.appRow.toVersion || "") : "" readonly property bool isError: d.action === "error" + readonly property bool isInstalled: + root.appRow ? (root.appRow.isInstalled === true) : false readonly property int rowStage: root.appRow && root.appRow.installStage !== undefined ? root.appRow.installStage @@ -128,8 +130,9 @@ ItemDelegate { case "upgrade": return qsTr("Upgrade") case "downgrade": return qsTr("Downgrade") case "reinstall": return qsTr("Reinstall") - default: return qsTr("Installed") + case "installed": return qsTr("Installed") } + return d.isInstalled ? qsTr("Installed") : qsTr("Install") } color: { if (d.isError) return Theme.palette.error @@ -145,8 +148,9 @@ ItemDelegate { case "upgrade": return Theme.palette.info case "downgrade": return Theme.palette.info case "reinstall": return Theme.palette.info - default: return Theme.palette.textTertiary + case "installed": return Theme.palette.textTertiary } + return d.isInstalled ? Theme.palette.textTertiary : Theme.palette.primary } radius: Theme.spacing.radiusLarge diff --git a/src/Basecamp/Shell/OverlayDialogs.qml b/src/Basecamp/Shell/OverlayDialogs.qml index f75ae05..529d42d 100644 --- a/src/Basecamp/Shell/OverlayDialogs.qml +++ b/src/Basecamp/Shell/OverlayDialogs.qml @@ -95,6 +95,7 @@ Item { AddApplicationDialog { id: addApplicationDialog requiredPackagesModel: backend.requiredPackagesModel + onClosed: backend.notifyAddApplicationDialogClosed() onInstallRequested: function(name, repositoryUrl, versionPins) { addApplicationDialog.installStage = InstallStage.Downloading backend.confirmCatalogInstall(name, repositoryUrl, versionPins) @@ -195,15 +196,23 @@ Item { backend.onAppLauncherClicked(name); } - function onAddApplicationRequested(metadata, requiredPackages) { - if (addApplicationDialog.visible) { + function onRequestOpenAddApplicationDialog(metadata) { + if (!addApplicationDialog.visible) { + addApplicationDialog.openWith(metadata); + } else if (addApplicationDialog.metadata.name === metadata.name) { + // Version re-resolve while the same app's dialog is already open. addApplicationDialog.metadata = metadata; addApplicationDialog.installStage = metadata.installStage || InstallStage.None; - } else { - addApplicationDialog.openWith(metadata); } } + function onAddApplicationDataUpdated(metadata) { + if (!addApplicationDialog.visible) return; + if (addApplicationDialog.metadata.name !== metadata.name) return; + addApplicationDialog.metadata = metadata; + addApplicationDialog.installStage = metadata.installStage || InstallStage.None; + } + function onCatalogInstallStageChanged(name, stage) { if (!addApplicationDialog.visible) return; if (addApplicationDialog.metadata.name !== name) return; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5e37d8b..ccbba97 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -118,6 +118,7 @@ set(SOURCES CoreModuleManager.cpp UIPluginManager.cpp PackageCoordinator.cpp + InstallRegistry.cpp AppsModel.cpp PluginLoader.cpp restricted/DenyAllReply.cpp diff --git a/src/InstallRegistry.cpp b/src/InstallRegistry.cpp new file mode 100644 index 0000000..15ab3c4 --- /dev/null +++ b/src/InstallRegistry.cpp @@ -0,0 +1,125 @@ +#include "InstallRegistry.h" + +InstallRegistry::InstallRegistry(QObject* parent) : QObject(parent) {} + +int InstallRegistry::stage(const QString& name) const +{ + const auto it = m_ops.constFind(name); + return it == m_ops.cend() ? InstallStage::None + : static_cast(it->stage); +} + +bool InstallRegistry::isInFlight(const QString& name) const +{ + const auto it = m_ops.constFind(name); + if (it == m_ops.cend()) return false; + switch (it->stage) { + case InstallStage::Downloading: + case InstallStage::Queued: + case InstallStage::Installing: + return true; + default: + return false; + } +} + +QString InstallRegistry::error(const QString& name) const +{ + const auto it = m_ops.constFind(name); + return it == m_ops.cend() ? QString() : it->error; +} + +QString InstallRegistry::targetVersion(const QString& name) const +{ + const auto it = m_ops.constFind(name); + return it == m_ops.cend() ? QString() : it->targetVersion; +} + +QString InstallRegistry::targetHash(const QString& name) const +{ + const auto it = m_ops.constFind(name); + return it == m_ops.cend() ? QString() : it->targetHash; +} + +void InstallRegistry::begin(const QString& name, + const QString& targetVersion, + const QString& targetHash, + const QString& startedByTopLevel) +{ + if (name.isEmpty()) return; + const bool added = !m_ops.contains(name); + Entry& e = m_ops[name]; + e.name = name; + e.targetVersion = targetVersion; + e.targetHash = targetHash; + e.stage = InstallStage::Downloading; + e.error.clear(); + e.startedByTopLevel = startedByTopLevel; + emit stageChanged(name, e.stage); + if (added) emit activeNamesChanged(); +} + +void InstallRegistry::setStage(const QString& name, InstallStage::Value stage) +{ + auto it = m_ops.find(name); + if (it == m_ops.end()) return; + if (it->stage == stage) return; + it->stage = stage; + emit stageChanged(name, stage); +} + +void InstallRegistry::fail(const QString& name, const QString& error) +{ + auto it = m_ops.find(name); + if (it == m_ops.end()) return; + const bool stageChanged_ = it->stage != InstallStage::Failed; + it->stage = InstallStage::Failed; + it->error = error; + if (stageChanged_) emit stageChanged(name, InstallStage::Failed); + emit errorChanged(name, error); +} + +void InstallRegistry::finish(const QString& name) +{ + setStage(name, InstallStage::Installed); +} + +void InstallRegistry::clear(const QString& name) +{ + if (!m_ops.contains(name)) return; + m_ops.remove(name); + emit stageChanged(name, InstallStage::None); + emit errorChanged(name, QString()); + emit activeNamesChanged(); +} + +void InstallRegistry::clearByTopLevel(const QString& topLevelName) +{ + if (topLevelName.isEmpty()) return; + QStringList toRemove; + for (auto it = m_ops.cbegin(); it != m_ops.cend(); ++it) { + if (it->startedByTopLevel == topLevelName) toRemove.append(it.key()); + } + if (toRemove.isEmpty()) return; + for (const QString& name : toRemove) m_ops.remove(name); + for (const QString& name : toRemove) { + emit stageChanged(name, InstallStage::None); + emit errorChanged(name, QString()); + } + emit activeNamesChanged(); +} + +void InstallRegistry::beginOrTrack(const QString& name, + const QString& targetVersion, + const QString& targetHash, + const QString& startedByTopLevel) +{ + if (name.isEmpty()) return; + if (!m_ops.contains(name)) { + begin(name, targetVersion, targetHash, startedByTopLevel); + return; + } + Entry& e = m_ops[name]; + if (!targetVersion.isEmpty()) e.targetVersion = targetVersion; + if (!targetHash.isEmpty()) e.targetHash = targetHash; +} diff --git a/src/InstallRegistry.h b/src/InstallRegistry.h new file mode 100644 index 0000000..0ec2fc2 --- /dev/null +++ b/src/InstallRegistry.h @@ -0,0 +1,56 @@ +#pragma once + +#include "InstallEnums.h" + +#include +#include +#include +#include + +// Registry of in-flight install operations. One entry per package name +class InstallRegistry : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList activeNames READ activeNames NOTIFY activeNamesChanged) + +public: + struct Entry { + QString name; + QString targetVersion; + QString targetHash; + InstallStage::Value stage = InstallStage::None; + QString error; + QString startedByTopLevel; + }; + + explicit InstallRegistry(QObject* parent = nullptr); + + Q_INVOKABLE bool has(const QString& name) const { return m_ops.contains(name); } + Q_INVOKABLE int stage(const QString& name) const; + Q_INVOKABLE bool isInFlight(const QString& name) const; + QString error(const QString& name) const; + QString targetVersion(const QString& name) const; + QString targetHash(const QString& name) const; + QStringList activeNames() const { return m_ops.keys(); } + + void beginOrTrack(const QString& name, + const QString& targetVersion, + const QString& targetHash, + const QString& startedByTopLevel); + void begin(const QString& name, + const QString& targetVersion, + const QString& targetHash, + const QString& startedByTopLevel); + void setStage(const QString& name, InstallStage::Value stage); + void fail(const QString& name, const QString& error); + void finish(const QString& name); + void clear(const QString& name); + void clearByTopLevel(const QString& topLevelName); + +signals: + void activeNamesChanged(); + void stageChanged(const QString& name, InstallStage::Value stage); + void errorChanged(const QString& name, const QString& error); + +private: + QHash m_ops; +}; diff --git a/src/MainUIBackend.cpp b/src/MainUIBackend.cpp index aa20fb7..5529615 100644 --- a/src/MainUIBackend.cpp +++ b/src/MainUIBackend.cpp @@ -47,6 +47,7 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, QObject* parent) m_uiPluginManager = new UIPluginManager(m_logosAPI, m_coreModuleManager, this); m_packageCoordinator = new PackageCoordinator(m_logosAPI, m_coreModuleManager, m_uiPluginManager, m_appsModel, this); m_packageCoordinator->setRequiredPackagesModel(m_requiredPackagesModel); + m_appsModel->setInstallRegistry(m_packageCoordinator->installRegistry()); // Setter-injection closes the cycle — UIPluginManager queries // PackageCoordinator for installType / missing-deps when building its @@ -99,8 +100,10 @@ MainUIBackend::MainUIBackend(LogosAPI* logosAPI, QObject* parent) this, &MainUIBackend::upgradeCascadeConfirmationRequested); connect(m_packageCoordinator, &PackageCoordinator::uninstallMultiCascadeConfirmationRequested, this, &MainUIBackend::uninstallMultiCascadeConfirmationRequested); - connect(m_packageCoordinator, &PackageCoordinator::addApplicationRequested, - this, &MainUIBackend::addApplicationRequested); + connect(m_packageCoordinator, &PackageCoordinator::requestOpenAddApplicationDialog, + this, &MainUIBackend::requestOpenAddApplicationDialog); + connect(m_packageCoordinator, &PackageCoordinator::addApplicationDataUpdated, + this, &MainUIBackend::addApplicationDataUpdated); connect(m_packageCoordinator, &PackageCoordinator::launchAppRequested, this, &MainUIBackend::launchAppRequested); connect(m_packageCoordinator, &PackageCoordinator::catalogInstallStageChanged, @@ -241,6 +244,8 @@ void MainUIBackend::openApp(const QString& name, const QString& repositoryUrl, c { m_packageCoordinator->openApp(name, repositoryUrl, versionPins, allowFastLaunch); } void MainUIBackend::confirmCatalogInstall(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins) { m_packageCoordinator->confirmCatalogInstall(name, repositoryUrl, versionPins); } +void MainUIBackend::notifyAddApplicationDialogClosed() +{ m_packageCoordinator->notifyAddApplicationDialogClosed(); } // cancelPendingAction is the one slot that doesn't route to a single manager: // a pending action lives on either UIPluginManager (local unload cascade) or diff --git a/src/MainUIBackend.h b/src/MainUIBackend.h index 0179bfe..ef84f2c 100644 --- a/src/MainUIBackend.h +++ b/src/MainUIBackend.h @@ -175,6 +175,7 @@ public slots: Q_INVOKABLE void confirmCatalogInstall(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins = QVariantMap()); + Q_INVOKABLE void notifyAddApplicationDialogClosed(); // Core Module operations — routing rule: cascade-aware (load/unload) // goes through UIPluginManager so it can run the pre-flight dependent @@ -209,7 +210,8 @@ signals: // App-Manager dialog + install lifecycle. See PackageCoordinator for // the contract — these are pure re-emits. - void addApplicationRequested(const QVariantMap& metadata); + void requestOpenAddApplicationDialog(const QVariantMap& metadata); + void addApplicationDataUpdated(const QVariantMap& metadata); void launchAppRequested(const QString& name); void catalogInstallStageChanged(const QString& name, InstallStage::Value stage); void catalogInstallFinished(const QString& name); diff --git a/src/PackageCoordinator.cpp b/src/PackageCoordinator.cpp index 1405760..148fc8c 100644 --- a/src/PackageCoordinator.cpp +++ b/src/PackageCoordinator.cpp @@ -1,4 +1,5 @@ #include "PackageCoordinator.h" +#include "InstallRegistry.h" #include "AppsFilterProxy.h" #include "AppsModel.h" #include "CoreModuleManager.h" @@ -31,6 +32,7 @@ PackageCoordinator::PackageCoordinator(LogosAPI* logosAPI, , m_coreModuleManager(coreModuleManager) , m_uiPluginManager(uiPluginManager) , m_appsModel(appsModel) + , m_installRegistry(new InstallRegistry(this)) { subscribeToPackageInstallationEvents(); subscribeToPackageDownloaderEvents(); @@ -995,11 +997,6 @@ void PackageCoordinator::refreshDependencyInfo() [self](QVariantList packages) { if (!self) return; self->m_installedPackagesCache = packages; - - // Snapshot the previous installed set BEFORE the wholesale assignments - // below overwrite it. - const QSet previouslyInstalled = self->m_installedNameSet; - QMap typeMap; QSet nameSet; QHash versionByName; @@ -1026,35 +1023,14 @@ void PackageCoordinator::refreshDependencyInfo() if (!version.isEmpty()) versionByName.insert(lookupName, version); if (!rootHash.isEmpty()) hashByName.insert(lookupName, rootHash); } - self->m_installTypeByModule = typeMap; + self->m_installTypeByModule = std::move(typeMap); self->m_displayNameByModule = std::move(displayNameMap); self->m_installedNameSet = std::move(nameSet); self->m_installedVersionByName = std::move(versionByName); - for (auto it = hashByName.cbegin(); it != hashByName.cend(); ++it) { - self->m_installedHashByName.insert(it.key(), it.value()); - } - for (const QString& name : previouslyInstalled) { - if (!self->m_installedNameSet.contains(name)) { - self->m_installedHashByName.remove(name); - } - } - - // Replay markInstalled across the full package set — fetchUiPluginMetadata - // only saw UI plugins. Idempotent on (version, hash). + self->m_installedHashByName = std::move(hashByName); if (self->m_appsModel) { - self->m_appsModel->beginBulkInstalledUpdate(); - for (const QString& name : previouslyInstalled) { - if (!self->m_installedNameSet.contains(name)) { - self->m_appsModel->markInstalled(name, QString(), QString()); - } - } - for (auto it = self->m_installedVersionByName.cbegin(); - it != self->m_installedVersionByName.cend(); ++it) { - self->m_appsModel->markInstalled( - it.key(), it.value(), - self->m_installedHashByName.value(it.key())); - } - self->m_appsModel->endBulkInstalledUpdate(); + self->m_appsModel->replaceInstalledSet( + self->m_installedVersionByName, self->m_installedHashByName); } // Second pass — per-module missing/dependents queries. Dispatched @@ -1213,6 +1189,34 @@ QVariantMap nameAndRepo(const QString& name, const QString& 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, @@ -1280,12 +1284,11 @@ QVariantList PackageCoordinator::computeDepChanges( return out; } -void PackageCoordinator::setSessionStage(const QString& name, InstallStage::Value stage) +void PackageCoordinator::setOpStage(const QString& name, InstallStage::Value stage) { - auto it = m_installSessions.find(name); - if (it == m_installSessions.end()) return; - if (it->stage == stage) return; - it->stage = 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); } @@ -1317,6 +1320,13 @@ void PackageCoordinator::openApp(const QString& name, 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) @@ -1327,6 +1337,7 @@ void PackageCoordinator::runResolverAndOpenDialog(const QString& name, const QString targetVersion = versionPins.value(name).toString(); const int epoch = ++m_dialogResolveEpoch[name]; + m_activeAddDialogName = name; const QString depsJson = buildResolverDepsJson(name, repositoryUrl, versionPins); @@ -1334,7 +1345,12 @@ void PackageCoordinator::runResolverAndOpenDialog(const QString& name, << "repo=" << repositoryUrl << "targetVersion=" << targetVersion << "pins=" << versionPins.size() << "epoch=" << epoch; - emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, /*changes=*/{}); + 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); @@ -1349,7 +1365,13 @@ void PackageCoordinator::runResolverAndOpenDialog(const QString& name, } const QVariantList changes = self->computeDepChanges(resolved, self->m_installedVersionByName); - self->emitDialogMetadata(name, repositoryUrl, targetVersion, catalogRow, changes); + 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); }); } @@ -1357,8 +1379,12 @@ void PackageCoordinator::emitDialogMetadata(const QString& name, const QString& repositoryUrl, const QString& targetVersion, const QVariantMap& catalogRow, - const QVariantList& changes) + const QVariantList& changes, + bool requestOpen) { + if (name != m_activeAddDialogName) + return; + QVariantMap metadata; metadata["name"] = name; metadata["repositoryUrl"] = repositoryUrl; @@ -1388,10 +1414,7 @@ void PackageCoordinator::emitDialogMetadata(const QString& name, ? QString() : versionsList.first().toMap().value("manifest").toMap().value("version").toString(); - metadata["installStage"] = static_cast( - m_installSessions.contains(name) - ? m_installSessions.value(name).stage - : InstallStage::None); + 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 @@ -1424,10 +1447,43 @@ void PackageCoordinator::emitDialogMetadata(const QString& name, 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); + } + if (m_requiredPackagesModel) m_requiredPackagesModel->setRequiredPackages(requiredEntries); - emit addApplicationRequested(metadata); + 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, @@ -1436,26 +1492,28 @@ void PackageCoordinator::confirmCatalogInstall(const QString& name, { if (!m_logosAPI || name.isEmpty()) return; - if (m_installSessions.contains(name)) { + if (m_installRegistry->has(name)) { qDebug() << "confirmCatalogInstall: session for" << name << "already in progress, ignoring"; return; } - InstallSession s; - s.name = name; - s.stage = InstallStage::Downloading; - m_installSessions.insert(name, s); - - if (m_appsModel) m_appsModel->setInstallStage(name, InstallStage::Downloading); + 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, [self, name](QVariantList results) { if (!self) return; + if (!results.isEmpty()) + self->m_lastResolvedRawByName.insert(name, results); QVariantList toInstall; for (const QVariant& v : results) { @@ -1478,9 +1536,9 @@ void PackageCoordinator::confirmCatalogInstall(const QString& name, || installedHash.isEmpty() || resolvedHash == installedHash; if (versionMatches && hashMatches) { - if (self->m_appsModel) - self->m_appsModel->setInstallStage(rowName, - InstallStage::Installed); + self->m_installRegistry->beginOrTrack(rowName, resolvedVersion, + resolvedHash, name); + self->m_installRegistry->setStage(rowName, InstallStage::Installed); continue; } toInstall.append(v); @@ -1489,25 +1547,31 @@ void PackageCoordinator::confirmCatalogInstall(const QString& name, if (toInstall.isEmpty()) { // Nothing left to do after the skip-already-installed // filter; treat as a successful no-op rather than Failed. - self->setSessionStage(name, InstallStage::Installed); + self->setOpStage(name, InstallStage::Installed); emit self->catalogInstallFinished(name); + self->refreshOverlayAfterInstall(name); QTimer::singleShot(1500, self.data(), [self, name]() { if (!self) return; - self->m_installSessions.remove(name); + self->m_installRegistry->clearByTopLevel(name); }); return; } for (const QVariant& v : toInstall) { - const QString rowName = v.toMap().value("name").toString(); - if (!rowName.isEmpty() && self->m_appsModel) - self->m_appsModel->setInstallStage(rowName, - InstallStage::Queued); + 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->setSessionStage(name, InstallStage::Installing); + self->setOpStage(name, InstallStage::Installing); self->installResultsSequential(toInstall, name, 0); - }); + }, + Timeout(kDownloadIpcDeadlineMs)); } void PackageCoordinator::installOnePackage(const QVariantMap& dl, @@ -1547,24 +1611,34 @@ void PackageCoordinator::installResultsSequential(const QVariantList& results, << "of" << results.size() << "rowName=" << rowName << "topLevel=" << topLevelName; - if (!rowName.isEmpty() && m_appsModel) - m_appsModel->setInstallStage(rowName, InstallStage::Installing); + 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] + [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()) { - const InstallStage::Value stage = success - ? InstallStage::Installed - : InstallStage::Failed; - if (self->m_appsModel) - self->m_appsModel->setInstallStage(rowName, stage, - success ? QString() : err); + 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() @@ -1584,21 +1658,22 @@ void PackageCoordinator::installResultsSequential(const QVariantList& results, if (!failures.isEmpty()) { qDebug() << " install loop complete with failures for" << topLevelName << ":" << failures.size(); - self->setSessionStage(topLevelName, InstallStage::Failed); + self->setOpStage(topLevelName, InstallStage::Failed); emit self->catalogInstallFailed( topLevelName, failures.join(QStringLiteral("; "))); QTimer::singleShot(2500, self.data(), [self, topLevelName]() { if (!self) return; - self->m_installSessions.remove(topLevelName); + self->m_installRegistry->clearByTopLevel(topLevelName); }); return; } - self->setSessionStage(topLevelName, InstallStage::Installed); + self->setOpStage(topLevelName, InstallStage::Installed); emit self->catalogInstallFinished(topLevelName); + self->refreshOverlayAfterInstall(topLevelName); QTimer::singleShot(1500, self.data(), [self, topLevelName]() { if (!self) return; - self->m_installSessions.remove(topLevelName); + self->m_installRegistry->clearByTopLevel(topLevelName); }); }); } diff --git a/src/PackageCoordinator.h b/src/PackageCoordinator.h index eaaadb1..a3af998 100644 --- a/src/PackageCoordinator.h +++ b/src/PackageCoordinator.h @@ -12,6 +12,7 @@ class AppsFilterProxy; class AppsModel; +class InstallRegistry; class CoreModuleManager; class UIPluginManager; @@ -133,6 +134,13 @@ public slots: Q_INVOKABLE void removeRepository(const QString& url); Q_INVOKABLE void setRepositoryEnabled(const QString& url, bool enabled); + // Called by QML when the Add Application dialog closes so stale async + // resolver callbacks for previously-opened apps don't mutate the shared + // required-packages model or reopen the dialog. + Q_INVOKABLE void notifyAddApplicationDialogClosed(); + + InstallRegistry* installRegistry() const { return m_installRegistry; } + signals: // Tells MainUIBackend to refresh the uiModules / launcherApps / coreModules // properties — their values compose installType/missing-deps from here with @@ -148,7 +156,12 @@ signals: // changed. void uiPluginsFetched(const QVariantList& uiPlugins); - void addApplicationRequested(const QVariantMap& metadata); + // Sync entry from openApp() only — QML opens the modal if not already + // visible (or refreshes in place when re-resolving the same app). + void requestOpenAddApplicationDialog(const QVariantMap& metadata); + // Passive refresh — never opens the modal; stale callbacks are dropped in + // emitDialogMetadata before this is emitted. + void addApplicationDataUpdated(const QVariantMap& metadata); void catalogInstallStageChanged(const QString& name, InstallStage::Value stage); void catalogInstallFinished(const QString& name); void catalogInstallFailed(const QString& name, const QString& error); @@ -267,6 +280,10 @@ private: QString buildResolverDepsJson(const QString& name, const QString& repositoryUrl, const QVariantMap& versionPins) const; + // Transitive required-package set ({name, repositoryUrl}) computed purely + // from the local catalog dependency graph — no async resolver. + QVariantList collectCatalogRequired(const QString& name, + const QString& repositoryUrl) const; QString buildInstalledPackagesJson() const; QVariantList computeDepChanges(const QVariantList& resolved, const QHash& installedByName) const; @@ -286,7 +303,11 @@ private: const QString& repositoryUrl, const QString& targetVersion, const QVariantMap& catalogRow, - const QVariantList& changes); + const QVariantList& changes, + bool requestOpen); + // Recompute resolver overlay from cached raw resolve + current disk state. + // Keeps dep badges correct after the install registry is cleared. + void refreshOverlayAfterInstall(const QString& topLevelName); void installResultsSequential(const QVariantList& results, const QString& topLevelName, int index, @@ -294,8 +315,9 @@ private: void installOnePackage(const QVariantMap& downloadResult, std::function onDone); - // Move a session's stage and emit catalogInstallStageChanged. - void setSessionStage(const QString& name, InstallStage::Value stage); + // Drive the in-flight registry. setOpStage updates the InstallRegistry entry + // and emits catalogInstallStageChanged. + void setOpStage(const QString& name, InstallStage::Value stage); // Wiring (not owned — see ctor comment). LogosAPI* m_logosAPI; @@ -326,11 +348,13 @@ private: // to feed AppsModel's // DifferentHash detection. QHash m_dialogResolveEpoch; - struct InstallSession { - QString name; - InstallStage::Value stage = InstallStage::None; - }; - QHash m_installSessions; + QString m_activeAddDialogName; + + // Last resolver output per top-level: raw IPC rows and derived changes. + QHash m_lastResolvedRawByName; + QHash m_lastResolvedChangesByName; + + InstallRegistry* m_installRegistry = nullptr; QVariantList m_repositories; int m_repositoriesLoadingCount = 0;