Skip to main content

max / alloy

pkg: make the installed and system tabs live over rpm-ostree The two tabs that fronted rpm-ostree rendered a placeholder saying the tool was not on the dev box. They now parse `rpm-ostree status --json`: the system tab lists the ostree deployments, the installed tab lists the booted deployment's layered packages. One command, one parse, read two ways. The parser is written against a real capture, not a guess. rpm-ostree status needs the running D-Bus daemon, so the shape came from a booted Alloy install in QEMU: the command run by a oneshot unit on the installed disk, read back off the stateroot. An Alloy deployment carries a container-image-reference, the bootc case, rather than a classic ostree remote:ref, and both are modeled with neither invented when absent. A stock image layers nothing, and the installed tab says that state plainly rather than reading empty as a machine with nothing on it. Rpm mirrors the Backend split without being one: a reader returning an Invocation and a pure parse, checkable against the RPM_OSTREE fixture with no rpm-ostree present. The fixture is that real capture with the base-commit manifest blob trimmed, and parses_this_machines_real_status re-checks the trim on any ostree box. refresh reads rpm-ostree on the same tick as the containers, and r refreshes on every tab now that the rpm-ostree tabs have a reason to. 72 pkg tests, clippy and the full workspace suite green.
Author: Max Johnson <me@maxj.phd> · 2026-07-23 00:14 UTC
Signed with PGP, not checked
Commit: 3c3a590ab10b6b18f79d75b34418580564a38030
Parent: 43893dc
2 files changed, +379 insertions, -30 deletions
M docs/CONSOLE.md +2 -2
@@ -156,9 +156,9 @@
156 156
157 157 The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy config` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens.
158 158
159 - - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, and `alloy pkg box`. Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
159 + - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
160 160 - **Next.** `alloy config`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together.
161 - - **Blocked on the target machine.** `alloy display`, `alloy update`, and `alloy pkg install` front `swaymsg`/`wlr-randr` and `rpm-ostree`, none of which exist on a non-Fedora, non-sway development box. The boxes tab was never blocked this way, since podman and flatpak are both on the dev box and their output was captured from it. Writing them now would mean shipping parsers checked against nothing but their own fixtures, which is exactly how the two parser bugs found so far got written. They want the QEMU image or real hardware.
161 + - **Blocked on the target machine.** `alloy display` fronts `swaymsg`/`wlr-randr`, neither of which exists on a non-sway development box, so its parser cannot be checked against real output here — the way the two box-parser bugs got written. The rpm-ostree tabs were in this category until their output was captured from a booted Alloy install in QEMU (2026-07-22); the `install` and `system` parsers are written against that real capture and re-checkable with `parses_this_machines_real_status` on any ostree box. `alloy display` still wants the image or real hardware.
162 162 - **Then.** `alloy sync`, `alloy theme`. `alloy theme` swaps the runtime theme in place (makeover consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments.
163 163 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
164 164 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -10,11 +10,15 @@
10 10 //! | `alloy pkg box` | boxes | `podman ps` and `flatpak list` |
11 11 //! | `alloy update` | system | `rpm-ostree status --json` |
12 12 //!
13 - //! Only the boxes tab is live. The other two front `rpm-ostree`, which does not
14 - //! exist on a non-Fedora development box, and docs/CONSOLE.md:144 is explicit
15 - //! that shipping parsers checked against nothing but their own fixtures is how
16 - //! the two parser bugs found so far got written. They render what they are
17 - //! waiting for instead of guessing at it.
13 + //! All three tabs are live. The boxes tab fronts podman and flatpak; the
14 + //! installed and system tabs front `rpm-ostree status --json`, parsed once and
15 + //! read two ways ([`Status`]). rpm-ostree does not exist on a non-Fedora
16 + //! development box, so off an ostree system those two tabs say so rather than
17 + //! showing an empty inventory that reads as a machine with nothing on it. The
18 + //! `RPM_OSTREE` fixture was captured from a booted Alloy install, not invented:
19 + //! docs/CONSOLE.md:144 is explicit that shipping parsers checked against nothing
20 + //! but their own fixtures is how the two box-parser bugs got written, and
21 + //! `parses_this_machines_real_status` is the live check for an ostree box.
18 22 //!
19 23 //! # Not a package manager
20 24 //!
@@ -997,6 +1001,143 @@
997 1001 });
998 1002 }
999 1003
1004 + // ---- the host image: rpm-ostree deployments ----
1005 +
1006 + /// The parse of `rpm-ostree status --json`.
1007 + ///
1008 + /// One command feeds two tabs: the system tab lists [`Status::deployments`], the
1009 + /// installed tab lists the booted deployment's layered packages. They are two
1010 + /// readings of one fact, which is why they refresh together and share a status
1011 + /// line. Unknown top-level keys (`transaction`, `cached-update`, `update-driver`)
1012 + /// are ignored; the two tabs read deployments and nothing else.
1013 + #[derive(Debug, Default, Deserialize)]
1014 + struct Status {
1015 + #[serde(default)]
1016 + deployments: Vec<Deployment>,
1017 + }
1018 +
1019 + impl Status {
1020 + /// The deployment currently running, if the status names one.
1021 + ///
1022 + /// The installed tab is about the layering on the machine as it is now, so it
1023 + /// reads the booted deployment rather than a staged or rollback one. A status
1024 + /// with no booted deployment is possible (mid-stage) and yields no layered
1025 + /// list rather than a wrong one.
1026 + fn booted(&self) -> Option<&Deployment> {
1027 + self.deployments.iter().find(|d| d.booted)
1028 + }
1029 + }
1030 +
1031 + /// One ostree deployment.
1032 + ///
1033 + /// Alloy is a bootc container image, so its deployments carry a
1034 + /// `container-image-reference` rather than the classic ostree `origin`
1035 + /// (a `remote:ref` string). Both are modeled and neither is invented when
1036 + /// absent: a machine tracking an ostree remote instead of a container still
1037 + /// reads correctly, and the row shows whichever identity the deployment actually
1038 + /// has. Fields the tabs do not read (the base-commit metadata, the override
1039 + /// tables) are left out of the struct rather than parsed and ignored.
1040 + #[derive(Debug, Deserialize)]
1041 + struct Deployment {
1042 + checksum: String,
1043 + /// The image's own version label, e.g. `43.20260719.0`. Human-meaningful and
1044 + /// date-bearing, which is why the system tab shows it rather than formatting
1045 + /// the raw `timestamp`.
1046 + #[serde(default)]
1047 + version: Option<String>,
1048 + #[serde(default)]
1049 + booted: bool,
1050 + #[serde(default)]
1051 + staged: bool,
1052 + #[serde(default)]
1053 + pinned: bool,
1054 + /// The container image this deployment was pulled from — the bootc case, and
1055 + /// the one every Alloy deployment is in.
1056 + #[serde(rename = "container-image-reference", default)]
1057 + image: Option<String>,
1058 + /// The classic ostree origin (`remote:ref`), present instead of `image` on a
1059 + /// deployment that tracks an ostree remote rather than a container.
1060 + #[serde(default)]
1061 + origin: Option<String>,
1062 + /// Layered packages actually applied over the base image.
1063 + #[serde(default)]
1064 + packages: Vec<String>,
1065 + /// Layered packages the user requested by name. Equal to `packages` except
1066 + /// while a change to the layering is staged but not yet booted, which is the
1067 + /// one moment the two disagree.
1068 + #[serde(rename = "requested-packages", default)]
1069 + requested_packages: Vec<String>,
1070 + }
1071 +
1072 + impl Deployment {
1073 + /// The deployment's identity for the system tab: its container image, or its
1074 + /// ostree origin, or — failing both — the bare commit, so a deployment with
1075 + /// neither still names itself rather than showing an empty cell.
1076 + fn source(&self) -> &str {
1077 + self.image
1078 + .as_deref()
1079 + .or(self.origin.as_deref())
1080 + .unwrap_or(&self.checksum)
1081 + }
1082 +
1083 + /// The version label, or a dash when the deployment carries none. A commit
1084 + /// without a version is legal ostree and the column shows the gap rather than
1085 + /// collapsing the row.
1086 + fn version(&self) -> &str {
1087 + self.version.as_deref().unwrap_or("-")
1088 + }
1089 +
1090 + /// The layered set for the installed tab: the requested names, falling back
1091 + /// to the applied set. Empty is the ordinary, healthy answer on an image
1092 + /// system and the tab says as much rather than reading empty as broken.
1093 + fn layered(&self) -> &[String] {
1094 + if self.requested_packages.is_empty() {
1095 + &self.packages
1096 + } else {
1097 + &self.requested_packages
1098 + }
1099 + }
1100 +
1101 + /// The word in the state column and its severity: the booted deployment is
1102 + /// the healthy resting state, a staged one is a pending change worth a
1103 + /// glance, and everything else is a rollback target sitting in reserve.
1104 + fn state(&self) -> (&'static str, Severity) {
1105 + if self.booted {
1106 + ("booted", Severity::Healthy)
1107 + } else if self.staged {
1108 + ("staged", Severity::Warn)
1109 + } else {
1110 + ("rollback", Severity::Info)
1111 + }
1112 + }
1113 + }
1114 +
1115 + /// Reads `rpm-ostree status --json`.
1116 + ///
1117 + /// Deliberately not a [`Backend`]: those answer for containers and yield [`Box`]
1118 + /// rows, and rpm-ostree answers for the host image on a different axis entirely.
1119 + /// It keeps the same return-not-execute shape, so [`Rpm::parse`] is a pure
1120 + /// function checkable against a fixture with no rpm-ostree on the machine —
1121 + /// which the dev box this is written on does not have.
1122 + struct Rpm;
1123 +
1124 + impl Rpm {
1125 + /// Whether this is an ostree machine at all. Probed once, like the container
1126 + /// backends in [`detect`], rather than on every refresh: the answer does not
1127 + /// change while the console is running.
1128 + fn present() -> bool {
1129 + Invocation::new("rpm-ostree").arg("--version").probe()
1130 + }
1131 +
1132 + fn status() -> Invocation {
1133 + Invocation::new("rpm-ostree").args(["status", "--json"])
1134 + }
1135 +
1136 + fn parse(raw: &str) -> Result<Status> {
1137 + serde_json::from_str(raw).context("rpm-ostree emitted invalid JSON")
1138 + }
1139 + }
1140 +
1000 1141 // ---- the view ----
1001 1142
1002 1143 /// The three tabs, in bar order.
@@ -1038,13 +1179,12 @@
1038 1179 }
1039 1180 }
1040 1181
1041 - /// What the installed and system tabs are waiting for.
1182 + /// Shown on the installed and system tabs when there is no rpm-ostree to read.
1042 1183 ///
1043 - /// Both read `rpm-ostree status --json`. Saying so is more useful than an empty
1044 - /// list, and more honest than a mock: this is a screen about what is really on
1045 - /// the machine, and the machine this is running on is not the target.
1046 - const RPM_OSTREE_PENDING: &str =
1047 - "needs rpm-ostree, which is not on this machine (see docs/CONSOLE.md)";
1184 + /// Both tabs front `rpm-ostree status --json`. Off an ostree system the binary
1185 + /// is absent, and saying so is more honest than an empty list: this is a screen
1186 + /// about what is really on the machine, and a dev box is not an Alloy install.
1187 + const RPM_OSTREE_ABSENT: &str = "not an ostree system: rpm-ostree is not installed";
1048 1188
1049 1189 /// A remove the user has been asked to confirm.
1050 1190 ///
@@ -1080,6 +1220,15 @@
1080 1220 error: Option<String>,
1081 1221 pending: Option<PendingRemove>,
1082 1222 ticks: u64,
1223 + /// Whether rpm-ostree is on this machine, probed once at construction. Off an
1224 + /// ostree system it is absent, and the installed and system tabs say so
1225 + /// rather than reading empty as a machine with nothing installed.
1226 + has_rpm: bool,
1227 + /// The last `rpm-ostree status --json`, or the error from trying. `None`
1228 + /// until the first read, and left `None` forever when [`has_rpm`] is false.
1229 + ///
1230 + /// [`has_rpm`]: PkgView::has_rpm
1231 + rpm: Option<Result<Status>>,
1083 1232 }
1084 1233
1085 1234 impl PkgView {
@@ -1096,6 +1245,8 @@
1096 1245 error: None,
1097 1246 pending: None,
1098 1247 ticks: 0,
1248 + has_rpm: Rpm::present(),
1249 + rpm: None,
1099 1250 };
1100 1251 view.refresh(log);
1101 1252 view
@@ -1151,6 +1302,14 @@
1151 1302 if let Some(message) = failure {
1152 1303 self.error = Some(message);
1153 1304 }
1305 +
1306 + // The host image, on the same refresh as the containers: the installed
1307 + // and system tabs are as live as the boxes tab, and a rollback the user
1308 + // just pinned shows up on the next tick without a keypress. Skipped
1309 + // entirely off an ostree system, where the probe already answered no.
1310 + if self.has_rpm {
1311 + self.rpm = Some(Rpm::status().run(log).and_then(|raw| Rpm::parse(&raw)));
1312 + }
1154 1313 }
1155 1314
1156 1315 /// Rows for declared boxes the system does not have.
@@ -1420,6 +1579,120 @@
1420 1579
1421 1580 frame.render_widget(Line::from(text::muted(theme, self.summary())), summary_area);
1422 1581 }
1582 +
1583 + /// The installed tab: the layered packages on top of the base image.
1584 + ///
1585 + /// On an Alloy system this is empty, and that emptiness is the thing the tab
1586 + /// exists to show — an image system carries no per-machine package drift, so
1587 + /// the honest reading is "nothing added", not a blank that looks like a
1588 + /// failure. The rows, when there are any, are the exception worth seeing.
1589 + fn render_installed(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1590 + let status = match self.rpm_or_reason() {
1591 + Ok(status) => status,
1592 + Err(line) => {
1593 + frame.render_widget(Line::from(text::muted(theme, line)), area);
1594 + return;
1595 + }
1596 + };
1597 +
1598 + let [list_area, summary_area] =
1599 + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
1600 +
1601 + let packages = status.booted().map(Deployment::layered).unwrap_or_default();
1602 + if packages.is_empty() {
1603 + frame.render_widget(
1604 + Line::from(text::muted(
1605 + theme,
1606 + "nothing layered — this system is the image, with no packages added on top",
1607 + )),
1608 + list_area,
1609 + );
1610 + frame.render_widget(
1611 + Line::from(text::muted(theme, "0 layered packages")),
1612 + summary_area,
1613 + );
1614 + return;
1615 + }
1616 +
1617 + let rows: Vec<Line> = packages
1618 + .iter()
1619 + .map(|name| Line::from(text::bold(theme, name.clone())))
1620 + .collect();
1621 + frame.render_widget(AlloyList::new(theme, rows), list_area);
1622 + frame.render_widget(
1623 + Line::from(text::muted(
1624 + theme,
1625 + format!("{} layered on top of the base image", packages.len()),
1626 + )),
1627 + summary_area,
1628 + );
1629 + }
1630 +
1631 + /// The system tab: the ostree deployments, booted first.
1632 + ///
1633 + /// One row per deployment — the booted one, anything staged, and the
1634 + /// rollback targets held in reserve — each naming the image or origin it came
1635 + /// from and its version. This is what `alloy update` acts on, so seeing the
1636 + /// booted image and the one waiting to replace it in the same list is the
1637 + /// point.
1638 + fn render_system(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
1639 + let status = match self.rpm_or_reason() {
1640 + Ok(status) => status,
1641 + Err(line) => {
1642 + frame.render_widget(Line::from(text::muted(theme, line)), area);
1643 + return;
1644 + }
1645 + };
1646 +
1647 + let [list_area, summary_area] =
1648 + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(area);
1649 +
1650 + if status.deployments.is_empty() {
1651 + frame.render_widget(Line::from(text::muted(theme, "no deployments")), list_area);
1652 + return;
1653 + }
1654 +
1655 + let rows: Vec<Line> = status
1656 + .deployments
1657 + .iter()
1658 + .map(|dep| self.deployment_row(theme, dep))
1659 + .collect();
1660 + frame.render_widget(AlloyList::new(theme, rows), list_area);
1661 +
1662 + let pinned = status.deployments.iter().filter(|d| d.pinned).count();
1663 + let mut summary = format!("{} deployments", status.deployments.len());
1664 + if pinned > 0 {
1665 + summary.push_str(&format!(", {pinned} pinned"));
1666 + }
1667 + frame.render_widget(Line::from(text::muted(theme, summary)), summary_area);
1668 + }
1669 +
1670 + fn deployment_row<'a>(&self, theme: &Theme, dep: &'a Deployment) -> Line<'a> {
1671 + let (state, severity) = dep.state();
1672 + Line::from(vec![
1673 + Span::styled(format!("{state:<9}"), severity.style(theme)),
1674 + text::bold(theme, format!("{:<34}", truncate(dep.source(), 33))),
1675 + text::secondary(theme, format!("{:<16}", truncate(dep.version(), 15))),
1676 + text::muted(theme, if dep.pinned { "pinned" } else { "" }),
1677 + ])
1678 + }
1679 +
1680 + /// The parsed status, or the line to render in its place.
1681 + ///
1682 + /// Three cases the tabs share: no rpm-ostree at all (not an ostree system),
1683 + /// rpm-ostree present but the read failed, and a read that has not happened
1684 + /// yet. Each is a reason the list is empty that is not "the machine has
1685 + /// nothing", which is the one wrong thing an inventory must never imply.
1686 + fn rpm_or_reason(&self) -> std::result::Result<&Status, String> {
1687 + if !self.has_rpm {
1688 + return Err(RPM_OSTREE_ABSENT.to_string());
1689 + }
1690 + match &self.rpm {
1691 + Some(Ok(status)) => Ok(status),
1692 + Some(Err(err)) => Err(format!("rpm-ostree failed: {err}")),
1693 + None => Err("reading rpm-ostree...".to_string()),
1694 + }
1695 + }
1423 1696 }
1424 1697
1425 1698 impl View for PkgView {
@@ -1446,9 +1719,11 @@
1446 1719 hint("e", "export"),
1447 1720 hint("enter", "shell"),
1448 1721 hint("x", "remove"),
1449 - hint("r", "refresh"),
1450 1722 ]);
1451 1723 }
1724 + // Every tab refreshes; the rpm-ostree tabs have no per-row actions yet,
1725 + // so refresh is the whole of their interaction.
1726 + hints.push(hint("r", "refresh"));
1452 1727 hints
1453 1728 }
1454 1729
@@ -1456,12 +1731,17 @@
1456 1731 if let Some(error) = &self.error {
1457 1732 return Some((Severity::Error, error.clone()));
1458 1733 }
1459 - // The blocked tabs say so in the status line as well as the body: the
1460 - // body text explains, the status line is what a user scanning the
1461 - // footer sees without reading.
1734 + // The rpm-ostree tabs echo their blocker in the footer too: the body
1735 + // explains, the status line is what a user scanning the bottom sees
1736 + // without reading. A tab that is reading fine says nothing, same as the
1737 + // boxes tab.
1462 1738 match self.tab() {
1463 - Tab::Installed | Tab::System => Some((Severity::Warn, RPM_OSTREE_PENDING.to_string())),
1464 1739 Tab::Boxes => None,
1740 + Tab::Installed | Tab::System => match self.rpm_or_reason() {
1741 + Ok(_) => None,
1742 + Err(_) if !self.has_rpm => Some((Severity::Warn, RPM_OSTREE_ABSENT.to_string())),
1743 + Err(reason) => Some((Severity::Error, reason)),
1744 + },
1465 1745 }
1466 1746 }
1467 1747
@@ -1489,16 +1769,8 @@
1489 1769
1490 1770 match self.tab() {
1491 1771 Tab::Boxes => self.render_boxes(frame, body, theme),
1492 - // Both blocked tabs name what they would show, so the wait reads as
1493 - // a missing tool rather than as an empty system.
1494 - Tab::Installed | Tab::System => {
1495 - let subject = match self.tab() {
1496 - Tab::Installed => "layered packages",
1497 - _ => "deployments",
1498 - };
1499 - let line = format!("{subject}: {RPM_OSTREE_PENDING}");
1500 - frame.render_widget(Line::from(text::muted(theme, line)), body);
1501 - }
1772 + Tab::Installed => self.render_installed(frame, body, theme),
1773 + Tab::System => self.render_system(frame, body, theme),
1502 1774 }
1503 1775 }
1504 1776
@@ -1519,6 +1791,13 @@
1519 1791 _ => {}
1520 1792 }
1521 1793
1794 + // Refresh works on every tab: the rpm-ostree tabs are read-only and this
1795 + // is their one control. Handled before the boxes-only gate below.
1796 + if key.code == KeyCode::Char('r') {
1797 + self.refresh(log);
1798 + return Flow::Continue;
1799 + }
1800 +
1522 1801 if self.tab() != Tab::Boxes {
1523 1802 return Flow::Continue;
1524 1803 }
@@ -1530,7 +1809,6 @@
1530 1809 KeyCode::Char('c') => self.create(log),
1531 1810 KeyCode::Char('e') => self.export(log),
1532 1811 KeyCode::Char('x') => return self.confirm_remove(),
1533 - KeyCode::Char('r') => self.refresh(log),
1534 1812 KeyCode::Enter => return self.enter(log),
1535 1813 _ => {}
1536 1814 }
@@ -1587,6 +1865,68 @@
1587 1865 const FLATPAK: &str = "org.chromium.Chromium\tChromium Web Browser\tflathub\n\
1588 1866 dev.edfloreshz.Tasks\tTasks\tflathub\n";
1589 1867
1868 + // Captured from a real `alloy install` booted in QEMU, 2026-07-22, by running
1869 + // `rpm-ostree status --json` on the installed system. The `base-commit-meta`
1870 + // manifest blob (a 38 KB embedded OCI manifest) and the null `transaction` /
1871 + // `cached-update` / `update-driver` keys are elided; the parser reads none of
1872 + // them. This is the whole of a stock Alloy deployment: one booted
1873 + // container-image deployment with nothing layered, which is the state the
1874 + // installed tab exists to show.
1875 + const RPM_OSTREE: &str = r#"{
1876 + "deployments": [
1877 + {
1878 + "booted": true,
1879 + "staged": false,
1880 + "pinned": false,
1881 + "checksum": "8c661a7de56796ed1794c6061d09d5ee75d45abcf48bbf0c85cabbdfb6bd4cc9",
1882 + "container-image-reference": "ostree-unverified-registry:/run/initramfs/live/source/alloy:local",
1883 + "container-image-reference-digest": "sha256:dcc5a684cceefca22366d09aed5aed9698ccbfa576abe543e32ad349ab58af5b",
1884 + "version": "43.20260719.0",
1885 + "timestamp": 1784753154,
1886 + "packages": [],
1887 + "requested-packages": []
1888 + }
1889 + ]
1890 + }"#;
1891 +
1892 + // Not a real Alloy capture: a stock image layers nothing, so this state does
1893 + // not occur on Alloy. Hand-authored to rpm-ostree's documented schema (the
1894 + // field shapes match the real capture above) to exercise the cases the
1895 + // pristine one cannot — a booted deployment with layered packages, a staged
1896 + // change pending underneath it, and a classic ostree-origin rollback target
1897 + // with no container reference. It is what a machine someone had run
1898 + // `rpm-ostree install` on would report, which is exactly the exception the
1899 + // installed tab is for.
1900 + const RPM_OSTREE_LAYERED: &str = r#"{
1901 + "deployments": [
1902 + {
1903 + "staged": true,
1904 + "booted": false,
1905 + "checksum": "1111111111111111111111111111111111111111111111111111111111111111",
1906 + "container-image-reference": "ostree-unverified-registry:localhost/alloy:local",
1907 + "version": "43.20260720.0",
1908 + "requested-packages": ["vim-enhanced", "rsync", "tmux"],
1909 + "packages": ["vim-enhanced", "rsync", "tmux"]
1910 + },
1911 + {
1912 + "booted": true,
1913 + "pinned": true,
1914 + "checksum": "2222222222222222222222222222222222222222222222222222222222222222",
1915 + "container-image-reference": "ostree-unverified-registry:localhost/alloy:local",
1916 + "version": "43.20260719.0",
1917 + "requested-packages": ["vim-enhanced", "rsync"],
1918 + "packages": ["vim-enhanced", "rsync"]
1919 + },
1920 + {
1921 + "booted": false,
1922 + "checksum": "3333333333333333333333333333333333333333333333333333333333333333",
1923 + "origin": "fedora:fedora/43/x86_64/silverblue",
1924 + "version": "42.20260601.0",
1925 + "packages": []
1926 + }
1927 + ]
1928 + }"#;
1929 +
1590 1930 /// A spec declaring each name at a level, with no source fields.
1591 1931 ///
1592 1932 /// Enough for the parsers, which only ask the spec for a level and a
@@ -2324,6 +2664,11 @@
2324 2664 error: None,
2325 2665 pending: None,
2326 2666 ticks: 0,
2667 + // The fixture views exercise the boxes tab; rpm-ostree is neither
2668 + // probed nor read here, which keeps these tests off the host and
2669 + // deterministic. The rpm-ostree parser is covered on its own above.
2670 + has_rpm: false,
2671 + rpm: None,
2327 2672 };
2328 2673 view.cursor.resize(view.boxes.len());
2329 2674 (view, CommandLog::new())
@@ -2504,9 +2849,13 @@
2504 2849 let labels: Vec<&str> = boxes_view.hints().iter().map(|h| h.key).collect();
2505 2850 assert!(labels.contains(&"x"), "the live tab offers its actions");
2506 2851
2852 + // The rpm-ostree tabs have no per-row actions, so they offer tab movement
2853 + // and refresh and nothing else — not the box verbs, which would advertise
2854 + // actions that do nothing here.
2507 2855 let (system_view, _log) = fixture_view(Tab::System, Vec::new());
2508 2856 let labels: Vec<&str> = system_view.hints().iter().map(|h| h.key).collect();
2509 - assert_eq!(labels, ["h/l"], "a blocked tab offers only tab movement");
2857 + assert_eq!(labels, ["h/l", "r"]);
2858 + assert!(!labels.contains(&"x"), "and none of the box actions");
2510 2859 }
Lines truncated