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. 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 new file mode 100644 index 0000000..0ccefb8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,227 @@ +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: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[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(); + + // 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, self_conn)?; + 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, self_conn: SyncConnection) -> 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 (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_ref() { + "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(5); + + 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 _: Result<(), _> = kwin_proxy.method_call( + "org.kde.kwin.Scripting", + "unloadScript", + (&context.script_name,), + ); + + result +} + +pub(crate) fn parse_active_window_info(payload: &str) -> anyhow::Result { + // 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)] +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!((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