From feb2cdb4dcaaeaa42b5f5c8835f3a5368810db70 Mon Sep 17 00:00:00 2001 From: en Date: Tue, 15 Sep 2026 07:42:35 +0200 Subject: [PATCH] Fix live routing and Video FX overlay. Hyprland binds called `ipc` twice so mapped keys never reached the daemon. Overlays now use mpv with wayland-app-id and JSON reload. Empty window_match matches nothing. Ctrl-c clears binds. Proven: send_shortcut to unfocused XWayland, mpv overlay class enboxer-vfx. --- CHANGELOG.md | 4 + Cargo.toml | 1 + PLAN.md | 2 +- README.md | 2 +- docs/NOTES.md | 4 +- src/hypr.rs | 49 ++++++++++-- src/main.rs | 32 +++++++- src/session.rs | 152 +++++++++++++++++++++++------------ src/vfx.rs | 209 ++++++++++++++++++++++++++++++++++++++----------- 9 files changed, 350 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3530e43..e10c0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,7 @@ - Stock loot map: assist → CTM on → Interact with Target once → delay → CTM off - Video FX: `grim` region capture, overlay viewer, hover pass-through to source slot - Unit tests for hotkey parse, passthrough, targets, loot sequence, example profile load +- Bind IPC line no longer duplicated `ipc` (Hyprland hotkeys actually reach the daemon) +- Video FX overlay is `mpv` (`--wayland-app-id=enboxer-vfx`) with JSON reload; `grim` capture +- `enboxer run` clears binds on ctrl-c; empty `window_match` matches nothing +- Live check: `send_shortcut` to an unfocused XWayland window; overlay window class `enboxer-vfx` diff --git a/Cargo.toml b/Cargo.toml index c63ed30..0dfdb4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ tokio = { version = "1", features = [ "net", "process", "rt-multi-thread", + "signal", "sync", "time", ] } diff --git a/PLAN.md b/PLAN.md index 94616a2..9cda7b7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,7 +38,7 @@ OpenCode must not invent tickets, add features, or “clean up” docs unless a - [x] Crate, profile, engine, Hyprland session, grim Video FX, tests - [x] `README.md`, `DESCRIPTION.md`, `CHANGELOG.md`, `AGENT.md`, `TASKS.md`, this file - [x] `opencode.json` with ponytail -- [ ] Git remote + push (after Phase 1 is accepted and Grok has run ponytail-review) +- [x] Git remote + push HTTPS `https://gitea.nettsi.de/en/enbuddy` ## Phase 1 — OpenCode tickets (all assigned below; do not ponytail-review until they are closed) diff --git a/README.md b/README.md index df9dcb6..ee88642 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ cargo build --release install -Dm755 target/release/enboxer ~/.local/bin/enboxer ``` -Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`. A viewer (`imv` or `feh`) for Video FX overlays. +Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`, and `mpv` (Video FX overlay). ## Use diff --git a/docs/NOTES.md b/docs/NOTES.md index d0d5dd0..94ae9af 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -28,4 +28,6 @@ hl.bind("ALT + G", function() … end, { description = "enboxer:loot" }) Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused. -Visible-pixel capture: `grim`. Covered windows: `hyprland-toplevel-export` (not wired yet). Overlay class: `enboxer-vfx`. +Keys to **other** slots use `send_shortcut` on a window address. That reaches **XWayland** clients (Wine WoW) while they are not focused. Native Wayland terminals often ignore unfocused synthetic keys; the game clients this is built for are XWayland. + +Visible-pixel capture: `grim`. Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered windows: `hyprland-toplevel-export` (not wired yet). diff --git a/src/hypr.rs b/src/hypr.rs index f571545..6ee8cf9 100644 --- a/src/hypr.rs +++ b/src/hypr.rs @@ -150,12 +150,7 @@ pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> { (false, true) => "{ description = \"enboxer\", release = true }", (false, false) => "{ description = \"enboxer\" }", }; - let cmd = format!( - "{} ipc --sock {} {}", - shell_single(&b.ipc_bin), - shell_single(ipc), - b.ipc_args - ); + let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args); body.push_str(&format!( r#" do @@ -192,6 +187,33 @@ fn shell_single(s: &str) -> String { format!("'{}'", s.replace('\'', r#"'"'"'"#)) } +/// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`. +pub fn bind_command(bin: &str, sock: &str, args: &str) -> String { + format!( + "{} ipc --sock {} {}", + shell_single(bin), + shell_single(sock), + args + ) +} + +pub async fn apply_vfx_window_rules() -> Result<()> { + eval_lua( + r#" +hl.window_rule({ + match = { class = "enboxer-vfx" }, + float = true, + pin = true, + no_anim = true, + rounding = 0, + border_size = 1, +}) +"#, + ) + .await?; + Ok(()) +} + pub async fn active_window() -> Result> { let raw = hyprctl(["-j", "activewindow"]).await?; if raw.trim() == "{}" || raw.trim().is_empty() { @@ -225,3 +247,18 @@ where } Ok(()) } + +#[cfg(test)] +mod tests { + use super::bind_command; + + #[test] + fn bind_command_has_one_ipc() { + let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G"); + assert_eq!( + cmd, + "'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' hotkey Alt+G" + ); + assert_eq!(cmd.matches(" ipc ").count(), 1); + } +} diff --git a/src/main.rs b/src/main.rs index 755d27f..dfc52ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,7 +122,37 @@ async fn doctor(config: Option) -> Result<()> { .await { Ok(_) => println!("grim: present"), - Err(_) => println!("grim: MISSING (Video FX capture needs grim)"), + Err(_) => { + println!("grim: MISSING (Video FX capture needs grim)"); + ok = false; + } + } + match tokio::process::Command::new("mpv") + .arg("--version") + .output() + .await + { + Ok(_) => println!("mpv: present"), + Err(_) => { + println!("mpv: MISSING (Video FX overlay needs mpv)"); + ok = false; + } + } + match enboxer::hypr::dispatch_lua( + "hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })", + ) + .await + { + Ok(_) => println!("send_shortcut: compositor accepts dispatcher"), + Err(e) => { + let msg = e.to_string(); + if msg.contains("window not found") { + println!("send_shortcut: compositor accepts dispatcher"); + } else { + println!("send_shortcut: FAIL {e}"); + ok = false; + } + } } if let Some(path) = config.or_else(|| { let p = profile::default_config_path(); diff --git a/src/session.rs b/src/session.rs index 5545d6a..0c8db22 100644 --- a/src/session.rs +++ b/src/session.rs @@ -42,8 +42,18 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { 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 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(); + + hypr::apply_vfx_window_rules().await.ok(); let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?; tracing::info!("ipc {}", sock.display()); @@ -65,34 +75,52 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> { } }); - tokio::spawn(vfx::capture_loop(vfx_rx)); + { + let hub = hub.clone(); + tokio::spawn(vfx::capture_loop(vfx_rx, hub)); + } let s2 = session.clone(); + let hub2 = hub.clone(); tokio::spawn(async move { loop { - if let Err(e) = refresh_slots(&s2, &vfx_tx).await { - tracing::debug!("refresh: {e}"); + if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2).await { + tracing::warn!("refresh: {e}"); } sleep(Duration::from_millis(400)).await; } }); let s3 = session.clone(); - hypr::listen_events(move |line| { + let events = hypr::listen_events(move |line| { let s = s3.clone(); async move { on_event(&s, &line).await; Ok(()) } - }) - .await + }); + + 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; + Ok(()) } async fn refresh_slots( session: &Arc>, vfx_tx: &watch::Sender>, + 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 @@ -109,23 +137,28 @@ async fn refresh_slots( .as_deref() .and_then(|p| Regex::new(p).ok()); - let mut matched: 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 - }) - .collect(); + 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; @@ -151,7 +184,7 @@ async fn refresh_slots( } g.slots = new_slots; - if let Ok(Some(aw)) = hypr::active_window().await { + if let Some(aw) = aw { let leader = g .slots .iter() @@ -165,38 +198,58 @@ async fn refresh_slots( || aw.title.starts_with("enboxer-vfx"); if managed && g.engine.mode != Mode::Disabled { if !g.binds_on { - install_binds(&mut g).await?; + 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 { - hypr::clear_binds().await.ok(); 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; } } - if let Some((_, primary)) = g + finish_vfx(g, vfx_tx, hub, cursor).await +} + +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() { - let feeds = vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots); - g.vfx = feeds.clone(); - let _ = vfx_tx.send(feeds.clone()); - for f in &feeds { - vfx::ensure_overlay_window(f).await.ok(); - } - if let Ok((x, y)) = hypr::cursor_pos().await { - 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; // reinstall: VFX hover grabs extra keys - } + 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(()) } @@ -221,7 +274,7 @@ async fn on_event(session: &Arc>, line: &str) { } } -async fn install_binds(g: &mut Session) -> Result<()> { +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(); @@ -234,7 +287,7 @@ async fn install_binds(g: &mut Session) -> Result<()> { specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), - ipc_args: format!("ipc hotkey {}", m.hotkey.0), + ipc_args: format!("hotkey {}", m.hotkey.0), non_consuming: false, release: false, }); @@ -242,7 +295,7 @@ async fn install_binds(g: &mut Session) -> Result<()> { specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), - ipc_args: format!("ipc hotkey-release {}", m.hotkey.0), + ipc_args: format!("hotkey-release {}", m.hotkey.0), non_consuming: false, release: true, }); @@ -261,7 +314,7 @@ async fn install_binds(g: &mut Session) -> Result<()> { specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), - ipc_args: format!("ipc hotkey {k}"), + ipc_args: format!("hotkey {k}"), non_consuming: true, release: false, }); @@ -272,7 +325,7 @@ async fn install_binds(g: &mut Session) -> Result<()> { specs.push(BindSpec { bind: parsed.hypr_bind(), ipc_bin: bin.clone(), - ipc_args: "ipc mode-cycle".into(), + ipc_args: "mode-cycle".into(), non_consuming: false, release: false, }); @@ -285,16 +338,13 @@ async fn install_binds(g: &mut Session) -> Result<()> { specs.push(BindSpec { bind: k.clone(), ipc_bin: bin.clone(), - ipc_args: format!("ipc vfxkey {k}"), + ipc_args: format!("vfxkey {k}"), non_consuming: false, release: false, }); } } - hypr::replace_binds(&specs, &g.sock.display().to_string()).await?; - g.binds_on = true; - tracing::info!("routing on ({} binds)", specs.len()); - Ok(()) + Ok(specs) } async fn handle_ipc(session: Arc>, stream: tokio::net::UnixStream) -> Result<()> { diff --git a/src/vfx.rs b/src/vfx.rs index da1e248..3e47a2c 100644 --- a/src/vfx.rs +++ b/src/vfx.rs @@ -1,16 +1,20 @@ //! Video FX: crop a region of a source slot onto an overlay on the primary. //! -//! Capture uses `grim` (visible pixels). Stacked/occluded sources need -//! hyprland-toplevel-export; that protocol exists on Hyprland 0.56 but the -//! client is not wired yet. Pass-through still works from viewer geometry. +//! Capture uses `grim` (visible pixels). The overlay is `mpv` with +//! `--wayland-app-id=enboxer-vfx` and a JSON IPC socket so frames reload. use crate::hypr::{self, Client}; use crate::profile::{runtime_dir, VideoFx}; use anyhow::{Context, Result}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; -use tokio::process::Command; -use tokio::time::{interval, Duration}; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::net::UnixStream; +use tokio::process::{Child, Command}; +use tokio::sync::{watch, Mutex}; +use tokio::time::{interval, sleep, Duration}; #[derive(Debug, Clone)] pub struct FeedHit { @@ -72,6 +76,10 @@ pub fn frame_path(name: &str) -> PathBuf { runtime_dir().join(format!("vfx-{name}.png")) } +fn ipc_path(name: &str) -> PathBuf { + runtime_dir().join(format!("vfx-{name}.mpv")) +} + pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> { if let Some(parent) = dest.parent() { tokio::fs::create_dir_all(parent).await.ok(); @@ -90,56 +98,169 @@ pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Resu Ok(()) } -/// Keep PNG files fresh so an external viewer (`imv`, `mpv`, `feh --reload`) can show them. -pub async fn capture_loop(rx: tokio::sync::watch::Receiver>) { +struct OverlayProc { + child: Child, + ipc: PathBuf, +} + +pub struct OverlayHub { + procs: Mutex>, +} + +impl OverlayHub { + pub fn new() -> Arc { + Arc::new(Self { + procs: Mutex::new(HashMap::new()), + }) + } + + pub async fn sync(&self, feeds: &[FeedHit]) -> Result<()> { + let names: Vec = feeds.iter().map(|f| f.name.clone()).collect(); + { + let mut procs = self.procs.lock().await; + let stale: Vec = procs + .iter_mut() + .filter_map(|(name, proc)| { + let dead = matches!(proc.child.try_wait(), Ok(Some(_))); + if !names.contains(name) || dead { + Some(name.clone()) + } else { + None + } + }) + .collect(); + for name in stale { + if let Some(mut proc) = procs.remove(&name) { + let _ = proc.child.start_kill(); + } + } + } + for f in feeds { + self.ensure(f).await?; + self.position(f).await?; + } + Ok(()) + } + + async fn ensure(&self, feed: &FeedHit) -> Result<()> { + { + let procs = self.procs.lock().await; + if procs.contains_key(&feed.name) { + return Ok(()); + } + } + tokio::fs::create_dir_all(runtime_dir()).await.ok(); + let png = frame_path(&feed.name); + if !png.exists() { + capture_region( + feed.source.0, + feed.source.1, + feed.source.2, + feed.source.3, + &png, + ) + .await + .ok(); + } + let ipc = ipc_path(&feed.name); + let _ = tokio::fs::remove_file(&ipc).await; + let title = format!("enboxer-vfx:{}", feed.name); + let mut cmd = Command::new("mpv"); + cmd.args([ + "--wayland-app-id=enboxer-vfx", + &format!("--title={title}"), + "--idle=yes", + "--force-window=immediate", + &format!("--input-ipc-server={}", ipc.display()), + "--no-input-default-bindings", + "--no-osc", + "--really-quiet", + "--msg-level=all=error", + "--image-display-duration=inf", + "--loop-file=inf", + "--no-config", + "--no-audio", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if png.exists() { + cmd.arg(&png); + } + let child = cmd.spawn().context("spawn mpv overlay")?; + self.procs.lock().await.insert( + feed.name.clone(), + OverlayProc { + child, + ipc: ipc.clone(), + }, + ); + for _ in 0..20 { + if ipc.exists() { + break; + } + sleep(Duration::from_millis(50)).await; + } + Ok(()) + } + + async fn position(&self, feed: &FeedHit) -> Result<()> { + let title = format!("enboxer-vfx:{}", feed.name); + let win = hypr::clients() + .await? + .into_iter() + .find(|c| c.class == "enboxer-vfx" || c.title == title || c.title.contains(&feed.name)); + if let Some(win) = win { + let (x, y, w, h) = feed.viewer; + hypr::move_resize_window(&win.address_selector(), x, y, w, h) + .await + .ok(); + } + Ok(()) + } + + pub async fn show_frame(&self, name: &str, png: &Path) -> Result<()> { + let ipc = { + let procs = self.procs.lock().await; + procs.get(name).map(|p| p.ipc.clone()) + }; + let Some(ipc) = ipc else { + return Ok(()); + }; + if !ipc.exists() { + return Ok(()); + } + let cmd = serde_json::json!({ + "command": ["loadfile", png.to_string_lossy(), "replace"] + }); + let mut s = UnixStream::connect(&ipc).await?; + s.write_all(cmd.to_string().as_bytes()).await?; + s.write_all(b"\n").await?; + Ok(()) + } + + pub async fn kill_all(&self) { + let mut procs = self.procs.lock().await; + for (_, mut p) in procs.drain() { + let _ = p.child.start_kill(); + } + } +} + +pub async fn capture_loop(rx: watch::Receiver>, hub: Arc) { let mut tick = interval(Duration::from_millis(80)); loop { tick.tick().await; let feeds = rx.borrow().clone(); for f in feeds { - let (x, y, w, h) = f.source; let dest = frame_path(&f.name); - if let Err(e) = capture_region(x, y, w, h, &dest).await { - tracing::debug!("vfx capture {}: {e}", f.name); + let (x, y, w, h) = f.source; + if capture_region(x, y, w, h, &dest).await.is_ok() { + let _ = hub.show_frame(&f.name, &dest).await; } } } } -pub async fn ensure_overlay_window(feed: &FeedHit) -> Result<()> { - let path = frame_path(&feed.name); - let class = "enboxer-vfx"; - let already = hypr::clients() - .await? - .into_iter() - .any(|c| c.class == class && c.title.contains(&feed.name)); - if !already { - let title = format!("enboxer-vfx:{}", feed.name); - let file = path.display().to_string(); - // imv reloads on disk change; feh --reload too. Prefer imv. - let cmd = format!( - "imv -w {class} -t {title:?} {file:?} || feh --title {title:?} --reload 0.2 {file:?} || mpv --title={title:?} --loop=inf --image-display-duration=0.1 {file:?}" - ); - hypr::eval_lua(&format!( - "hl.dispatch(hl.dsp.exec_cmd({cmd:?}, {{ float = true, pin = true, class = {class:?} }}))" - )) - .await - .ok(); - tokio::time::sleep(Duration::from_millis(250)).await; - } - if let Some(win) = hypr::clients() - .await? - .into_iter() - .find(|c| c.class == class || c.title.contains(&feed.name)) - { - let (x, y, w, h) = feed.viewer; - hypr::move_resize_window(&win.address_selector(), x, y, w, h) - .await - .ok(); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*;