The QML layer never scrapes package-manager output. Every transaction runs through a small privileged helper that talks to the package manager's library and reports newline-delimited JSON (NDJSON) events on stdout. Three helpers implement the protocol today:
| Helper | Library | Covers |
|---|---|---|
scripts/rpm_helper.py |
libdnf5 (python3-libdnf5) | rpm install / remove / upgrade / downgrade / plan |
scripts/ostree_helper.py |
rpm-ostree (command line) | atomic Fedora: layering, removal and whole-deployment upgrades |
scripts/apt_helper.py |
python-apt (python3-apt) | deb install / remove / upgrade / downgrade / plan |
scripts/pacman_helper.py |
pyalpm | pacman install / remove / upgrade / plan (official repos only, no AUR) |
scripts/flatpak_helper.py |
libflatpak (gi) | flatpak update / install (+ eol listing) |
Backend.qml picks the transaction helper from the detected distro
family (/etc/os-release). The protocol is deliberately package-
manager-agnostic — the QML layer needs no changes per backend. See
Porting inventory for what lives outside it.
<helper> [plan] <action> <spec>...
- Actions:
install,remove,upgrade,downgrade. <spec>is a package name or name-version in the package manager's native spec syntax. Dependencies are the resolver's job — callers pass the packages the user chose, never a dependency closure.- Transactions require root (run via
pkexec). Prefixing any action withplanresolves it against the existing metadata cache, prints theplanevent and exits — it must work unprivileged and make no changes. That prefix is what lets a UI show the consequences of a transaction before asking for a password: the helper runs underpkexec, which prompts when the process starts, so a plan produced inside the real run would arrive after the authentication it is meant to inform. - One rpm-only option:
install --copr <owner/project> <spec>...enables that Copr before resolving, so a package found by the Copr search costs one authorisation instead of two (the repository and the install are one thing to the person pressing the button). It is refused with any other action and skipped underplan, which is unprivileged. No other helper has an equivalent, because no other family has Copr. - stdout carries only NDJSON events. stderr is free-form (never parsed).
- Exit code 0 on success, 1 on failure, 2 on usage errors. Regardless of
the exit path, the last event on stdout is always
done.
One JSON object per line. Unknown fields and unknown event types must be ignored by consumers (forward compatibility).
| Event | Fields | Meaning |
|---|---|---|
status |
message |
Coarse state before the plan exists ("repos" = refreshing repo metadata) |
plan |
ops: [{name, evr, action, downloadBytes, installBytes}], totalDownloadBytes, installDeltaBytes |
The resolved transaction — the whole of it, including packages the caller never asked for: dependencies pulled in, and anything the resolver decided to remove. action is a human string (Install, Upgrade, Remove, …); outbound-only ops have downloadBytes: 0. installDeltaBytes is the net disk change, incoming sizes minus outgoing |
op-start |
name, phase, bytesTotal? (download), index/total? (install/remove) |
Work on one package begins in the given phase |
progress |
name, phase, percent, download also: bytesTransferred, bytesTotal, totalTransferred |
Per-package progress. percent is the package's own 0–100 |
op-done |
name, phase, totalTransferred? |
The package finished this phase |
op-error |
name, phase, message |
The package failed this phase |
script |
name |
A scriptlet/hook of that package is running |
warning |
message |
Non-fatal problem worth surfacing in logs |
error |
message |
Fatal problem (resolution, transaction). Followed by done |
done |
ok, failed: [spec], nothingToDo?, staged? |
Always the final event. staged: true means the transaction succeeded but changed the next boot rather than the running system — an image-based helper's answer, and the UI says so instead of claiming the package is in use |
Phases: download, install, remove. Package managers without a
distinct download phase simply never emit it.
totalTransferredis the byte sum across all downloads of the transaction and is monotonically non-decreasing. UIs derive their overall download bar fromtotalTransferred / totalDownloadBytes— never from interleaved per-package percentages (parallel downloads make those jump).- When a download ends, any bytes not yet reported are folded into
totalTransferred, so it reachestotalDownloadBytesexactly. - Install/remove phases are serial:
index/totalonop-startorder the transaction;percentcovers the current package only. - Events for a package may interleave with other packages during the download phase, never during install/remove.
- Emitting
progressat most on integer-percent changes keeps event volume bounded.
What a Debian/Arch port must provide, beyond a protocol helper:
Neutral already (no work): flatpak_helper.py, appimage.py, fwupd
firmware (fwupdmgr/LVFS), reviews (ODRS), Flathub metadata, the whole QML
layer at transaction level.
The transaction seam — Backend.qml holds the helper path the QML
call sites use. A port swaps this (or branches on the detected distro).
Distro-specific, outside the protocol. The metadata layer dispatches
per backend: on Fedora the dnf code paths in enrich.py run; on the
Debian family scripts/pkg_backend.py provides the apt implementations.
| Area | dnf | apt |
|---|---|---|
| Update checks & counts | DMS SystemUpdateService daemon |
same daemon (backend support on the DMS side) |
| Shell self-update pass | DMS daemon — must survive the shell reload | same |
| Post-run verification | rpm -q version compare |
dpkg-query -W (via Backend.installedVersionsCommand) |
| Name search fallback | dnf repoquery | pkg_backend.name_search |
| Package info fallback | dnf repoquery --info | pkg_backend.package_info |
| Update sizes | dnf repoquery --info | pkg_backend.update_sizes |
| Previous versions / restore | dnf repoquery + helper downgrade |
pkg_backend.available_versions + helper downgrade name=version |
| Held packages | dnf versionlock/excludepkgs + DMS ignored list | apt-mark showhold + DMS ignored list |
| Installed inventory & dashboard | rpm -qa |
pkg_backend.installed_table / dashboard (.list mtimes as install times) |
| Reboot recommendation | kernel/glibc/… name pattern | linux-image/libc6/… pattern (Backend.rebootPackagePattern) |
| Distro upgrade notice | Bodhi (Fedora releases) | not offered |
| Package changelogs | dnf/rpm changelog | /usr/share/doc/<pkg>/changelog.Debian.gz — the installed version, read locally |
| Notes for git builds | upstream GitHub, keyed on the rpm URL field | same, keyed on pkg_backend.package_info homepage |
| AppStream catalog for system apps | Fedora swcatalog XML | DEP-11 YAML from apt's lists and the appstream cache |
The pacman column of pkg_backend.py mirrors the same functions via a
read-only libalpm handle: real install dates, IgnorePkg holds, an
AUR/foreign count on the dashboard, and an always-empty previous-versions
answer (pacman repos keep no history). Its changelog answer comes from
pacman -Qc, which is empty for the many packages that ship none; Arch
reads its AppStream catalog from the same XML paths as Fedora, filled by
archlinux-appstream-data. AUR is deliberately out of scope for the
transaction helpers: detection comes from the DMS daemon's AUR backend
(paru/yay -Qua), and upgrades ride that same daemon pass —
paru -Syu in a visible terminal, the shell updater's own answer. The
plugin builds no AUR packages itself; the interactive PKGBUILD review
exists for safety.
Silverblue, Kinoite, Bazzite and Bluefin run rpm packages but no rpm
transactions: /usr belongs to the image. Backend.qml detects them by
/run/ostree-booted — the one answer that does not depend on what the
image calls itself — and routes to scripts/ostree_helper.py.
| Area | mutable Fedora | atomic Fedora |
|---|---|---|
| Install / remove | libdnf5 transaction | rpm-ostree install / uninstall, layered, live at the next boot |
| Upgrade | per package | the whole deployment; the specs are reported, the transaction is the image |
| Downgrade / previous versions | dnf repoquery + downgrade |
refused: going back is the previous deployment (rpm-ostree rollback) |
| Plan | resolved unprivileged against the cache | answered from what was asked: rpm-ostree resolves inside its daemon, which asks polkit even for --dry-run |
| Repository list & switches | libdnf5 | the .repo files themselves (repo_backend.list_repo_files) |
| Adding a Copr | dnf copr enable |
the hub's own .repo file, fetched and written — the same file under the same name |
| Removing a base package | ordinary removal | refused with a reason: it needs rpm-ostree override remove, which changes what the image is |
| After a successful run | in use | done carries staged: true, the UI says "takes effect after reboot" and raises the reboot notice |
Two notes on the metadata sources, both of which apply to Fedora too:
changelogs and AppStream data are only as present as their distro
package (appstream-data, appstream, archlinux-appstream-data) —
without it, names and icons fall back to the package manager and the
desktop entries on disk. And a package's changelog outside dnf describes
the installed version: fetching the pending version's changelog would
mean a network round trip inside a synchronous popup, which is not worth
it.
Known open item for parity: confirming the DMS daemon's update checks on real installs of both families. The metadata layer itself is complete.