widgets: stop aborting on machines without backlight/battery/pulseaudio

The release profile builds with panic = "abort", so a panic in any helper
thread took the whole dashboard down:

- backlight: WalkDir yields /sys/class/backlight itself, so detect() was
  always true and new() picked the class dir as the device, then panicked
  reading brightness. Use min_depth(1) and tolerate read errors.
- battery: detect() only checked that UPower's DisplayDevice answered,
  which it does on every machine; use IsPresent. The monitor thread now
  returns an error instead of unwrapping, and publishes its first reading.
- pulseaudio: a failed connection panicked ("damn"); clicks before the
  first sink query unwrapped a None channel map. Both are now handled, and
  the connect loop no longer busy-spins.
- bar widget: don't underflow when narrower than the label column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-03 09:15:54 +00:00
co-authored by Claude Fable 5.1
parent 01af00b5ea
commit c033335912
4 changed files with 126 additions and 111 deletions
+28 -21
View File
@@ -32,23 +32,33 @@ pub struct Backlight {
dirty: bool, dirty: bool,
} }
/// First device under /sys/class/backlight, if any. `min_depth(1)` skips the
/// class directory itself, which otherwise got picked up as a "device".
fn find_device() -> Option<PathBuf> {
WalkDir::new("/sys/class/backlight")
.min_depth(1)
.max_depth(1)
.into_iter()
.flatten()
.map(|e| e.path().to_path_buf())
.next()
}
impl Backlight { impl Backlight {
fn update(&mut self) { fn update(&mut self) {
self.cur = read_file_as_u64(self.device_path.join("brightness").as_path()).unwrap(); if let Ok(cur) = read_file_as_u64(self.device_path.join("brightness").as_path()) {
self.max = read_file_as_u64(self.device_path.join("max_brightness").as_path()).unwrap(); self.cur = cur;
}
if let Ok(max) = read_file_as_u64(self.device_path.join("max_brightness").as_path()) {
self.max = max;
}
} }
pub fn new(path: Option<&str>, fm: &mut FontMap, font: &'static str, size: f32) -> BarWidget { pub fn new(path: Option<&str>, fm: &mut FontMap, font: &'static str, size: f32) -> BarWidget {
let mut device_path = None; let device_path = match path {
if let Some(path) = path { Some(path) => Some(Path::new("/sys/class/backlight").join(path)),
device_path = Some(Path::new("/sys/class/backlight").to_path_buf().join(path)); None => find_device(),
} else { };
for entry in WalkDir::new("/sys/class/backlight") {
if let Ok(entry) = entry {
device_path = Some(entry.path().to_path_buf());
}
}
}
let mut dev = Backlight { let mut dev = Backlight {
device_path: device_path.expect("did not find backlight device"), device_path: device_path.expect("did not find backlight device"),
cur: 0, cur: 0,
@@ -61,18 +71,15 @@ impl Backlight {
} }
pub fn detect() -> bool { pub fn detect() -> bool {
for entry in WalkDir::new("/sys/class/backlight") { find_device().is_some()
if entry.is_ok() {
return true;
}
}
return false;
} }
pub fn set(&mut self, brightness: f32) { pub fn set(&mut self, brightness: f32) {
let val = (self.max as f32 * brightness) as u64; let val = (self.max as f32 * brightness.clamp(0.0, 1.0)) as u64;
let _ = write_file_as_u64(self.device_path.join("brightness").as_path(), val); let _ = write_file_as_u64(self.device_path.join("brightness").as_path(), val);
self.cur = read_file_as_u64(self.device_path.join("brightness").as_path()).unwrap(); if let Ok(cur) = read_file_as_u64(self.device_path.join("brightness").as_path()) {
self.cur = cur;
}
} }
} }
@@ -84,7 +91,7 @@ impl BarWidgetImpl for Backlight {
self.dirty self.dirty
} }
fn value(&mut self) -> f32 { fn value(&mut self) -> f32 {
if self.cur > self.max { if self.max == 0 || self.cur > self.max {
// what. // what.
return 1.0; return 1.0;
} }
+7 -15
View File
@@ -62,21 +62,13 @@ impl Widget for BarWidget {
let val = self.inner_widget.value(); let val = self.inner_widget.value();
let c = self.inner_widget.color(); let c = self.inner_widget.color();
draw_bar( let bar_width = self.geometry.width.saturating_sub(bar_offset);
&mut view.offset((bar_offset, 0)), if bar_width < 2 {
c, return self.geometry;
self.geometry.width - bar_offset, }
size, draw_bar(&mut view.offset((bar_offset, 0)), c, bar_width, size, val).unwrap();
val,
)
.unwrap();
draw_box( draw_box(&mut view.offset((bar_offset, 0)), c, (bar_width, size)).unwrap();
&mut view.offset((bar_offset, 0)),
c,
(self.geometry.width - bar_offset, size),
)
.unwrap();
self.geometry self.geometry
} }
@@ -108,7 +100,7 @@ impl Widget for BarWidget {
match event { match event {
Event::PointerEvent(ev) => { Event::PointerEvent(ev) => {
let offset = 4 * self.size.ceil() as u32; let offset = 4 * self.size.ceil() as u32;
if ev.pos.0 >= offset { if ev.pos.0 >= offset && self.geometry.width > offset {
let val = (ev.pos.0 - offset) as f32 / (self.geometry.width - offset) as f32; let val = (ev.pos.0 - offset) as f32 / (self.geometry.width - offset) as f32;
self.inner_widget.click(val, ev.button); self.inner_widget.click(val, ev.button);
} }
+42 -38
View File
@@ -47,77 +47,84 @@ struct InnerBattery {
dirty: bool, dirty: bool,
} }
fn get_battery_state() -> Result<UpowerBatteryState, Box<dyn Error>> { const DEVICE_PATH: &str = "/org/freedesktop/UPower/devices/DisplayDevice";
const DEVICE_IFACE: &str = "org.freedesktop.UPower.Device";
/// True if UPower reports a present battery. The DisplayDevice object exists
/// even on machines without a battery, so checking for mere reachability
/// (as before) made every desktop grow a battery widget.
fn battery_present() -> Result<bool, Box<dyn Error>> {
let conn = LocalConnection::new_system()?; let conn = LocalConnection::new_system()?;
let device_path = "/org/freedesktop/UPower/devices/DisplayDevice";
let proxy = conn.with_proxy( let proxy = conn.with_proxy(
"org.freedesktop.UPower", "org.freedesktop.UPower",
device_path, DEVICE_PATH,
Duration::from_millis(500), Duration::from_millis(500),
); );
let state: u32 = proxy.get("org.freedesktop.UPower.Device", "State")?; let present: bool = proxy.get(DEVICE_IFACE, "IsPresent")?;
Ok(UpowerBatteryState::from_dbus(state as u64)) Ok(present)
} }
fn start_monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) { fn monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) -> Result<(), Box<dyn Error>> {
thread::Builder::new() let conn = LocalConnection::new_system()?;
.name("battmon".to_string())
.spawn(move || {
let conn = LocalConnection::new_system().unwrap();
let device_path = "/org/freedesktop/UPower/devices/DisplayDevice";
let proxy = conn.with_proxy( let proxy = conn.with_proxy(
"org.freedesktop.UPower", "org.freedesktop.UPower",
device_path, DEVICE_PATH,
Duration::from_millis(500), Duration::from_millis(500),
); );
let capacity: f64 = proxy let capacity: f64 = proxy.get(DEVICE_IFACE, "Percentage")?;
.get("org.freedesktop.UPower.Device", "Percentage") let state: u32 = proxy.get(DEVICE_IFACE, "State")?;
.expect("unable to get property");
let state: u32 = proxy
.get("org.freedesktop.UPower.Device", "State")
.expect("unable to get state");
{ {
let mut inner = inner.lock().unwrap(); let mut inner = inner.lock().unwrap();
inner.value = capacity as f32 / 100.; inner.value = capacity as f32 / 100.;
inner.state = UpowerBatteryState::from_dbus(state as u64); inner.state = UpowerBatteryState::from_dbus(state as u64);
inner.dirty = true;
} }
events.lock().unwrap().add_event(Event::PowerUpdate);
proxy proxy.match_signal(
.match_signal( move |c: PropertiesPropertiesChanged, _: &LocalConnection, _: &dbus::Message| {
move |c: PropertiesPropertiesChanged, if c.interface_name != DEVICE_IFACE {
_: &LocalConnection,
_: &dbus::Message| {
if c.interface_name != "org.freedesktop.UPower.Device" {
return true; return true;
} }
let mut inner = inner.lock().unwrap(); let mut inner = inner.lock().unwrap();
for (key, value) in c.changed_properties { for (key, value) in c.changed_properties {
match key.as_str() { match key.as_str() {
"State" => { "State" => {
inner.state = if let Some(v) = value.0.as_u64() {
UpowerBatteryState::from_dbus(value.0.as_u64().unwrap()); inner.state = UpowerBatteryState::from_dbus(v);
inner.dirty = true; inner.dirty = true;
let mut events = events.lock().unwrap(); events.lock().unwrap().add_event(Event::PowerUpdate);
events.add_event(Event::PowerUpdate); }
} }
"Percentage" => { "Percentage" => {
inner.value = value.0.as_f64().unwrap() as f32 / 100.; if let Some(v) = value.0.as_f64() {
inner.value = v as f32 / 100.;
inner.dirty = true; inner.dirty = true;
let mut events = events.lock().unwrap(); events.lock().unwrap().add_event(Event::PowerUpdate);
events.add_event(Event::PowerUpdate); }
} }
_ => (), _ => (),
} }
} }
true true
}, },
) )?;
.unwrap();
loop { loop {
conn.process(Duration::from_millis(60000)).unwrap(); conn.process(Duration::from_millis(60000))?;
}
}
fn start_monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) {
thread::Builder::new()
.name("battmon".to_string())
.spawn(move || {
// The release profile uses panic = "abort": a panic here would take
// the whole dashboard down, so report and leave the bar at 0.
if let Err(e) = monitor(inner, events) {
eprintln!("wldash: battery monitor stopped: {}", e);
} }
}) })
.unwrap(); .unwrap();
@@ -146,10 +153,7 @@ impl Battery {
} }
pub fn detect() -> bool { pub fn detect() -> bool {
match get_battery_state() { battery_present().unwrap_or(false)
Ok(_) => true,
Err(_) => false,
}
} }
} }
+23 -11
View File
@@ -48,10 +48,17 @@ fn monitor(
context.borrow_mut().connect(None, FlagSet::NOFLAGS, None)?; context.borrow_mut().connect(None, FlagSet::NOFLAGS, None)?;
loop { loop {
mainloop.iterate(false); match mainloop.iterate(true) {
IterateResult::Quit(_) | IterateResult::Err(_) => {
return Err("pulseaudio mainloop quit while connecting".into())
}
_ => (),
}
match context.borrow().get_state() { match context.borrow().get_state() {
State::Ready => break, State::Ready => break,
State::Failed | State::Terminated => panic!("damn"), State::Failed | State::Terminated => {
return Err("could not connect to pulseaudio".into())
}
_ => (), _ => (),
} }
} }
@@ -120,15 +127,14 @@ fn monitor(
let mut inner = local_inner.lock().unwrap(); let mut inner = local_inner.lock().unwrap();
let mut introspector = local_context.borrow_mut().introspect(); let mut introspector = local_context.borrow_mut().introspect();
match inner.last_click.take() { // Clicks arriving before the first sink query finished have no
Some(Change::Volume(volume)) => { // channel map to work with; drop them instead of panicking.
// do something match (inner.last_click.take(), inner.channel_volume) {
let mut vol = inner.channel_volume.unwrap(); (Some(Change::Volume(volume)), Some(mut vol)) => {
vol.scale(Volume((volume * Volume::NORMAL.0 as f32).round() as u32)); vol.scale(Volume((volume * Volume::NORMAL.0 as f32).round() as u32));
introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None); introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None);
} }
Some(Change::VolumeInc(value)) => { (Some(Change::VolumeInc(value)), Some(mut vol)) => {
let mut vol = inner.channel_volume.unwrap();
// apply step to volumes // apply step to volumes
let step = (value * Volume::NORMAL.0 as f32).round() as i32; let step = (value * Volume::NORMAL.0 as f32).round() as i32;
if step > 0 { if step > 0 {
@@ -141,10 +147,10 @@ fn monitor(
inner.channel_volume = Some(vol); inner.channel_volume = Some(vol);
introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None); introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None);
} }
Some(Change::ToggleMute) => { (Some(Change::ToggleMute), _) => {
introspector.set_sink_mute_by_name("@DEFAULT_SINK@", !inner.mute, None); introspector.set_sink_mute_by_name("@DEFAULT_SINK@", !inner.mute, None);
} }
None => (), _ => (),
} }
}), }),
); );
@@ -165,7 +171,13 @@ fn monitor(
fn start_monitor(events: Arc<Mutex<Events>>, inner: Arc<Mutex<InnerAudio>>, pipe: RawFd) { fn start_monitor(events: Arc<Mutex<Events>>, inner: Arc<Mutex<InnerAudio>>, pipe: RawFd) {
thread::Builder::new() thread::Builder::new()
.name("audiomon".to_string()) .name("audiomon".to_string())
.spawn(move || monitor(events, inner, pipe).unwrap()) .spawn(move || {
// panic = "abort" in release: a failed audio connection must not
// kill the dashboard. The widget simply stays at 0.
if let Err(e) = monitor(events, inner, pipe) {
eprintln!("wldash: audio monitor stopped: {}", e);
}
})
.unwrap(); .unwrap();
} }