Files
Dario Gabriel Lipicar 7851bf9f46 feat(host): per-plugin identities, an opt-in access policy, and the source split
Each loaded plugin -- including pure-QML ones -- gets its own LogosAPI identity
rather than sharing the host's, so a plugin's calls are attributable and can be
refused independently. The host-services grant is wired through to
capability_module, and its trust root is guarded on an OUTCOME rather than a
log line.

Inter-module access policy stays OFF by default: enforce mode's derived
deny-by-default gates every ui_qml app's calls to its own backend module,
because UI plugins load out-of-process and are not tracked as dependents in the
core ModuleRegistry. Operators opt in per launch with --access-policy enforce
or LOGOS_ACCESS_POLICY.

Takes the Qt host runtime from logos-plugin-qt rather than logos-qt-sdk, which
keeps only the Qt<->lp seam headers, and moves logos-protocol onto the rev that
split host needs. On Windows logos_core must come LAST on the link line: GNU ld
resolves an archive left to right, so the view runtime's references have to be
undefined already when it reaches the import library.

Separates the two source trees -- app/ is the host, src/ is the UI shell -- and
brings the CI onto setup-nix-cache-action. Merges master.
2026-08-22 16:42:13 -03:00

625 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 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"},
{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<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));
}