#include "AppsModel.h" #include "InstallRegistry.h" #include #include namespace { constexpr QChar kSep = QLatin1Char('\n'); // The bundler refuses to emit a 0.4.0 package whose icon is not a validated // 256x256 assets/icon.png, so the version is a guarantee that the artwork is // safe to render edge-to-edge. Below 0.4.0 the icon is whatever the author // shipped — typically a 24-28px glyph that must stay inset. // // Mirrors Manifest::requiresIconContract() in logos-package, the authority // for this rule; keep the two in step. bool manifestSupportsFullBleedIconImpl(const QString& manifestVersion) { const QStringList parts = manifestVersion.split(QLatin1Char('.')); if (parts.size() < 2) return false; bool okMajor = false, okMinor = false; const int major = parts.at(0).toInt(&okMajor); const int minor = parts.at(1).toInt(&okMinor); if (!okMajor || !okMinor) return false; if (major > 0) return true; return major == 0 && minor >= 4; } QString catalogIconUrl(const QVariantMap& row) { const QString raw = row.value("icon").toString(); return raw.contains(QLatin1String("://")) ? raw : QString(); } } bool AppsModel::supportsFullBleedIcon(const QString& manifestVersion) { return manifestSupportsFullBleedIconImpl(manifestVersion); } QString AppsModel::key(const QString& repo, const QString& name) { return repo + kSep + name; } AppsModel::AppsModel(QObject* parent) : QAbstractListModel(parent) {} // ── QAbstractListModel ───────────────────────────────────────────────────── int AppsModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : m_rows.size(); } QVariant AppsModel::data(const QModelIndex& index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_rows.size()) return {}; const Row& r = m_rows[index.row()]; switch (role) { case NameRole: return r.name; case RepositoryUrlRole: return r.repositoryUrl; case DisplayNameRole: return r.displayName.isEmpty() ? r.name : r.displayName; case DescriptionRole: return r.description; case CategoryRole: return r.category; case TypeRole: return r.type; case IconUrlRole: return r.iconUrl; case SupportsFullBleedIconRole: return r.supportsFullBleedIcon; case VersionsRole: return r.versions; case DependenciesRole: return r.dependencies; case InstalledVersionRole: return r.installedVersion; case LatestVersionRole: return r.latestVersion; case HasUpdateRole: return !r.installedVersion.isEmpty() && !r.latestVersion.isEmpty() && r.installedVersion != r.latestVersion; case IsInstalledRole: return !r.installedVersion.isEmpty() && r.missingDeps.isEmpty(); case MissingDepsRole: return r.missingDeps; case InstallStatusRole: return static_cast(r.installStatus); case InstallTypeRole: return r.installType; 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 m_installRegistry ? m_installRegistry->stage(r.name) : static_cast(InstallStage::None); case InstallErrorRole: return m_installRegistry ? m_installRegistry->error(r.name) : QString(); } return {}; } QHash AppsModel::roleNames() const { return { {NameRole, "name"}, {RepositoryUrlRole, "repositoryUrl"}, {DisplayNameRole, "displayName"}, {DescriptionRole, "description"}, {CategoryRole, "category"}, {TypeRole, "type"}, {IconUrlRole, "iconUrl"}, {SupportsFullBleedIconRole, "supportsFullBleedIcon"}, {VersionsRole, "versions"}, {DependenciesRole, "dependencies"}, {InstalledVersionRole, "installedVersion"}, {LatestVersionRole, "latestVersion"}, {HasUpdateRole, "hasUpdate"}, {IsInstalledRole, "isInstalled"}, {MissingDepsRole, "missingDeps"}, {InstallStatusRole, "installStatus"}, {InstallTypeRole, "installType"}, {ActionRole, "action"}, {ToVersionRole, "toVersion"}, {IsTopLevelRole, "isTopLevel"}, {ResolverErrorRole, "resolverError"}, {InstallStageRole, "installStage"}, {InstallErrorRole, "installError"}, }; } QStringList AppsModel::categories() const { QStringList seen; seen.append(QStringLiteral("All")); for (const Row& r : m_rows) { if (r.category.isEmpty()) continue; QString c = r.category; c[0] = c[0].toUpper(); if (!seen.contains(c)) seen.append(c); } std::sort(seen.begin() + 1, seen.end()); // keep "All" first return seen; } // ── Helpers ──────────────────────────────────────────────────────────────── // Three-way version compare. Returns -1 / 0 / +1. // // Delegates to the shared semver implementation in logos-package — the same // code lgx, lgpm, lgpd and the package-manager UI use. It used to split on '.' // and QString::toInt() each component (dropping the `ok` flag), so // "0-rc1".toInt() silently yielded 0 and every pre-release compared EQUAL to // its own release: an installed 1.0.0-rc.1 against an available 1.0.0 showed no // Upgrade, and 1.0.0-rc.2 vs 1.0.0-rc.10 compared equal. static int versionCmp(const QString& a, const QString& b) { return logos::semver::compare(a.toStdString(), b.toStdString()); } void AppsModel::recomputeInstallStatus(Row& r) { if (r.installedVersion.isEmpty()) { r.installStatus = InstallStatus::NotInstalled; return; } if (!r.missingDeps.isEmpty()) { r.installStatus = InstallStatus::NotInstalled; return; } const QString releaseVersion = r.latestVersion; const QString releaseHash = r.versions.isEmpty() ? QString() : r.versions.first().toMap().value("rootHash").toString(); if (releaseVersion.isEmpty()) { // No catalog version to compare against — best-effort Installed. r.installStatus = InstallStatus::Installed; return; } const int cmp = versionCmp(r.installedVersion, releaseVersion); if (cmp < 0) { r.installStatus = InstallStatus::UpgradeAvailable; return; } if (cmp > 0) { r.installStatus = InstallStatus::DowngradeAvailable; return; } if (!releaseHash.isEmpty() && !r.installedHash.isEmpty() && releaseHash != r.installedHash) { r.installStatus = InstallStatus::DifferentHash; return; } for (const QVariant& v : r.dependencies) { const QVariantMap dep = v.toMap(); const QString depName = dep.value("name").toString(); if (depName.isEmpty()) continue; const int depIdx = rowOf(depName, r.repositoryUrl); if (depIdx < 0) continue; // dep not in this repo — see comment above const Row& depRow = m_rows[depIdx]; if (depRow.versions.isEmpty()) continue; const QString expectedDepHash = depRow.versions.first().toMap().value("rootHash").toString(); const QString installedDepHash = depRow.installedHash; if (expectedDepHash.isEmpty() || installedDepHash.isEmpty()) continue; if (expectedDepHash != installedDepHash) { r.installStatus = InstallStatus::DifferentHash; return; } } r.installStatus = InstallStatus::Installed; } void AppsModel::recomputeVersionDerivedFields(Row& r) { QVariantMap firstManifest; if (!r.versions.isEmpty()) { firstManifest = r.versions.first().toMap().value("manifest").toMap(); } r.latestVersion = firstManifest.value("version").toString(); r.dependencies.clear(); if (!r.versions.isEmpty()) { const QVariantList raw = firstManifest.value("dependencies").toList(); for (const QVariant& v : raw) { QVariantMap entry; if (v.typeId() == QMetaType::QString) { entry["name"] = v.toString(); entry["version"] = QString(); } else { const QVariantMap m = v.toMap(); entry["name"] = m.value("name").toString(); entry["version"] = m.value("version").toString(); } if (entry.value("name").toString().isEmpty()) continue; r.dependencies.append(entry); } } recomputeInstallStatus(r); } // ── Mutation: bulk replace from catalog ──────────────────────────────────── void AppsModel::replaceCatalog(const QVariantList& catalogRows) { QSet incoming; QSet incomingNames; incoming.reserve(catalogRows.size()); incomingNames.reserve(catalogRows.size()); for (const QVariant& v : catalogRows) { const QVariantMap row = v.toMap(); const QString name = row.value("name").toString(); if (name.isEmpty()) continue; incoming.insert(key(row.value("repositoryUrl").toString(), name)); incomingNames.insert(name); } QList toRemove; for (int i = 0; i < m_rows.size(); ++i) { // Local rows (no repositoryUrl) survive a catalog replace UNLESS the // incoming catalog now provides that name — in which case the catalog // row is the source of truth and the local one is dropped. if (m_rows[i].repositoryUrl.isEmpty()) { if (incomingNames.contains(m_rows[i].name)) toRemove.append(i); continue; } const QString k = key(m_rows[i].repositoryUrl, m_rows[i].name); if (!incoming.contains(k)) toRemove.append(i); } for (int i = toRemove.size() - 1; i >= 0; --i) { const int idx = toRemove[i]; beginRemoveRows({}, idx, idx); m_rows.removeAt(idx); endRemoveRows(); } m_indexByKey.clear(); m_indicesByName.clear(); for (int i = 0; i < m_rows.size(); ++i) { m_indexByKey.insert(key(m_rows[i].repositoryUrl, m_rows[i].name), i); m_indicesByName.insert(m_rows[i].name, i); } // Upsert incoming rows. for (const QVariant& v : catalogRows) { const QVariantMap row = v.toMap(); const QString name = row.value("name").toString(); if (name.isEmpty()) continue; const QString repo = row.value("repositoryUrl").toString(); const QString k = key(repo, name); const auto it = m_indexByKey.find(k); if (it == m_indexByKey.end()) { // Insert new row at the end. const int idx = m_rows.size(); beginInsertRows({}, idx, idx); Row r; r.name = name; r.repositoryUrl = repo; r.displayName = row.value("displayName").toString(); r.description = row.value("description").toString(); r.category = row.value("category").toString(); r.type = row.value("type").toString(); r.iconUrl = catalogIconUrl(row); r.supportsFullBleedIcon = AppsModel::supportsFullBleedIcon( row.value("manifestVersion").toString()); r.versions = row.value("versions").toList(); recomputeVersionDerivedFields(r); m_rows.append(std::move(r)); m_indexByKey.insert(k, idx); m_indicesByName.insert(name, idx); endInsertRows(); } else { // Update catalog fields on existing row, preserve everything else. const int idx = it.value(); Row& r = m_rows[idx]; r.displayName = row.value("displayName").toString(); r.description = row.value("description").toString(); r.category = row.value("category").toString(); r.type = row.value("type").toString(); const QString ic = catalogIconUrl(row); if (!ic.isEmpty()) r.iconUrl = ic; else if (r.installedVersion.isEmpty()) r.iconUrl.clear(); r.supportsFullBleedIcon = AppsModel::supportsFullBleedIcon( row.value("manifestVersion").toString()); r.versions = row.value("versions").toList(); recomputeVersionDerivedFields(r); const QModelIndex mi = index(idx); emit dataChanged(mi, mi, { DisplayNameRole, DescriptionRole, CategoryRole, TypeRole, IconUrlRole, SupportsFullBleedIconRole, VersionsRole, LatestVersionRole, HasUpdateRole, DependenciesRole, InstallStatusRole }); } } emit categoriesChanged(); } // ── Mutation: local-only installed rows ──────────────────────────────────── void AppsModel::mergeLocalOnlyInstalled(const QVariantList& installedPackages) { // Names still present on disk. Used to drop stale Local rows below. QSet freshNames; freshNames.reserve(installedPackages.size()); for (const QVariant& v : installedPackages) { const QString name = v.toMap().value("name").toString(); if (!name.isEmpty()) freshNames.insert(name); } // Prune Local rows whose module got uninstalled. Catalog rows (non- // empty repositoryUrl) are replaceCatalog's job — untouched here. QList toRemove; bool categoriesTouched = false; for (int i = 0; i < m_rows.size(); ++i) { if (!m_rows[i].repositoryUrl.isEmpty()) continue; if (freshNames.contains(m_rows[i].name)) continue; toRemove.append(i); if (!m_rows[i].category.isEmpty()) categoriesTouched = true; } for (int i = toRemove.size() - 1; i >= 0; --i) { const int idx = toRemove[i]; beginRemoveRows({}, idx, idx); m_rows.removeAt(idx); endRemoveRows(); } if (!toRemove.isEmpty()) { m_indexByKey.clear(); m_indicesByName.clear(); for (int i = 0; i < m_rows.size(); ++i) { m_indexByKey.insert(key(m_rows[i].repositoryUrl, m_rows[i].name), i); m_indicesByName.insert(m_rows[i].name, i); } } for (const QVariant& v : installedPackages) { const QVariantMap pkg = v.toMap(); const QString name = pkg.value("name").toString(); if (name.isEmpty()) continue; // Only surface USER-installed packages (under Application Support) // as Local. Embedded packages ship inside the app bundle and are // already represented via the built-in module list — showing them // here would double-list them and clutter the section. if (pkg.value("installType").toString() != QLatin1String("user")) continue; // Skip if any row for this name exists (catalog or previously merged // local). markInstalled/setInstallType/setIconUrl already reach the // existing row via m_indicesByName; nothing to do here. if (m_indicesByName.contains(name)) continue; const int idx = m_rows.size(); beginInsertRows({}, idx, idx); Row r; r.name = name; r.repositoryUrl = QString(); r.displayName = pkg.value("displayName").toString(); r.description = pkg.value("description").toString(); r.category = pkg.value("category").toString(); r.type = pkg.value("type").toString(); r.installedVersion = pkg.value("version").toString(); r.installedHash = pkg.value("hashes").toMap().value("root").toString(); r.installType = pkg.value("installType").toString(); // versions{} + empty latestVersion → recomputeInstallStatus lands on // InstallStatus::Installed (installedVersion set + no release to // compare against). HasUpdate stays false. Local-only rows expose no // Install/Upgrade action. recomputeVersionDerivedFields(r); m_rows.append(std::move(r)); m_indexByKey.insert(key(QString(), name), idx); m_indicesByName.insert(name, idx); endInsertRows(); if (!m_rows.last().category.isEmpty()) categoriesTouched = true; } if (categoriesTouched) emit categoriesChanged(); } // ── Mutation: on-disk state ──────────────────────────────────────────────── void AppsModel::markInstalled(const QString& name, const QString& installedVersion, const QString& installedHash) { bool anyChanged = false; for (int idx : m_indicesByName.values(name)) { Row& r = m_rows[idx]; if (r.installedVersion == installedVersion && r.installedHash == installedHash) continue; r.installedVersion = installedVersion; r.installedHash = installedHash; recomputeInstallStatus(r); const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {InstalledVersionRole, HasUpdateRole, IsInstalledRole, InstallStatusRole}); anyChanged = true; } if (!anyChanged || m_inBulkInstalledUpdate) return; for (int idx = 0; idx < m_rows.size(); ++idx) { Row& other = m_rows[idx]; if (other.name == name) continue; const InstallStatus::Value prev = other.installStatus; recomputeInstallStatus(other); if (other.installStatus != prev) { const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {InstallStatusRole, IsInstalledRole}); } } } 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; } void AppsModel::endBulkInstalledUpdate() { if (!m_inBulkInstalledUpdate) return; m_inBulkInstalledUpdate = false; for (int idx = 0; idx < m_rows.size(); ++idx) { Row& r = m_rows[idx]; const InstallStatus::Value prev = r.installStatus; recomputeInstallStatus(r); if (r.installStatus != prev) { const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {InstallStatusRole, IsInstalledRole}); } } } void AppsModel::setInstallType(const QString& name, const QString& installType) { for (int idx : m_indicesByName.values(name)) { Row& r = m_rows[idx]; if (r.installType == installType) continue; r.installType = installType; const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {InstallTypeRole}); } } void AppsModel::setIconUrl(const QString& name, const QString& iconUrl) { for (int idx : m_indicesByName.values(name)) { Row& r = m_rows[idx]; if (r.iconUrl == iconUrl) continue; r.iconUrl = iconUrl; const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {IconUrlRole}); } } void AppsModel::setMissingDeps(const QString& name, const QStringList& missing) { for (int idx : m_indicesByName.values(name)) { Row& r = m_rows[idx]; if (r.missingDeps == missing) continue; r.missingDeps = missing; recomputeInstallStatus(r); const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {MissingDepsRole, IsInstalledRole, InstallStatusRole}); } } // ── Wiring: live install state ──────────────────────────────────────────── void AppsModel::setInstallRegistry(InstallRegistry* installRegistry) { 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 ───────────────────────────────────────────── void AppsModel::setResolverOverlay(const QList& rows) { clearResolverOverlay(); // A package can appear in the overlay more than once — as a user-pinned // top-level entry AND as a transitive dependency of another pinned package // (resolved to the newest catalog version). Both map to the SAME row via // rowOf(name, repo), so without a guard the transitive copy overwrites the // pin and the version dropdown snaps back to the newest release. An explicit // top-level entry is authoritative: once one has written a row, a later // non-top-level duplicate of the same package must not clobber it. // (Defensive — the resolver also no longer emits that duplicate.) QSet pinnedRows; for (const ResolverRow& src : rows) { const int idx = rowOf(src.name, src.repositoryUrl); if (idx < 0) continue; if (!src.isTopLevel && pinnedRows.contains(idx)) continue; Row& r = m_rows[idx]; r.action = src.action; r.toVersion = src.toVersion; r.isTopLevel = src.isTopLevel; r.resolverError = src.resolverError; if (src.isTopLevel) pinnedRows.insert(idx); const QModelIndex mi = index(idx); emit dataChanged(mi, mi, {ActionRole, ToVersionRole, IsTopLevelRole, ResolverErrorRole}); } } void AppsModel::clearResolverOverlay() { for (int i = 0; i < m_rows.size(); ++i) { Row& r = m_rows[i]; if (r.action.isEmpty() && r.toVersion.isEmpty() && !r.isTopLevel && r.resolverError.isEmpty()) continue; r.action.clear(); r.toVersion.clear(); r.isTopLevel = false; r.resolverError.clear(); const QModelIndex mi = index(i); emit dataChanged(mi, mi, {ActionRole, ToVersionRole, IsTopLevelRole, ResolverErrorRole}); } } // ── Lookup ───────────────────────────────────────────────────────────────── int AppsModel::rowOf(const QString& name, const QString& repositoryUrl) const { if (!repositoryUrl.isEmpty()) { const auto it = m_indexByKey.find(key(repositoryUrl, name)); return it == m_indexByKey.end() ? -1 : it.value(); } for (int i = 0; i < m_rows.size(); ++i) if (m_rows[i].name == name) return i; return -1; } QVariantMap AppsModel::rowData(int row) const { if (row < 0 || row >= m_rows.size()) return {}; const QModelIndex mi = index(row); QVariantMap m; const auto roles = roleNames(); for (auto it = roles.cbegin(); it != roles.cend(); ++it) m.insert(QString::fromUtf8(it.value()), data(mi, it.key())); return m; } QVariantMap AppsModel::rowDataByName(const QString& name, const QString& repositoryUrl) const { return rowData(rowOf(name, repositoryUrl)); }