Skip to main content

max / alloy

pkg: add the package view and its boxes tab The three tabs and the isolation dial from wiki alloy-package-ux. Three verbs open one view, each landing on its own tab: `alloy pkg install` on installed, `alloy pkg box` on boxes, `alloy update` on system. Only the boxes tab is live. The other two parse `rpm-ostree status --json`, which does not exist here, so they name what they are waiting for rather than shipping a parser checked against nothing but its own fixtures. Podman and flatpak are both present on this machine, so the boxes tab was never blocked the way the task claimed. `Backend` returns Invocation values and runs nothing; the view executes them through CommandLog. That makes CONSOLE.md's "every action shows its argv" structural instead of remembered, and leaves every parser testable with no container runtime installed. Flatpak has no JSON output at all, so its contract is `--columns` over a tab-separated body. Level is optional per row. Flatpak implements exactly one level, so those rows are always sandboxed even when ad-hoc, but a bare `podman run` container could be host or workspace and Alloy cannot tell. Inferring it from the container's mounts would be a guess presented as a fact about isolation, which is what this screen exists not to do. The declared-vs-ad-hoc marker reads ~/.config/alloy/boxes.toml, so "declared" means named in that file rather than a heuristic. Without a real source the column would be dead and every row ad-hoc. Removal uses `podman rm --force`: plain rm refuses a running container, which turns one confirmed removal into a stop, a re-read, and a second remove. The confirm modal is the safety mechanism. Fixtures were captured from this machine's real podman and flatpak output, and both parsers plus the spec path were run against it live. Also drops the dead_code allows the shell carried for this view, moves `truncate` out of mesh now that a second screen wants it, and adds Invocation::command for Flow::Suspend.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-19 22:11 UTC
Signed with PGP, not checked
Commit: c01d758dc4f95de54f5186620209cf8b55f55861
Parent: 825417e
7 files changed, +637 insertions, -27 deletions
M Cargo.lock +61 -6
@@ -28,6 +28,7 @@
28 28 "ratatui",
29 29 "serde",
30 30 "serde_json",
31 + "toml 0.9.12+spec-1.1.0",
31 32 ]
32 33
33 34 [[package]]
@@ -745,7 +746,7 @@
745 746 dependencies = [
746 747 "include_dir",
747 748 "serde",
748 - "toml",
749 + "toml 0.8.23",
749 750 ]
750 751
751 752 [[package]]
@@ -1267,6 +1268,15 @@
1267 1268 "serde",
1268 1269 ]
1269 1270
1271 + [[package]]
1272 + name = "serde_spanned"
1273 + version = "1.1.1"
1274 + source = "registry+https://github.com/rust-lang/crates.io-index"
1275 + checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1276 + dependencies = [
1277 + "serde_core",
1278 + ]
1279 +
1270 1280 [[package]]
1271 1281 name = "sha2"
1272 1282 version = "0.10.9"
@@ -1524,11 +1534,26 @@
1524 1534 checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
1525 1535 dependencies = [
1526 1536 "serde",
1527 - "serde_spanned",
1528 - "toml_datetime",
1537 + "serde_spanned 0.6.9",
1538 + "toml_datetime 0.6.11",
1529 1539 "toml_edit",
1530 1540 ]
1531 1541
1542 + [[package]]
1543 + name = "toml"
1544 + version = "0.9.12+spec-1.1.0"
1545 + source = "registry+https://github.com/rust-lang/crates.io-index"
1546 + checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
1547 + dependencies = [
1548 + "indexmap",
1549 + "serde_core",
1550 + "serde_spanned 1.1.1",
1551 + "toml_datetime 0.7.5+spec-1.1.0",
1552 + "toml_parser",
1553 + "toml_writer",
1554 + "winnow 0.7.15",
1555 + ]
1556 +
1532 1557 [[package]]
1533 1558 name = "toml_datetime"
1534 1559 version = "0.6.11"
@@ -1538,6 +1563,15 @@
1538 1563 "serde",
1539 1564 ]
1540 1565
1566 + [[package]]
1567 + name = "toml_datetime"
1568 + version = "0.7.5+spec-1.1.0"
1569 + source = "registry+https://github.com/rust-lang/crates.io-index"
1570 + checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
1571 + dependencies = [
1572 + "serde_core",
1573 + ]
1574 +
1541 1575 [[package]]
1542 1576 name = "toml_edit"
1543 1577 version = "0.22.27"
@@ -1546,10 +1580,19 @@
1546 1580 dependencies = [
1547 1581 "indexmap",
1548 1582 "serde",
1549 - "serde_spanned",
1550 - "toml_datetime",
1583 + "serde_spanned 0.6.9",
1584 + "toml_datetime 0.6.11",
1551 1585 "toml_write",
1552 - "winnow",
1586 + "winnow 0.7.15",
1587 + ]
1588 +
1589 + [[package]]
1590 + name = "toml_parser"
1591 + version = "1.1.2+spec-1.1.0"
1592 + source = "registry+https://github.com/rust-lang/crates.io-index"
1593 + checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
1594 + dependencies = [
1595 + "winnow 1.0.4",
1553 1596 ]
1554 1597
1555 1598 [[package]]
@@ -1558,6 +1601,12 @@
1558 1601 source = "registry+https://github.com/rust-lang/crates.io-index"
1559 1602 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
1560 1603
1604 + [[package]]
1605 + name = "toml_writer"
1606 + version = "1.1.2+spec-1.1.0"
1607 + source = "registry+https://github.com/rust-lang/crates.io-index"
1608 + checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1609 +
1561 1610 [[package]]
1562 1611 name = "typenum"
1563 1612 version = "1.20.1"
@@ -1810,6 +1859,12 @@
1810 1859 "memchr",
1811 1860 ]
1812 1861
1862 + [[package]]
1863 + name = "winnow"
1864 + version = "1.0.4"
1865 + source = "registry+https://github.com/rust-lang/crates.io-index"
1866 + checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1867 +
1813 1868 [[package]]
1814 1869 name = "wit-bindgen"
1815 1870 version = "0.46.0"
@@ -19,4 +19,5 @@
19 19 ratatui.workspace = true
20 20 serde = { version = "1.0.229", features = ["derive"] }
21 21 serde_json = "1.0.150"
22 + toml = "0.9"
22 23 makeover.workspace = true
@@ -156,6 +156,19 @@
156 156 self.capture()
157 157 }
158 158
159 + /// The command as a [`Command`], for a child that cannot be captured.
160 + ///
161 + /// For [`Flow::Suspend`](crate::shell::Flow::Suspend): entering a box hands
162 + /// the terminal to an interactive program, which means inheriting stdio
163 + /// rather than collecting stdout. Callers log the invocation themselves
164 + /// before handing it over, since the command outlives this call and its
165 + /// outcome is not a captured result.
166 + pub fn command(&self) -> Command {
167 + let mut command = Command::new(&self.program);
168 + command.args(&self.args);
169 + command
170 + }
171 +
159 172 fn capture(&self) -> Result<String> {
160 173 let output = Command::new(&self.program)
161 174 .args(&self.args)
@@ -10,6 +10,7 @@
10 10 mod cli;
11 11 mod mesh;
12 12 mod net;
13 + mod pkg;
13 14 mod shell;
14 15 mod theme;
15 16
@@ -42,6 +43,31 @@
42 43 /// Mesh network peers and exit node
43 44 #[command(alias = "tail")]
44 45 Mesh,
46 + /// What is installed, where, and what each surface costs
47 + Pkg {
48 + #[command(subcommand)]
49 + surface: Option<PkgSurface>,
50 + },
51 + // Kept as a top-level verb rather than folded in as `alloy pkg system`:
52 + // docs/CONSOLE.md:22 already specs this name and there is no reason to
53 + // deprecate a documented one. It opens the same view on its own tab —
54 + // installing a package stages a deployment, and that staged deployment is
55 + // exactly what the system tab shows.
56 + /// System image: what is booted, what is staged, and rollback
57 + Update,
58 + }
59 +
60 + /// Which surface of the package view to open on.
61 + ///
62 + /// Imperative because these also take a package argument once `rpm-ostree` is
63 + /// reachable; the tab labels they land on are nouns, since those head a list of
64 + /// what is already there.
65 + #[derive(Subcommand)]
66 + enum PkgSurface {
67 + /// Packages layered onto the base image
68 + Install,
69 + /// Containers and sandboxed apps
70 + Box,
45 71 }
46 72
47 73 fn main() -> Result<()> {
@@ -62,5 +88,19 @@
62 88 let mut view = mesh::MeshView::new(&mut log);
63 89 shell::run(&theme, &mut view, &mut log)
64 90 }
91 + Command::Pkg { surface } => {
92 + let tab = match surface {
93 + Some(PkgSurface::Box) => pkg::Tab::Boxes,
94 + // Bare `alloy pkg` lands where `alloy pkg install` does: the
95 + // layered packages are the surface the verb is named for.
96 + Some(PkgSurface::Install) | None => pkg::Tab::Installed,
97 + };
98 + let mut view = pkg::PkgView::new(tab, &mut log);
99 + shell::run(&theme, &mut view, &mut log)
100 + }
101 + Command::Update => {
102 + let mut view = pkg::PkgView::new(pkg::Tab::System, &mut log);
103 + shell::run(&theme, &mut view, &mut log)
104 + }
65 105 }
66 106 }
@@ -44,7 +44,7 @@
44 44 use serde::Deserialize;
45 45
46 46 use crate::cli::{CommandLog, Invocation};
47 - use crate::shell::{Flow, View, block_title};
47 + use crate::shell::{Flow, View, block_title, truncate};
48 48
49 49 /// Ticks between background refreshes. Peers come and go on the scale of a
50 50 /// laptop lid closing, not a keypress, so polling every second would spawn a
@@ -552,16 +552,6 @@
552 552 }
553 553 }
554 554
555 - /// Clip to `width` columns, marking the clip. Counts `char`s rather than
556 - /// bytes: hostnames carry non-ASCII, and slicing those by byte index panics.
557 - fn truncate(text: &str, width: usize) -> String {
558 - if text.chars().count() <= width {
559 - return text.to_string();
560 - }
561 - let kept: String = text.chars().take(width.saturating_sub(1)).collect();
562 - format!("{kept}…")
563 - }
564 -
565 555 impl View for MeshView {
566 556 /// "mesh (tailscale)", or "mesh (tailscale via hs.example.org)" on a
567 557 /// self-hosted control plane.
@@ -23,16 +23,15 @@
23 23 /// Not `Copy` or `PartialEq`: two variants carry owned data. Callers match
24 24 /// rather than compare.
25 25 ///
26 - /// `Exit`, `Confirm`, and `Suspend` have no production consumer yet. The first
27 - /// view with a destructive action and a shell-out is `alloy pkg`, which is
28 - /// blocked on Fedora hardware; the shell side landed first because both
29 - /// mechanisms get more expensive to retrofit once `alloy config` has form state
30 - /// to lose. Covered by tests in this module. Drop the allow when `alloy pkg`
31 - /// lands.
32 - #[allow(dead_code)]
26 + /// `Confirm` and `Suspend` are `alloy pkg`'s: removing a box raises the first,
27 + /// entering one raises the second. `Exit` still has no production consumer —
28 + /// every view so far leaves through the shell's own quit key — and is kept
29 + /// because a view that needs to close itself should not have to add a variant
30 + /// to do it. Covered by tests in this module.
33 31 #[derive(Debug)]
34 32 pub enum Flow {
35 33 Continue,
34 + #[allow(dead_code)]
36 35 Exit,
37 36 /// Open a confirmation modal. The view keeps whatever it was about to do
38 37 /// and performs it in [`View::confirmed`] if the user agrees.
@@ -58,9 +57,6 @@
58 57
59 58 impl Confirm {
60 59 /// A destructive confirm: the common case, and the reason this exists.
61 - ///
62 - /// Unused until `alloy pkg` gains its remove action; see [`Flow`].
63 - #[allow(dead_code)]
64 60 pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
65 61 Self {
66 62 title: title.into(),
@@ -299,6 +295,21 @@
299 295 format!(" {title} ")
300 296 }
301 297
298 + /// Clip to `width` columns, marking the clip.
299 + ///
300 + /// Counts `char`s rather than bytes: hostnames, image references, and app names
301 + /// all carry non-ASCII, and slicing those by byte index panics. Lives here
302 + /// beside [`block_title`] rather than in a view because `pkg` is the second
303 + /// screen to want it, which is the point docs/CONSOLE.md sets for extracting
304 + /// shared machinery.
305 + pub fn truncate(text: &str, width: usize) -> String {
306 + if text.chars().count() <= width {
307 + return text.to_string();
308 + }
309 + let kept: String = text.chars().take(width.saturating_sub(1)).collect();
310 + format!("{kept}…")
311 + }
312 +
302 313 #[cfg(test)]
303 314 mod tests {
304 315 use super::*;
@@ -1,0 +1,1420 @@
1 + //! `alloy pkg` — the package view: what is installed, where, and what each
2 + //! surface costs.
3 + //!
4 + //! See wiki `alloy-package-ux` for the settled design. Three tabs in one view,
5 + //! three verbs, each landing on its own tab:
6 + //!
7 + //! | Verb | Tab | Source |
8 + //! |---|---|---|
9 + //! | `alloy pkg install` | installed | `rpm-ostree status --json` |
10 + //! | `alloy pkg box` | boxes | `podman ps` and `flatpak list` |
11 + //! | `alloy update` | system | `rpm-ostree status --json` |
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.
18 + //!
19 + //! # Not a package manager
20 + //!
21 + //! docs/CONSOLE.md's non-goal stands: the console never browses or searches a
22 + //! catalog. It shows an inventory. MANIFESTO principle 6 is about the install
23 + //! decision — given software X, where does it go and what does that cost — and
24 + //! a store is separable from that.
25 + //!
26 + //! # Backends build commands, they do not run them
27 + //!
28 + //! [`Backend`] returns [`Invocation`] values and the view runs them through
29 + //! [`CommandLog`]. docs/CONSOLE.md commits every action to being displayed as
30 + //! the argv it runs, so that property falls out structurally rather than by
31 + //! remembering to log. It also means every backend is testable with no
32 + //! container runtime installed: [`Backend::parse`] is a pure function over
33 + //! captured output, which is the only part of this that can be checked on a
34 + //! machine that is not the target.
35 + //!
36 + //! <!-- wiki: alloy-package-ux -->
37 +
38 + use std::collections::BTreeMap;
39 +
40 + use alloy_tui::keys::{Action, classify};
41 + use alloy_tui::{
42 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, Theme, hint, text,
43 + };
44 + use anyhow::{Context, Result};
45 + use ratatui::Frame;
46 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
47 + use ratatui::layout::{Constraint, Layout, Rect};
48 + use ratatui::text::{Line, Span};
49 + use serde::Deserialize;
50 +
51 + use crate::cli::{CommandLog, Invocation};
52 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
53 +
54 + /// Ticks between background refreshes.
55 + ///
56 + /// Slower than `mesh`'s: a container's state changes when someone starts or
57 + /// stops it, which is a deliberate act, not a laptop lid closing. Every refresh
58 + /// spawns one process per backend.
59 + const POLL_TICKS: u64 = 10;
60 +
61 + /// How isolated a box is from the host. The user picks this; Alloy picks the
62 + /// backend that implements it.
63 + ///
64 + /// Level is the stable interface precisely so backends stay swappable — if a
65 + /// better sandbox appears, [`Level::Sandboxed`] repoints and nothing above it
66 + /// changes. It also means the trait never models an operation half its
67 + /// implementors cannot perform, because flatpak is never asked to do
68 + /// [`Level::Host`].
69 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
70 + #[serde(rename_all = "lowercase")]
71 + pub enum Level {
72 + /// Full home, devices, D-Bus, host integration.
73 + Host,
74 + /// Container-private home, explicit mounts only, network on, no devices,
75 + /// no host D-Bus.
76 + Workspace,
77 + /// Portal-mediated: file picker only, permission-gated devices.
78 + Sandboxed,
79 + }
80 +
81 + impl Level {
82 + /// The word shown in the level column.
83 + ///
84 + /// `workspace` is described everywhere as limiting blast radius, never as
85 + /// sandboxing. A rootless podman container with a bind mount is a speed
86 + /// bump against a careless dependency, not a boundary against an attacker,
87 + /// and `sandboxed` is the only level with a real isolation model behind it.
88 + /// Presenting three interchangeable degrees of safety would be the same
89 + /// failure this view exists to correct.
90 + const fn label(self) -> &'static str {
91 + match self {
92 + Level::Host => "host",
93 + Level::Workspace => "workspace",
94 + Level::Sandboxed => "sandboxed",
95 + }
96 + }
97 + }
98 +
99 + /// Whether a box is reproducible.
100 + ///
101 + /// Boxes made outside Alloy — a bare `podman run`, a direct `flatpak install` —
102 + /// are shown anyway, because an inventory that omits them lies and not lying is
103 + /// the entire point of the screen. The marker then does the reproducibility
104 + /// teaching for free: the boxes that survive a rebuild are visibly distinct
105 + /// from the ones that do not.
106 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
107 + pub enum Origin {
108 + /// Named in the box spec, so it can be rebuilt.
109 + Declared,
110 + /// Found on the system but not in the spec.
111 + AdHoc,
112 + }
113 +
114 + impl Origin {
115 + const fn label(self) -> &'static str {
116 + match self {
117 + Origin::Declared => "declared",
118 + Origin::AdHoc => "ad-hoc",
119 + }
120 + }
121 + }
122 +
123 + /// What a box is currently doing.
124 + ///
125 + /// `Other` keeps the backend's own word rather than collapsing it. Podman's
126 + /// state vocabulary gains members (`paused`, `stopping`, `exited`), and showing
127 + /// an unfamiliar one beats mapping it to "unknown" — same reasoning as `mesh`
128 + /// reporting `BackendState` verbatim.
129 + #[derive(Debug, Clone, PartialEq, Eq)]
130 + pub enum BoxState {
131 + Running,
132 + /// A sandboxed box is one app; it is installed, not started and stopped.
133 + Installed,
134 + Other(String),
135 + }
136 +
137 + impl BoxState {
138 + fn label(&self) -> &str {
139 + match self {
140 + BoxState::Running => "running",
141 + BoxState::Installed => "installed",
142 + BoxState::Other(state) => state,
143 + }
144 + }
145 +
146 + /// Resting states are healthy. `Other` is every state that is neither the
147 + /// box running nor an app sitting installed — exited, paused, created —
148 + /// each of which is worth a glance.
149 + const fn severity(&self) -> Severity {
150 + match self {
151 + BoxState::Running | BoxState::Installed => Severity::Healthy,
152 + BoxState::Other(_) => Severity::Warn,
153 + }
154 + }
155 +
156 + const fn is_running(&self) -> bool {
157 + matches!(self, BoxState::Running)
158 + }
159 + }
160 +
161 + /// One row in the boxes tab.
162 + ///
163 + /// Deliberately shadows `std::boxed::Box` inside this module. "Box" is the
164 + /// user's word for this thing — it is distrobox's own, and `alloy pkg box` is
165 + /// the verb — and the domain type earns the name here. The two places that want
166 + /// the pointer say `std::boxed::Box` explicitly.
167 + #[derive(Debug, Clone)]
168 + pub struct Box {
169 + pub name: String,
170 + /// Absent when the box was not made by Alloy and its backend does not imply
171 + /// a level.
172 + ///
173 + /// The mapping only runs one way: a level selects a backend, so flatpak
174 + /// rows are always [`Level::Sandboxed`] even when ad-hoc, while a bare
175 + /// podman container could be either `host` or `workspace` and Alloy does
176 + /// not know which. Guessing from its mounts would be a guess presented as a
177 + /// fact about isolation, which is the one thing this screen must not do.
178 + pub level: Option<Level>,
179 + /// Image reference for host and workspace, app id for sandboxed.
180 + pub source: String,
181 + pub state: BoxState,
182 + pub origin: Origin,
183 + }
184 +
185 + impl Box {
186 + fn level_label(&self) -> &'static str {
187 + self.level.map_or("unknown", Level::label)
188 + }
189 + }
190 +
191 + /// The declared boxes, read from the box spec.
192 + ///
193 + /// The spec is what makes [`Origin`] mean something: declared is "named in this
194 + /// file", not a heuristic. Absent file means every box is ad-hoc, which is the
195 + /// correct reading of a system with no spec.
196 + #[derive(Debug, Default)]
197 + pub struct Spec {
198 + levels: BTreeMap<String, Level>,
199 + }
200 +
201 + impl Spec {
202 + /// Read the spec, or an empty one if it is absent or unreadable.
203 + ///
204 + /// Failures are silent by design. A missing spec is the ordinary case, and
205 + /// a malformed one must not stop the view from showing the inventory —
206 + /// which is the half of the screen that does not depend on the file at all.
207 + pub fn load() -> Self {
208 + let Some(path) = spec_path() else {
209 + return Self::default();
210 + };
211 + let Ok(raw) = std::fs::read_to_string(path) else {
212 + return Self::default();
213 + };
214 + Self::parse(&raw).unwrap_or_default()
215 + }
216 +
217 + fn parse(raw: &str) -> Result<Self> {
218 + let file: SpecFile = toml::from_str(raw).context("box spec is not valid TOML")?;
219 + Ok(Self {
220 + levels: file
221 + .r#box
222 + .into_iter()
223 + .map(|(name, spec)| (name, spec.level))
224 + .collect(),
225 + })
226 + }
227 +
228 + /// The declared level for `name`, if it is in the spec.
229 + fn level(&self, name: &str) -> Option<Level> {
230 + self.levels.get(name).copied()
231 + }
232 +
233 + fn origin(&self, name: &str) -> Origin {
234 + if self.levels.contains_key(name) {
235 + Origin::Declared
236 + } else {
237 + Origin::AdHoc
238 + }
239 + }
240 + }
241 +
242 + /// `~/.config/alloy/boxes.toml`, honoring `XDG_CONFIG_HOME`.
243 + fn spec_path() -> Option<std::path::PathBuf> {
244 + let base = match std::env::var_os("XDG_CONFIG_HOME") {
245 + Some(dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
246 + _ => std::path::PathBuf::from(std::env::var_os("HOME")?).join(".config"),
247 + };
248 + Some(base.join("alloy").join("boxes.toml"))
249 + }
250 +
251 + /// The box spec file. Only `level` is read here; `image`, `app`, `mounts`, and
252 + /// `export` are what creating a box will need, and creation is not in this
253 + /// screen yet. Unknown keys are ignored rather than rejected so a spec written
254 + /// against a later Alloy still yields its levels.
255 + #[derive(Deserialize, Default)]
256 + struct SpecFile {
257 + #[serde(default)]
258 + r#box: BTreeMap<String, SpecBox>,
259 + }
260 +
261 + #[derive(Deserialize)]
262 + struct SpecBox {
263 + level: Level,
264 + }
265 +
266 + /// A source of boxes, and the commands that manage them.
267 + ///
268 + /// Every method returns an [`Invocation`] rather than running one. See the
269 + /// module docs.
270 + pub trait Backend {
271 + /// The tool this fronts, for the view title.
272 + fn name(&self) -> &'static str;
273 +
274 + /// The command whose output [`Backend::parse`] reads.
275 + fn list(&self) -> Invocation;
276 +
277 + /// Turn that command's stdout into rows.
278 + fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>>;
279 +
280 + /// Start `boxed`, or `None` when the concept does not apply.
281 + fn start(&self, boxed: &Box) -> Option<Invocation>;
282 +
283 + /// Stop `boxed`, or `None` when the concept does not apply.
284 + fn stop(&self, boxed: &Box) -> Option<Invocation>;
285 +
286 + /// Remove `boxed`. Always available: everything here can be deleted.
287 + fn remove(&self, boxed: &Box) -> Invocation;
288 +
289 + /// Open a shell in `boxed`. Handed to
290 + /// [`Flow::Suspend`](crate::shell::Flow::Suspend), since these want a real
291 + /// TTY.
292 + fn enter(&self, boxed: &Box) -> Invocation;
293 + }
294 +
295 + /// The backends present on this machine, in display order.
296 + ///
297 + /// Empty is a legal result and renders as an empty inventory rather than as
298 + /// mock rows. `mesh` and `audio` fall back to a mock because a blank peer list
299 + /// teaches nothing; here "no container backend installed" is itself the true
300 + /// and useful answer, and inventing boxes would undercut a screen whose whole
301 + /// claim is that it does not lie about what is on the system.
302 + pub fn detect() -> Vec<std::boxed::Box<dyn Backend>> {
303 + let mut backends: Vec<std::boxed::Box<dyn Backend>> = Vec::new();
304 + if Invocation::new("podman").arg("--version").probe() {
305 + backends.push(std::boxed::Box::new(Podman));
306 + }
307 + if Invocation::new("flatpak").arg("--version").probe() {
308 + backends.push(std::boxed::Box::new(Flatpak));
309 + }
310 + backends
311 + }
312 +
313 + // ---- podman: the host and workspace levels ----
314 +
315 + pub struct Podman;
316 +
317 + impl Backend for Podman {
318 + fn name(&self) -> &'static str {
319 + "podman"
320 + }
321 +
322 + /// `--all` so stopped boxes stay visible. A box someone forgot they made is
323 + /// exactly what an inventory is for, and the default `ps` hides it.
324 + fn list(&self) -> Invocation {
325 + Invocation::new("podman").args(["ps", "--format", "json", "--all"])
326 + }
327 +
328 + fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>> {
329 + let rows: Vec<PodmanContainer> =
330 + serde_json::from_str(raw).context("podman emitted invalid JSON")?;
331 +
332 + let mut boxes: Vec<Box> = rows
333 + .into_iter()
334 + .map(|row| {
335 + let name = row.name();
336 + Box {
337 + level: spec.level(&name),
338 + origin: spec.origin(&name),
339 + state: podman_state(&row.state),
340 + source: row.image,
341 + name,
342 + }
343 + })
344 + .collect();
345 + sort_boxes(&mut boxes);
346 + Ok(boxes)
347 + }
348 +
349 + fn start(&self, boxed: &Box) -> Option<Invocation> {
350 + (!boxed.state.is_running())
351 + .then(|| Invocation::new("podman").args(["start", &boxed.name]))
352 + }
353 +
354 + fn stop(&self, boxed: &Box) -> Option<Invocation> {
355 + boxed
356 + .state
357 + .is_running()
358 + .then(|| Invocation::new("podman").args(["stop", &boxed.name]))
359 + }
360 +
361 + /// `--force` so one confirmed action does what the user asked.
362 + ///
363 + /// Plain `podman rm` refuses a running container, which would turn "remove
364 + /// this box" into a stop, a re-read, and a second remove for a user who
365 + /// already answered a destructive prompt naming the box. The prompt is the
366 + /// safety mechanism; making the command behind it fail is not a second one.
367 + fn remove(&self, boxed: &Box) -> Invocation {
368 + Invocation::new("podman").args(["rm", "--force", &boxed.name])
369 + }
370 +
371 + /// `distrobox enter` for boxes Alloy declared, a plain shell otherwise.
372 + ///
373 + /// Alloy makes its boxes with distrobox (wiki `alloy-package-ux`, "container
374 + /// backend pick"), so declared boxes answer to it and get the host
375 + /// integration that comes with it. A bare `podman run` container never went
376 + /// through distrobox and would fail that command, so it gets the primitive
377 + /// that does work on it.
378 + fn enter(&self, boxed: &Box) -> Invocation {
379 + match boxed.origin {
380 + Origin::Declared => Invocation::new("distrobox").args(["enter", &boxed.name]),
381 + Origin::AdHoc => {
382 + Invocation::new("podman").args(["exec", "-it", &boxed.name, "/bin/sh"])
383 + }
384 + }
385 + }
386 + }
387 +
388 + /// Map podman's state word onto [`BoxState`], keeping anything unrecognized.
389 + fn podman_state(raw: &str) -> BoxState {
390 + if raw == "running" {
391 + BoxState::Running
392 + } else {
393 + BoxState::Other(raw.to_string())
394 + }
395 + }
396 +
397 + /// One entry of `podman ps --format json`.
398 + ///
399 + /// Only the five fields the row needs. Podman emits around two dozen, and
400 + /// naming the rest would be a maintenance cost for data nothing reads.
401 + #[derive(Deserialize)]
402 + struct PodmanContainer {
403 + #[serde(default, rename = "Names")]
404 + names: Vec<String>,
405 + #[serde(default, rename = "Image")]
406 + image: String,
407 + #[serde(default, rename = "State")]
408 + state: String,
409 + #[serde(default, rename = "Id")]
410 + id: String,
411 + }
412 +
413 + impl PodmanContainer {
414 + /// The container's name, falling back to a short id.
415 + ///
416 + /// `Names` is an array and can be empty — a container made with no `--name`
417 + /// gets one generated, but the field is not promised, and a row with no
418 + /// identity is a row the user cannot act on.
419 + fn name(&self) -> String {
420 + self.names.first().cloned().unwrap_or_else(|| {
421 + let short: String = self.id.chars().take(12).collect();
422 + if short.is_empty() {
423 + "(unnamed)".to_string()
424 + } else {
425 + short
426 + }
427 + })
428 + }
429 + }
430 +
431 + // ---- flatpak: the sandboxed level ----
432 +
433 + pub struct Flatpak;
434 +
435 + /// Columns requested from `flatpak list`, in the order the parser reads them.
436 + ///
437 + /// Flatpak has no JSON output — unlike podman and rpm-ostree, the machine
438 + /// contract here is `--columns`, which is documented and stable, against a
439 + /// tab-separated body. Requesting explicit columns rather than parsing the
440 + /// default table is what makes it a contract instead of a layout.
441 + const FLATPAK_COLUMNS: &str = "application,name,origin";
442 +
443 + impl Backend for Flatpak {
444 + fn name(&self) -> &'static str {
445 + "flatpak"
446 + }
447 +
448 + /// `--app` so runtimes stay out. A user has one row's worth of interest in
449 + /// Chromium and none in `org.freedesktop.Platform`, which is an
450 + /// implementation detail of the app above it.
451 + fn list(&self) -> Invocation {
452 + Invocation::new("flatpak").args(["list", "--app", &format!("--columns={FLATPAK_COLUMNS}")])
453 + }
454 +
455 + fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>> {
456 + let mut boxes: Vec<Box> = raw
457 + .lines()
458 + .filter(|line| !line.trim().is_empty())
459 + .filter_map(|line| {
460 + let mut fields = line.split('\t');
461 + let app = fields.next()?.trim();
462 + if app.is_empty() {
463 + return None;
464 + }
465 + let name = fields.next().unwrap_or("").trim();
466 + Some(Box {
467 + // A sandboxed box contains exactly one app, so the app is
468 + // the box. Its human name is the identity a user recognizes;
469 + // the app id is the source that identifies it to flatpak.
470 + name: if name.is_empty() { app.to_string() } else { name.to_string() },
471 + // Always known: flatpak implements exactly one level, so
472 + // even an app installed outside Alloy is sandboxed.
473 + level: Some(Level::Sandboxed),
474 + source: app.to_string(),
475 + state: BoxState::Installed,
476 + origin: spec.origin(app),
477 + })
478 + })
479 + .collect();
480 + sort_boxes(&mut boxes);
481 + Ok(boxes)
482 + }
483 +
484 + // A sandboxed box is one app. It is installed or it is not; there is no
485 + // container to start and stop, and offering the verbs would imply a
486 + // lifecycle that does not exist at this level.
487 + fn start(&self, _boxed: &Box) -> Option<Invocation> {
488 + None
489 + }
490 +
491 + fn stop(&self, _boxed: &Box) -> Option<Invocation> {
492 + None
493 + }
494 +
495 + fn remove(&self, boxed: &Box) -> Invocation {
496 + Invocation::new("flatpak").args(["uninstall", &boxed.source])
497 + }
498 +
499 + /// A shell inside the app's sandbox, which is what "enter" means at this
500 + /// level: the same look around that `distrobox enter` gives, under the
Lines truncated