Add automatic idle inhibition for fullscreen playback and selected apps
This commit is contained in:
1 parent
453ec16583
commit
03a265b97f
17 files changed
+842
-22
No files matched your search
Generated
+1
@@ -1656,6 +1656,7 @@ dependencies = [
|
||||
"ferese-layout",
|
||||
"ferese-protocols",
|
||||
"fontdb",
|
||||
"futures-lite",
|
||||
"image",
|
||||
"jiff",
|
||||
"libc",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<DaemonConfig>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<u32>,
|
||||
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<WindowId> {
|
||||
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::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let playing_windows = self
|
||||
.media_players
|
||||
.iter()
|
||||
.filter(|player| player.playing)
|
||||
.filter_map(|player| player_window(player, &application))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
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::<XdgToplevelSurfaceData>()
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
pub desktop_entry: Option<String>,
|
||||
pub playing: bool,
|
||||
}
|
||||
|
||||
async fn bus_call<T: serde::de::DeserializeOwned + zbus::zvariant::Type>(
|
||||
connection: &Connection,
|
||||
member: &str,
|
||||
name: &str,
|
||||
) -> zbus::Result<T> {
|
||||
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<OwnedValue> {
|
||||
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<String, Player>,
|
||||
previous: &mut Vec<Player>,
|
||||
send: &mut impl FnMut(Vec<Player>) -> 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<Player>) -> 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<String> = 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<String, OwnedValue>, Vec<String>)>()
|
||||
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<Ferese>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
"get-keybindings" => {
|
||||
let map = crate::config::physical_keymap(&self.input_settings).ok();
|
||||
return Ok(json!(
|
||||
|
||||
@@ -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<dyn Error>> {
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
+14
-1
@@ -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<WindowRule>,
|
||||
pub(crate) window_rules: Vec<WindowRule>,
|
||||
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<Keycode>,
|
||||
pub(crate) swipe: Swipe,
|
||||
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
||||
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
|
||||
pub(crate) media_players: Vec<crate::idle_inhibition::media::Player>,
|
||||
pub(crate) automatic_idle_inhibited: bool,
|
||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||
pub direct_backend: Option<DirectBackendState>,
|
||||
_ipc_socket: Option<IpcSocketGuard>,
|
||||
@@ -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<DaemonConfig>,
|
||||
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);
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) struct WindowRuleConfig {
|
||||
pub height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -24,6 +25,13 @@ pub struct WindowRule {
|
||||
height: Option<f64>,
|
||||
fullscreen: Option<bool>,
|
||||
block_out_from_screencasts: Option<bool>,
|
||||
idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
}
|
||||
|
||||
impl WindowRule {
|
||||
pub(crate) fn idle_policy(&self) -> Option<crate::idle_inhibition::Mode> {
|
||||
self.idle_inhibit
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -34,6 +42,7 @@ pub struct WindowRuleResult {
|
||||
pub height: Option<f64>,
|
||||
pub fullscreen: Option<bool>,
|
||||
pub block_out_from_screencasts: Option<bool>,
|
||||
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
|
||||
}
|
||||
|
||||
/// 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<WindowRuleResult> {
|
||||
// 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<WindowRule, St
|
||||
height,
|
||||
fullscreen: rule.fullscreen,
|
||||
block_out_from_screencasts: rule.block_out_from_screencasts,
|
||||
idle_inhibit: rule.idle_inhibit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,7 +184,7 @@ pub(crate) fn is_native_dialog(app_id: Option<&str>) -> 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),
|
||||
|
||||
+47
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -177,6 +177,7 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> 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<PathBuf, io::Error> {
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|reload-config>".to_owned()
|
||||
"usage: feresectl outputs\n feresectl output-profiles\n feresectl <output-confirm|output-revert>\n feresectl output-layout <internal-only|external-only|extend|mirror>\n feresectl toggle-display-mode\n feresectl output-profile <name|auto>\n feresectl output-internal <on|off>\n feresectl autostart\n feresectl screenshot [--geometry \"x,y WxH\"]\n feresectl screenshot-window <window-id>\n feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl workspace-back-and-forth\n feresectl <focus-last-window|focus-mru-next|focus-mru-previous>\n feresectl <toggle-floating|toggle-maximized|toggle-fullscreen|toggle-layout|toggle-overview|toggle-keybinding-guide>\n feresectl <cycle-column-width|center-column|consume|expel|close|exit|request-logout>\n feresectl <get-focused-window|get-windows|get-workspaces|get-outputs|get-idle-inhibition|reload-config>".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());
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'''<node>
|
||||
<interface name="org.mpris.MediaPlayer2"><property name="DesktopEntry" type="s" access="read"/></interface>
|
||||
<interface name="{PLAYER}"><property name="PlaybackStatus" type="s" access="read"/></interface>
|
||||
</node>''')
|
||||
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()
|
||||
Reference in new issue
Block a user