mirror of
https://github.com/logos-co/logos-app.git
synced 2026-08-31 03:11:08 +00:00
app/ IntentRegistry, IntentBroker, IntentBridgeAdapter,
UIPluginPresenter, ShellIntentEndpoint.
IntentBridgeAdapter includes LogosQmlBridge.h, so it must
be host-side — the shell is not allowed to see logos
runtime types.
app/interfaces/ ShellSections.h, following the InstallEnums.h precedent
for a QML-registered enum both sides need.
src/ The ContentViews.qml handler and MainContainer's half of
the presentation seam.
An app calls logos.request(); PluginLoader has already attached its bridge
at the finishUiQmlLoad funnel, before setSource(), because setSource is
where QML runs and an app requesting from Component.onCompleted would
otherwise be an unknown bridge. The broker checks the caller's own `uses`
declaration, resolves a provider, mints a SEPARATE dispatch id the
requester never sees, has the presenter load and present the provider, and
routes the answer back to that requester alone.
The chooser is raised on every ambiguous request. Remembering a pick
("always use this app") is deliberately not shipped: it needs a settings
screen to review and revoke from, and a way to mark intents that must
never be remembered. Recorded as a follow-up in docs/app-to-app-intents.md.
The shell is a provider like any other for dispatch, and emphatically not
an app: it has no widget and must never be loaded. IntentRegistry owns
that fact — it already tracks the shell's module name to refuse disk
records claiming it — and IntentBroker asks rather than keeping a copy.
638 lines
25 KiB
C++
638 lines
25 KiB
C++
#include "AppsModel.h"
|
|
|
|
#include "InstallRegistry.h"
|
|
|
|
#include <logos/semver.hpp>
|
|
|
|
#include <QSet>
|
|
|
|
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 ProvidesRole: return r.provides;
|
|
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<int>(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<int>(InstallStage::None);
|
|
case InstallErrorRole:
|
|
return m_installRegistry ? m_installRegistry->error(r.name) : QString();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QHash<int, QByteArray> AppsModel::roleNames() const
|
|
{
|
|
return {
|
|
{NameRole, "name"},
|
|
{RepositoryUrlRole, "repositoryUrl"},
|
|
{DisplayNameRole, "displayName"},
|
|
{DescriptionRole, "description"},
|
|
{CategoryRole, "category"},
|
|
{TypeRole, "type"},
|
|
{IconUrlRole, "iconUrl"},
|
|
{SupportsFullBleedIconRole, "supportsFullBleedIcon"},
|
|
{VersionsRole, "versions"},
|
|
{DependenciesRole, "dependencies"},
|
|
{ProvidesRole, "provides"},
|
|
{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);
|
|
}
|
|
}
|
|
|
|
// Intents the package advertises (names only)
|
|
r.provides.clear();
|
|
for (const QVariant& v : firstManifest.value("provides").toList()) {
|
|
const QString intent = v.typeId() == QMetaType::QString
|
|
? v.toString()
|
|
: v.toMap().value(QStringLiteral("intent")).toString();
|
|
if (!intent.isEmpty() && !r.provides.contains(intent))
|
|
r.provides.append(intent);
|
|
}
|
|
|
|
recomputeInstallStatus(r);
|
|
}
|
|
|
|
// ── Mutation: bulk replace from catalog ────────────────────────────────────
|
|
|
|
void AppsModel::replaceCatalog(const QVariantList& catalogRows)
|
|
{
|
|
QSet<QString> incoming;
|
|
QSet<QString> 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<int> 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<QString> 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<int> 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<QString, QString>& versionByName,
|
|
const QHash<QString, QString>& 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<int> 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<ResolverRow>& 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<int> 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));
|
|
}
|