use std::env;
use std::error::Error;
use std::io;
use std::path::{Path, PathBuf};
use log::warn;
use serde::Deserialize;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use crate::widget::{ActiveWindow, Command, Msg, Workspaces};
use crate::command::CommandReceiver;
use crate::producer::{MsgSender, Producer, ProducerFuture, ProducerResult};
#[derive(Deserialize)]
struct RawWorkspace {
id: i32,
#[serde(default)]
monitor: Option<String>,
}
#[derive(Deserialize)]
struct RawActiveWorkspace {
id: i32,
}
#[derive(Deserialize)]
struct RawMonitor {
name: String,
#[serde(rename = "activeWorkspace")]
active_workspace: RawActiveWorkspace,
}
pub fn parse_workspace_entries(json: &str) -> serde_json::Result<Vec<(i32, Option<String>)>> {
let raw: Vec<RawWorkspace> = serde_json::from_str(json)?;
Ok(raw.into_iter().map(|w| (w.id, w.monitor)).collect())
}
pub fn parse_workspaces(json: &str) -> serde_json::Result<Vec<i32>> {
Ok(parse_workspace_entries(json)?
.into_iter()
.map(|(id, _)| id)
.collect())
}
pub fn parse_active(json: &str) -> serde_json::Result<i32> {
let raw: RawActiveWorkspace = serde_json::from_str(json)?;
Ok(raw.id)
}
pub fn parse_monitors(json: &str) -> serde_json::Result<Vec<(String, i32)>> {
let raw: Vec<RawMonitor> = serde_json::from_str(json)?;
Ok(raw
.into_iter()
.map(|m| (m.name, m.active_workspace.id))
.collect())
}
#[derive(Deserialize)]
struct RawActiveWindow {
#[serde(default)]
class: String,
#[serde(default)]
title: String,
}
pub fn parse_active_window(json: &str) -> serde_json::Result<ActiveWindow> {
let raw: RawActiveWindow = serde_json::from_str(json)?;
Ok(ActiveWindow::new(raw.class, raw.title))
}
pub fn parse_activewindow_stream(line: &str) -> Option<(String, String)> {
let (name, rest) = line.split_once(">>")?;
if name != "activewindow" && name != "activewindowv2" {
return None;
}
let (class, title) = rest.split_once(',')?;
Some((class.to_string(), title.to_string()))
}
pub fn snapshot_from_json(
workspaces_json: &str,
active_json: &str,
) -> serde_json::Result<Workspaces> {
let ids = parse_workspaces(workspaces_json)?;
let active = parse_active(active_json)?;
Ok(Workspaces::new(ids, active))
}
pub fn snapshot_with_monitors(
workspaces_json: &str,
active_json: &str,
monitors_json: &str,
) -> serde_json::Result<Workspaces> {
let workspaces: Vec<(i32, String)> = parse_workspace_entries(workspaces_json)?
.into_iter()
.filter_map(|(id, monitor)| monitor.map(|m| (id, m)))
.collect();
let focused = parse_active(active_json)?;
let actives = parse_monitors(monitors_json)?;
Ok(Workspaces::with_monitors(workspaces, actives, focused))
}
pub fn dispatch_request(command: &Command) -> Option<String> {
match command {
Command::SwitchWorkspace(id) => {
Some(format!("dispatch hl.dsp.focus({{ workspace = {id} }})"))
}
_ => None,
}
}
pub async fn run_commands(mut commands: CommandReceiver) -> ProducerResult {
let dir = resolve_socket_dir()?;
while let Some(command) = commands.recv().await {
let Some(request) = dispatch_request(&command) else {
continue;
};
if let Err(e) = query(&dir, &request).await {
warn!("hyprland: command {request:?} failed: {e}");
}
}
Ok(())
}
fn socket_dir(base: &str, signature: &str) -> PathBuf {
Path::new(base).join("hypr").join(signature)
}
fn is_workspace_event(line: &str) -> bool {
let name = line.split(">>").next().unwrap_or("");
name.contains("workspace") || name == "focusedmon" || name == "activespecial"
}
pub fn is_focus_or_lifecycle_event(name: &str) -> bool {
matches!(
name,
"activewindow" | "activewindowv2" | "openwindow" | "closewindow" | "focusedmon"
)
}
pub fn parse_focusedmon(line: &str) -> Option<String> {
let (name, rest) = line.split_once(">>")?;
if name != "focusedmon" {
return None;
}
let (monitor, _ws_id) = rest.split_once(',')?;
Some(monitor.to_string())
}
fn resolve_socket_dir() -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.map_err(|_| "HYPRLAND_INSTANCE_SIGNATURE is unset; not running under Hyprland")?;
if let Ok(runtime) = env::var("XDG_RUNTIME_DIR") {
let dir = socket_dir(&runtime, &signature);
if dir.exists() {
return Ok(dir);
}
}
let legacy = socket_dir("/tmp", &signature);
if legacy.exists() {
return Ok(legacy);
}
Err(format!("no Hyprland socket directory found for signature {signature}").into())
}
async fn query(dir: &Path, request: &str) -> io::Result<String> {
let mut stream = UnixStream::connect(dir.join(".socket.sock")).await?;
stream.write_all(request.as_bytes()).await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
Ok(response)
}
async fn fetch_snapshot(dir: &Path) -> Result<Workspaces, Box<dyn Error + Send + Sync>> {
let workspaces_json = query(dir, "j/workspaces").await?;
let active_json = query(dir, "j/activeworkspace").await?;
let monitors_json = query(dir, "j/monitors").await?;
Ok(snapshot_with_monitors(
&workspaces_json,
&active_json,
&monitors_json,
)?)
}
async fn fetch_activewindow(dir: &Path) -> Result<ActiveWindow, Box<dyn Error + Send + Sync>> {
let json = query(dir, "j/activewindow").await?;
Ok(parse_active_window(&json)?)
}
async fn fetch_initial_focused_activewindow(
dir: &Path,
) -> Result<(String, ActiveWindow), Box<dyn Error + Send + Sync>> {
let active_ws_json = query(dir, "j/activeworkspace").await?;
let active_ws_id = parse_active(&active_ws_json)?;
let monitors_json = query(dir, "j/monitors").await?;
let monitors = parse_monitors(&monitors_json)?;
let focused = monitors
.iter()
.find(|(_, ws)| *ws == active_ws_id)
.map(|(name, _)| name.clone())
.ok_or("no monitor matches the globally active workspace")?;
let active_json = query(dir, "j/activewindow").await?;
let window = parse_active_window(&active_json)?;
Ok((focused, window))
}
pub struct HyprlandProducer;
impl HyprlandProducer {
pub fn new() -> Self {
Self
}
}
impl Default for HyprlandProducer {
fn default() -> Self {
Self::new()
}
}
impl Producer for HyprlandProducer {
fn name(&self) -> String {
"hyprland".to_string()
}
fn run(self: Box<Self>, tx: MsgSender) -> ProducerFuture {
Box::pin(run(tx))
}
}
async fn run(tx: MsgSender) -> ProducerResult {
let dir = resolve_socket_dir()?;
if let Ok(snapshot) = fetch_snapshot(&dir).await {
if tx.send(Msg::Workspaces(snapshot)).is_err() {
return Ok(());
}
} else {
warn!("hyprland: initial workspace query failed");
}
let mut last_focused_monitor: Option<String> = None;
if let Ok((monitor, window)) = fetch_initial_focused_activewindow(&dir).await {
last_focused_monitor = Some(monitor.clone());
let window = (!window.is_empty()).then_some(window);
if tx.send(Msg::ActiveWindow { monitor, window }).is_err() {
return Ok(());
}
} else {
warn!("hyprland: initial activewindow query failed");
}
let events = UnixStream::connect(dir.join(".socket2.sock")).await?;
let mut lines = BufReader::new(events).lines();
while let Some(line) = lines.next_line().await? {
let name = line.split(">>").next().unwrap_or("");
if is_workspace_event(&line) {
if let Ok(snapshot) = fetch_snapshot(&dir).await {
if tx.send(Msg::Workspaces(snapshot)).is_err() {
return Ok(());
}
} else {
warn!("hyprland: workspace refresh failed");
}
}
if !is_focus_or_lifecycle_event(name) {
continue;
}
match name {
"focusedmon" => {
if let Some(mon) = parse_focusedmon(&line) {
last_focused_monitor = Some(mon.clone());
if let Ok(window) = fetch_activewindow(&dir).await {
let window = (!window.is_empty()).then_some(window);
if tx
.send(Msg::ActiveWindow {
monitor: mon,
window,
})
.is_err()
{
return Ok(());
}
} else {
warn!("hyprland: focusedmon activewindow refresh failed");
}
}
}
"activewindow" | "activewindowv2" => {
let Some(monitor) = last_focused_monitor.clone() else {
continue;
};
let Some((class, title)) = parse_activewindow_stream(&line) else {
continue;
};
if tx
.send(Msg::ActiveWindow {
monitor,
window: Some(ActiveWindow::new(class, title)),
})
.is_err()
{
return Ok(());
}
}
"openwindow" | "closewindow" => {
let Some(monitor) = last_focused_monitor.clone() else {
continue;
};
if let Ok(window) = fetch_activewindow(&dir).await {
let window = (!window.is_empty()).then_some(window);
if tx.send(Msg::ActiveWindow { monitor, window }).is_err() {
return Ok(());
}
} else {
warn!("hyprland: openwindow/closewindow refresh failed");
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const WORKSPACES_JSON: &str = r#"[
{"id": 3, "name": "3", "monitor": "DP-1", "windows": 2},
{"id": 1, "name": "1", "monitor": "DP-1", "windows": 5},
{"id": 2, "name": "2", "monitor": "DP-1", "windows": 0}
]"#;
const ACTIVE_JSON: &str = r#"{"id": 2, "name": "2", "monitor": "DP-1"}"#;
#[test]
fn parse_workspaces_extracts_ids_ignoring_other_fields() {
let ids = parse_workspaces(WORKSPACES_JSON).expect("valid json");
assert_eq!(ids, vec![3, 1, 2]);
}
#[test]
fn parse_active_extracts_the_active_id() {
assert_eq!(parse_active(ACTIVE_JSON).expect("valid json"), 2);
}
#[test]
fn snapshot_from_json_normalizes_into_a_workspaces() {
let snapshot = snapshot_from_json(WORKSPACES_JSON, ACTIVE_JSON).expect("valid json");
assert_eq!(snapshot.ids(), &[1, 2, 3]);
assert_eq!(snapshot.active(), 2);
assert_eq!(snapshot.label(), "1 [2] 3");
}
const MULTI_WORKSPACES_JSON: &str = r#"[
{"id": 2, "name": "2", "monitor": "DP-1", "windows": 1},
{"id": 6, "name": "6", "monitor": "HDMI-A-1", "windows": 0},
{"id": 1, "name": "1", "monitor": "DP-1", "windows": 4},
{"id": 5, "name": "5", "monitor": "HDMI-A-1", "windows": 2}
]"#;
const MONITORS_JSON: &str = r#"[
{"id": 0, "name": "DP-1", "activeWorkspace": {"id": 2, "name": "2"}},
{"id": 1, "name": "HDMI-A-1", "activeWorkspace": {"id": 5, "name": "5"}}
]"#;
#[test]
fn parse_workspace_entries_pairs_each_id_with_its_monitor() {
let entries = parse_workspace_entries(MULTI_WORKSPACES_JSON).expect("valid json");
assert_eq!(
entries,
vec![
(2, Some("DP-1".to_string())),
(6, Some("HDMI-A-1".to_string())),
(1, Some("DP-1".to_string())),
(5, Some("HDMI-A-1".to_string())),
]
);
}
#[test]
fn parse_monitors_extracts_each_monitors_active_workspace() {
let monitors = parse_monitors(MONITORS_JSON).expect("valid json");
assert_eq!(
monitors,
vec![("DP-1".to_string(), 2), ("HDMI-A-1".to_string(), 5)]
);
}
#[test]
fn snapshot_with_monitors_scopes_each_monitors_workspaces() {
let snapshot = snapshot_with_monitors(MULTI_WORKSPACES_JSON, ACTIVE_JSON, MONITORS_JSON)
.expect("valid json");
assert_eq!(snapshot.ids_for("DP-1"), vec![1, 2]);
assert_eq!(snapshot.active_for("DP-1"), Some(2));
assert_eq!(snapshot.ids_for("HDMI-A-1"), vec![5, 6]);
assert_eq!(snapshot.active_for("HDMI-A-1"), Some(5));
assert_eq!(snapshot.ids(), &[1, 2, 5, 6]);
assert_eq!(snapshot.active(), 2);
}
#[test]
fn parse_workspaces_rejects_malformed_json() {
assert!(parse_workspaces("not json").is_err());
}
#[test]
fn socket_dir_joins_base_hypr_and_signature() {
assert_eq!(
socket_dir("/run/user/1000", "abc123"),
PathBuf::from("/run/user/1000/hypr/abc123")
);
}
#[test]
fn workspace_events_are_recognized() {
assert!(is_workspace_event("workspace>>2"));
assert!(is_workspace_event("workspacev2>>2,name"));
assert!(is_workspace_event("createworkspacev2>>5,5"));
assert!(is_workspace_event("destroyworkspace>>3"));
assert!(is_workspace_event("moveworkspace>>2,DP-1"));
assert!(is_workspace_event("focusedmon>>DP-1,2"));
assert!(is_workspace_event("activespecial>>scratch,DP-1"));
}
#[test]
fn non_workspace_events_are_ignored() {
assert!(!is_workspace_event("activewindow>>class,title"));
assert!(!is_workspace_event("openwindow>>0x55,2,class,title"));
assert!(!is_workspace_event("submap>>resize"));
}
const ACTIVEWINDOW_JSON: &str =
r#"{"address": "0x55", "class": "firefox", "title": "GitHub", "workspace": {"id": 2}}"#;
const ACTIVEWINDOW_CLASS_ONLY: &str = r#"{"address": "0x55", "class": "kitty"}"#;
#[test]
fn parse_active_window_extracts_class_and_title_ignoring_extras() {
let window = parse_active_window(ACTIVEWINDOW_JSON).expect("valid json");
assert_eq!(window.class(), "firefox");
assert_eq!(window.title(), "GitHub");
}
#[test]
fn parse_active_window_tolerates_a_missing_title() {
let window = parse_active_window(ACTIVEWINDOW_CLASS_ONLY).expect("valid json");
assert_eq!(window.class(), "kitty");
assert_eq!(window.title(), "");
}
#[test]
fn parse_active_window_folds_empty_object_to_empty_snapshot() {
let window = parse_active_window("{}").expect("valid json");
assert!(window.is_empty());
}
#[test]
fn parse_active_window_rejects_malformed_json() {
assert!(parse_active_window("not json").is_err());
}
#[test]
fn parse_activewindow_stream_handles_both_v1_and_v2_event_names() {
assert_eq!(
parse_activewindow_stream("activewindow>>firefox,GitHub"),
Some(("firefox".to_string(), "GitHub".to_string()))
);
assert_eq!(
parse_activewindow_stream("activewindowv2>>kitty,vim"),
Some(("kitty".to_string(), "vim".to_string()))
);
}
#[test]
fn parse_activewindow_stream_returns_none_on_unrelated_events() {
assert!(parse_activewindow_stream("workspace>>2").is_none());
assert!(parse_activewindow_stream("activewindow>>").is_none());
assert!(parse_activewindow_stream("not an event line").is_none());
}
#[test]
fn parse_focusedmon_extracts_the_monitor_name() {
assert_eq!(
parse_focusedmon("focusedmon>>DP-1,2"),
Some("DP-1".to_string())
);
assert_eq!(
parse_focusedmon("focusedmon>>HDMI-A-1,5"),
Some("HDMI-A-1".to_string())
);
assert!(parse_focusedmon("focusedmon>>").is_none());
assert!(parse_focusedmon("activewindow>>firefox,GitHub").is_none());
assert!(parse_focusedmon("not an event line").is_none());
}
#[test]
fn focus_or_lifecycle_events_are_recognized() {
assert!(is_focus_or_lifecycle_event("activewindow"));
assert!(is_focus_or_lifecycle_event("activewindowv2"));
assert!(is_focus_or_lifecycle_event("openwindow"));
assert!(is_focus_or_lifecycle_event("closewindow"));
assert!(is_focus_or_lifecycle_event("focusedmon"));
}
#[test]
fn non_focus_events_are_ignored_for_active_window_tracking() {
assert!(!is_focus_or_lifecycle_event("workspace"));
assert!(!is_focus_or_lifecycle_event("workspacev2"));
assert!(!is_focus_or_lifecycle_event("submap"));
}
#[test]
fn switch_workspace_maps_to_a_dispatch_request() {
assert_eq!(
dispatch_request(&Command::SwitchWorkspace(4)).as_deref(),
Some("dispatch hl.dsp.focus({ workspace = 4 })")
);
}
}