//! Hyprland 0.56 IPC: JSON queries + Lua eval/dispatch. No game injection. use crate::hotkey::ParsedHotkey; use anyhow::{bail, Context, Result}; use serde::Deserialize; use std::path::PathBuf; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::net::UnixStream; use tokio::process::Command; #[derive(Debug, Clone, Deserialize)] pub struct Client { pub address: String, pub class: String, pub title: String, pub pid: i32, pub at: [i32; 2], pub size: [i32; 2], #[serde(default)] pub mapped: bool, #[serde(default)] pub hidden: bool, #[serde(default)] pub xwayland: bool, #[serde(rename = "focusHistoryID", default)] pub focus_history_id: i64, } impl Client { pub fn address_selector(&self) -> String { let a = self.address.trim(); if a.starts_with("address:") { a.to_string() } else { format!("address:{a}") } } } pub async fn clients() -> Result> { let raw = hyprctl(["-j", "clients"]).await?; serde_json::from_str(&raw).context("parse hyprctl clients") } pub async fn cursor_pos() -> Result<(i32, i32)> { let raw = hyprctl(["-j", "cursorpos"]).await?; #[derive(Deserialize)] struct P { x: i32, y: i32, } let p: P = serde_json::from_str(&raw).context("cursorpos")?; Ok((p.x, p.y)) } pub async fn hyprctl(args: I) -> Result where I: IntoIterator, S: AsRef, { let out = Command::new("hyprctl") .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .context("spawn hyprctl")?; if !out.status.success() { bail!("hyprctl failed: {}", String::from_utf8_lossy(&out.stderr)); } Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } pub async fn eval_lua(code: &str) -> Result { hyprctl(["eval", code]).await } pub async fn dispatch_lua(expr: &str) -> Result { hyprctl(["dispatch", expr]).await } pub fn send_shortcut_expr(window: &str, parsed: &ParsedHotkey) -> String { let mods = parsed.hypr_mods(); format!( "hl.dsp.send_shortcut({{ window = {window:?}, mods = {mods:?}, key = {:?} }})", parsed.hypr_key() ) } pub fn send_key_state_expr(window: &str, parsed: &ParsedHotkey, state: &str) -> String { let mods = parsed.hypr_mods(); format!( "hl.dsp.send_key_state({{ window = {window:?}, mods = {mods:?}, key = {:?}, state = {state:?} }})", parsed.hypr_key() ) } pub async fn send_key(window: &str, combo: &str, state: Option<&str>) -> Result<()> { let parsed = crate::hotkey::parse(combo)?; let expr = match state { Some(s) => send_key_state_expr(window, &parsed, s), None => send_shortcut_expr(window, &parsed), }; dispatch_lua(&expr).await?; Ok(()) } pub async fn focus_window(selector: &str) -> Result<()> { dispatch_lua(&format!("hl.dsp.focus({{ window = {selector:?} }})")).await?; Ok(()) } /// XWayland (Wine) accepts keys while unfocused. Native Wayland often does not, /// so we briefly focus, send, then restore. pub async fn deliver_key(client: &Client, combo: &str, state: Option<&str>) -> Result<()> { let sel = client.address_selector(); if client.xwayland { return send_key(&sel, combo, state).await; } let prev = active_window().await.ok().flatten(); let _ = focus_window(&sel).await; send_key(&sel, combo, state).await?; if let Some(p) = prev { if p.address != client.address { let _ = focus_window(&p.address_selector()).await; } } Ok(()) } pub async fn deliver_click(client: &Client, button: u32) -> Result<()> { let sel = client.address_selector(); if client.xwayland { return send_mouse_click(&sel, button).await; } let prev = active_window().await.ok().flatten(); let _ = focus_window(&sel).await; send_mouse_click(&sel, button).await?; if let Some(p) = prev { if p.address != client.address { let _ = focus_window(&p.address_selector()).await; } } Ok(()) } pub async fn send_mouse_click(window: &str, button: u32) -> Result<()> { // 272 = BTN_LEFT, 273 = BTN_RIGHT let expr = format!( "hl.dsp.send_shortcut({{ window = {window:?}, mods = \"\", key = \"mouse:{button}\" }})" ); dispatch_lua(&expr).await?; Ok(()) } pub async fn move_cursor(x: i32, y: i32) -> Result<()> { dispatch_lua(&format!("hl.dsp.cursor.move({{ x = {x}, y = {y} }})")).await?; Ok(()) } pub async fn move_resize_window(selector: &str, x: i32, y: i32, w: i32, h: i32) -> Result<()> { dispatch_lua(&format!( "hl.dsp.window.move({{ window = {selector:?}, x = {x}, y = {y}, relative = false }})" )) .await?; dispatch_lua(&format!( "hl.dsp.window.resize({{ window = {selector:?}, x = {w}, y = {h}, relative = false }})" )) .await?; Ok(()) } const CLEAR_BINDS: &str = r#" _G.enboxer = _G.enboxer or { binds = {} } for _, b in ipairs(_G.enboxer.binds) do pcall(function() b:remove() end) end _G.enboxer.binds = {} "#; /// Install or replace the enBoxer bind table in Hyprland's Lua VM. pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> { let mut body = String::from(CLEAR_BINDS); for b in binds { let opts = match (b.non_consuming, b.release) { (true, true) => "{ description = \"enboxer\", non_consuming = true, release = true }", (true, false) => "{ description = \"enboxer\", non_consuming = true }", (false, true) => "{ description = \"enboxer\", release = true }", (false, false) => "{ description = \"enboxer\" }", }; let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args); body.push_str(&format!( r#" do local b = hl.bind({bind:?}, function() hl.dispatch(hl.dsp.exec_cmd({cmd:?})) end, {opts}) table.insert(_G.enboxer.binds, b) end "#, bind = b.bind, cmd = cmd, opts = opts, )); } eval_lua(&body).await?; Ok(()) } pub async fn clear_binds() -> Result<()> { eval_lua(CLEAR_BINDS).await?; Ok(()) } #[derive(Debug, Clone)] pub struct BindSpec { pub bind: String, pub ipc_bin: String, pub ipc_args: String, pub non_consuming: bool, pub release: bool, } 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`. /// `bin`, `sock`, and `args` are all single-quoted so a map hotkey containing /// shell metacharacters (`;`, backticks, `$()`, quotes, spaces) cannot inject /// into the compositor's `exec_cmd`. pub fn bind_command(bin: &str, sock: &str, args: &str) -> String { format!( "{} ipc --sock {} {}", shell_single(bin), shell_single(sock), shell_single(args) ) } pub async fn notify(text: &str) -> Result<()> { // 1 = info icon; 2500 ms let _ = hyprctl(["notify", "1", "2500", "rgb(88aaff)", text]).await; Ok(()) } pub async fn apply_borderless_rules(class_re: &str) -> Result<()> { if class_re.is_empty() { return Ok(()); } let expr = format!( r#" hl.window_rule({{ match = {{ class = {class_re:?} }}, rounding = 0, border_size = 0, }}) "# ); eval_lua(&expr).await?; Ok(()) } 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() { return Ok(None); } Ok(serde_json::from_str(&raw).ok()) } pub fn socket2_path() -> Result { let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") .context("HYPRLAND_INSTANCE_SIGNATURE not set — not in a Hyprland session")?; let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); Ok(PathBuf::from(runtime) .join("hypr") .join(sig) .join(".socket2.sock")) } pub async fn listen_events(mut on_line: F) -> Result<()> where F: FnMut(String) -> Fut, Fut: std::future::Future>, { let path = socket2_path()?; let stream = UnixStream::connect(&path) .await .with_context(|| format!("connect {}", path.display()))?; let mut lines = BufReader::new(stream).lines(); while let Some(line) = lines.next_line().await? { on_line(line).await?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[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); } #[test] fn bind_command_quotes_args_with_metacharacters() { // shell_single must escape a hostile arg (semicolon, backtick, // dollar-paren) so it cannot inject into Hyprland's exec_cmd. let s = bind_command( "enboxer", "/tmp/enboxer.sock", "hotkey Alt+G; cat /etc/passwd", ); assert!(s.starts_with("'enboxer' ipc --sock '/tmp/enboxer.sock' ")); assert!(s.contains("'hotkey Alt+G; cat /etc/passwd'")); } }