Item 3 (Grok round 4): replace regex window matching with process-tree tracking.

Master quote: "i do not know why you have regex for the games? If you are launching the games you should be able to find out what you launched instead and then place it in the correct spot in the grid. programs spawn under a tree structure on linux so you should easily be able to figure out what you spawned."

Implementation:

- src/process.rs (new): pid_is_ancestor(ancestor, candidate) walks /proc/<pid>/status PPid chain upward. Bounded to 64 hops so a malformed /proc cannot spin us. Unit-tested with three cases: self, zero-pids, and the running test process against its own ppid.

- src/lib.rs: pub mod process registered.

- src/profile.rs:
  * Profile.window_match field removed (was Option<String> class + Option<String> title regex).
  * WindowMatch struct removed.
  * HashSet import kept (still used elsewhere).

- src/session.rs:
  * Session.spawned_pids: HashSet<u32> field added + initialised.
  * refresh_slots: replaces regex filter with crate::process::pid_is_ancestor(root, c.pid) against every spawned root.
  * profile-init log line dropped window_match.class / .title args.
  * borderless path: removed class_re use; for now re-applies apply_vfx_window_rules (per-spawn windowrulev2 lands once the launcher wires the pid in Item 4).
  * matches_regex helper removed (was only used by arm_auto_apply).

- src/layout.rs:
  * select_windows now a placeholder: returns the first N visible clients in z-order. Real picker is in session::refresh_slots.
  * Regex import dropped.

- src/gui.rs:
  * WindowMatch import removed.
  * Default profile literal: window_match field removed.
  * Profile editor: the four class/title text-edit lines replaced with a comment explaining Item 3.
  * arm_auto_apply: regex compile block + class_pat / title_pat / any_pattern bindings removed; the thread body now uses the process-tree match with a placeholder for the spawned-pids source until Item 4 wires it.

- src/team.rs + src/engine.rs: Profile literals with window_match: Default::default() removed.

- src/hypr.rs: untouched. apply_borderless_rules kept but no longer called by the daemon.

cargo test 100/100 (3 new pid_is_ancestor tests); clippy clean.
This commit is contained in:
en 2026-09-17 05:42:06 +02:00
parent 20271264cf
commit 1d92bc568d
8 changed files with 158 additions and 151 deletions

View File

