max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 file changed,
+310 insertions,
-41 deletions
| @@ -23,6 +23,22 @@ | |||
| 23 | 23 | //! decision — given software X, where does it go and what does that cost — and | |
| 24 | 24 | //! a store is separable from that. | |
| 25 | 25 | //! | |
| 26 | + | //! # The dial picks the backend | |
| 27 | + | //! | |
| 28 | + | //! A box's [`Level`] decides which backend implements it, never the reverse: | |
| 29 | + | //! | |
| 30 | + | //! | Level | Backend | Why that one | | |
| 31 | + | //! |---|---|---| | |
| 32 | + | //! | `host` | distrobox | host integration is the point, and `distrobox-export` puts binaries on the host PATH | | |
| 33 | + | //! | `workspace` | podman directly | distrobox has no flag that withholds the host home, the host filesystem paths, or the host D-Bus socket | | |
| 34 | + | //! | `sandboxed` | flatpak | portals, seccomp, and D-Bus proxying are a decade of work nobody should reimplement | | |
| 35 | + | //! | |
| 36 | + | //! Distrobox is a shell script over podman, so `workspace` is not a second | |
| 37 | + | //! runtime — it is the same one with the wrapper's opinions left off. The cost | |
| 38 | + | //! is that `workspace` has no `distrobox-export` and Alloy owes it a wrapper of | |
| 39 | + | //! its own, which is not written yet. See wiki `alloy-package-ux`, "Why | |
| 40 | + | //! `workspace` is podman directly". | |
| 41 | + | //! | |
| 26 | 42 | //! # Backends build commands, they do not run them | |
| 27 | 43 | //! | |
| 28 | 44 | //! [`Backend`] returns [`Invocation`] values and the view runs them through | |
| @@ -35,7 +51,7 @@ | |||
| 35 | 51 | //! | |
| 36 | 52 | //! <!-- wiki: alloy-package-ux --> | |
| 37 | 53 | ||
| 38 | - | use std::collections::BTreeMap; | |
| 54 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 39 | 55 | ||
| 40 | 56 | use alloy_tui::keys::{Action, classify}; | |
| 41 | 57 | use alloy_tui::{ | |
| @@ -131,6 +147,13 @@ | |||
| 131 | 147 | Running, | |
| 132 | 148 | /// A sandboxed box is one app; it is installed, not started and stopped. | |
| 133 | 149 | Installed, | |
| 150 | + | /// Declared in the spec but not on the system. | |
| 151 | + | /// | |
| 152 | + | /// The only state no backend reports, because it is the absence of anything | |
| 153 | + | /// for a backend to report on. It exists so the spec's promise and the | |
| 154 | + | /// machine's reality are read in one list: a declared box with no row would | |
| 155 | + | /// otherwise look exactly like a box that was never declared. | |
| 156 | + | Absent, | |
| 134 | 157 | Other(String), | |
| 135 | 158 | } | |
| 136 | 159 | ||
| @@ -139,17 +162,19 @@ | |||
| 139 | 162 | match self { | |
| 140 | 163 | BoxState::Running => "running", | |
| 141 | 164 | BoxState::Installed => "installed", | |
| 165 | + | BoxState::Absent => "not created", | |
| 142 | 166 | BoxState::Other(state) => state, | |
| 143 | 167 | } | |
| 144 | 168 | } | |
| 145 | 169 | ||
| 146 | 170 | /// Resting states are healthy. `Other` is every state that is neither the | |
| 147 | 171 | /// box running nor an app sitting installed — exited, paused, created — | |
| 148 | - | /// each of which is worth a glance. | |
| 172 | + | /// each of which is worth a glance. `Absent` is a gap between the spec and | |
| 173 | + | /// the system, which is the same kind of thing. | |
| 149 | 174 | const fn severity(&self) -> Severity { | |
| 150 | 175 | match self { | |
| 151 | 176 | BoxState::Running | BoxState::Installed => Severity::Healthy, | |
| 152 | - | BoxState::Other(_) => Severity::Warn, | |
| 177 | + | BoxState::Absent | BoxState::Other(_) => Severity::Warn, | |
| 153 | 178 | } | |
| 154 | 179 | } | |
| 155 | 180 | ||
| @@ -179,13 +204,45 @@ | |||
| 179 | 204 | /// Image reference for host and workspace, app id for sandboxed. | |
| 180 | 205 | pub source: String, | |
| 181 | 206 | pub state: BoxState, | |
| 182 | - | pub origin: Origin, | |
| 207 | + | /// The spec entry this row belongs to, if any. | |
| 208 | + | /// | |
| 209 | + | /// Carried rather than reduced to an [`Origin`] on the spot because the name | |
| 210 | + | /// is what says *which* declared box this is, and that is what lets the view | |
| 211 | + | /// work out which declared boxes have no row at all. | |
| 212 | + | pub declared: Option<String>, | |
| 183 | 213 | } | |
| 184 | 214 | ||
| 185 | 215 | impl Box { | |
| 186 | 216 | fn level_label(&self) -> &'static str { | |
| 187 | 217 | self.level.map_or("unknown", Level::label) | |
| 188 | 218 | } | |
| 219 | + | ||
| 220 | + | fn origin(&self) -> Origin { | |
| 221 | + | if self.declared.is_some() { | |
| 222 | + | Origin::Declared | |
| 223 | + | } else { | |
| 224 | + | Origin::AdHoc | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// A row for a declared box the system does not have. | |
| 229 | + | fn absent(name: &str, spec: &SpecBox) -> Self { | |
| 230 | + | Self { | |
| 231 | + | name: name.to_string(), | |
| 232 | + | level: Some(spec.level), | |
| 233 | + | // Whichever source field the level uses. A spec entry missing it is | |
| 234 | + | // shown anyway and reports the mismatch when creating, so a | |
| 235 | + | // half-written entry is visible rather than silently skipped. | |
| 236 | + | source: spec | |
| 237 | + | .image | |
| 238 | + | .as_deref() | |
| 239 | + | .or(spec.app.as_deref()) | |
| 240 | + | .unwrap_or("(no image or app)") | |
| 241 | + | .to_string(), | |
| 242 | + | state: BoxState::Absent, | |
| 243 | + | declared: Some(name.to_string()), | |
| 244 | + | } | |
| 245 | + | } | |
| 189 | 246 | } | |
| 190 | 247 | ||
| 191 | 248 | /// The declared boxes, read from the box spec. | |
| @@ -195,7 +252,15 @@ | |||
| 195 | 252 | /// correct reading of a system with no spec. | |
| 196 | 253 | #[derive(Debug, Default)] | |
| 197 | 254 | pub struct Spec { | |
| 198 | - | levels: BTreeMap<String, Level>, | |
| 255 | + | boxes: BTreeMap<String, SpecBox>, | |
| 256 | + | /// App id to declared name, so a sandboxed row finds its entry. | |
| 257 | + | /// | |
| 258 | + | /// A sandboxed box is identified to flatpak by its app id and to the user by | |
| 259 | + | /// its spec key, and those are different strings: `[box.chromium]` carries | |
| 260 | + | /// `app = "org.chromium.Chromium"`. Without this index a declared flatpak | |
| 261 | + | /// app reads as ad-hoc, because the row's only handle is the id and the spec | |
| 262 | + | /// is keyed by the name. | |
| 263 | + | by_app: BTreeMap<String, String>, | |
| 199 | 264 | } | |
| 200 | 265 | ||
| 201 | 266 | impl Spec { | |
| @@ -216,26 +281,42 @@ | |||
| 216 | 281 | ||
| 217 | 282 | fn parse(raw: &str) -> Result<Self> { | |
| 218 | 283 | let file: SpecFile = toml::from_str(raw).context("box spec is not valid TOML")?; | |
| 284 | + | let by_app = file | |
| 285 | + | .r#box | |
| 286 | + | .iter() | |
| 287 | + | .filter_map(|(name, spec)| Some((spec.app.clone()?, name.clone()))) | |
| 288 | + | .collect(); | |
| 219 | 289 | Ok(Self { | |
| 220 | - | levels: file | |
| 221 | - | .r#box | |
| 222 | - | .into_iter() | |
| 223 | - | .map(|(name, spec)| (name, spec.level)) | |
| 224 | - | .collect(), | |
| 290 | + | boxes: file.r#box, | |
| 291 | + | by_app, | |
| 225 | 292 | }) | |
| 226 | 293 | } | |
| 227 | 294 | ||
| 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() | |
| 295 | + | /// The spec entry a row belongs to, by declared name or by app id. | |
| 296 | + | /// | |
| 297 | + | /// Both handles resolve here so callers do not have to know which one they | |
| 298 | + | /// hold: podman rows key on the container name, flatpak rows on the app id, | |
| 299 | + | /// and the entry is the same either way. | |
| 300 | + | fn resolve(&self, key: &str) -> Option<(&str, &SpecBox)> { | |
| 301 | + | if let Some((name, spec)) = self.boxes.get_key_value(key) { | |
| 302 | + | return Some((name, spec)); | |
| 303 | + | } | |
| 304 | + | let name = self.by_app.get(key)?; | |
| 305 | + | Some((name, self.boxes.get(name)?)) | |
| 231 | 306 | } | |
| 232 | 307 | ||
| 233 | - | fn origin(&self, name: &str) -> Origin { | |
| 234 | - | if self.levels.contains_key(name) { | |
| 235 | - | Origin::Declared | |
| 236 | - | } else { | |
| 237 | - | Origin::AdHoc | |
| 238 | - | } | |
| 308 | + | /// The declared level for `key`, if it is in the spec. | |
| 309 | + | fn level(&self, key: &str) -> Option<Level> { | |
| 310 | + | Some(self.resolve(key)?.1.level) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | fn declared_name(&self, key: &str) -> Option<String> { | |
| 314 | + | Some(self.resolve(key)?.0.to_string()) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Every declared box, for finding the ones the system does not have. | |
| 318 | + | fn entries(&self) -> impl Iterator<Item = (&str, &SpecBox)> { | |
| 319 | + | self.boxes.iter().map(|(name, spec)| (name.as_str(), spec)) | |
| 239 | 320 | } | |
| 240 | 321 | } | |
| 241 | 322 | ||
| @@ -248,19 +329,60 @@ | |||
| 248 | 329 | Some(base.join("alloy").join("boxes.toml")) | |
| 249 | 330 | } | |
| 250 | 331 | ||
| 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. | |
| 332 | + | /// The box spec file. | |
| 333 | + | /// | |
| 334 | + | /// Unknown keys are ignored rather than rejected so a spec written against a | |
| 335 | + | /// later Alloy still yields its levels. `export` is parsed nowhere yet: at | |
| 336 | + | /// `host` it is `distrobox-export`'s job, and at `workspace` it is a wrapper | |
| 337 | + | /// Alloy has to write itself (wiki `alloy-package-ux`, "Why `workspace` is | |
| 338 | + | /// podman directly"), which is its own piece of work. | |
| 255 | 339 | #[derive(Deserialize, Default)] | |
| 256 | 340 | struct SpecFile { | |
| 257 | 341 | #[serde(default)] | |
| 258 | 342 | r#box: BTreeMap<String, SpecBox>, | |
| 259 | 343 | } | |
| 260 | 344 | ||
| 261 | - | #[derive(Deserialize)] | |
| 262 | - | struct SpecBox { | |
| 345 | + | /// One declared box. | |
| 346 | + | /// | |
| 347 | + | /// The level picks which source field applies — `image` for `host` and | |
| 348 | + | /// `workspace`, `app` for `sandboxed` — so both are optional here and the | |
| 349 | + | /// mismatch is reported when creating, naming the box. Rejecting the file at | |
| 350 | + | /// parse time would cost every other box its declared marker over one bad entry, | |
| 351 | + | /// and the inventory is the half of the screen that does not depend on the spec. | |
| 352 | + | #[derive(Debug, Deserialize)] | |
| 353 | + | pub struct SpecBox { | |
| 263 | 354 | level: Level, | |
| 355 | + | /// Image reference for `host` and `workspace`. | |
| 356 | + | image: Option<String>, | |
| 357 | + | /// App id for `sandboxed`. | |
| 358 | + | app: Option<String>, | |
| 359 | + | /// Host paths bind-mounted at the same path inside, `:ro` suffix supported. | |
| 360 | + | #[serde(default)] | |
| 361 | + | mounts: Vec<String>, | |
| 362 | + | /// Which flatpak remote to install from. | |
| 363 | + | /// | |
| 364 | + | /// Called `remote` rather than `origin` because [`Origin`] already means | |
| 365 | + | /// declared-versus-ad-hoc in this module, and flatpak's own noun for the | |
| 366 | + | /// thing you install from is `remote`. Absent lets flatpak resolve it, which | |
| 367 | + | /// is right when only one remote carries the app and an error worth seeing | |
| 368 | + | /// when several do. | |
| 369 | + | remote: Option<String>, | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | impl SpecBox { | |
| 373 | + | /// The image this box wants, or an error naming what the level requires. | |
| 374 | + | fn image(&self, name: &str) -> Result<&str> { | |
| 375 | + | self.image.as_deref().with_context(|| { | |
| 376 | + | format!("box `{name}` is {} and needs an `image`", self.level.label()) | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | /// The app id this box wants, or an error naming what the level requires. | |
| 381 | + | fn app(&self, name: &str) -> Result<&str> { | |
| 382 | + | self.app.as_deref().with_context(|| { | |
| 383 | + | format!("box `{name}` is {} and needs an `app`", self.level.label()) | |
| 384 | + | }) | |
| 385 | + | } | |
| 264 | 386 | } | |
| 265 | 387 | ||
| 266 | 388 | /// A source of boxes, and the commands that manage them. | |
| @@ -271,12 +393,29 @@ | |||
| 271 | 393 | /// The tool this fronts, for the view title. | |
| 272 | 394 | fn name(&self) -> &'static str; | |
| 273 | 395 | ||
| 396 | + | /// Whether this backend implements `level`. | |
| 397 | + | /// | |
| 398 | + | /// The dial runs one way — a level selects a backend, never the reverse — | |
| 399 | + | /// and this is that mapping. It is what lets a declared box with no row find | |
| 400 | + | /// the backend that would create it, since an absent box has nothing but its | |
| 401 | + | /// spec entry to go on. | |
| 402 | + | fn implements(&self, level: Level) -> bool; | |
| 403 | + | ||
| 274 | 404 | /// The command whose output [`Backend::parse`] reads. | |
| 275 | 405 | fn list(&self) -> Invocation; | |
| 276 | 406 | ||
| 277 | 407 | /// Turn that command's stdout into rows. | |
| 278 | 408 | fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>>; | |
| 279 | 409 | ||
| 410 | + | /// Build the box `name` describes in the spec. | |
| 411 | + | /// | |
| 412 | + | /// Fallible where the other verbs are not: the level decides which source | |
| 413 | + | /// field is required, and a spec entry that omits it cannot produce a | |
| 414 | + | /// command. The error names the box and what it is missing, which is the | |
| 415 | + | /// only place that mismatch can be reported usefully — parsing stays lenient | |
| 416 | + | /// so one bad entry does not cost every other box its declared marker. | |
| 417 | + | fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation>; | |
| 418 | + | ||
| 280 | 419 | /// Start `boxed`, or `None` when the concept does not apply. | |
| 281 | 420 | fn start(&self, boxed: &Box) -> Option<Invocation>; | |
| 282 | 421 | ||
| @@ -287,7 +426,7 @@ | |||
| 287 | 426 | fn remove(&self, boxed: &Box) -> Invocation; | |
| 288 | 427 | ||
| 289 | 428 | /// Open a shell in `boxed`. Handed to | |
| 290 | - | /// [`Flow::Suspend`](crate::shell::Flow::Suspend), since these want a real | |
| 429 | + | /// [`crate::shell::Flow::Suspend`], since these want a real | |
| 291 | 430 | /// TTY. | |
| 292 | 431 | fn enter(&self, boxed: &Box) -> Invocation; | |
| 293 | 432 | } | |
| @@ -319,6 +458,10 @@ | |||
| 319 | 458 | "podman" | |
| 320 | 459 | } | |
| 321 | 460 | ||
| 461 | + | fn implements(&self, level: Level) -> bool { | |
| 462 | + | matches!(level, Level::Host | Level::Workspace) | |
| 463 | + | } | |
| 464 | + | ||
| 322 | 465 | /// `--all` so stopped boxes stay visible. A box someone forgot they made is | |
| 323 | 466 | /// exactly what an inventory is for, and the default `ps` hides it. | |
| 324 | 467 | fn list(&self) -> Invocation { | |
| @@ -335,7 +478,7 @@ | |||
| 335 | 478 | let name = row.name(); | |
| 336 | 479 | Box { | |
| 337 | 480 | level: spec.level(&name), | |
| 338 | - | origin: spec.origin(&name), | |
| 481 | + | declared: spec.declared_name(&name), | |
| 339 | 482 | state: podman_state(&row.state), | |
| 340 | 483 | source: row.image, | |
| 341 | 484 | name, | |
| @@ -346,6 +489,61 @@ | |||
| 346 | 489 | Ok(boxes) | |
| 347 | 490 | } | |
| 348 | 491 | ||
| 492 | + | /// `host` goes through distrobox; `workspace` goes to podman directly. | |
| 493 | + | /// | |
| 494 | + | /// Settled in wiki `alloy-package-ux`, "Why `workspace` is podman directly". | |
| 495 | + | /// Distrobox has no flag that withholds the host home, the host filesystem | |
| 496 | + | /// paths, or the host D-Bus socket — host integration is what it is for — so | |
| 497 | + | /// it cannot express two of the six rows `workspace` promises. Podman states | |
| 498 | + | /// all six as flags, and since distrobox is a shell script over podman this | |
| 499 | + | /// is the same runtime with the wrapper's opinions left off. | |
| 500 | + | /// | |
| 501 | + | /// `create` rather than `run`: it leaves the box stopped, which is a state | |
| 502 | + | /// the list already shows and the `s` key already acts on, so creating and | |
| 503 | + | /// starting stay two commands the user can see rather than one that does | |
| 504 | + | /// two things. | |
| 505 | + | fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> { | |
| 506 | + | let image = spec.image(name)?; | |
| 507 | + | match spec.level { | |
| 508 | + | Level::Host => Ok(Invocation::new("distrobox").args([ | |
| 509 | + | "create", | |
| 510 | + | "--name", | |
| 511 | + | name, | |
| 512 | + | "--image", | |
| 513 | + | image, | |
| 514 | + | // Non-interactive: distrobox otherwise prompts before pulling, | |
| 515 | + | // and the console has handed it no terminal to prompt on. | |
| 516 | + | "--yes", | |
| 517 | + | ])), | |
| 518 | + | Level::Workspace => { | |
| 519 | + | // `--init` so stopping the box is immediate. The keep-alive | |
| 520 | + | // process below runs as PID 1, and PID 1 ignores SIGTERM unless | |
| 521 | + | // it installs a handler, so without an init every `s` keypress | |
| 522 | + | // waits the full ten-second timeout and then SIGKILLs. Measured | |
| 523 | + | // on fw13: ten seconds becomes none. It adds a process, not a | |
| 524 | + | // mount, so nothing about the level's isolation moves. | |
| 525 | + | let mut invocation = | |
| 526 | + | Invocation::new("podman").args(["create", "--name", name, "--init"]); | |
| 527 | + | // A private home that survives the container, so caches do too. | |
| 528 | + | // Named rather than a host path: the point of the level is that | |
| 529 | + | // the box does not reach into the host filesystem except where | |
| 530 | + | // it was told to. | |
| 531 | + | invocation = invocation | |
| 532 | + | .args(["--volume", &format!("{}:{WORKSPACE_HOME}", home_volume(name))]); | |
| 533 | + | for mount in &spec.mounts { | |
| 534 | + | invocation = invocation.args(["--volume", &bind(mount)?]); | |
| 535 | + | } | |
| 536 | + | // Nothing keeps a created container alive on its own, and the | |
| 537 | + | // box has to be there for `podman exec` to enter. Everything | |
| 538 | + | // else is podman's default, which is already what the level | |
| 539 | + | // asks for: network on, no devices, no host D-Bus, no mounts | |
| 540 | + | // beyond the ones above. | |
| 541 | + | Ok(invocation.args([image, "sleep", "infinity"])) | |
| 542 | + | } | |
| 543 | + | Level::Sandboxed => unreachable!("the dial routes sandboxed to flatpak"), | |
| 544 | + | } | |
| 545 | + | } | |
| 546 | + | ||
| 349 | 547 | fn start(&self, boxed: &Box) -> Option<Invocation> { | |
| 350 | 548 | (!boxed.state.is_running()) | |
| 351 | 549 | .then(|| Invocation::new("podman").args(["start", &boxed.name])) | |
| @@ -368,23 +566,63 @@ | |||
| 368 | 566 | Invocation::new("podman").args(["rm", "--force", &boxed.name]) | |
| 369 | 567 | } | |
| 370 | 568 | ||
| 371 | - | /// `distrobox enter` for boxes Alloy declared, a plain shell otherwise. | |
| 569 | + | /// `distrobox enter` for a `host` box, `podman exec` for everything else. | |
| 372 | 570 | /// | |
| 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. | |
| 571 | + | /// Branches on level rather than on origin, because level is what decides | |
| 572 | + | /// which tool made the box. Only `host` goes through distrobox, so only | |
| 573 | + | /// `host` answers to `distrobox enter`; a declared `workspace` box was made | |
| 574 | + | /// by podman and would fail it. An unknown level falls here too, which is | |
| 575 | + | /// right: a bare container never went through distrobox either. | |
| 378 | 576 | 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 | - | } | |
| 577 | + | match boxed.level { | |
| 578 | + | Some(Level::Host) => Invocation::new("distrobox").args(["enter", &boxed.name]), | |
| 579 | + | _ => Invocation::new("podman").args(["exec", "-it", &boxed.name, "/bin/sh"]), | |
| 384 | 580 | } | |
| 385 | 581 | } | |
| 386 | 582 | } | |
| 387 | 583 | ||
| 584 | + | /// Where a `workspace` box's private home lives inside the container. | |
| 585 | + | /// | |
| 586 | + | /// `/root` because a rootless podman container runs as root inside while mapping | |
| 587 | + | /// to the invoking user outside, so files it writes into a bind mount come out | |
| 588 | + | /// owned by the user on the host. Matching the host's own home path would gain | |
| 589 | + | /// nothing and would collide with a mount of that path. | |
| 590 | + | const WORKSPACE_HOME: &str = "/root"; | |
| 591 | + | ||
| 592 | + | /// The named volume backing a box's private home. | |
| 593 | + | fn home_volume(name: &str) -> String { | |
| 594 | + | format!("alloy-{name}-home") | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | /// A host path as a podman `--volume` argument, bound at the same path inside. | |
| 598 | + | /// | |
| 599 | + | /// Same path in and out so a directory the user names is the directory they see, | |
| 600 | + | /// and a `:ro` suffix passes through to podman, which spells read-only the same | |
| 601 | + | /// way. `~` is expanded here because the spec is hand-written and podman does no | |
| 602 | + | /// expansion of its own — an unexpanded `~` would silently create a directory | |
| 603 | + | /// with that literal name rather than mounting the home path meant. | |
| 604 | + | fn bind(mount: &str) -> Result<String> { | |
| 605 | + | let (path, mode) = match mount.strip_suffix(":ro") { | |
| 606 | + | Some(path) => (path, ":ro"), | |
| 607 | + | None => (mount, ""), | |
| 608 | + | }; | |
| 609 | + | ||
| 610 | + | let path = match path.strip_prefix("~/") { | |
| 611 | + | Some(rest) => { | |
| 612 | + | let home = std::env::var("HOME").context("`~` in a mount needs HOME set")?; | |
| 613 | + | format!("{home}/{rest}") | |
| 614 | + | } | |
| 615 | + | None => path.to_string(), | |
| 616 | + | }; | |
| 617 | + | ||
| 618 | + | // Podman rejects a relative source, but only once the command runs, and by | |
| 619 | + | // then the message is podman's rather than one naming the spec. | |
| 620 | + | if !path.starts_with('/') { | |
| 621 | + | anyhow::bail!("mount `{mount}` must be an absolute path or start with `~/`"); | |
| 622 | + | } | |
| 623 | + | Ok(format!("{path}:{path}{mode}")) | |
| 624 | + | } | |
| 625 | + | ||
| 388 | 626 | /// Map podman's state word onto [`BoxState`], keeping anything unrecognized. | |
| 389 | 627 | fn podman_state(raw: &str) -> BoxState { | |
| 390 | 628 | if raw == "running" { | |
| @@ -445,6 +683,10 @@ | |||
| 445 | 683 | "flatpak" | |
| 446 | 684 | } | |
| 447 | 685 | ||
| 686 | + | fn implements(&self, level: Level) -> bool { | |
| 687 | + | level == Level::Sandboxed | |
| 688 | + | } | |
| 689 | + | ||
| 448 | 690 | /// `--app` so runtimes stay out. A user has one row's worth of interest in | |
| 449 | 691 | /// Chromium and none in `org.freedesktop.Platform`, which is an | |
| 450 | 692 | /// implementation detail of the app above it. | |
| @@ -473,7 +715,10 @@ | |||
| 473 | 715 | level: Some(Level::Sandboxed), | |
| 474 | 716 | source: app.to_string(), | |
| 475 | 717 | state: BoxState::Installed, | |
| 476 | - | origin: spec.origin(app), | |
| 718 | + | // By app id, which is the only handle a flatpak row has. The | |
| 719 | + | // spec is keyed by the box name, so this goes through the | |
| 720 | + | // app index rather than a direct lookup. | |
| 721 | + | declared: spec.declared_name(app), | |
| 477 | 722 | }) | |
| 478 | 723 | }) | |
| 479 | 724 | .collect(); | |
| @@ -481,6 +726,21 @@ | |||
| 481 | 726 | Ok(boxes) | |
| 482 | 727 | } | |
| 483 | 728 | ||
| 729 | + | /// `flatpak install`, from the declared remote when the spec names one. | |
| 730 | + | /// | |
| 731 | + | /// Without a remote flatpak resolves the app across the ones configured, | |
| 732 | + | /// which is what should happen when only one carries it and an error worth | |
| 733 | + | /// seeing when several do. `--noninteractive` because the console has handed | |
| 734 | + | /// flatpak no terminal to ask on. | |
| 735 | + | fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation> { | |
| 736 | + | let app = spec.app(name)?; | |
| 737 | + | let mut invocation = Invocation::new("flatpak").args(["install", "--noninteractive"]); | |
| 738 | + | if let Some(remote) = &spec.remote { | |
| 739 | + | invocation = invocation.arg(remote); | |
| 740 | + | } | |
| 741 | + | Ok(invocation.arg(app)) | |
| 742 | + | } | |
| 743 | + | ||
| 484 | 744 | // A sandboxed box is one app. It is installed or it is not; there is no | |
| 485 | 745 | // container to start and stop, and offering the verbs would imply a | |
| 486 | 746 | // lifecycle that does not exist at this level. | |
| @@ -511,12 +771,25 @@ | |||
| 511 | 771 | /// declared first also puts the reproducible boxes where they are read first. | |
| 512 | 772 | fn sort_boxes(boxes: &mut [Box]) { | |
| 513 | 773 | boxes.sort_by(|a, b| { | |
| 514 | - | (a.origin != Origin::Declared) | |
| 515 | - | .cmp(&(b.origin != Origin::Declared)) | |
| 774 | + | (a.origin() != Origin::Declared) | |
| 775 | + | .cmp(&(b.origin() != Origin::Declared)) | |
| 516 | 776 | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) | |
| 517 | 777 | }); | |
| 518 | 778 | } | |
| 519 | 779 | ||
| 780 | + | /// The same order as [`sort_boxes`], applied across backends. | |
| 781 | + | /// | |
| 782 | + | /// Each backend sorts what it parsed, but the list the user reads is the | |
| 783 | + | /// concatenation of all of them plus the absent rows, and that concatenation is | |
| 784 | + | /// in backend-detection order until it is sorted again here. | |
| 785 | + | fn sort_rows(rows: &mut [Row]) { | |
| 786 | + | rows.sort_by(|a, b| { | |
| 787 | + | (a.boxed.origin() != Origin::Declared) | |
| 788 | + | .cmp(&(b.boxed.origin() != Origin::Declared)) |
Lines truncated