Skip to content
Source
Sign inSign up

ipc.rs

296 lines10.0 KB

1use crate::Result;
2use serde::{Deserialize, Serialize};
3use std::{
4 io::{BufRead, BufReader, ErrorKind, Read, Write},
5 os::unix::net::UnixStream,
6 path::PathBuf,
7 time::{Duration, Instant},
8};
9​
10#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
11pub struct Rect {
12 pub x: f64,
13 pub y: f64,
14 pub width: f64,
15 pub height: f64,
17​
18impl Rect {
19 pub fn valid(&self) -> bool {
20 [self.x, self.y, self.width, self.height]
21 .iter()
22 .all(|n| n.is_finite() && n.abs() < 100_000.0)
23 && self.width > 0.0
24 && self.height > 0.0
25 }
26 pub fn intersects(&self, other: Self) -> bool {
27 self.x < other.x + other.width
28 && self.x + self.width > other.x
29 && self.y < other.y + other.height
30 && self.y + self.height > other.y
31 }
33​
34#[derive(Clone, Debug, Deserialize, PartialEq)]
35pub struct Window {
36 pub address: String,
37 pub pid: u32,
38 pub title: String,
39 pub class: String,
40 pub at: [i32; 2],
41 pub size: [i32; 2],
42 pub mapped: bool,
43 pub hidden: bool,
45impl Window {
46 pub fn address(&self) -> Result<u64> {
47 let address = self
48 .address
49 .strip_prefix("0x")
50 .ok_or("Invalid Hyprland window address")?;
51 Ok(u64::from_str_radix(address, 16)?)
52 }
53 pub fn bounds(&self) -> Rect {
54 Rect {
55 x: self.at[0].into(),
56 y: self.at[1].into(),
57 width: self.size[0].into(),
58 height: self.size[1].into(),
59 }
60 }
61 pub fn metadata(&self) -> serde_json::Value {
62 serde_json::json!({"title": self.title, "appName": self.class, "appIdentifier": self.class,
63 "processId": self.pid, "bounds": self.bounds(), "clientBounds": self.bounds()})
64 }
65 /// Same window, geometry and state; only a CLI title spinner may differ.
66 pub fn matches_after_capture(&self, current: &Self) -> bool {
67 self.address == current.address
68 && self.pid == current.pid
69 && self.class == current.class
70 && self.at == current.at
71 && self.size == current.size
72 && self.mapped == current.mapped
73 && self.hidden == current.hidden
74 && capture_title(&self.title) == capture_title(&current.title)
75 }
77​
78/// Match the same leading CLI spinner accepted by the Wayland AT-SPI window matcher.
79fn capture_title(title: &str) -> &str {
80 let mut chars = title.chars();
81 if matches!(
82 chars.next(),
83 Some('⠋' | '⠙' | '⠹' | '⠸' | '⠼' | '⠴' | '⠦' | '⠧' | '⠇' | '⠏')
84 ) && chars.clone().next().is_some_and(char::is_whitespace)
85 {
86 let label = chars.as_str().trim_start();
87 if !label.is_empty() {
88 return label;
89 }
90 }
91 title
93​
94pub fn session_directory() -> Result<PathBuf> {
95 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
96 if !desktop
97 .split(':')
98 .any(|name| name.eq_ignore_ascii_case("hyprland"))
99 || std::env::var_os("FLATPAK_ID").is_some()
100 || std::env::var_os("SNAP").is_some()
101 {
102 return Err("Window capture requires a native Hyprland session.".into());
103 }
104 let runtime = PathBuf::from(std::env::var("XDG_RUNTIME_DIR")?);
105 let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")?;
106 if !runtime.is_absolute()
107 || instance.is_empty()
108 || !instance
109 .bytes()
110 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
111 {
112 return Err("Invalid Hyprland session environment.".into());
113 }
114 Ok(runtime.join("hypr").join(instance))
116​
117pub fn request(command: &str) -> Result<String> {
118 let mut socket = UnixStream::connect(session_directory()?.join(".socket.sock"))?;
119 socket.set_read_timeout(Some(Duration::from_secs(2)))?;
120 socket.set_write_timeout(Some(Duration::from_secs(2)))?;
121 socket.write_all(command.as_bytes())?;
122 let mut reply = String::new();
123 socket
124 .take(4 * 1024 * 1024 + 1)
125 .read_to_string(&mut reply)?;
126 if reply.len() > 4 * 1024 * 1024 {
127 return Err("Oversized Hyprland reply.".into());
128 }
129 Ok(reply)
131​
132pub fn ensure_unlocked() -> Result<()> {
133 // Fail closed on unsupported/unknown replies too; never capture through a lock screen.
134 #[derive(Deserialize)]
135 struct LockState {
136 locked: bool,
137 }
138 if serde_json::from_str::<LockState>(&request("j/locked")?)?.locked {
139 return Err("Unlock your Hyprland session before capturing a window.".into());
140 }
141 Ok(())
143​
144pub fn animations_enabled() -> bool {
145 // Electron handles the standard reduced-motion setting; also honor Hyprland's own switch.
146 request("j/getoption animations:enabled")
147 .ok()
148 .and_then(|reply| serde_json::from_str::<serde_json::Value>(&reply).ok())
149 .and_then(|value| value.get("int").and_then(serde_json::Value::as_i64))
150 != Some(0)
152pub fn windows() -> Result<Vec<Window>> {
153 Ok(serde_json::from_str(&request("j/clients")?)?)
155pub fn active_window() -> Result<Window> {
156 let window: Window = serde_json::from_str(&request("j/activewindow")?)
157 .map_err(|_| "Hyprland has no active window to capture.")?;
158 if !window.mapped || window.hidden || !window.bounds().valid() || window.pid == 0 {
159 return Err("Hyprland has no visible active window to capture.".into());
160 }
161 Ok(window)
163pub fn destination(windows: Vec<Window>, pid: u32, title: &str) -> Result<Option<Window>> {
164 let mut matches = windows
165 .into_iter()
166 .filter(|w| w.pid == pid && w.title == title && w.mapped && !w.hidden);
167 let found = matches.next();
168 if matches.next().is_some() {
169 return Err("More than one T3 Code window matches the capture destination.".into());
170 }
171 Ok(found)
173​
174pub fn activate(pid: u32, title: &str) -> Result<()> {
175 // Subscribe before looking up a newly mapped T3 window, so no map/title event is missed.
176 let socket = UnixStream::connect(session_directory()?.join(".socket2.sock"))?;
177 let deadline = Instant::now() + Duration::from_secs(3);
178 let mut events = BufReader::new(socket);
179 loop {
180 ensure_unlocked()?;
181 if let Some(window) = destination(windows()?, pid, title)? {
182 let address = window.address()?;
183 let lua = format!("/dispatch hl.dsp.focus({{ window = \"address:0x{address:x}\" }})");
184 if request(&lua)?.trim() != "ok"
185 && request(&format!("/dispatch focuswindow address:0x{address:x}"))?.trim() != "ok"
186 {
187 return Err("Hyprland could not focus T3 Code.".into());
188 }
189 return Ok(());
190 }
191 next_event(&mut events, deadline)?;
192 }
194​
195/// Waits for one Hyprland event, or explains that T3 Code never appeared before `deadline`.
196fn next_event(events: &mut BufReader<UnixStream>, deadline: Instant) -> Result<()> {
197 let not_visible = "T3 Code did not become visible.";
198 let left = deadline.checked_duration_since(Instant::now());
199 events.get_ref().set_read_timeout(Some(
200 left.filter(|left| !left.is_zero()).ok_or(not_visible)?,
201 ))?;
202 let mut event = Vec::new();
203 let read = events.by_ref().take(8193).read_until(b'\n', &mut event);
204 // The read timeout ends at the deadline, and Linux reports its expiry as WouldBlock.
205 if read
206 .as_ref()
207 .is_err_and(|error| matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
208 {
209 return Err(not_visible.into());
210 }
211 if read? == 0 || event.len() > 8192 {
212 return Err("Hyprland's event stream closed or returned an invalid event.".into());
213 }
214 Ok(())
216​
217#[cfg(test)]
218mod tests {
219 use super::*;
220 fn window(pid: u32, title: &str) -> Window {
221 Window {
222 address: "0x123456789abcdef".into(),
223 pid,
224 title: title.into(),
225 class: "t3".into(),
226 at: [-1920, 20],
227 size: [1000, 800],
228 mapped: true,
229 hidden: false,
230 }
231 }
232 #[test]
233 fn address_preserves_upper_bits() {
234 assert_eq!(window(1, "t").address().unwrap(), 0x123456789abcdef);
235 }
236 #[test]
237 fn destination_requires_unique_process_and_title() {
238 assert!(
239 destination(vec![window(2, "T3")], 1, "T3")
240 .unwrap()
241 .is_none()
242 );
243 assert!(
244 destination(vec![window(1, "other")], 1, "T3")
245 .unwrap()
246 .is_none()
247 );
248 assert!(destination(vec![window(1, "T3"), window(1, "T3")], 1, "T3").is_err());
249 assert!(
250 destination(vec![window(2, "T3"), window(1, "T3")], 1, "T3")
251 .unwrap()
252 .is_some()
253 );
254 }
255 #[test]
256 fn event_wait_explains_an_activation_timeout() {
257 let (socket, mut hyprland) = UnixStream::pair().unwrap();
258 // A quiet socket reports the same WouldBlock as an expired read timeout, without waiting.
259 socket.set_nonblocking(true).unwrap();
260 let mut events = BufReader::new(socket);
261 let deadline = Instant::now() + Duration::from_secs(3);
262 assert_eq!(
263 next_event(&mut events, deadline).unwrap_err().to_string(),
264 "T3 Code did not become visible."
265 );
266 hyprland.write_all(b"openwindow>>1,1,t3,T3 Code\n").unwrap();
267 assert!(next_event(&mut events, deadline).is_ok());
268 drop(hyprland);
269 assert_eq!(
270 next_event(&mut events, deadline).unwrap_err().to_string(),
271 "Hyprland's event stream closed or returned an invalid event."
272 );
273 }
274 #[test]
275 fn rectangles_accept_negative_origins_not_invalid_sizes() {
276 assert!(window(1, "t").bounds().valid());
277 assert!(
278 !Rect {
279 x: 0.,
280 y: 0.,
281 width: f64::NAN,
282 height: 10.
283 }
284 .valid()
285 );
286 assert!(
287 !Rect {
288 x: 0.,
289 y: 0.,
290 width: 0.,
291 height: 10.
292 }
293 .valid()
294 );
295 }