Skip to main content

hyprcorrect_platform/linux/
focus.rs

1//! Hyprland focus and window-close events.
2//!
3//! Subscribes to Hyprland's IPC event socket
4//! (`$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock`)
5//! and translates the line-based event stream into [`FocusEvent`]s. The
6//! daemon uses these to keep a per-window keystroke buffer — typing in
7//! one window does not poison the buffer of another, and switching back
8//! to a window restores its prior buffer state — and to apply the
9//! privacy app-blocklist (gating buffer accumulation by window class).
10//!
11//! See `DESIGN.md` — "The keystroke buffer".
12
13use std::io::{BufRead, BufReader};
14use std::os::unix::net::UnixStream;
15use std::path::PathBuf;
16use std::process::Command;
17use std::sync::mpsc::{self, Receiver, Sender};
18
19/// A focus or window-lifecycle event from Hyprland.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FocusEvent {
22    /// A window gained focus. The address is lowercase hex without
23    /// `0x`. The class is taken from the immediately-preceding
24    /// `activewindow>>` line (or empty if Hyprland did not emit one).
25    Focused { address: String, class: String },
26    /// A window was closed. Its keystroke buffer can be dropped.
27    Closed { address: String },
28}
29
30/// The currently focused window at startup, used to seed the daemon.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct InitialFocus {
33    pub address: String,
34    pub class: String,
35}
36
37/// An error subscribing to Hyprland's IPC event stream.
38#[derive(Debug, thiserror::Error)]
39pub enum FocusError {
40    /// `XDG_RUNTIME_DIR` or `HYPRLAND_INSTANCE_SIGNATURE` is unset —
41    /// not running under Hyprland.
42    #[error(
43        "Hyprland IPC is unavailable: $XDG_RUNTIME_DIR or $HYPRLAND_INSTANCE_SIGNATURE is unset"
44    )]
45    Env,
46    /// The IPC socket could not be opened.
47    #[error("could not connect to Hyprland IPC socket: {0}")]
48    Connect(String),
49    /// The reader thread could not be spawned.
50    #[error("could not spawn IPC reader thread: {0}")]
51    Thread(String),
52}
53
54/// Start the Hyprland focus subscription.
55///
56/// Returns the currently-focused window (address + class) — if any —
57/// plus a receiver of subsequent [`FocusEvent`]s.
58///
59/// # Errors
60///
61/// See [`FocusError`].
62pub fn start() -> Result<(Option<InitialFocus>, Receiver<FocusEvent>), FocusError> {
63    let socket_path = socket2_path()?;
64    let stream = UnixStream::connect(&socket_path)
65        .map_err(|e| FocusError::Connect(format!("{}: {e}", socket_path.display())))?;
66    let initial = query_active_window();
67
68    let (tx, rx) = mpsc::channel();
69    std::thread::Builder::new()
70        .name("hyprcorrect-focus".into())
71        .spawn(move || read_events(stream, &tx))
72        .map_err(|e| FocusError::Thread(e.to_string()))?;
73
74    Ok((initial, rx))
75}
76
77fn socket2_path() -> Result<PathBuf, FocusError> {
78    let runtime = std::env::var_os("XDG_RUNTIME_DIR").ok_or(FocusError::Env)?;
79    let instance = std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").ok_or(FocusError::Env)?;
80    Ok(PathBuf::from(runtime)
81        .join("hypr")
82        .join(instance)
83        .join(".socket2.sock"))
84}
85
86/// Query `hyprctl activewindow -j` for the currently focused window.
87/// Returns `None` if the call fails or no window is focused.
88fn query_active_window() -> Option<InitialFocus> {
89    let output = Command::new("hyprctl")
90        .args(["activewindow", "-j"])
91        .output()
92        .ok()?;
93    if !output.status.success() {
94        return None;
95    }
96    let text = std::str::from_utf8(&output.stdout).ok()?;
97    let address = extract_json_string(text, "address")?;
98    let class = extract_json_string(text, "class").unwrap_or_default();
99    let address = normalize_address(&address);
100    if address.is_empty() {
101        None
102    } else {
103        Some(InitialFocus { address, class })
104    }
105}
106
107/// Crude extraction of a top-level string field from JSON. Beats
108/// pulling in `serde_json` for two fields.
109fn extract_json_string(text: &str, field: &str) -> Option<String> {
110    let needle = format!("\"{field}\"");
111    let after_key = text.split(&needle).nth(1)?;
112    // Step past the `:` and leading whitespace.
113    let after_colon = after_key.split_once(':')?.1.trim_start();
114    let value = after_colon.strip_prefix('"')?;
115    let (s, _) = value.split_once('"')?;
116    Some(s.to_string())
117}
118
119fn read_events(stream: UnixStream, tx: &Sender<FocusEvent>) {
120    let reader = BufReader::new(stream);
121    // The text-form `activewindow>>CLASS,TITLE` event always arrives
122    // before its sibling `activewindowv2>>ADDR`; buffer the class and
123    // attach it when the address lands.
124    let mut last_class: Option<String> = None;
125    for line in reader.lines() {
126        let Ok(line) = line else { return };
127        let Some((kind, payload)) = line.split_once(">>") else {
128            continue;
129        };
130        match kind {
131            "activewindow" => {
132                last_class = Some(
133                    payload
134                        .split_once(',')
135                        .map_or(payload, |(class, _)| class)
136                        .to_string(),
137                );
138            }
139            "activewindowv2" => {
140                let address = normalize_address(payload);
141                let class = last_class.clone().unwrap_or_default();
142                if tx.send(FocusEvent::Focused { address, class }).is_err() {
143                    return; // receiver dropped — daemon is shutting down
144                }
145            }
146            "closewindow" => {
147                let address = normalize_address(payload);
148                if tx.send(FocusEvent::Closed { address }).is_err() {
149                    return;
150                }
151            }
152            _ => {}
153        }
154    }
155}
156
157/// Strip a leading `0x` and lowercase the rest, so addresses from
158/// `hyprctl activewindow -j` (with prefix) and from `.socket2.sock`
159/// events (without prefix) compare equal.
160fn normalize_address(addr: &str) -> String {
161    addr.trim()
162        .strip_prefix("0x")
163        .unwrap_or_else(|| addr.trim())
164        .to_ascii_lowercase()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::sync::mpsc;
171
172    fn run(lines: &[&str]) -> Vec<FocusEvent> {
173        // We can't easily spin up a UnixStream in a unit test, so
174        // exercise the line-parsing arm directly.
175        let (tx, rx) = mpsc::channel();
176        let mut last_class: Option<String> = None;
177        for line in lines {
178            let Some((kind, payload)) = line.split_once(">>") else {
179                continue;
180            };
181            match kind {
182                "activewindow" => {
183                    last_class = Some(
184                        payload
185                            .split_once(',')
186                            .map_or(payload, |(c, _)| c)
187                            .to_string(),
188                    );
189                }
190                "activewindowv2" => {
191                    let _ = tx.send(FocusEvent::Focused {
192                        address: normalize_address(payload),
193                        class: last_class.clone().unwrap_or_default(),
194                    });
195                }
196                "closewindow" => {
197                    let _ = tx.send(FocusEvent::Closed {
198                        address: normalize_address(payload),
199                    });
200                }
201                _ => {}
202            }
203        }
204        drop(tx);
205        rx.iter().collect()
206    }
207
208    #[test]
209    fn pairs_text_class_with_v2_address() {
210        let events = run(&[
211            "workspace>>2",
212            "activewindow>>kitty,fish",
213            "activewindowv2>>563c9141fe00",
214        ]);
215        assert_eq!(
216            events,
217            vec![FocusEvent::Focused {
218                address: "563c9141fe00".into(),
219                class: "kitty".into(),
220            }]
221        );
222    }
223
224    #[test]
225    fn close_emits_closed() {
226        let events = run(&["closewindow>>0xAbCdEf"]);
227        assert_eq!(
228            events,
229            vec![FocusEvent::Closed {
230                address: "abcdef".into(),
231            }]
232        );
233    }
234
235    #[test]
236    fn ignores_unknown_events() {
237        let events = run(&[
238            "workspace>>2",
239            "openwindow>>abc,1,kitty,fish",
240            "monitor>>DP-1",
241        ]);
242        assert!(events.is_empty());
243    }
244
245    #[test]
246    fn extract_json_string_handles_typical_hyprctl_json() {
247        let json = r#"{"address": "0x563c9141fe00", "class":"kitty"}"#;
248        assert_eq!(
249            extract_json_string(json, "address"),
250            Some("0x563c9141fe00".into())
251        );
252        assert_eq!(extract_json_string(json, "class"), Some("kitty".into()));
253    }
254
255    #[test]
256    fn normalize_strips_prefix_and_lowercases() {
257        assert_eq!(normalize_address("0xAbCdEf"), "abcdef");
258        assert_eq!(normalize_address("563c9141fe00"), "563c9141fe00");
259        assert_eq!(normalize_address("  0x563C9141FE00  "), "563c9141fe00");
260    }
261}