diff --git a/Cargo.lock b/Cargo.lock index 08caaaf..f9ace09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1787,6 +1787,7 @@ dependencies = [ "ferese-theme", "iced_accessibility", "jiff", + "libc", "libcosmic", "pipewire", "serde", diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index bac516f..88cbd79 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -12,6 +12,7 @@ ferese-config = { path = "../../crates/ferese-config" } ferese-core = { path = "../../crates/ferese-core" } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } jiff.workspace = true +libc.workspace = true # Keep GPU rendering available as a fallback/override. The shell defaults to # tiny-skia to avoid a second GPU initialization during desktop startup. libcosmic = { workspace = true, features = ["wgpu", "a11y"] } diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 4efd91a..ebdd66c 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -355,16 +355,9 @@ impl cosmic::Application for FereseShell { Subscription::none() }, self.notifications.subscription().map(Message::NotificationEvent), - if self.notifications.has_toasts() { - cosmic::iced::time::every(Duration::from_millis(if self.notifications.animating() { - 16 - } else { - 250 - })) - .map(|_| Message::NotificationTick) - } else { - Subscription::none() - }, + self.notifications + .tick_subscription() + .map(|_| Message::NotificationTick), event::listen_with(|event, _status, id| match &event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING .load(Ordering::Relaxed) diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index 2039889..774fc54 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -392,6 +392,8 @@ impl Center { } pub fn hover(&mut self, id: u32, hovered: bool) { + self.tick(); + if hovered { self.hovered = Some(id); } else if self.hovered == Some(id) { @@ -535,8 +537,30 @@ impl Center { cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream) } - pub fn has_toasts(&self) -> bool { - !self.toasts.is_empty() + fn expiry_deadline(&self) -> Option { + self.toasts + .iter() + .filter(|toast| !toast.hovered && toast.closing.is_none()) + .filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining)) + .min() + } + + pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> { + if self.animating() { + return cosmic::iced::time::every(Duration::from_millis(16)).map(|_| ()); + } + + fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream + use<> { + let deadline = *deadline; + cosmic::iced::futures::stream::once(async move { + tokio::time::sleep(deadline.saturating_duration_since(Instant::now())).await; + }) + } + + self.expiry_deadline() + .map_or_else(cosmic::iced::Subscription::none, |deadline| { + cosmic::iced::Subscription::run_with(deadline, deadline_stream) + }) } pub fn handle_event(&mut self, event: Event) { @@ -710,6 +734,25 @@ mod tests { assert_eq!(center.entries.len(), 4); } + #[test] + fn expiry_deadlines_skip_hovered_and_persistent_toasts() { + let (mut center, _) = fixture(); + assert!(center.expiry_deadline().is_none()); + center.receive(notice(1)); + assert_eq!( + center.expiry_deadline(), + Some(center.last_tick + Duration::from_secs(6)) + ); + + center.hover(1, true); + assert!(center.expiry_deadline().is_none()); + center.hover(1, false); + assert!(center.expiry_deadline().is_some()); + + center.toasts[0].remaining = None; + assert!(center.expiry_deadline().is_none()); + } + #[test] fn hover_pauses_expiry_and_replacements_reset_the_timer() { let (mut center, commands) = fixture(); diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 24931d6..18e63ab 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -2,13 +2,14 @@ mod audio_cache; mod bluetooth; mod dbus_cache; +mod hardware; mod network; use std::path::Path; use std::process::{Command, Stdio}; use std::sync::mpsc::SyncSender; use std::sync::{Arc, Condvar, Mutex, mpsc}; -use std::time::Duration; +use std::time::{Duration, Instant}; use std::{env, fs, thread}; #[derive(Clone, Debug, Default)] @@ -130,7 +131,7 @@ impl Updates { } } -type PollState = (Mutex<(u64, bool, Option, bool)>, Condvar); +type PollState = (Mutex<(u64, bool, Option, bool, u64)>, Condvar); fn wait_for_poll(shared: &PollState) -> Option { let state = shared @@ -140,6 +141,12 @@ fn wait_for_poll(shared: &PollState) -> Option { (!state.3).then_some(state.0) } +fn wake_status(shared: &PollState) { + let mut state = shared.0.lock().unwrap_or_else(|error| error.into_inner()); + state.4 = state.4.wrapping_add(1); + shared.1.notify_one(); +} + impl Service { pub fn start(settings: Option>) -> Self { let live_settings = Arc::new(Mutex::new(settings)); @@ -148,21 +155,46 @@ impl Service { let (updates, rx) = tokio::sync::watch::channel(None); // Polling and writes have separate workers: a missing D-Bus service must // never hold up volume/brightness changes. Publish only coherent polls. - let shared = Arc::new((Mutex::new((0u64, false, None::, false)), Condvar::new())); + let shared = Arc::new((Mutex::new((0u64, false, None::, false, 0)), Condvar::new())); let polling = shared.clone(); thread::spawn(move || { - let mut system_bus = StatusBus::new(true); - let mut session_bus = StatusBus::new(false); - let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager"); - let mut bluetooth = dbus_cache::Cache::new("org.bluez"); + let mut network = dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone()); + let mut bluetooth = dbus_cache::Cache::with_wake("org.bluez", polling.clone()); + let mut battery_cache = dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone()); + let mut profiles = dbus_cache::Cache::with_wake("org.freedesktop.UPower.PowerProfiles", polling.clone()); + let mut legacy_profiles = dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone()); + let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone()); let mut audio = audio_cache::Cache::new(polling.clone()); + let mut hardware = hardware::Monitor::start(polling.clone()).ok(); - // A poll overlapping a write is discarded below. Wait for the - // write to finish before retrying instead of launching commands - // repeatedly while a slow control operation is still running. while let Some(before) = wait_for_poll(&polling) { - let mut snapshot = poll(&mut system_bus, &mut session_bus); + let revision = polling.0.lock().unwrap().4; + if hardware.as_ref().is_none_or(|monitor| !monitor.alive()) { + hardware = hardware::Monitor::start(polling.clone()).ok(); + } + let capabilities = login.read(before, read_capabilities).unwrap_or_default(); + let primary_profiles = profiles.read(before, |connection| { + read_power_profiles( + connection, + "org.freedesktop.UPower.PowerProfiles", + "/org/freedesktop/UPower/PowerProfiles", + ) + .map(Some) + }); + let fallback_profiles = legacy_profiles.read(before, |connection| { + read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles").map(Some) + }); + + let mut snapshot = Snapshot { + battery: battery_cache.read(before, read_battery).or_else(battery), + power_profiles: primary_profiles.or(fallback_profiles), + brightness: brightness(), + poweroff: capabilities.0, + reboot: capabilities.1, + suspend: capabilities.2, + ..Snapshot::default() + }; snapshot.audio = audio.read(before); snapshot.network = network.read(before, network::read); snapshot.bluetooth = bluetooth.read(before, bluetooth::read); @@ -178,8 +210,35 @@ impl Service { generation: state.0, error: state.2.clone(), })); + let retry = [ + network.next_retry(), + bluetooth.next_retry(), + battery_cache.next_retry(), + profiles.next_retry(), + legacy_profiles.next_retry(), + login.next_retry(), + audio.next_retry(), + hardware + .as_ref() + .is_none_or(|monitor| !monitor.alive()) + .then(|| Instant::now() + Duration::from_secs(5)), + ] + .into_iter() + .flatten() + .min(); + let unchanged = |state: &mut (u64, bool, Option, bool, u64)| { + !state.3 && state.0 == before && state.4 == revision + }; if !audio.changed() { - let _ = polling.1.wait_timeout(state, Duration::from_secs(2)); + if let Some(deadline) = retry { + let _ = polling.1.wait_timeout_while( + state, + deadline.saturating_duration_since(Instant::now()), + unchanged, + ); + } else { + drop(polling.1.wait_while(state, unchanged)); + } } } } @@ -207,6 +266,7 @@ impl Service { let mut state = shared.0.lock().unwrap(); state.1 = false; state.2 = error; + state.4 = state.4.wrapping_add(1); shared.1.notify_one(); } } @@ -329,6 +389,7 @@ impl StatusBus { } } + #[cfg(test)] fn can_power(&mut self, method: &str) -> bool { self.query(|connection| { connection @@ -345,6 +406,7 @@ impl StatusBus { .is_some_and(|value| value == "yes" || value == "challenge") } + #[cfg(test)] fn notification_service_owned(&mut self) -> bool { self.query(|connection| { connection @@ -362,19 +424,49 @@ impl StatusBus { } } -fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot { - Snapshot { - network: None, - bluetooth: None, - audio: None, - battery: battery(), - power_profiles: power_profiles(system_bus), - brightness: brightness(), - notifications: notifications(session_bus), - poweroff: system_bus.can_power("CanPowerOff"), - reboot: system_bus.can_power("CanReboot"), - suspend: system_bus.can_power("CanSuspend"), +fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result> { + let can = |method| -> zbus::Result { + let value: String = connection + .call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + method, + &(), + )? + .body() + .deserialize()?; + + Ok(value == "yes" || value == "challenge") + }; + + Ok(Some((can("CanPowerOff")?, can("CanReboot")?, can("CanSuspend")?))) +} + +fn read_battery(connection: &zbus::blocking::Connection) -> zbus::Result> { + let proxy = zbus::blocking::Proxy::new( + connection, + "org.freedesktop.UPower", + "/org/freedesktop/UPower/devices/DisplayDevice", + "org.freedesktop.UPower.Device", + )?; + if !proxy.get_property::("IsPresent")? { + return Ok(None); } + let percent = proxy.get_property::("Percentage")?; + let status = match proxy.get_property::("State")? { + 1 => "Charging", + 2 => "Discharging", + 3 => "Empty", + 4 => "Full", + 5 => "Pending charge", + 6 => "Pending discharge", + _ => "Unknown", + }; + Ok(Some(Battery { + percent: percent.round().clamp(0., 100.) as u8, + status: status.into(), + })) } pub fn parse_audio(value: &str) -> Option<(u8, bool)> { @@ -429,6 +521,7 @@ fn battery() -> Option { }) } +#[cfg(test)] fn power_profiles(bus: &mut StatusBus) -> Option { bus.query(|connection| { read_power_profiles( @@ -462,38 +555,24 @@ fn read_power_profiles( } fn brightness() -> Option { - parse_brightness(&run("brightnessctl", &["--class=backlight", "--machine-readable", "info"]).ok()?) + let mut devices = fs::read_dir("/sys/class/backlight") + .ok()? + .flatten() + .map(|entry| entry.path()) + .collect::>(); + devices.sort(); + devices.iter().find_map(|path| { + let current = read(path, "brightness")?.parse::().ok()?; + let max = read(path, "max_brightness")?.parse::().ok()?; + brightness_percent(current, max) + }) } -fn parse_brightness(value: &str) -> Option { - // device,class,current,percentage,max. Compute the ratio ourselves to - // preserve the previous rounding rather than using the CLI's percentage. - let mut fields = value.trim().split(','); - let _device = fields.next()?; - if fields.next()? != "backlight" { - return None; - } - let current = fields.next()?.parse::().ok()?; - let _percentage = fields.next()?; - let max = fields.next()?.parse::().ok()?; - (fields.next().is_none() && max.is_finite() && max > 0.0 && current.is_finite() && current >= 0.0) +fn brightness_percent(current: f64, max: f64) -> Option { + (max.is_finite() && max > 0.0 && current.is_finite() && current >= 0.0) .then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8) } -fn notifications(bus: &mut StatusBus) -> Option { - // Check service ownership first: swaync-client otherwise waits indefinitely. - if !bus.notification_service_owned() { - return None; - } - let count = run("swaync-client", &["--count"]).ok()?.parse().ok()?; - let dnd = match run("swaync-client", &["--get-dnd"]).ok()?.as_str() { - "true" => true, - "false" => false, - _ => return None, - }; - Some(Notifications { count, dnd }) -} - pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> { let action = match action { Action::Poweroff => "poweroff", @@ -531,8 +610,7 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { "brightnessctl", &["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))], ), - Action::Dnd(on) => run("swaync-client", &[if *on { "--dnd-on" } else { "--dnd-off" }]), - Action::Notifications => run("swaync-client", &["--open-panel"]), + Action::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()), Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => { run("powerprofilesctl", &["set", profile]) } @@ -721,20 +799,13 @@ mod tests { } #[test] - fn brightness_info_preserves_ratio_rounding_and_rejects_bad_devices() { - assert_eq!(parse_brightness("intel,backlight,72,18%,400"), Some(18)); - assert_eq!(parse_brightness("panel,backlight,2,66%,3\n"), Some(67)); - assert_eq!(parse_brightness("panel,backlight,0,0%,400"), Some(0)); - for value in [ - "", - "panel,leds,1,1%,100", - "panel,backlight,1,0%,0", - "panel,backlight,NaN,0%,100", - "panel,backlight,1,0%,inf", - "panel,backlight,-1,0%,100", - "panel,backlight,1,0%,100,extra", - ] { - assert_eq!(parse_brightness(value), None, "{value}"); + fn brightness_ratio_rounds_and_rejects_invalid_values() { + assert_eq!(brightness_percent(72.0, 400.0), Some(18)); + assert_eq!(brightness_percent(2.0, 3.0), Some(67)); + assert_eq!(brightness_percent(0.0, 400.0), Some(0)); + + for (current, max) in [(1.0, 0.0), (-1.0, 100.0), (f64::NAN, 100.0), (1.0, f64::INFINITY)] { + assert_eq!(brightness_percent(current, max), None); } } @@ -802,7 +873,7 @@ mod tests { #[test] fn polling_waits_for_a_write_and_uses_the_completed_generation() { - let shared = Arc::new((Mutex::new((7, true, None, false)), Condvar::new())); + let shared = Arc::new((Mutex::new((7, true, None, false, 0)), Condvar::new())); let worker_state = shared.clone(); let (sender, receiver) = mpsc::channel(); let worker = thread::spawn(move || { @@ -824,7 +895,7 @@ mod tests { #[test] fn shutdown_unblocks_polling_even_during_a_write() { - let shared = Arc::new((Mutex::new((7, true, None, false)), Condvar::new())); + let shared = Arc::new((Mutex::new((7, true, None, false, 0)), Condvar::new())); let worker_state = shared.clone(); let (sender, receiver) = mpsc::channel(); let worker = thread::spawn(move || { diff --git a/shell/ferese-shell/src/status/audio_cache.rs b/shell/ferese-shell/src/status/audio_cache.rs index 96474f6..053dd48 100644 --- a/shell/ferese-shell/src/status/audio_cache.rs +++ b/shell/ferese-shell/src/status/audio_cache.rs @@ -49,6 +49,10 @@ impl Cache { self.value.clone() } + pub(super) fn next_retry(&self) -> Option { + (!self.signals.alive() || self.value.is_none()).then_some(self.retry_at) + } + pub(super) fn changed(&self) -> bool { self.signals.revision() != self.revision } @@ -62,7 +66,7 @@ mod tests { fn cache() -> Cache { Cache { - signals: Signals::test(Arc::new((Mutex::new((0, false, None, false)), Condvar::new()))), + signals: Signals::test(Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()))), revision: 0, action: u64::MAX, value: None, diff --git a/shell/ferese-shell/src/status/audio_cache/signals.rs b/shell/ferese-shell/src/status/audio_cache/signals.rs index 367f7bb..fd99b03 100644 --- a/shell/ferese-shell/src/status/audio_cache/signals.rs +++ b/shell/ferese-shell/src/status/audio_cache/signals.rs @@ -23,8 +23,7 @@ impl State { pub(super) fn changed(&self) { self.revision.fetch_add(1, Ordering::AcqRel); // Synchronize with the poller's predicate check and condvar wait. - let _guard = self.wake.0.lock().unwrap_or_else(|error| error.into_inner()); - self.wake.1.notify_one(); + super::super::wake_status(&self.wake); } pub(super) fn set_alive(&self, alive: bool) { @@ -324,7 +323,7 @@ context.objects = [ .stderr(Stdio::null()); let mut server = Server(command.spawn().unwrap()); wait_for(|| root.path().join("pipewire-0").exists()); - let wake = Arc::new((Mutex::new((0, false, None, false)), Condvar::new())); + let wake = Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new())); let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into())); wait_for(|| signals.alive()); std::thread::sleep(Duration::from_millis(500)); diff --git a/shell/ferese-shell/src/status/dbus_cache.rs b/shell/ferese-shell/src/status/dbus_cache.rs index 1f71f1e..d8ba2a4 100644 --- a/shell/ferese-shell/src/status/dbus_cache.rs +++ b/shell/ferese-shell/src/status/dbus_cache.rs @@ -7,7 +7,7 @@ use cosmic::iced::futures::channel::oneshot; use cosmic::iced::futures::{FutureExt, StreamExt}; use zbus::blocking::{Connection, MessageIterator}; -use super::StatusBus; +use super::{PollState, StatusBus, wake_status}; struct Signals { revision: Arc, @@ -16,7 +16,7 @@ struct Signals { } impl Signals { - fn new(connection: &Connection, service: &str) -> zbus::Result { + fn new(connection: &Connection, service: &str, wake: Option>) -> zbus::Result { // Install both rules before the snapshot, including when the service is // currently absent. Owner changes invalidate paths after a restart. let changes = MessageIterator::for_match_rule( @@ -51,6 +51,7 @@ impl Signals { // this before entering our async runtime. let changes = changes.into_inner(); let owners = owners.into_inner(); + let event_wake = wake.clone(); runtime.block_on(async move { let mut events = cosmic::iced::futures::stream::select(changes, owners); let mut stopped = stopped.fuse(); @@ -59,6 +60,7 @@ impl Signals { _ = stopped => break, event = events.next() => { changed.fetch_add(1, Ordering::Release); + if let Some(wake) = &event_wake { wake_status(wake); } if !matches!(event, Some(Ok(_))) { break; } } } @@ -69,6 +71,9 @@ impl Signals { }); } running.store(false, Ordering::Release); + if let Some(wake) = &wake { + wake_status(wake); + } }); Ok(Self { revision, @@ -86,6 +91,8 @@ pub(super) struct Cache { action: u64, value: Option, retry_at: Instant, + retry: bool, + wake: Option>, } impl Cache { @@ -98,33 +105,59 @@ impl Cache { action: u64::MAX, value: None, retry_at: Instant::now(), + retry: false, + wake: None, } } + pub(super) fn with_wake(service: &'static str, wake: Arc) -> Self { + let mut cache = Self::new(service); + cache.wake = Some(wake); + cache + } + + pub(super) fn next_retry(&self) -> Option { + (self.action != u64::MAX && (self.signals.is_none() || self.retry)).then_some(self.retry_at) + } + pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result>) -> Option { if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) { self.signals = None; self.bus.connection = None; self.value = None; + self.retry_at = Instant::now(); } - if self.signals.is_none() { - self.signals = self.bus.query(|connection| Signals::new(connection, self.service)); + if self.signals.is_none() && Instant::now() >= self.retry_at { + self.signals = self + .bus + .query(|connection| Signals::new(connection, self.service, self.wake.clone())); self.revision = 0; } // If subscription setup fails, read fresh rather than trusting a cache - // with no invalidation. Retry setup on the next regular status poll. + // with no invalidation. Retry setup at the bounded recovery deadline. let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire)); - if revision.is_none() - || revision != Some(self.revision) + if (revision.is_none() && Instant::now() >= self.retry_at) + || revision.is_some_and(|revision| revision != self.revision) || action != self.action - || (self.value.is_none() && Instant::now() >= self.retry_at) + || (self.retry && Instant::now() >= self.retry_at) { // Keep signal subscriptions across service errors. A dead bus is // detected by the listener; a transient query failure retries later. - self.value = self - .bus - .query(|connection| Ok(connection.clone())) - .and_then(|connection| read(&connection).ok().flatten()); + let result = self.bus.query(|connection| { + let owned = connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "NameHasOwner", + &(self.service,), + )? + .body() + .deserialize::()?; + if owned { read(connection) } else { Ok(None) } + }); + self.retry = result.is_none(); + self.value = result.flatten(); self.retry_at = Instant::now() + Duration::from_secs(5); self.action = action; self.revision = revision.unwrap_or(0); @@ -178,6 +211,7 @@ mod tests { assert_eq!(cache.read(0, read_counter), Some(1)); } assert_eq!(count.load(Ordering::Relaxed), 1); + assert!(cache.next_retry().is_none(), "healthy subscriptions must not poll"); assert_eq!(cache.read(1, read_counter), Some(2)); server .emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &()) @@ -193,6 +227,7 @@ mod tests { assert!(Instant::now() < deadline, "owner loss kept stale data"); std::thread::sleep(Duration::from_millis(1)); } + assert!(cache.next_retry().is_none(), "absent services wait for owner changes"); server.request_name("org.ferese.StatusCounter").unwrap(); let deadline = Instant::now() + Duration::from_secs(2); while cache.read(1, read_counter).is_none() { @@ -207,6 +242,9 @@ mod tests { .is_none() ); assert!(cache.read(2, read_counter).is_none()); + // The production cache reconnects to the system bus after an error; + // keep this recovery attempt on the isolated test bus instead. + cache.bus.connection = Some(bus.connect()); cache.retry_at = Instant::now(); assert!(cache.read(2, read_counter).is_some()); } @@ -218,7 +256,11 @@ mod tests { let client = bus.connect(); let server = bus.connect(); server.request_name("org.ferese.StatusTest").unwrap(); - let signals = Signals::new(&client, "org.ferese.StatusTest").unwrap(); + let wake = Arc::new(( + std::sync::Mutex::new((0, false, None, false, 0)), + std::sync::Condvar::new(), + )); + let signals = Signals::new(&client, "org.ferese.StatusTest", Some(wake.clone())).unwrap(); let wait = |before| { let deadline = Instant::now() + Duration::from_secs(2); while signals.revision.load(Ordering::Acquire) == before && Instant::now() < deadline { @@ -230,6 +272,13 @@ mod tests { server .emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &()) .unwrap(); + let (state, timeout) = wake + .1 + .wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| state.4 == 0) + .unwrap(); + assert!(!timeout.timed_out(), "service signal must wake the status worker"); + assert!(state.4 > 0); + drop(state); wait(before); let before = signals.revision.load(Ordering::Acquire); server.release_name("org.ferese.StatusTest").unwrap(); diff --git a/shell/ferese-shell/src/status/hardware.rs b/shell/ferese-shell/src/status/hardware.rs new file mode 100644 index 0000000..dcfb4b0 --- /dev/null +++ b/shell/ferese-shell/src/status/hardware.rs @@ -0,0 +1,121 @@ +//! Kernel battery/backlight events, with a cancellation fd and no polling timer. +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::{PollState, wake_status}; + +pub(super) struct Monitor { + _stop: UnixStream, + alive: Arc, +} + +fn relevant(message: &[u8]) -> bool { + message + .split(|byte| *byte == 0) + .any(|field| field == b"SUBSYSTEM=backlight" || field == b"SUBSYSTEM=power_supply") +} + +impl Monitor { + pub fn alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } + + pub fn start(wake: Arc) -> std::io::Result { + // SAFETY: socket has no borrowed pointer arguments; successful fd is owned below. + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, + libc::NETLINK_KOBJECT_UEVENT, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the successful socket call returned a unique owned descriptor. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + // SAFETY: zero is a valid initialization for sockaddr_nl including padding. + let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + address.nl_family = libc::AF_NETLINK as u16; + address.nl_groups = 1; + // SAFETY: the address pointer and its length describe the initialized struct. + if unsafe { + libc::bind( + fd.as_raw_fd(), + (&raw const address).cast(), + std::mem::size_of_val(&address) as _, + ) + } < 0 + { + return Err(std::io::Error::last_os_error()); + } + let (stop, cancelled) = UnixStream::pair()?; + let alive = Arc::new(AtomicBool::new(true)); + let running = alive.clone(); + std::thread::Builder::new() + .name("ferese-hardware-events".into()) + .spawn(move || { + let mut descriptors = [ + libc::pollfd { + fd: fd.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: cancelled.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + let mut buffer = [0u8; 65536]; + loop { + // SAFETY: both descriptors remain owned by this thread; poll writes within the array. + let result = unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) }; + if result < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted { + continue; + } + if result < 0 + || descriptors[1].revents != 0 + || descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 + { + break; + } + let mut length = std::mem::size_of_val(&address) as libc::socklen_t; + // SAFETY: the owned fd, writable byte buffer and sockaddr storage are valid for their lengths. + let count = unsafe { + libc::recvfrom( + fd.as_raw_fd(), + buffer.as_mut_ptr().cast(), + buffer.len(), + 0, + (&raw mut address).cast(), + &mut length, + ) + }; + if count < 0 { + break; + } + if address.nl_pid == 0 && relevant(&buffer[..count as usize]) { + wake_status(&wake); + } + } + running.store(false, Ordering::Release); + wake_status(&wake); + })?; + Ok(Self { _stop: stop, alive }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_battery_and_backlight_events_refresh_status() { + assert!(relevant(b"change@/devices/panel\0SUBSYSTEM=backlight\0")); + assert!(relevant(b"SUBSYSTEM=power_supply\0ACTION=change\0")); + assert!(!relevant(b"SUBSYSTEM=input\0")); + assert!(!relevant(b"OTHER=SUBSYSTEM=backlight\0")); + } +}