From d710674d5064754e7b3df8936e0faa31333704cb Mon Sep 17 00:00:00 2001 From: Demmie <2e3s19@gmail.com> Date: Thu, 20 Apr 2023 02:15:50 -0400 Subject: [PATCH] Implement X11 active window watcher --- src/kwin_window.rs | 2 +- src/main.rs | 4 ++ src/x11_connection.rs | 129 ++++++++++++++++++++++++++++++++++++ src/x11_screensaver_idle.rs | 33 +++------ src/x11_window.rs | 49 ++++++++++++++ 5 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 src/x11_connection.rs create mode 100644 src/x11_window.rs diff --git a/src/kwin_window.rs b/src/kwin_window.rs index c531f53..fb704b3 100644 --- a/src/kwin_window.rs +++ b/src/kwin_window.rs @@ -193,7 +193,7 @@ impl Watcher for WindowWatcher { info!("Starting active window watcher"); loop { if let Err(error) = send_active_window(client, &active_window) { - error!("Error on sending active window heartbeat: {error}"); + error!("Error on sending active window: {error}"); } thread::sleep(client.config.poll_time_window); } diff --git a/src/main.rs b/src/main.rs index 00372e2..a828490 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,9 @@ mod wl_bindings; mod wl_connection; mod wl_foreign_toplevel; mod wl_kwin_idle; +mod x11_connection; mod x11_screensaver_idle; +mod x11_window; use config::Config; use fern::colors::{Color, ColoredLevelConfig}; @@ -20,6 +22,7 @@ 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 x11_window::WindowWatcher as X11WindowWatcher; use crate::wl_foreign_toplevel::WindowWatcher as WlrForeignToplevelWindowWatcher; @@ -67,6 +70,7 @@ const IDLE_WATCHERS: &[WatcherConstructor] = const ACTIVE_WINDOW_WATCHERS: &[WatcherConstructor] = &[ watcher!(WlrForeignToplevelWindowWatcher), watcher!(KwinWindowWatcher), + watcher!(X11WindowWatcher), ]; fn setup_logger() -> Result<(), fern::InitError> { diff --git a/src/x11_connection.rs b/src/x11_connection.rs new file mode 100644 index 0000000..8342740 --- /dev/null +++ b/src/x11_connection.rs @@ -0,0 +1,129 @@ +use std::{env, str}; + +use log::warn; +use x11rb::connection::Connection; +use x11rb::protocol::screensaver::ConnectionExt as ScreensaverConnectionExt; +use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt, GetPropertyReply, Window}; +use x11rb::rust_connection::RustConnection; + +use crate::BoxedError; + +pub struct WindowData { + pub title: String, + pub app_id: String, +} + +pub struct X11Connection { + connection: RustConnection, + screen_root: Window, +} + +impl X11Connection { + pub 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(X11Connection { + connection, + screen_root, + }) + } + + pub fn seconds_since_last_input(&self) -> Result { + let reply = self + .connection + .screensaver_query_info(self.screen_root)? + .reply()?; + + Ok(reply.ms_since_user_input / 1000) + } + + pub fn active_window_data(&self) -> Result { + let focus: Window = self.find_active_window()?; + + let name = self.get_property( + focus, + self.intern_atom(b"_NET_WM_NAME")?, + self.intern_atom(b"UTF8_STRING")?, + u32::MAX, + )?; + let class = self.get_property( + focus, + AtomEnum::WM_CLASS.into(), + AtomEnum::STRING.into(), + u32::MAX, + )?; + + let title = str::from_utf8(&name.value).map_err(|e| format!("Invalid title UTF: {e}"))?; + + Ok(WindowData { + title: title.to_string(), + app_id: parse_wm_class(&class)?.to_string(), + }) + } + + fn get_property( + &self, + window: Window, + property: Atom, + property_type: Atom, + long_length: u32, + ) -> Result { + self.connection + .get_property(false, window, property, property_type, 0, long_length)? + .reply() + .map_err(std::convert::Into::into) + } + + fn intern_atom(&self, name: &[u8]) -> Result { + Ok(self.connection.intern_atom(false, name)?.reply()?.atom) + } + + fn find_active_window(&self) -> Result { + let window: Atom = AtomEnum::WINDOW.into(); + let net_active_window = self.intern_atom(b"_NET_ACTIVE_WINDOW")?; + let active_window = self.get_property(self.screen_root, net_active_window, window, 1)?; + + if active_window.format == 32 && active_window.length == 1 { + active_window + .value32() + .ok_or("Invalid message. Expected value with format = 32")? + .next() + .ok_or("Active window is not found".into()) + } else { + // Query the input focus + Ok(self + .connection + .get_input_focus() + .map_err(|e| format!("Failed to get input focus: {e}"))? + .reply() + .map_err(|e| format!("Failed to read input focus from reply: {e}"))? + .focus) + } + } +} + +fn parse_wm_class(property: &GetPropertyReply) -> Result<&str, BoxedError> { + if property.format != 8 { + return Err("Malformed property: wrong format".into()); + } + let value = &property.value; + // The property should contain two null-terminated strings. Find them. + if let Some(middle) = value.iter().position(|&b| b == 0) { + let (_, class) = value.split_at(middle); + // Skip the null byte at the beginning + let mut class = &class[1..]; + // Remove the last null byte from the class, if it is there. + if class.last() == Some(&0) { + class = &class[..class.len() - 1]; + } + Ok(std::str::from_utf8(class)?) + } else { + Err("Missing null byte".into()) + } +} diff --git a/src/x11_screensaver_idle.rs b/src/x11_screensaver_idle.rs index 7f6d4ba..5f0560b 100644 --- a/src/x11_screensaver_idle.rs +++ b/src/x11_screensaver_idle.rs @@ -1,32 +1,21 @@ -use std::{env, sync::Arc, thread}; +use std::{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}; +use crate::{report_client::ReportClient, x11_connection::X11Connection, BoxedError, Watcher}; pub struct IdleWatcher { - connection: RustConnection, - screen_root: u32, + connection: X11Connection, } 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 seconds_since_input = self.connection.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; @@ -62,18 +51,12 @@ impl IdleWatcher { 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 = X11Connection::new()?; - let (connection, screen_num) = x11rb::connect(None)?; - let screen_root = connection.setup().roots[screen_num].root; + // Check if screensaver extension is supported + connection.seconds_since_last_input()?; - Ok(IdleWatcher { - connection, - screen_root, - }) + Ok(IdleWatcher { connection }) } fn watch(&mut self, client: &Arc) { diff --git a/src/x11_window.rs b/src/x11_window.rs new file mode 100644 index 0000000..0d523db --- /dev/null +++ b/src/x11_window.rs @@ -0,0 +1,49 @@ +use std::{thread, time::Duration}; + +use crate::{report_client::ReportClient, x11_connection::X11Connection, BoxedError, Watcher}; + +pub struct WindowWatcher { + connection: X11Connection, + last_title: String, + last_app_id: String, +} + +impl WindowWatcher { + fn send_active_window(&mut self, client: &ReportClient) -> Result<(), BoxedError> { + let data = self.connection.active_window_data()?; + + if data.app_id != self.last_app_id || data.title != self.last_title { + debug!( + "Changed window app_id=\"{}\", title=\"{}\"", + data.app_id, data.title + ); + self.last_app_id = data.app_id; + self.last_title = data.title; + } + + client + .send_active_window(&self.last_app_id, &self.last_title) + .map_err(|_| "Failed to send heartbeat for active window".into()) + } +} + +impl Watcher for WindowWatcher { + fn new() -> Result { + Ok(WindowWatcher { + connection: X11Connection::new()?, + last_title: String::new(), + last_app_id: String::new(), + }) + } + + fn watch(&mut self, client: &std::sync::Arc) { + info!("Starting active window watcher"); + loop { + if let Err(error) = self.send_active_window(client) { + error!("Error on sending active window: {error}"); + } + + thread::sleep(client.config.poll_time_window); + } + } +}