From 52374b03d7fda5fad1fe926a02de5cc957707c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 23 Jul 2026 21:07:41 -0300 Subject: [PATCH] fix(kwin): match the real capsule window class in the switcher rule KWin matches wmclass values case-sensitively, so the lowercase rule never matched the runtime GTK class. Use the hardware-verified KDE Wayland class and rewrite stale managed rules on upgrade. Refs #49 --- scripts/install-desktop-integration | 2 +- scripts/install.sh | 10 +++--- src-tauri/src/kwin.rs | 48 ++++++++++++++++++++--------- tests/install-script-smoke.mjs | 1 + 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/scripts/install-desktop-integration b/scripts/install-desktop-integration index 4d468d4..225a283 100755 --- a/scripts/install-desktop-integration +++ b/scripts/install-desktop-integration @@ -27,7 +27,7 @@ Icon=pickgauge Terminal=false Categories=Utility;Monitor; Keywords=usage;quota;codex;claude; -StartupWMClass=pickgauge +StartupWMClass=Pickgauge EOF command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$APPS_DIR" || true diff --git a/scripts/install.sh b/scripts/install.sh index f4bfa69..e619c31 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,9 +8,9 @@ set -eu REPO="pickforge/pickgauge" APP_NAME="PickGauge" BIN_NAME="pickgauge" -# The window's app_id (bundle identifier). The .desktop basename and -# StartupWMClass must equal it or the running window shows a generic icon. +# The window's app_id (bundle identifier) determines the .desktop basename. APP_ID="pickgauge" +WM_CLASS="Pickgauge" # Environment overrides: # PICKGAUGE_INSTALL_DIR Linux AppImage target dir. Default: $HOME/.local/bin. @@ -236,8 +236,8 @@ write_desktop_launcher() { launcher_command=$1 launcher_icon=$2 launcher_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications" - # Basename and StartupWMClass must equal the window's app_id so the desktop - # environment ties the running window to this entry (and its icon). + # The desktop environment uses the runtime window class to tie the running + # window to this entry (and its icon). launcher_file="$launcher_dir/$APP_ID.desktop" mkdir -p "$launcher_dir" 2>/dev/null || return 0 @@ -249,7 +249,7 @@ write_desktop_launcher() { printf 'Comment=Local AI usage tray\n' printf 'Exec="%s"\n' "$launcher_exec" printf 'Icon=%s\n' "$launcher_icon" - printf 'StartupWMClass=%s\n' "$APP_ID" + printf 'StartupWMClass=%s\n' "$WM_CLASS" printf 'Terminal=false\n' printf 'Categories=Development;\n' printf 'Keywords=pickgauge;usage;codex;claude;\n' diff --git a/src-tauri/src/kwin.rs b/src-tauri/src/kwin.rs index eeb8e14..0362487 100644 --- a/src-tauri/src/kwin.rs +++ b/src-tauri/src/kwin.rs @@ -20,7 +20,8 @@ use std::process::Command; const RULE_GROUP: &str = "pickgauge-float-keep-above"; const FLOAT_TITLE: &str = "PickGauge Float"; -const WM_CLASS: &str = "pickgauge"; +// KWin wmclass matching is case-sensitive; GTK derives this runtime class from the binary name (hardware-verified on KDE Wayland). +const WM_CLASS: &str = "Pickgauge"; fn find_command(name: &str) -> Option { let path = std::env::var_os("PATH")?; @@ -33,7 +34,7 @@ fn find_command(name: &str) -> Option { None } -fn group_has_key(contents: &str, group: &str, key: &str) -> bool { +fn group_value<'a>(contents: &'a str, group: &str, key: &str) -> Option<&'a str> { let header = format!("[{group}]"); let mut in_group = false; for line in contents.lines() { @@ -42,11 +43,25 @@ fn group_has_key(contents: &str, group: &str, key: &str) -> bool { in_group = trimmed == header; continue; } - if in_group && trimmed.starts_with(key) { - return true; + if in_group { + match trimmed.split_once('=') { + Some((candidate, value)) if candidate.trim() == key => { + return Some(value.trim()); + } + _ => {} + } } } - false + None +} + +fn group_has_key(contents: &str, group: &str, key: &str) -> bool { + group_value(contents, group, key).is_some() +} + +fn rule_is_current(contents: &str) -> bool { + group_has_key(contents, RULE_GROUP, "positionrule") + && group_value(contents, RULE_GROUP, "wmclass") == Some(WM_CLASS) } /// Whether the current session is a KDE Wayland compositor session — native @@ -81,8 +96,7 @@ pub fn ensure_float_rule() { let rules_path = Path::new(&home).join(".config/kwinrulesrc"); let existing_rules = std::fs::read_to_string(&rules_path).unwrap_or_default(); let already_registered = existing_rules.contains(RULE_GROUP); - // "positionrule" inside our group marks the current revision; rewrite older ones. - if already_registered && group_has_key(&existing_rules, RULE_GROUP, "positionrule") { + if already_registered && rule_is_current(&existing_rules) { return; } @@ -113,7 +127,7 @@ pub fn ensure_float_rule() { }; write("Description", "PickGauge float capsule (managed by PickGauge)"); - // Match: window class contains "pickgauge" AND title is exactly the + // Match: window class contains "Pickgauge" AND title is exactly the // float window title, so the main window is unaffected. write("wmclass", WM_CLASS); write("wmclassmatch", "2"); // substring @@ -181,6 +195,11 @@ pub fn ensure_float_rule() { mod tests { use super::*; + #[test] + fn rule_uses_the_runtime_window_class() { + assert_eq!(WM_CLASS, "Pickgauge"); + } + #[test] fn native_wayland_kde_session_installs_the_rule() { // Also covers pickforge/pickgauge#49: the predicate takes no @@ -234,12 +253,11 @@ mod tests { } #[test] - fn group_has_key_finds_a_key_in_the_current_revision() { - let contents = "[pickgauge-float-keep-above]\nskiptaskbar=true\npositionrule=3\n"; - assert!(group_has_key( - contents, - "pickgauge-float-keep-above", - "positionrule" - )); + fn current_rule_requires_the_exact_runtime_window_class() { + let current = "[pickgauge-float-keep-above]\nwmclass=Pickgauge\npositionrule=3\n"; + let stale = "[pickgauge-float-keep-above]\nwmclass=pickgauge\npositionrule=3\n"; + + assert!(rule_is_current(current)); + assert!(!rule_is_current(stale)); } } diff --git a/tests/install-script-smoke.mjs b/tests/install-script-smoke.mjs index 5d7f3f9..185e219 100644 --- a/tests/install-script-smoke.mjs +++ b/tests/install-script-smoke.mjs @@ -219,6 +219,7 @@ test("AppImage install writes a FUSE-aware wrapper, desktop entry, and icon", (r assert.match(readFileSync(command, "utf8"), new RegExp(appImage.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(readFileSync(launcher, "utf8"), /Exec=".*\/\.local\/bin\/pickgauge"/); assert.match(readFileSync(launcher, "utf8"), /Icon=.*pickgauge/); + assert.match(readFileSync(launcher, "utf8"), /^StartupWMClass=Pickgauge$/m); assert.equal(existsSync(icon), true); assert.match(output, /Launch with `pickgauge`/); assertHeadlessForwarding(command, home);