enBoxer/src/session.rs
en 91cfee9609 Warn on malformed arm_auto_apply regex instead of silently falling through
Both window_match.class and window_match.title patterns are now logged
as warnings when regex::Regex::new returns Err. Before, .ok() silently
swallowed compile errors and treated a bad pattern as 'not configured',
which collapsed back to the original bug: any open window could fire
layout-apply.

The no-configured-patterns fallback (accept a window with a non-empty
class) is preserved for users who haven't set window_match at all.
2026-09-16 07:59:56 +02:00

1183 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<FeedHit>,
pub vfx_source: Option<u32>,
pub mouse_broadcast: bool,
pub mouse_follow: bool,
}
impl Session {
pub fn new(profile: Profile, exe: PathBuf, sock: PathBuf) -> Result<Self> {
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<Mutex<Session>>,
vfx_tx: &watch::Sender<Vec<FeedHit>>,
hub: &Arc<vfx::OverlayHub>,
slot_hub: &Arc<Mutex<OverlayHub>>,
) -> 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<Client> = 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<Mutex<Session>>,
slot_hub: &Arc<Mutex<OverlayHub>>,
) {
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<Vec<FeedHit>>,
hub: &Arc<vfx::OverlayHub>,
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<Mutex<Session>>, 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<Vec<BindSpec>> {
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<Mutex<Session>>, 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<Mutex<Session>>, 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::<u32>() {
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::<u32>() {
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::<u32>() {
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::<u32>().ok())
.unwrap_or(272);
wm_ok(broadcast_click(session, btn).await)
}
"mirror-click" => {
let btn = arg
.strip_prefix("mouse:")
.and_then(|s| s.parse::<u32>().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<Mutex<Session>>, name: Option<&str>) -> Result<Mode> {
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<Mutex<Session>>) -> 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<Mutex<Session>>) -> 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<Mutex<Session>>, 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<Mutex<Session>>, 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<Mutex<Session>>, 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<Mutex<Session>>) -> 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(())
}
async fn toggle_pin(session: &Arc<Mutex<Session>>) -> Result<()> {
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<Mutex<Session>>) -> 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<Mutex<Session>>) -> 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<Mutex<Session>>, 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<Client> {
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<Mutex<Session>>) -> 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<String> {
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<Mutex<Session>>, 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::<Vec<_>>()
};
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<Mutex<Session>>, 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<String> {
vfx_hover_keys()
.into_iter()
.filter(|k| !k.starts_with("mouse:"))
.collect()
}
fn vfx_hover_keys() -> Vec<String> {
let mut v: Vec<String> = [
"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<Mutex<Session>>, 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<Mutex<Session>>, 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<Mutex<Session>>, 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<Action>, 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<String> {
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");
}
}