From 03a265b97fc2bcdba4edda23a752c053a0a73a3b Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 3 Oct 2026 11:20:55 +0100 Subject: [PATCH] Add automatic idle inhibition for fullscreen playback and selected apps --- Cargo.lock | 1 + README.md | 3 +- compositor/Cargo.toml | 1 + compositor/src/config.rs | 3 + compositor/src/handlers/xdg_shell.rs | 3 + compositor/src/idle_inhibition.rs | 268 ++++++++++++++++++ compositor/src/idle_inhibition/media.rs | 245 ++++++++++++++++ compositor/src/ipc.rs | 11 + compositor/src/main.rs | 2 + compositor/src/portal_session.rs | 17 +- compositor/src/state.rs | 15 +- compositor/src/window_rules.rs | 19 +- docs/configuration.md | 48 +++- docs/development.md | 12 + feresectl/src/main.rs | 5 +- packaging/config.kdl | 4 + .../tests/test_idle_inhibition_isolated.py | 207 ++++++++++++++ 17 files changed, 842 insertions(+), 22 deletions(-) create mode 100644 compositor/src/idle_inhibition.rs create mode 100644 compositor/src/idle_inhibition/media.rs create mode 100644 scripts/tests/test_idle_inhibition_isolated.py diff --git a/Cargo.lock b/Cargo.lock index 1775904..a4f8826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1656,6 +1656,7 @@ dependencies = [ "ferese-layout", "ferese-protocols", "fontdb", + "futures-lite", "image", "jiff", "libc", diff --git a/README.md b/README.md index 37bfe1f..db48978 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Ferese combines a window manager and desktop shell with one configuration. - Desktop widgets, including a clock and sticky notes - Screenshots with annotation, screen sharing and built-in recording - A built-in lock screen and authentication dialogs +- [Idle inhibition](docs/configuration.md#idle-inhibition) during fullscreen media playback, with rules for selected apps - Settings and Control Center with Wi-Fi, Bluetooth and audio controls ## How Ferese compares @@ -74,7 +75,7 @@ Configure Ferese through **Control Center → Settings** or `~/.config/ferese/co Changes apply live. Invalid edits leave the last working configuration in place. The [configuration reference](docs/configuration.md) and [example -config](packaging/config.kdl) cover everything from appearance and window layouts to +config](packaging/config.kdl) cover appearance, window layouts, displays and startup apps. ## Default shortcuts diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index 6e515a9..57913ea 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -15,6 +15,7 @@ ferese-core = { path = "../crates/ferese-core" } ferese-ipc = { path = "../crates/ferese-ipc" } ferese-layout = { path = "../crates/ferese-layout" } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } +futures-lite = "2.6.1" libc.workspace = true jiff.workspace = true notify = "8.2.0" diff --git a/compositor/src/config.rs b/compositor/src/config.rs index cfda30b..a7d49f1 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -34,6 +34,8 @@ pub struct Config { #[serde(default)] lock_screen: crate::session_lock::IdleSettings, #[serde(default)] + idle_inhibit: crate::idle_inhibition::Settings, + #[serde(default)] desktop_widgets: ferese_config::desktop::DesktopWidgets, #[serde(default)] pub(crate) autostart: Vec, @@ -115,6 +117,7 @@ impl Config { let bindings = self.bindings(&input_settings)?; Ok(crate::RuntimeConfig { lock_idle: self.lock_screen.validate()?, + idle_inhibit: self.idle_inhibit, autostart: self.autostart.clone(), layout_mode: self.layout_mode(), workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth, diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index e32b258..6d5c08b 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -36,6 +36,7 @@ impl XdgShellHandler for Ferese { fn title_changed(&mut self, surface: ToplevelSurface) { self.refresh_capture_privacy(); if self.windows.refresh_metadata(surface.wl_surface()) { + self.refresh_idle_inhibition(); self.send_shell_snapshots(); for output in self.surface_outputs(surface.wl_surface()) { self.defer_output_redraw(output); @@ -46,6 +47,7 @@ impl XdgShellHandler for Ferese { fn app_id_changed(&mut self, surface: ToplevelSurface) { self.refresh_capture_privacy(); if self.windows.refresh_metadata(surface.wl_surface()) { + self.refresh_idle_inhibition(); self.send_shell_snapshots(); for output in self.surface_outputs(surface.wl_surface()) { self.defer_output_redraw(output); @@ -55,6 +57,7 @@ impl XdgShellHandler for Ferese { fn parent_changed(&mut self, surface: ToplevelSurface) { self.refresh_capture_privacy(); + self.refresh_idle_inhibition(); let Some(parent) = surface.parent() else { return; }; diff --git a/compositor/src/idle_inhibition.rs b/compositor/src/idle_inhibition.rs new file mode 100644 index 0000000..55fc5ad --- /dev/null +++ b/compositor/src/idle_inhibition.rs @@ -0,0 +1,268 @@ +//! Automatic policy feeds the same idle notifier as Wayland and portal requests. +pub(crate) mod media; + +use std::collections::HashSet; + +use ferese_layout::WindowId; +use serde::Deserialize; +use smithay::reexports::wayland_server::Resource; +use smithay::wayland::compositor::with_states; +use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; + +use crate::Ferese; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct Settings { + pub fullscreen_playback: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + fullscreen_playback: true, + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Mode { + None, + Visible, + Fullscreen, + Playing, + FullscreenPlaying, +} + +impl Mode { + fn inhibits(self, visible: bool, fullscreen: bool, playing: bool) -> bool { + visible + && match self { + Self::None => false, + Self::Visible => true, + Self::Fullscreen => fullscreen, + Self::Playing => playing, + Self::FullscreenPlaying => fullscreen && playing, + } + } +} + +#[derive(Clone, Copy)] +struct AppWindow<'a> { + id: WindowId, + pid: Option, + app_id: &'a str, +} + +/// MPRIS reports application playback, not which of its windows owns a video. +/// Prefer process credentials; use DesktopEntry only when no process matches. +/// Multiple matching windows are ambiguous, including hidden windows. +fn player_window(player: &media::Player, windows: &[AppWindow<'_>]) -> Option { + let mut matches = windows + .iter() + .filter(|window| player.pid.is_some() && window.pid == player.pid); + if let Some(window) = matches.next() { + return matches.next().is_none().then_some(window.id); + } + + let desktop = player.desktop_entry.as_deref()?; + let mut matches = windows + .iter() + .filter(|window| crate::window_rules::normalize_app_id(window.app_id) == desktop); + let window = matches.next()?; + matches.next().is_none().then_some(window.id) +} + +impl Ferese { + pub(crate) fn refresh_idle_inhibition(&mut self) { + let playing = self.media_players.iter().any(|player| player.playing); + let has_rules = self.window_rules.iter().any(|rule| rule.idle_policy().is_some()); + let selected = self + .window_rules + .iter() + .any(|rule| rule.idle_policy().is_some_and(|mode| mode != Mode::None)); + let playback_policy = self.idle_inhibit.fullscreen_playback + || self + .window_rules + .iter() + .any(|rule| matches!(rule.idle_policy(), Some(Mode::Playing | Mode::FullscreenPlaying))); + let automatic = (playback_policy && playing) || selected; + let eligible = if self.idle_inhibitors.is_empty() && !automatic { + Default::default() + } else { + self.callback_outputs() + }; + + let application = if playback_policy && playing { + self.windows + .records() + .map(|(&id, record)| { + let pid = record + .surface + .as_ref() + .and_then(|surface| surface.client()) + .and_then(|client| client.get_credentials(&self.display_handle).ok()) + .and_then(|credentials| u32::try_from(credentials.pid).ok()); + AppWindow { + id, + pid, + app_id: &record.app_id, + } + }) + .collect::>() + } else { + Vec::new() + }; + let playing_windows = self + .media_players + .iter() + .filter(|player| player.playing) + .filter_map(|player| player_window(player, &application)) + .collect::>(); + + let automatic = automatic + && !self.session_lock.active() + && self.windows.records().any(|(&id, record)| { + let Some(surface) = &record.surface else { + return false; + }; + if !eligible.contains_key(&surface.into()) { + return false; + } + + let explicit = has_rules + .then(|| { + let transient = with_states(surface, |states| { + states + .data_map + .get::() + .is_some_and(|data| data.lock().unwrap().parent.is_some()) + }); + crate::window_rules::resolve( + &self.window_rules, + Some(&record.app_id), + Some(&record.title), + transient, + ) + .idle_inhibit + }) + .flatten(); + let mode = explicit.unwrap_or(if self.idle_inhibit.fullscreen_playback { + Mode::FullscreenPlaying + } else { + Mode::None + }); + let fullscreen = self + .workspaces + .workspace_for_window(id) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .is_some_and(|workspace| workspace.fullscreen == Some(id)); + + mode.inhibits(true, fullscreen, playing_windows.contains(&id)) + }); + let requested = self + .idle_inhibitors + .keys() + .any(|surface| eligible.contains_key(&surface.into())); + self.automatic_idle_inhibited = automatic; + self.idle_notifier_state + .set_is_inhibited(automatic || requested || self.portal_session.idle_inhibited()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_releases_on_pause_hidden_window_and_fullscreen_exit() { + assert!(Mode::FullscreenPlaying.inhibits(true, true, true)); + assert!(!Mode::FullscreenPlaying.inhibits(true, true, false)); + assert!(!Mode::FullscreenPlaying.inhibits(false, true, true)); + assert!(!Mode::FullscreenPlaying.inhibits(true, false, true)); + assert!(Mode::Visible.inhibits(true, false, false)); + assert!(!Mode::Visible.inhibits(false, true, true)); + assert!(Mode::Playing.inhibits(true, false, true)); + assert!(!Mode::Playing.inhibits(true, false, false)); + assert!(Mode::Fullscreen.inhibits(true, true, false)); + assert!(!Mode::None.inhibits(true, true, true)); + } + + #[test] + fn configuration_validates_modes_and_keeps_idle_rules_out_of_placement() { + let config = crate::config::Config::parse_source( + "idle-inhibit { fullscreen-playback #false; }\nwindow-rule app-id=\"player\" idle-inhibit=\"visible\"\nwindow-rule app-id=\"player\" title=\"Private\" idle-inhibit=\"none\"\n", + ).unwrap().runtime_config().unwrap(); + assert!(!config.idle_inhibit.fullscreen_playback); + let visible = crate::window_rules::resolve(&config.window_rules, Some("player"), None, false); + assert_eq!(visible.idle_inhibit, Some(Mode::Visible)); + let none = crate::window_rules::resolve(&config.window_rules, Some("player"), Some("Private"), false); + assert_eq!(none.idle_inhibit, Some(Mode::None)); + assert_eq!(crate::window_rules::live_result(visible, none, false), None); + for mode in ["playing", "fullscreen", "fullscreen-playing", "none"] { + assert!( + crate::config::Config::parse_source(&format!( + "window-rule app-id=\"player\" idle-inhibit=\"{mode}\"\n" + )) + .unwrap() + .runtime_config() + .is_ok() + ); + } + assert!( + crate::config::Config::parse_source("window-rule app-id=\"player\" idle-inhibit=\"alwayss\"\n").is_err() + ); + assert!(crate::config::Config::parse_source("idle-inhibit { fullscreen-playback \"yes\"; }\n").is_err()); + } + + #[test] + fn player_identity_prefers_pid_and_rejects_ambiguous_or_closed_windows() { + let player = media::Player { + name: "org.mpris.MediaPlayer2.test".into(), + owner: ":1.5".into(), + pid: Some(42), + desktop_entry: Some("player".into()), + playing: true, + }; + let window = AppWindow { + id: WindowId(1), + pid: Some(42), + app_id: "other", + }; + let unrelated = AppWindow { + id: WindowId(2), + pid: Some(99), + app_id: "Player.desktop", + }; + assert_eq!(player_window(&player, &[window, unrelated]), Some(window.id)); + assert_eq!(player_window(&player, &[unrelated]), Some(unrelated.id)); + assert_eq!(player_window(&player, &[]), None); + assert_eq!( + player_window( + &player, + &[ + window, + AppWindow { + id: WindowId(3), + ..window + } + ] + ), + None + ); + assert_eq!( + player_window( + &player, + &[ + unrelated, + AppWindow { + id: WindowId(4), + ..unrelated + } + ] + ), + None + ); + } +} diff --git a/compositor/src/idle_inhibition/media.rs b/compositor/src/idle_inhibition/media.rs new file mode 100644 index 0000000..d009b81 --- /dev/null +++ b/compositor/src/idle_inhibition/media.rs @@ -0,0 +1,245 @@ +//! MPRIS reads and signal handling run off the compositor thread, without polling. +use std::collections::HashMap; +use std::time::Duration; + +use calloop::EventLoop; +use calloop::channel::{Event, channel}; +use futures_lite::{StreamExt, future, stream}; +use zbus::zvariant::OwnedValue; +use zbus::{Connection, MatchRule, MessageStream}; + +use crate::Ferese; + +const PREFIX: &str = "org.mpris.MediaPlayer2"; +const PATH: &str = "/org/mpris/MediaPlayer2"; +const PLAYER: &str = "org.mpris.MediaPlayer2.Player"; +const PROPERTIES: &str = "org.freedesktop.DBus.Properties"; +const MAX_PLAYERS: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Player { + pub name: String, + pub owner: String, + pub pid: Option, + pub desktop_entry: Option, + pub playing: bool, +} + +async fn bus_call( + connection: &Connection, + member: &str, + name: &str, +) -> zbus::Result { + connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + member, + &(name,), + ) + .await? + .body() + .deserialize() +} + +async fn property(connection: &Connection, owner: &str, interface: &str, name: &str) -> zbus::Result { + connection + .call_method(Some(owner), PATH, Some(PROPERTIES), "Get", &(interface, name)) + .await? + .body() + .deserialize() +} + +async fn refresh_player(connection: &Connection, player: &mut Player, identity: bool) { + // Bind reads to the unique owner, never a name that can change mid-query. + player.playing = property(connection, &player.owner, PLAYER, "PlaybackStatus") + .await + .ok() + .and_then(|value| String::try_from(value).ok()) + .is_some_and(|status| status == "Playing"); + + if identity { + player.pid = bus_call(connection, "GetConnectionUnixProcessID", &player.owner) + .await + .ok(); + player.desktop_entry = property(connection, &player.owner, PREFIX, "DesktopEntry") + .await + .ok() + .and_then(|value| String::try_from(value).ok()) + .map(|value| crate::window_rules::normalize_app_id(&value)) + .filter(|value| !value.is_empty()); + } +} + +fn publish( + players: &HashMap, + previous: &mut Vec, + send: &mut impl FnMut(Vec) -> bool, +) -> bool { + let mut snapshot: Vec<_> = players.values().cloned().collect(); + snapshot.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + if snapshot == *previous { + return true; + } + + *previous = snapshot.clone(); + send(snapshot) +} + +async fn monitor(connection: Connection, mut send: impl FnMut(Vec) -> bool) -> zbus::Result<()> { + // Subscribe before discovery so startup and owner replacement cannot lose + // a pause/stop event. Ignore unrelated property traffic such as position. + let properties = MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .path(PATH)? + .interface(PROPERTIES)? + .member("PropertiesChanged")? + .build(); + let owners = MatchRule::builder() + .msg_type(zbus::message::Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .arg0ns(PREFIX)? + .build(); + let properties = MessageStream::for_match_rule(properties, &connection, Some(128)).await?; + let owners = MessageStream::for_match_rule(owners, &connection, Some(128)).await?; + let mut signals = stream::or(properties, owners); + let mut players = HashMap::new(); + let mut previous = Vec::new(); + let names: Vec = connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .await? + .body() + .deserialize()?; + let mut names: Vec<_> = names + .into_iter() + .filter(|name| name.strip_prefix(PREFIX).is_some_and(|suffix| suffix.starts_with('.'))) + .collect(); + names.sort_unstable(); + for name in names.into_iter().take(MAX_PLAYERS) { + if let Ok(owner) = bus_call(&connection, "GetNameOwner", &name).await { + let mut player = Player { + name: name.clone(), + owner, + pid: None, + desktop_entry: None, + playing: false, + }; + refresh_player(&connection, &mut player, true).await; + players.insert(name, player); + } + } + + if !publish(&players, &mut previous, &mut send) { + return Ok(()); + } + while let Some(message) = signals.next().await { + let message = message?; + let header = message.header(); + if header + .member() + .is_some_and(|member| member.as_str() == "NameOwnerChanged") + { + let (name, _, owner): (String, String, String) = message.body().deserialize()?; + // Remove the old owner's state before looking up the replacement. + players.remove(&name); + if !publish(&players, &mut previous, &mut send) { + return Ok(()); + } + + if !owner.is_empty() && players.len() < MAX_PLAYERS { + let mut player = Player { + name: name.clone(), + owner, + pid: None, + desktop_entry: None, + playing: false, + }; + refresh_player(&connection, &mut player, true).await; + players.insert(name, player); + } + } else { + let Ok((interface, changed, invalidated)) = + message + .body() + .deserialize::<(String, HashMap, Vec)>() + else { + continue; + }; + let identity = interface == PREFIX + && (changed.contains_key("DesktopEntry") || invalidated.iter().any(|field| field == "DesktopEntry")); + let status = interface == PLAYER + && (changed.contains_key("PlaybackStatus") + || invalidated.iter().any(|field| field == "PlaybackStatus")); + if !identity && !status { + continue; + } + + let Some(owner) = header.sender() else { + continue; + }; + for player in players.values_mut().filter(|player| player.owner == owner.as_str()) { + // Re-read current state: a signal queued during discovery may + // describe older playback than the startup snapshot. + refresh_player(&connection, player, identity).await; + } + } + + if !publish(&players, &mut previous, &mut send) { + return Ok(()); + } + } + + Err(zbus::Error::Failure("MPRIS signal connection ended".into())) +} + +pub(crate) fn init(event_loop: &mut EventLoop) -> Result<(), Box> { + let (sender, receiver) = channel(); + event_loop.handle().insert_source(receiver, |event, _, state| { + if let Event::Msg(players) = event { + state.media_players = players; + state.refresh_idle_inhibition(); + } + })?; + + std::thread::Builder::new() + .name("ferese-playback".into()) + .spawn(move || { + let mut had_players = false; + loop { + let result = future::block_on(async { + let connection = zbus::connection::Builder::session()? + .method_timeout(Duration::from_millis(500)) + .build() + .await?; + monitor(connection, |players| { + had_players = !players.is_empty(); + sender.send(players).is_ok() + }) + .await + }); + match result { + Ok(()) => break, + Err(error) => { + tracing::debug!(%error, "playback monitor disconnected; releasing automatic inhibition") + } + } + + if had_players && sender.send(Vec::new()).is_err() { + break; + } + had_players = false; + // Reconnect only after failure; healthy operation sleeps on signals. + std::thread::sleep(Duration::from_secs(5)); + } + })?; + Ok(()) +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 4f810cf..0dfb8f5 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -692,6 +692,17 @@ impl Ferese { "close" => self.close_focused_window(), "get-focused-window" => return Ok(self.focused_window_json()), "get-windows" => return Ok(self.windows_json()), + "get-idle-inhibition" => { + return Ok(json!({ + "inhibited": self.idle_notifier_state.is_inhibited(), + "automatic": self.automatic_idle_inhibited, + "fullscreen-playback": self.idle_inhibit.fullscreen_playback, + "portal": self.portal_session.idle_inhibited(), + "players": self.media_players.iter().map(|player| json!({ + "name": player.name, "desktop-entry": player.desktop_entry, "playing": player.playing, + })).collect::>(), + })); + } "get-keybindings" => { let map = crate::config::physical_keymap(&self.input_settings).ok(); return Ok(json!( diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 6962929..d73ca09 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -12,6 +12,7 @@ mod frame_scheduler; mod gestures; mod grabs; mod handlers; +mod idle_inhibition; mod input; mod input_capture; mod ipc; @@ -109,6 +110,7 @@ fn main() -> Result<(), Box> { }); theme::init(&mut event_loop, &mut state, candidate)?; resume::init(&mut event_loop)?; + idle_inhibition::media::init(&mut event_loop)?; overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); diff --git a/compositor/src/portal_session.rs b/compositor/src/portal_session.rs index db97e1e..f708107 100644 --- a/compositor/src/portal_session.rs +++ b/compositor/src/portal_session.rs @@ -116,7 +116,7 @@ impl PortalSession { } } - fn idle_inhibited(&self) -> bool { + pub(crate) fn idle_inhibited(&self) -> bool { self.inhibitors.values().any(|inhibitor| inhibitor.flags & 8 != 0) } @@ -234,21 +234,6 @@ impl PortalSession { } impl crate::Ferese { - pub(crate) fn refresh_idle_inhibition(&mut self) { - let eligible = if self.idle_inhibitors.is_empty() { - Default::default() - } else { - self.callback_outputs() - }; - - self.idle_notifier_state.set_is_inhibited( - self.idle_inhibitors - .keys() - .any(|surface| eligible.contains_key(&surface.into())) - || self.portal_session.idle_inhibited(), - ); - } - pub(crate) fn end_portal_session(&mut self) { let token = self.portal_session.query.as_ref().map(|query| query.token); let result = diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0a741eb..dafd783 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -443,7 +443,7 @@ pub struct Ferese { pub(crate) input_settings: InputSettings, pub(crate) bindings: BindingSet, workspace_auto_back_and_forth: bool, - window_rules: Vec, + pub(crate) window_rules: Vec, pub(crate) theme_settings: ThemeSettings, pub(crate) inactive_dim: crate::config::InactiveDimSettings, animations_enabled: bool, @@ -464,6 +464,9 @@ pub struct Ferese { pub intercepted_keys: HashSet, pub(crate) swipe: Swipe, pub idle_inhibitors: HashMap, + pub(crate) idle_inhibit: crate::idle_inhibition::Settings, + pub(crate) media_players: Vec, + pub(crate) automatic_idle_inhibited: bool, pub active_shortcuts_inhibitor: Option, pub direct_backend: Option, _ipc_socket: Option, @@ -517,6 +520,7 @@ pub struct Ferese { #[derive(Clone)] pub struct RuntimeConfig { pub(crate) lock_idle: IdleSettings, + pub(crate) idle_inhibit: crate::idle_inhibition::Settings, pub(crate) autostart: Vec, pub(crate) overview_font_family: String, pub(crate) wallpaper: WallpaperConfig, @@ -701,6 +705,9 @@ impl Ferese { intercepted_keys: HashSet::new(), swipe: Swipe::default(), idle_inhibitors: HashMap::new(), + idle_inhibit: config.idle_inhibit, + media_players: Vec::new(), + automatic_idle_inhibited: false, active_shortcuts_inhibitor: None, direct_backend: None, _ipc_socket: None, @@ -852,6 +859,8 @@ impl Ferese { self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings); } let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); + let idle_policy_changed = self.idle_inhibit != config.idle_inhibit || old_rules != self.window_rules; + self.idle_inhibit = config.idle_inhibit; self.theme_settings = config.theme_settings; self.inactive_dim = config.inactive_dim; self.column_width_presets = config.column_width_presets; @@ -885,6 +894,10 @@ impl Ferese { self.reapply_window_rules(&old_rules); } + if idle_policy_changed { + self.refresh_idle_inhibition(); + } + self.overview.set_font_family(config.overview_font_family); if self.wallpaper.configuration() != &config.wallpaper { self.wallpaper.reload(config.wallpaper); diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index 34670c8..b1239d8 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -11,6 +11,7 @@ pub(crate) struct WindowRuleConfig { pub height: Option, pub fullscreen: Option, pub block_out_from_screencasts: Option, + pub idle_inhibit: Option, } #[derive(Clone, Debug, PartialEq)] @@ -24,6 +25,13 @@ pub struct WindowRule { height: Option, fullscreen: Option, block_out_from_screencasts: Option, + idle_inhibit: Option, +} + +impl WindowRule { + pub(crate) fn idle_policy(&self) -> Option { + self.idle_inhibit + } } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -34,6 +42,7 @@ pub struct WindowRuleResult { pub height: Option, pub fullscreen: Option, pub block_out_from_screencasts: Option, + pub idle_inhibit: Option, } /// Only changed matches should override an existing window's manual state. @@ -42,9 +51,11 @@ pub(crate) fn live_result( mut new: WindowRuleResult, transient: bool, ) -> Option { - // Capture policy is resolved independently of placement at capture time. + // Capture and idle policies are reevaluated without changing placement. old.block_out_from_screencasts = None; new.block_out_from_screencasts = None; + old.idle_inhibit = None; + new.idle_inhibit = None; if old == new { return None; } @@ -97,6 +108,7 @@ pub fn resolve(rules: &[WindowRule], app_id: Option<&str>, title: Option<&str>, result.height = rule.height.or(result.height); result.fullscreen = rule.fullscreen.or(result.fullscreen); result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts); + result.idle_inhibit = rule.idle_inhibit.or(result.idle_inhibit); } result @@ -137,6 +149,7 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result) -> bool { ) } -fn normalize_app_id(value: &str) -> String { +pub(crate) fn normalize_app_id(value: &str) -> String { value .trim() .strip_suffix(".desktop") @@ -215,6 +228,7 @@ mod tests { height: None, fullscreen: None, block_out_from_screencasts: None, + idle_inhibit: None, } } @@ -346,6 +360,7 @@ mod tests { height: None, fullscreen: None, block_out_from_screencasts: None, + idle_inhibit: None, }; let invalid_size = WindowRuleConfig { width: Some(f64::NAN), diff --git a/docs/configuration.md b/docs/configuration.md index d210a7e..f1fef4d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -534,6 +534,7 @@ window rules can override that behavior. | `width`, `height` | numbers > 0 | Application-chosen floating size | | `fullscreen` | boolean | Leave fullscreen state unchanged | | `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs | +| `idle-inhibit` | `none`, `visible`, `fullscreen`, `playing`, `fullscreen-playing` | Automatic fullscreen playback; see [Idle inhibition](#idle-inhibition) | `window-rule app-id="org.example.Private" block-out-from-screencasts=#true` keeps matching windows visible on the display but omits them (including their @@ -574,7 +575,9 @@ Transient dialogs and cancelled drags do not update this memory. Floating → ti → floating also restores the window's last floating geometry. Nested previews keep drag memory in process and do not write the user's saved placements. -Title changes do not trigger new rules; config rule changes apply to existing windows. +Title changes do not reapply placement rules. Capture privacy and idle inhibition +follow current app ID, title and parent metadata. Config rule changes apply to +existing windows. ```kdl window-rule app-id="dev.ferese.Settings" floating=#true @@ -776,6 +779,49 @@ unlock in a nested compositor before enabling automatic locking. A crashed locker leaves the session locked; recovery requires ending that session from another TTY. +## Idle inhibition + +Ferese keeps the session awake during fullscreen playback when an application +reports `Playing` through MPRIS. Pausing or stopping playback releases this +automatic inhibition. A window must have content visible on an awake display; +switching away, closing it, locking the session or sleeping its display releases +inhibition too. + +Disable the default fullscreen playback policy with: + +```kdl +idle-inhibit { + fullscreen-playback #false +} +``` + +Use window rules for presentations, games or other apps that should keep the +session awake while visible: + +```kdl +window-rule app-id="org.example.Presentation" idle-inhibit="visible" +``` + +| `idle-inhibit` rule | Behavior | +| --- | --- | +| `visible` | Inhibit while visible, regardless of playback state | +| `fullscreen` | Inhibit while visible and fullscreen | +| `playing` | Inhibit while visible and its MPRIS player reports `Playing` | +| `fullscreen-playing` | Inhibit while visible, fullscreen and its MPRIS player reports `Playing` | +| `none` | Disable automatic inhibition for this window | + +MPRIS identifies an application, not an individual video window. Ferese matches +the player's process ID first, then its `DesktopEntry` against the window's app +ID. If several windows match, automatic playback inhibition stays off. MPRIS +also does not distinguish audio from video, so fullscreen audio playback can +inhibit too. Players without MPRIS can request inhibition through the existing +Wayland or portal protocols; a fullscreen window alone does not prove playback. + +These settings control automatic inhibition. They do not override an +application's own Wayland or portal request, and they do not block manual +locking or suspend. Run `feresectl get-idle-inhibition` to inspect the current +inhibition state and discovered players. Changes take effect on config reload. + ## Optional session protocols These are launch-time environment switches, not KDL keys. Enable only for diff --git a/docs/development.md b/docs/development.md index 2346152..729f26f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,6 +83,18 @@ cargo test --locked -p ferese backends::direct::capture::tests -- --ignored cargo test --locked -p ferese mirrored_pixels_fit -- --ignored ``` +## Idle inhibition checks + +The playback test runs a fake MPRIS player on a private bus and opens a client in +a temporary nested desktop. It checks pause/stop, workspace switching, window closure, +player replacement and live rule changes. It needs a Wayland session, +PyGObject, `dbus-run-session`, `cc` and `wayland-scanner`. + +```sh +cargo build --release --locked -p ferese -p feresectl +FERESE_TEST_IDLE_INHIBITION=1 FERESE_TEST_BINARY=target/release/ferese FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_idle_inhibition_isolated.py +``` + ## Report a bug Include the commit or installed release, reproduction steps, backend (nested or diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index ab2f59a..cbaeaf5 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -177,6 +177,7 @@ fn parse_args(args: impl IntoIterator) -> Result<(String, Value), | "close" | "get-focused-window" | "get-windows" + | "get-idle-inhibition" | "get-workspaces" | "get-outputs" | "outputs" @@ -219,7 +220,7 @@ fn socket_path() -> Result { } fn usage() -> String { - "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() + "usage: feresectl outputs\n feresectl output-profiles\n feresectl \n feresectl output-layout \n feresectl toggle-display-mode\n feresectl output-profile \n feresectl output-internal \n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window \n feresectl \n feresectl \n feresectl workspace-back-and-forth\n feresectl \n feresectl \n feresectl \n feresectl ".to_owned() } #[cfg(test)] @@ -346,5 +347,7 @@ mod tests { assert!(parse_args(["focus".to_owned()]).is_err()); assert!(parse_args(["reload-config".to_owned()]).is_ok()); assert!(parse_args(["reload-config".to_owned(), "extra".to_owned()]).is_err()); + assert!(parse_args(["get-idle-inhibition".to_owned()]).is_ok()); + assert!(parse_args(["get-idle-inhibition".to_owned(), "extra".to_owned()]).is_err()); } } diff --git a/packaging/config.kdl b/packaging/config.kdl index 42a41e1..19d88e4 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -197,6 +197,10 @@ lock-screen { // Install swayidle and test unlocking before enabling automatic locking. // autostart "swayidle" "-w" "timeout" "300" "ferese-lock" "before-sleep" "ferese-lock" "lock" "ferese-lock" +// Fullscreen MPRIS playback keeps the session awake until paused or hidden. +// idle-inhibit { fullscreen-playback #false; } +// window-rule app-id="org.example.Presentation" idle-inhibit="visible" + // Find display names with feresectl get-outputs, then adjust and uncomment. // output-profile "docked" { // output "HDMI-A-1" scale=1.5 { position 0 0; } diff --git a/scripts/tests/test_idle_inhibition_isolated.py b/scripts/tests/test_idle_inhibition_isolated.py new file mode 100644 index 0000000..339f9a6 --- /dev/null +++ b/scripts/tests/test_idle_inhibition_isolated.py @@ -0,0 +1,207 @@ +"""Test playback and window visibility on a private bus and nested desktop. + +FERESE_TEST_IDLE_INHIBITION=1 python3 scripts/tests/test_idle_inhibition_isolated.py +Requires Wayland, PyGObject, dbus-run-session, cc and wayland-scanner. +FERESE_TEST_BINARY and FERESE_TEST_CTL can select release binaries. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +REPO = Path(__file__).resolve().parents[2] +BINARY = REPO / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") +CTL = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") +NAME = "org.mpris.MediaPlayer2.ferese_test" +PATH = "/org/mpris/MediaPlayer2" +PLAYER = "org.mpris.MediaPlayer2.Player" +APP = "ferese.test.window-capture" + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_IDLE_INHIBITION") == "1", "requires a Wayland host") +class IdleInhibition(unittest.TestCase): + def test_private_playback_and_visibility(self): + result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +def private_checks(): + import gi + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib + + loop = GLib.MainLoop() + threading.Thread(target=loop.run, daemon=True).start() + + class FakePlayer: + def __init__(self, status="Paused"): + self.status = status + self.connection = Gio.DBusConnection.new_for_address_sync( + os.environ["DBUS_SESSION_BUS_ADDRESS"], + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, None) + node = Gio.DBusNodeInfo.new_for_xml(f''' + + + ''') + self.registrations = [self.connection.register_object(PATH, interface, None, self.property, None) + for interface in node.interfaces] + self.connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "RequestName", GLib.Variant("(su)", (NAME, 0)), None, + Gio.DBusCallFlags.NONE, 2000, None) + + def property(self, connection, sender, path, interface, name): + return GLib.Variant("s", self.status if name == "PlaybackStatus" else APP) + + def set(self, status, invalidated=False): + self.status = status + changed = {} if invalidated else {"PlaybackStatus": GLib.Variant("s", status)} + fields = ["PlaybackStatus"] if invalidated else [] + self.connection.emit_signal(None, PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", + GLib.Variant("(sa{sv}as)", (PLAYER, changed, fields))) + self.connection.flush_sync(None) + + def close(self): + self.connection.close_sync(None) + + processes = [] + player = FakePlayer() + try: + with tempfile.TemporaryDirectory(prefix="ferese-idle-") as directory: + root = Path(directory) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese/config.kdl" + config.parent.mkdir(parents=True) + base = f'animations {{ reduced-motion #true; }}\nwindow-rule app-id="{APP}" floating=#true\n' + config.write_text(base) + protocols = subprocess.check_output(["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip() + xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml") + for kind, output in [("client-header", "xdg-shell-client-protocol.h"), ("private-code", "xdg-shell-protocol.c")]: + subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True) + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split() + subprocess.run(["cc", "-Wall", "-Wextra", "-I", str(root), str(REPO / "scripts/tests/fixtures/window-capture.c"), + str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True) + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state")) + for variable in ["WAYLAND_SOCKET", "FERESE_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"]: + env.pop(variable, None) + + with (root / "compositor.log").open("w") as log: + try: + compositor = subprocess.Popen([str(BINARY), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + processes.append(compositor) + + def wait(check, message): + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + if compositor.poll() is not None: + raise AssertionError((root / "compositor.log").read_text()) + if check(): + return + time.sleep(0.02) + raise AssertionError(message + "\n" + (root / "compositor.log").read_text()) + + wait(lambda: (runtime / "ferese/control.sock").exists(), "compositor startup") + env["WAYLAND_DISPLAY"] = str(next(path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock"))) + + def call(*args): + return json.loads(subprocess.check_output([str(CTL), *args], env=env, timeout=5)) + + def inhibited(expected): + def matches(): + state = call("get-idle-inhibition") + return state["automatic"] == expected and state["inhibited"] == expected + + wait(matches, f"idle inhibition != {expected}") + + wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "MPRIS discovery") + client = subprocess.Popen([str(root / "client")], env=env, stdout=log, stderr=log, start_new_session=True) + processes.append(client) + wait(lambda: len(call("get-windows")) == 1, "window map") + call("toggle-fullscreen") + inhibited(False) + player.set("Playing") + inhibited(True) + player.set("Paused") + inhibited(False) + player.set("Playing", invalidated=True) + inhibited(True) + player.set("Stopped") + inhibited(False) + player.set("Playing") + inhibited(True) + call("workspace", "2") + inhibited(False) + call("workspace", "1") + inhibited(True) + + player.close() + inhibited(False) + player = FakePlayer("Paused") + wait(lambda: len(call("get-idle-inhibition")["players"]) == 1, "replacement discovery") + inhibited(False) + player.set("Playing") + inhibited(True) + ambiguous = subprocess.Popen([str(root / "client"), "cover"], env=env, + stdout=log, stderr=log, start_new_session=True) + processes.append(ambiguous) + wait(lambda: len(call("get-windows")) == 2, "second player window") + inhibited(False) + os.killpg(ambiguous.pid, signal.SIGTERM) + ambiguous.wait(timeout=5) + wait(lambda: len(call("get-windows")) == 1, "ambiguous window closure") + inhibited(True) + config.write_text(base + f'window-rule app-id="{APP}" idle-inhibit="none"\n') + call("reload-config") + inhibited(False) + config.write_text(base) + call("reload-config") + inhibited(True) + call("toggle-fullscreen") + inhibited(False) + + config.write_text(base + f'idle-inhibit {{ fullscreen-playback #false; }}\nwindow-rule app-id="{APP}" idle-inhibit="visible"\n') + call("reload-config") + inhibited(True) + player.set("Stopped") + inhibited(True) + call("workspace", "2") + inhibited(False) + call("workspace", "1") + inhibited(True) + config.write_text(base + 'idle-inhibit { fullscreen-playback #false; }\n') + call("reload-config") + inhibited(False) + config.write_text(base + f'window-rule app-id="{APP}" idle-inhibit="visible"\n') + call("reload-config") + inhibited(True) + os.killpg(client.pid, signal.SIGTERM) + client.wait(timeout=5) + wait(lambda: not call("get-windows"), "closed window remained registered") + inhibited(False) + finally: + for process in reversed(processes): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + finally: + player.close() + loop.quit() + + +if __name__ == "__main__": + if "--private" in sys.argv: + private_checks() + else: + unittest.main()