@ -298,7 +298,6 @@ mod tests {
name: "t".into(), name: "t".into(),
client: "wow-retail".into(), client: "wow-retail".into(),
slots: 3, slots: 3,
window_match: Default::default(),
mode_default: Mode::Maps, mode_default: Mode::Maps,
repeater: Default::default(), repeater: Default::default(),
game_binds, game_binds,
@ -693,7 +692,6 @@ mod tests {
name: "rr".into(), name: "rr".into(),
client: "wow-retail".into(), client: "wow-retail".into(),
slots: n, slots: n,
window_match: Default::default(),
mode_default: Mode::Maps, mode_default: Mode::Maps,
repeater: Default::default(), repeater: Default::default(),
game_binds: BTreeMap::new(), game_binds: BTreeMap::new(),

View File

@ -4,7 +4,7 @@ use crate::hotkey::Hotkey;
use crate::macros::print_macros; use crate::macros::print_macros;
use crate::profile::{ use crate::profile::{
Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile,
Repeater, Step, VideoFx, WindowMatch, Repeater, Step, VideoFx,
}; };
use crate::session; use crate::session;
use anyhow::Result; use anyhow::Result;
@ -80,10 +80,6 @@ fn default_profile() -> Profile {
name: "team".into(), name: "team".into(),
client: "wow-retail".into(), client: "wow-retail".into(),
slots: 2, slots: 2,
window_match: WindowMatch {
class: Some("(?i)wow|warcraft".into()),
title: None,
},
mode_default: Mode::Maps, mode_default: Mode::Maps,
repeater: Repeater::default(), repeater: Repeater::default(),
game_binds, game_binds,
@ -654,20 +650,7 @@ impl App {
ui.label("Slots"); ui.label("Slots");
ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16)); ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16));
}); });
let mut class = self.profile.window_match.class.clone().unwrap_or_default(); // window_match regex UI removed (Item 3).
ui.horizontal(|ui| {
ui.label("Window class regex");
if ui.text_edit_singleline(&mut class).changed() {
self.profile.window_match.class = if class.is_empty() { None } else { Some(class) };
}
});
let mut title = self.profile.window_match.title.clone().unwrap_or_default();
ui.horizontal(|ui| {
ui.label("Window title regex");
if ui.text_edit_singleline(&mut title).changed() {
self.profile.window_match.title = if title.is_empty() { None } else { Some(title) };
}
});
ui.label("Mode"); ui.label("Mode");
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui if ui
@ -1630,56 +1613,37 @@ impl App {
// than silently treated as "no pattern" — the latter would make // than silently treated as "no pattern" — the latter would make
// a typo in the YAML fall back to firing on ANY client, which // a typo in the YAML fall back to firing on ANY client, which
// is the exact bug we fixed in 7c94417. // is the exact bug we fixed in 7c94417.
let class_pat = self
.profile
.window_match
.class
.as_deref()
.and_then(|p| match regex::Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(
"arm_auto_apply: invalid window_match.class regex {:?}: {}",
p, e
);
None
}
});
let title_pat = self
.profile
.window_match
.title
.as_deref()
.and_then(|p| match regex::Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
tracing::warn!(
"arm_auto_apply: invalid window_match.title regex {:?}: {}",
p, e
);
None
}
});
let any_pattern = class_pat.is_some() || title_pat.is_some();
std::thread::Builder::new() std::thread::Builder::new()
.name("enboxer-auto-apply".into()) .name("enboxer-auto-apply".into())
.spawn(move || { .spawn(move || {
use std::process::Command as SyncCommand; use std::process::Command as SyncCommand;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let deadline = std::time::Duration::from_secs(30); let deadline = std::time::Duration::from_secs(30);
// Returns true if the client's class/title match the // Item 3: process-tree match replaces regex. A client
// configured patterns. If neither pattern is configured // matches if (a) its pid is in any spawned tree root,
// (no `window_match` set on the profile), accept the // OR (b) no launched PIDs are tracked yet (operator
// first client with a non-empty class so existing // is testing interactively without launching) and the
// profiles keep working. // client has a non-empty class. The full wiring
let matched = |cls: &str, ttl: &str| -> bool { // (spawned pids from the GUI's launch flow) lands in
let class_ok = class_pat.as_ref().is_none_or(|re| re.is_match(cls)); // Item 4 (process-tree window discovery + game
let title_ok = title_pat.as_ref().is_none_or(|re| re.is_match(ttl)); // launcher dropdown).
if !any_pattern { let spawned: std::collections::HashSet<u32> = {
!cls.is_empty() // Read Session.spawned_pids through the IPC
} else { // socket: ask the daemon for its current
class_ok && title_ok // tracked pids. For now (Item 3 placeholder),
// the daemon does not yet store them across the
// IPC boundary; the loop here just matches every
// non-empty client so existing layouts apply.
std::collections::HashSet::new()
};
let matched = |cls: &str, _ttl: &str, pid: i64| -> bool {
if spawned.is_empty() {
return !cls.is_empty();
} }
let pid_u = pid.max(0) as u32;
spawned.iter().any(|&root| {
crate::process::pid_is_ancestor(root, pid_u)
})
}; };
while start.elapsed() < deadline { while start.elapsed() < deadline {
std::thread::sleep(std::time::Duration::from_millis(1000)); std::thread::sleep(std::time::Duration::from_millis(1000));
@ -1700,7 +1664,11 @@ impl App {
.get("title") .get("title")
.and_then(|x| x.as_str()) .and_then(|x| x.as_str())
.unwrap_or(""); .unwrap_or("");
matched(cls, ttl) let pid = c
.get("pid")
.and_then(|x| x.as_i64())
.unwrap_or(0);
matched(cls, ttl, pid)
}); });
if hit { if hit {
let mut cmd = SyncCommand::new( let mut cmd = SyncCommand::new(

View File

@ -3,7 +3,6 @@
use crate::hypr::{self, Client}; use crate::hypr::{self, Client};
use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile}; use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile};
use anyhow::Result; use anyhow::Result;
use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -162,41 +161,22 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
out out
} }
pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> { // Item 3: select_windows is now a placeholder. The real picker
let class_re = profile // runs in `session::refresh_slots` against `Session.spawned_pids`.
.window_match // This stub returns the first `profile.slots` visible clients in
.class // z-order so layout code that still calls select_windows during
.as_deref() // the rest of the Item 3 rollout does not silently lose every
.and_then(|p| Regex::new(p).ok()); // window. It will be deleted once refresh_slots fully owns slot
let title_re = profile // assignment.
.window_match pub fn select_windows(_profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> {
.title let mut visible: Vec<Client> = clients
.as_deref()
.and_then(|p| Regex::new(p).ok());
if class_re.is_none() && title_re.is_none() {
return vec![];
}
let mut matched: Vec<Client> = clients
.into_iter() .into_iter()
.filter(|c| { .filter(|c| c.mapped && !c.hidden && c.class != "enboxer-vfx")
if c.class == "enboxer-vfx" {
return false;
}
let class_ok = class_re
.as_ref()
.map(|r| r.is_match(&c.class))
.unwrap_or(true);
let title_ok = title_re
.as_ref()
.map(|r| r.is_match(&c.title))
.unwrap_or(true);
class_ok && title_ok && c.mapped && !c.hidden
})
.collect(); .collect();
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); visible.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
matched visible
.into_iter() .into_iter()
.take(profile.slots as usize) .take(_profile.slots as usize)
.enumerate() .enumerate()
.map(|(i, c)| ((i as u32) + 1, c)) .map(|(i, c)| ((i as u32) + 1, c))
.collect() .collect()

View File

@ -15,3 +15,5 @@ pub mod vfx;
pub mod wayland_layer; pub mod wayland_layer;
pub mod gbm_runtime; pub mod gbm_runtime;
pub mod process;

71
src/process.rs Normal file
View File

@ -0,0 +1,71 @@
//! Process-tree helpers for Item 3 (Grok round 4).
//!
//! The enBoxer daemon launches games itself, so it knows the PID. When
//! the spawned process opens a Wayland window, that window belongs to
//! either the spawned process or one of its descendants (typically
//! `wine` -> `wine-preloader` -> `WoW.exe`). On Linux, every process
//! exposes its parent PID via `/proc/<pid>/status` PPid field.
//!
//! We use that to confirm a Hyprland client's pid belongs to the tree
//! rooted at one of the spawned PIDs, instead of matching on regex.
use std::path::Path;
/// True if `candidate` is the same as `ancestor` or any of its
/// descendants (one level deep is enough for Wine; we walk up the full
/// PPid chain to be safe for Steam / Lutris / native processes that
/// fork their children through a manager).
pub fn pid_is_ancestor(ancestor: u32, candidate: u32) -> bool {
if ancestor == 0 || candidate == 0 {
return false;
}
let mut current = candidate;
// Bound the walk to a sane depth so a malformed /proc can't spin us.
for _ in 0..64 {
if current == ancestor {
return true;
}
match parent_pid(current) {
Some(0) | None => return false,
Some(p) if p == current => return false,
Some(p) => current = p,
}
}
false
}
/// Read /proc/<pid>/status and return the PPid field as a u32.
pub fn parent_pid(pid: u32) -> Option<u32> {
let path = Path::new("/proc").join(pid.to_string()).join("status");
let s = std::fs::read_to_string(&path).ok()?;
for line in s.lines() {
if let Some(rest) = line.strip_prefix("PPid:") {
return rest.trim().parse().ok();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn self_is_own_ancestor() {
assert!(pid_is_ancestor(1, 1));
}
#[test]
fn zero_pids_are_not_ancestors() {
assert!(!pid_is_ancestor(0, 1));
assert!(!pid_is_ancestor(1, 0));
}
#[test]
fn parent_pid_reads_own_status() {
let me = std::process::id();
let ppid = parent_pid(me).expect("our own parent pid");
assert!(ppid >= 1);
assert!(pid_is_ancestor(ppid, me));
}
}

View File

@ -11,8 +11,6 @@ pub struct Profile {
pub client: String, pub client: String,
#[serde(default = "default_slots")] #[serde(default = "default_slots")]
pub slots: u32, pub slots: u32,
#[serde(default)]
pub window_match: WindowMatch,
#[serde(default = "default_mode")] #[serde(default = "default_mode")]
pub mode_default: Mode, pub mode_default: Mode,
#[serde(default)] #[serde(default)]
@ -112,11 +110,6 @@ impl Mode {
} }
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WindowMatch {
pub class: Option<String>,
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Repeater { pub struct Repeater {

View File

@ -5,7 +5,6 @@ use crate::overlay::OverlayHub;
use crate::profile::{runtime_dir, Mode, Profile}; use crate::profile::{runtime_dir, Mode, Profile};
use crate::vfx::{self, FeedHit}; use crate::vfx::{self, FeedHit};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use regex::Regex;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@ -31,6 +30,11 @@ pub struct Session {
/// held. Tunable via `ENBOXER_MOUSE_REPEAT_MS`; defaults to 50 ms /// held. Tunable via `ENBOXER_MOUSE_REPEAT_MS`; defaults to 50 ms
/// (20 Hz) which feels responsive without saturating the IPC socket. /// (20 Hz) which feels responsive without saturating the IPC socket.
pub mouse_repeat_ms: u64, pub mouse_repeat_ms: u64,
/// PIDs we launched (via the GUI's launch flow). refresh_slots
/// matches Hyprland clients whose pid is in any of these trees
/// (Item 3). Empty = no launches yet, in which case every
/// visible non-vfx client matches so layout can be tested.
pub spawned_pids: std::collections::HashSet<u32>,
} }
/// Per-button repeat-loop handle + cancel signal. Holding the cancel /// Per-button repeat-loop handle + cancel signal. Holding the cancel
@ -60,6 +64,7 @@ impl Session {
.and_then(|s| s.parse::<u64>().ok()) .and_then(|s| s.parse::<u64>().ok())
.filter(|&n| (1..=2000).contains(&n)) .filter(|&n| (1..=2000).contains(&n))
.unwrap_or(50), .unwrap_or(50),
spawned_pids: std::collections::HashSet::new(),
}) })
} }
} }
@ -72,14 +77,15 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
} }
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer")); let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"));
tracing::info!( tracing::info!(
"profile {} slots={} match class={:?} title={:?}", "profile {} slots={} mode={:?}",
profile.name, profile.name,
profile.slots, profile.slots,
profile.window_match.class, profile.mode_default,
profile.window_match.title
); );
let borderless = profile.layout.borderless; let borderless = profile.layout.borderless;
let class_re = profile.window_match.class.clone(); // borderless rule key was window_match.class; removed in
// Item 3 (process-tree matching replaces regex). borderless
// rules now apply to every spawned tree root.
let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?)); let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
let (vfx_tx, vfx_rx) = watch::channel(Vec::new()); let (vfx_tx, vfx_rx) = watch::channel(Vec::new());
let hub = vfx::OverlayHub::new(); let hub = vfx::OverlayHub::new();
@ -87,9 +93,14 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
hypr::apply_vfx_window_rules().await.ok(); hypr::apply_vfx_window_rules().await.ok();
if borderless { if borderless {
if let Some(class) = class_re { // apply_borderless_rules used to take a class regex (Item 3
hypr::apply_borderless_rules(&class).await.ok(); // removed that path). For now we re-apply the VFX window
} // rules to every spawned root + the vfx class itself.
// Per-spawn windowrules via `hyprctl dispatch` will land
// once the launcher wires the pid in (Item 4).
let spawned = session.lock().await.spawned_pids.clone();
let _ = spawned; // TODO: per-pid windowrulev2 dispatch
hypr::apply_vfx_window_rules().await.ok();
} }
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
@ -163,43 +174,28 @@ async fn refresh_slots(
let aw = hypr::active_window().await.ok().flatten(); let aw = hypr::active_window().await.ok().flatten();
let cursor = hypr::cursor_pos().await.ok(); let cursor = hypr::cursor_pos().await.ok();
let mut g = session.lock().await; let mut g = session.lock().await;
let class_re = g // Item 3: match by spawned-pid process tree instead of regex.
.engine // Empty spawned_pids means the daemon hasn't launched any
.profile // game; in that case we accept every visible non-vfx client
.window_match // so the operator can still test layout interactively.
.class let spawned_roots = g.spawned_pids.iter().copied().collect::<Vec<u32>>();
.as_deref() let mut matched: Vec<Client> = clients
.and_then(|p| Regex::new(p).ok()); .into_iter()
let title_re = g .filter(|c| {
.engine if c.class == "enboxer-vfx" {
.profile return false;
.window_match }
.title if !c.mapped || c.hidden {
.as_deref() return false;
.and_then(|p| Regex::new(p).ok()); }
if spawned_roots.is_empty() {
let has_filter = class_re.is_some() || title_re.is_some(); return true;
let mut matched: Vec<Client> = if !has_filter { }
Vec::new() spawned_roots
} else { .iter()
clients .any(|&root| crate::process::pid_is_ancestor(root, c.pid.max(0) as u32))
.into_iter() })
.filter(|c| { .collect();
if c.class == "enboxer-vfx" {
return false;
}
let class_ok = class_re
.as_ref()
.map(|r| r.is_match(&c.class))
.unwrap_or(true);
let title_ok = title_re
.as_ref()
.map(|r| r.is_match(&c.title))
.unwrap_or(true);
class_ok && title_ok && c.mapped && !c.hidden
})
.collect()
};
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
let n = g.engine.profile.slots as usize; let n = g.engine.profile.slots as usize;

View File

@ -200,7 +200,6 @@ mod tests {
name: "wow-team".into(), name: "wow-team".into(),
client: "wow-retail".into(), client: "wow-retail".into(),
slots: 2, slots: 2,
window_match: Default::default(),
mode_default: Mode::Maps, mode_default: Mode::Maps,
repeater: Default::default(), repeater: Default::default(),
game_binds: BTreeMap::new(), game_binds: BTreeMap::new(),