Skip to main content

max / alloy

Give the console verbs a module layer, seven files at once crates/alloy/src had no subdirectories: fourteen verb files, one per console screen, each stacking model, parser, backend and view in a single file that only shows those layers as separate at 2000 lines. The seven over budget now split along them. bluetooth was split first and alone, on purpose, so the other six copy one shape rather than inventing six. Each verb keeps src/<verb>.rs as a facade holding its //! header, private mod declarations and the re-exports; the layers go beneath. main.rs's flat mod list is unchanged apart from `mod size;`, and profile.rs is byte-identical, which is what makes these facades load-bearing rather than cosmetic. Every facade narrowed and none widened. Re-exporting the full list the sheets prescribed produced an unused-import warning per name, because rustc does lint an unused pub(crate) use and the workspace sets unused = "warn". So each verb re-exports only what is spelled outside it and the rest drop to pub(super). No path a caller uses moved. Captured command output moves out of the source and into testdata/, seven files, each referenced by exactly one include_str!. The split forced it: a const in one child cannot be shared with a sibling. format_size and its test relocate to a crate-level src/size.rs, which is why install reports one fewer test than its file held. The sbin_path seal is strengthened rather than loosened. It cuts each module at its trailing #[cfg(test)] to prove cli.rs is the crate's only Command::new, and it asserted the tail was exactly one test module. A facade ending `#[cfg(test)] mod fixtures;` is a second legal shape, so the check now walks the tail and demands every item below the cut be a cfg(test) module, with a should_panic test for code landing under a declaration. It scans 92 files where it scanned seven monoliths, and it now rejects a case it previously could not see. One incidental relaxation: the inline module no longer has to be named `tests`. 1247 tests at HEAD, 1248 now. The one addition is that seal test; a name-level diff in both directions shows none lost.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 00:03 UTC
Signed with PGP, not checked
Commit: e08476a3675a87d7db1aebdfba86bb9529e3e193
Parent: 9f21454
65 files changed, +25999 insertions, -3332 deletions
@@ -26,8 +26,9 @@
26 26 //! system shows that field.
27 27 //!
28 28 //! So this screen shows all five, raw, and then says in a sentence what the
29 - //! combination means and which key changes it. [`Standing`] is that mapping and
30 - //! is the substance of the verb. Everything else here is plumbing to feed it.
29 + //! combination means and which key changes it. [`Standing`](model::Standing) is
30 + //! that mapping and is the substance of the verb. Everything else here is
31 + //! plumbing to feed it.
31 32 //!
32 33 //! # Nothing is automatic
33 34 //!
@@ -44,7 +45,7 @@
44 45 //! the adapter, and they have different fixes: the first is `power on`, the
45 46 //! second is a laptop function key the console cannot press. Reading rfkill
46 47 //! alongside `show` is the difference between "press w" and a `power on` that
47 - //! fails with a D-Bus error nobody can act on. See [`Radio`].
48 + //! fails with a D-Bus error nobody can act on. See [`Radio`](model::Radio).
48 49 //!
49 50 //! # Scanning suspends rather than polling
50 51 //!
@@ -58,22 +59,17 @@
58 59 //!
59 60 //! <!-- wiki: alloy-console -->
60 61
61 - use anyhow::{Context, Result};
62 - use serde::Deserialize;
62 + mod backend;
63 + mod model;
64 + mod parse;
65 + mod view;
63 66
64 - use alloy_tui::keys::Action;
65 - use alloy_tui::{
66 - AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
67 - hint, text, unavailable,
68 - };
69 - use ratatui::Frame;
70 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
71 - use ratatui::layout::{Constraint, Layout, Rect};
72 - use ratatui::text::{Line, Span};
73 - use ratatui::widgets::{Paragraph, Wrap};
74 -
75 - use crate::cli::{CommandLog, Invocation};
76 - use crate::shell::{Confirm, Flow, View, block_title, truncate};
67 + // The verb's vocabulary stays inside the verb: `model`, `parse` and `backend`
68 + // are `pub(super)` throughout and are named by nothing outside this directory.
69 + // What crosses the boundary is the screen and the tab `main.rs` opens it on,
70 + // so that is what the facade carries, and `crate::bluetooth::BluetoothView` is
71 + // the path it was before the split.
72 + pub(crate) use view::{BluetoothView, Tab};
77 73
78 74 /// How long a scan runs before the terminal comes back.
79 75 ///
@@ -83,2226 +79,5 @@
83 79 /// discovering at all.
84 80 const SCAN_SECONDS: u32 = 15;
85 81
86 - // ---- the model ----
87 -
88 - /// The controller, from `bluetoothctl show`.
89 - #[derive(Debug, Clone, PartialEq, Eq)]
90 - pub(crate) struct Adapter {
91 - pub address: String,
92 - /// The controller's name, which is what a phone scanning for this machine
93 - /// sees. `Alias` rather than `Name`: alias is the settable one and is what
94 - /// bluez advertises when the two differ.
95 - pub alias: String,
96 - pub powered: bool,
97 - pub discoverable: bool,
98 - pub pairable: bool,
99 - pub discovering: bool,
100 - }
101 -
102 - /// rfkill's verdict on the Bluetooth radio.
103 - ///
104 - /// Separate from [`Adapter::powered`] because they answer different questions
105 - /// and only one of them the console can act on. A soft block is software and
106 - /// `rfkill unblock bluetooth` clears it. A hard block is a physical switch or a
107 - /// firmware key, and no command clears it, so saying "press w" there would be
108 - /// advice that cannot work.
109 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
110 - pub(crate) enum Radio {
111 - Unblocked,
112 - Soft,
113 - Hard,
114 - /// rfkill is absent, or lists no Bluetooth line. Reported as unknown rather
115 - /// than assumed unblocked: a missing answer is not a negative one.
116 - Unknown,
117 - }
118 -
119 - impl Radio {
120 - /// Why the adapter is down, phrased to be the whole of what the user needs
121 - /// to do next. `None` where the radio is not the reason.
122 - pub(crate) const fn blocker(self) -> Option<&'static str> {
123 - match self {
124 - // Named as a switch rather than as rfkill: the fix is a key on the
125 - // keyboard, and the tool that reported it is not the tool that
126 - // fixes it.
127 - Self::Hard => {
128 - Some("the radio is blocked by a hardware switch, which no command can clear")
129 - }
130 - Self::Soft => Some("the radio is soft-blocked (rfkill unblock bluetooth)"),
131 - Self::Unblocked | Self::Unknown => None,
132 - }
133 - }
134 - }
135 -
136 - /// One device bluez knows about, with every state field it keeps.
137 - #[derive(Debug, Clone, PartialEq, Eq)]
138 - pub(crate) struct Device {
139 - pub address: String,
140 - pub name: String,
141 - /// `public` or `random`. Kept because a random address is why a device can
142 - /// appear twice under two addresses, which is otherwise unexplainable.
143 - pub address_type: Option<String>,
144 - /// bluez's `Icon`: `audio-headset`, `input-mouse`, `phone`. Absent on
145 - /// plenty of BLE devices, which is itself worth showing.
146 - pub icon: Option<String>,
147 - pub paired: bool,
148 - pub bonded: bool,
149 - pub trusted: bool,
150 - pub blocked: bool,
151 - pub connected: bool,
152 - /// `Battery Percentage`, which bluez only publishes for a connected device
153 - /// that reports it.
154 - pub battery: Option<u8>,
155 - }
156 -
157 - /// What the five booleans add up to, and the whole point of the verb.
158 - ///
159 - /// Ordered by what decides the answer rather than by severity: blocked beats
160 - /// everything because nothing else on the screen works while it holds, and
161 - /// connected is reported before paired because it is the more immediate fact.
162 - /// Trusted is never folded away, in either direction, since it is the field the
163 - /// user came here without knowing about.
164 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
165 - pub(crate) enum Standing {
166 - /// bluez refuses this device outright.
167 - Blocked,
168 - /// Connected and trusted. What people mean when they say paired.
169 - Connected,
170 - /// Connected right now, and it will not come back on its own.
171 - ConnectedUntrusted,
172 - /// Keys exchanged and trusted, no link up at the moment.
173 - Trusted,
174 - /// Keys exchanged, not trusted, not connected. The state that reads as
175 - /// Bluetooth having forgotten the device.
176 - Paired,
177 - /// An address bluez has seen and holds no keys for.
178 - Seen,
179 - }
180 -
181 - impl Standing {
182 - /// Read the five fields.
183 - pub(crate) const fn of(device: &Device) -> Self {
184 - if device.blocked {
185 - Self::Blocked
186 - } else if device.connected {
187 - if device.trusted {
188 - Self::Connected
189 - } else {
190 - Self::ConnectedUntrusted
191 - }
192 - } else if device.paired {
193 - if device.trusted {
194 - Self::Trusted
195 - } else {
196 - Self::Paired
197 - }
198 - } else {
199 - Self::Seen
200 - }
201 - }
202 -
203 - /// The row label. Long enough to carry the trust half, because a row that
204 - /// says only "connected" is the lie this verb exists to stop telling.
205 - pub(crate) const fn label(self) -> &'static str {
206 - match self {
207 - Self::Blocked => "blocked",
208 - Self::Connected => "connected",
209 - Self::ConnectedUntrusted => "connected, untrusted",
210 - Self::Trusted => "trusted, not connected",
211 - Self::Paired => "paired, untrusted",
212 - Self::Seen => "not paired",
213 - }
214 - }
215 -
216 - pub(crate) const fn severity(self) -> Severity {
217 - match self {
218 - Self::Blocked => Severity::Error,
219 - Self::Connected => Severity::Healthy,
220 - // Warn rather than Healthy: it works now and will stop working
221 - // later, which is the case a color should catch.
222 - Self::ConnectedUntrusted | Self::Paired => Severity::Warn,
223 - Self::Trusted | Self::Seen => Severity::Info,
224 - }
225 - }
226 -
227 - /// What this combination means, and which key changes it.
228 - ///
229 - /// The text a user actually came for. Written as plain sentences rather
230 - /// than as field documentation, because the reader is someone whose mouse
231 - /// stopped working and not someone reading the bluez API.
232 - pub(crate) const fn explain(self) -> &'static str {
233 - match self {
234 - Self::Blocked => {
235 - "bluez is refusing this device. Blocked means it will not connect and will not \
236 - pair, and no other key on this screen will change that while it holds. Press b \
237 - to unblock it."
238 - }
239 - Self::Connected => {
240 - "Connected, and trusted, so bluez accepts it back on its own whenever it is in \
241 - range. This is the state most people mean by paired. Press c to disconnect."
242 - }
243 - Self::ConnectedUntrusted => {
244 - "Connected right now, and not trusted. bluez will not accept a connection it did \
245 - not ask for, so once this device goes out of range or the machine reboots, it \
246 - will not come back on its own. Press t to trust it."
247 - }
248 - Self::Trusted => {
249 - "Paired and trusted, so bluez accepts it whenever it is in range. No link is up \
250 - at the moment, which usually means the device is off or out of range. Press c \
251 - to connect it now."
252 - }
253 - Self::Paired => {
254 - "Paired, so the keys are exchanged, and not trusted, so bluez will not accept it \
255 - back on its own. This is the state that reads as Bluetooth having forgotten the \
256 - device. Press t to trust it, or c to connect it this once."
257 - }
258 - Self::Seen => {
259 - "bluez has seen this address and holds no keys for it. Press p to pair, which \
260 - exchanges keys and moves it to the known list."
261 - }
262 - }
263 - }
264 - }
265 -
266 - impl Device {
267 - pub(crate) const fn standing(&self) -> Standing {
268 - Standing::of(self)
269 - }
270 -
271 - /// A short word for the row, from bluez's icon.
272 - ///
273 - /// The icon is a freedesktop name (`audio-headset`, `input-mouse`), which
274 - /// is a fine thing to key off and a poor thing to show. Anything
275 - /// unrecognized is passed through rather than mapped to "device": the raw
276 - /// name is more informative than a shrug.
277 - pub(crate) fn kind(&self) -> &str {
278 - match self.icon.as_deref() {
279 - None => "unknown",
280 - Some("audio-headset" | "audio-headphones") => "headset",
281 - Some("audio-card") => "speaker",
282 - Some("input-mouse") => "mouse",
283 - Some("input-keyboard") => "keyboard",
284 - Some("input-gaming") => "gamepad",
285 - Some("input-tablet") => "tablet",
286 - Some(other) => other,
287 - }
288 - }
289 -
290 - /// Whether this is something audio would come out of.
291 - ///
292 - /// Gates the default-sink handoff. bluez's icon is the only classification
293 - /// available before connecting, and it is right often enough; a device that
294 - /// is wrong about it still gets the key, and PipeWire's answer (no matching
295 - /// sink) is the honest refusal.
296 - pub(crate) fn is_audio(&self) -> bool {
297 - self.icon
298 - .as_deref()
299 - .is_some_and(|icon| icon.starts_with("audio"))
300 - }
301 -
302 - /// The extra fact worth saying when the headline does not cover it.
303 - ///
304 - /// Paired without bonded means the keys were never written down, so the
305 - /// pairing does not survive a reboot. It is rare and it is invisible in
306 - /// every other tool, which is exactly the combination that earns a line.
307 - pub(crate) const fn caveat(&self) -> Option<&'static str> {
308 - if self.paired && !self.bonded {
309 - Some(
310 - "Paired but not bonded: the keys were not written to disk, so this pairing does \
311 - not survive a reboot.",
312 - )
313 - } else {
314 - None
315 - }
316 - }
317 -
318 - /// The PipeWire sink name prefix this device's audio would arrive under.
319 - ///
320 - /// bluez-to-PipeWire naming, measured rather than guessed: a device at
321 - /// `D2:0F:A1:0C:48:3F` becomes `bluez_output.D2_0F_A1_0C_48_3F.1`, with a
322 - /// trailing profile index that is not predictable. So this is a prefix and
323 - /// the caller matches on it.
324 - fn sink_prefix(&self) -> String {
325 - format!("bluez_output.{}", self.address.replace(':', "_"))
326 - }
327 - }
328 -
329 - // ---- backends ----
330 -
331 - /// Backends build argv and run nothing. The view executes through the log,
332 - /// which is what makes docs/CONSOLE.md's coverage promise structural rather
333 - /// than a habit.
334 - pub(crate) trait Backend {
335 - fn name(&self) -> &'static str;
336 -
337 - /// The controller. An error here is the whole screen's error: with no
338 - /// adapter there is nothing to list.
339 - fn adapter(&self, log: &mut CommandLog) -> Result<Adapter>;
340 -
341 - /// Every address bluez knows, as `(address, name)`. The state fields come
342 - /// from [`Backend::info`].
343 - fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>>;
344 -
345 - /// Everything bluez keeps about one device.
346 - fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device>;
347 -
348 - /// The radio, read without logging the command. rfkill is the console's own
349 - /// bookkeeping: the user asked about Bluetooth, not about rfkill, and the
350 - /// answer only ever appears as a phrase in the header.
351 - ///
352 - /// The log is still taken, for the one case that is not bookkeeping. A read
353 - /// that succeeds and names no Bluetooth line is a real answer of
354 - /// [`Radio::Unknown`] and stays quiet; a read that could not run at all is a
355 - /// fault, and the header phrase it produces reads identically. See
356 - /// [`radio_from`].
357 - fn radio(&self, _log: &mut CommandLog) -> Radio {
358 - Radio::Unknown
359 - }
360 -
361 - fn pair(&self, _device: &Device) -> Option<Invocation> {
362 - None
363 - }
364 -
365 - fn connect(&self, _device: &Device) -> Option<Invocation> {
366 - None
367 - }
368 -
369 - fn disconnect(&self, _device: &Device) -> Option<Invocation> {
370 - None
371 - }
372 -
373 - fn trust(&self, _device: &Device, _trusted: bool) -> Option<Invocation> {
374 - None
375 - }
376 -
377 - fn block(&self, _device: &Device, _blocked: bool) -> Option<Invocation> {
378 - None
379 - }
380 -
381 - /// Forget the device: drops the keys as well as the entry.
382 - fn remove(&self, _device: &Device) -> Option<Invocation> {
383 - None
384 - }
385 -
386 - fn power(&self, _on: bool) -> Option<Invocation> {
387 - None
388 - }
389 -
390 - /// A bounded discovery run, handed the terminal rather than captured.
391 - fn scan(&self) -> Option<Invocation> {
392 - None
393 - }
394 -
395 - /// List PipeWire's sinks, for finding the one a connected headset landed on.
396 - fn sinks(&self) -> Option<Invocation> {
397 - None
398 - }
399 -
400 - fn set_default_sink(&self, _sink: &str) -> Option<Invocation> {
401 - None
402 - }
403 - }
404 -
405 - /// Pick a backend: the real one when `bluetoothctl` answers, the mock
406 - /// otherwise.
407 - ///
408 - /// A `--version` probe rather than a `which` check, matching every other verb.
409 - /// Note that this probes the client and not the daemon: bluetoothctl answers
410 - /// `--version` with bluetoothd down. That is deliberate, because the daemon
411 - /// being down is a state the screen should report rather than one that should
412 - /// drop it to mock devices, and [`Backend::adapter`] is where it surfaces.
413 - pub(crate) fn detect() -> Box<dyn Backend> {
414 - if Invocation::new("bluetoothctl").arg("--version").probe() {
415 - Box::new(BluetoothCtl {
416 - pactl: Invocation::new("pactl").arg("--version").probe(),
417 - })
418 - } else {
419 - Box::new(Mock)
420 - }
421 - }
422 -
423 - pub(crate) struct BluetoothCtl {
424 - /// Whether the audio handoff is offered. Read once at construction, like
425 - /// `disk`'s udisks flag.
426 - pactl: bool,
427 - }
428 -
429 - impl BluetoothCtl {
430 - /// The pairing agent capability.
431 - ///
432 - /// `NoInputNoOutput` would suppress the passkey prompt entirely and pair
433 - /// silently, which is the wrong trade here: a device asking a human to
434 - /// confirm a number is the one moment Bluetooth's security model is
435 - /// visible, and suppressing it to save a keystroke is how pairing becomes
436 - /// magic. `KeyboardDisplay` says the terminal can both show and answer,
437 - /// which is true, because pairing suspends onto a real one.
438 - const AGENT: &'static str = "KeyboardDisplay";
439 -
440 - fn ctl() -> Invocation {
441 - Invocation::new("bluetoothctl")
442 - }
443 - }
444 -
445 - impl Backend for BluetoothCtl {
446 - fn name(&self) -> &'static str {
447 - "bluetoothctl"
448 - }
449 -
450 - fn adapter(&self, log: &mut CommandLog) -> Result<Adapter> {
451 - parse_show(&Self::ctl().arg("show").run(log)?)
452 - }
453 -
454 - fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>> {
455 - Ok(parse_device_list(&Self::ctl().arg("devices").run(log)?))
456 - }
457 -
458 - fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device> {
459 - parse_info(&Self::ctl().args(["info", address]).run(log)?)
460 - }
461 -
462 - fn radio(&self, log: &mut CommandLog) -> Radio {
463 - radio_from(rfkill_invocation().capture_quiet(), log)
464 - }
465 -
466 - fn pair(&self, device: &Device) -> Option<Invocation> {
467 - Some(Self::ctl().args(["--agent", Self::AGENT, "pair", &device.address]))
468 - }
469 -
470 - fn connect(&self, device: &Device) -> Option<Invocation> {
471 - Some(Self::ctl().args(["connect", &device.address]))
472 - }
473 -
474 - fn disconnect(&self, device: &Device) -> Option<Invocation> {
475 - Some(Self::ctl().args(["disconnect", &device.address]))
476 - }
477 -
478 - fn trust(&self, device: &Device, trusted: bool) -> Option<Invocation> {
479 - let verb = if trusted { "trust" } else { "untrust" };
480 - Some(Self::ctl().args([verb, &device.address]))
481 - }
482 -
483 - fn block(&self, device: &Device, blocked: bool) -> Option<Invocation> {
484 - let verb = if blocked { "block" } else { "unblock" };
485 - Some(Self::ctl().args([verb, &device.address]))
486 - }
487 -
488 - fn remove(&self, device: &Device) -> Option<Invocation> {
489 - Some(Self::ctl().args(["remove", &device.address]))
490 - }
491 -
492 - fn power(&self, on: bool) -> Option<Invocation> {
493 - Some(Self::ctl().args(["power", if on { "on" } else { "off" }]))
494 - }
495 -
496 - fn scan(&self) -> Option<Invocation> {
497 - Some(Self::ctl().args(["--timeout", &SCAN_SECONDS.to_string(), "scan", "on"]))
498 - }
499 -
500 - fn sinks(&self) -> Option<Invocation> {
501 - self.pactl
502 - .then(|| Invocation::new("pactl").args(["-f", "json", "list", "sinks"]))
503 - }
504 -
505 - fn set_default_sink(&self, sink: &str) -> Option<Invocation> {
506 - self.pactl
507 - .then(|| Invocation::new("pactl").args(["set-default-sink", sink]))
508 - }
509 - }
510 -
511 - /// Fixed sample state, for machines without bluez.
512 - ///
513 - /// The devices are this machine's real ones, reduced, and they keep the state
514 - /// that made the verb worth writing: paired, bonded, and not trusted. A mock
515 - /// that showed everything connected and trusted would demonstrate the one case
516 - /// the screen has nothing to teach about.
517 - pub(crate) struct Mock;
518 -
519 - impl Backend for Mock {
520 - fn name(&self) -> &'static str {
521 - "mock"
522 - }
523 -
524 - fn adapter(&self, log: &mut CommandLog) -> Result<Adapter> {
525 - log.record("# no bluetoothctl; showing a mock adapter", Severity::Warn);
526 - Ok(Adapter {
527 - address: "D8:B3:2F:BD:B8:78".to_string(),
528 - alias: "mock".to_string(),
529 - powered: true,
530 - discoverable: false,
531 - pairable: true,
Lines truncated
@@ -32,13 +32,13 @@
32 32 //! this file.
33 33 //! 2. **Every partition write confirms, naming what is on the volume.** Not the
34 34 //! command that would run and not "are you sure": the size, the label and
35 - //! the filesystem about to be destroyed. [`describe_loss`] is that sentence,
35 + //! the filesystem about to be destroyed. `describe_loss` in [`view`] is that sentence,
36 36 //! in one place so four prompts cannot drift.
37 37 //! 3. **Still nothing escalates.** The operations are not in `udisksctl`, whose
38 38 //! verbs are mount, unmount, unlock, lock, loop-setup, loop-delete,
39 39 //! power-off and smart-simulate. They are on the udisks2 D-Bus interfaces,
40 40 //! reached with `busctl call` so they stay argv and stay in the command log.
41 - //! See [`udisks_call`].
41 + //! See `udisks_call` in [`backend`].
42 42 //!
43 43 //! # Two tools, one screen
44 44 //!
@@ -61,7 +61,7 @@
61 61 //! removable bit says otherwise, because that bit means "the medium can leave
62 62 //! the drive" (a card reader, an optical drive) rather than "the drive can leave
63 63 //! the machine". Going by `RM` alone would put the one disk the user came here
64 - //! for on the wrong tab. So [`Drive::detachable`] is the flag OR a hot-plug
64 + //! for on the wrong tab. So [`Drive::detachable`](model::Drive::detachable) is the flag OR a hot-plug
65 65 //! transport, and the T9 is the fixture that pins it.
66 66 //!
67 67 //! # Nothing is filtered away
@@ -74,2507 +74,17 @@
74 74 //!
75 75 //! <!-- wiki: alloy-console -->
76 76
77 - use anyhow::{Context, Result};
78 - use serde::Deserialize;
79 -
80 - use alloy_tui::keys::Action;
81 - use alloy_tui::{
82 - AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, TextField,
83 - Theme, binding, hint, text, unavailable,
84 - };
85 - use ratatui::Frame;
86 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
87 - use ratatui::layout::{Constraint, Layout, Rect};
88 - use ratatui::style::{Modifier, Style};
89 - use ratatui::text::{Line, Span};
90 - use ratatui::widgets::{Paragraph, Wrap};
91 -
92 - use crate::cli::{CommandLog, Invocation};
93 - use crate::install::format_size;
94 - use crate::shell::{Confirm, Flow, View, block_title, truncate};
95 -
96 - // ---- the model ----
97 -
98 - /// The drive a volume sits on. Carried by value on each volume rather than
99 - /// referenced, because the list is small and a row needs its drive's identity to
100 - /// render at all.
101 - #[derive(Debug, Clone, PartialEq, Eq)]
102 - pub(crate) struct Drive {
103 - /// Whole-device path, which is what `udisksctl power-off` takes.
104 - pub path: String,
105 - pub model: Option<String>,
106 - /// The kernel's `RM` bit. Not the same question as [`Self::detachable`].
107 - pub removable: bool,
108 - /// `usb`, `nvme`, `sata`, `mmc`. `None` where lsblk cannot attribute it.
109 - pub transport: Option<String>,
110 - }
111 -
112 - /// Transports whose devices a person unplugs.
113 - ///
114 - /// `ieee1394` is here for completeness rather than from a measurement; the two
115 - /// that matter are usb and mmc, which is a Framework expansion card.
116 - const HOTPLUG_TRANSPORTS: [&str; 3] = ["usb", "mmc", "ieee1394"];
117 -
118 - impl Drive {
119 - /// Whether this drive is one a person unplugs.
120 - ///
121 - /// See the module docs: the `RM` bit alone gets an external USB SSD wrong,
122 - /// and that is the disk most likely to be the reason someone opened this
123 - /// screen.
124 - pub(crate) fn detachable(&self) -> bool {
125 - self.removable
126 - || self
127 - .transport
128 - .as_deref()
129 - .is_some_and(|tran| HOTPLUG_TRANSPORTS.contains(&tran))
130 - }
131 -
132 - fn model_or_dash(&self) -> &str {
133 - self.model.as_deref().unwrap_or("-")
134 - }
135 - }
136 -
137 - /// One mountable thing: a partition, or a whole disk carrying a filesystem
138 - /// directly, which is what a hybrid ISO written to a stick looks like.
139 - #[derive(Debug, Clone, PartialEq, Eq)]
140 - pub(crate) struct Volume {
141 - pub path: String,
142 - pub name: String,
143 - pub size: u64,
144 - /// `None` for a partition with no recognisable filesystem. Those are listed
145 - /// rather than dropped: a stick with an unformatted partition is a thing a
146 - /// user needs to see to understand why it will not mount.
147 - pub fstype: Option<String>,
148 - pub label: Option<String>,
149 - pub mountpoint: Option<String>,
150 - pub read_only: bool,
151 - /// lsblk's `TYPE`, kept verbatim rather than reduced to a bool.
152 - ///
153 - /// The partitioning surface needs `part` specifically, and the obvious
154 - /// shortcut (`kind != "disk"`) is wrong: a LUKS mapping is `crypt`, sits
155 - /// under a partition, and is not one. Deleting the thing a `crypt` row
156 - /// points at is a different operation from deleting a partition, so the
157 - /// distinction has to survive parsing.
158 - pub kind: String,
159 - pub drive: Drive,
160 - }
161 -
162 - /// Mounts the console will not offer to unmount.
163 - ///
164 - /// Not a security boundary, since udisks would refuse most of these anyway.
165 - /// It is about the refusal being legible: "/ holds the running system" said
166 - /// here beats udisks' own error arriving three seconds later with a D-Bus
167 - /// prefix on it.
168 - const SYSTEM_MOUNTS: [&str; 5] = ["/", "/boot", "/boot/efi", "/var", "/sysroot"];
169 -
170 - /// Why a volume cannot be mounted or unmounted right now.
171 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
172 - pub(crate) enum Blocked {
173 - /// No filesystem lsblk could name, so there is nothing to mount.
174 - NoFilesystem,
175 - /// Already mounted, so mounting again is not the action wanted.
176 - Mounted,
177 - /// Not mounted, so unmounting is not the action wanted.
178 - NotMounted,
179 - /// Part of the running system.
180 - System,
181 - /// On the disk the running system is installed to. Wider than
182 - /// [`Self::System`], which is about one volume: this refuses a partition
183 - /// that is itself idle because a sibling on the same disk is not.
184 - SystemDisk,
185 - /// Not a partition, so there is no partition to delete or resize. A whole
186 - /// disk carrying a filesystem directly, or a LUKS mapping.
187 - NotAPartition,
188 - /// Still mounted. Editing a partition under a live filesystem is how a
189 - /// mounted tree ends up pointing at bytes that moved.
190 - MountedForEdit,
191 - /// The device is read-only, so nothing can be written to it.
192 - ReadOnly,
193 - }
194 -
195 - impl Blocked {
196 - /// Phrased to follow the volume path, so the whole line reads as one
197 - /// sentence: "/dev/sdb1 has no filesystem to mount".
198 - pub(crate) const fn reason(self) -> &'static str {
199 - match self {
200 - Self::NoFilesystem => "has no filesystem to mount",
201 - Self::Mounted => "is already mounted",
202 - Self::NotMounted => "is not mounted",
203 - Self::System => "holds part of the running system",
204 - Self::SystemDisk => "is on the disk the running system boots from",
205 - Self::NotAPartition => "is not a partition",
206 - Self::MountedForEdit => "is mounted; unmount it first",
207 - Self::ReadOnly => "is read-only",
208 - }
209 - }
210 -
211 - /// Whether this refusal is absolute rather than a state the user can clear.
212 - ///
213 - /// The distinction is the whole reason the partitioning surface is safe:
214 - /// `MountedForEdit` says do something first, and [`Self::SystemDisk`] says
215 - /// this will never be offered. A refusal the user can argue with by
216 - /// pressing the key again is not a guard.
217 - pub(crate) const fn absolute(self) -> bool {
218 - matches!(self, Self::SystemDisk)
219 - }
220 - }
221 -
222 - impl Volume {
223 - /// Whether this volume is part of the running system.
224 - ///
225 - /// Two shapes, and the second was found by running the parser against this
226 - /// machine rather than by reasoning about it. lsblk reports active swap with
227 - /// a mountpoint of `[SWAP]`: a bracketed pseudo-use rather than a directory.
228 - /// It is genuinely in use and genuinely not unmountable, and without this it
229 - /// reads as an ordinary mounted filesystem sitting at a path, which the
230 - /// console would then offer to unmount. Any bracketed value is treated the
231 - /// same way, since the bracket is lsblk's own marker for "not a path".
232 - fn is_system(&self) -> bool {
233 - self.mountpoint
234 - .as_deref()
235 - .is_some_and(|at| at.starts_with('[') || SYSTEM_MOUNTS.contains(&at))
236 - }
237 -
238 - /// Why `m` would do nothing here, or `None` if it would mount.
239 - pub(crate) fn mount_blocker(&self) -> Option<Blocked> {
240 - if self.mountpoint.is_some() {
241 - Some(Blocked::Mounted)
242 - } else if self.fstype.is_none() {
243 - Some(Blocked::NoFilesystem)
244 - } else {
245 - None
246 - }
247 - }
248 -
249 - /// Why `u` would do nothing here, or `None` if it would unmount.
250 - ///
251 - /// System is reported ahead of not-mounted so a root filesystem says what it
252 - /// is rather than being described by whether it happens to be mounted.
253 - pub(crate) fn unmount_blocker(&self) -> Option<Blocked> {
254 - if self.is_system() {
255 - Some(Blocked::System)
256 - } else if self.mountpoint.is_none() {
257 - Some(Blocked::NotMounted)
258 - } else {
259 - None
260 - }
261 - }
262 -
263 - /// Whether this row is a partition in the partition table, as opposed to a
264 - /// whole disk carrying a filesystem or a LUKS mapping sitting on one.
265 - pub(crate) fn is_partition(&self) -> bool {
266 - self.kind == "part"
267 - }
268 -
269 - /// Why the partition under this row cannot be deleted or resized, ignoring
270 - /// the disk-level check the view adds on top.
271 - ///
272 - /// Ordered most-permanent first, so the reason a user is given is the one
273 - /// they cannot do anything about rather than the one they can. A LUKS
274 - /// mapping that is also mounted should say it is not a partition, because
275 - /// unmounting it will not make it into one.
276 - pub(crate) fn edit_blocker(&self) -> Option<Blocked> {
277 - if self.is_system() {
278 - Some(Blocked::System)
279 - } else if !self.is_partition() {
280 - Some(Blocked::NotAPartition)
281 - } else if self.read_only {
282 - Some(Blocked::ReadOnly)
283 - } else if self.mountpoint.is_some() {
284 - Some(Blocked::MountedForEdit)
285 - } else {
286 - None
287 - }
288 - }
289 -
290 - /// Why this volume cannot be formatted.
291 - ///
292 - /// Looser than [`Self::edit_blocker`] by exactly one rule: a whole disk can
293 - /// be formatted. Writing a filesystem straight onto `/dev/sdb` is what a
294 - /// user who wants one big volume with no partition table is asking for, and
295 - /// it is what a stick arrives from the factory as.
296 - pub(crate) fn format_blocker(&self) -> Option<Blocked> {
297 - match self.edit_blocker() {
298 - Some(Blocked::NotAPartition) if self.kind == "disk" => None,
299 - other => other,
300 - }
301 - }
302 -
303 - fn fstype_or_dash(&self) -> &str {
304 - self.fstype.as_deref().unwrap_or("-")
305 - }
306 -
307 - /// The label, or the drive's model where there is none. A stick with no
308 - /// filesystem label is far more recognisable as "SanDisk 3.2Gen1" than as an
309 - /// empty column.
310 - fn describe(&self) -> String {
311 - match self.label.as_deref() {
312 - Some(label) if !label.trim().is_empty() => label.to_string(),
313 - _ => self.drive.model_or_dash().to_string(),
314 - }
315 - }
316 -
317 - fn where_at(&self) -> &str {
318 - self.mountpoint.as_deref().unwrap_or("not mounted")
319 - }
320 - }
321 -
322 - // ---- backends ----
323 -
324 - /// Backends build argv and run nothing. The view executes through the log,
325 - /// which is what makes docs/CONSOLE.md's coverage promise structural rather
326 - /// than a habit.
327 - pub(crate) trait Backend {
328 - fn name(&self) -> &'static str;
329 -
330 - fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>>;
331 -
332 - fn mount(&self, _volume: &Volume) -> Option<Invocation> {
333 - None
334 - }
335 -
336 - fn unmount(&self, _volume: &Volume) -> Option<Invocation> {
337 - None
338 - }
339 -
340 - /// Powers off the whole drive the volume sits on, which is what "eject"
341 - /// means for something without a physical tray.
342 - fn eject(&self, _volume: &Volume) -> Option<Invocation> {
343 - None
344 - }
345 -
346 - /// Add a partition to the drive this volume sits on.
347 - ///
348 - /// `size` of 0 means "as large as the free space allows", which is udisks'
349 - /// own convention rather than one invented here.
350 - fn create_partition(&self, _drive: &Drive, _size: u64) -> Option<Invocation> {
351 - None
352 - }
353 -
354 - fn delete_partition(&self, _volume: &Volume) -> Option<Invocation> {
355 - None
356 - }
357 -
358 - fn format(&self, _volume: &Volume, _fstype: &str) -> Option<Invocation> {
359 - None
360 - }
361 -
362 - fn resize_partition(&self, _volume: &Volume, _size: u64) -> Option<Invocation> {
363 - None
364 - }
365 - }
366 -
367 - /// Filesystems the format action offers.
368 - ///
369 - /// Every one of these has its `mkfs` in the image, checked against the built
370 - /// rootfs. Offering a type whose tool is absent fails inside udisks with a
371 - /// message about a helper rather than about the choice the user made.
372 - ///
373 - /// Ordered by what a person formatting a removable drive actually wants:
374 - /// `exfat` for a large stick that has to be readable elsewhere, `vfat` for a
375 - /// small one and for an ESP, `ext4` for a Linux-only disk. `btrfs` and `xfs`
376 - /// follow for whole-disk use. `ntfs` is last and is here for interoperability
377 - /// rather than as a recommendation.
378 - pub(crate) const FILESYSTEMS: [&str; 6] = ["exfat", "vfat", "ext4", "btrfs", "xfs", "ntfs"];
379 -
380 - /// The udisks2 object path for a kernel device name.
381 - ///
382 - /// udisks escapes anything outside `[A-Za-z0-9]` as `_` followed by the byte in
383 - /// hex, so `dm-0` is `dm_2d0`. Every name this console sees today (`sdb1`,
384 - /// `nvme0n1p3`) passes through unchanged, which is exactly why the escaping is
385 - /// implemented rather than assumed away: the first device that needs it would
386 - /// otherwise produce a path that silently addresses nothing.
387 - fn udisks_path(name: &str) -> String {
388 - let mut escaped = String::with_capacity(name.len());
389 - for byte in name.bytes() {
390 - if byte.is_ascii_alphanumeric() {
391 - escaped.push(byte as char);
392 - } else {
393 - use std::fmt::Write as _;
394 - let _ = write!(escaped, "_{byte:02x}");
395 - }
396 - }
397 - format!("/org/freedesktop/UDisks2/block_devices/{escaped}")
398 - }
399 -
400 - /// A `busctl call` against udisks2, as argv.
401 - ///
402 - /// `busctl` rather than `udisksctl`, because udisksctl has no partition verbs
403 - /// at all: its whole command set is mount, unmount, unlock, lock, loop-setup,
404 - /// loop-delete, power-off and smart-simulate. The operations this surface needs
405 - /// exist only on the D-Bus interfaces, so the choice is between calling them
406 - /// and reaching for `sfdisk` under `run0`. Calling them keeps the property the
407 - /// verb was built on: udisks answers a session user through polkit, and nothing
408 - /// here escalates.
409 - ///
410 - /// `busctl` and not `gdbus`: both are in the image, and busctl is systemd's,
411 - /// which is already a hard dependency, while gdbus arrives with glib as a
412 - /// transitive one.
413 - fn udisks_call(name: &str, interface: &str, method: &str, args: &[&str]) -> Invocation {
414 - let mut invocation = Invocation::new("busctl").args([
415 - "call",
416 - "org.freedesktop.UDisks2",
417 - &udisks_path(name),
418 - &format!("org.freedesktop.UDisks2.{interface}"),
419 - method,
420 - ]);
421 - for arg in args {
422 - invocation = invocation.arg(*arg);
423 - }
424 - invocation
425 - }
426 -
427 - /// Pick a backend: the real one when `lsblk` answers, the mock otherwise.
428 - ///
429 - /// A `--version` probe rather than a `which` check, matching `net`, `mesh` and
430 - /// `install`.
431 - pub(crate) fn detect() -> Box<dyn Backend> {
432 - if Invocation::new("lsblk").arg("--version").probe() {
433 - Box::new(LsBlk {
434 - udisks: udisks_present(),
435 - })
436 - } else {
437 - Box::new(Mock)
438 - }
439 - }
440 -
441 - /// Whether udisksctl is here *and* its daemon is answering.
442 - ///
443 - /// `status` rather than `--version`, deliberately: udisksctl is a client and
444 - /// exits nonzero with "Error connecting to the udisks daemon" when udisksd is
445 - /// not running, which is exactly the state a bare container is in. Probing the
446 - /// binary alone would offer action keys that fail the moment they are pressed.
447 - fn udisks_present() -> bool {
448 - Invocation::new("udisksctl").arg("status").probe()
449 - }
450 -
451 - pub(crate) struct LsBlk {
452 - /// Whether the action keys are offered. Read once at construction: udisksd
453 - /// starting mid-session is not worth a probe on every frame.
454 - udisks: bool,
455 - }
456 -
457 - impl LsBlk {
458 - /// `-b` for bytes so the size arrives as a number to format, and an explicit
459 - /// column list because lsblk's default set carries neither `PATH` nor
460 - /// `TRAN`. `FSTYPE` and `LABEL` are what this verb adds over the column set
461 - /// `install` asks for.
462 - fn invocation() -> Invocation {
463 - Invocation::new("lsblk").args([
464 - "-J",
465 - "-b",
466 - "-o",
467 - "PATH,NAME,TYPE,SIZE,FSTYPE,LABEL,MOUNTPOINTS,RM,RO,TRAN,MODEL",
468 - ])
469 - }
470 - }
471 -
472 - impl Backend for LsBlk {
473 - fn name(&self) -> &'static str {
474 - "lsblk"
475 - }
476 -
477 - fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>> {
478 - parse_volumes(&Self::invocation().run(log)?)
479 - }
480 -
481 - fn mount(&self, volume: &Volume) -> Option<Invocation> {
482 - self.udisks
483 - .then(|| Invocation::new("udisksctl").args(["mount", "-b", volume.path.as_str()]))
484 - }
485 -
486 - fn unmount(&self, volume: &Volume) -> Option<Invocation> {
487 - self.udisks
488 - .then(|| Invocation::new("udisksctl").args(["unmount", "-b", volume.path.as_str()]))
489 - }
490 -
491 - fn eject(&self, volume: &Volume) -> Option<Invocation> {
492 - self.udisks.then(|| {
493 - Invocation::new("udisksctl").args(["power-off", "-b", volume.drive.path.as_str()])
494 - })
495 - }
496 -
497 - /// `offset` 0 with a size lets udisks place the partition in the first free
498 - /// region large enough; `size` 0 as well means the largest free region,
499 - /// whole. Neither number is computed here on purpose.
500 - ///
501 - /// The console could read partition starts out of `lsblk -o START` and work
502 - /// out the gaps itself, and then it would hold a second model of the
503 - /// partition table that can disagree with the one udisks is about to act
504 - /// on. Alignment, the GPT tail, and whatever an extended partition is doing
505 - /// are all places that model would be subtly wrong, and being wrong here
506 - /// means placing a partition over something. Ask for the shape and let the
507 - /// tool that owns the table decide where it lands.
508 - fn create_partition(&self, drive: &Drive, size: u64) -> Option<Invocation> {
509 - let name = drive.path.rsplit('/').next()?;
510 - self.udisks.then(|| {
511 - udisks_call(
512 - name,
513 - "PartitionTable",
514 - "CreatePartition",
515 - &["ttssa{sv}", "0", &size.to_string(), "", "", "0"],
516 - )
517 - })
518 - }
519 -
520 - fn delete_partition(&self, volume: &Volume) -> Option<Invocation> {
521 - self.udisks
522 - .then(|| udisks_call(&volume.name, "Partition", "Delete", &["a{sv}", "0"]))
523 - }
524 -
525 - fn format(&self, volume: &Volume, fstype: &str) -> Option<Invocation> {
526 - self.udisks
527 - .then(|| udisks_call(&volume.name, "Block", "Format", &["sa{sv}", fstype, "0"]))
528 - }
529 -
530 - fn resize_partition(&self, volume: &Volume, size: u64) -> Option<Invocation> {
531 - self.udisks.then(|| {
532 - udisks_call(
533 - &volume.name,
534 - "Partition",
535 - "Resize",
536 - &["ta{sv}", &size.to_string(), "0"],
537 - )
538 - })
539 - }
540 - }
541 -
542 - /// Fixed sample volumes, for machines without lsblk.
543 - pub(crate) struct Mock;
544 -
545 - impl Backend for Mock {
546 - fn name(&self) -> &'static str {
547 - "mock"
548 - }
549 -
550 - fn list(&self, log: &mut CommandLog) -> Result<Vec<Volume>> {
Lines truncated
@@ -12,15 +12,16 @@
12 12 //! from the summary: the last screen is the one that acts, and this is the
13 13 //! only screen of the six that asks nothing.
14 14 //!
15 - //! The install runs through [`Sequence`], so the frame keeps drawing for the
16 - //! minutes `bootc` takes.
15 + //! The install runs through [`Sequence`](crate::run::Sequence), so the frame
16 + //! keeps drawing for the minutes `bootc` takes.
17 17 //!
18 18 //! Two of the install's arguments cannot be written in advance, so the plan is
19 - //! [`Stage`]s rather than a flat list. bootc decides which partition holds the
20 - //! new root while it partitions, and names the ostree deployment after a
21 - //! checksum that does not exist until the deploy finishes. Both are discovered
22 - //! by running a command and reading its output, and the second discovery is
23 - //! only possible after the first one's result has been mounted.
19 + //! [`Stage`](crate::run::Stage)s rather than a flat list. bootc decides which
20 + //! partition holds the new root while it partitions, and names the ostree
21 + //! deployment after a checksum that does not exist until the deploy finishes.
22 + //! Both are discovered by running a command and reading its output, and the
23 + //! second discovery is only possible after the first one's result has been
24 + //! mounted.
24 25 //!
25 26 //! The deployment directory is the part worth understanding. An ostree system's
26 27 //! `/etc` is not at `<mount>/etc`; it is inside the deployment, at
@@ -56,9313 +57,22 @@
56 57 //!
57 58 //! <!-- wiki: alloy-console -->
58 59
59 - use std::sync::Arc;
60 - use std::sync::atomic::{AtomicBool, Ordering};
61 - use std::time::Duration;
62 -
63 - use alloy_tui::{AlloyBlock, AlloyList, Hint, Severity, Theme, hint, text};
64 - use anyhow::{Context, Result};
65 - use ratatui::Frame;
66 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
67 - use ratatui::layout::Rect;
68 - use ratatui::style::{Modifier, Style};
69 - use ratatui::text::{Line, Span};
70 - use ratatui::widgets::Paragraph;
71 - use serde::Deserialize;
72 - use sha_crypt::{PasswordHasher, ShaCrypt};
73 -
74 - use alloy_tui::{Cursor, FocusRing};
75 -
76 - use crate::cli::{CommandLog, Invocation, Secret};
77 - use crate::preseed::{ANSWERS, Preseed};
78 - use crate::profile::Profile;
79 - use crate::recovery;
80 - use crate::run::{Sequence, Stage};
81 - use crate::shell::{Confirm, Flow, TICK, View, block_title, truncate};
82 - use crate::system::{SecureBoot, secure_boot};
83 - use crate::wizard::Steps;
84 - use alloy_tui::TextField;
85 -
86 - /// The questions, in the order they are asked.
87 - ///
88 - /// The disk comes first because it is the one that can be wrong in a way
89 - /// nothing later recovers from, and because a user who cannot see their disk in
90 - /// the list should find that out before typing anything.
91 - /// Encryption sits after the account rather than beside the disk it applies to.
92 - /// Both screens that take a secret are then adjacent, so the passphrases are
93 - /// typed in one stretch, and the review comes last of the questions.
94 - ///
95 - /// Credits sits after the review, which puts a screen between the summary and
96 - /// the disk being erased. That is a side effect rather than the reason: the
97 - /// page belongs at the end because it is the one screen that is not a question,
98 - /// and a user who has just read what is about to run is the one most likely to
99 - /// look at what it is made of.
100 - const STEPS: [Step; 6] = [
101 - Step::Disk,
102 - Step::Hostname,
103 - Step::Account,
104 - Step::Encryption,
105 - Step::Summary,
106 - Step::Credits,
107 - ];
108 -
109 - /// Which question the wizard is on.
110 - ///
111 - /// An enum rather than a bare index so `render` and `handle` match on what is
112 - /// being asked instead of on a number, and so adding a step is a compiler error
113 - /// everywhere it needs to be handled.
114 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
115 - enum Step {
116 - Disk,
117 - Hostname,
118 - Account,
119 - Encryption,
120 - Summary,
121 - Credits,
122 - }
123 -
124 - impl Step {
125 - /// Short enough that the title fits 80 columns on the longest step.
126 - ///
127 - /// The title is `install (bootc), step 6 of 6: <label>`, which is 38
128 - /// columns before the label, so the budget is real but not tight. "review
129 - /// and install" was the longest and is now merely the second longest, since
130 - /// it stopped being the step that installs.
131 - const fn label(self) -> &'static str {
132 - match self {
133 - Self::Disk => "select a disk",
134 - Self::Hostname => "name this machine",
135 - Self::Account => "create your account",
136 - Self::Encryption => "encrypt the disk",
137 - Self::Summary => "review",
138 - Self::Credits => "credits",
139 - }
140 - }
141 -
142 - /// Whether this step takes typing, which decides if the shell keeps
143 - /// claiming `q` as quit while it is on screen.
144 - const fn types(self) -> bool {
145 - matches!(self, Self::Hostname | Self::Account | Self::Encryption)
146 - }
147 - }
148 -
149 - /// One line of the credits page, before a theme has been near it.
150 - ///
151 - /// Borrowed from the manifest, which is `'static`, so laying the page out costs
152 - /// no copies of strings that are about to be formatted anyway.
153 - #[derive(Debug, Clone, Copy)]
154 - enum Row {
155 - /// The gap between sections.
156 - Blank,
157 - /// A section heading.
158 - Title(&'static str),
159 - /// A project and the license it is under.
160 - Project {
161 - name: &'static str,
162 - license: &'static str,
163 - },
164 - /// Where to find it.
165 - Detail(&'static str),
166 - /// What it is doing here, when the name does not say it.
167 - Note(&'static str),
168 - }
169 -
170 - /// Which field of the account step has focus.
171 - ///
172 - /// Indices into a [`FocusRing`], named so the render and key paths agree about
173 - /// what slot 2 is.
174 - const FIELD_USERNAME: usize = 0;
175 - const FIELD_PASSWORD: usize = 1;
176 - const FIELD_CONFIRM: usize = 2;
177 - const FIELD_PUBKEY: usize = 3;
178 - const ACCOUNT_FIELDS: usize = 4;
179 -
180 - /// Which slot of the hostname step has focus.
181 - ///
182 - /// Two, because the step carries the machine's name and the one question that
183 - /// would otherwise have been a step of its own. Folding the timezone in here
184 - /// rather than after it is what keeps the wizard at four screens.
185 - const SLOT_HOSTNAME: usize = 0;
186 - const SLOT_TIMEZONE: usize = 1;
187 - const HOSTNAME_SLOTS: usize = 2;
188 -
189 - /// Which slot of the encryption step has focus.
190 - ///
191 - /// The checkbox leads because it decides whether the two fields under it mean
192 - /// anything, and a screen whose first slot is a field the answer may discard
193 - /// reads backwards.
194 - const SLOT_ENCRYPT: usize = 0;
195 - const FIELD_PASSPHRASE: usize = 1;
196 - const FIELD_PASSPHRASE_CONFIRM: usize = 2;
197 - const ENCRYPT_SLOTS: usize = 3;
198 -
199 - /// Longest username `useradd` accepts.
200 - const USERNAME_MAX: usize = 32;
201 -
202 - /// Width of the right-aligned label column on the form panes.
203 - ///
204 - /// Named because two things depend on it agreeing: the label itself, and the
205 - /// budget [`field_line`](InstallView::field_line) has left for a value that has
206 - /// to scroll under its caret.
207 - const LABEL_WIDTH: usize = 10;
208 -
209 - /// Check a username against what `useradd` will accept.
210 - ///
211 - /// Rules are the portable ones NAME_REGEX enforces on Fedora: start with a
212 - /// lowercase letter or underscore, then lowercase letters, digits, underscores
213 - /// or hyphens. Uppercase is rejected rather than folded, because a name that
214 - /// silently becomes something else is worse than one refused with a reason.
215 - ///
216 - /// `root` is refused separately: it exists already, so `useradd` would fail
217 - /// after the disk had been written, which is the worst possible moment to find
218 - /// out.
219 - fn validate_username(name: &str) -> Result<(), String> {
220 - if name.is_empty() {
221 - return Err("a username is required".into());
222 - }
223 - if name == "root" {
224 - return Err("root already exists; pick another name".into());
225 - }
226 - if name.chars().count() > USERNAME_MAX {
227 - return Err(format!("a username is at most {USERNAME_MAX} characters"));
228 - }
229 -
230 - let first = name.chars().next().unwrap_or_default();
231 - if !first.is_ascii_lowercase() && first != '_' {
232 - return Err("a username starts with a lowercase letter".into());
233 - }
234 -
235 - let allowed = |c: &char| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_' || *c == '-';
236 - if let Some(bad) = name.chars().find(|c| !allowed(c)) {
237 - return Err(format!("'{bad}' is not allowed here; use a-z, 0-9, _ or -"));
238 - }
239 - Ok(())
240 - }
241 -
242 - /// Check the password pair.
243 - ///
244 - /// No minimum length, deliberately. Every length rule is a policy invented by
245 - /// whoever wrote the installer, and a machine's owner choosing a short password
246 - /// on their own laptop is their call to make. What is checked is the pair
247 - /// agreeing, because a typo in a password nobody can see is the one mistake
248 - /// here that is both easy to make and impossible to recover from after reboot.
249 - fn validate_password(password: &str, confirm: &str) -> Result<(), String> {
250 - if password.is_empty() {
251 - return Err("a password is required".into());
252 - }
253 - if password != confirm {
254 - return Err("the passwords do not match".into());
255 - }
256 - Ok(())
257 - }
258 -
259 - /// Whether what the user typed back is the phrase that was enrolled.
260 - ///
261 - /// **Byte-exact, because the thing it stands in for is.** Lowercasing both
262 - /// sides and collapsing runs of whitespace is tempting, since the phrase is read
263 - /// off a screen and typed back at a console whose keymap may not be the user's,
264 - /// and refusing a correct transcription over a double space or a capital reads
265 - /// as arbitrary.
266 - ///
267 - /// That has the failure backwards. [`enroll_plan`] hands the phrase
268 - /// to `systemd-cryptenroll` as a password, and LUKS compares passwords as
269 - /// bytes: no case folding, no whitespace collapsing, leading and trailing
270 - /// spaces significant. So the lenient check accepted transcriptions that
271 - /// **cannot open the disk.** Someone who wrote the words down capitalised was
272 - /// told they had it right, and would find out otherwise on the one day they
273 - /// needed it, with no second chance and nothing on screen to look at.
274 - ///
275 - /// A confirmation gate is only worth having if passing it means what it looks
276 - /// like it means. This one exists to prove the user can get back in, so it has
277 - /// to be exactly the gate they will meet, generosity included. Being stricter
278 - /// than the real gate would be its own bug; being looser is this one.
279 - ///
280 - /// The generated phrase is already lowercase words joined by single spaces
281 - /// (see [`crate::recovery::phrase`]), so nothing about what is displayed
282 - /// changes. What changes is that typing it back differently is now refused
283 - /// here rather than at a LUKS prompt in a year.
284 - fn phrase_matches(expected: &str, typed: &str) -> bool {
285 - expected == typed
286 - }
287 -
288 - /// Check the disk passphrase pair.
289 - ///
290 - /// Separate from [`validate_password`] only so the complaints name the right
291 - /// thing: a screen that asks for a disk passphrase and answers "a password is
292 - /// required" is talking about a different field. The no-minimum stance above
293 - /// applies here for the same reason, and the pair still has to agree — more so,
294 - /// since a typo in this one is not recoverable by logging in.
295 - fn validate_passphrase(passphrase: &str, confirm: &str) -> Result<(), String> {
296 - if passphrase.is_empty() {
297 - return Err("a passphrase is required to encrypt the disk".into());
298 - }
299 - if passphrase != confirm {
300 - return Err("the passphrases do not match".into());
301 - }
302 - Ok(())
303 - }
304 -
305 - /// The hostname an install gets if the user does not change it.
306 - ///
307 - /// Matches `DEFAULT_HOSTNAME` in `usr/lib/os-release`, which is the value the
308 - /// Containerfile's identity step rewrites when a medium is minted with a name,
309 - /// so the installer's default and the image's own name cannot drift apart. Not
310 - /// `/etc/hostname`: see the identity step for why neither that file nor
311 - /// `/usr/lib/hostname` can carry a baked value out of a container build.
312 - const DEFAULT_HOSTNAME: &str = "alloy";
313 -
314 - /// Longest single hostname label, per RFC 1123.
315 - const HOSTNAME_MAX: usize = 63;
316 -
317 - /// Where a minted medium carries the public key it was baked with.
318 - ///
319 - /// Written by the Containerfile's identity step from the `ALLOY_SSH_KEY` build
320 - /// argument, and read by `etc/ssh/sshd_config.d/20-alloy-installer.conf` as the
321 - /// `installer` account's `AuthorizedKeysFile`. So on a headless install this
322 - /// file holds the key the operator is connected with right now.
323 - ///
324 - /// The installer reads it to seed the account step rather than growing a
325 - /// second place a key can come from. The two paths are one key store: baking
326 - /// is how a key reaches the medium, and this is how it reaches the machine the
327 - /// medium installs.
328 - const BAKED_KEY: &str = "/usr/lib/alloy/authorized_keys";
329 -
330 - /// The key a medium was minted with, if it carries a usable one.
331 - ///
332 - /// Path taken as an argument so the tests can point it at a file they wrote;
333 - /// [`InstallView::new`] passes [`BAKED_KEY`].
334 - ///
335 - /// Only the first key. `authorized_keys` is a multi-key format and sshd reads
336 - /// every line of it, but the identity step writes exactly one and a medium
337 - /// carrying several is not a shape anything here produces. Taking the first is
338 - /// then a choice between one key and a field the user cannot read to the end
339 - /// of, and a seed is meant to be checked on screen.
340 - ///
341 - /// Anything that does not validate is dropped rather than seeded. A field
342 - /// pre-filled with a line sshd will ignore is worse than an empty one: the
343 - /// summary would show a key, the install would plant it, and the machine would
344 - /// refuse the login anyway.
345 - fn baked_pubkey_at(path: &str) -> Option<String> {
346 - let contents = std::fs::read_to_string(path).ok()?;
347 - let key = contents
348 - .lines()
349 - .map(str::trim)
350 - .find(|line| !line.is_empty() && !line.starts_with('#'))?;
351 - (validate_pubkey(key).is_ok() && !key.is_empty()).then(|| key.to_string())
352 - }
353 -
354 - /// Check a hostname, returning why it is unacceptable.
355 - ///
356 - /// RFC 1123 rules for a single label, which is what `/etc/hostname` holds:
357 - /// letters, digits and hyphens, not starting or ending with a hyphen, at most
358 - /// 63 characters. Dots are refused rather than accepted as an FQDN, because
359 - /// `/etc/hostname` holding a dotted name makes `hostname -s` and `hostname -f`
360 - /// disagree, and the installer should not be the thing that sets that up.
361 - ///
362 - /// Returns the message shown to the user, so each case says what to do rather
363 - /// than that something is wrong.
364 - pub(crate) fn validate_hostname(name: &str) -> Result<(), String> {
365 - if name.is_empty() {
366 - return Err("a hostname is required".into());
367 - }
368 - if name.chars().count() > HOSTNAME_MAX {
369 - return Err(format!("a hostname is at most {HOSTNAME_MAX} characters"));
370 - }
371 - if name.contains('.') {
372 - return Err("a hostname cannot contain dots; use the short name".into());
373 - }
374 - if name.starts_with('-') || name.ends_with('-') {
375 - return Err("a hostname cannot start or end with a hyphen".into());
376 - }
377 - let allowed = |c: &char| c.is_ascii_alphanumeric() || *c == '-';
378 - if let Some(bad) = name.chars().find(|c| !allowed(c)) {
379 - return Err(format!("'{bad}' is not allowed here; use a-z, 0-9 or -"));
380 - }
381 - Ok(())
382 - }
383 -
384 - /// The key types OpenSSH will accept in an `authorized_keys` line.
385 - ///
386 - /// `ssh-dss` is deliberately absent: OpenSSH disabled DSA at runtime years ago
387 - /// and removed it outright in 9.8, so accepting one here would write a file the
388 - /// target's sshd ignores, which is indistinguishable from the installer having
389 - /// dropped the key.
390 - const PUBKEY_TYPES: [&str; 6] = [
391 - "ssh-ed25519",
392 - "sk-ssh-ed25519@openssh.com",
393 - "ssh-rsa",
394 - "ecdsa-sha2-nistp256",
395 - "ecdsa-sha2-nistp384",
396 - "ecdsa-sha2-nistp521",
397 - ];
398 -
399 - /// Check an SSH public key, returning why it is unacceptable.
400 - ///
401 - /// **Empty is acceptable.** The field is optional because a minted image carries
402 - /// the key already, so this is the recovery
403 - /// path rather than the happy one. What is not acceptable is a value that looks
404 - /// like a key and is not, because sshd's response to a malformed
405 - /// `authorized_keys` line is to ignore it silently, and the user finds out by
406 - /// being unable to log in.
407 - ///
408 - /// The case worth its own message is a **private** key. `id_ed25519` and
409 - /// `id_ed25519.pub` differ by four characters, the wrong one is the first
410 - /// completion in most shells, and a private key pasted into a field on screen in
411 - /// front of whoever is standing there is a real thing to have happen. Naming it
412 - /// is the difference between "that is the wrong file" and a generic rejection
413 - /// the user answers by pasting it again.
414 - pub(crate) fn validate_pubkey(key: &str) -> Result<(), String> {
415 - let key = key.trim();
416 - if key.is_empty() {
417 - return Ok(());
418 - }
419 -
420 - // Checked before the shape, because a private key fails the shape check too
421 - // and would otherwise get the unhelpful message.
422 - if key.starts_with("-----BEGIN") {
423 - return Err("that is a private key; paste the .pub file instead".into());
424 - }
425 -
426 - let mut parts = key.split_whitespace();
427 - let (Some(kind), Some(body)) = (parts.next(), parts.next()) else {
428 - return Err("a key is '<type> <base64>', as found in a .pub file".into());
429 - };
430 - if !PUBKEY_TYPES.contains(&kind) {
431 - return Err(format!("'{kind}' is not a key type openssh accepts"));
432 - }
433 - // The base64 body, not the whole line: a comment may hold anything, and
434 - // people put spaces and punctuation in them.
435 - if body.len() % 4 != 0 || !body.trim_end_matches('=').bytes().all(is_base64) {
436 - return Err("the key body is not valid base64; the paste may be truncated".into());
437 - }
438 - // Every accepted type encodes its own name at the front of the body, and
439 - // base64 maps the first three bytes of that to a fixed prefix. So a body
440 - // pasted from a different line than its type is catchable without decoding
441 - // anything: `ssh-ed25519` bodies begin `AAAAC3NzaC1lZDI1`, `ssh-rsa` bodies
442 - // `AAAAB3NzaC1yc2E`. Checking the shared `AAAA` alone is enough to catch a
443 - // body that is base64 of something else entirely, which is the realistic
444 - // paste error rather than a crafted mismatch.
445 - if !body.starts_with("AAAA") {
446 - return Err("that base64 is not an ssh key body".into());
447 - }
448 - Ok(())
449 - }
450 -
451 - /// A base64 digit, in the standard alphabet `authorized_keys` uses.
452 - fn is_base64(b: u8) -> bool {
453 - b.is_ascii_alphanumeric() || b == b'+' || b == b'/'
454 - }
455 -
456 - /// A key as a review screen should show it.
457 - ///
458 - /// The full body is 68 characters for ed25519 and nearer 400 for RSA, which on a
459 - /// summary would push every other row off the screen and still not be something
460 - /// anyone reads character by character. What a person checks is the type, the
461 - /// comment naming which machine it came from, and enough of the body to tell two
462 - /// keys apart, so that is what this keeps.
463 - fn pubkey_summary(key: &str) -> String {
464 - let mut parts = key.split_whitespace();
465 - let (Some(kind), Some(body)) = (parts.next(), parts.next()) else {
466 - return key.to_string();
467 - };
468 - // The tail rather than the head: every body of a given type starts with the
469 - // same encoded type name, so the first characters are the ones that do not
470 - // distinguish anything.
471 - let tail: String = body
472 - .chars()
473 - .skip(body.chars().count().saturating_sub(8))
474 - .collect();
475 - let comment = parts.collect::<Vec<_>>().join(" ");
476 - if comment.is_empty() {
477 - format!("{kind} …{tail}")
478 - } else {
479 - format!("{kind} …{tail} {comment}")
480 - }
481 - }
482 -
483 - // ---- what the installer has been told ----
484 -
485 - /// The answers collected so far.
486 - ///
487 - /// Separate from the view's own state (cursor position, error text) because
488 - /// this is what the final `bootc install to-disk` invocation is built from,
489 - /// and what the summary step reads back. Nothing else survives to the end.
490 - #[derive(Debug, Default)]
491 - pub(crate) struct Answers {
492 - /// Device path of the install target, e.g. `/dev/nvme0n1`.
493 - pub disk: Option<String>,
494 - /// Written to `/etc/hostname` on the installed system.
495 - pub hostname: Option<String>,
496 - /// The account created on the installed system.
497 - ///
498 - /// The password is deliberately not here. It stays in its
499 - /// [`TextField`] until the plan is built, which keeps the number of
500 - /// places holding a readable copy to one. See [`Secret`].
501 - pub username: Option<String>,
502 - /// The SSH public key authorized for that account, if one was given.
503 - ///
504 - /// Not a [`Secret`], unlike the password, and the distinction is the point:
505 - /// this half of a keypair is meant to be published. Holding it here rather
506 - /// than leaving it in its [`TextField`] is therefore fine, and the summary
507 - /// step needs to read it back to say whether the machine will be reachable.
508 - pub pubkey: Option<String>,
509 - /// Whether the install may ask the network where this machine is, to set
510 - /// the timezone from it.
511 - ///
512 - /// `false` unless the user ticked the box, and `Default` gives that for
513 - /// free. Off is the honest default because the lookup is not local: it
514 - /// sends this machine's address to a third party (see [`GEO_HOST`]), and an
515 - /// installer that did that unasked would be doing the thing Alloy exists
516 - /// not to do. Left off, the install sets no timezone at all and the system
517 - /// comes up UTC, which the hostname pane says on screen.
518 - pub locate_timezone: bool,
519 - /// Whether the install encrypts the disk with LUKS.
520 - ///
521 - /// The passphrase is deliberately not here, for the same reason the account
522 - /// password is not: it stays in its [`TextField`] until the plan is built.
523 - ///
524 - /// `Default` gives `false` here while the checkbox the user sees starts
525 - /// ticked, which is the one place in this struct where the derived default
526 - /// is not the offered one. It is safe only because the summary cannot be
527 - /// reached without passing the encryption step, and that step writes this
528 - /// field whichever way it went. Nothing should read it before then: an
529 - /// unset value here reads as "no encryption", which is the wrong way for
530 - /// this particular question to fail.
531 - pub encrypt: bool,
532 - }
Lines truncated
@@ -30,6 +30,7 @@
30 30 mod settings;
31 31 mod setup;
32 32 mod shell;
33 + mod size;
33 34 mod stale;
34 35 mod status;
35 36 mod store;
@@ -12,7 +12,7 @@
12 12 //!
13 13 //! All three tabs are live. The boxes tab fronts podman and flatpak; the
14 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
15 + //! read two ways ([`Status`](ostree::Status)). rpm-ostree does not exist on a non-Fedora
16 16 //! development box, so off an ostree system those two tabs say so rather than
17 17 //! showing an empty inventory that reads as a machine with nothing on it. The
18 18 //! `RPM_OSTREE` fixture was captured from a booted Alloy install, not invented:
@@ -29,7 +29,7 @@
29 29 //!
30 30 //! # The dial picks the backend
31 31 //!
32 - //! A box's [`Level`] decides which backend implements it, never the reverse:
32 + //! A box's [`Level`](model::Level) decides which backend implements it, never the reverse:
33 33 //!
34 34 //! | Level | Backend | Why that one |
35 35 //! |---|---|---|
@@ -40,4607 +40,37 @@
40 40 //! Distrobox is a shell script over podman, so `workspace` is not a second
41 41 //! runtime — it is the same one with the wrapper's opinions left off. The cost
42 42 //! is that `workspace` has no `distrobox-export`, so Alloy writes that wrapper
43 - //! itself: see [`wrapper`]. See also wiki `alloy-package-ux`, "Why `workspace`
43 + //! itself: see [`wrapper`](export::wrapper). See also wiki `alloy-package-ux`, "Why `workspace`
44 44 //! is podman directly".
45 45 //!
46 46 //! # Backends build commands, they do not run them
47 47 //!
48 - //! [`Backend`] returns [`Invocation`] values and the view runs them through
49 - //! [`CommandLog`]. docs/CONSOLE.md commits every action to being displayed as
48 + //! [`Backend`](backend::Backend) returns [`Invocation`](crate::cli::Invocation)
49 + //! values and the view runs them through [`CommandLog`](crate::cli::CommandLog). docs/CONSOLE.md commits every action to being displayed as
50 50 //! the argv it runs, so that property falls out structurally rather than by
51 51 //! remembering to log. It also means every backend is testable with no
52 - //! container runtime installed: [`Backend::parse`] is a pure function over
52 + //! container runtime installed: [`Backend::parse`](backend::Backend::parse) is a
53 + //! pure function over
53 54 //! captured output, which is the only part of this that can be checked on a
54 55 //! machine that is not the target.
55 56 //!
56 57 //! <!-- wiki: alloy-package-ux -->
57 58
58 - use std::collections::{BTreeMap, BTreeSet};
59 - use std::fmt::Write as _;
60 -
61 - use alloy_tui::keys::{Action, classify};
62 - use alloy_tui::{
63 - AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, Theme, hint, text,
64 - };
65 - use anyhow::{Context, Result};
66 - use ratatui::Frame;
67 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
68 - use ratatui::layout::{Constraint, Layout, Rect};
69 - use ratatui::text::{Line, Span};
70 - use serde::Deserialize;
71 -
72 - use crate::cli::{CommandLog, Effect, Invocation};
73 - use crate::component::{self, is_component};
74 - use crate::shell::{Confirm, Flow, View, block_title, truncate};
75 - use crate::stale::{self, Staleness};
76 -
77 - /// Ticks between background refreshes.
78 - ///
79 - /// Slower than `mesh`'s: a container's state changes when someone starts or
80 - /// stops it, which is a deliberate act, not a laptop lid closing. Every refresh
81 - /// spawns one process per backend.
82 - const POLL_TICKS: u64 = 10;
83 -
84 - /// How isolated a box is from the host. The user picks this; Alloy picks the
85 - /// backend that implements it.
86 - ///
87 - /// Level is the stable interface precisely so backends stay swappable — if a
88 - /// better sandbox appears, [`Level::Sandboxed`] repoints and nothing above it
89 - /// changes. It also means the trait never models an operation half its
90 - /// implementors cannot perform, because flatpak is never asked to do
91 - /// [`Level::Host`].
92 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
93 - #[serde(rename_all = "lowercase")]
94 - pub(crate) enum Level {
95 - /// Full home, devices, D-Bus, host integration.
96 - Host,
97 - /// Container-private home, explicit mounts only, network on, no devices,
98 - /// no host D-Bus.
99 - Workspace,
100 - /// Portal-mediated: file picker only, permission-gated devices.
101 - Sandboxed,
102 - }
103 -
104 - impl Level {
105 - /// The word shown in the level column.
106 - ///
107 - /// `workspace` is described everywhere as limiting blast radius, never as
108 - /// sandboxing. A rootless podman container with a bind mount is a speed
109 - /// bump against a careless dependency, not a boundary against an attacker,
110 - /// and `sandboxed` is the only level with a real isolation model behind it.
111 - /// Presenting three interchangeable degrees of safety would be the same
112 - /// failure this view exists to correct.
113 - const fn label(self) -> &'static str {
114 - match self {
115 - Level::Host => "host",
116 - Level::Workspace => "workspace",
117 - Level::Sandboxed => "sandboxed",
118 - }
119 - }
120 - }
121 -
122 - /// Whether a box is reproducible.
123 - ///
124 - /// Boxes made outside Alloy — a bare `podman run`, a direct `flatpak install` —
125 - /// are shown anyway, because an inventory that omits them lies and not lying is
126 - /// the entire point of the screen. The marker then does the reproducibility
127 - /// teaching for free: the boxes that survive a rebuild are visibly distinct
128 - /// from the ones that do not.
129 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
130 - pub(crate) enum Origin {
131 - /// Named in the box spec, so it can be rebuilt.
132 - Declared,
133 - /// Found on the system but not in the spec.
134 - AdHoc,
135 - }
136 -
137 - impl Origin {
138 - const fn label(self) -> &'static str {
139 - match self {
140 - Origin::Declared => "declared",
141 - Origin::AdHoc => "ad-hoc",
142 - }
143 - }
144 - }
145 -
146 - /// What a box is currently doing.
147 - ///
148 - /// `Other` keeps the backend's own word rather than collapsing it. Podman's
149 - /// state vocabulary gains members (`paused`, `stopping`, `exited`), and showing
150 - /// an unfamiliar one beats mapping it to "unknown", the same reasoning as
151 - /// `mesh` reporting `BackendState` verbatim.
152 - #[derive(Debug, Clone, PartialEq, Eq)]
153 - pub(crate) enum BoxState {
154 - Running,
155 - /// A sandboxed box is one app; it is installed, not started and stopped.
156 - Installed,
157 - /// Declared in the spec but not on the system.
158 - ///
159 - /// The only state no backend reports, because it is the absence of anything
160 - /// for a backend to report on. It exists so the spec's promise and the
161 - /// machine's reality are read in one list: a declared box with no row would
162 - /// otherwise look exactly like a box that was never declared.
163 - Absent,
164 - Other(String),
165 - }
166 -
167 - impl BoxState {
168 - fn label(&self) -> &str {
169 - match self {
170 - BoxState::Running => "running",
171 - BoxState::Installed => "installed",
172 - BoxState::Absent => "not created",
173 - BoxState::Other(state) => state,
174 - }
175 - }
176 -
177 - /// Resting states are healthy. `Other` is every state that is neither the
178 - /// box running nor an app sitting installed — exited, paused, created —
179 - /// each of which is worth a glance. `Absent` is a gap between the spec and
180 - /// the system, which is the same kind of thing.
181 - const fn severity(&self) -> Severity {
182 - match self {
183 - BoxState::Running | BoxState::Installed => Severity::Healthy,
184 - BoxState::Absent | BoxState::Other(_) => Severity::Warn,
185 - }
186 - }
187 -
188 - const fn is_running(&self) -> bool {
189 - matches!(self, BoxState::Running)
190 - }
191 - }
192 -
193 - /// One row in the boxes tab.
194 - ///
195 - /// Deliberately shadows `std::boxed::Box` inside this module. "Box" is the
196 - /// user's word for this thing — it is distrobox's own, and `alloy pkg box` is
197 - /// the verb — and the domain type earns the name here. The two places that want
198 - /// the pointer say `std::boxed::Box` explicitly.
199 - #[derive(Debug, Clone)]
200 - pub(crate) struct Box {
201 - pub name: String,
202 - /// Absent when the box was not made by Alloy and its backend does not imply
203 - /// a level.
204 - ///
205 - /// The mapping only runs one way: a level selects a backend, so flatpak
206 - /// rows are always [`Level::Sandboxed`] even when ad-hoc, while a bare
207 - /// podman container could be either `host` or `workspace` and Alloy does
208 - /// not know which. Guessing from its mounts would be a guess presented as a
209 - /// fact about isolation, which is the one thing this screen must not do.
210 - pub level: Option<Level>,
211 - /// Image reference for host and workspace, app id for sandboxed.
212 - pub source: String,
213 - pub state: BoxState,
214 - /// The spec entry this row belongs to, if any.
215 - ///
216 - /// Carried rather than reduced to an [`Origin`] on the spot because the name
217 - /// is what says *which* declared box this is, and that is what lets the view
218 - /// work out which declared boxes have no row at all.
219 - pub declared: Option<String>,
220 - /// Which flatpak installation the app lives in.
221 - ///
222 - /// Flatpak-only, and `None` everywhere else: podman has one place to put a
223 - /// container and rpm-ostree is not a backend here. It is carried on the row
224 - /// rather than assumed at removal time because the two installations hold
225 - /// different sets of apps, and `flatpak uninstall` addressed at the wrong
226 - /// one reports the app as not installed while it is sitting on the screen.
227 - pub scope: Option<Scope>,
228 - }
229 -
230 - /// A flatpak installation: the per-user one, or the system-wide one.
231 - ///
232 - /// Flatpak also supports named custom installations, which parse to `None`
233 - /// rather than to a member here. Alloy neither creates them nor knows their
234 - /// names, and inventing a third member would mean guessing a flag for one.
235 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
236 - pub(crate) enum Scope {
237 - User,
238 - System,
239 - }
240 -
241 - impl Scope {
242 - /// The flag that addresses this installation.
243 - const fn flag(self) -> &'static str {
244 - match self {
245 - Scope::User => "--user",
246 - Scope::System => "--system",
247 - }
248 - }
249 -
250 - /// Flatpak's own word for an installation, as the `installation` column
251 - /// prints it.
252 - fn parse(word: &str) -> Option<Self> {
253 - match word.trim() {
254 - "user" => Some(Scope::User),
255 - "system" => Some(Scope::System),
256 - _ => None,
257 - }
258 - }
259 - }
260 -
261 - impl Box {
262 - fn level_label(&self) -> &'static str {
263 - self.level.map_or("unknown", Level::label)
264 - }
265 -
266 - fn origin(&self) -> Origin {
267 - if self.declared.is_some() {
268 - Origin::Declared
269 - } else {
270 - Origin::AdHoc
271 - }
272 - }
273 -
274 - /// A row for a declared box the system does not have.
275 - fn absent(name: &str, spec: &SpecBox) -> Self {
276 - Self {
277 - name: name.to_string(),
278 - level: Some(spec.level),
279 - // Whichever source field the level uses. A spec entry missing it is
280 - // shown anyway and reports the mismatch when creating, so a
281 - // half-written entry is visible rather than silently skipped.
282 - source: spec
283 - .image
284 - .as_deref()
285 - .or(spec.app.as_deref())
286 - .unwrap_or("(no image or app)")
287 - .to_string(),
288 - state: BoxState::Absent,
289 - declared: Some(name.to_string()),
290 - // Nothing is installed, so there is no installation to name. A
291 - // sandboxed box gets one when it is created, and Alloy creates
292 - // them per-user.
293 - scope: None,
294 - }
295 - }
296 - }
297 -
298 - /// The declared boxes, read from the box spec.
299 - ///
300 - /// The spec is what makes [`Origin`] mean something: declared is "named in this
301 - /// file", not a heuristic. Absent file means every box is ad-hoc, which is the
302 - /// correct reading of a system with no spec.
303 - #[derive(Debug, Default)]
304 - pub(crate) struct Spec {
305 - boxes: BTreeMap<String, SpecBox>,
306 - /// App id to declared name, so a sandboxed row finds its entry.
307 - ///
308 - /// A sandboxed box is identified to flatpak by its app id and to the user by
309 - /// its spec key, and those are different strings: `[box.inkscape]` carries
310 - /// `app = "org.inkscape.Inkscape"`. Without this index a declared flatpak
311 - /// app reads as ad-hoc, because the row's only handle is the id and the spec
312 - /// is keyed by the name.
313 - by_app: BTreeMap<String, String>,
314 - }
315 -
316 - impl Spec {
317 - /// Read the spec, or an empty one if it is absent or unreadable.
318 - ///
319 - /// Failures are silent by design. A missing spec is the ordinary case, and
320 - /// a malformed one must not stop the view from showing the inventory —
321 - /// which is the half of the screen that does not depend on the file at all.
322 - pub(crate) fn load() -> Self {
323 - let Some(path) = spec_path() else {
324 - return Self::default();
325 - };
326 - let Ok(raw) = std::fs::read_to_string(path) else {
327 - return Self::default();
328 - };
329 - Self::parse(&raw).unwrap_or_default()
330 - }
331 -
332 - fn parse(raw: &str) -> Result<Self> {
333 - let file: SpecFile = toml::from_str(raw).context("box spec is not valid TOML")?;
334 - let by_app = file
335 - .r#box
336 - .iter()
337 - .filter_map(|(name, spec)| Some((spec.app.clone()?, name.clone())))
338 - .collect();
339 - Ok(Self {
340 - boxes: file.r#box,
341 - by_app,
342 - })
343 - }
344 -
345 - /// The spec entry a row belongs to, by declared name or by app id.
346 - ///
347 - /// Both handles resolve here so callers do not have to know which one they
348 - /// hold: podman rows key on the container name, flatpak rows on the app id,
349 - /// and the entry is the same either way.
350 - fn resolve(&self, key: &str) -> Option<(&str, &SpecBox)> {
351 - if let Some((name, spec)) = self.boxes.get_key_value(key) {
352 - return Some((name, spec));
353 - }
354 - let name = self.by_app.get(key)?;
355 - Some((name, self.boxes.get(name)?))
356 - }
357 -
358 - /// The declared level for `key`, if it is in the spec.
359 - fn level(&self, key: &str) -> Option<Level> {
360 - Some(self.resolve(key)?.1.level)
361 - }
362 -
363 - fn declared_name(&self, key: &str) -> Option<String> {
364 - Some(self.resolve(key)?.0.to_string())
365 - }
366 -
367 - /// Every declared box, for finding the ones the system does not have.
368 - fn entries(&self) -> impl Iterator<Item = (&str, &SpecBox)> {
369 - self.boxes.iter().map(|(name, spec)| (name.as_str(), spec))
370 - }
371 - }
372 -
373 - /// `~/.config/alloy/boxes.toml`, honoring `XDG_CONFIG_HOME`.
374 - fn spec_path() -> Option<std::path::PathBuf> {
375 - let base = match std::env::var_os("XDG_CONFIG_HOME") {
376 - Some(dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
377 - _ => std::path::PathBuf::from(std::env::var_os("HOME")?).join(".config"),
378 - };
379 - Some(base.join("alloy").join("boxes.toml"))
380 - }
381 -
382 - /// The box spec file.
383 - ///
384 - /// Unknown keys are ignored rather than rejected so a spec written against a
385 - /// later Alloy still yields its levels.
386 - #[derive(Deserialize, Default)]
387 - struct SpecFile {
388 - #[serde(default)]
389 - r#box: BTreeMap<String, SpecBox>,
390 - }
391 -
392 - /// One declared box.
393 - ///
394 - /// The level picks which source field applies — `image` for `host` and
395 - /// `workspace`, `app` for `sandboxed` — so both are optional here and the
396 - /// mismatch is reported when creating, naming the box. Rejecting the file at
397 - /// parse time would cost every other box its declared marker over one bad entry,
398 - /// and the inventory is the half of the screen that does not depend on the spec.
399 - #[derive(Debug, Deserialize)]
400 - pub(crate) struct SpecBox {
401 - level: Level,
402 - /// Image reference for `host` and `workspace`.
403 - image: Option<String>,
404 - /// App id for `sandboxed`.
405 - app: Option<String>,
406 - /// Host paths bind-mounted at the same path inside, `:ro` suffix supported.
407 - #[serde(default)]
408 - mounts: Vec<String>,
409 - /// Which flatpak remote to install from.
410 - ///
411 - /// Called `remote` rather than `origin` because [`Origin`] already means
412 - /// declared-versus-ad-hoc in this module, and flatpak's own noun for the
413 - /// thing you install from is `remote`. Absent lets flatpak resolve it, which
414 - /// is right when only one remote carries the app and an error worth seeing
415 - /// when several do.
416 - remote: Option<String>,
417 - /// Which of the box's binaries reach the host PATH.
418 - #[serde(default)]
419 - export: Export,
420 - }
421 -
422 - /// The `export` table: what a box puts on the host PATH.
423 - ///
424 - /// A table rather than a bare list because `bin` is not the only thing a box
425 - /// can export — `distrobox-export` also does `--app` for desktop entries — and
426 - /// a spec that spells `export = ["rg"]` today has nowhere to put the second
427 - /// kind tomorrow without breaking every file already written.
428 - #[derive(Debug, Default, Deserialize)]
429 - struct Export {
430 - #[serde(default)]
431 - bin: Vec<String>,
432 - }
433 -
434 - impl SpecBox {
435 - /// The image this box wants, or an error naming what the level requires.
436 - fn image(&self, name: &str) -> Result<&str> {
437 - self.image.as_deref().with_context(|| {
438 - format!(
439 - "box `{name}` is {} and needs an `image`",
440 - self.level.label()
441 - )
442 - })
443 - }
444 -
445 - /// The binaries this box exports, or an error saying it declares none.
446 - ///
447 - /// An error rather than an empty slice: `e` on a box with nothing to export
448 - /// would otherwise report success having done nothing, and the spec file is
449 - /// exactly where the user would go looking for why.
450 - fn bins(&self, name: &str) -> Result<&[String]> {
451 - if self.export.bin.is_empty() {
452 - anyhow::bail!("box `{name}` declares no `export.bin`");
453 - }
454 - Ok(&self.export.bin)
455 - }
456 -
457 - /// The app id this box wants, or an error naming what the level requires.
458 - fn app(&self, name: &str) -> Result<&str> {
459 - self.app
460 - .as_deref()
461 - .with_context(|| format!("box `{name}` is {} and needs an `app`", self.level.label()))
462 - }
463 - }
464 -
465 - /// A source of boxes, and the commands that manage them.
466 - ///
467 - /// Every method returns an [`Invocation`] rather than running one. See the
468 - /// module docs.
469 - pub(crate) trait Backend {
470 - /// The tool this fronts, for the view title.
471 - fn name(&self) -> &'static str;
472 -
473 - /// Whether this backend implements `level`.
474 - ///
475 - /// The dial runs one way — a level selects a backend, never the reverse —
476 - /// and this is that mapping. It is what lets a declared box with no row find
477 - /// the backend that would create it, since an absent box has nothing but its
478 - /// spec entry to go on.
479 - fn implements(&self, level: Level) -> bool;
480 -
481 - /// The command whose output [`Backend::parse`] reads.
482 - fn list(&self) -> Invocation;
483 -
484 - /// Turn that command's stdout into rows.
485 - fn parse(&self, raw: &str, spec: &Spec) -> Result<Vec<Box>>;
486 -
487 - /// Build the box `name` describes in the spec.
488 - ///
489 - /// Fallible where the other verbs are not: the level decides which source
490 - /// field is required, and a spec entry that omits it cannot produce a
491 - /// command. The error names the box and what it is missing, which is the
492 - /// only place that mismatch can be reported usefully — parsing stays lenient
493 - /// so one bad entry does not cost every other box its declared marker.
494 - fn create(&self, name: &str, spec: &SpecBox) -> Result<Invocation>;
495 -
496 - /// Put the box's declared binaries on the host PATH.
497 - ///
498 - /// The one verb whose two levels do genuinely different things, which is
499 - /// why it returns [`Effect`] rather than [`Invocation`]: `host` has
500 - /// `distrobox-export` to call, and `workspace` has no such tool because it
501 - /// is not distrobox, so Alloy writes the wrapper itself. Both still keep
502 - /// the "return, do not perform" rule, so both are testable here.
503 - ///
504 - /// A `Vec` because exporting three binaries is three effects, and the log
505 - /// should say so: one line per file that appeared rather than one line
506 - /// claiming an export happened. The same `Vec` carries the migration of any
507 - /// wrapper left loose in the shared directory, and the report that ends it.
508 - fn export(&self, name: &str, spec: &SpecBox) -> Result<Vec<Effect>>;
509 -
510 - /// Start `boxed`, or `None` when the concept does not apply.
511 - fn start(&self, boxed: &Box) -> Option<Invocation>;
512 -
513 - /// Stop `boxed`, or `None` when the concept does not apply.
514 - fn stop(&self, boxed: &Box) -> Option<Invocation>;
515 -
516 - /// Remove `boxed`. Always available: everything here can be deleted.
517 - fn remove(&self, boxed: &Box) -> Invocation;
518 -
Lines truncated
@@ -20,11 +20,12 @@
20 20 //! ## Two verbs, one view
21 21 //!
22 22 //! `alloy config <path>` is the same view entered with a file instead of a tab:
23 - //! [`Surface::File`], which draws the form and neither the tab bar nor the app
24 - //! list, since both would be one-item lists over a choice the command line
25 - //! already made. Everything below the chrome — the rows, the pick overlay, the
26 - //! editing, the save — is the code the tabs use, because a second render path
27 - //! for one file is how the two would start to disagree.
23 + //! [`Surface::File`](view::Surface::File), which draws the form and neither
24 + //! the tab bar nor the app list, since both would be one-item lists over a
25 + //! choice the command line already made. Everything below the chrome — the
26 + //! rows, the pick overlay, the editing, the save — is the code the tabs use,
27 + //! because a second render path for one file is how the two would start to
28 + //! disagree.
28 29 //!
29 30 //! It resolves the schema by the path the schema declares, not by the file's
30 31 //! name: two tools can both keep a `config.toml`. A file no schema targets is
@@ -41,3154 +42,19 @@
41 42 //!
42 43 //! <!-- wiki: alloy-settings -->
43 44
44 - use std::collections::BTreeSet;
45 - use std::path::{Path, PathBuf};
46 -
47 - use alloy_tui::keys::{Action, classify};
48 - use alloy_tui::{
49 - AlloyBlock, AlloyConnector, AlloyField, AlloyForm, AlloyList, AlloyPicker, AlloyTabs, Cursor,
50 - FieldKind, FocusRing, FormRow, Hint, PickRow, Severity, TextField, Theme, hint, layout,
51 - list_row_y, text,
52 - };
53 - use anyhow::Context as _;
54 - use ratatui::Frame;
55 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
56 - use ratatui::layout::{Constraint, Layout, Rect};
57 - use ratatui::text::{Line, Span};
58 - use ratatui::widgets::Paragraph;
59 - use toml::Value;
60 -
61 - use crate::bind::{Bind, FileBind};
62 - use crate::cli::{CommandLog, contract_home};
63 - use crate::editor::Editor;
64 - use crate::schema::{self, Field, Schema, Section, Syntax};
65 - use crate::shell::{Confirm, Flow, View};
66 - use crate::system::SystemBind;
67 -
68 - /// Extension every schema file carries.
69 - const SCHEMA_EXT: &str = "schema";
70 -
71 - /// A section with more fields than this starts folded.
72 - ///
73 - /// The number was set against rio's 29-slot colors group, which would otherwise
74 - /// have been two thirds of the form, so someone who came to change the font
75 - /// scrolled past every palette entry to reach it. Above the threshold the
76 - /// section is a single row that says what is inside; below it, folding a
77 - /// five-field section would hide it for no gain.
78 - ///
79 - /// Nothing shipped reaches it today: shop resolves its palette from a theme id,
80 - /// so the schema that replaced rio's has three fields and no sections at all.
81 - /// The threshold stays because the next schema in the catalog is as likely to
82 - /// be a yazi or a mako as it is to be another three-key file.
83 - const FOLD_ABOVE: usize = 8;
84 -
85 - // ---------------------------------------------------------------------------
86 - // The catalog
87 - // ---------------------------------------------------------------------------
88 -
89 - /// Where schema files are looked for, highest precedence first.
90 - fn search_path() -> Vec<PathBuf> {
91 - let mut dirs = Vec::new();
92 - if let Some(config) = config_home() {
93 - dirs.push(config.join("alloy").join("schemas"));
94 - }
95 - dirs.push(PathBuf::from("/usr/share/alloy/schemas"));
96 - // Build-from-source fallback, so `cargo run` in a fresh clone opens the
97 - // schemas the repo ships rather than an empty list.
98 - dirs.push(PathBuf::from(concat!(
99 - env!("CARGO_MANIFEST_DIR"),
100 - "/../../schemas"
101 - )));
102 - dirs
103 - }
104 -
105 - /// `$XDG_CONFIG_HOME`, or `~/.config`.
106 - ///
107 - /// The spec says a relative value is invalid and must be ignored rather than
108 - /// resolved against the working directory, which is why the absolute check is
109 - /// here. Same rule [`theme`](crate::theme) applies.
110 - fn config_home() -> Option<PathBuf> {
111 - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
112 - let path = PathBuf::from(xdg);
113 - if path.is_absolute() {
114 - return Some(path);
115 - }
116 - }
117 - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
118 - }
119 -
120 - fn home() -> Option<PathBuf> {
121 - std::env::var_os("HOME").map(PathBuf::from)
122 - }
123 -
124 - /// Resolve a schema's `target_path` against the environment.
125 - ///
126 - /// Two substitutions, both at the front: `~` for the home directory and
127 - /// `$XDG_CONFIG_HOME` for the config home. Schemas carry these unexpanded so
128 - /// that parsing one does not depend on who is running it, and a schema written
129 - /// on a machine with a custom `XDG_CONFIG_HOME` still describes the right file
130 - /// on one without.
131 - pub(crate) fn expand(target: &str) -> Option<PathBuf> {
132 - expand_in(target, config_home().as_deref(), home().as_deref())
133 - }
134 -
135 - /// [`expand`] against explicit directories.
136 - ///
137 - /// Split out so the substitution rules are testable without setting process
138 - /// environment: `set_var` is unsafe in a threaded test binary for good reason,
139 - /// and a test that changes `HOME` under the other tests is a flake waiting for
140 - /// a slow machine.
141 - fn expand_in(target: &str, config: Option<&Path>, home: Option<&Path>) -> Option<PathBuf> {
142 - if let Some(rest) = target.strip_prefix("$XDG_CONFIG_HOME/") {
143 - return Some(config?.join(rest));
144 - }
145 - if let Some(rest) = target.strip_prefix("~/") {
146 - return Some(home?.join(rest));
147 - }
148 - // Anything else must name itself. A relative path would resolve against
149 - // whatever directory the console happened to be started in.
150 - let path = PathBuf::from(target);
151 - path.is_absolute().then_some(path)
152 - }
153 -
154 - /// One entry in the Applications list: an app, and the form behind it.
155 - struct App {
156 - /// What the list shows. The tool, not the path: someone looking for shop's
157 - /// config is looking for shop.
158 - name: String,
159 - /// The `.schema` file this came from, named in the diagnostic when it is
160 - /// the thing that is broken.
161 - source: PathBuf,
162 - /// The file this app's form edits, resolved.
163 - ///
164 - /// `None` when the schema names no `target_path`, or names one that does
165 - /// not resolve on this machine — the same two cases that leave `state` an
166 - /// error, kept as a field of its own because `alloy config <path>` matches
167 - /// on it and cannot reach into a `Form` that was never built.
168 - target: Option<PathBuf>,
169 - /// The syntax the schema declared, kept so a form row can open the same
170 - /// file as text without re-reading the schema to find out how to colour it.
171 - syntax: Syntax,
172 - state: Result<Pane, String>,
173 - /// Whether the text pane is standing in for this app's form.
174 - ///
175 - /// Set only by the list-row route below, and it is what makes Esc mean
176 - /// "back to the form" there and "close the view" everywhere else. An app
177 - /// whose schema declares a syntax with no form engine is a text pane
178 - /// permanently, and Esc must not try to build it a form it never had.
179 - text_over_form: bool,
180 - }
181 -
182 - /// What an opened app shows: a form, or the file as text.
183 - ///
184 - /// The second is docs/CONSOLE.md's text-edit fallback, and it is a peer of the
185 - /// form rather than an error state. Two of the four routes into it are not
186 - /// failures at all — sway declares `syntax = "sway"` and has no form to fail at
187 - /// — so modelling it as a third arm of `Result` would have said "broken" about
188 - /// an app working exactly as designed.
189 - enum Pane {
190 - /// Boxed only because a `Form` is three times the size of an `Editor` and
191 - /// an unboxed pair would make every app in the catalog pay the larger one.
192 - Form(Box<Form>),
193 - Text(Editor),
194 - }
195 -
196 - impl Pane {
197 - /// Whether this pane is holding edits that are not on disk.
198 - fn dirty(&self) -> bool {
199 - match self {
200 - Self::Form(form) => form.bind.dirty(),
201 - Self::Text(editor) => editor.dirty(),
202 - }
203 - }
204 -
205 - /// The file or front the pane is over, for the pane title.
206 - fn origin(&self) -> String {
207 - match self {
208 - Self::Form(form) => form.bind.origin(),
209 - Self::Text(editor) => editor.path().display().to_string(),
210 - }
211 - }
212 -
213 - /// Whether a buffer inside this pane has the keyboard.
214 - fn typing(&self) -> bool {
215 - match self {
216 - Self::Form(form) => form.typing(),
217 - Self::Text(editor) => editor.editing(),
218 - }
219 - }
220 - }
221 -
222 - /// An opened app's form: the bind, and where the user is in it.
223 - ///
224 - /// Fold state and cursor live here rather than on the view so that leaving an
225 - /// app and coming back lands where you left, which matters most for the one
226 - /// app whose form is long enough to scroll.
227 - struct Form {
228 - bind: Box<dyn Bind>,
229 - /// Section paths that are currently collapsed.
230 - folded: BTreeSet<String>,
231 - cursor: Cursor,
232 - mode: Mode,
233 - /// A setter that came back asking for a password, waiting to be run again
234 - /// with an agent to ask through.
235 - ///
236 - /// Held rather than raised because [`commit`](Form::commit) is called from
237 - /// three keys and returns nothing: the form knows the command needs
238 - /// authorizing, and only [`View::handle`] can tell the shell to go and do
239 - /// it. Taken on the way out of the key, so it cannot outlive the keypress
240 - /// that produced it.
241 - ///
242 - /// Carries the argv as well, since a `Command` renders as a debug struct
243 - /// and the log pane shows commands the way a user would type them.
244 - reauth: Option<(String, std::process::Command)>,
245 - }
246 -
247 - /// What the form is doing with the row under the cursor.
248 - ///
249 - /// Modal rather than always-editable, per docs/CONSOLE.md. In `Navigate` the
250 - /// reserved keymap holds; in `Editing` the field owns every key, which is the
251 - /// obligation `classify` documents for a view with an active text input.
252 - enum Mode {
253 - Navigate,
254 - Editing {
255 - /// The row being edited. Held rather than read back off the cursor so
256 - /// that nothing which moves the cursor can move the edit with it.
257 - row: usize,
258 - buffer: TextField,
259 - /// Why the last Enter did not commit. The row stays in edit showing
260 - /// this rather than dropping the user's text on the floor.
261 - error: Option<String>,
262 - },
263 - /// An enum row, open on its pick overlay.
264 - ///
265 - /// A separate mode and not an `Editing` with a list attached: the two
266 - /// answer different questions. Editing asks what the value should be and a
267 - /// pick asks which of these it is, and only one of them can fail to parse.
268 - Picking {
269 - row: usize,
270 - /// The substring the choices are narrowed by.
271 - filter: TextField,
272 - /// Rides the *filtered* list, not the full one.
273 - cursor: Cursor,
274 - },
275 - }
276 -
277 - /// One line of a rendered form.
278 - enum Row<'a> {
279 - Section(&'a Section),
280 - Field(&'a Field),
281 - }
282 -
283 - impl App {
284 - /// Read every schema on the search path, nearest first.
285 - ///
286 - /// A directory that does not exist is skipped rather than reported: only
287 - /// one of the three is expected to exist on any given machine.
288 - fn catalog() -> Vec<Self> {
289 - let mut apps: Vec<Self> = Vec::new();
290 - for dir in search_path() {
291 - let Ok(entries) = std::fs::read_dir(&dir) else {
292 - continue;
293 - };
294 - let mut found: Vec<PathBuf> = entries
295 - .filter_map(Result::ok)
296 - .map(|entry| entry.path())
297 - .filter(|path| path.extension().is_some_and(|ext| ext == SCHEMA_EXT))
298 - .collect();
299 - // read_dir order is the filesystem's, which is not stable between
300 - // machines. The list a user sees should not be.
301 - found.sort();
302 - for path in found {
303 - let app = Self::open(&path);
304 - // Nearest wins: a user's own schema for a tool replaces the
305 - // one the image ships rather than listing the tool twice.
306 - if !apps.iter().any(|existing| existing.name == app.name) {
307 - apps.push(app);
308 - }
309 - }
310 - }
311 - apps
312 - }
313 -
314 - fn open(source: &Path) -> Self {
315 - // Falls back to the file's own name so a schema too broken to name its
316 - // tool still appears as something the user can recognise.
317 - let fallback = source.file_stem().map_or_else(
318 - || source.display().to_string(),
319 - |stem| stem.to_string_lossy().to_string(),
320 - );
321 -
322 - let schema = match Schema::load(source) {
323 - Ok(schema) => schema,
324 - // The schema is broken and the file it named may be perfectly
325 - // fine. docs/CONSOLE.md routes an unknown `schema_version` and an
326 - // unknown field type here, and both are failures of the schema
327 - // rather than of the config, so refusing to open the config would
328 - // punish the user for our authoring mistake. The header is read
329 - // again leniently for the one thing that matters, the path.
330 - Err(error) => {
331 - let reason = format!("{error:#}");
332 - let recovered = std::fs::read_to_string(source)
333 - .ok()
334 - .and_then(|text| Schema::recover(&text));
335 - let Some(recovered) = recovered else {
336 - return Self {
337 - name: fallback,
338 - source: source.to_path_buf(),
339 - target: None,
340 - syntax: Syntax::default(),
341 - state: Err(reason),
342 - text_over_form: false,
343 - };
344 - };
345 - let target = expand(&recovered.target_path);
346 - let state = target.as_deref().map_or_else(
347 - || Err(reason.clone()),
348 - |path| {
349 - Editor::open(path, recovered.syntax, Some(reason.clone())).map(Pane::Text)
350 - },
351 - );
352 - return Self {
353 - name: recovered.target_tool.unwrap_or(fallback),
354 - source: source.to_path_buf(),
355 - target,
356 - syntax: recovered.syntax,
357 - state,
358 - text_over_form: false,
359 - };
360 - }
361 - };
362 -
363 - let name = schema.header.target_tool.clone();
364 - let syntax = schema.header.syntax;
365 - let target = schema.header.target_path.as_deref().and_then(expand);
366 - let state = match (schema.header.target_path.as_deref(), &target) {
367 - (None, _) => Err("the schema does not say where the file lives".to_string()),
368 - (Some(declared), None) => Err(format!("cannot resolve `{declared}`")),
369 - // No form engine for this syntax, and the schema said so. Not a
370 - // diagnostic: this is the app appearing in the list with something
371 - // a person can edit, which is the whole point of declaring it.
372 - (Some(_), Some(path)) if !syntax.forms() => {
373 - Editor::open(path, syntax, None).map(Pane::Text)
374 - }
375 - // The schema is fine and the file is not. docs/CONSOLE.md routes
376 - // this to the same pane carrying the reason, which is strictly
377 - // better than the red paragraph it used to get: a config the bind
378 - // refused to parse is a config someone has to open to fix, and
379 - // until now the console could show it to them and not let them
380 - // touch it.
381 - (Some(_), Some(path)) => match FileBind::open(schema, path) {
382 - Ok(bind) => Ok(Pane::Form(Box::new(Form::new(Box::new(bind))))),
383 - Err(error) => {
384 - Editor::open(path, syntax, Some(format!("{error:#}"))).map(Pane::Text)
385 - }
386 - },
387 - };
388 - Self {
389 - name,
390 - source: source.to_path_buf(),
391 - target,
392 - syntax,
393 - state,
394 - text_over_form: false,
395 - }
396 - }
397 - }
398 -
399 - impl Form {
400 - fn new(bind: Box<dyn Bind>) -> Self {
401 - // Big sections start folded. Computed once, at open: a fold state that
402 - // recomputed itself would spring back open the moment a user closed a
403 - // small section.
404 - let folded = bind
405 - .sections()
406 - .iter()
407 - .filter(|section| {
408 - bind.fields()
409 - .iter()
410 - .filter(|field| in_section(&field.path, &section.path))
411 - .count()
412 - > FOLD_ABOVE
413 - })
414 - .map(|section| section.path.clone())
415 - .collect();
416 -
417 - let mut form = Self {
418 - bind,
419 - folded,
420 - cursor: Cursor::new(),
421 - mode: Mode::Navigate,
422 - reauth: None,
423 - };
424 - form.cursor.resize(form.rows().len());
425 - form
426 - }
427 -
428 - /// The visible rows: every section header, plus the fields of the open
429 - /// ones, plus any field no section claims.
430 - ///
431 - /// Rebuilt each frame rather than cached. It is a walk over a few dozen
432 - /// fields, and a cached copy is a second source of truth that has to be
433 - /// invalidated on every fold.
434 - fn rows(&self) -> Vec<Row<'_>> {
435 - let mut rows = Vec::new();
436 - for section in self.bind.sections() {
437 - rows.push(Row::Section(section));
438 - if self.folded.contains(&section.path) {
439 - continue;
440 - }
441 - rows.extend(
442 - self.bind
443 - .fields()
444 - .iter()
445 - .filter(|field| in_section(&field.path, &section.path))
446 - .map(Row::Field),
447 - );
448 - }
449 - // Fields outside every section go last, under no header. A schema that
450 - // declares no sections at all is the same case, and renders as a plain
451 - // list of fields.
452 - rows.extend(
453 - self.bind
454 - .fields()
455 - .iter()
456 - .filter(|field| {
457 - !self
458 - .bind
459 - .sections()
460 - .iter()
461 - .any(|section| in_section(&field.path, &section.path))
462 - })
463 - .map(Row::Field),
464 - );
465 - rows
466 - }
467 -
468 - /// Fold or unfold the section under the cursor. Returns whether it did.
469 - fn toggle_fold(&mut self) -> bool {
470 - let Some(index) = self.cursor.selected() else {
471 - return false;
472 - };
473 - let rows = self.rows();
474 - let Some(Row::Section(section)) = rows.get(index) else {
475 - return false;
476 - };
477 - let path = section.path.clone();
478 - drop(rows);
479 - if !self.folded.remove(&path) {
480 - self.folded.insert(path);
481 - }
482 - // Folding changes how many rows there are, and the cursor is riding
483 - // that list. Resizing after rather than before keeps the header the
484 - // user just folded under the cursor.
485 - let len = self.rows().len();
486 - self.cursor.resize(len);
487 - true
488 - }
489 -
490 - fn editing(&self) -> bool {
491 - matches!(self.mode, Mode::Editing { .. })
492 - }
493 -
494 - fn picking(&self) -> bool {
495 - matches!(self.mode, Mode::Picking { .. })
496 - }
497 -
498 - /// Whether the keyboard belongs to a text buffer rather than to the view.
499 - fn typing(&self) -> bool {
500 - self.editing() || self.picking()
501 - }
502 -
503 - /// The choices matching the current filter, as (raw value, label,
504 - /// description).
505 - ///
506 - /// Case-insensitive, and matched against the raw value as well as the
507 - /// label: a user who knows the value types the value, and a zone list where
508 - /// the two are the same string should not care which one is being searched.
509 - fn choices(&self) -> Vec<(&str, &str, Option<&str>)> {
510 - let Mode::Picking { row, filter, .. } = &self.mode else {
511 - return Vec::new();
512 - };
513 - let rows = self.rows();
514 - let Some(Row::Field(field)) = rows.get(*row) else {
515 - return Vec::new();
516 - };
517 - let schema::FieldKind::Enum { values, .. } = &field.kind else {
518 - return Vec::new();
519 - };
520 - let needle = filter.value().to_lowercase();
521 - values
522 - .iter()
523 - .filter(|choice| {
Lines truncated
@@ -54,1827 +54,26 @@
54 54 //!
55 55 //! <!-- wiki: alloy-console -->
56 56
57 - use std::collections::HashMap;
58 -
59 - use alloy_tui::{
60 - AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, Severity, TextField, Theme, hint,
61 - layout, text,
62 - };
63 - use anyhow::Result;
64 - use ratatui::Frame;
65 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
66 - use ratatui::layout::{Constraint, Layout, Rect};
67 - use ratatui::style::{Modifier, Style};
68 - use ratatui::text::{Line, Span};
69 - use serde::Deserialize;
70 -
71 - use crate::cli::{CommandLog, Invocation};
72 - use crate::shell::{Confirm, Flow, View, block_title, truncate};
73 -
74 - /// Ticks between background refreshes.
75 - ///
76 - /// Matching [`mesh`](crate::mesh): a folder finishing a scan or a peer
77 - /// connecting happens on the scale of seconds-to-minutes, and three processes
78 - /// per second to learn nothing would be worse than a clock that is five
79 - /// seconds stale.
80 - const POLL_TICKS: u64 = 5;
81 -
82 - /// The user unit enrollment enables.
83 - const UNIT: &str = "syncthing.service";
84 -
85 - // ---------------------------------------------------------------------------
86 - // What the screen shows
87 - // ---------------------------------------------------------------------------
88 -
89 - /// A synchronized directory.
90 - #[derive(Debug, Clone, PartialEq, Eq)]
91 - pub(crate) struct Folder {
92 - pub id: String,
93 - /// Human label, falling back to the id when unset. Syncthing allows an
94 - /// empty label and shows the id in that case; so does this.
95 - pub label: String,
96 - pub path: String,
97 - /// `sendreceive`, `sendonly`, `receiveonly`, as Syncthing spells them.
98 - pub kind: String,
99 - pub paused: bool,
100 - /// How many devices this folder is shared with, this machine included.
101 - pub shared_with: usize,
102 - }
103 -
104 - impl Folder {
105 - fn severity(&self) -> Severity {
106 - if self.paused {
107 - Severity::Warn
108 - } else {
109 - Severity::Healthy
110 - }
111 - }
112 -
113 - fn state_label(&self) -> &'static str {
114 - if self.paused { "paused" } else { "syncing" }
115 - }
116 - }
117 -
118 - /// A peer this machine syncs with, or this machine itself.
119 - #[derive(Debug, Clone, PartialEq, Eq)]
120 - pub(crate) struct Device {
121 - pub id: String,
122 - pub name: String,
123 - pub paused: bool,
124 - /// Connected right now. Always false for this machine, which does not
125 - /// connect to itself, so [`Device::state_label`] special-cases it.
126 - pub connected: bool,
127 - pub is_self: bool,
128 - }
129 -
130 - impl Device {
131 - fn severity(&self) -> Severity {
132 - // Info rather than a health colour for this machine: it is a
133 - // notable row, not a good or bad one. Matches how `mesh` tints the
134 - // exit node.
135 - if self.is_self {
136 - return Severity::Info;
137 - }
138 - // Paused and disconnected share a tint and differ in label, as
139 - // online/offline do in `mesh`. The distinction worth colouring is
140 - // "reaching this device" against "not", and neither is reaching it.
141 - if self.paused || !self.connected {
142 - return Severity::Warn;
143 - }
144 - Severity::Healthy
145 - }
146 -
147 - fn state_label(&self) -> &'static str {
148 - if self.is_self {
149 - return "this machine";
150 - }
151 - if self.paused {
152 - return "paused";
153 - }
154 - if self.connected {
155 - "connected"
156 - } else {
157 - "disconnected"
158 - }
159 - }
160 -
161 - /// The first block of the device id, which is how Syncthing's own UI
162 - /// abbreviates it and enough to tell two devices apart by eye.
163 - fn short_id(&self) -> &str {
164 - self.id.split('-').next().unwrap_or(&self.id)
165 - }
166 - }
167 -
168 - /// A device that has tried to connect and is not configured here yet.
169 - ///
170 - /// This is how a pairing actually completes: the other machine adds this one,
171 - /// dials it, and lands here waiting to be let in. Without this list the answer
172 - /// to "I added you, now what" is the web UI, which is the interface
173 - /// docs/CONTINUITY.md says `alloy sync` exists to replace.
174 - #[derive(Debug, Clone, PartialEq, Eq)]
175 - pub(crate) struct PendingDevice {
176 - pub id: String,
177 - /// The name the other machine advertises for itself. Syncthing falls back
178 - /// to a hex string when the device has no name set, and so does this.
179 - pub name: String,
180 - /// Where it dialled from, as `host:port`.
181 - pub address: String,
182 - /// When it last tried, as Syncthing's RFC 3339 timestamp.
183 - pub time: String,
184 - }
185 -
186 - impl PendingDevice {
187 - /// Same abbreviation the configured device rows use.
188 - fn short_id(&self) -> &str {
189 - self.id.split('-').next().unwrap_or(&self.id)
190 - }
191 - }
192 -
193 - /// The date out of an RFC 3339 timestamp.
194 - ///
195 - /// Only the day, as `mesh` does with last-seen: the minute a device first
196 - /// knocked is not a thing anyone acts on, and a full timestamp would crowd the
197 - /// row that carries the address.
198 - fn day(time: &str) -> &str {
199 - time.split('T').next().unwrap_or(time)
200 - }
201 -
202 - /// Everything one refresh gathers.
203 - #[derive(Debug, Clone, Default, PartialEq, Eq)]
204 - pub(crate) struct SyncState {
205 - pub folders: Vec<Folder>,
206 - pub devices: Vec<Device>,
207 - pub pending: Vec<PendingDevice>,
208 - }
209 -
210 - /// Whether the daemon is there to talk to.
211 - ///
212 - /// Two states rather than a `Result`, because "not running" is the ordinary
213 - /// condition of a machine that has not enrolled yet: it wants an offer, not an
214 - /// error. There is deliberately no "syncthing is absent" arm — [`detect`]
215 - /// answers that by handing back [`Mock`], the same way [`mesh`](crate::mesh)
216 - /// does, so a machine without the tool gets a demonstrable screen labelled
217 - /// `mock` rather than a dead end.
218 - #[derive(Debug, Clone, PartialEq, Eq)]
219 - pub(crate) enum Reach {
220 - /// The daemon answered.
221 - Running(SyncState),
222 - /// Syncthing is installed and the daemon is not answering.
223 - NotRunning,
224 - }
225 -
226 - // ---------------------------------------------------------------------------
227 - // The backend seam
228 - // ---------------------------------------------------------------------------
229 -
230 - pub(crate) trait Backend {
231 - fn name(&self) -> &'static str;
232 -
233 - /// Read the whole screen, or say why there is nothing to read.
234 - fn reach(&self, log: &mut CommandLog) -> Result<Reach>;
235 -
236 - /// Start the daemon and have it start at every login.
237 - fn enroll(&self, log: &mut CommandLog) -> Result<()>;
238 -
239 - /// Pause or resume a folder.
240 - fn set_folder_paused(&self, folder: &Folder, paused: bool, log: &mut CommandLog) -> Result<()>;
241 -
242 - /// Pause or resume a device.
243 - fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()>;
244 -
245 - /// Start synchronizing a directory.
246 - fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()>;
247 -
248 - /// Share this machine's folders with another device.
249 - fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()>;
250 -
251 - /// Stop synchronizing a directory. Leaves the files where they are.
252 - fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()>;
253 -
254 - /// Forget a device.
255 - fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()>;
256 -
257 - /// Let a waiting device in.
258 - ///
259 - /// No matching `dismiss`: Syncthing's REST API can drop a pending entry
260 - /// and `syncthing cli` does not expose that, so the console can accept an
261 - /// invitation and cannot decline one. An unaccepted device simply stays in
262 - /// the list, which is the honest behaviour to ship rather than a button
263 - /// that lies. See the module docs.
264 - fn accept_device(&self, pending: &PendingDevice, log: &mut CommandLog) -> Result<()>;
265 - }
266 -
267 - /// A folder the user is describing but has not added yet.
268 - ///
269 - /// Plain strings rather than the [`TextField`]s they come from, so the
270 - /// backends take data instead of UI state and the validation below is
271 - /// testable without a keyboard.
272 - #[derive(Debug, Clone, Default, PartialEq, Eq)]
273 - pub(crate) struct FolderDraft {
274 - pub id: String,
275 - pub label: String,
276 - pub path: String,
277 - }
278 -
279 - /// A device the user is describing but has not added yet.
280 - #[derive(Debug, Clone, Default, PartialEq, Eq)]
281 - pub(crate) struct DeviceDraft {
282 - pub id: String,
283 - pub name: String,
284 - }
285 -
286 - /// Check a folder draft before spending a command on it.
287 - ///
288 - /// Deliberately shallow. Whether the path exists, whether the id collides with
289 - /// an existing folder, whether the filesystem is writable: Syncthing answers
290 - /// all of those and answers them correctly, and duplicating its rules here
291 - /// would mean two validators to keep in agreement. What this catches is the
292 - /// empty submit, which is the one case where the error would otherwise come
293 - /// back as an opaque usage message about a flag the user never saw.
294 - fn validate_folder(draft: &FolderDraft) -> Result<(), String> {
295 - if draft.id.trim().is_empty() {
296 - return Err("a folder needs an id".into());
297 - }
298 - if draft.path.trim().is_empty() {
299 - return Err("a folder needs a path".into());
300 - }
301 - Ok(())
302 - }
303 -
304 - /// Check a device draft. Same shallowness, one extra rule.
305 - ///
306 - /// The length check earns its place against the *truncated* paste, which is
307 - /// the error a 56-character id invites: half an id produces no useful
308 - /// complaint from anything downstream, and this says how many characters
309 - /// arrived. It is not here to second-guess the id's validity — Syncthing
310 - /// verifies the check digits itself and says `check digit incorrect`, which is
311 - /// a perfectly actionable message, so a wrong-but-full-length id is left to it
312 - /// rather than duplicated here.
313 - ///
314 - /// The empty case matters for a different reason, found while testing against
315 - /// a live daemon: `config devices add --device-id ""` **exits 0 and silently
316 - /// does nothing**. Without this check a user could press enter on a blank
317 - /// field, see no error, and find no device.
318 - fn validate_device(draft: &DeviceDraft) -> Result<(), String> {
319 - let id = draft.id.trim();
320 - if id.is_empty() {
321 - return Err("a device needs an id".into());
322 - }
323 - // 56 characters in eight dash-separated groups of seven, which is how
324 - // Syncthing prints one and how a user will paste it.
325 - let bare: String = id.chars().filter(|c| *c != '-').collect();
326 - if bare.len() != 56 {
327 - return Err(format!(
328 - "a device id is 56 characters in eight groups; this one has {}",
329 - bare.len()
330 - ));
331 - }
332 - Ok(())
333 - }
334 -
335 - /// Pick a backend: `syncthing` when it answers, the mock otherwise.
336 - pub(crate) fn detect() -> Box<dyn Backend> {
337 - if Invocation::new("syncthing").arg("--version").probe() {
338 - Box::new(Syncthing)
339 - } else {
340 - Box::new(Mock)
341 - }
342 - }
343 -
344 - pub(crate) struct Syncthing;
345 -
346 - impl Backend for Syncthing {
347 - fn name(&self) -> &'static str {
348 - "syncthing"
349 - }
350 -
351 - fn reach(&self, log: &mut CommandLog) -> Result<Reach> {
352 - // Unlogged and first, because on an un-enrolled machine this fails
353 - // every five seconds and a log pane filling with the same connection
354 - // refused would bury the commands worth reading.
355 - let Ok(config) = Invocation::new("syncthing")
356 - .args(["cli", "config", "dump-json"])
357 - .capture_quiet()
358 - else {
359 - return Ok(Reach::NotRunning);
360 - };
361 - // Now that the daemon is known to answer, the same read runs logged,
362 - // so the pane teaches the command rather than hiding it.
363 - let config = Invocation::new("syncthing")
364 - .args(["cli", "config", "dump-json"])
365 - .run(log)
366 - .unwrap_or(config);
367 - let connections = Invocation::new("syncthing")
368 - .args(["cli", "show", "connections"])
369 - .run(log)?;
370 - let system = Invocation::new("syncthing")
371 - .args(["cli", "show", "system"])
372 - .run(log)?;
373 - let pending = Invocation::new("syncthing")
374 - .args(["cli", "show", "pending", "devices"])
375 - .run(log)?;
376 - Ok(Reach::Running(parse(
377 - &config,
378 - &connections,
379 - &system,
380 - &pending,
381 - )?))
382 - }
383 -
384 - /// `--now` so enrolling starts the daemon as well as arranging for it to
385 - /// start next time. A user who pressed enroll and then had to log out
386 - /// before anything happened would reasonably read that as broken.
387 - fn enroll(&self, log: &mut CommandLog) -> Result<()> {
388 - Invocation::new("systemctl")
389 - .args(["--user", "enable", "--now", UNIT])
390 - .run(log)
391 - .map(drop)
392 - }
393 -
394 - fn set_folder_paused(&self, folder: &Folder, paused: bool, log: &mut CommandLog) -> Result<()> {
395 - Invocation::new("syncthing")
396 - .args(["cli", "config", "folders", &folder.id, "paused", "set"])
397 - .arg(paused.to_string())
398 - .run(log)
399 - .map(drop)
400 - }
401 -
402 - fn set_device_paused(&self, device: &Device, paused: bool, log: &mut CommandLog) -> Result<()> {
403 - Invocation::new("syncthing")
404 - .args(["cli", "config", "devices", &device.id, "paused", "set"])
405 - .arg(paused.to_string())
406 - .run(log)
407 - .map(drop)
408 - }
409 -
410 - /// The label is passed even when empty, because Syncthing treats an absent
411 - /// `--label` and an empty one the same way and the parser above already
412 - /// falls back to the id for display.
413 - fn add_folder(&self, draft: &FolderDraft, log: &mut CommandLog) -> Result<()> {
414 - Invocation::new("syncthing")
415 - .args(["cli", "config", "folders", "add"])
416 - .arg("--id")
417 - .arg(draft.id.trim())
418 - .arg("--label")
419 - .arg(draft.label.trim())
420 - .arg("--path")
421 - .arg(draft.path.trim())
422 - .run(log)
423 - .map(drop)
424 - }
425 -
426 - fn add_device(&self, draft: &DeviceDraft, log: &mut CommandLog) -> Result<()> {
427 - Invocation::new("syncthing")
428 - .args(["cli", "config", "devices", "add"])
429 - .arg("--device-id")
430 - .arg(draft.id.trim())
431 - .arg("--name")
432 - .arg(draft.name.trim())
433 - .run(log)
434 - .map(drop)
435 - }
436 -
437 - fn remove_folder(&self, folder: &Folder, log: &mut CommandLog) -> Result<()> {
438 - Invocation::new("syncthing")
439 - .args(["cli", "config", "folders", &folder.id, "delete"])
440 - .run(log)
441 - .map(drop)
442 - }
443 -
444 - fn remove_device(&self, device: &Device, log: &mut CommandLog) -> Result<()> {
445 - Invocation::new("syncthing")
446 - .args(["cli", "config", "devices", &device.id, "delete"])
447 - .run(log)
448 - .map(drop)
449 - }
450 -
451 - /// Accepting is adding: the same command the add overlay runs, with the
452 - /// id and name already known. Syncthing drops the entry from the pending
453 - /// list once the device is configured, so nothing has to clear it.
454 - fn accept_device(&self, pending: &PendingDevice, log: &mut CommandLog) -> Result<()> {
455 - self.add_device(
456 - &DeviceDraft {
457 - id: pending.id.clone(),
458 - name: pending.name.clone(),
459 - },
460 - log,
461 - )
462 - }
463 - }
464 -
465 - /// Fixed sample state, for machines without Syncthing.
466 - ///
467 - /// Same purpose as [`mesh::Mock`](crate::mesh::Mock): the screen is
468 - /// developable and demonstrable on a box that will never run the daemon, and
469 - /// the title says which backend is answering so nobody mistakes it for real.
470 - pub(crate) struct Mock;
471 -
472 - impl Backend for Mock {
473 - fn name(&self) -> &'static str {
474 - "mock"
475 - }
476 -
477 - fn reach(&self, _log: &mut CommandLog) -> Result<Reach> {
478 - Ok(Reach::Running(SyncState {
479 - folders: vec![
480 - Folder {
481 - id: "documents".into(),
482 - label: "Documents".into(),
483 - path: "~/Documents".into(),
484 - kind: "sendreceive".into(),
485 - paused: false,
486 - shared_with: 2,
487 - },
488 - Folder {
489 - id: "photos".into(),
490 - label: "Pictures".into(),
491 - path: "~/Pictures".into(),
492 - kind: "sendonly".into(),
493 - paused: true,
494 - shared_with: 1,
495 - },
496 - ],
497 - devices: vec![
498 - Device {
499 - id: "AAAAAAA-BBBBBBB".into(),
500 - name: "fw13".into(),
501 - paused: false,
502 - connected: false,
503 - is_self: true,
504 - },
505 - Device {
506 - id: "CCCCCCC-DDDDDDD".into(),
507 - name: "astra".into(),
508 - paused: false,
509 - connected: true,
510 - is_self: false,
511 - },
512 - ],
513 - pending: vec![PendingDevice {
514 - id: "EEEEEEE-FFFFFFF".into(),
515 - name: "mbp".into(),
516 - address: "192.168.1.24:22000".into(),
517 - time: "2026-07-25T19:59:54Z".into(),
518 - }],
519 - }))
520 - }
521 -
522 - fn enroll(&self, _log: &mut CommandLog) -> Result<()> {
523 - Ok(())
524 - }
525 -
526 - fn set_folder_paused(&self, _f: &Folder, _p: bool, _log: &mut CommandLog) -> Result<()> {
527 - Ok(())
528 - }
529 -
530 - fn set_device_paused(&self, _d: &Device, _p: bool, _log: &mut CommandLog) -> Result<()> {
531 - Ok(())
532 - }
533 -
534 - fn add_folder(&self, _draft: &FolderDraft, _log: &mut CommandLog) -> Result<()> {
535 - Ok(())
536 - }
537 -
538 - fn add_device(&self, _draft: &DeviceDraft, _log: &mut CommandLog) -> Result<()> {
539 - Ok(())
540 - }
541 -
542 - fn remove_folder(&self, _folder: &Folder, _log: &mut CommandLog) -> Result<()> {
543 - Ok(())
544 - }
545 -
546 - fn remove_device(&self, _device: &Device, _log: &mut CommandLog) -> Result<()> {
547 - Ok(())
548 - }
549 -
550 - fn accept_device(&self, _pending: &PendingDevice, _log: &mut CommandLog) -> Result<()> {
551 - Ok(())
552 - }
553 - }
Lines truncated
@@ -46,7 +46,7 @@
46 46 //! not be entered can still enumerate and explain itself. Measured on fw13: a
47 47 //! `USB Type-C Digital AV Adapter` sits on the bus at 12 Mb/s claiming exactly
48 48 //! that class. It is the one device class that is a diagnostic rather than a
49 - //! function, so [`Attachment::note`] surfaces it on the row instead of leaving
49 + //! function, so [`Attachment::note`](model::Attachment::note) surfaces it on the row instead of leaving
50 50 //! it as a number in the detail pane.
51 51 //!
52 52 //! # Reading sysfs rather than fronting a CLI
@@ -65,7 +65,7 @@
65 65 //! rather than `usbguard list-devices` is what keeps this screen answering on a
66 66 //! machine where the daemon is off, which today is every machine.
67 67 //!
68 - //! What the screen does say is [`Enforcement`]: whether anything is policing the
68 + //! What the screen does say is [`Enforcement`](policy::Enforcement): whether anything is policing the
69 69 //! bus at all. That is a read of two files and one `systemctl is-active`, no
70 70 //! privilege and no IPC, and it is on screen because the alternative is worse
71 71 //! than silence. A reader who can see that the image carries usbguard, and is
@@ -77,7 +77,7 @@
77 77 //! the daemon hands out in `list-devices`. Measured against the alloy image:
78 78 //! `usbguard generate-policy` runs with no daemon at all, exits 0, and emits
79 79 //! rules keyed on id, serial, name, via-port and with-interface, every one of
80 - //! which this module already parses out of sysfs. So [`rule_for`] composes the
80 + //! which this module already parses out of sysfs. So [`rule_for`](policy::rule_for) composes the
81 81 //! rule here as a pure function and `listing::parse_listed` reads the same
82 82 //! grammar back.
83 83 //!
@@ -91,7 +91,7 @@
91 91 //! Two fields of a `generate-policy` line are not composed here. `hash` and
92 92 //! `parent-hash` are over the USB descriptor blob, which this module never
93 93 //! reads, and a usbguard rule matches on the attributes it states rather than
94 - //! demanding the ones it omits. So [`rule_for`] emits the hash-free form, and
94 + //! demanding the ones it omits. So [`rule_for`](policy::rule_for) emits the hash-free form, and
95 95 //! anything comparing it against a generated line compares the hash-free part.
96 96 //!
97 97 //! The keyboard gate is `usr/bin/alloy-usb-gate`, which is the clause that makes deny-unknown safe to arm at all: it drops
@@ -104,21 +104,20 @@
104 104 //!
105 105 //! <!-- wiki: alloy-console -->
106 106
107 - use std::fmt::Write as _;
108 107 use std::path::Path;
109 108
110 - use alloy_tui::keys::Action;
111 - use alloy_tui::{
112 - AlloyBlock, AlloyList, AlloyTabs, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
113 - };
114 - use ratatui::Frame;
115 - use ratatui::crossterm::event::{KeyCode, KeyEvent};
116 - use ratatui::layout::{Constraint, Layout, Rect};
117 - use ratatui::text::Line;
118 - use ratatui::widgets::{Paragraph, Wrap};
109 + mod bus;
110 + mod connectors;
111 + mod model;
112 + mod policy;
113 + mod view;
119 114
120 - use crate::cli::{CommandLog, Invocation};
121 - use crate::shell::{Flow, View, block_title, truncate};
115 + // The verb's vocabulary stays inside the verb: `model`, `bus`, `connectors`
116 + // and `policy` are `pub(super)` throughout and are named by nothing outside
117 + // this directory. What crosses the boundary is the screen and the tab
118 + // `main.rs` opens it on, so that is what the facade carries, and
119 + // `crate::usb::UsbView` is the path it was before the split.
120 + pub(crate) use view::{Tab, UsbView};
122 121
123 122 /// The USB device tree. One directory per device, plus one per interface.
124 123 ///
@@ -133,2237 +132,22 @@
133 132 /// and `portN-partner.M` would be an alternate mode the partner entered.
134 133 const CONNECTORS: &str = "/sys/class/typec";
135 134
136 - // ---- the model ----
137 -
138 - /// One device on the USB bus.
139 - #[derive(Debug, Clone, PartialEq, Eq)]
140 - pub(crate) struct Attachment {
141 - /// The kernel's own address, and this device's sysfs directory name.
142 - ///
143 - /// `3-1.1.2` reads as bus 3, port 1, through the hub on port 1, port 2. It
144 - /// is the only identifier every device has: ids can collide across two of
145 - /// the same dongle and serials are frequently absent or garbage.
146 - pub(crate) address: String,
147 - pub(crate) vendor_id: String,
148 - pub(crate) product_id: String,
149 - /// `manufacturer`, which many devices do not set.
150 - pub(crate) vendor: String,
151 - /// `product`, likewise.
152 - pub(crate) product: String,
153 - /// `serial`, when it is a serial rather than filler. See [`serial_of`].
154 - pub(crate) serial: Option<String>,
155 - pub(crate) speed: Speed,
156 - /// Negotiated lanes. USB 3.2 Gen 2x2 is the only thing that reports 2, and
157 - /// nothing here has; carried because "10 Gb/s" over one lane and over two
158 - /// are different links and the row should not flatten them.
159 - pub(crate) rx_lanes: u8,
160 - pub(crate) tx_lanes: u8,
161 - /// The device-level class, which is usually `00` — meaning "look at the
162 - /// interfaces" — and occasionally load-bearing. See [`Attachment::note`].
163 - pub(crate) class: Class,
164 - pub(crate) interfaces: Vec<Interface>,
165 - pub(crate) removable: Fixity,
166 - /// Whether the kernel has authorized this device. Always true until
167 - /// something sets a policy, which is exactly what the enforcement half will
168 - /// change, so the field is here before anything writes it.
169 - pub(crate) authorized: bool,
170 - /// `bMaxPower`, verbatim (`500mA`). A budget the device asked for, not a
171 - /// measurement of what it draws.
172 - pub(crate) max_power: String,
173 - /// A root hub is the controller itself, not something someone plugged in.
174 - pub(crate) is_root_hub: bool,
175 - /// How many hubs deep, for the tree indent. Derived from the address.
176 - pub(crate) depth: usize,
177 - }
178 -
179 - /// One interface a device claims, and the driver bound to it.
180 - ///
181 - /// A device is not one thing. The `MiniFuse 2` on this desk claims six
182 - /// interfaces — four audio, one MIDI, one vendor-specific — and a policy that
183 - /// reasons about "an audio device" is reasoning about the interfaces.
184 - #[derive(Debug, Clone, PartialEq, Eq)]
185 - pub(crate) struct Interface {
186 - /// The `:1.0` tail of the directory name.
187 - pub(crate) number: String,
188 - pub(crate) class: Class,
189 - pub(crate) subclass: String,
190 - pub(crate) protocol: String,
191 - /// The bound driver, or `None` for an interface nothing claimed. An
192 - /// unclaimed interface is not an error — `fe/01/01` (DFU) is present on
193 - /// half the devices here and no driver wants it — but it is the shape a
194 - /// device with a missing module also has, so it is shown rather than
195 - /// hidden.
196 - pub(crate) driver: Option<String>,
197 - }
198 -
199 - /// A USB class code, device-level or interface-level.
200 - ///
201 - /// The same numbering serves both positions, which is why one type covers them:
202 - /// `09` means hub in either place. Held as the raw byte so an unknown class
203 - /// still renders as itself rather than as "unknown".
204 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
205 - pub(crate) struct Class(pub(crate) u8);
206 -
207 - impl Class {
208 - /// The USB-IF name, or `None` for a code with no assigned meaning.
209 - ///
210 - /// Only the codes that are actually assigned are here. A device claiming
211 - /// something outside the list renders as its hex, which is more useful than
212 - /// a guess: an unassigned class is a real thing to notice.
213 - pub(crate) const fn name(self) -> Option<&'static str> {
214 - Some(match self.0 {
215 - 0x00 => "per-interface",
216 - 0x01 => "audio",
217 - 0x02 => "communications",
218 - 0x03 => "HID",
219 - 0x05 => "physical",
220 - 0x06 => "image",
221 - 0x07 => "printer",
222 - 0x08 => "mass storage",
223 - 0x09 => "hub",
224 - 0x0a => "CDC data",
225 - 0x0b => "smart card",
226 - 0x0d => "content security",
227 - 0x0e => "video",
228 - 0x0f => "personal healthcare",
229 - 0x10 => "audio/video",
230 - 0x11 => "billboard",
231 - 0x12 => "USB-C bridge",
232 - 0x3c => "I3C",
233 - 0xdc => "diagnostic",
234 - 0xe0 => "wireless",
235 - 0xef => "miscellaneous",
236 - 0xfe => "application-specific",
237 - 0xff => "vendor-specific",
238 - _ => return None,
239 - })
240 - }
241 -
242 - /// How a row spells it: the name when there is one, the hex when there is
243 - /// not, and never both — the detail pane carries the hex for every class.
244 - pub(crate) fn label(self) -> String {
245 - self.name()
246 - .map_or_else(|| format!("{:02x}", self.0), ToString::to_string)
247 - }
248 -
249 - /// Whether this is the class a device uses to report a failed alternate
250 - /// mode. See the module docs.
251 - pub(crate) const fn is_billboard(self) -> bool {
252 - self.0 == 0x11
253 - }
254 - }
255 -
256 - /// The negotiated link speed, from `speed` (in Mb/s, as a decimal string).
257 - ///
258 - /// An enum rather than the number because the number is not what a person
259 - /// wants: `480` is High-Speed and `12` is Full-Speed, and a device that should
260 - /// be one sitting at the other is the single most common USB complaint there
261 - /// is. Naming the rung is what makes that visible.
262 - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
263 - pub(crate) enum Speed {
264 - /// 1.5 Mb/s. USB 1.0.
265 - Low,
266 - /// 12 Mb/s. USB 1.1.
267 - Full,
268 - /// 480 Mb/s. USB 2.0.
269 - High,
270 - /// 5 Gb/s. USB 3.0.
271 - Super,
272 - /// 10 Gb/s. USB 3.1 Gen 2.
273 - SuperPlus,
274 - /// 20 Gb/s. USB 3.2 Gen 2x2.
275 - Super20,
276 - /// A rung the kernel reported that this list does not name. Carried so a
277 - /// future speed renders as its number rather than as a lie.
278 - Other(u32),
279 - /// No `speed` file, or one that did not parse.
280 - Unknown,
281 - }
282 -
283 - impl Speed {
284 - /// Parse the `speed` file. Its unit is Mb/s and it is sometimes fractional
285 - /// (`1.5`), which is why this does not go through `u32::from_str`.
286 - pub(crate) fn parse(raw: &str) -> Self {
287 - match raw.trim() {
288 - "1.5" => Self::Low,
289 - "12" => Self::Full,
290 - "480" => Self::High,
291 - "5000" => Self::Super,
292 - "10000" => Self::SuperPlus,
293 - "20000" => Self::Super20,
294 - other => other.parse().map_or(Self::Unknown, Self::Other),
295 - }
296 - }
297 -
298 - /// The rung's name, which is what the row shows.
299 - pub(crate) fn label(self) -> String {
300 - match self {
301 - Self::Low => "1.5 Mb/s".into(),
302 - Self::Full => "12 Mb/s".into(),
303 - Self::High => "480 Mb/s".into(),
304 - Self::Super => "5 Gb/s".into(),
305 - Self::SuperPlus => "10 Gb/s".into(),
306 - Self::Super20 => "20 Gb/s".into(),
307 - Self::Other(mbps) => format!("{mbps} Mb/s"),
308 - Self::Unknown => "-".into(),
309 - }
310 - }
311 -
312 - /// The USB generation that rung belongs to, for the detail pane.
313 - pub(crate) const fn generation(self) -> &'static str {
314 - match self {
315 - Self::Low => "USB 1.0 low-speed",
316 - Self::Full => "USB 1.1 full-speed",
317 - Self::High => "USB 2.0 high-speed",
318 - Self::Super => "USB 3.0 SuperSpeed",
319 - Self::SuperPlus => "USB 3.1 SuperSpeed+",
320 - Self::Super20 => "USB 3.2 SuperSpeed+ 20Gbps",
321 - Self::Other(_) | Self::Unknown => "unknown",
322 - }
323 - }
324 - }
325 -
326 - /// What `removable` says, which is a firmware claim rather than a fact.
327 - ///
328 - /// Named `Fixity` rather than `Removable` so the type is not its own variant.
329 - ///
330 - /// Three values, and `unknown` is the most common: measured on fw13, 14 of 26
331 - /// devices report it. Kept as three rather than collapsed to a bool because
332 - /// "the firmware did not say" and "the firmware said no" are different, and the
333 - /// enforcement half will care — a fixed device appearing as removable is a
334 - /// thing worth noticing.
335 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
336 - pub(crate) enum Fixity {
337 - Fixed,
338 - Removable,
339 - Unknown,
340 - }
341 -
342 - impl Fixity {
343 - pub(crate) fn parse(raw: &str) -> Self {
344 - match raw.trim() {
345 - "fixed" => Self::Fixed,
346 - "removable" => Self::Removable,
347 - _ => Self::Unknown,
348 - }
349 - }
350 -
351 - pub(crate) const fn label(self) -> &'static str {
352 - match self {
353 - Self::Fixed => "fixed",
354 - Self::Removable => "removable",
355 - Self::Unknown => "unstated",
356 - }
357 - }
358 - }
359 -
360 - impl Attachment {
361 - /// The name a row leads with.
362 - ///
363 - /// `product` when the device set one, the ids when it did not. Never the
364 - /// address: that is the second column, and leading with it would make every
365 - /// row start with the same shape.
366 - pub(crate) fn name(&self) -> String {
367 - if !self.product.is_empty() {
368 - return self.product.clone();
369 - }
370 - if !self.vendor.is_empty() {
371 - return self.vendor.clone();
372 - }
373 - format!("{}:{}", self.vendor_id, self.product_id)
374 - }
375 -
376 - /// What this device is, from its interfaces.
377 - ///
378 - /// Interface classes, deduplicated, in first-claimed order. A device with a
379 - /// meaningful device-level class (anything but `00` and `ef`) is described
380 - /// by that instead: `ef` is "miscellaneous", which says nothing, and `00`
381 - /// means "ask the interfaces" outright.
382 - pub(crate) fn kind(&self) -> String {
383 - if self.class.0 != 0x00 && self.class.0 != 0xef {
384 - return self.class.label();
385 - }
386 - let mut seen: Vec<String> = Vec::new();
387 - for interface in &self.interfaces {
388 - let label = interface.class.label();
389 - if !seen.contains(&label) {
390 - seen.push(label);
391 - }
392 - }
393 - if seen.is_empty() {
394 - "-".into()
395 - } else {
396 - seen.join(", ")
397 - }
398 - }
399 -
400 - /// A one-line diagnostic for the row, when the device is telling us
401 - /// something rather than merely being something.
402 - ///
403 - /// Only two so far, and both are real states a person would otherwise have
404 - /// to know a class code to see.
405 - pub(crate) fn note(&self) -> Option<&'static str> {
406 - if self.class.is_billboard() {
407 - return Some("alternate mode did not come up");
408 - }
409 - if !self.authorized {
410 - return Some("not authorized");
411 - }
412 - None
413 - }
414 -
415 - /// Interfaces that enumerated with no driver bound.
416 - ///
417 - /// Not an error on its own — see [`Interface::driver`] — so this feeds the
418 - /// detail pane rather than the status line.
419 - pub(crate) fn unclaimed(&self) -> usize {
420 - self.interfaces
421 - .iter()
422 - .filter(|interface| interface.driver.is_none())
423 - .count()
424 - }
425 - }
426 -
427 - // ---- reading the bus ----
428 -
429 - /// Whether a directory name under [`ATTACHMENTS`] names a device.
430 - ///
431 - /// Interfaces live in the same directory and are told apart by the colon in
432 - /// their name. Root hubs are `usbN` and are devices, so they pass.
433 - fn is_device_name(name: &str) -> bool {
434 - !name.contains(':')
435 - }
436 -
437 - /// Whether this address is a root hub rather than something plugged in.
438 - fn is_root_hub_name(name: &str) -> bool {
439 - name.starts_with("usb")
440 - }
441 -
442 - /// How many hubs deep an address sits, for the tree indent.
443 - ///
444 - /// `3-1.1.2` is three: the dots are hub traversals and the leading `bus-port`
445 - /// is the first level. A root hub is zero. Derived rather than read because
446 - /// sysfs states the parentage as a directory tree that is flattened here, and
447 - /// the address already carries it losslessly.
448 - fn depth_of(name: &str) -> usize {
449 - if is_root_hub_name(name) {
450 - return 0;
451 - }
452 - let Some((_, path)) = name.split_once('-') else {
453 - return 0;
454 - };
455 - path.split('.').count()
456 - }
457 -
458 - /// The `serial` file, when it holds a serial.
459 - ///
460 - /// Devices lie here, and one of them is on this desk: the `MiniFuse 2` reports
461 - /// sixteen `0xFF` bytes, which arrive as `ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ` and are not a
462 - /// serial number. Anything with a non-ASCII-printable byte is refused, and so
463 - /// is the empty string, which the `8BitDo` receiver reports.
464 - ///
465 - /// Refused rather than shown-as-is because a serial is the field a permanent
466 - /// allow rule will be keyed on when the enforcement half lands. A rule keyed on
467 - /// filler matches every device that ships the same filler.
468 - fn serial_of(raw: &str) -> Option<String> {
469 - let trimmed = raw.trim();
470 - if trimmed.is_empty() {
471 - return None;
472 - }
473 - if !trimmed.chars().all(|c| c.is_ascii_graphic() || c == ' ') {
474 - return None;
475 - }
476 - Some(trimmed.to_string())
477 - }
478 -
479 - /// A hex byte from a sysfs class/subclass/protocol file.
480 - fn class_of(raw: &str) -> Class {
481 - Class(u8::from_str_radix(raw.trim(), 16).unwrap_or(0))
482 - }
483 -
484 135 /// Read a file under `dir`, trimmed, or the empty string.
485 136 ///
486 137 /// Every field here is optional in the sense that some device somewhere does
487 138 /// not have it, so a missing file is an empty value rather than an error. The
488 139 /// alternative — refusing to describe a device because it did not set
489 140 /// `manufacturer` — hides exactly the odd hardware this screen is for.
141 + ///
142 + /// In the facade rather than in [`bus`] because both subsystems read sysfs the
143 + /// same way and neither owns the reader. Routing the connector reads through
144 + /// `bus::field` would couple two lists the module docs above insist stay
145 + /// separate.
490 146 fn field(dir: &Path, name: &str) -> String {
491 147 std::fs::read_to_string(dir.join(name))
492 148 .map(|raw| raw.trim().to_string())
493 149 .unwrap_or_default()
494 150 }
495 151
496 - /// Every attachment on this machine, root hubs first, then depth order.
497 - pub(crate) fn attachments() -> Vec<Attachment> {
498 - attachments_in(Path::new(ATTACHMENTS))
499 - }
500 -
501 - /// [`attachments`] against a given sysfs root, which is the whole of it.
502 - ///
503 - /// Split out for the tests, as `display` does: the shapes worth asserting on
504 - /// are a device with a garbage serial and a device claiming six interfaces, and
505 - /// neither can be arranged under the real `/sys`.
506 - fn attachments_in(root: &Path) -> Vec<Attachment> {
507 - let Ok(entries) = std::fs::read_dir(root) else {
508 - return Vec::new();
509 - };
510 - let mut names: Vec<String> = entries
511 - .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
512 - .filter(|name| is_device_name(name))
513 - .collect();
514 - // Sorted so the list is the machine rather than the directory order, and
515 - // so two runs on an unchanged machine render identically.
516 - names.sort_by_key(|name| address_order(name));
517 -
518 - names.iter().map(|name| attachment_at(root, name)).collect()
519 - }
520 -
521 - /// A sort key that puts `usb3` beside the `3-*` devices it parents.
522 - ///
523 - /// Plain string order interleaves the root hubs into a block of their own and
524 - /// scatters each bus's devices away from it, which reads as eight controllers
525 - /// followed by an unattributed list. Keyed on the bus number first, then on the
526 - /// port path with each segment zero-padded so `3-1.10` sorts after `3-1.2`.
527 - fn address_order(name: &str) -> (u32, Vec<u32>) {
528 - if let Some(bus) = name.strip_prefix("usb") {
529 - return (bus.parse().unwrap_or(0), Vec::new());
530 - }
531 - let Some((bus, path)) = name.split_once('-') else {
532 - return (0, Vec::new());
533 - };
534 - (
535 - bus.parse().unwrap_or(0),
536 - path.split('.')
537 - .map(|segment| segment.parse().unwrap_or(0))
538 - .collect(),
539 - )
540 - }
541 -
542 - /// One device, read from its directory.
543 - fn attachment_at(root: &Path, name: &str) -> Attachment {
544 - let dir = root.join(name);
545 - Attachment {
546 - address: name.to_string(),
547 - vendor_id: field(&dir, "idVendor"),
548 - product_id: field(&dir, "idProduct"),
549 - vendor: field(&dir, "manufacturer"),
550 - product: field(&dir, "product"),
551 - serial: serial_of(&field(&dir, "serial")),
552 - speed: Speed::parse(&field(&dir, "speed")),
553 - rx_lanes: field(&dir, "rx_lanes").parse().unwrap_or(1),
554 - tx_lanes: field(&dir, "tx_lanes").parse().unwrap_or(1),
555 - class: class_of(&field(&dir, "bDeviceClass")),
556 - interfaces: interfaces_of(root, name),
557 - removable: Fixity::parse(&field(&dir, "removable")),
558 - // Absent reads as authorized, which is what a kernel with no policy
559 - // does. Reading a missing file as "blocked" would paint every device on
560 - // an older kernel red.
561 - authorized: field(&dir, "authorized") != "0",
562 - max_power: field(&dir, "bMaxPower"),
563 - is_root_hub: is_root_hub_name(name),
Lines truncated
@@ -81,7 +81,7 @@
81 81 }
82 82 }
83 83
84 - /// The part of a module that ships, with its `#[cfg(test)] mod tests` cut off.
84 + /// The part of a module that ships, with its `#[cfg(test)]` tail cut off.
85 85 ///
86 86 /// Test code spawns freely and should: `pkcheck --version` probes whether the
87 87 /// machine running the tests has polkit at all, and the `workspace` export test
@@ -89,44 +89,71 @@
89 89 /// user's session `PATH` decides.
90 90 ///
91 91 /// The cut is the first `#[cfg(test)]` in the first column, which every module
92 - /// here places on the file's last item, whether that item opens the tests inline
93 - /// or declares them in a `tests.rs` sibling. That shape is asserted rather than
94 - /// assumed: a module that grew code below its tests would otherwise have that
95 - /// code silently exempted, which is the one way this check could go quiet
96 - /// without failing.
92 + /// here places on the file's last item. What follows it is checked rather than
93 + /// assumed, by [`assert_test_only_tail`]: a module that grew code below its
94 + /// tests would otherwise have that code silently exempted, which is the one way
95 + /// this check could go quiet without failing.
97 96 fn shipped(name: &str, text: &str) -> String {
98 97 let marker = "\n#[cfg(test)]\n";
99 98 let Some(at) = text.find(marker) else {
100 99 return text.to_string();
101 100 };
102 101
103 - let tail = &text[at + 1..];
104 - assert_eq!(
105 - tail.matches(marker).count(),
106 - 0,
107 - "{name} has more than one `#[cfg(test)]` in the first column; \
108 - the tail is no longer a single test module"
109 - );
110 - let inline = tail.starts_with("#[cfg(test)]\nmod tests {\n");
111 - let sibling = tail.starts_with("#[cfg(test)]\nmod tests;");
112 - assert!(
113 - inline || sibling,
114 - "{name}'s `#[cfg(test)]` neither opens nor declares a `mod tests`"
115 - );
116 - if inline {
102 + assert_test_only_tail(name, text[at + 1..].trim_end());
103 + text[..at].to_string()
104 + }
105 +
106 + /// Everything below the cut is a `#[cfg(test)]` module, and nothing else.
107 + ///
108 + /// The cut exempts whatever follows it, so what follows has to be test code and
109 + /// only test code. Three shapes are read, and a module may combine them:
110 + ///
111 + /// - `#[cfg(test)] mod tests { ... }`, the inline form, which runs to the end
112 + /// of the file and is therefore last;
113 + /// - `#[cfg(test)] mod tests;`, the same tests in a `tests.rs` sibling;
114 + /// - any number of those declarations, which is what a verb split into a
115 + /// `<verb>.rs` facade over a `<verb>/` directory needs: the facade declares
116 + /// the `fixtures` module its children's tests share, and ships nothing else.
117 + ///
118 + /// A declaration names a file [`collect`] walks in its own right, so nothing is
119 + /// exempted by being declared here; the module named is read like any other.
120 + fn assert_test_only_tail(name: &str, tail: &str) {
121 + let mut rest = tail;
122 + let mut modules = 0usize;
123 + while !rest.is_empty() {
124 + let head = rest.lines().next().unwrap_or_default();
125 + let item = rest.strip_prefix("#[cfg(test)]\n").unwrap_or_else(|| {
126 + panic!(
127 + "{name} has something below its test modules that is not one: {head}\n\
128 + everything after the first `#[cfg(test)]` is exempt from this file's checks, \
129 + so everything after it has to be test code"
130 + )
131 + });
132 + modules += 1;
133 + let declaration = item.lines().next().unwrap_or_default();
134 + let named = declaration.strip_prefix("mod ").unwrap_or_else(|| {
135 + panic!(
136 + "{name} attaches `#[cfg(test)]` to something that is not a module: {declaration}"
137 + )
138 + });
139 + if named.ends_with(';') {
140 + rest = item[declaration.len()..].trim_start_matches('\n');
141 + continue;
142 + }
117 143 assert!(
118 - tail.trim_end().ends_with('}'),
144 + named.ends_with('{'),
145 + "{name} opens `mod {named}` in a form this check cannot read"
146 + );
147 + assert!(
148 + rest.ends_with('}'),
119 149 "{name} does not end with its test module"
120 150 );
121 - } else {
122 - assert_eq!(
123 - tail.trim_end(),
124 - "#[cfg(test)]\nmod tests;",
125 - "{name} has code below its test module declaration"
126 - );
151 + return;
127 152 }
128 -
129 - text[..at].to_string()
153 + assert!(
154 + modules > 0,
155 + "{name}'s `#[cfg(test)]` neither opens nor declares a test module"
156 + );
130 157 }
131 158
132 159 /// Lines that are wholly a comment, which is what a text matcher must not read.
@@ -320,6 +347,19 @@
320 347 assert!(spawns(&shipped("fake.rs", &tested)).is_empty());
321 348 assert_eq!(spawns(&tested).len(), 1, "and the cut is what excused it");
322 349
350 + // A verb split into a facade over a directory declares its shared fixtures
351 + // below the cut, and ships everything above it.
352 + let facade = "mod model;\nmod parse;\n\n#[cfg(test)]\nmod fixtures;\n";
353 + assert_eq!(shipped("fake.rs", facade), "mod model;\nmod parse;\n");
354 +
355 + // More than one declaration, and a declaration followed by the inline form.
356 + let both = "fn ship() {}\n\n#[cfg(test)]\nmod fixtures;\n\n#[cfg(test)]\nmod tests;\n";
357 + assert_eq!(shipped("fake.rs", both), "fn ship() {}\n");
358 + let mixed = format!(
359 + "fn ship() {{}}\n\n#[cfg(test)]\nmod fixtures;\n\n#[cfg(test)]\nmod tests {{\n{direct}}}\n"
360 + );
361 + assert!(spawns(&shipped("fake.rs", &mixed)).is_empty());
362 +
323 363 // The alias `spawns` cannot see, and the check that sees it.
324 364 let aliased = "use std::process::Command as Proc;\n\n Proc::new(\"wipefs\")\n";
325 365 assert!(spawns(aliased).is_empty(), "the literal check cannot");
@@ -344,6 +384,21 @@
344 384 assert!(spawns(" /// Command::new(\"x\")").is_empty());
345 385 }
346 386
387 + /// And code below the cut is still code, whatever precedes it.
388 + ///
389 + /// The declaration form is the one that made this worth a test of its own: an
390 + /// inline `mod tests` swallows the rest of the file, so nothing can follow it,
391 + /// while `#[cfg(test)] mod fixtures;` leaves the file open and a later edit can
392 + /// land a spawn under it. That edit would be exempt from every check here.
393 + #[test]
394 + #[should_panic = "has something below its test modules"]
395 + fn code_below_a_test_module_declaration_is_refused() {
396 + shipped(
397 + "fake.rs",
398 + "fn ship() {}\n\n#[cfg(test)]\nmod fixtures;\n\nfn snuck_in() {}\n",
399 + );
400 + }
401 +
347 402 /// The rename has more than one spelling, and each of them is a spawn.
348 403 ///
349 404 /// Every line here reaches `Proc::new` with `std::process::Command` behind it,
@@ -1,0 +1,471 @@
1 + //! Where the argv is built, and the only layer that knows a tool exists.
2 + //!
3 + //! Backends build [`Invocation`]s and run nothing themselves; the view executes
4 + //! through the log, which is what makes docs/CONSOLE.md's coverage promise
5 + //! structural rather than a habit. Names [`model`](super::model) and
6 + //! [`parse`](super::parse), and is named only by [`view`](super::view).
7 +
8 + use alloy_tui::Severity;
9 + use anyhow::Result;
10 +
11 + use super::SCAN_SECONDS;
12 + use super::model::{Adapter, Device, Radio};
13 + use super::parse::{parse_device_list, parse_info, parse_rfkill, parse_show};
14 + use crate::cli::{CommandLog, Invocation};
15 +
16 + /// Backends build argv and run nothing. The view executes through the log,
17 + /// which is what makes docs/CONSOLE.md's coverage promise structural rather
18 + /// than a habit.
19 + pub(super) trait Backend {
20 + fn name(&self) -> &'static str;
21 +
22 + /// The controller. An error here is the whole screen's error: with no
23 + /// adapter there is nothing to list.
24 + fn adapter(&self, log: &mut CommandLog) -> Result<Adapter>;
25 +
26 + /// Every address bluez knows, as `(address, name)`. The state fields come
27 + /// from [`Backend::info`].
28 + fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>>;
29 +
30 + /// Everything bluez keeps about one device.
31 + fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device>;
32 +
33 + /// The radio, read without logging the command. rfkill is the console's own
34 + /// bookkeeping: the user asked about Bluetooth, not about rfkill, and the
35 + /// answer only ever appears as a phrase in the header.
36 + ///
37 + /// The log is still taken, for the one case that is not bookkeeping. A read
38 + /// that succeeds and names no Bluetooth line is a real answer of
39 + /// [`Radio::Unknown`] and stays quiet; a read that could not run at all is a
40 + /// fault, and the header phrase it produces reads identically. See
41 + /// [`radio_from`].
42 + fn radio(&self, _log: &mut CommandLog) -> Radio {
43 + Radio::Unknown
44 + }
45 +
46 + fn pair(&self, _device: &Device) -> Option<Invocation> {
47 + None
48 + }
49 +
50 + fn connect(&self, _device: &Device) -> Option<Invocation> {
51 + None
52 + }
53 +
54 + fn disconnect(&self, _device: &Device) -> Option<Invocation> {
55 + None
56 + }
57 +
58 + fn trust(&self, _device: &Device, _trusted: bool) -> Option<Invocation> {
59 + None
60 + }
61 +
62 + fn block(&self, _device: &Device, _blocked: bool) -> Option<Invocation> {
63 + None
64 + }
65 +
66 + /// Forget the device: drops the keys as well as the entry.
67 + fn remove(&self, _device: &Device) -> Option<Invocation> {
68 + None
69 + }
70 +
71 + fn power(&self, _on: bool) -> Option<Invocation> {
72 + None
73 + }
74 +
75 + /// A bounded discovery run, handed the terminal rather than captured.
76 + fn scan(&self) -> Option<Invocation> {
77 + None
78 + }
79 +
80 + /// List PipeWire's sinks, for finding the one a connected headset landed on.
81 + fn sinks(&self) -> Option<Invocation> {
82 + None
83 + }
84 +
85 + fn set_default_sink(&self, _sink: &str) -> Option<Invocation> {
86 + None
87 + }
88 + }
89 +
90 + /// Pick a backend: the real one when `bluetoothctl` answers, the mock
91 + /// otherwise.
92 + ///
93 + /// A `--version` probe rather than a `which` check, matching every other verb.
94 + /// Note that this probes the client and not the daemon: bluetoothctl answers
95 + /// `--version` with bluetoothd down. That is deliberate, because the daemon
96 + /// being down is a state the screen should report rather than one that should
97 + /// drop it to mock devices, and [`Backend::adapter`] is where it surfaces.
98 + pub(super) fn detect() -> Box<dyn Backend> {
99 + if Invocation::new("bluetoothctl").arg("--version").probe() {
100 + Box::new(BluetoothCtl {
101 + pactl: Invocation::new("pactl").arg("--version").probe(),
102 + })
103 + } else {
104 + Box::new(Mock)
105 + }
106 + }
107 +
108 + pub(super) struct BluetoothCtl {
109 + /// Whether the audio handoff is offered. Read once at construction, like
110 + /// `disk`'s udisks flag.
111 + pactl: bool,
112 + }
113 +
114 + impl BluetoothCtl {
115 + /// The pairing agent capability.
116 + ///
117 + /// `NoInputNoOutput` would suppress the passkey prompt entirely and pair
118 + /// silently, which is the wrong trade here: a device asking a human to
119 + /// confirm a number is the one moment Bluetooth's security model is
120 + /// visible, and suppressing it to save a keystroke is how pairing becomes
121 + /// magic. `KeyboardDisplay` says the terminal can both show and answer,
122 + /// which is true, because pairing suspends onto a real one.
123 + const AGENT: &'static str = "KeyboardDisplay";
124 +
125 + fn ctl() -> Invocation {
126 + Invocation::new("bluetoothctl")
127 + }
128 + }
129 +
130 + impl Backend for BluetoothCtl {
131 + fn name(&self) -> &'static str {
132 + "bluetoothctl"
133 + }
134 +
135 + fn adapter(&self, log: &mut CommandLog) -> Result<Adapter> {
136 + parse_show(&Self::ctl().arg("show").run(log)?)
137 + }
138 +
139 + fn devices(&self, log: &mut CommandLog) -> Result<Vec<(String, String)>> {
140 + Ok(parse_device_list(&Self::ctl().arg("devices").run(log)?))
141 + }
142 +
143 + fn info(&self, address: &str, log: &mut CommandLog) -> Result<Device> {
144 + parse_info(&Self::ctl().args(["info", address]).run(log)?)
145 + }
146 +
147 + fn radio(&self, log: &mut CommandLog) -> Radio {
148 + radio_from(rfkill_invocation().capture_quiet(), log)
149 + }
150 +
151 + fn pair(&self, device: &Device) -> Option<Invocation> {
152 + Some(Self::ctl().args(["--agent", Self::AGENT, "pair", &device.address]))
153 + }
154 +
155 + fn connect(&self, device: &Device) -> Option<Invocation> {
156 + Some(Self::ctl().args(["connect", &device.address]))
157 + }
158 +
159 + fn disconnect(&self, device: &Device) -> Option<Invocation> {
160 + Some(Self::ctl().args(["disconnect", &device.address]))
161 + }
162 +
163 + fn trust(&self, device: &Device, trusted: bool) -> Option<Invocation> {
164 + let verb = if trusted { "trust" } else { "untrust" };
165 + Some(Self::ctl().args([verb, &device.address]))
166 + }
167 +
168 + fn block(&self, device: &Device, blocked: bool) -> Option<Invocation> {
169 + let verb = if blocked { "block" } else { "unblock" };
170 + Some(Self::ctl().args([verb, &device.address]))
171 + }
172 +
173 + fn remove(&self, device: &Device) -> Option<Invocation> {
174 + Some(Self::ctl().args(["remove", &device.address]))
175 + }
176 +
177 + fn power(&self, on: bool) -> Option<Invocation> {
178 + Some(Self::ctl().args(["power", if on { "on" } else { "off" }]))
179 + }
180 +
181 + fn scan(&self) -> Option<Invocation> {
182 + Some(Self::ctl().args(["--timeout", &SCAN_SECONDS.to_string(), "scan", "on"]))
183 + }
184 +
185 + fn sinks(&self) -> Option<Invocation> {
186 + self.pactl
187 + .then(|| Invocation::new("pactl").args(["-f", "json", "list", "sinks"]))
188 + }
189 +
190 + fn set_default_sink(&self, sink: &str) -> Option<Invocation> {
191 + self.pactl
192 + .then(|| Invocation::new("pactl").args(["set-default-sink", sink]))
193 + }
194 + }
195 +
196 + /// Fixed sample state, for machines without bluez.
197 + ///
198 + /// The devices are this machine's real ones, reduced, and they keep the state
199 + /// that made the verb worth writing: paired, bonded, and not trusted. A mock
200 + /// that showed everything connected and trusted would demonstrate the one case
201 + /// the screen has nothing to teach about.
202 + pub(super) struct Mock;
203 +
204 + impl Backend for Mock {
205 + fn name(&self) -> &'static str {
206 + "mock"
207 + }
208 +
209 + fn adapter(&self, log: &mut CommandLog) -> Result<Adapter> {
210 + log.record("# no bluetoothctl; showing a mock adapter", Severity::Warn);
211 + Ok(Adapter {
212 + address: "D8:B3:2F:BD:B8:78".to_string(),
213 + alias: "mock".to_string(),
214 + powered: true,
215 + discoverable: false,
216 + pairable: true,
217 + discovering: false,
218 + })
219 + }
220 +
221 + fn devices(&self, _log: &mut CommandLog) -> Result<Vec<(String, String)>> {
222 + Ok(vec![
223 + ("D2:0F:A1:0C:48:3F".to_string(), "MX Master 4".to_string()),
224 + ("C0:28:8D:11:22:33".to_string(), "WH-1000XM5".to_string()),
225 + ])
226 + }
227 +
228 + fn info(&self, address: &str, _log: &mut CommandLog) -> Result<Device> {
229 + let headset = address.starts_with("C0:");
230 + Ok(Device {
231 + address: address.to_string(),
232 + name: if headset { "WH-1000XM5" } else { "MX Master 4" }.to_string(),
233 + address_type: Some(if headset { "public" } else { "random" }.to_string()),
234 + icon: Some(
235 + if headset {
236 + "audio-headset"
237 + } else {
238 + "input-mouse"
239 + }
240 + .to_string(),
241 + ),
242 + paired: true,
243 + bonded: true,
244 + trusted: false,
245 + blocked: false,
246 + connected: headset,
247 + battery: headset.then_some(72),
248 + })
249 + }
250 + }
251 +
252 + /// rfkill, by bare name.
253 + ///
254 + /// It was a two-element list, `["rfkill", "/usr/sbin/rfkill"]`, tried in order
255 + /// because the console cannot count on `PATH` carrying `/usr/sbin` (GoingsOn
256 + /// problem `d0dc9dad`, measured on fw12). The second element was never observed
257 + /// to be the one that worked: on the shipped image `/usr/sbin` is a symlink to
258 + /// `bin` and the bare name resolves, which
259 + /// `cli::SBIN_DIRS` sets out with the measurement. What
260 + /// replaced it is [`child_command`](crate::cli::child_command), which puts the
261 + /// sbin directories on the `PATH` of every child rather than on this one call
262 + /// site, so the pane shows the name a user would type and no tool added later
263 + /// has to remember a second spelling of itself.
264 + ///
265 + /// A failed read is indistinguishable on screen from a successful read of a
266 + /// machine with no Bluetooth rfkill line, which is what [`radio_from`] guards:
267 + /// it reports rather than retries.
268 + ///
269 + /// `--noheadings --output` rather than the default listing or `--json`:
270 + /// util-linux 2.39 accepts `--json` and ignores it, printing the human format,
271 + /// so a JSON parser here would fail on the machine it was written for. Measured
272 + /// on fw13.
273 + fn rfkill_invocation() -> Invocation {
274 + Invocation::new("rfkill").args([
275 + "--noheadings",
276 + "--output",
277 + "TYPE,SOFT,HARD",
278 + "list",
279 + "bluetooth",
280 + ])
281 + }
282 +
283 + /// A radio verdict from an rfkill read, with a failed read said out loud.
284 + ///
285 + /// [`Radio::Unknown`] is two different facts wearing one name. rfkill ran and
286 + /// listed no Bluetooth line, which is an answer: the machine has no Bluetooth
287 + /// radio rfkill knows about, and the header correctly says nothing about
288 + /// blocking. Or the read failed, in which case the console knows nothing and
289 + /// the header says nothing about blocking anyway, which is the same screen for
290 + /// the opposite reason. The second case also drops hard-block detection, so an
291 + /// adapter that is off because of a physical switch reads as an adapter that is
292 + /// simply off, and the advice the user gets is advice that cannot work.
293 + ///
294 + /// Widening [`Radio`] with a `Failed` variant was the other option and was not
295 + /// taken: nothing on screen would render it differently from `Unknown`, since
296 + /// there is nothing useful to tell a user about the console's own bookkeeping
297 + /// tool, and a variant every match arm has to name for no visible difference is
298 + /// cost without a reader. The log pane is where the console says what it did, so
299 + /// the fault goes there as commentary, in the form the mock backends already use
300 + /// for a line that is not a command.
301 + ///
302 + /// Split out from [`Backend::radio`] so the failure path has a test. Spawning an
303 + /// rfkill that fails would mean depending on the machine the tests run on, and a
304 + /// test that passes because this machine happens to have rfkill is a test that
305 + /// proves nothing.
306 + ///
307 + /// One limit, stated rather than hidden: two of the refresh paths run inside
308 + /// [`CommandLog::quiet`](crate::cli::CommandLog::quiet), so a fault they hit is
309 + /// suppressed with everything else on those paths. The first read of the screen
310 + /// and the manual `r` are both loud, which is where a persistent fault will be
311 + /// seen; a fault that only ever appears under a quiet refresh will not be.
312 + fn radio_from(read: Result<String>, log: &mut CommandLog) -> Radio {
313 + match read {
314 + Ok(raw) => parse_rfkill(&raw),
315 + Err(err) => {
316 + log.record(
317 + format!("# could not read the radio from rfkill ({err})"),
318 + Severity::Warn,
319 + );
320 + Radio::Unknown
321 + }
322 + }
323 + }
324 +
325 + #[cfg(test)]
326 + mod tests {
327 + use super::*;
328 + use crate::bluetooth::fixtures::mouse;
329 +
330 + /// rfkill ships in `/usr/sbin`, which on the shipped image is a symlink to
331 + /// `bin`, so the bare name resolves; finding it on a host where it does not
332 + /// is `cli::child_command`'s job. Asserted here so the line cannot drift
333 + /// back to an absolute path and split that across two mechanisms again;
334 + /// `tests/sbin_path.rs` is the same rule for the rest of the crate.
335 + #[test]
336 + fn rfkill_is_asked_for_by_bare_name() {
337 + assert_eq!(
338 + rfkill_invocation().display(),
339 + "rfkill --noheadings --output TYPE,SOFT,HARD list bluetooth"
340 + );
341 + }
342 +
343 + /// A read that failed is a fault, and it produces the same header as
344 + /// a machine with no Bluetooth rfkill line. The old second spawn existed to
345 + /// cover that; nothing covers it if the failure stays quiet.
346 + #[test]
347 + fn a_radio_read_that_failed_says_so() {
348 + let mut log = CommandLog::new();
349 + let radio = radio_from(Err(anyhow::anyhow!("No such file or directory")), &mut log);
350 + assert_eq!(radio, Radio::Unknown);
351 +
352 + let entry = log.entries().last().expect("the fault is recorded");
353 + assert!(
354 + entry
355 + .command
356 + .contains("could not read the radio from rfkill"),
357 + "{}",
358 + entry.command
359 + );
360 + assert!(entry.command.contains("No such file or directory"));
361 + assert_eq!(entry.outcome, Severity::Warn);
362 + }
363 +
364 + /// And a read that ran and listed nothing is an answer, not a fault, so it
365 + /// stays out of a pane the user is reading about Bluetooth.
366 + #[test]
367 + fn a_radio_read_that_found_no_bluetooth_line_stays_quiet() {
368 + let mut log = CommandLog::new();
369 + assert_eq!(
370 + radio_from(Ok("wlan unblocked unblocked\n".to_string()), &mut log),
371 + Radio::Unknown
372 + );
373 + assert!(log.entries().is_empty(), "nothing to report");
374 + }
375 +
376 + /// Argv is asserted through `display()`, never by running anything.
377 + #[test]
378 + fn the_argv_is_what_bluetoothctl_expects() {
379 + let backend = BluetoothCtl { pactl: true };
380 + let device = mouse();
381 +
382 + assert_eq!(
383 + backend.connect(&device).unwrap().display(),
384 + "bluetoothctl connect D2:0F:A1:0C:48:3F"
385 + );
386 + assert_eq!(
387 + backend.disconnect(&device).unwrap().display(),
388 + "bluetoothctl disconnect D2:0F:A1:0C:48:3F"
389 + );
390 + assert_eq!(
391 + backend.trust(&device, true).unwrap().display(),
392 + "bluetoothctl trust D2:0F:A1:0C:48:3F"
393 + );
394 + assert_eq!(
395 + backend.trust(&device, false).unwrap().display(),
396 + "bluetoothctl untrust D2:0F:A1:0C:48:3F"
397 + );
398 + assert_eq!(
399 + backend.block(&device, true).unwrap().display(),
400 + "bluetoothctl block D2:0F:A1:0C:48:3F"
401 + );
402 + assert_eq!(
403 + backend.block(&device, false).unwrap().display(),
404 + "bluetoothctl unblock D2:0F:A1:0C:48:3F"
405 + );
406 + assert_eq!(
407 + backend.remove(&device).unwrap().display(),
408 + "bluetoothctl remove D2:0F:A1:0C:48:3F"
409 + );
410 + assert_eq!(
411 + backend.power(true).unwrap().display(),
412 + "bluetoothctl power on"
413 + );
414 + assert_eq!(
415 + backend.power(false).unwrap().display(),
416 + "bluetoothctl power off"
417 + );
418 + }
419 +
420 + /// Pairing carries an agent capability that can answer a passkey prompt,
421 + /// and the scan is bounded. Both are the reason those two suspend rather
422 + /// than run captured.
423 + #[test]
424 + fn pairing_registers_an_agent_and_scanning_is_bounded() {
425 + let backend = BluetoothCtl { pactl: true };
426 + assert_eq!(
427 + backend.pair(&mouse()).unwrap().display(),
428 + "bluetoothctl --agent KeyboardDisplay pair D2:0F:A1:0C:48:3F"
429 + );
430 + assert_eq!(
431 + backend.scan().unwrap().display(),
432 + format!("bluetoothctl --timeout {SCAN_SECONDS} scan on")
433 + );
434 + }
435 +
436 + /// Without pactl the audio handoff offers nothing rather than offering a
437 + /// command that would fail.
438 + #[test]
439 + fn no_pactl_means_no_audio_handoff() {
440 + let backend = BluetoothCtl { pactl: false };
441 + assert!(backend.sinks().is_none());
442 + assert!(backend.set_default_sink("anything").is_none());
443 + }
444 +
445 + /// Read against this machine's real adapter and devices. Ignored by
446 + /// default: it depends on what is paired, so it prints rather than
447 + /// asserting shape. Run it after touching the parsers.
448 + #[test]
449 + #[ignore = "depends on what is paired with this machine"]
450 + fn reads_this_machines_real_devices() {
451 + let mut log = CommandLog::new();
452 + let backend = BluetoothCtl { pactl: false };
453 + println!("radio: {:?}", backend.radio(&mut log));
454 + match backend.adapter(&mut log) {
455 + Ok(adapter) => println!("{adapter:?}"),
456 + Err(err) => println!("no adapter: {err}"),
457 + }
458 + for (address, name) in backend.devices(&mut log).unwrap_or_default() {
459 + match backend.info(&address, &mut log) {
460 + Ok(device) => println!(
461 + "{:<22} {:<19} {:<10} {}",
462 + device.name,
463 + device.address,
464 + device.kind(),
465 + device.standing().label()
466 + ),
467 + Err(err) => println!("{name} ({address}): {err}"),
468 + }
469 + }
470 + }
471 + }
@@ -1,0 +1,77 @@
1 + //! Captured tool output, and the two devices built from it.
2 + //!
3 + //! Here rather than in one module's `mod tests` because the parser tests, the
4 + //! model tests and the backend tests all read the same two devices, and three
5 + //! copies of a capture is three things to re-capture. A fixture only one
6 + //! module's tests use stays in that module.
7 + //!
8 + //! Every capture is verbatim: the tab indentation under an untabbed header is
9 + //! what the parsers are pinned against, so nothing here may be tidied.
10 +
11 + use super::model::Device;
12 + use super::parse::parse_info;
13 +
14 + // Captured verbatim from `bluetoothctl show` on fw13 (bluez 5.72,
15 + // 2026-08-05), with the UUID block cut to two lines. Kept real rather than
16 + // tidied: the fields are tab-indented under an untabbed `Controller`
17 + // header, the address appears only on that header, and `Name` and `Alias`
18 + // are separate fields that happen to agree here.
19 + pub(super) const SHOW: &str = "Controller D8:B3:2F:BD:B8:78 (public)
20 + \tManufacturer: 0x0046 (70)
21 + \tName: fw13
22 + \tAlias: fw13
23 + \tClass: 0x006c010c (7078156)
24 + \tPowered: yes
25 + \tDiscoverable: no
26 + \tDiscoverableTimeout: 0x000000b4 (180)
27 + \tPairable: yes
28 + \tUUID: Audio Sink (0000110b-0000-1000-8000-00805f9b34fb)
29 + \tUUID: Handsfree (0000111e-0000-1000-8000-00805f9b34fb)
30 + \tDiscovering: no
31 + \tRoles: central
32 + ";
33 +
34 + // Captured verbatim from `bluetoothctl info D2:0F:A1:0C:48:3F` on the same
35 + // machine. This is the fixture the whole verb is written around: paired and
36 + // bonded and *not* trusted, which is the combination a user reads as
37 + // Bluetooth forgetting their mouse.
38 + const INFO_MOUSE: &str = "Device D2:0F:A1:0C:48:3F (random)
39 + \tName: MX Master 4
40 + \tAlias: MX Master 4
41 + \tAppearance: 0x03c2 (962)
42 + \tIcon: input-mouse
43 + \tPaired: yes
44 + \tBonded: yes
45 + \tTrusted: no
46 + \tBlocked: no
47 + \tConnected: no
48 + \tLegacyPairing: no
49 + \tUUID: Battery Service (0000180f-0000-1000-8000-00805f9b34fb)
50 + \tModalias: usb:v046DpB042d0015
51 + ";
52 +
53 + // A connected headset, from the same command shape. `Battery Percentage` is
54 + // bluez's hex-then-decimal pair and only appears while connected.
55 + const INFO_HEADSET: &str = "Device C0:28:8D:11:22:33 (public)
56 + \tName: WH-1000XM5
57 + \tAlias: WH-1000XM5
58 + \tIcon: audio-headset
59 + \tPaired: yes
60 + \tBonded: yes
61 + \tTrusted: yes
62 + \tBlocked: no
63 + \tConnected: yes
64 + \tBattery Percentage: 0x5f (95)
65 + ";
66 +
67 + pub(super) const DEVICES: &str = "Device D2:0F:A1:0C:48:3F MX Master 4
68 + Device D1:45:06:A5:F1:FD Logi POP Mouse
69 + ";
70 +
71 + pub(super) fn mouse() -> Device {
72 + parse_info(INFO_MOUSE).expect("the fixture is real bluetoothctl output")
73 + }
74 +
75 + pub(super) fn headset() -> Device {
76 + parse_info(INFO_HEADSET).expect("the fixture is real bluetoothctl output")
77 + }
@@ -1,0 +1,337 @@
1 + //! The five state fields bluez keeps per device, and what they add up to.
2 + //!
3 + //! The substance of the verb: [`Standing`] is the mapping from bluez's five
4 + //! independent booleans to the one sentence a user came for. Everything in the
5 + //! sibling modules exists to feed it. Names std and `alloy_tui` only, so the
6 + //! mapping can be read without reading a parser or a backend.
7 +
8 + use alloy_tui::Severity;
9 +
10 + /// The controller, from `bluetoothctl show`.
11 + #[derive(Debug, Clone, PartialEq, Eq)]
12 + pub(super) struct Adapter {
13 + pub address: String,
14 + /// The controller's name, which is what a phone scanning for this machine
15 + /// sees. `Alias` rather than `Name`: alias is the settable one and is what
16 + /// bluez advertises when the two differ.
17 + pub alias: String,
18 + pub powered: bool,
19 + pub discoverable: bool,
20 + pub pairable: bool,
21 + pub discovering: bool,
22 + }
23 +
24 + /// rfkill's verdict on the Bluetooth radio.
25 + ///
26 + /// Separate from [`Adapter::powered`] because they answer different questions
27 + /// and only one of them the console can act on. A soft block is software and
28 + /// `rfkill unblock bluetooth` clears it. A hard block is a physical switch or a
29 + /// firmware key, and no command clears it, so saying "press w" there would be
30 + /// advice that cannot work.
31 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 + pub(super) enum Radio {
33 + Unblocked,
34 + Soft,
35 + Hard,
36 + /// rfkill is absent, or lists no Bluetooth line. Reported as unknown rather
37 + /// than assumed unblocked: a missing answer is not a negative one.
38 + Unknown,
39 + }
40 +
41 + impl Radio {
42 + /// Why the adapter is down, phrased to be the whole of what the user needs
43 + /// to do next. `None` where the radio is not the reason.
44 + pub(super) const fn blocker(self) -> Option<&'static str> {
45 + match self {
46 + // Named as a switch rather than as rfkill: the fix is a key on the
47 + // keyboard, and the tool that reported it is not the tool that
48 + // fixes it.
49 + Self::Hard => {
50 + Some("the radio is blocked by a hardware switch, which no command can clear")
51 + }
52 + Self::Soft => Some("the radio is soft-blocked (rfkill unblock bluetooth)"),
53 + Self::Unblocked | Self::Unknown => None,
54 + }
55 + }
56 + }
57 +
58 + /// One device bluez knows about, with every state field it keeps.
59 + #[derive(Debug, Clone, PartialEq, Eq)]
60 + pub(super) struct Device {
61 + pub address: String,
62 + pub name: String,
63 + /// `public` or `random`. Kept because a random address is why a device can
64 + /// appear twice under two addresses, which is otherwise unexplainable.
65 + pub address_type: Option<String>,
66 + /// bluez's `Icon`: `audio-headset`, `input-mouse`, `phone`. Absent on
67 + /// plenty of BLE devices, which is itself worth showing.
68 + pub icon: Option<String>,
69 + pub paired: bool,
70 + pub bonded: bool,
71 + pub trusted: bool,
72 + pub blocked: bool,
73 + pub connected: bool,
74 + /// `Battery Percentage`, which bluez only publishes for a connected device
75 + /// that reports it.
76 + pub battery: Option<u8>,
77 + }
78 +
79 + /// What the five booleans add up to, and the whole point of the verb.
80 + ///
81 + /// Ordered by what decides the answer rather than by severity: blocked beats
82 + /// everything because nothing else on the screen works while it holds, and
83 + /// connected is reported before paired because it is the more immediate fact.
84 + /// Trusted is never folded away, in either direction, since it is the field the
85 + /// user came here without knowing about.
86 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
87 + pub(super) enum Standing {
88 + /// bluez refuses this device outright.
89 + Blocked,
90 + /// Connected and trusted. What people mean when they say paired.
91 + Connected,
92 + /// Connected right now, and it will not come back on its own.
93 + ConnectedUntrusted,
94 + /// Keys exchanged and trusted, no link up at the moment.
95 + Trusted,
96 + /// Keys exchanged, not trusted, not connected. The state that reads as
97 + /// Bluetooth having forgotten the device.
98 + Paired,
99 + /// An address bluez has seen and holds no keys for.
100 + Seen,
101 + }
102 +
103 + impl Standing {
104 + /// Read the five fields.
105 + pub(super) const fn of(device: &Device) -> Self {
106 + if device.blocked {
107 + Self::Blocked
108 + } else if device.connected {
109 + if device.trusted {
110 + Self::Connected
111 + } else {
112 + Self::ConnectedUntrusted
113 + }
114 + } else if device.paired {
115 + if device.trusted {
116 + Self::Trusted
117 + } else {
118 + Self::Paired
119 + }
120 + } else {
121 + Self::Seen
122 + }
123 + }
124 +
125 + /// The row label. Long enough to carry the trust half, because a row that
126 + /// says only "connected" is the lie this verb exists to stop telling.
127 + pub(super) const fn label(self) -> &'static str {
128 + match self {
129 + Self::Blocked => "blocked",
130 + Self::Connected => "connected",
131 + Self::ConnectedUntrusted => "connected, untrusted",
132 + Self::Trusted => "trusted, not connected",
133 + Self::Paired => "paired, untrusted",
134 + Self::Seen => "not paired",
135 + }
136 + }
137 +
138 + pub(super) const fn severity(self) -> Severity {
139 + match self {
140 + Self::Blocked => Severity::Error,
141 + Self::Connected => Severity::Healthy,
142 + // Warn rather than Healthy: it works now and will stop working
143 + // later, which is the case a color should catch.
144 + Self::ConnectedUntrusted | Self::Paired => Severity::Warn,
145 + Self::Trusted | Self::Seen => Severity::Info,
146 + }
147 + }
148 +
149 + /// What this combination means, and which key changes it.
150 + ///
151 + /// The text a user actually came for. Written as plain sentences rather
152 + /// than as field documentation, because the reader is someone whose mouse
153 + /// stopped working and not someone reading the bluez API.
154 + pub(super) const fn explain(self) -> &'static str {
155 + match self {
156 + Self::Blocked => {
157 + "bluez is refusing this device. Blocked means it will not connect and will not \
158 + pair, and no other key on this screen will change that while it holds. Press b \
159 + to unblock it."
160 + }
161 + Self::Connected => {
162 + "Connected, and trusted, so bluez accepts it back on its own whenever it is in \
163 + range. This is the state most people mean by paired. Press c to disconnect."
164 + }
165 + Self::ConnectedUntrusted => {
166 + "Connected right now, and not trusted. bluez will not accept a connection it did \
167 + not ask for, so once this device goes out of range or the machine reboots, it \
168 + will not come back on its own. Press t to trust it."
169 + }
170 + Self::Trusted => {
171 + "Paired and trusted, so bluez accepts it whenever it is in range. No link is up \
172 + at the moment, which usually means the device is off or out of range. Press c \
173 + to connect it now."
174 + }
175 + Self::Paired => {
176 + "Paired, so the keys are exchanged, and not trusted, so bluez will not accept it \
177 + back on its own. This is the state that reads as Bluetooth having forgotten the \
178 + device. Press t to trust it, or c to connect it this once."
179 + }
180 + Self::Seen => {
181 + "bluez has seen this address and holds no keys for it. Press p to pair, which \
182 + exchanges keys and moves it to the known list."
183 + }
184 + }
185 + }
186 + }
187 +
188 + impl Device {
189 + pub(super) const fn standing(&self) -> Standing {
190 + Standing::of(self)
191 + }
192 +
193 + /// A short word for the row, from bluez's icon.
194 + ///
195 + /// The icon is a freedesktop name (`audio-headset`, `input-mouse`), which
196 + /// is a fine thing to key off and a poor thing to show. Anything
197 + /// unrecognized is passed through rather than mapped to "device": the raw
198 + /// name is more informative than a shrug.
199 + pub(super) fn kind(&self) -> &str {
200 + match self.icon.as_deref() {
201 + None => "unknown",
202 + Some("audio-headset" | "audio-headphones") => "headset",
203 + Some("audio-card") => "speaker",
204 + Some("input-mouse") => "mouse",
205 + Some("input-keyboard") => "keyboard",
206 + Some("input-gaming") => "gamepad",
207 + Some("input-tablet") => "tablet",
208 + Some(other) => other,
209 + }
210 + }
211 +
212 + /// Whether this is something audio would come out of.
213 + ///
214 + /// Gates the default-sink handoff. bluez's icon is the only classification
215 + /// available before connecting, and it is right often enough; a device that
216 + /// is wrong about it still gets the key, and PipeWire's answer (no matching
217 + /// sink) is the honest refusal.
218 + pub(super) fn is_audio(&self) -> bool {
219 + self.icon
220 + .as_deref()
221 + .is_some_and(|icon| icon.starts_with("audio"))
222 + }
223 +
224 + /// The extra fact worth saying when the headline does not cover it.
225 + ///
226 + /// Paired without bonded means the keys were never written down, so the
227 + /// pairing does not survive a reboot. It is rare and it is invisible in
228 + /// every other tool, which is exactly the combination that earns a line.
229 + pub(super) const fn caveat(&self) -> Option<&'static str> {
230 + if self.paired && !self.bonded {
231 + Some(
232 + "Paired but not bonded: the keys were not written to disk, so this pairing does \
233 + not survive a reboot.",
234 + )
235 + } else {
236 + None
237 + }
238 + }
239 +
240 + /// The PipeWire sink name prefix this device's audio would arrive under.
241 + ///
242 + /// bluez-to-PipeWire naming, measured rather than guessed: a device at
243 + /// `D2:0F:A1:0C:48:3F` becomes `bluez_output.D2_0F_A1_0C_48_3F.1`, with a
244 + /// trailing profile index that is not predictable. So this is a prefix and
245 + /// the caller matches on it.
246 + pub(super) fn sink_prefix(&self) -> String {
247 + format!("bluez_output.{}", self.address.replace(':', "_"))
248 + }
249 + }
250 +
251 + #[cfg(test)]
252 + mod tests {
253 + use super::*;
254 + use crate::bluetooth::fixtures::{headset, mouse};
255 +
256 + /// The claim the whole module rests on: paired and trusted are different
257 + /// questions, and the one that decides reconnection is the second.
258 + #[test]
259 + fn paired_without_trusted_is_its_own_state() {
260 + let device = mouse();
261 + assert_eq!(device.standing(), Standing::Paired);
262 + assert_eq!(device.standing().label(), "paired, untrusted");
263 + assert!(
264 + device.standing().explain().contains("not trusted"),
265 + "the explanation must name the field that decides it"
266 + );
267 + }
268 +
269 + /// Connected is not the whole answer either. A connected untrusted device
270 + /// works now and will not come back, and the row has to say so.
271 + #[test]
272 + fn connected_without_trusted_is_not_reported_as_connected() {
273 + let mut device = headset();
274 + device.trusted = false;
275 + assert_eq!(device.standing(), Standing::ConnectedUntrusted);
276 + assert_eq!(device.standing().label(), "connected, untrusted");
277 + assert_eq!(device.standing().severity(), Severity::Warn);
278 + }
279 +
280 + /// Blocked outranks everything: nothing else on the screen works while it
281 + /// holds, so reporting a blocked device by its pairing state would send the
282 + /// user after the wrong key.
283 + #[test]
284 + fn blocked_outranks_every_other_field() {
285 + let mut device = headset();
286 + device.blocked = true;
287 + assert_eq!(device.standing(), Standing::Blocked);
288 + assert_eq!(device.standing().severity(), Severity::Error);
289 + }
290 +
291 + #[test]
292 + fn every_standing_names_a_key_to_press() {
293 + for standing in [
294 + Standing::Blocked,
295 + Standing::Connected,
296 + Standing::ConnectedUntrusted,
297 + Standing::Trusted,
298 + Standing::Paired,
299 + Standing::Seen,
300 + ] {
301 + let explanation = standing.explain();
302 + assert!(
303 + explanation.contains("Press "),
304 + "{standing:?} explains nothing to do: {explanation}"
305 + );
306 + }
307 + }
308 +
309 + /// Paired without bonded means the keys were never written down. It is rare
310 + /// and invisible everywhere else, which is what earns it a line.
311 + #[test]
312 + fn paired_without_bonded_earns_a_caveat() {
313 + let mut device = mouse();
314 + assert_eq!(device.caveat(), None);
315 + device.bonded = false;
316 + assert!(device.caveat().unwrap().contains("does not survive"));
317 + }
318 +
319 + #[test]
320 + fn a_mouse_is_not_an_audio_device() {
321 + assert!(!mouse().is_audio());
322 + assert_eq!(mouse().kind(), "mouse");
323 + assert!(headset().is_audio());
324 + assert_eq!(headset().kind(), "headset");
325 + }
326 +
327 + /// An unrecognized icon is shown rather than flattened to "device": the raw
328 + /// freedesktop name tells the user more than a shrug does.
329 + #[test]
330 + fn an_unknown_icon_is_passed_through() {
331 + let mut device = mouse();
332 + device.icon = Some("video-display".to_string());
333 + assert_eq!(device.kind(), "video-display");
334 + device.icon = None;
335 + assert_eq!(device.kind(), "unknown");
336 + }
337 + }
@@ -1,0 +1,282 @@
1 + //! Text in, [`model`](super::model) out.
2 + //!
3 + //! Every parser here is total on the text it is given and spawns nothing, which
4 + //! is what lets the captured fixtures in `fixtures` stand in for the tools.
5 + //! Names the model and std only: a parser that logged would need
6 + //! `crate::cli::CommandLog`, and that is [`backend`](super::backend)'s business.
7 +
8 + use anyhow::{Context, Result};
9 + use serde::Deserialize;
10 +
11 + use super::model::{Adapter, Device, Radio};
12 +
13 + /// One `Key: value` field from `show` or `info` output.
14 + ///
15 + /// Both commands emit tab-indented `Key: value` lines under an untabbed header.
16 + /// Trimming rather than matching the tab keeps this working against either, and
17 + /// against the untabbed header line if a key ever moves there.
18 + fn field<'a>(raw: &'a str, key: &str) -> Option<&'a str> {
19 + raw.lines().find_map(|line| {
20 + let line = line.trim();
21 + line.strip_prefix(key)?.strip_prefix(':').map(str::trim)
22 + })
23 + }
24 +
25 + /// A bluez boolean field. Anything other than `yes` reads as false, including
26 + /// a missing field: bluez omits what does not apply, and an absent `Connected`
27 + /// means not connected.
28 + fn flag(raw: &str, key: &str) -> bool {
29 + field(raw, key) == Some("yes")
30 + }
31 +
32 + /// The decimal inside bluez's `0x5f (95)` pairs.
33 + ///
34 + /// bluez prints numbers twice, hex then decimal in parentheses. The decimal is
35 + /// the one worth reading and the parentheses are what make it findable.
36 + fn paren_decimal(raw: &str) -> Option<u8> {
37 + let start = raw.find('(')? + 1;
38 + let end = raw[start..].find(')')? + start;
39 + raw[start..end].trim().parse().ok()
40 + }
41 +
42 + /// `bluetoothctl devices` output: `Device <address> <name>` per line.
43 + ///
44 + /// A device bluez has no name for is listed with its address in the name
45 + /// position, dashed rather than colonned. That is passed through as the name
46 + /// rather than blanked, because it is what `bluetoothctl` shows and matching it
47 + /// is how a user connects the two screens.
48 + pub(super) fn parse_device_list(raw: &str) -> Vec<(String, String)> {
49 + raw.lines()
50 + .filter_map(|line| {
51 + let rest = line.trim().strip_prefix("Device ")?;
52 + let (address, name) = rest.split_once(' ')?;
53 + // A blank name would render as an empty column with a cursor on it.
54 + let name = if name.trim().is_empty() {
55 + address
56 + } else {
57 + name.trim()
58 + };
59 + Some((address.to_string(), name.to_string()))
60 + })
61 + .collect()
62 + }
63 +
64 + /// `bluetoothctl show`.
65 + ///
66 + /// The address comes from the header line, `Controller <address> (public)`,
67 + /// which is the only place it appears.
68 + pub(super) fn parse_show(raw: &str) -> Result<Adapter> {
69 + let address = raw
70 + .lines()
71 + .find_map(|line| line.trim().strip_prefix("Controller "))
72 + .and_then(|rest| rest.split_whitespace().next())
73 + .context("`bluetoothctl show` named no controller")?
74 + .to_string();
75 +
76 + Ok(Adapter {
77 + alias: field(raw, "Alias")
78 + .or_else(|| field(raw, "Name"))
79 + .unwrap_or(&address)
80 + .to_string(),
81 + address,
82 + powered: flag(raw, "Powered"),
83 + discoverable: flag(raw, "Discoverable"),
84 + pairable: flag(raw, "Pairable"),
85 + discovering: flag(raw, "Discovering"),
86 + })
87 + }
88 +
89 + /// `bluetoothctl info <address>`.
90 + ///
91 + /// The header is `Device <address> (random)`, and the address type in those
92 + /// parentheses appears nowhere else.
93 + pub(super) fn parse_info(raw: &str) -> Result<Device> {
94 + let header = raw
95 + .lines()
96 + .find_map(|line| line.trim().strip_prefix("Device "))
97 + .context("`bluetoothctl info` named no device")?;
98 + let mut parts = header.split_whitespace();
99 + let address = parts
100 + .next()
101 + .context("`bluetoothctl info` named no address")?
102 + .to_string();
103 + let address_type = parts
104 + .next()
105 + .map(|kind| kind.trim_matches(['(', ')']).to_string());
106 +
107 + Ok(Device {
108 + name: field(raw, "Name")
109 + .or_else(|| field(raw, "Alias"))
110 + .unwrap_or(&address)
111 + .to_string(),
112 + address,
113 + address_type,
114 + icon: field(raw, "Icon").map(str::to_string),
115 + paired: flag(raw, "Paired"),
116 + bonded: flag(raw, "Bonded"),
117 + trusted: flag(raw, "Trusted"),
118 + blocked: flag(raw, "Blocked"),
119 + connected: flag(raw, "Connected"),
120 + battery: field(raw, "Battery Percentage").and_then(paren_decimal),
121 + })
122 + }
123 +
124 + /// `rfkill --noheadings --output TYPE,SOFT,HARD list bluetooth`.
125 + ///
126 + /// One line per radio: `bluetooth unblocked unblocked`. Hard is reported ahead
127 + /// of soft where both hold, because it is the one no command clears and telling
128 + /// someone to run `rfkill unblock` there wastes their time.
129 + pub(super) fn parse_rfkill(raw: &str) -> Radio {
130 + let mut soft = false;
131 + let mut hard = false;
132 + let mut seen = false;
133 + for line in raw.lines() {
134 + let mut columns = line.split_whitespace();
135 + if columns.next() != Some("bluetooth") {
136 + continue;
137 + }
138 + seen = true;
139 + soft |= columns.next() == Some("blocked");
140 + hard |= columns.next() == Some("blocked");
141 + }
142 + match (seen, hard, soft) {
143 + (false, _, _) => Radio::Unknown,
144 + (true, true, _) => Radio::Hard,
145 + (true, false, true) => Radio::Soft,
146 + (true, false, false) => Radio::Unblocked,
147 + }
148 + }
149 +
150 + #[derive(Deserialize)]
151 + struct PactlSink {
152 + name: String,
153 + }
154 +
155 + /// The PipeWire sink a connected device's audio arrives on, if there is one.
156 + ///
157 + /// Prefix match rather than equality: the sink name carries a trailing profile
158 + /// index that depends on which profile the device negotiated, and a headset
159 + /// that switches from A2DP to HFP changes it.
160 + pub(super) fn bluetooth_sink(raw: &str, device: &Device) -> Result<Option<String>> {
161 + let sinks: Vec<PactlSink> = serde_json::from_str(raw).context("pactl emitted invalid JSON")?;
162 + let prefix = device.sink_prefix();
163 + Ok(sinks
164 + .into_iter()
165 + .map(|sink| sink.name)
166 + .find(|name| name.starts_with(&prefix)))
167 + }
168 +
169 + #[cfg(test)]
170 + mod tests {
171 + use super::*;
172 + use crate::bluetooth::fixtures::{DEVICES, SHOW, headset, mouse};
173 +
174 + #[test]
175 + fn the_controller_address_comes_off_the_header() {
176 + let adapter = parse_show(SHOW).expect("the fixture is real bluetoothctl output");
177 + assert_eq!(adapter.address, "D8:B3:2F:BD:B8:78");
178 + assert_eq!(adapter.alias, "fw13");
179 + assert!(adapter.powered);
180 + assert!(adapter.pairable);
181 + assert!(!adapter.discoverable);
182 + assert!(!adapter.discovering);
183 + }
184 +
185 + /// `show` carries an `Audio Sink` UUID line. A field reader that matched on
186 + /// a substring rather than on a key would find "Sink" inside it, so the
187 + /// parser is pinned against the shape that would break it.
188 + #[test]
189 + fn uuid_lines_are_not_mistaken_for_fields() {
190 + let adapter = parse_show(SHOW).expect("real output");
191 + assert_eq!(adapter.alias, "fw13", "a UUID line leaked into a field");
192 + assert_eq!(field(SHOW, "Discovering"), Some("no"));
193 + }
194 +
195 + #[test]
196 + fn a_controllerless_show_is_an_error() {
197 + assert!(parse_show("No default controller available\n").is_err());
198 + }
199 +
200 + #[test]
201 + fn the_device_list_splits_address_from_name() {
202 + let listed = parse_device_list(DEVICES);
203 + assert_eq!(listed.len(), 2);
204 + assert_eq!(listed[0].0, "D2:0F:A1:0C:48:3F");
205 + assert_eq!(listed[0].1, "MX Master 4");
206 + // Names with spaces survive: splitting on every space would truncate
207 + // every Logitech device on the machine to "MX".
208 + assert_eq!(listed[1].1, "Logi POP Mouse");
209 + }
210 +
211 + /// bluez lists a nameless device with its address in the name position.
212 + /// A blank name would render as an empty column with a cursor on it.
213 + #[test]
214 + fn a_nameless_device_keeps_an_identifier() {
215 + let listed = parse_device_list("Device AA:BB:CC:DD:EE:FF AA-BB-CC-DD-EE-FF\n");
216 + assert_eq!(listed[0].1, "AA-BB-CC-DD-EE-FF");
217 + }
218 +
219 + #[test]
220 + fn info_reads_every_state_field() {
221 + let device = mouse();
222 + assert_eq!(device.address, "D2:0F:A1:0C:48:3F");
223 + assert_eq!(device.name, "MX Master 4");
224 + assert_eq!(device.address_type.as_deref(), Some("random"));
225 + assert_eq!(device.icon.as_deref(), Some("input-mouse"));
226 + assert!(device.paired);
227 + assert!(device.bonded);
228 + assert!(!device.trusted);
229 + assert!(!device.blocked);
230 + assert!(!device.connected);
231 + assert_eq!(device.battery, None);
232 + }
233 +
234 + /// bluez prints numbers as hex then decimal in parentheses, and the decimal
235 + /// is the readable one. Reading the hex would report a 95% battery as 5.
236 + #[test]
237 + fn battery_comes_from_the_decimal_in_the_parentheses() {
238 + assert_eq!(headset().battery, Some(95));
239 + assert_eq!(paren_decimal("0x5f (95)"), Some(95));
240 + assert_eq!(paren_decimal("no parentheses"), None);
241 + }
242 +
243 + #[test]
244 + fn rfkill_reports_hard_ahead_of_soft() {
245 + assert_eq!(
246 + parse_rfkill("bluetooth unblocked unblocked\n"),
247 + Radio::Unblocked
248 + );
249 + assert_eq!(parse_rfkill("bluetooth blocked unblocked\n"), Radio::Soft);
250 + assert_eq!(parse_rfkill("bluetooth unblocked blocked\n"), Radio::Hard);
251 + // Both set: the hardware switch is the one no command clears, so it is
252 + // the one the user is told about.
253 + assert_eq!(parse_rfkill("bluetooth blocked blocked\n"), Radio::Hard);
254 + }
255 +
256 + /// A machine with rfkill and no Bluetooth radio, and a machine with no
257 + /// rfkill, both report unknown. Assuming unblocked would put "press w" on
258 + /// screen for a machine where it cannot help.
259 + #[test]
260 + fn a_missing_rfkill_line_is_unknown_rather_than_unblocked() {
261 + assert_eq!(parse_rfkill(""), Radio::Unknown);
262 + assert_eq!(parse_rfkill("wlan unblocked unblocked\n"), Radio::Unknown);
263 + assert_eq!(Radio::Unknown.blocker(), None);
264 + assert_eq!(Radio::Unblocked.blocker(), None);
265 + assert!(Radio::Hard.blocker().unwrap().contains("hardware switch"));
266 + }
267 +
268 + /// The bluez-to-PipeWire naming, matched by prefix because the trailing
269 + /// profile index changes when a headset renegotiates.
270 + #[test]
271 + fn the_sink_is_matched_by_address_prefix() {
272 + let raw = r#"[
273 + {"name":"alsa_output.pci-0000_c1_00.6.HiFi__Speaker__sink"},
274 + {"name":"bluez_output.C0_28_8D_11_22_33.1"}
275 + ]"#;
276 + let found = bluetooth_sink(raw, &headset()).expect("valid JSON");
277 + assert_eq!(found.as_deref(), Some("bluez_output.C0_28_8D_11_22_33.1"));
278 +
279 + // A device with no sink is not an error, it is an answer.
280 + assert_eq!(bluetooth_sink(raw, &mouse()).expect("valid JSON"), None);
281 + }
282 + }
@@ -1,0 +1,1138 @@
1 + //! The screen: tabs, rows, the detail block, and the keys.
2 + //!
3 + //! The top of the stack. Names every sibling and is named by none of them, so a
4 + //! change to what is drawn cannot reach the parsers or the argv.
5 +
6 + use alloy_tui::keys::Action;
7 + use alloy_tui::{
8 + AlloyBlock, AlloyList, AlloyTabs, Cursor, FocusRing, Hint, KeyGroup, Severity, Theme, binding,
9 + hint, text, unavailable,
10 + };
11 + use anyhow::Result;
12 + use ratatui::Frame;
13 + use ratatui::crossterm::event::{KeyCode, KeyEvent};
14 + use ratatui::layout::{Constraint, Layout, Rect};
15 + use ratatui::text::{Line, Span};
16 + use ratatui::widgets::{Paragraph, Wrap};
17 +
18 + use super::SCAN_SECONDS;
19 + use super::backend::{Backend, detect};
20 + use super::model::{Adapter, Device, Radio};
21 + use super::parse::bluetooth_sink;
22 + use crate::cli::{CommandLog, Invocation};
23 + use crate::shell::{Confirm, Flow, View, block_title, truncate};
24 +
25 + /// Which list is showing.
26 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 + pub(crate) enum Tab {
28 + /// Devices bluez holds keys for. The default: it is what the verb is for,
29 + /// and it is the list a user opens the screen already having a device on.
30 + Known,
31 + /// Addresses bluez has seen and has no keys for. Populated by a scan, and
32 + /// by whatever bluez cached from earlier ones.
33 + Nearby,
34 + }
35 +
36 + impl Tab {
37 + const ALL: [Tab; 2] = [Tab::Known, Tab::Nearby];
38 +
39 + const fn label(self) -> &'static str {
40 + match self {
41 + Self::Known => "known",
42 + Self::Nearby => "nearby",
43 + }
44 + }
45 +
46 + const fn slot(self) -> usize {
47 + match self {
48 + Self::Known => 0,
49 + Self::Nearby => 1,
50 + }
51 + }
52 +
53 + const fn from_slot(slot: usize) -> Self {
54 + match slot {
55 + 0 => Self::Known,
56 + _ => Self::Nearby,
57 + }
58 + }
59 + }
60 +
61 + /// What the pending confirmation would do once answered.
62 + enum PendingAction {
63 + /// Forget a device: drops the keys, not only the list entry.
64 + Remove(Device),
65 + }
66 +
67 + /// The `alloy bluetooth` screen.
68 + pub(crate) struct BluetoothView {
69 + backend: Box<dyn Backend>,
70 + adapter: Option<Adapter>,
71 + radio: Radio,
72 + devices: Vec<Device>,
73 + tabs: FocusRing,
74 + /// One cursor per tab, for `disk`'s reason: the lists are different lengths
75 + /// and a shared cursor moves a selection the user cannot see.
76 + known_cursor: Cursor,
77 + nearby_cursor: Cursor,
78 + error: Option<String>,
79 + /// A one-line report of what an action did, for the cases where the state
80 + /// change is not visible in the rows. Setting the default sink is the whole
81 + /// of that category today.
82 + note: Option<String>,
83 + pending_action: Option<PendingAction>,
84 + }
85 +
86 + impl BluetoothView {
87 + pub(crate) fn new(tab: Tab, log: &mut CommandLog) -> Self {
88 + let mut tabs = FocusRing::new(Tab::ALL.len());
89 + tabs.focus(tab.slot());
90 +
91 + let mut view = Self {
92 + backend: detect(),
93 + adapter: None,
94 + radio: Radio::Unknown,
95 + devices: Vec::new(),
96 + tabs,
97 + known_cursor: Cursor::new(),
98 + nearby_cursor: Cursor::new(),
99 + error: None,
100 + note: None,
101 + pending_action: None,
102 + };
103 + view.refresh(log);
104 + view
105 + }
106 +
107 + fn tab(&self) -> Tab {
108 + Tab::from_slot(self.tabs.current())
109 + }
110 +
111 + fn rows(&self) -> Vec<&Device> {
112 + self.devices
113 + .iter()
114 + .filter(|device| match self.tab() {
115 + Tab::Known => device.paired,
116 + Tab::Nearby => !device.paired,
117 + })
118 + .collect()
119 + }
120 +
121 + fn cursor(&mut self) -> &mut Cursor {
122 + match self.tab() {
123 + Tab::Known => &mut self.known_cursor,
124 + Tab::Nearby => &mut self.nearby_cursor,
125 + }
126 + }
127 +
128 + fn selected(&self) -> Option<Device> {
129 + let cursor = match self.tab() {
130 + Tab::Known => &self.known_cursor,
131 + Tab::Nearby => &self.nearby_cursor,
132 + };
133 + self.rows().get(cursor.selected()?).map(|d| (*d).clone())
134 + }
135 +
136 + /// Re-read the adapter and every device.
137 + ///
138 + /// One `devices` call plus one `info` per device, and the `info` reads are
139 + /// quiet. The count is the honest cost of showing five real fields per row:
140 + /// `devices Paired` and friends would answer four of them in a fixed number
141 + /// of calls and cannot answer Blocked at all, which would leave a blocked
142 + /// device rendering as an ordinary paired one. Refresh is manual, so the
143 + /// cost lands on a key the user pressed.
144 + ///
145 + /// A failed read keeps the previous rows and reports the error, on `disk`'s
146 + /// rule: stale rows are still the best information available.
147 + fn refresh(&mut self, log: &mut CommandLog) {
148 + self.radio = self.backend.radio(log);
149 +
150 + match self.backend.adapter(log) {
151 + Ok(adapter) => {
152 + self.adapter = Some(adapter);
153 + self.error = None;
154 + }
155 + Err(err) => {
156 + self.error = Some(err.to_string());
157 + self.adapter = None;
158 + }
159 + }
160 +
161 + match self.backend.devices(log) {
162 + Ok(listed) => {
163 + let devices = log.quiet(|log| {
164 + listed
165 + .into_iter()
166 + .map(|(address, name)| {
167 + // A device that disappears between the list and the
168 + // info read still gets a row, carrying what the
169 + // list knew. Dropping it would make a device vanish
170 + // from a refresh for a reason nothing explains.
171 + self.backend.info(&address, log).unwrap_or(Device {
172 + address,
173 + name,
174 + address_type: None,
175 + icon: None,
176 + paired: false,
177 + bonded: false,
178 + trusted: false,
179 + blocked: false,
180 + connected: false,
181 + battery: None,
182 + })
183 + })
184 + .collect()
185 + });
186 + self.devices = devices;
187 + self.error = None;
188 + }
189 + Err(err) => self.error = Some(err.to_string()),
190 + }
191 +
192 + // Both cursors, whichever tab is showing: switching tabs must not land
193 + // on an index that no longer exists.
194 + let known = self.devices.iter().filter(|d| d.paired).count();
195 + let nearby = self.devices.len() - known;
196 + self.known_cursor.resize(known);
197 + self.nearby_cursor.resize(nearby);
198 + }
199 +
200 + /// The standard post-action shape: clear the error and re-read on success,
201 + /// report and keep the rows on failure.
202 + fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
203 + match result {
204 + Ok(()) => {
205 + self.error = None;
206 + log.quiet(|log| self.refresh(log));
207 + }
208 + Err(err) => self.error = Some(err.to_string()),
209 + }
210 + }
211 +
212 + /// Whether bluez is here to act at all.
213 + fn can_act(&self) -> bool {
214 + self.adapter.is_some() && self.backend.power(true).is_some()
215 + }
216 +
217 + /// Whether the adapter is up. Every per-device action needs this, and
218 + /// saying so once beats six identical refusals.
219 + fn powered(&self) -> bool {
220 + self.adapter.as_ref().is_some_and(|a| a.powered)
221 + }
222 +
223 + /// The selection, or an error set and `None`. Every action starts here.
224 + fn require_selection(&mut self) -> Option<Device> {
225 + match self.selected() {
226 + Some(device) => Some(device),
227 + None => {
228 + self.error = Some("nothing selected".to_string());
229 + None
230 + }
231 + }
232 + }
233 +
234 + /// Run an action built from the selected device.
235 + ///
236 + /// The shape every per-device key shares: require a selection, refuse with
237 + /// a reason if the adapter is down, refuse with a reason if the backend
238 + /// offers no command, otherwise run and re-read.
239 + fn act(
240 + &mut self,
241 + log: &mut CommandLog,
242 + build: impl Fn(&dyn Backend, &Device) -> Option<Invocation>,
243 + ) {
244 + let Some(device) = self.require_selection() else {
245 + return;
246 + };
247 + if !self.powered() {
248 + self.error = Some(format!(
249 + "{} cannot act: {}",
250 + device.name,
251 + self.power_reason()
252 + ));
253 + return;
254 + }
255 + let Some(invocation) = build(self.backend.as_ref(), &device) else {
256 + self.error = Some(format!(
257 + "{} offers no command for {}",
258 + self.backend.name(),
259 + device.name
260 + ));
261 + return;
262 + };
263 + self.finish(invocation.run(log).map(drop), log);
264 + }
265 +
266 + /// Why the adapter is down, in the words that name the fix.
267 + fn power_reason(&self) -> String {
268 + self.radio.blocker().map_or_else(
269 + || "the adapter is not powered (press w)".to_string(),
270 + str::to_string,
271 + )
272 + }
273 +
274 + /// Connect or disconnect, whichever the current state makes meaningful.
275 + ///
276 + /// One key rather than two, because the pair of them is one question and
277 + /// the row already says which way it will go.
278 + fn toggle_connection(&mut self, log: &mut CommandLog) {
279 + let Some(device) = self.selected() else {
280 + self.error = Some("nothing selected".to_string());
281 + return;
282 + };
283 + if device.blocked {
284 + self.error = Some(format!(
285 + "{} is blocked, so it will not connect. Press b to unblock it.",
286 + device.name
287 + ));
288 + return;
289 + }
290 + if !device.paired && !device.connected {
291 + self.error = Some(format!(
292 + "{} is not paired, so there are no keys to connect with. Press p to pair it.",
293 + device.name
294 + ));
295 + return;
296 + }
297 + let connected = device.connected;
298 + self.act(log, move |backend, device| {
299 + if connected {
300 + backend.disconnect(device)
301 + } else {
302 + backend.connect(device)
303 + }
304 + });
305 + }
306 +
307 + fn toggle_trust(&mut self, log: &mut CommandLog) {
308 + let Some(device) = self.selected() else {
309 + self.error = Some("nothing selected".to_string());
310 + return;
311 + };
312 + let wanted = !device.trusted;
313 + self.act(log, move |backend, device| backend.trust(device, wanted));
314 + }
315 +
316 + fn toggle_block(&mut self, log: &mut CommandLog) {
317 + let Some(device) = self.selected() else {
318 + self.error = Some("nothing selected".to_string());
319 + return;
320 + };
321 + let wanted = !device.blocked;
322 + self.act(log, move |backend, device| backend.block(device, wanted));
323 + }
324 +
325 + fn toggle_power(&mut self, log: &mut CommandLog) {
326 + // A hard block is the one state where the obvious key cannot work, and
327 + // saying so beats running a command that fails with a D-Bus error.
328 + if let Radio::Hard = self.radio {
329 + self.error = Some(
330 + "the radio is blocked by a hardware switch. Nothing here can clear it; \
331 + use the machine's Bluetooth key."
332 + .to_string(),
333 + );
334 + return;
335 + }
336 + let wanted = !self.powered();
337 + let Some(invocation) = self.backend.power(wanted) else {
338 + self.error = Some(format!("{} cannot power the adapter", self.backend.name()));
339 + return;
340 + };
341 + self.finish(invocation.run(log).map(drop), log);
342 + }
343 +
344 + /// Pair, by handing the terminal to bluetoothctl.
345 + ///
346 + /// Suspended rather than captured so the agent has somewhere to ask. A
347 + /// device that wants a passkey confirmed prints the number and waits for an
348 + /// answer; captured, that is a hang with no output, which is the single
349 + /// worst thing this screen could do.
350 + fn pair_selected(&mut self, log: &mut CommandLog) -> Flow {
351 + let Some(device) = self.require_selection() else {
352 + return Flow::Continue;
353 + };
354 + if device.paired {
355 + self.error = Some(format!(
356 + "{} is already paired. Press c to connect it, or t to trust it.",
357 + device.name
358 + ));
359 + return Flow::Continue;
360 + }
361 + if !self.powered() {
362 + self.error = Some(format!("cannot pair: {}", self.power_reason()));
363 + return Flow::Continue;
364 + }
365 + let Some(invocation) = self.backend.pair(&device) else {
366 + self.error = Some(format!("{} cannot pair", self.backend.name()));
367 + return Flow::Continue;
368 + };
369 + // Logged here rather than by `run`, because a suspended command outlives
370 + // this call and has no captured outcome to record.
371 + log.record(invocation.display(), Severity::Info);
372 + Flow::Suspend(invocation.command())
373 + }
374 +
375 + /// Scan, by handing the terminal to bluetoothctl for [`SCAN_SECONDS`].
376 + fn scan(&mut self, log: &mut CommandLog) -> Flow {
377 + if !self.powered() {
378 + self.error = Some(format!("cannot scan: {}", self.power_reason()));
379 + return Flow::Continue;
380 + }
381 + let Some(invocation) = self.backend.scan() else {
382 + self.error = Some(format!("{} cannot scan", self.backend.name()));
383 + return Flow::Continue;
384 + };
385 + log.record(invocation.display(), Severity::Info);
386 + Flow::Suspend(invocation.command())
387 + }
388 +
389 + /// Hand a connected audio device to PipeWire as the default sink.
390 + ///
391 + /// Offered rather than taken. A headset that grabs the default sink the
392 + /// moment it connects is the behavior that moves a call into an earpiece
393 + /// nobody is wearing, and the whole premise of this screen is that
394 + /// Bluetooth is worse when it acts on its own.
395 + fn hand_to_audio(&mut self, log: &mut CommandLog) {
396 + let Some(device) = self.require_selection() else {
397 + return;
398 + };
399 + if !device.connected {
400 + self.error = Some(format!(
401 + "{} is not connected, so it has no sink yet. Press c first.",
402 + device.name
403 + ));
404 + return;
405 + }
406 + let Some(list) = self.backend.sinks() else {
407 + self.error = Some("pactl is not here, so audio cannot be routed".to_string());
408 + return;
409 + };
410 + // Reading the sink list is the console working out what to run, not an
411 + // action the user asked for, so it stays out of the pane.
412 + let found = log.quiet(|log| list.run(log).and_then(|raw| bluetooth_sink(&raw, &device)));
413 + let sink = match found {
414 + Ok(Some(sink)) => sink,
415 + Ok(None) => {
416 + // Names the reason rather than reporting an absence: a
417 + // connected mouse has no sink and never will, and a connected
418 + // headset without one has not negotiated a profile yet.
419 + self.error = Some(if device.is_audio() {
420 + format!(
421 + "{} is connected but PipeWire has no sink for it yet. Give it a moment \
422 + and press r.",
423 + device.name
424 + )
425 + } else {
426 + format!(
427 + "{} is a {}, so no audio comes out of it",
428 + device.name,
429 + device.kind()
430 + )
431 + });
432 + return;
433 + }
434 + Err(err) => {
435 + self.error = Some(err.to_string());
436 + return;
437 + }
438 + };
439 + let Some(invocation) = self.backend.set_default_sink(&sink) else {
440 + self.error = Some("pactl is not here, so audio cannot be routed".to_string());
441 + return;
442 + };
443 + match invocation.run(log) {
444 + Ok(_) => {
445 + self.error = None;
446 + // The rows do not change when the default sink does, so without
447 + // this the screen would answer a keypress with nothing.
448 + self.note = Some(format!("{} is now the default sink", device.name));
449 + }
450 + Err(err) => self.error = Some(err.to_string()),
451 + }
452 + }
453 +
454 + /// Forget the selection.
455 + ///
456 + /// Always confirms, unlike `disk`'s eject, and the asymmetry is deliberate:
457 + /// removing drops the link keys, so the cost of a mistaken press is pairing
458 + /// the device again from scratch, and there is no safe version of the
459 + /// action to leave unconfirmed.
460 + fn remove_selected(&mut self) -> Flow {
461 + let Some(device) = self.require_selection() else {
462 + return Flow::Continue;
463 + };
464 + let message = format!(
465 + "Forget {}? This drops the pairing keys, not only the list entry, so reconnecting \
466 + means pairing it again from the device's own pairing mode.",
467 + device.name
468 + );
469 + self.pending_action = Some(PendingAction::Remove(device));
470 + Flow::Confirm(Confirm::destructive("forget device", message))
471 + }
472 +
473 + // ---- rendering ----
474 +
475 + /// The adapter, as two lines: what it is, and what state it is in.
476 + fn adapter_lines<'a>(&self, theme: &Theme) -> Vec<Line<'a>> {
477 + let Some(adapter) = &self.adapter else {
478 + return vec![
479 + Line::from(text::bold(theme, "no controller")),
480 + Line::from(text::muted(
481 + theme,
482 + "bluetoothd is not answering, or this machine has no Bluetooth adapter",
483 + )),
484 + ];
485 + };
486 +
487 + let mut words: Vec<Span<'a>> = Vec::new();
488 + let mut push = |span: Span<'a>| {
489 + if !words.is_empty() {
490 + words.push(Span::raw(" "));
491 + }
492 + words.push(span);
493 + };
494 +
495 + // The radio comes first when it is the reason, because it outranks
496 + // everything else on the line: a blocked radio makes the rest moot.
497 + match self.radio {
498 + Radio::Hard => push(Span::styled(
499 + "hardware-blocked",
500 + Severity::Error.style(theme),
Lines truncated