hyprcorrect_platform/linux/
focus.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FocusEvent {
22 Focused { address: String, class: String },
26 Closed { address: String },
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct InitialFocus {
33 pub address: String,
34 pub class: String,
35}
36
37#[derive(Debug, thiserror::Error)]
39pub enum FocusError {
40 #[error(
43 "Hyprland IPC is unavailable: $XDG_RUNTIME_DIR or $HYPRLAND_INSTANCE_SIGNATURE is unset"
44 )]
45 Env,
46 #[error("could not connect to Hyprland IPC socket: {0}")]
48 Connect(String),
49 #[error("could not spawn IPC reader thread: {0}")]
51 Thread(String),
52}
53
54pub 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
86fn 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
107fn extract_json_string(text: &str, field: &str) -> Option<String> {
110 let needle = format!("\"{field}\"");
111 let after_key = text.split(&needle).nth(1)?;
112 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 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; }
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
157fn 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 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}