Add automatic idle inhibition for fullscreen playback and selected apps

This commit is contained in:
Samuel Onoja committed 2026-10-03 11:20:55 +01:00
1 parent 453ec16583
commit 03a265b97f
17 files changed
+842 -22

No files matched your search

Generated
+1
View File
@@ -1656,6 +1656,7 @@ dependencies = [
"ferese-layout", "ferese-layout",
"ferese-protocols", "ferese-protocols",
"fontdb", "fontdb",
"futures-lite",
"image", "image",
"jiff", "jiff",
"libc", "libc",
+2 -1
View File
@@ -29,6 +29,7 @@ Ferese combines a window manager and desktop shell with one configuration.
- Desktop widgets, including a clock and sticky notes - Desktop widgets, including a clock and sticky notes
- Screenshots with annotation, screen sharing and built-in recording - Screenshots with annotation, screen sharing and built-in recording
- A built-in lock screen and authentication dialogs - 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 - Settings and Control Center with Wi-Fi, Bluetooth and audio controls
## How Ferese compares ## 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. Changes apply live. Invalid edits leave the last working configuration in place.
The [configuration reference](docs/configuration.md) and [example 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. displays and startup apps.
## Default shortcuts ## Default shortcuts
+1
View File
@@ -15,6 +15,7 @@ ferese-core = { path = "../crates/ferese-core" }
ferese-ipc = { path = "../crates/ferese-ipc" } ferese-ipc = { path = "../crates/ferese-ipc" }
ferese-layout = { path = "../crates/ferese-layout" } ferese-layout = { path = "../crates/ferese-layout" }
ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] } ferese-protocols = { path = "../crates/ferese-protocols", features = ["server"] }
futures-lite = "2.6.1"
libc.workspace = true libc.workspace = true
jiff.workspace = true jiff.workspace = true
notify = "8.2.0" notify = "8.2.0"
+3
View File
@@ -34,6 +34,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
lock_screen: crate::session_lock::IdleSettings, lock_screen: crate::session_lock::IdleSettings,
#[serde(default)] #[serde(default)]
idle_inhibit: crate::idle_inhibition::Settings,
#[serde(default)]
desktop_widgets: ferese_config::desktop::DesktopWidgets, desktop_widgets: ferese_config::desktop::DesktopWidgets,
#[serde(default)] #[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
@@ -115,6 +117,7 @@ impl Config {
let bindings = self.bindings(&input_settings)?; let bindings = self.bindings(&input_settings)?;
Ok(crate::RuntimeConfig { Ok(crate::RuntimeConfig {
lock_idle: self.lock_screen.validate()?, lock_idle: self.lock_screen.validate()?,
idle_inhibit: self.idle_inhibit,
autostart: self.autostart.clone(), autostart: self.autostart.clone(),
layout_mode: self.layout_mode(), layout_mode: self.layout_mode(),
workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth, workspace_auto_back_and_forth: self.workspaces.auto_back_and_forth,
+3
View File
@@ -36,6 +36,7 @@ impl XdgShellHandler for Ferese {
fn title_changed(&mut self, surface: ToplevelSurface) { fn title_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy(); self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) { if self.windows.refresh_metadata(surface.wl_surface()) {
self.refresh_idle_inhibition();
self.send_shell_snapshots(); self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) { for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output); self.defer_output_redraw(output);
@@ -46,6 +47,7 @@ impl XdgShellHandler for Ferese {
fn app_id_changed(&mut self, surface: ToplevelSurface) { fn app_id_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy(); self.refresh_capture_privacy();
if self.windows.refresh_metadata(surface.wl_surface()) { if self.windows.refresh_metadata(surface.wl_surface()) {
self.refresh_idle_inhibition();
self.send_shell_snapshots(); self.send_shell_snapshots();
for output in self.surface_outputs(surface.wl_surface()) { for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output); self.defer_output_redraw(output);
@@ -55,6 +57,7 @@ impl XdgShellHandler for Ferese {
fn parent_changed(&mut self, surface: ToplevelSurface) { fn parent_changed(&mut self, surface: ToplevelSurface) {
self.refresh_capture_privacy(); self.refresh_capture_privacy();
self.refresh_idle_inhibition();
let Some(parent) = surface.parent() else { let Some(parent) = surface.parent() else {
return; return;
}; };
+268
View File
@@ -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
);
}
}
+245
View File
@@ -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(())
}
+11
View File
@@ -692,6 +692,17 @@ impl Ferese {
"close" => self.close_focused_window(), "close" => self.close_focused_window(),
"get-focused-window" => return Ok(self.focused_window_json()), "get-focused-window" => return Ok(self.focused_window_json()),
"get-windows" => return Ok(self.windows_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" => { "get-keybindings" => {
let map = crate::config::physical_keymap(&self.input_settings).ok(); let map = crate::config::physical_keymap(&self.input_settings).ok();
return Ok(json!( return Ok(json!(
+2
View File
@@ -12,6 +12,7 @@ mod frame_scheduler;
mod gestures; mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
mod idle_inhibition;
mod input; mod input;
mod input_capture; mod input_capture;
mod ipc; mod ipc;
@@ -109,6 +110,7 @@ fn main() -> Result<(), Box<dyn Error>> {
}); });
theme::init(&mut event_loop, &mut state, candidate)?; theme::init(&mut event_loop, &mut state, candidate)?;
resume::init(&mut event_loop)?; resume::init(&mut event_loop)?;
idle_inhibition::media::init(&mut event_loop)?;
overview::init_font_loader(&mut event_loop, &mut state)?; overview::init_font_loader(&mut event_loop, &mut state)?;
backends::init(launch.backend, &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"); info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
+1 -16
View File
@@ -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) self.inhibitors.values().any(|inhibitor| inhibitor.flags & 8 != 0)
} }
@@ -234,21 +234,6 @@ impl PortalSession {
} }
impl crate::Ferese { 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) { pub(crate) fn end_portal_session(&mut self) {
let token = self.portal_session.query.as_ref().map(|query| query.token); let token = self.portal_session.query.as_ref().map(|query| query.token);
let result = let result =
+14 -1
View File
@@ -443,7 +443,7 @@ pub struct Ferese {
pub(crate) input_settings: InputSettings, pub(crate) input_settings: InputSettings,
pub(crate) bindings: BindingSet, pub(crate) bindings: BindingSet,
workspace_auto_back_and_forth: bool, workspace_auto_back_and_forth: bool,
window_rules: Vec<WindowRule>, pub(crate) window_rules: Vec<WindowRule>,
pub(crate) theme_settings: ThemeSettings, pub(crate) theme_settings: ThemeSettings,
pub(crate) inactive_dim: crate::config::InactiveDimSettings, pub(crate) inactive_dim: crate::config::InactiveDimSettings,
animations_enabled: bool, animations_enabled: bool,
@@ -464,6 +464,9 @@ pub struct Ferese {
pub intercepted_keys: HashSet<Keycode>, pub intercepted_keys: HashSet<Keycode>,
pub(crate) swipe: Swipe, pub(crate) swipe: Swipe,
pub idle_inhibitors: HashMap<WlSurface, usize>, 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 active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
pub direct_backend: Option<DirectBackendState>, pub direct_backend: Option<DirectBackendState>,
_ipc_socket: Option<IpcSocketGuard>, _ipc_socket: Option<IpcSocketGuard>,
@@ -517,6 +520,7 @@ pub struct Ferese {
#[derive(Clone)] #[derive(Clone)]
pub struct RuntimeConfig { pub struct RuntimeConfig {
pub(crate) lock_idle: IdleSettings, pub(crate) lock_idle: IdleSettings,
pub(crate) idle_inhibit: crate::idle_inhibition::Settings,
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
pub(crate) overview_font_family: String, pub(crate) overview_font_family: String,
pub(crate) wallpaper: WallpaperConfig, pub(crate) wallpaper: WallpaperConfig,
@@ -701,6 +705,9 @@ impl Ferese {
intercepted_keys: HashSet::new(), intercepted_keys: HashSet::new(),
swipe: Swipe::default(), swipe: Swipe::default(),
idle_inhibitors: HashMap::new(), idle_inhibitors: HashMap::new(),
idle_inhibit: config.idle_inhibit,
media_players: Vec::new(),
automatic_idle_inhibited: false,
active_shortcuts_inhibitor: None, active_shortcuts_inhibitor: None,
direct_backend: None, direct_backend: None,
_ipc_socket: None, _ipc_socket: None,
@@ -852,6 +859,8 @@ impl Ferese {
self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings); self.portal_shortcuts.reconcile(&self.bindings, &self.input_settings);
} }
let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); 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.theme_settings = config.theme_settings;
self.inactive_dim = config.inactive_dim; self.inactive_dim = config.inactive_dim;
self.column_width_presets = config.column_width_presets; self.column_width_presets = config.column_width_presets;
@@ -885,6 +894,10 @@ impl Ferese {
self.reapply_window_rules(&old_rules); self.reapply_window_rules(&old_rules);
} }
if idle_policy_changed {
self.refresh_idle_inhibition();
}
self.overview.set_font_family(config.overview_font_family); self.overview.set_font_family(config.overview_font_family);
if self.wallpaper.configuration() != &config.wallpaper { if self.wallpaper.configuration() != &config.wallpaper {
self.wallpaper.reload(config.wallpaper); self.wallpaper.reload(config.wallpaper);
+17 -2
View File
@@ -11,6 +11,7 @@ pub(crate) struct WindowRuleConfig {
pub height: Option<f64>, pub height: Option<f64>,
pub fullscreen: Option<bool>, pub fullscreen: Option<bool>,
pub block_out_from_screencasts: Option<bool>, pub block_out_from_screencasts: Option<bool>,
pub idle_inhibit: Option<crate::idle_inhibition::Mode>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -24,6 +25,13 @@ pub struct WindowRule {
height: Option<f64>, height: Option<f64>,
fullscreen: Option<bool>, fullscreen: Option<bool>,
block_out_from_screencasts: 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)] #[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -34,6 +42,7 @@ pub struct WindowRuleResult {
pub height: Option<f64>, pub height: Option<f64>,
pub fullscreen: Option<bool>, pub fullscreen: Option<bool>,
pub block_out_from_screencasts: 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. /// Only changed matches should override an existing window's manual state.
@@ -42,9 +51,11 @@ pub(crate) fn live_result(
mut new: WindowRuleResult, mut new: WindowRuleResult,
transient: bool, transient: bool,
) -> Option<WindowRuleResult> { ) -> 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; old.block_out_from_screencasts = None;
new.block_out_from_screencasts = None; new.block_out_from_screencasts = None;
old.idle_inhibit = None;
new.idle_inhibit = None;
if old == new { if old == new {
return None; 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.height = rule.height.or(result.height);
result.fullscreen = rule.fullscreen.or(result.fullscreen); result.fullscreen = rule.fullscreen.or(result.fullscreen);
result.block_out_from_screencasts = rule.block_out_from_screencasts.or(result.block_out_from_screencasts); 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 result
@@ -137,6 +149,7 @@ fn validate_rule(index: usize, rule: &WindowRuleConfig) -> Result<WindowRule, St
height, height,
fullscreen: rule.fullscreen, fullscreen: rule.fullscreen,
block_out_from_screencasts: rule.block_out_from_screencasts, 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 value
.trim() .trim()
.strip_suffix(".desktop") .strip_suffix(".desktop")
@@ -215,6 +228,7 @@ mod tests {
height: None, height: None,
fullscreen: None, fullscreen: None,
block_out_from_screencasts: None, block_out_from_screencasts: None,
idle_inhibit: None,
} }
} }
@@ -346,6 +360,7 @@ mod tests {
height: None, height: None,
fullscreen: None, fullscreen: None,
block_out_from_screencasts: None, block_out_from_screencasts: None,
idle_inhibit: None,
}; };
let invalid_size = WindowRuleConfig { let invalid_size = WindowRuleConfig {
width: Some(f64::NAN), width: Some(f64::NAN),
+47 -1
View File
@@ -534,6 +534,7 @@ window rules can override that behavior.
| `width`, `height` | numbers > 0 | Application-chosen floating size | | `width`, `height` | numbers > 0 | Application-chosen floating size |
| `fullscreen` | boolean | Leave fullscreen state unchanged | | `fullscreen` | boolean | Leave fullscreen state unchanged |
| `block-out-from-screencasts` | boolean | Exclude from captures; enabled by default for Ferese authentication dialogs | | `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` `window-rule app-id="org.example.Private" block-out-from-screencasts=#true`
keeps matching windows visible on the display but omits them (including their 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 → 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. 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 ```kdl
window-rule app-id="dev.ferese.Settings" floating=#true 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 locker leaves the session locked; recovery requires ending that session from
another TTY. 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 ## Optional session protocols
These are launch-time environment switches, not KDL keys. Enable only for These are launch-time environment switches, not KDL keys. Enable only for
+12
View File
@@ -83,6 +83,18 @@ cargo test --locked -p ferese backends::direct::capture::tests -- --ignored
cargo test --locked -p ferese mirrored_pixels_fit -- --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 ## Report a bug
Include the commit or installed release, reproduction steps, backend (nested or Include the commit or installed release, reproduction steps, backend (nested or
+4 -1
View File
@@ -177,6 +177,7 @@ fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value),
| "close" | "close"
| "get-focused-window" | "get-focused-window"
| "get-windows" | "get-windows"
| "get-idle-inhibition"
| "get-workspaces" | "get-workspaces"
| "get-outputs" | "get-outputs"
| "outputs" | "outputs"
@@ -219,7 +220,7 @@ fn socket_path() -> Result<PathBuf, io::Error> {
} }
fn usage() -> String { 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)] #[cfg(test)]
@@ -346,5 +347,7 @@ mod tests {
assert!(parse_args(["focus".to_owned()]).is_err()); assert!(parse_args(["focus".to_owned()]).is_err());
assert!(parse_args(["reload-config".to_owned()]).is_ok()); assert!(parse_args(["reload-config".to_owned()]).is_ok());
assert!(parse_args(["reload-config".to_owned(), "extra".to_owned()]).is_err()); 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());
} }
} }
+4
View File
@@ -197,6 +197,10 @@ lock-screen {
// Install swayidle and test unlocking before enabling automatic locking. // Install swayidle and test unlocking before enabling automatic locking.
// autostart "swayidle" "-w" "timeout" "300" "ferese-lock" "before-sleep" "ferese-lock" "lock" "ferese-lock" // 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. // Find display names with feresectl get-outputs, then adjust and uncomment.
// output-profile "docked" { // output-profile "docked" {
// output "HDMI-A-1" scale=1.5 { position 0 0; } // 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()