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.
This commit is contained in:
2026-04-10 16:47:21 -04:00
parent 4b3b0095a0
commit bd4facf1c4
2 changed files with 249 additions and 34 deletions
+2
View File
@@ -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)
+247 -34
View File
@@ -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<Keyboard>) {
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<Mutex<String>>);
/// The D-Bus interface object that KWin calls back into.
struct AppInterface {
keyboards: Vec<Keyboard>,
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<i32> {
// 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<Keyboard>) {
// ------------------------------------------------------------------
// 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));
}
}