1use crate::Result;
2use serde::{Deserialize, Serialize};
3use std::{
4 io::{BufRead, BufReader, 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,
16}
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 }
32}
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,
44}
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}
66
67pub fn session_directory() -> Result<PathBuf> {
68 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
69 if !desktop
70 .split(':')
71 .any(|name| name.eq_ignore_ascii_case("hyprland"))
72 || std::env::var_os("FLATPAK_ID").is_some()
73 || std::env::var_os("SNAP").is_some()
74 {
75 return Err("Window capture requires a native Hyprland session.".into());
76 }
77 let runtime = PathBuf::from(std::env::var("XDG_RUNTIME_DIR")?);
78 let instance = std::env::var("HYPRLAND_INSTANCE_SIGNATURE")?;
79 if !runtime.is_absolute()
80 || instance.is_empty()
81 || !instance
82 .bytes()
83 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
84 {
85 return Err("Invalid Hyprland session environment.".into());
86 }
87 Ok(runtime.join("hypr").join(instance))
88}
89
90pub fn request(command: &str) -> Result<String> {
91 let mut socket = UnixStream::connect(session_directory()?.join(".socket.sock"))?;
92 socket.set_read_timeout(Some(Duration::from_secs(2)))?;
93 socket.set_write_timeout(Some(Duration::from_secs(2)))?;
94 socket.write_all(command.as_bytes())?;
95 let mut reply = String::new();
96 socket
97 .take(4 * 1024 * 1024 + 1)
98 .read_to_string(&mut reply)?;
99 if reply.len() > 4 * 1024 * 1024 {
100 return Err("Oversized Hyprland reply.".into());
101 }
102 Ok(reply)
103}
104
105pub fn ensure_unlocked() -> Result<()> {
106 // Fail closed on unsupported/unknown replies too; never capture through a lock screen.
107 #[derive(Deserialize)]
108 struct LockState {
109 locked: bool,
110 }
111 if serde_json::from_str::<LockState>(&request("j/locked")?)?.locked {
112 return Err("Unlock your Hyprland session before capturing a window.".into());
113 }
114 Ok(())
115}
116
117pub fn animations_enabled() -> bool {
118 // Electron handles the standard reduced-motion setting; also honor Hyprland's own switch.
119 request("j/getoption animations:enabled")
120 .ok()
121 .and_then(|reply| serde_json::from_str::<serde_json::Value>(&reply).ok())
122 .and_then(|value| value.get("int").and_then(serde_json::Value::as_i64))
123 != Some(0)
124}
125pub fn windows() -> Result<Vec<Window>> {
126 Ok(serde_json::from_str(&request("j/clients")?)?)
127}
128pub fn active_window() -> Result<Window> {
129 let window: Window = serde_json::from_str(&request("j/activewindow")?)
130 .map_err(|_| "Hyprland has no active window to capture.")?;
131 if !window.mapped || window.hidden || !window.bounds().valid() || window.pid == 0 {
132 return Err("Hyprland has no visible active window to capture.".into());
133 }
134 Ok(window)
135}
136pub fn destination(windows: Vec<Window>, pid: u32, title: &str) -> Result<Option<Window>> {
137 let mut matches = windows
138 .into_iter()
139 .filter(|w| w.pid == pid && w.title == title && w.mapped && !w.hidden);
140 let found = matches.next();
141 if matches.next().is_some() {
142 return Err("More than one The Knife window matches the capture destination.".into());
143 }
144 Ok(found)
145}
146
147pub fn activate(pid: u32, title: &str) -> Result<()> {
148 // Subscribe before looking up a newly mapped T3 window, so no map/title event is missed.
149 let socket = UnixStream::connect(session_directory()?.join(".socket2.sock"))?;
150 let deadline = Instant::now() + Duration::from_secs(3);
151 let mut events = BufReader::new(socket);
152 loop {
153 ensure_unlocked()?;
154 if let Some(window) = destination(windows()?, pid, title)? {
155 let address = window.address()?;
156 let lua = format!("/dispatch hl.dsp.focus({{ window = \"address:0x{address:x}\" }})");
157 if request(&lua)?.trim() != "ok"
158 && request(&format!("/dispatch focuswindow address:0x{address:x}"))?.trim() != "ok"
159 {
160 return Err("Hyprland could not focus The Knife.".into());
161 }
162 return Ok(());
163 }
164 events.get_ref().set_read_timeout(Some(
165 deadline
166 .checked_duration_since(Instant::now())
167 .ok_or("The Knife did not become visible.")?,
168 ))?;
169 let mut event = Vec::new();
170 if events.by_ref().take(8193).read_until(b'\n', &mut event)? == 0 || event.len() > 8192 {
171 return Err("Hyprland's event stream closed or returned an invalid event.".into());
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 fn window(pid: u32, title: &str) -> Window {
180 Window {
181 address: "0x123456789abcdef".into(),
182 pid,
183 title: title.into(),
184 class: "t3".into(),
185 at: [-1920, 20],
186 size: [1000, 800],
187 mapped: true,
188 hidden: false,
189 }
190 }
191 #[test]
192 fn address_preserves_upper_bits() {
193 assert_eq!(window(1, "t").address().unwrap(), 0x123456789abcdef);
194 }
195 #[test]
196 fn destination_requires_unique_process_and_title() {
197 assert!(
198 destination(vec![window(2, "T3")], 1, "T3")
199 .unwrap()
200 .is_none()
201 );
202 assert!(
203 destination(vec![window(1, "other")], 1, "T3")
204 .unwrap()
205 .is_none()
206 );
207 assert!(destination(vec![window(1, "T3"), window(1, "T3")], 1, "T3").is_err());
208 assert!(
209 destination(vec![window(2, "T3"), window(1, "T3")], 1, "T3")
210 .unwrap()
211 .is_some()
212 );
213 }
214 #[test]
215 fn rectangles_accept_negative_origins_not_invalid_sizes() {
216 assert!(window(1, "t").bounds().valid());
217 assert!(
218 !Rect {
219 x: 0.,
220 y: 0.,
221 width: f64::NAN,
222 height: 10.
223 }
224 .valid()
225 );
226 assert!(
227 !Rect {
228 x: 0.,
229 y: 0.,
230 width: 0.,
231 height: 10.
232 }
233 .valid()
234 );
235 }
236}