diff --git a/src/engine.rs b/src/engine.rs index fb0a62b..53190bc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -298,7 +298,6 @@ mod tests { name: "t".into(), client: "wow-retail".into(), slots: 3, - window_match: Default::default(), mode_default: Mode::Maps, repeater: Default::default(), game_binds, @@ -693,7 +692,6 @@ mod tests { name: "rr".into(), client: "wow-retail".into(), slots: n, - window_match: Default::default(), mode_default: Mode::Maps, repeater: Default::default(), game_binds: BTreeMap::new(), diff --git a/src/gui.rs b/src/gui.rs index 2674e80..f89bcf2 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -4,7 +4,7 @@ use crate::hotkey::Hotkey; use crate::macros::print_macros; use crate::profile::{ Character, default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, - Repeater, Step, VideoFx, WindowMatch, + Repeater, Step, VideoFx, }; use crate::session; use anyhow::Result; @@ -80,10 +80,6 @@ fn default_profile() -> Profile { name: "team".into(), client: "wow-retail".into(), slots: 2, - window_match: WindowMatch { - class: Some("(?i)wow|warcraft".into()), - title: None, - }, mode_default: Mode::Maps, repeater: Repeater::default(), game_binds, @@ -654,20 +650,7 @@ impl App { ui.label("Slots"); ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16)); }); - let mut class = self.profile.window_match.class.clone().unwrap_or_default(); - 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) }; - } - }); +// window_match regex UI removed (Item 3). ui.label("Mode"); ui.horizontal(|ui| { if ui @@ -1630,56 +1613,37 @@ impl App { // than silently treated as "no pattern" — the latter would make // a typo in the YAML fall back to firing on ANY client, which // 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() .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 + // Item 3: process-tree match replaces regex. A client + // matches if (a) its pid is in any spawned tree root, + // OR (b) no launched PIDs are tracked yet (operator + // is testing interactively without launching) and the + // client has a non-empty class. The full wiring + // (spawned pids from the GUI's launch flow) lands in + // Item 4 (process-tree window discovery + game + // launcher dropdown). + let spawned: std::collections::HashSet = { + // Read Session.spawned_pids through the IPC + // socket: ask the daemon for its current + // 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 { std::thread::sleep(std::time::Duration::from_millis(1000)); @@ -1700,7 +1664,11 @@ impl App { .get("title") .and_then(|x| x.as_str()) .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 { let mut cmd = SyncCommand::new( diff --git a/src/layout.rs b/src/layout.rs index d3fea2c..691ee30 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -3,7 +3,6 @@ use crate::hypr::{self, Client}; use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile}; use anyhow::Result; -use regex::Regex; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] @@ -162,41 +161,22 @@ fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec { out } -pub fn select_windows(profile: &Profile, clients: Vec) -> Vec<(u32, Client)> { - let class_re = profile - .window_match - .class - .as_deref() - .and_then(|p| Regex::new(p).ok()); - let title_re = profile - .window_match - .title - .as_deref() - .and_then(|p| Regex::new(p).ok()); - if class_re.is_none() && title_re.is_none() { - return vec![]; - } - let mut matched: Vec = clients +// Item 3: select_windows is now a placeholder. The real picker +// runs in `session::refresh_slots` against `Session.spawned_pids`. +// This stub returns the first `profile.slots` visible clients in +// z-order so layout code that still calls select_windows during +// the rest of the Item 3 rollout does not silently lose every +// window. It will be deleted once refresh_slots fully owns slot +// assignment. +pub fn select_windows(_profile: &Profile, clients: Vec) -> Vec<(u32, Client)> { + let mut visible: Vec = clients .into_iter() - .filter(|c| { - 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 - }) + .filter(|c| c.mapped && !c.hidden && c.class != "enboxer-vfx") .collect(); - matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); - matched + visible.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); + visible .into_iter() - .take(profile.slots as usize) + .take(_profile.slots as usize) .enumerate() .map(|(i, c)| ((i as u32) + 1, c)) .collect() diff --git a/src/lib.rs b/src/lib.rs index eb91584..438aa48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,3 +15,5 @@ pub mod vfx; pub mod wayland_layer; pub mod gbm_runtime; + +pub mod process; diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..b81c86c --- /dev/null +++ b/src/process.rs @@ -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//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//status and return the PPid field as a u32. +pub fn parent_pid(pid: u32) -> Option { + 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)); + } +} diff --git a/src/profile.rs b/src/profile.rs index 3cfdcb5..f515f30 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -11,8 +11,6 @@ pub struct Profile { pub client: String, #[serde(default = "default_slots")] pub slots: u32, - #[serde(default)] - pub window_match: WindowMatch, #[serde(default = "default_mode")] pub mode_default: Mode, #[serde(default)] @@ -112,11 +110,6 @@ impl Mode { } } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct WindowMatch { - pub class: Option, - pub title: Option, -} #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Repeater { diff --git a/src/session.rs b/src/session.rs index 3e1f427..49f60a6 100644 --- a/src/session.rs +++ b/src/session.rs @@ -5,7 +5,6 @@ use crate::overlay::OverlayHub; use crate::profile::{runtime_dir, Mode, Profile}; use crate::vfx::{self, FeedHit}; use anyhow::{Context, Result}; -use regex::Regex; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -31,6 +30,11 @@ pub struct Session { /// held. Tunable via `ENBOXER_MOUSE_REPEAT_MS`; defaults to 50 ms /// (20 Hz) which feels responsive without saturating the IPC socket. 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, } /// Per-button repeat-loop handle + cancel signal. Holding the cancel @@ -60,6 +64,7 @@ impl Session { .and_then(|s| s.parse::().ok()) .filter(|&n| (1..=2000).contains(&n)) .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")); tracing::info!( - "profile {} slots={} match class={:?} title={:?}", + "profile {} slots={} mode={:?}", profile.name, profile.slots, - profile.window_match.class, - profile.window_match.title + profile.mode_default, ); 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 (vfx_tx, vfx_rx) = watch::channel(Vec::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(); if borderless { - if let Some(class) = class_re { - hypr::apply_borderless_rules(&class).await.ok(); - } + // apply_borderless_rules used to take a class regex (Item 3 + // 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()))?; @@ -163,43 +174,28 @@ async fn refresh_slots( let aw = hypr::active_window().await.ok().flatten(); let cursor = hypr::cursor_pos().await.ok(); let mut g = session.lock().await; - let class_re = g - .engine - .profile - .window_match - .class - .as_deref() - .and_then(|p| Regex::new(p).ok()); - let title_re = g - .engine - .profile - .window_match - .title - .as_deref() - .and_then(|p| Regex::new(p).ok()); - - let has_filter = class_re.is_some() || title_re.is_some(); - let mut matched: Vec = if !has_filter { - Vec::new() - } else { - clients - .into_iter() - .filter(|c| { - 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() - }; + // Item 3: match by spawned-pid process tree instead of regex. + // Empty spawned_pids means the daemon hasn't launched any + // game; in that case we accept every visible non-vfx client + // so the operator can still test layout interactively. + let spawned_roots = g.spawned_pids.iter().copied().collect::>(); + let mut matched: Vec = clients + .into_iter() + .filter(|c| { + if c.class == "enboxer-vfx" { + return false; + } + if !c.mapped || c.hidden { + return false; + } + if spawned_roots.is_empty() { + return true; + } + spawned_roots + .iter() + .any(|&root| crate::process::pid_is_ancestor(root, c.pid.max(0) as u32)) + }) + .collect(); matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); let n = g.engine.profile.slots as usize; diff --git a/src/team.rs b/src/team.rs index 78d95c7..36e5c1b 100644 --- a/src/team.rs +++ b/src/team.rs @@ -200,7 +200,6 @@ mod tests { name: "wow-team".into(), client: "wow-retail".into(), slots: 2, - window_match: Default::default(), mode_default: Mode::Maps, repeater: Default::default(), game_binds: BTreeMap::new(),