From 2f6ddf36328d3b4efa020db2176626733460fe9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Alves?= Date: Sun, 8 Feb 2026 19:09:42 +0000 Subject: [PATCH 1/4] Update Cargo.toml for version 0.2.1 --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 633d714..6847ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,9 @@ keywords = ["xdotool", "wayland", "kde"] categories = ["command-line-utilities"] edition = "2024" +[lib] +path = "src/lib.rs" + [profile.release] strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. From 245faa7dae088326d3c39194056be3e294107ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Alves?= Date: Sun, 8 Feb 2026 19:12:51 +0000 Subject: [PATCH 2/4] Add library API for active window info --- src/lib.rs | 213 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 src/lib.rs diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d3cd0be --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,213 @@ +use std::error::Error; +use std::io::Write; +use std::sync::mpsc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context}; +use dbus::{ + blocking::{Connection, SyncConnection}, + channel::MatchingReceiver, + message::MatchRule, +}; +use serde::{Deserialize, Serialize}; + +mod templates; +use templates::{SCRIPT_FOOTER, SCRIPT_HEADER}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActiveWindowInfo { + pub id: String, + pub title: String, + pub class_name: String, + pub pid: u32, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +#[derive(Default, Serialize)] +struct Globals { + dbus_addr: String, + cmdline: String, + debug: bool, + kde5: bool, + marker: String, + script_name: String, + shortcut: String, +} + +const STEP_ACTIVE_WINDOW_INFO: &str = r#" + output_debug("STEP getactivewindowinfo") + let w = workspace_activeWindow(); + if (w == null) { + output_error("No active window"); + } else { + output_result(JSON.stringify({ + id: w.internalId, + title: w.caption, + class_name: w.resourceClass, + pid: w.pid, + x: w.x, + y: w.y, + width: w.width, + height: w.height + })); + } +"#; + +pub fn get_active_window_info() -> Result> { + get_active_window_info_impl().map_err(|err| err.into()) +} + +fn get_active_window_info_impl() -> anyhow::Result { + let mut context = Globals { + cmdline: "kdotool::get_active_window_info".to_string(), + ..Default::default() + }; + + if let Ok(version) = std::env::var("KDE_SESSION_VERSION") { + if version == "5" { + context.kde5 = true; + } + } + + let unique_suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("failed to read system time")? + .as_millis(); + context.marker = format!("kdotool-lib-{unique_suffix}"); + context.script_name = context.marker.clone(); + + let script_contents = generate_script(&context)?; + let result_payload = run_script(&script_contents, &context)?; + parse_active_window_info(&result_payload) +} + +pub(crate) fn generate_script(globals: &Globals) -> anyhow::Result { + let mut full_script = String::new(); + let mut reg = handlebars::Handlebars::new(); + reg.set_strict_mode(true); + let render_context = handlebars::Context::wraps(globals)?; + + full_script.push_str(®.render_template_with_context(SCRIPT_HEADER, &render_context)?); + full_script.push_str(®.render_template_with_context(STEP_ACTIVE_WINDOW_INFO, &render_context)?); + full_script.push_str(®.render_template_with_context(SCRIPT_FOOTER, &render_context)?); + + Ok(full_script) +} + +pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Result { + enum ScriptMessage { + Result(String), + Error(String), + } + + let kwin_conn = Connection::new_session()?; + let kwin_proxy = kwin_conn.with_proxy("org.kde.KWin", "/Scripting", Duration::from_millis(5000)); + + let self_conn = SyncConnection::new_session()?; + let (tx, rx) = mpsc::channel(); + + let _receiver = self_conn.start_receive( + MatchRule::new_method_call(), + Box::new(move |message, _connection| -> bool { + if let Some(member) = message.member() { + if let Some(arg) = message.get1::() { + match member.as_str() { + "result" => { + let _ = tx.send(ScriptMessage::Result(arg)); + } + "error" => { + let _ = tx.send(ScriptMessage::Error(arg)); + } + _ => {} + } + } + } + true + }), + ); + + let mut script_file = tempfile::NamedTempFile::with_prefix("kdotool-")?; + script_file.write_all(script_contents.as_bytes())?; + let script_file_path = script_file.into_temp_path(); + + let script_id: i32; + (script_id,) = kwin_proxy.method_call( + "org.kde.kwin.Scripting", + "loadScript", + (script_file_path.to_str().unwrap(), &context.script_name), + )?; + if script_id < 0 { + return Err(anyhow!( + "Failed to load script. A script with the same name may already exist." + )); + } + + let script_proxy = kwin_conn.with_proxy( + "org.kde.KWin", + if context.kde5 { + format!("/{script_id}") + } else { + format!("/Scripting/Script{script_id}") + }, + Duration::from_millis(5000), + ); + + let _: () = script_proxy.method_call("org.kde.kwin.Script", "run", ())?; + let _: () = script_proxy.method_call("org.kde.kwin.Script", "stop", ())?; + + let start = Instant::now(); + let timeout = Duration::from_secs(2); + + let result = loop { + self_conn.process(Duration::from_millis(100))?; + match rx.try_recv() { + Ok(ScriptMessage::Result(payload)) => break Ok(payload), + Ok(ScriptMessage::Error(message)) => { + break Err(anyhow!("KWin script error: {message}")); + } + Err(mpsc::TryRecvError::Empty) => { + if start.elapsed() > timeout { + break Err(anyhow!("Timed out waiting for KWin response")); + } + } + Err(mpsc::TryRecvError::Disconnected) => { + break Err(anyhow!("KWin response channel disconnected")); + } + } + }; + + let _ = kwin_proxy.method_call( + "org.kde.kwin.Scripting", + "unloadScript", + (&context.script_name,), + ); + + result +} + +pub(crate) fn parse_active_window_info(payload: &str) -> anyhow::Result { + serde_json::from_str(payload).context("failed to parse active window info") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_active_window_info() { + let payload = r#"{\"id\":\"0x123\",\"title\":\"Terminal\",\"class_name\":\"konsole\",\"pid\":4242,\"x\":10,\"y\":20,\"width\":800,\"height\":600}"#; + let info = parse_active_window_info(payload).expect("should parse payload"); + + assert_eq!(info.id, "0x123"); + assert_eq!(info.title, "Terminal"); + assert_eq!(info.class_name, "konsole"); + assert_eq!(info.pid, 4242); + assert_eq!(info.x, 10); + assert_eq!(info.y, 20); + assert_eq!(info.width, 800); + assert_eq!(info.height, 600); + } +} \ No newline at end of file From c42b7973cf9429b0a758e129e92176478190aa4a Mon Sep 17 00:00:00 2001 From: goncaloalves Date: Sun, 8 Feb 2026 19:29:21 +0000 Subject: [PATCH 3/4] Refactor run_script to improve message handling and type safety --- src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index d3cd0be..70e726c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,7 +114,7 @@ pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Re Box::new(move |message, _connection| -> bool { if let Some(member) = message.member() { if let Some(arg) = message.get1::() { - match member.as_str() { + match member.as_ref() { "result" => { let _ = tx.send(ScriptMessage::Result(arg)); } @@ -179,7 +179,7 @@ pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Re } }; - let _ = kwin_proxy.method_call( + let _: Result<(), _> = kwin_proxy.method_call( "org.kde.kwin.Scripting", "unloadScript", (&context.script_name,), From 911a24995c4b0fad0fd3d8e7da8c331bf0acb1ab Mon Sep 17 00:00:00 2001 From: goncaloalves Date: Sun, 8 Feb 2026 19:50:44 +0000 Subject: [PATCH 4/4] Add active window example and update ActiveWindowInfo to use f64 for coordinates and dimensions --- examples/active_window.rs | 18 +++++++++++++++++ src/lib.rs | 42 ++++++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 examples/active_window.rs diff --git a/examples/active_window.rs b/examples/active_window.rs new file mode 100644 index 0000000..b81bf1d --- /dev/null +++ b/examples/active_window.rs @@ -0,0 +1,18 @@ +fn main() { + match kdotool::get_active_window_info() { + Ok(info) => { + println!("id: {}", info.id); + println!("title: {}", info.title); + println!("class_name: {}", info.class_name); + println!("pid: {}", info.pid); + println!("x: {}", info.x); + println!("y: {}", info.y); + println!("width: {}", info.width); + println!("height: {}", info.height); + } + Err(err) => { + eprintln!("error: {err}"); + std::process::exit(1); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 70e726c..0ccefb8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,10 +20,10 @@ pub struct ActiveWindowInfo { pub title: String, pub class_name: String, pub pid: u32, - pub x: i32, - pub y: i32, - pub width: i32, - pub height: i32, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, } #[derive(Default, Serialize)] @@ -79,8 +79,13 @@ fn get_active_window_info_impl() -> anyhow::Result { context.marker = format!("kdotool-lib-{unique_suffix}"); context.script_name = context.marker.clone(); + // Establish the DBus listener connection first so we know the address + // to embed in the generated KWin script. + let self_conn = SyncConnection::new_session()?; + context.dbus_addr = self_conn.unique_name().to_string(); + let script_contents = generate_script(&context)?; - let result_payload = run_script(&script_contents, &context)?; + let result_payload = run_script(&script_contents, &context, self_conn)?; parse_active_window_info(&result_payload) } @@ -97,7 +102,7 @@ pub(crate) fn generate_script(globals: &Globals) -> anyhow::Result { Ok(full_script) } -pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Result { +pub(crate) fn run_script(script_contents: &str, context: &Globals, self_conn: SyncConnection) -> anyhow::Result { enum ScriptMessage { Result(String), Error(String), @@ -106,7 +111,6 @@ pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Re let kwin_conn = Connection::new_session()?; let kwin_proxy = kwin_conn.with_proxy("org.kde.KWin", "/Scripting", Duration::from_millis(5000)); - let self_conn = SyncConnection::new_session()?; let (tx, rx) = mpsc::channel(); let _receiver = self_conn.start_receive( @@ -159,7 +163,7 @@ pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Re let _: () = script_proxy.method_call("org.kde.kwin.Script", "stop", ())?; let start = Instant::now(); - let timeout = Duration::from_secs(2); + let timeout = Duration::from_secs(5); let result = loop { self_conn.process(Duration::from_millis(100))?; @@ -189,7 +193,17 @@ pub(crate) fn run_script(script_contents: &str, context: &Globals) -> anyhow::Re } pub(crate) fn parse_active_window_info(payload: &str) -> anyhow::Result { - serde_json::from_str(payload).context("failed to parse active window info") + // KWin sends JSON.stringify output as a DBus string, which arrives with + // escaped inner quotes. Try parsing directly first; if that fails, try + // interpreting as a JSON string literal to unescape it. + serde_json::from_str(payload) + .or_else(|_| { + let unescaped: String = serde_json::from_str(payload) + .context("failed to unescape payload")?; + serde_json::from_str(&unescaped) + .context("failed to parse unescaped payload") + }) + .context("failed to parse active window info") } #[cfg(test)] @@ -198,16 +212,16 @@ mod tests { #[test] fn parses_active_window_info() { - let payload = r#"{\"id\":\"0x123\",\"title\":\"Terminal\",\"class_name\":\"konsole\",\"pid\":4242,\"x\":10,\"y\":20,\"width\":800,\"height\":600}"#; + let payload = r#"{"id":"0x123","title":"Terminal","class_name":"konsole","pid":4242,"x":10,"y":20,"width":800,"height":600}"#; let info = parse_active_window_info(payload).expect("should parse payload"); assert_eq!(info.id, "0x123"); assert_eq!(info.title, "Terminal"); assert_eq!(info.class_name, "konsole"); assert_eq!(info.pid, 4242); - assert_eq!(info.x, 10); - assert_eq!(info.y, 20); - assert_eq!(info.width, 800); - assert_eq!(info.height, 600); + assert!((info.x - 10.0).abs() < f64::EPSILON); + assert!((info.y - 20.0).abs() < f64::EPSILON); + assert!((info.width - 800.0).abs() < f64::EPSILON); + assert!((info.height - 600.0).abs() < f64::EPSILON); } } \ No newline at end of file