//! 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 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 = format!( "{} ipc --sock {} {}", shell_single(&b.ipc_bin), shell_single(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#"'"'"'"#)) } 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(()) }