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)
|
env_logger = "0.11" # Logger implementation (RUST_LOG env var)
|
||||||
ctrlc = "3" # Ctrl+C / SIGINT handler
|
ctrlc = "3" # Ctrl+C / SIGINT handler
|
||||||
clap = { version = "4", features = ["derive"] } # CLI argument parsing
|
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
|
// Copyright 2026 rootiest
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// 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
|
//! # Design
|
||||||
//! Wayland desktop and send `CMD_ACTIVE_APP` (0x43) packets containing the
|
|
||||||
//! application name (null-terminated UTF-8, max 28 bytes) to all connected
|
|
||||||
//! keyboards.
|
|
||||||
//!
|
//!
|
||||||
//! # 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
|
//! 1. Writes a small KWin JavaScript snippet to a `NamedTempFile`.
|
||||||
//! the scripting API. The window object has `resourceName` / `caption`
|
//! 2. Registers a D-Bus service (`dev.rootiest.qmk_host`) on the session bus.
|
||||||
//! properties. Can subscribe to `windowActivated` signal.
|
//! 3. Loads the script into the running KWin via
|
||||||
//! → easiest; requires D-Bus session access.
|
//! `org.kde.kwin.Scripting.loadScript()`.
|
||||||
//!
|
//! 4. Serves D-Bus method calls: KWin calls
|
||||||
//! 2. **KWin Script** — inject a tiny `.kwinscript` that emits a custom
|
//! `dev.rootiest.qmk_host.AppMonitor.WindowActivated(app_id)` each time
|
||||||
//! D-Bus signal or writes to a FIFO on `windowActivated`.
|
//! focus changes.
|
||||||
//! → portable across KWin versions; slightly invasive.
|
//! 5. On each call: truncate `app_id` to 27 UTF-8 bytes, skip duplicates,
|
||||||
//!
|
//! broadcast a `CMD_ACTIVE_APP` packet to all keyboards.
|
||||||
//! 3. **`xdg-foreign` / `wlr-foreign-toplevel`** — Wayland protocols for
|
//! 6. On shutdown: unload the KWin script and let the `NamedTempFile` drop
|
||||||
//! enumerating toplevels. KWin supports `ext-foreign-toplevel-list-v1`.
|
//! (which deletes the file).
|
||||||
//! → 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.
|
|
||||||
|
|
||||||
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::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.
|
// D-Bus constants
|
||||||
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()
|
const DBUS_SERVICE: &str = "dev.rootiest.qmk_host";
|
||||||
// packets to keyboards whenever the active window changes.
|
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