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,
}
/// 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 {
fn update(&mut self) {
self.cur = read_file_as_u64(self.device_path.join("brightness").as_path()).unwrap();
self.max = read_file_as_u64(self.device_path.join("max_brightness").as_path()).unwrap();
if let Ok(cur) = read_file_as_u64(self.device_path.join("brightness").as_path()) {
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 {
let mut device_path = None;
if let Some(path) = path {
device_path = Some(Path::new("/sys/class/backlight").to_path_buf().join(path));
} else {
for entry in WalkDir::new("/sys/class/backlight") {
if let Ok(entry) = entry {
device_path = Some(entry.path().to_path_buf());
}
}
}
let device_path = match path {
Some(path) => Some(Path::new("/sys/class/backlight").join(path)),
None => find_device(),
};
let mut dev = Backlight {
device_path: device_path.expect("did not find backlight device"),
cur: 0,
@@ -61,18 +71,15 @@ impl Backlight {
}
pub fn detect() -> bool {
for entry in WalkDir::new("/sys/class/backlight") {
if entry.is_ok() {
return true;
}
}
return false;
find_device().is_some()
}
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);
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
}
fn value(&mut self) -> f32 {
if self.cur > self.max {
if self.max == 0 || self.cur > self.max {
// what.
return 1.0;
}
+7 -15
View File
@@ -62,21 +62,13 @@ impl Widget for BarWidget {
let val = self.inner_widget.value();
let c = self.inner_widget.color();
draw_bar(
&mut view.offset((bar_offset, 0)),
c,
self.geometry.width - bar_offset,
size,
val,
)
.unwrap();
let bar_width = self.geometry.width.saturating_sub(bar_offset);
if bar_width < 2 {
return self.geometry;
}
draw_bar(&mut view.offset((bar_offset, 0)), c, bar_width, size, val).unwrap();
draw_box(
&mut view.offset((bar_offset, 0)),
c,
(self.geometry.width - bar_offset, size),
)
.unwrap();
draw_box(&mut view.offset((bar_offset, 0)), c, (bar_width, size)).unwrap();
self.geometry
}
@@ -108,7 +100,7 @@ impl Widget for BarWidget {
match event {
Event::PointerEvent(ev) => {
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;
self.inner_widget.click(val, ev.button);
}
+42 -38
View File
@@ -47,77 +47,84 @@ struct InnerBattery {
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 device_path = "/org/freedesktop/UPower/devices/DisplayDevice";
let proxy = conn.with_proxy(
"org.freedesktop.UPower",
device_path,
DEVICE_PATH,
Duration::from_millis(500),
);
let state: u32 = proxy.get("org.freedesktop.UPower.Device", "State")?;
Ok(UpowerBatteryState::from_dbus(state as u64))
let present: bool = proxy.get(DEVICE_IFACE, "IsPresent")?;
Ok(present)
}
fn start_monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) {
thread::Builder::new()
.name("battmon".to_string())
.spawn(move || {
let conn = LocalConnection::new_system().unwrap();
let device_path = "/org/freedesktop/UPower/devices/DisplayDevice";
fn monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) -> Result<(), Box<dyn Error>> {
let conn = LocalConnection::new_system()?;
let proxy = conn.with_proxy(
"org.freedesktop.UPower",
device_path,
DEVICE_PATH,
Duration::from_millis(500),
);
let capacity: f64 = proxy
.get("org.freedesktop.UPower.Device", "Percentage")
.expect("unable to get property");
let state: u32 = proxy
.get("org.freedesktop.UPower.Device", "State")
.expect("unable to get state");
let capacity: f64 = proxy.get(DEVICE_IFACE, "Percentage")?;
let state: u32 = proxy.get(DEVICE_IFACE, "State")?;
{
let mut inner = inner.lock().unwrap();
inner.value = capacity as f32 / 100.;
inner.state = UpowerBatteryState::from_dbus(state as u64);
inner.dirty = true;
}
events.lock().unwrap().add_event(Event::PowerUpdate);
proxy
.match_signal(
move |c: PropertiesPropertiesChanged,
_: &LocalConnection,
_: &dbus::Message| {
if c.interface_name != "org.freedesktop.UPower.Device" {
proxy.match_signal(
move |c: PropertiesPropertiesChanged, _: &LocalConnection, _: &dbus::Message| {
if c.interface_name != DEVICE_IFACE {
return true;
}
let mut inner = inner.lock().unwrap();
for (key, value) in c.changed_properties {
match key.as_str() {
"State" => {
inner.state =
UpowerBatteryState::from_dbus(value.0.as_u64().unwrap());
if let Some(v) = value.0.as_u64() {
inner.state = UpowerBatteryState::from_dbus(v);
inner.dirty = true;
let mut events = events.lock().unwrap();
events.add_event(Event::PowerUpdate);
events.lock().unwrap().add_event(Event::PowerUpdate);
}
}
"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;
let mut events = events.lock().unwrap();
events.add_event(Event::PowerUpdate);
events.lock().unwrap().add_event(Event::PowerUpdate);
}
}
_ => (),
}
}
true
},
)
.unwrap();
)?;
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();
@@ -146,10 +153,7 @@ impl Battery {
}
pub fn detect() -> bool {
match get_battery_state() {
Ok(_) => true,
Err(_) => false,
}
battery_present().unwrap_or(false)
}
}
+23 -11
View File
@@ -48,10 +48,17 @@ fn monitor(
context.borrow_mut().connect(None, FlagSet::NOFLAGS, None)?;
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() {
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 introspector = local_context.borrow_mut().introspect();
match inner.last_click.take() {
Some(Change::Volume(volume)) => {
// do something
let mut vol = inner.channel_volume.unwrap();
// Clicks arriving before the first sink query finished have no
// channel map to work with; drop them instead of panicking.
match (inner.last_click.take(), inner.channel_volume) {
(Some(Change::Volume(volume)), Some(mut vol)) => {
vol.scale(Volume((volume * Volume::NORMAL.0 as f32).round() as u32));
introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None);
}
Some(Change::VolumeInc(value)) => {
let mut vol = inner.channel_volume.unwrap();
(Some(Change::VolumeInc(value)), Some(mut vol)) => {
// apply step to volumes
let step = (value * Volume::NORMAL.0 as f32).round() as i32;
if step > 0 {
@@ -141,10 +147,10 @@ fn monitor(
inner.channel_volume = Some(vol);
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);
}
None => (),
_ => (),
}
}),
);
@@ -165,7 +171,13 @@ fn monitor(
fn start_monitor(events: Arc<Mutex<Events>>, inner: Arc<Mutex<InnerAudio>>, pipe: RawFd) {
thread::Builder::new()
.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();
}