max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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, §ion.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(§ion.path) { | |
| 439 | - | continue; | |
| 440 | - | } | |
| 441 | - | rows.extend( | |
| 442 | - | self.bind | |
| 443 | - | .fields() | |
| 444 | - | .iter() | |
| 445 | - | .filter(|field| in_section(&field.path, §ion.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, §ion.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