| 1 | use crate::Result; |
| 2 | use serde::{Deserialize, Serialize}; |
| 3 | use 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)] |
| 11 | pub struct Rect { |
| 12 | pub x: f64, |
| 13 | pub y: f64, |
| 14 | pub width: f64, |
| 15 | pub height: f64, |
| 16 | } |
| 17 | |
| 18 | impl 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)] |
| 35 | pub 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 | } |
| 45 | impl 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 | |
| 67 | pub 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 | |
| 90 | pub 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 | |
| 105 | pub 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 | |
| 117 | pub 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 | } |
| 125 | pub fn windows() -> Result<Vec<Window>> { |
| 126 | Ok(serde_json::from_str(&request("j/clients")?)?) |
| 127 | } |
| 128 | pub 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 | } |
| 136 | pub 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 | |
| 147 | pub 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)] |
| 177 | mod 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 | } |