From 1b8b2cdf249cc4868f834a32cca6813eed0ea147 Mon Sep 17 00:00:00 2001 From: en Date: Wed, 16 Sep 2026 05:46:39 +0200 Subject: [PATCH] GUI: arm_auto_apply waits for window_match; new-team pads characters - arm_auto_apply (gui.rs ~1625): compile profile.window_match.class and .title regexes once, then poll hyprctl for a client that matches before firing layout-apply. Fall back to 'first non-empty class' when neither pattern is configured, so existing profiles keep working. Closes the security/correctness gap where any open window could trigger layout-apply against the desktop. - New-team wizard (gui.rs ~1438): profile.characters.truncate is replaced with resize(slots, Character::default()) so a wizard with N members and zero existing characters now actually gets N Launch buttons. Character gains #[derive(... Default)]. - CHANGELOG entries. cargo test 96+/0; clippy clean. --- CHANGELOG.md | 13 ++++++++++ src/gui.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++----- src/profile.rs | 2 +- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a154002..f7ac511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,3 +57,16 @@ read access could speak our IPC protocol (`Command::Type` etc.); this is the cheapest defense. Helpers: `profile::chmod_runtime_dir()` and `profile::chmod_socket(path)`. + +- **GUI:** New-team wizard now pads `profile.characters` to `slots` before + stamping `lutris_game`. A wizard with 5 members and an empty character + list previously saved a team with zero Launch rows; it now fills the + rows with `Character::default()` and applies the Lutris game to each. +- **GUI:** `arm_auto_apply` now waits for a client matching + `profile.window_match.class` / `.title` regexes before firing + `layout-apply`, instead of firing on the first non-empty client list. + With no patterns configured it still requires a non-empty class on the + matched client, so existing profiles behave the same. This prevents + `layout-apply` against the desktop when only a terminal (or any + unrelated window) is open. +- **Profile:** `Character` derives `Default` (needed for the padding). diff --git a/src/gui.rs b/src/gui.rs index 80caed7..80ca088 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -3,8 +3,8 @@ use crate::hotkey::Hotkey; use crate::macros::print_macros; use crate::profile::{ - default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step, - VideoFx, WindowMatch, + Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, + Repeater, Step, VideoFx, WindowMatch, }; use crate::session; use anyhow::Result; @@ -1438,9 +1438,22 @@ impl App { let mut profile = self.profile.clone(); profile.name = name.to_string(); profile.slots = wiz.members.max(1); - // Drop characters beyond the new member count. - profile.characters.truncate(profile.slots as usize); - // Apply the Lutris game pick to every character. + // Pad to slot count: truncate any extras, but extend with defaults + // so a brand-new profile (members > 0, characters == 0) actually + // gets a Launch row per member. + profile.characters + .resize(profile.slots as usize, Character::default()); + // Character::default() leaves slot = 0, which collides with the + // Launch button lookup (find(|c| c.slot == slot)). Assign slot 1..=N + // to any character still at 0, leaving properly-assigned entries + // untouched. + for (i, ch) in profile.characters.iter_mut().enumerate() { + if ch.slot == 0 { + ch.slot = (i as u32) + 1; + } + } + // Apply the Lutris game pick to every character (including the + // freshly-padded ones). if let Some(slug_l) = wiz.lutris_slug.clone() { for ch in &mut profile.characters { ch.lutris_game = Some(slug_l.clone()); @@ -1619,12 +1632,42 @@ impl App { // and then sends the layout-apply IPC. Detached; logs on error. let profile_path = self.path.clone(); let allow = self.allow_layout; + // Compile the profile's window_match patterns once, outside the + // poll loop. If a pattern is malformed we treat it as "not + // configured" rather than crashing the auto-apply thread. + let class_pat = self + .profile + .window_match + .class + .as_deref() + .and_then(|p| regex::Regex::new(p).ok()); + let title_pat = self + .profile + .window_match + .title + .as_deref() + .and_then(|p| regex::Regex::new(p).ok()); + let any_pattern = class_pat.is_some() || title_pat.is_some(); std::thread::Builder::new() .name("enboxer-auto-apply".into()) .spawn(move || { use std::process::Command as SyncCommand; let start = std::time::Instant::now(); let deadline = std::time::Duration::from_secs(30); + // Returns true if the client's class/title match the + // configured patterns. If neither pattern is configured + // (no `window_match` set on the profile), accept the + // first client with a non-empty class so existing + // profiles keep working. + let matched = |cls: &str, ttl: &str| -> bool { + let class_ok = class_pat.as_ref().is_none_or(|re| re.is_match(cls)); + let title_ok = title_pat.as_ref().is_none_or(|re| re.is_match(ttl)); + if !any_pattern { + !cls.is_empty() + } else { + class_ok && title_ok + } + }; while start.elapsed() < deadline { std::thread::sleep(std::time::Duration::from_millis(1000)); let out = SyncCommand::new("hyprctl") @@ -1635,7 +1678,18 @@ impl App { serde_json::from_slice::(&out.stdout) { if let Some(arr) = v.as_array() { - if !arr.is_empty() { + let hit = arr.iter().any(|c| { + let cls = c + .get("class") + .and_then(|x| x.as_str()) + .unwrap_or(""); + let ttl = c + .get("title") + .and_then(|x| x.as_str()) + .unwrap_or(""); + matched(cls, ttl) + }); + if hit { let mut cmd = SyncCommand::new( std::env::current_exe() .unwrap_or_else(|_| std::path::PathBuf::from("enboxer")), diff --git a/src/profile.rs b/src/profile.rs index 445aa6a..f73f2a0 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -157,7 +157,7 @@ pub enum InteractStyle { Hold, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Character { pub slot: u32, #[serde(default)]