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.
This commit is contained in:
en 2026-09-16 05:46:39 +02:00
parent cece41d2ee
commit 1b8b2cdf24
3 changed files with 74 additions and 7 deletions

View File

@ -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).

View File

@ -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::<serde_json::Value>(&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")),

View File

@ -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)]