use crate::engine::{Action, Engine, Hold}; use crate::hotkey::{self, passthrough_id}; use crate::hypr::{self, BindSpec, Client}; 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}; use tokio::net::UnixListener; use tokio::sync::{watch, Mutex}; use tokio::time::{sleep, Duration}; pub struct Session { pub engine: Engine, pub slots: Vec<(u32, Client)>, pub binds_on: bool, pub exe: PathBuf, pub sock: PathBuf, pub vfx: Vec, pub vfx_source: Option, pub mouse_broadcast: bool, pub mouse_follow: bool, } impl Session { pub fn new(profile: Profile, exe: PathBuf, sock: PathBuf) -> Result { Ok(Self { engine: Engine::new(profile)?, slots: Vec::new(), binds_on: false, exe, sock, vfx: Vec::new(), vfx_source: None, mouse_broadcast: false, mouse_follow: false, }) } } pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { std::fs::create_dir_all(runtime_dir()).ok(); crate::profile::chmod_runtime_dir(); if sock.exists() { let _ = std::fs::remove_file(&sock); } let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer")); tracing::info!( "profile {} slots={} match class={:?} title={:?}", profile.name, profile.slots, profile.window_match.class, profile.window_match.title ); let borderless = profile.layout.borderless; let class_re = profile.window_match.class.clone(); 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(); let slot_hub = Arc::new(Mutex::new(OverlayHub::new())); hypr::apply_vfx_window_rules().await.ok(); if borderless { if let Some(class) = class_re { hypr::apply_borderless_rules(&class).await.ok(); } } let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; crate::profile::chmod_socket(&sock); tracing::info!("ipc {}", sock.display()); let s1 = session.clone(); tokio::spawn(async move { loop { match listener.accept().await { Ok((stream, _)) => { let s = s1.clone(); tokio::spawn(async move { if let Err(e) = handle_ipc(s, stream).await { tracing::debug!("ipc: {e}"); } }); } Err(e) => tracing::warn!("accept: {e}"), } } }); { let hub = hub.clone(); tokio::spawn(vfx::capture_loop(vfx_rx, hub)); } let s2 = session.clone(); let hub2 = hub.clone(); let slot_hub2 = slot_hub.clone(); tokio::spawn(async move { loop { if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2, &slot_hub2).await { tracing::warn!("refresh: {e}"); } sleep(Duration::from_millis(400)).await; } }); let s3 = session.clone(); let events = hypr::listen_events(move |line| { let s = s3.clone(); async move { on_event(&s, &line).await; Ok(()) } }); tokio::select! { r = events => { tracing::warn!("hyprland event socket closed: {r:?}"); } _ = tokio::signal::ctrl_c() => { tracing::info!("ctrl-c"); } } let _ = hypr::clear_binds().await; hub.kill_all().await; slot_hub.lock().await.kill_all(); Ok(()) } async fn refresh_slots( session: &Arc>, vfx_tx: &watch::Sender>, hub: &Arc, slot_hub: &Arc>, ) -> Result<()> { let clients = hypr::clients().await?; 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() }; matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid)); let n = g.engine.profile.slots as usize; let new_slots: Vec<(u32, Client)> = matched .into_iter() .take(n) .enumerate() .map(|(i, c)| ((i as u32) + 1, c)) .collect(); let old_keys: Vec<(u32, &str)> = g .slots .iter() .map(|(s, c)| (*s, c.address.as_str())) .collect(); let new_keys: Vec<(u32, &str)> = new_slots .iter() .map(|(s, c)| (*s, c.address.as_str())) .collect(); if old_keys != new_keys { for (slot, c) in &new_slots { tracing::info!("slot {} {} {} {}", slot, c.class, c.title, c.address); } } g.slots = new_slots; if let Some(aw) = aw { let leader = g .slots .iter() .find(|(_, c)| c.address == aw.address) .map(|(s, _)| *s); if let Some(slot) = leader { g.engine.set_leader(slot); } let managed = g.slots.iter().any(|(_, c)| c.address == aw.address) || aw.class == "enboxer-vfx" || aw.title.starts_with("enboxer-vfx"); if managed { if !g.binds_on { let specs = bind_specs(&g)?; let sock = g.sock.display().to_string(); drop(g); hypr::replace_binds(&specs, &sock).await?; let mut g = session.lock().await; g.binds_on = true; tracing::info!("routing on ({} binds)", specs.len()); return finish_vfx(g, vfx_tx, hub, cursor).await; } } else if g.binds_on { g.binds_on = false; g.vfx_source = None; drop(g); hypr::clear_binds().await.ok(); tracing::info!("routing off (focus left the team)"); let g = session.lock().await; return finish_vfx(g, vfx_tx, hub, cursor).await; } } finish_vfx(g, vfx_tx, hub, cursor).await?; sync_slot_overlays(session, slot_hub).await; Ok(()) } /// Keep the slot-number overlay hub in sync with the current slot set. No-op /// when `ENBOXER_ENABLE_OVERLAY` is not set; otherwise the spawn stub logs. async fn sync_slot_overlays( session: &Arc>, slot_hub: &Arc>, ) { let slots = { let g = session.lock().await; g.slots.clone() }; let mut hub = slot_hub.lock().await; hub.sync(&slots); } async fn finish_vfx( mut g: tokio::sync::MutexGuard<'_, Session>, vfx_tx: &watch::Sender>, hub: &Arc, cursor: Option<(i32, i32)>, ) -> Result<()> { let feeds = if let Some((_, primary)) = g .slots .iter() .find(|(s, _)| *s == g.engine.leader_slot) .cloned() { vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots) } else { Vec::new() }; g.vfx = feeds.clone(); if let Some((x, y)) = cursor { let src = vfx::hit_test(&g.vfx, x, y) .filter(|h| h.pass_through) .map(|h| h.source_slot); if src != g.vfx_source { g.vfx_source = src; g.binds_on = false; } } drop(g); let _ = vfx_tx.send(feeds.clone()); hub.sync(&feeds).await.ok(); Ok(()) } async fn on_event(session: &Arc>, line: &str) { if let Some(rest) = line.strip_prefix("activewindowv2>>") { let addr = rest.trim(); let mut g = session.lock().await; let want = addr.trim().trim_start_matches("0x"); let leader = g .slots .iter() .find(|(_, c)| { c.address .trim() .trim_start_matches("0x") .eq_ignore_ascii_case(want) }) .map(|(s, _)| *s); if let Some(slot) = leader { g.engine.set_leader(slot); } } } fn bind_specs(g: &Session) -> Result> { let passthrough = g.engine.profile.passthrough_set()?; let mut specs = Vec::new(); let bin = g.exe.display().to_string(); for m in &g.engine.profile.maps { let id = passthrough_id(&m.hotkey.0).unwrap_or_else(|_| m.hotkey.0.clone()); if passthrough.contains(&id) { continue; } let parsed = m.hotkey.parse()?; specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), ipc_args: format!("hotkey {}", m.hotkey.0), non_consuming: false, release: false, }); if m.hold { specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), ipc_args: format!("hotkey-release {}", m.hotkey.0), non_consuming: false, release: true, }); } } if g.engine.mode == Mode::Mirror { let keys = if g.engine.profile.repeater.keys.is_empty() { clone_key_set() } else { g.engine.profile.repeater.keys.clone() }; for k in keys { let id = passthrough_id(&k).unwrap_or_else(|_| k.clone()); if passthrough.contains(&id) { continue; } if g.engine.profile.map_by_hotkey(&k).is_some() { continue; } let parsed = hotkey::parse(&k)?; specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), ipc_args: format!("hotkey {k}"), non_consuming: true, release: false, }); } } let wm = [ ("mode_cycle", "mode-cycle"), ("swap_next", "swap-next"), ("swap_prev", "swap-prev"), ("focus_next", "focus-next"), ("focus_prev", "focus-prev"), ("focus_main", "focus-main"), ("reset_all", "reset-all"), ("stay_on_top", "stay-on-top"), ("mouse_follow", "mouse-follow"), ("mouse_broadcast", "mouse-broadcast"), ]; for (name, ipc) in wm { if let Some(hk) = g.engine.profile.session_hotkeys.get(name) { let parsed = hotkey::parse(hk)?; specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), ipc_args: ipc.into(), non_consuming: false, release: false, }); } } for i in 1..=g.engine.profile.slots { let focus = format!("Ctrl+F{i}"); let swap = format!("Ctrl+Shift+F{i}"); if let Ok(p) = hotkey::parse(&focus) { specs.push(BindSpec { bind: p.hypr_bind(), ipc_bin: bin.clone(), ipc_args: format!("focus {i}"), non_consuming: false, release: false, }); } if i > 1 { if let Ok(p) = hotkey::parse(&swap) { specs.push(BindSpec { bind: p.hypr_bind(), ipc_bin: bin.clone(), ipc_args: format!("swap {i}"), non_consuming: false, release: false, }); } } } if g.mouse_broadcast { for btn in ["mouse:272", "mouse:273"] { specs.push(BindSpec { bind: btn.to_string(), ipc_bin: bin.clone(), ipc_args: format!("mouse-click {btn}"), non_consuming: true, release: false, }); } } if g.engine.mode == Mode::Mirror { // Mirror mode always broadcasts clicks to other captured windows // (T13). We install the binds independently of `mouse_broadcast` // so the behaviour is automatic for the operator. for btn in ["mouse:272", "mouse:273"] { if specs.iter().any(|s| s.bind == btn) { continue; } specs.push(BindSpec { bind: btn.to_string(), ipc_bin: bin.clone(), ipc_args: format!("mirror-click {btn}"), non_consuming: true, release: false, }); } } if g.vfx_source.is_some() { for k in vfx_hover_keys() { if specs.iter().any(|s| s.bind == k) { continue; } specs.push(BindSpec { bind: k.clone(), ipc_bin: bin.clone(), ipc_args: format!("vfxkey {k}"), non_consuming: false, release: false, }); } } Ok(specs) } async fn handle_ipc(session: Arc>, stream: tokio::net::UnixStream) -> Result<()> { let (r, mut w) = stream.into_split(); let mut lines = BufReader::new(r).lines(); while let Some(line) = lines.next_line().await? { let reply = dispatch_cmd(&session, &line).await; w.write_all(reply.as_bytes()).await?; w.write_all(b"\n").await?; } Ok(()) } async fn dispatch_cmd(session: &Arc>, line: &str) -> String { let mut parts = line.splitn(2, ' '); let cmd = parts.next().unwrap_or(""); let arg = parts.next().unwrap_or("").trim(); match cmd { "ping" => "ok".into(), "status" => { let g = session.lock().await; format!( "mode={} leader={} binds={} slots={} mouse_broadcast={} mouse_follow={} vfx_source={:?}", g.engine.mode.as_str(), g.engine.leader_slot, g.binds_on, g.slots.len(), g.mouse_broadcast, g.mouse_follow, g.vfx_source ) } "mode-cycle" => match set_mode(session, None).await { Ok(m) => format!("mode={}", m.as_str()), Err(e) => format!("err {e}"), }, "mode" => match set_mode(session, Some(arg)).await { Ok(m) => format!("mode={}", m.as_str()), Err(e) => format!("err {e}"), }, "hotkey" => match fire(session, arg, Hold::Tap).await { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), }, "hotkey-release" => match fire(session, arg, Hold::Up).await { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), }, "vfxkey" => { if let Some(rest) = arg.strip_prefix("mouse:") { match rest.parse::() { Ok(btn) => match fire_vfx_click(session, btn).await { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), }, Err(_) => format!("err bad mouse {arg}"), } } else { match fire_vfx(session, arg).await { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), } } } "swap-next" => wm_ok(swap_next(session).await), "swap-prev" => wm_ok(swap_prev(session).await), "swap" => match arg.parse::() { Ok(n) => wm_ok(swap_as_main(session, n).await), Err(_) => "err bad slot".into(), }, "focus-next" => wm_ok(focus_step(session, 1).await), "focus-prev" => wm_ok(focus_step(session, -1).await), "focus-main" => wm_ok(focus_slot(session, 1).await), "focus" => match arg.parse::() { Ok(n) => wm_ok(focus_slot(session, n).await), Err(_) => "err bad slot".into(), }, "reset-all" => wm_ok(reset_layout(session).await), "stay-on-top" => wm_ok(toggle_pin(session).await), "mouse-follow" => wm_ok(toggle_mouse_follow(session).await), "mouse-broadcast" => wm_ok(toggle_mouse_broadcast(session).await), "mouse-click" => { let btn = arg .strip_prefix("mouse:") .and_then(|s| s.parse::().ok()) .unwrap_or(272); wm_ok(broadcast_click(session, btn).await) } "mirror-click" => { let btn = arg .strip_prefix("mouse:") .and_then(|s| s.parse::().ok()) .unwrap_or(272); wm_ok(broadcast_mirror_click(session, btn).await) } "type" => wm_ok(type_to_others(session, arg).await), "clipboard" => wm_ok(clipboard_to_others(session).await), _ => format!("err unknown {cmd}"), } } async fn set_mode(session: &Arc>, name: Option<&str>) -> Result { let aw = hypr::active_window().await.ok().flatten(); let (mode, specs, sock, managed) = { let mut g = session.lock().await; let mode = if let Some(n) = name { Mode::parse_name(n).ok_or_else(|| anyhow::anyhow!("unknown mode {n}"))? } else { g.engine.cycle_mode() }; g.engine.mode = mode; let managed = aw.as_ref().is_some_and(|aw| { g.slots.iter().any(|(_, c)| c.address == aw.address) || aw.class == "enboxer-vfx" || aw.title.starts_with("enboxer-vfx") }); let specs = if managed { Some(bind_specs(&g)?) } else { None }; let sock = g.sock.display().to_string(); (mode, specs, sock, managed) }; if let Some(specs) = specs { hypr::replace_binds(&specs, &sock).await?; let mut g = session.lock().await; g.binds_on = managed; } hypr::notify(&format!("enBoxer: {}", mode.label())) .await .ok(); tracing::info!("mode {}", mode.as_str()); Ok(mode) } fn wm_ok(r: Result<()>) -> String { match r { Ok(()) => "ok".into(), Err(e) => format!("err {e}"), } } async fn swap_next(session: &Arc>) -> Result<()> { let n = { let g = session.lock().await; let len = g.slots.len() as u32; if len < 2 { return Ok(()); } let cur = g.engine.leader_slot; if cur >= len { 2 } else { cur + 1 } }; swap_as_main(session, n.max(2)).await } async fn swap_prev(session: &Arc>) -> Result<()> { let n = { let g = session.lock().await; let len = g.slots.len() as u32; if len < 2 { return Ok(()); } let cur = g.engine.leader_slot; if cur <= 2 { len } else { cur - 1 } }; swap_as_main(session, n).await } async fn swap_as_main(session: &Arc>, n: u32) -> Result<()> { let (wins, tiles) = { let mut g = session.lock().await; if n < 2 || n as usize > g.slots.len() { return Ok(()); } g.slots.swap(0, n as usize - 1); for (i, (id, _)) in g.slots.iter_mut().enumerate() { *id = i as u32 + 1; } g.engine.set_leader(1); (g.slots.clone(), g.engine.profile.layout.slots.clone()) }; if !tiles.is_empty() { crate::layout::apply(&tiles, &wins).await?; } if let Some((_, c)) = wins.first() { hypr::focus_window(&c.address_selector()).await.ok(); } hypr::notify(&format!("main is slot 1 (was {n})")) .await .ok(); Ok(()) } async fn focus_step(session: &Arc>, dir: i32) -> Result<()> { let n = { let g = session.lock().await; let len = g.slots.len() as i32; if len == 0 { return Ok(()); } let cur = g.engine.leader_slot as i32; let next = ((cur - 1 + dir).rem_euclid(len)) + 1; next as u32 }; focus_slot(session, n).await } async fn focus_slot(session: &Arc>, n: u32) -> Result<()> { let addr = { let mut g = session.lock().await; g.engine.set_leader(n); g.slots .iter() .find(|(s, _)| *s == n) .map(|(_, c)| c.address_selector()) }; if let Some(a) = addr { hypr::focus_window(&a).await?; } Ok(()) } async fn reset_layout(session: &Arc>) -> Result<()> { let (wins, tiles) = { let g = session.lock().await; (g.slots.clone(), g.engine.profile.layout.slots.clone()) }; if tiles.is_empty() { anyhow::bail!("no saved layout tiles"); } crate::layout::apply(&tiles, &wins).await?; hypr::notify("layout reset").await.ok(); Ok(()) } /// True iff the operator has explicitly allowed the daemon to mutate the /// Hyprland window stack. The enBoxer defaults are keys-only with safe /// passthrough; per-window moves (pin/float/move/resize) require an opt-in /// so a stray hotkey cannot wreck the user's layout while they are away /// from the keyboard. pub fn moves_allowed() -> bool { std::env::var("ENBOXER_ALLOW_LAYOUT") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false) } async fn toggle_pin(session: &Arc>) -> Result<()> { // Bug #12: pin/float/move/resize must be opt-in like layout-apply, // not silently fire on the leader slot on a hotkey press. if !moves_allowed() { tracing::warn!( "toggle_pin: refusing (set ENBOXER_ALLOW_LAYOUT=1 to enable)" ); return Ok(()); } let addr = { let g = session.lock().await; g.slots .iter() .find(|(s, _)| *s == g.engine.leader_slot) .map(|(_, c)| c.address_selector()) }; if let Some(a) = addr { hypr::dispatch_lua(&format!( "hl.dsp.window.pin({{ window = {a:?}, action = \"toggle\" }})" )) .await?; } Ok(()) } async fn toggle_mouse_follow(session: &Arc>) -> Result<()> { let on = { let mut g = session.lock().await; g.mouse_follow = !g.mouse_follow; g.mouse_follow }; let v = if on { 1 } else { 0 }; let _ = hypr::hyprctl(["keyword", "input:follow_mouse", &v.to_string()]).await; hypr::notify(&format!( "focus follows mouse {}", if on { "on" } else { "off" } )) .await .ok(); Ok(()) } async fn toggle_mouse_broadcast(session: &Arc>) -> Result<()> { { let mut g = session.lock().await; g.mouse_broadcast = !g.mouse_broadcast; g.binds_on = false; hypr::notify(&format!( "mouse broadcast {}", if g.mouse_broadcast { "on" } else { "off" } )) .await .ok(); } Ok(()) } async fn type_to_others(session: &Arc>, text: &str) -> Result<()> { let others = { let g = session.lock().await; others_clients(&g.slots, g.engine.leader_slot) }; for key in crate::hotkey::type_keys(text) { for c in &others { hypr::deliver_key(c, &key, None).await.ok(); } } Ok(()) } /// Pure: the captured slots that are NOT the current leader. Both `type_to_others` /// and `clipboard_to_others` route through this so the "exclude leader" shape is /// testable without a session. pub fn others_clients(slots: &[(u32, Client)], leader: u32) -> Vec { slots .iter() .filter(|(s, _)| *s != leader) .map(|(_, c)| c.clone()) .collect() } /// Pure: the non-leader captured slots that should receive a mirror-mode /// mouse click for `button`. The leader already got it (the user clicked /// on the primary). Returns `(target_client, button)` pairs in slot-number /// order so the daemon's click loop is deterministic. The X/Y of the /// click are forwarded from the cursor position in /// `broadcast_mirror_click`. /// /// Press-and-hold guard is OUT OF SCOPE for T13 (Master has not requested /// it yet); the caller should hold the mouse binds as a follow-up. /// T13-todo: press-and-hold guard — repeated button-down without release /// should re-fire on a configurable cadence. pub fn mirror_clicks_to( slots: &[(u32, Client)], leader: u32, button: u32, ) -> Vec<(Client, u32)> { slots .iter() .filter(|(s, _)| *s != leader) .map(|(_, c)| (c.clone(), button)) .collect() } async fn clipboard_to_others(session: &Arc>) -> Result<()> { let text = read_clipboard().await?; let others = { let g = session.lock().await; others_clients(&g.slots, g.engine.leader_slot) }; for c in &others { hypr::deliver_key(c, "Ctrl+v", None).await.ok(); } tracing::info!("clipboard broadcast: {} bytes to {} slot(s)", text.len(), others.len()); Ok(()) } async fn read_clipboard() -> Result { use std::process::Stdio; use tokio::process::Command; for cmd in ["wl-paste", "xclip"] { let args: &[&str] = if cmd == "xclip" { &["-o", "-selection", "clipboard"] } else { &["-n"] }; let out = Command::new(cmd) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await; if let Ok(out) = out { if out.status.success() { return Ok(String::from_utf8_lossy(&out.stdout).into_owned()); } } } anyhow::bail!("no clipboard tool (install wl-paste or xclip)") } async fn broadcast_click(session: &Arc>, button: u32) -> Result<()> { let (cx, cy) = hypr::cursor_pos().await?; let others = { let g = session.lock().await; if !g.mouse_broadcast { return Ok(()); } let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else { return Ok(()); }; let pw = primary.size[0].max(1) as f64; let ph = primary.size[1].max(1) as f64; let nx = (cx - primary.at[0]) as f64 / pw; let ny = (cy - primary.at[1]) as f64 / ph; g.slots .iter() .filter(|(s, _)| *s != g.engine.leader_slot) .map(|(_, c)| { let x = c.at[0] + (nx * c.size[0] as f64).round() as i32; let y = c.at[1] + (ny * c.size[1] as f64).round() as i32; (c.clone(), x, y) }) .collect::>() }; for (c, x, y) in others { hypr::move_cursor(x, y).await.ok(); hypr::deliver_click(&c, button).await.ok(); } hypr::move_cursor(cx, cy).await.ok(); Ok(()) } /// Mirror-mode click broadcast: when `mode == Mirror`, every click on the /// primary is also delivered to every other captured slot at the /// matching relative position. Independent of the `mouse_broadcast` /// toggle — mirror mode always does this. The cursor moves to each /// target's relative position, the click is delivered (focus, send, /// restore via `hypr::deliver_click` for native Wayland; direct for /// XWayland), and the cursor returns to where the user clicked. async fn broadcast_mirror_click(session: &Arc>, button: u32) -> Result<()> { let (cx, cy, plan) = { let g = session.lock().await; if g.engine.mode != Mode::Mirror { return Ok(()); } let Some((_, primary)) = g.slots.iter().find(|(s, _)| *s == g.engine.leader_slot) else { return Ok(()); }; let cx = hypr::cursor_pos().await?.0; let cy = hypr::cursor_pos().await?.1; let pw = primary.size[0].max(1) as f64; let ph = primary.size[1].max(1) as f64; let nx = (cx - primary.at[0]) as f64 / pw; let ny = (cy - primary.at[1]) as f64 / ph; let plan: Vec<(Client, i32, i32)> = mirror_clicks_to(&g.slots, g.engine.leader_slot, button) .into_iter() .map(|(c, _btn)| { let x = c.at[0] + (nx * c.size[0] as f64).round() as i32; let y = c.at[1] + (ny * c.size[1] as f64).round() as i32; (c, x, y) }) .collect(); (cx, cy, plan) }; for (c, x, y) in plan { hypr::move_cursor(x, y).await.ok(); hypr::deliver_click(&c, button).await.ok(); } hypr::move_cursor(cx, cy).await.ok(); Ok(()) } fn clone_key_set() -> Vec { vfx_hover_keys() .into_iter() .filter(|k| !k.starts_with("mouse:")) .collect() } fn vfx_hover_keys() -> Vec { let mut v: Vec = [ "Space", "Return", "Escape", "Tab", "BackSpace", "grave", "minus", "equal", "mouse:272", "mouse:273", ] .into_iter() .map(str::to_string) .collect(); for c in b'a'..=b'z' { v.push((c as char).to_string()); } for c in b'0'..=b'9' { v.push((c as char).to_string()); } v } async fn fire_vfx(session: &Arc>, hotkey: &str) -> Result<()> { let (src, slots) = { let g = session.lock().await; let src = g .vfx_source .ok_or_else(|| anyhow::anyhow!("no vfx hover"))?; (src, g.slots.clone()) }; execute( vec![Action::Send { key: hotkey.to_string(), slots: vec![src], hold: Hold::Tap, }], &slots, ) .await } async fn fire_vfx_click(session: &Arc>, button: u32) -> Result<()> { let (cx, cy) = hypr::cursor_pos().await?; let (feed, src) = { let g = session.lock().await; let slot = g .vfx_source .ok_or_else(|| anyhow::anyhow!("no vfx hover"))?; let feed = vfx::hit_test(&g.vfx, cx, cy) .filter(|h| h.pass_through && h.source_slot == slot) .ok_or_else(|| anyhow::anyhow!("vfx hover lost"))? .clone(); let src = g .slots .iter() .find(|(s, _)| *s == slot) .map(|(_, c)| c.clone()) .ok_or_else(|| anyhow::anyhow!("source slot gone"))?; (feed, src) }; let (sx, sy) = vfx::map_click(&feed, cx, cy); hypr::move_cursor(sx, sy).await?; hypr::deliver_click(&src, button).await?; hypr::move_cursor(cx, cy).await?; Ok(()) } async fn fire(session: &Arc>, hotkey: &str, edge: Hold) -> Result<()> { let (actions, slots) = { let mut g = session.lock().await; if let Some(src) = g.vfx_source { if g.engine.profile.map_by_hotkey(hotkey).is_none() && g.engine .profile .session_hotkeys .values() .all(|h| !h.eq_ignore_ascii_case(hotkey)) { let hold = if matches!(edge, Hold::Up) { Hold::Up } else { Hold::Tap }; ( vec![Action::Send { key: hotkey.to_string(), slots: vec![src], hold, }], g.slots.clone(), ) } else { (g.engine.fire(hotkey, edge)?, g.slots.clone()) } } else { (g.engine.fire(hotkey, edge)?, g.slots.clone()) } }; execute(actions, &slots).await } async fn execute(actions: Vec, slots: &[(u32, Client)]) -> Result<()> { for a in actions { match a { Action::Sleep(d) => sleep(d).await, Action::Send { key, slots: ids, hold, } => { let parsed_state = match hold { Hold::Down => Some("down"), Hold::Up => Some("up"), Hold::Tap => None, }; for id in ids { if let Some((_, c)) = slots.iter().find(|(s, _)| *s == id) { // Per-slot failures must NOT abort the rest of the // chain. For example, a smart_interact (CTM on -> Alt+J // -> sleep -> CTM off) must keep going through every // captured slot even if one wlr-keyboard barf means // Alt+J never lands on that client — otherwise CTM // can stay on for the survivors and the user has to // manually reset it. if let Err(e) = hypr::deliver_key(c, &key, parsed_state).await { tracing::warn!( "deliver_key slot {id} key {key:?} failed: {e}" ); } } } } } } Ok(()) } pub async fn ipc_send(sock: &Path, line: &str) -> Result { use tokio::io::AsyncBufReadExt; let mut stream = tokio::net::UnixStream::connect(sock) .await .with_context(|| format!("connect {}", sock.display()))?; stream.write_all(line.as_bytes()).await?; stream.write_all(b"\n").await?; let mut reader = BufReader::new(stream); let mut reply = String::new(); reader.read_line(&mut reply).await?; Ok(reply.trim().to_string()) } pub fn default_sock() -> PathBuf { runtime_dir().join("enboxer.sock") } #[cfg(test)] mod tests { use super::*; use crate::hypr::Client; fn client(addr: &str, at: (i32, i32)) -> Client { Client { address: addr.into(), class: "wow".into(), title: addr.into(), pid: 1, at: [at.0, at.1], size: [800, 600], mapped: true, hidden: false, xwayland: true, focus_history_id: 0, } } #[test] fn others_excludes_leader() { let slots = vec![ (1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000))), ]; let others = others_clients(&slots, 2); assert_eq!(others.len(), 2); assert_eq!(others[0].address, "0xa"); assert_eq!(others[1].address, "0xc"); } #[test] fn others_is_empty_when_only_leader() { let slots = vec![(1, client("0xa", (0, 0)))]; assert!(others_clients(&slots, 1).is_empty()); } #[test] fn type_routing_plan_covers_every_other_slot_per_char() { // 2 others, clipboard-shaped text "Hi\n" (3 type_keys after expansion). let slots = vec![ (1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000))), ]; let text = "Hi\n"; let keys = crate::hotkey::type_keys(text); assert_eq!(keys, vec!["Shift+h".to_string(), "i".into(), "Return".into()]); let plan: Vec<(String, String)> = others_clients(&slots, 1) .iter() .flat_map(|c| keys.iter().map(move |k| (c.address.clone(), k.clone()))) .collect(); // 2 others × 3 keys = 6 deliver_key calls. assert_eq!(plan.len(), 6); // Each "other" gets every key. for c in ["0xb", "0xc"] { let ks: Vec<&String> = plan .iter() .filter(|(addr, _)| addr == c) .map(|(_, k)| k) .collect(); let expected: Vec<&String> = keys.iter().collect(); assert_eq!(ks, expected); } // Leader "0xa" never appears in the routing plan. assert!(plan.iter().all(|(addr, _)| addr != "0xa")); } #[test] fn clipboard_routes_ctrl_v_to_each_other_slot() { let slots = vec![ (1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000))), ]; let others = others_clients(&slots, 2); let plan: Vec<(&Client, &str)> = others.iter().map(|c| (c, "Ctrl+v")).collect(); assert_eq!(plan.len(), 2); assert_eq!(plan[0].0.address, "0xa"); assert_eq!(plan[1].0.address, "0xc"); } #[test] fn mirror_clicks_to_excludes_leader() { let slots = vec![ (1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0))), (3, client("0xc", (0, 1000))), ]; let plan = mirror_clicks_to(&slots, 2, 272); assert_eq!(plan.len(), 2); assert_eq!(plan[0].0.address, "0xa"); assert_eq!(plan[0].1, 272); assert_eq!(plan[1].0.address, "0xc"); assert_eq!(plan[1].1, 272); // Leader ("0xb") is never in the plan. assert!(plan.iter().all(|(c, _)| c.address != "0xb")); } #[test] fn mirror_clicks_to_is_empty_when_only_leader() { let slots = vec![(1, client("0xa", (0, 0)))]; assert!(mirror_clicks_to(&slots, 1, 273).is_empty()); } #[test] fn mirror_clicks_to_carries_button_code() { let slots = vec![ (1, client("0xa", (0, 0))), (2, client("0xb", (1000, 0))), ]; let plan = mirror_clicks_to(&slots, 1, 273); assert_eq!(plan.len(), 1); assert_eq!(plan[0].1, 273, "right-click survives the filter"); } }