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:
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user