From 5c1b98d75f92ca87a44e50296f1c1d534de580d2 Mon Sep 17 00:00:00 2001 From: Demmie <2e3s19@gmail.com> Date: Wed, 19 Apr 2023 19:35:11 -0400 Subject: [PATCH] Implement idle watcher for X11 --- Cargo.lock | 76 +++++++++++++++++++++++++++--- Cargo.toml | 1 + src/config.rs | 17 ++++--- src/kwin_window.rs | 5 +- src/main.rs | 15 ++++-- src/report_client.rs | 4 +- src/wl_foreign_toplevel.rs | 6 +-- src/wl_kwin_idle.rs | 9 ++-- src/x11_screensaver_idle.rs | 93 +++++++++++++++++++++++++++++++++++++ 9 files changed, 195 insertions(+), 31 deletions(-) create mode 100644 src/x11_screensaver_idle.rs diff --git a/Cargo.lock b/Cargo.lock index fd6916f..18eb074 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ source = "git+https://github.com/ActivityWatch/aw-server-rust#1f4f07d86c06de89de dependencies = [ "aw-models", "chrono", - "gethostname", + "gethostname 0.4.1", "reqwest", "serde", "serde_json", @@ -220,12 +220,13 @@ dependencies = [ "chrono", "clap", "fern", - "gethostname", + "gethostname 0.4.1", "log", "serde_json", "wayland-backend", "wayland-client", "wayland-scanner", + "x11rb", "zbus", ] @@ -713,6 +714,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "gethostname" version = "0.4.1" @@ -1008,6 +1019,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.7.1" @@ -1053,6 +1073,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.26.2" @@ -1062,7 +1095,7 @@ dependencies = [ "bitflags", "cfg-if", "libc", - "memoffset", + "memoffset 0.7.1", "pin-utils", "static_assertions", ] @@ -1927,7 +1960,7 @@ dependencies = [ "cc", "downcast-rs", "io-lifetimes", - "nix", + "nix 0.26.2", "scoped-tls", "smallvec", "wayland-sys", @@ -1940,7 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85bde68449abab1a808e5227b6e295f4ae3680911eb7711b4a2cb90141edb780" dependencies = [ "bitflags", - "nix", + "nix 0.26.2", "wayland-backend", "wayland-scanner", ] @@ -2002,6 +2035,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "winapi-wsapoll" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2188,6 +2230,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "x11rb" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf3c79412dd91bae7a7366b8ad1565a85e35dd049affc3a6a2c549e97419617" +dependencies = [ + "gethostname 0.2.3", + "nix 0.25.1", + "winapi", + "winapi-wsapoll", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1513b141123073ce54d5bb1d33f801f17508fbd61e02060b1214e96d39c56" +dependencies = [ + "nix 0.25.1", +] + [[package]] name = "zbus" version = "3.11.1" @@ -2211,7 +2275,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.26.2", "once_cell", "ordered-stream", "rand", diff --git a/Cargo.toml b/Cargo.toml index 59c2576..be29cde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ zbus = "3.11.1" clap = "4.2.1" log = { version = "0.4.17", features = ["std"] } fern = { version = "0.6.2", features = ["colored"] } +x11rb = { version = "0.11.1", features = ["screensaver"] } diff --git a/src/config.rs b/src/config.rs index 64553e5..5d41f2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,13 @@ -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; use clap::{arg, value_parser, Command}; pub struct Config { pub port: u32, pub host: String, - pub idle_timeout: u32, - pub poll_time_idle: u32, - pub poll_time_window: u32, + pub idle_timeout: Duration, + pub poll_time_idle: Duration, + pub poll_time_window: Duration, pub idle_bucket_name: String, pub active_window_bucket_name: String, } @@ -40,13 +40,16 @@ impl Config { let hostname = gethostname::gethostname().into_string().unwrap(); let idle_bucket_name = format!("aw-watcher-afk_{hostname}"); let active_window_bucket_name = format!("aw-watcher-window_{hostname}"); + let poll_seconds_idle = *matches.get_one::("poll-time-idle").unwrap(); + let poll_seconds_window = *matches.get_one::("poll-time-window").unwrap(); + let idle_timeout_seconds = *matches.get_one::("idle-timeout").unwrap(); Self { port: *matches.get_one("port").unwrap(), host: String::clone(matches.get_one("host").unwrap()), - idle_timeout: *matches.get_one("idle-timeout").unwrap(), - poll_time_idle: *matches.get_one("poll-time-idle").unwrap(), - poll_time_window: *matches.get_one("poll-time-window").unwrap(), + idle_timeout: Duration::from_secs(u64::from(idle_timeout_seconds)), + poll_time_idle: Duration::from_secs(u64::from(poll_seconds_idle)), + poll_time_window: Duration::from_secs(u64::from(poll_seconds_window)), idle_bucket_name, active_window_bucket_name, } diff --git a/src/kwin_window.rs b/src/kwin_window.rs index 85bbadc..c531f53 100644 --- a/src/kwin_window.rs +++ b/src/kwin_window.rs @@ -11,7 +11,6 @@ use std::env::temp_dir; use std::path::Path; use std::sync::{mpsc::channel, Arc, Mutex}; use std::thread; -use std::time; use zbus::blocking::{Connection, ConnectionBuilder}; use zbus::dbus_interface; @@ -196,9 +195,7 @@ impl Watcher for WindowWatcher { if let Err(error) = send_active_window(client, &active_window) { error!("Error on sending active window heartbeat: {error}"); } - thread::sleep(time::Duration::from_secs(u64::from( - client.config.poll_time_window, - ))); + thread::sleep(client.config.poll_time_window); } } } diff --git a/src/main.rs b/src/main.rs index dad2809..00372e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod wl_bindings; mod wl_connection; mod wl_foreign_toplevel; mod wl_kwin_idle; +mod x11_screensaver_idle; use config::Config; use fern::colors::{Color, ColoredLevelConfig}; @@ -18,6 +19,7 @@ use report_client::ReportClient; use std::env; use std::{error::Error, str::FromStr, sync::Arc, thread}; use wl_kwin_idle::IdleWatcher as WlKwinIdleWatcher; +use x11_screensaver_idle::IdleWatcher as X11IdleWatcher; use crate::wl_foreign_toplevel::WindowWatcher as WlrForeignToplevelWindowWatcher; @@ -59,7 +61,8 @@ macro_rules! watcher { }; } -const IDLE_WATCHERS: &[WatcherConstructor] = &[watcher!(WlKwinIdleWatcher)]; +const IDLE_WATCHERS: &[WatcherConstructor] = + &[watcher!(WlKwinIdleWatcher), watcher!(X11IdleWatcher)]; const ACTIVE_WINDOW_WATCHERS: &[WatcherConstructor] = &[ watcher!(WlrForeignToplevelWindowWatcher), @@ -103,8 +106,14 @@ fn main() -> Result<(), BoxedError> { "Sending to server {}:{}", client.config.host, client.config.port ); - info!("Idle timeout: {} seconds", client.config.idle_timeout); - info!("Polling period: {} seconds", client.config.poll_time_idle); + info!( + "Idle timeout: {} seconds", + client.config.idle_timeout.as_secs() + ); + info!( + "Polling period: {} seconds", + client.config.poll_time_idle.as_secs() + ); let mut thread_handlers = Vec::new(); diff --git a/src/report_client.rs b/src/report_client.rs index 43a7329..7121b0b 100644 --- a/src/report_client.rs +++ b/src/report_client.rs @@ -39,7 +39,7 @@ impl ReportClient { data, }; - let pulsetime = f64::from(self.config.idle_timeout + self.config.poll_time_idle); + let pulsetime = (self.config.idle_timeout + self.config.poll_time_idle).as_secs_f64(); self.client .heartbeat(&self.config.idle_bucket_name, &event, pulsetime) .map_err(|_| "Failed to send heartbeat")?; @@ -58,7 +58,7 @@ impl ReportClient { data, }; - let interval_margin: f64 = f64::from(self.config.poll_time_idle + 1); + let interval_margin = self.config.poll_time_idle.as_secs_f64() + 1.0; self.client .heartbeat( &self.config.active_window_bucket_name, diff --git a/src/wl_foreign_toplevel.rs b/src/wl_foreign_toplevel.rs index cc2ecd6..085e1ac 100644 --- a/src/wl_foreign_toplevel.rs +++ b/src/wl_foreign_toplevel.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::{sync::Arc, thread, time}; +use std::{sync::Arc, thread}; use crate::{wl_connection::subscribe_state, Watcher}; @@ -160,9 +160,7 @@ impl Watcher for WindowWatcher { error!("Error on iteration: {e}"); } - thread::sleep(time::Duration::from_secs(u64::from( - client.config.poll_time_window, - ))); + thread::sleep(client.config.poll_time_window); } } } diff --git a/src/wl_kwin_idle.rs b/src/wl_kwin_idle.rs index b4c1ba8..eb89b96 100644 --- a/src/wl_kwin_idle.rs +++ b/src/wl_kwin_idle.rs @@ -5,7 +5,7 @@ use super::wl_bindings; use super::wl_connection::{subscribe_state, WlEventConnection}; use super::BoxedError; use chrono::{DateTime, Duration, Utc}; -use std::{sync::Arc, thread, time}; +use std::{sync::Arc, thread}; use wayland_client::{ globals::GlobalListContents, protocol::{wl_registry, wl_seat::WlSeat}, @@ -131,9 +131,10 @@ impl Watcher for IdleWatcher { } fn watch(&mut self, client: &Arc) { + let timeout = u32::try_from(client.config.idle_timeout.as_secs() * 1000); let mut idle_state = IdleState::new( self.connection - .get_kwin_idle_timeout(client.config.idle_timeout * 1000) + .get_kwin_idle_timeout(timeout.unwrap()) .unwrap(), Arc::clone(client), ); @@ -149,9 +150,7 @@ impl Watcher for IdleWatcher { } else if let Err(e) = idle_state.send_ping() { error!("Error on idle iteration: {e}"); } - thread::sleep(time::Duration::from_secs(u64::from( - client.config.poll_time_idle, - ))); + thread::sleep(client.config.poll_time_idle); } } } diff --git a/src/x11_screensaver_idle.rs b/src/x11_screensaver_idle.rs new file mode 100644 index 0000000..7f6d4ba --- /dev/null +++ b/src/x11_screensaver_idle.rs @@ -0,0 +1,93 @@ +use std::{env, sync::Arc, thread}; + +use chrono::{Duration, Utc}; +use x11rb::connection::Connection; +use x11rb::protocol::screensaver::ConnectionExt; +use x11rb::rust_connection::RustConnection; + +use crate::{report_client::ReportClient, BoxedError, Watcher}; + +pub struct IdleWatcher { + connection: RustConnection, + screen_root: u32, +} + +impl IdleWatcher { + fn seconds_since_last_input(&self) -> Result { + let a = self.connection.screensaver_query_info(self.screen_root)?; + let b = a.reply()?; + + Ok(b.ms_since_user_input / 1000) + } + + fn run(&self, is_idle: bool, client: &Arc) -> Result { + // The logic is rewritten from the original Python code: + // https://github.com/ActivityWatch/aw-watcher-afk/blob/ef531605cd8238e00138bbb980e5457054e05248/aw_watcher_afk/afk.py#L73 + let duration_1ms: Duration = Duration::milliseconds(1); + let duration_zero: Duration = Duration::zero(); + + let seconds_since_input = self.seconds_since_last_input()?; + let now = Utc::now(); + let time_since_input = Duration::seconds(i64::from(seconds_since_input)); + let last_input = now - time_since_input; + let mut is_idle_again = is_idle; + + if is_idle && u64::from(seconds_since_input) < client.config.idle_timeout.as_secs() { + debug!("No longer idle"); + client.ping(is_idle, last_input, duration_zero)?; + is_idle_again = false; + // ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event) + client.ping(is_idle, last_input + duration_1ms, duration_zero)?; + } else if !is_idle && u64::from(seconds_since_input) >= client.config.idle_timeout.as_secs() + { + debug!("Idle again"); + client.ping(is_idle, last_input, duration_zero)?; + is_idle_again = true; + // ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event) + client.ping(is_idle, last_input + duration_1ms, time_since_input)?; + } else { + // Send a heartbeat if no state change was made + if is_idle { + trace!("Reporting as idle"); + client.ping(is_idle, last_input, time_since_input)?; + } else { + trace!("Reporting as not idle"); + client.ping(is_idle, last_input, duration_zero)?; + } + } + + Ok(is_idle_again) + } +} + +impl Watcher for IdleWatcher { + fn new() -> Result { + if env::var("DISPLAY").is_err() { + warn!("DISPLAY is not set, setting to the default value \":0\""); + env::set_var("DISPLAY", ":0"); + } + + let (connection, screen_num) = x11rb::connect(None)?; + let screen_root = connection.setup().roots[screen_num].root; + + Ok(IdleWatcher { + connection, + screen_root, + }) + } + + fn watch(&mut self, client: &Arc) { + info!("Starting idle watcher"); + let mut is_idle = false; + loop { + match self.run(is_idle, client) { + Ok(is_idle_again) => { + is_idle = is_idle_again; + } + Err(e) => error!("Error on idle iteration: {e}"), + }; + + thread::sleep(client.config.poll_time_idle); + } + } +}