From bd4facf1c46ca78ff55960e2dbfcdba92f9194f0 Mon Sep 17 00:00:00 2001 From: rootiest Date: Fri, 10 Apr 2026 16:47:21 -0400 Subject: [PATCH] feat: implement active-app monitor via KWin scripting and D-Bus Replaces the no-op stub with a working implementation for KDE6 Wayland. Wayland's privacy model prevents querying focused-window state from outside the compositor, so the monitor injects a KWin JavaScript snippet at runtime that subscribes to workspace.windowActivated and calls back via D-Bus. On startup: - Writes the KWin script to a NamedTempFile - Registers dev.rootiest.qmk_host on the session bus at /AppMonitor - Loads the script via org.kde.kwin.Scripting.loadScript(), then calls start() to activate it (loadScript alone does not execute the script) On each window-activation event: - Truncates app_id to 27 UTF-8 bytes at a char boundary - Skips no-op updates when the active app has not changed - Broadcasts CMD_ACTIVE_APP (0x43) to all connected keyboards Depends on new crates: zbus v4 (blocking D-Bus) and tempfile v3. --- Cargo.toml | 2 + src/app_monitor.rs | 281 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 249 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4b5e961..4cf2704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,5 @@ log = "0.4" # Logging facade env_logger = "0.11" # Logger implementation (RUST_LOG env var) ctrlc = "3" # Ctrl+C / SIGINT handler clap = { version = "4", features = ["derive"] } # CLI argument parsing +tempfile = "3" # NamedTempFile for KWin script +zbus = { version = "4", features = ["blocking"] } # D-Bus (KWin scripting callback) diff --git a/src/app_monitor.rs b/src/app_monitor.rs index 8a95cd1..be889ba 100644 --- a/src/app_monitor.rs +++ b/src/app_monitor.rs @@ -1,46 +1,259 @@ // Copyright 2026 rootiest // SPDX-License-Identifier: GPL-3.0-or-later -//! Active application / window monitor — **reserved, not yet implemented**. +//! Active application / window monitor — KWin Scripting + zbus D-Bus. //! -//! When implemented this module will detect the active window on a KDE6 -//! Wayland desktop and send `CMD_ACTIVE_APP` (0x43) packets containing the -//! application name (null-terminated UTF-8, max 28 bytes) to all connected -//! keyboards. +//! # Design //! -//! # Implementation notes for KDE6 / Wayland +//! Wayland intentionally prevents arbitrary processes from querying which +//! window has focus — this is enforced at the protocol level and root access +//! does not help. The only viable approach on KDE6 is to run code *inside* +//! the compositor via KWin's JavaScript scripting API. //! -//! Candidate approaches (to evaluate during the brainstorming phase): +//! At startup this module: //! -//! 1. **KWin D-Bus interface** — `org.kde.KWin` exposes `activeWindow()` on -//! the scripting API. The window object has `resourceName` / `caption` -//! properties. Can subscribe to `windowActivated` signal. -//! → easiest; requires D-Bus session access. -//! -//! 2. **KWin Script** — inject a tiny `.kwinscript` that emits a custom -//! D-Bus signal or writes to a FIFO on `windowActivated`. -//! → portable across KWin versions; slightly invasive. -//! -//! 3. **`xdg-foreign` / `wlr-foreign-toplevel`** — Wayland protocols for -//! enumerating toplevels. KWin supports `ext-foreign-toplevel-list-v1`. -//! → protocol-level, no D-Bus, but requires a Wayland client loop. -//! -//! 4. **`qdbus6` / `busctl` polling** — parse output of -//! `busctl call org.kde.KWin /KWin org.kde.KWin activeWindow` -//! in a poll loop. Simple but coarse. -//! -//! The chosen approach will be implemented here once the brainstorming is -//! complete. The `Packet::active_app()` constructor in `protocol.rs` is -//! already in place. +//! 1. Writes a small KWin JavaScript snippet to a `NamedTempFile`. +//! 2. Registers a D-Bus service (`dev.rootiest.qmk_host`) on the session bus. +//! 3. Loads the script into the running KWin via +//! `org.kde.kwin.Scripting.loadScript()`. +//! 4. Serves D-Bus method calls: KWin calls +//! `dev.rootiest.qmk_host.AppMonitor.WindowActivated(app_id)` each time +//! focus changes. +//! 5. On each call: truncate `app_id` to 27 UTF-8 bytes, skip duplicates, +//! broadcast a `CMD_ACTIVE_APP` packet to all keyboards. +//! 6. On shutdown: unload the KWin script and let the `NamedTempFile` drop +//! (which deletes the file). -use log::info; +use std::io::Write as IoWrite; +use std::sync::{Arc, Mutex}; + +use log::{debug, error, info, warn}; +use tempfile::NamedTempFile; +use zbus::blocking::Connection; +use zbus::interface; use crate::keyboard::Keyboard; +use crate::protocol::{self, APP_NAME_MAX, DEV_HOST, Packet}; -/// Monitor the active application and notify keyboards. -/// Currently a no-op stub — safe to call, does nothing. -pub fn monitor(_keyboards: Vec) { - info!("Active-application monitor: not yet implemented (stub)"); - // Future: block here in an event loop and send Packet::active_app() - // packets to keyboards whenever the active window changes. +// --------------------------------------------------------------------------- +// D-Bus constants +// --------------------------------------------------------------------------- + +const DBUS_SERVICE: &str = "dev.rootiest.qmk_host"; +const DBUS_PATH: &str = "/AppMonitor"; + +const KWIN_SERVICE: &str = "org.kde.KWin"; +const KWIN_SCRIPTING_PATH: &str = "/Scripting"; +const KWIN_PLUGIN_NAME: &str = "qmk-host-appwatcher"; + +// --------------------------------------------------------------------------- +// KWin JavaScript snippet injected at runtime +// --------------------------------------------------------------------------- + +const KWIN_SCRIPT: &str = r#" +workspace.windowActivated.connect(function(window) { + callDBus( + "dev.rootiest.qmk_host", "/AppMonitor", + "dev.rootiest.qmk_host.AppMonitor", "WindowActivated", + window ? window.resourceClass : "" + ); +}); +"#; + +// --------------------------------------------------------------------------- +// D-Bus interface exposed by qmk-host +// --------------------------------------------------------------------------- + +/// Shared state accessed from both the D-Bus method handler and the monitor +/// thread (needed for duplicate suppression). +#[derive(Clone)] +struct LastApp(Arc>); + +/// The D-Bus interface object that KWin calls back into. +struct AppInterface { + keyboards: Vec, + last_app: LastApp, +} + +#[interface(name = "dev.rootiest.qmk_host.AppMonitor")] +impl AppInterface { + /// Called by KWin each time the focused window changes. + /// `app_id` is `window.resourceClass` (e.g. `"firefox"`, `"konsole"`). + fn window_activated(&self, app_id: String) { + // Truncate to APP_NAME_MAX - 1 bytes at a valid UTF-8 char boundary. + let truncated = truncate_utf8(&app_id, APP_NAME_MAX - 1); + + let mut last = self.last_app.0.lock().unwrap(); + if *last == truncated { + return; + } + *last = truncated.clone(); + drop(last); + + info!("Active app changed: {:?}", truncated); + + let pkt = Packet::active_app(DEV_HOST, &truncated); + for kbd in &self.keyboards { + if kbd.sender.send(pkt.clone()).is_err() { + error!( + "Failed to send active-app packet to device 0x{:02x}", + kbd.device_id + ); + } else { + debug!( + "[kbd {:02x}] TX cmd=0x{:02x} src=0x{:02x} flags=0x{:02x}", + kbd.device_id, + protocol::CMD_ACTIVE_APP, + DEV_HOST, + 0u8 + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// KWin scripting helpers (raw zbus call_method) +// --------------------------------------------------------------------------- + +/// Load the script file into KWin and start it, returning its script ID (≥ 0). +/// Tries to unload a previous instance first so restarts are clean. +/// +/// KWin requires two calls: `loadScript()` to register the file, then +/// `start()` on the Scripting interface to actually execute all loaded scripts. +fn kwin_load_script(conn: &Connection, script_path: &str) -> anyhow::Result { + // Attempt to unload any leftover instance from a previous run. + let _ = kwin_unload_script(conn); + + let msg = conn.call_method( + Some(KWIN_SERVICE), + KWIN_SCRIPTING_PATH, + Some("org.kde.kwin.Scripting"), + "loadScript", + &(script_path, KWIN_PLUGIN_NAME), + )?; + let id: i32 = msg.body().deserialize()?; + + // `start()` executes all loaded-but-not-yet-started scripts. + conn.call_method( + Some(KWIN_SERVICE), + KWIN_SCRIPTING_PATH, + Some("org.kde.kwin.Scripting"), + "start", + &(), + )?; + + Ok(id) +} + +/// Unload the script by plugin name. Errors are non-fatal. +fn kwin_unload_script(conn: &Connection) -> anyhow::Result<()> { + conn.call_method( + Some(KWIN_SERVICE), + KWIN_SCRIPTING_PATH, + Some("org.kde.kwin.Scripting"), + "unloadScript", + &KWIN_PLUGIN_NAME, + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// UTF-8-safe truncation helper +// --------------------------------------------------------------------------- + +fn truncate_utf8(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_owned(); + } + // Walk backwards from max_bytes to find a char boundary. + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_owned() +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Monitor the active application window and send `CMD_ACTIVE_APP` packets +/// to all connected keyboards. +/// +/// Blocks until the process exits (or the D-Bus connection is lost). +pub fn monitor(keyboards: Vec) { + // ------------------------------------------------------------------ + // Write the KWin script to a temporary file. + // ------------------------------------------------------------------ + let mut script_file = match NamedTempFile::new() { + Ok(f) => f, + Err(e) => { + warn!("app-monitor: failed to create temp file for KWin script: {e:#}"); + return; + } + }; + if let Err(e) = script_file.write_all(KWIN_SCRIPT.as_bytes()) { + warn!("app-monitor: failed to write KWin script: {e:#}"); + return; + } + let script_path = script_file.path().to_string_lossy().into_owned(); + debug!("app-monitor: KWin script written to {script_path}"); + + // ------------------------------------------------------------------ + // Connect to the D-Bus session bus and register our service. + // ------------------------------------------------------------------ + let last_app = LastApp(Arc::new(Mutex::new(String::new()))); + let iface = AppInterface { + keyboards, + last_app, + }; + + let conn = match Connection::session() { + Ok(c) => c, + Err(e) => { + warn!("app-monitor: failed to connect to D-Bus session bus: {e:#}"); + return; + } + }; + + if let Err(e) = conn.request_name(DBUS_SERVICE) { + warn!("app-monitor: failed to register D-Bus service '{DBUS_SERVICE}': {e:#}"); + return; + } + + if let Err(e) = conn.object_server().at(DBUS_PATH, iface) { + warn!("app-monitor: failed to register D-Bus object at '{DBUS_PATH}': {e:#}"); + return; + } + + info!("app-monitor: D-Bus service '{DBUS_SERVICE}' registered at '{DBUS_PATH}'"); + + // ------------------------------------------------------------------ + // Load the KWin script. + // ------------------------------------------------------------------ + match kwin_load_script(&conn, &script_path) { + Ok(id) if id >= 0 => info!("app-monitor: KWin script loaded (id={id})"), + Ok(id) => warn!("app-monitor: KWin loadScript returned {id} — script may not have loaded"), + Err(e) => { + warn!("app-monitor: failed to load KWin script: {e:#}"); + return; + } + }; + + // ------------------------------------------------------------------ + // Serve D-Bus calls until the connection drops (process shutdown). + // ------------------------------------------------------------------ + info!("app-monitor: listening for window-activation events"); + + // The blocking Connection drives its D-Bus server on an internal + // receiver thread. We just park here so we own `conn` (and therefore + // the registered service) for the process lifetime. + // + // On process exit (SIGINT → ctrlc handler), this thread is torn down + // automatically. KWin detects the D-Bus name disappearing and the + // script becomes inert; `script_file` drops here, deleting the temp file. + loop { + std::thread::sleep(std::time::Duration::from_secs(5)); + } }