use std::path::PathBuf;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt, BufReader},
net::UnixStream,
};
use tracing::debug;
use crate::error::{Result, StutterError};
#[derive(Debug, serde::Deserialize)]
pub struct ActiveWindow {
pub pid: u32,
pub address: String,
pub class: String,
}
pub fn get_socket_path(name: &str) -> Result<PathBuf> {
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").map_err(|_| StutterError::NoRuntimeDir)?;
let sig = std::env::var("HYPRLAND_INSTANCE_SIGNATURE").map_err(|_| StutterError::NoInstanceSignature)?;
Ok(PathBuf::from(runtime_dir).join("hypr").join(sig).join(name))
}
pub async fn connect_events(path: &std::path::Path) -> Result<BufReader<UnixStream>> {
let stream = UnixStream::connect(path).await?;
Ok(BufReader::new(stream))
}
pub async fn get_active_window(path: &std::path::Path) -> Result<(u32, String, String)> {
let mut stream = UnixStream::connect(path).await?;
stream.write_all(b"j/activewindow").await?;
let mut buf = String::new();
stream.read_to_string(&mut buf).await?;
let buf = buf.trim();
debug!(raw_response = buf);
if buf.is_empty() || buf == "{}" || buf == "unknown request" {
return Err(StutterError::NoActiveWindow);
}
let window: ActiveWindow = serde_json::from_str(buf)?;
if window.pid == 0 {
return Err(StutterError::NoActiveWindow);
}
Ok((
window.pid,
window.address.trim_start_matches("0x").to_owned(),
window.class,
))
}
use super::{FocusChange, FocusEvent, WmBackend};
pub struct HyprlandBackend {
reader: tokio::io::BufReader<tokio::net::UnixStream>,
cmd_socket_path: std::path::PathBuf,
line: String,
}
impl HyprlandBackend {
pub async fn connect() -> crate::error::Result<Self> {
let event_path = get_socket_path(".socket2.sock")?;
let cmd_socket_path = get_socket_path(".socket.sock")?;
let reader = connect_events(&event_path).await?;
Ok(Self {
reader,
cmd_socket_path,
line: String::new(),
})
}
}
impl WmBackend for HyprlandBackend {
async fn next_focus_event(&mut self) -> crate::error::Result<Option<FocusChange>> {
use tokio::io::AsyncBufReadExt;
loop {
self.line.clear();
let n = self.reader.read_line(&mut self.line).await?;
if n == 0 {
return Ok(None); }
let event = self.line.trim_end();
if event.starts_with("activewindow>>") {
match get_active_window(&self.cmd_socket_path).await {
Ok((pid, addr, class)) => {
return Ok(Some(FocusChange::Focused(FocusEvent { pid, addr, class })));
}
Err(crate::error::StutterError::NoActiveWindow) => {
return Ok(Some(FocusChange::Unfocused));
}
Err(e) => return Err(e),
}
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
fn parse_window(json: &str) -> Result<(u32, String, String)> {
let buf = json.trim();
if buf.is_empty() || buf == "{}" || buf == "unknown request" {
return Err(StutterError::NoActiveWindow);
}
let window: ActiveWindow = serde_json::from_str(buf)?;
if window.pid == 0 {
return Err(StutterError::NoActiveWindow);
}
Ok((
window.pid,
window.address.trim_start_matches("0x").to_owned(),
window.class,
))
}
#[test]
fn parses_active_window() {
let json = r#"{"pid":1234,"address":"0xdeadbeef","class":"kitty","title":"~"}"#;
let (pid, addr, class) = parse_window(json).unwrap();
assert_eq!(pid, 1234);
assert_eq!(addr, "deadbeef");
assert_eq!(class, "kitty");
}
#[test]
fn empty_response_is_no_active_window() {
assert!(matches!(parse_window("{}"), Err(StutterError::NoActiveWindow)));
assert!(matches!(parse_window(""), Err(StutterError::NoActiveWindow)));
assert!(matches!(
parse_window("unknown request"),
Err(StutterError::NoActiveWindow)
));
}
#[test]
fn pid_zero_is_no_active_window() {
let json = r#"{"pid":0,"address":"0x0","class":"","title":""}"#;
assert!(matches!(parse_window(json), Err(StutterError::NoActiveWindow)));
}
#[test]
fn address_strips_0x_prefix() {
let json = r#"{"pid":99,"address":"0xABCD","class":"x","title":"y"}"#;
let (_, addr, _) = parse_window(json).unwrap();
assert_eq!(addr, "ABCD");
}
#[test]
fn address_without_0x_prefix_unchanged() {
let json = r#"{"pid":1,"address":"ABCD","class":"x","title":"y"}"#;
let (_, addr, _) = parse_window(json).unwrap();
assert_eq!(addr, "ABCD");
}
}