Skip to content

Commit c99d9c7

Browse files
authored
fix: correct Hyprland targeted screenshots (#48)
* fix: correct Hyprland targeted screenshots * fix: clip offscreen window crops * fix: reject screenshots for unresolved targets
1 parent 510a49a commit c99d9c7

3 files changed

Lines changed: 254 additions & 9 deletions

File tree

src/server.rs

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -295,14 +295,34 @@ impl ComputerUseLinux {
295295
let max_depth = params.max_depth.unwrap_or(12).min(12);
296296
let include_screenshot = params.include_screenshot.unwrap_or(true);
297297
let screenshot_options = params.screenshot_options();
298+
let screenshot_target_requested = params.window_target().has_target();
298299
let app_filter = self
299300
.resolve_accessibility_app_filter(&params, window_context.as_ref())
300301
.await;
301302
let (screenshot, screenshot_error) = if include_screenshot {
302-
match capture_screenshot_raw()
303-
.await
304-
.and_then(|raw| prepare_screenshot_payload(raw, screenshot_options))
305-
{
303+
let result = capture_screenshot_raw().await.and_then(|raw| {
304+
if let Some(window) = window_context.as_ref() {
305+
let bounds = window.bounds.as_ref().ok_or_else(|| {
306+
anyhow::anyhow!(
307+
"targeted screenshot requires window bounds; refusing to return the full desktop"
308+
)
309+
})?;
310+
prepare_app_state_screenshot(
311+
raw,
312+
Some(bounds),
313+
screenshot_target_requested,
314+
screenshot_options,
315+
)
316+
} else {
317+
prepare_app_state_screenshot(
318+
raw,
319+
None,
320+
screenshot_target_requested,
321+
screenshot_options,
322+
)
323+
}
324+
});
325+
match result {
306326
Ok(capture) => (Some(capture), None),
307327
Err(error) => (None, Some(format!("{error:#}"))),
308328
}
@@ -1934,9 +1954,9 @@ struct ActionOutput {
19341954
impl ComputerUseLinux {
19351955
fn is_wayland_session(&self) -> bool {
19361956
crate::diagnostics::hydrate_session_bus_env();
1937-
env::var("XDG_SESSION_TYPE")
1938-
.ok()
1939-
.is_some_and(|value| value.eq_ignore_ascii_case("wayland"))
1957+
let session_type = env::var("XDG_SESSION_TYPE").ok();
1958+
let wayland_display = env::var("WAYLAND_DISPLAY").ok();
1959+
session_is_wayland(session_type.as_deref(), wayland_display.as_deref())
19401960
}
19411961

19421962
// The Wayland remote-desktop portal is now a *fallback* for input: when a
@@ -2975,6 +2995,56 @@ fn data_url_payload(data_url: &str) -> String {
29752995
.to_string()
29762996
}
29772997

2998+
fn session_is_wayland(session_type: Option<&str>, wayland_display: Option<&str>) -> bool {
2999+
match session_type {
3000+
Some(value) => value.eq_ignore_ascii_case("wayland"),
3001+
None => wayland_display.is_some_and(|value| !value.is_empty()),
3002+
}
3003+
}
3004+
3005+
fn prepare_app_state_screenshot(
3006+
mut raw: RawScreenshotCapture,
3007+
bounds: Option<&crate::windowing::WindowBounds>,
3008+
target_requested: bool,
3009+
options: ScreenshotPayloadOptions,
3010+
) -> Result<ScreenshotCapture> {
3011+
if target_requested && bounds.is_none() {
3012+
anyhow::bail!(
3013+
"targeted screenshot requires a resolved window; refusing to return the full desktop"
3014+
);
3015+
}
3016+
if let Some(bounds) = bounds {
3017+
let (mut x, mut y, mut width, mut height) = window_crop_rect(bounds).ok_or_else(|| {
3018+
anyhow::anyhow!(
3019+
"targeted screenshot has unusable window bounds; refusing to return the full desktop"
3020+
)
3021+
})?;
3022+
if x < 0 {
3023+
width = width.saturating_sub(x.unsigned_abs());
3024+
x = 0;
3025+
}
3026+
if y < 0 {
3027+
height = height.saturating_sub(y.unsigned_abs());
3028+
y = 0;
3029+
}
3030+
if width == 0 || height == 0 {
3031+
anyhow::bail!(
3032+
"targeted screenshot window is outside the captured desktop; refusing to return the full desktop"
3033+
);
3034+
}
3035+
let (bytes, width, height) = crop_png(&raw.bytes, x, y, width, height)
3036+
.map_err(|error| anyhow::anyhow!("targeted screenshot crop failed: {error}"))?;
3037+
raw = RawScreenshotCapture {
3038+
mime_type: raw.mime_type,
3039+
bytes,
3040+
source: raw.source,
3041+
width,
3042+
height,
3043+
};
3044+
}
3045+
prepare_screenshot_payload(raw, options)
3046+
}
3047+
29783048
/// Convert a window's bounds into a crop rectangle, if it has a usable origin
29793049
/// and non-zero size.
29803050
fn window_crop_rect(bounds: &crate::windowing::WindowBounds) -> Option<(i32, i32, u32, u32)> {
@@ -3876,6 +3946,95 @@ mod tests {
38763946
out
38773947
}
38783948

3949+
#[test]
3950+
fn targeted_app_state_crops_before_screenshot_payload_resize() {
3951+
let raw = RawScreenshotCapture {
3952+
mime_type: "image/png".to_string(),
3953+
bytes: solid_png(400, 200),
3954+
source: "test".to_string(),
3955+
width: 400,
3956+
height: 200,
3957+
};
3958+
let bounds = WindowBounds {
3959+
x: Some(50),
3960+
y: Some(20),
3961+
width: 200,
3962+
height: 100,
3963+
};
3964+
let capture = prepare_app_state_screenshot(
3965+
raw,
3966+
Some(&bounds),
3967+
true,
3968+
ScreenshotPayloadOptions {
3969+
max_width: Some(100),
3970+
max_height: Some(100),
3971+
max_bytes: Some(1024 * 1024),
3972+
..Default::default()
3973+
},
3974+
)
3975+
.unwrap();
3976+
3977+
assert_eq!(
3978+
(capture.coordinate_width, capture.coordinate_height),
3979+
(200, 100)
3980+
);
3981+
assert_eq!((capture.width, capture.height), (100, 50));
3982+
}
3983+
3984+
#[test]
3985+
fn unresolved_app_state_target_refuses_full_desktop_screenshot() {
3986+
let raw = RawScreenshotCapture {
3987+
mime_type: "image/png".to_string(),
3988+
bytes: solid_png(400, 200),
3989+
source: "test".to_string(),
3990+
width: 400,
3991+
height: 200,
3992+
};
3993+
3994+
let error =
3995+
prepare_app_state_screenshot(raw, None, true, ScreenshotPayloadOptions::default())
3996+
.unwrap_err();
3997+
3998+
assert!(error.to_string().contains("requires a resolved window"));
3999+
}
4000+
4001+
#[test]
4002+
fn targeted_app_state_crops_only_visible_part_of_offscreen_window() {
4003+
let raw = RawScreenshotCapture {
4004+
mime_type: "image/png".to_string(),
4005+
bytes: solid_png(400, 200),
4006+
source: "test".to_string(),
4007+
width: 400,
4008+
height: 200,
4009+
};
4010+
let bounds = WindowBounds {
4011+
x: Some(-50),
4012+
y: Some(-40),
4013+
width: 100,
4014+
height: 100,
4015+
};
4016+
4017+
let capture = prepare_app_state_screenshot(
4018+
raw,
4019+
Some(&bounds),
4020+
true,
4021+
ScreenshotPayloadOptions::default(),
4022+
)
4023+
.unwrap();
4024+
4025+
assert_eq!(
4026+
(capture.coordinate_width, capture.coordinate_height),
4027+
(50, 60)
4028+
);
4029+
}
4030+
4031+
#[test]
4032+
fn wayland_display_is_enough_to_select_portal_fallback() {
4033+
assert!(session_is_wayland(None, Some("wayland-1")));
4034+
assert!(session_is_wayland(Some("wayland"), None));
4035+
assert!(!session_is_wayland(Some("x11"), None));
4036+
}
4037+
38794038
#[test]
38804039
fn window_crop_happens_before_screenshot_payload_resize() {
38814040
let (cropped, width, height) = crop_png(&solid_png(400, 200), 50, 20, 200, 100).unwrap();

src/windowing/backends/hyprland.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,57 @@ pub fn list_windows() -> Result<Vec<WindowInfo>> {
6464
);
6565
}
6666

67-
parse_hyprland_clients(&String::from_utf8_lossy(&output.stdout))
67+
let clients_json = String::from_utf8_lossy(&output.stdout);
68+
let monitors_output = hyprctl_output(&["monitors", "-j"]).ok();
69+
match monitors_output.filter(|output| output.status.success()) {
70+
Some(monitors) => parse_hyprland_clients_with_monitors(&clients_json, &monitors.stdout),
71+
None => parse_hyprland_clients(&clients_json),
72+
}
73+
}
74+
75+
fn parse_hyprland_clients_with_monitors(
76+
clients_json: &str,
77+
monitors_json: &[u8],
78+
) -> Result<Vec<WindowInfo>> {
79+
let monitors: Vec<HyprlandMonitor> = serde_json::from_slice(monitors_json)
80+
.context("failed to parse hyprctl monitors -j output")?;
81+
let monitors = monitors
82+
.into_iter()
83+
.map(|monitor| (monitor.id, monitor))
84+
.collect::<std::collections::HashMap<_, _>>();
85+
let mut clients: Vec<HyprlandClient> =
86+
serde_json::from_str(clients_json).context("failed to parse hyprctl clients -j output")?;
87+
for client in &mut clients {
88+
let Some(monitor) = client.monitor.and_then(|id| monitors.get(&id)) else {
89+
continue;
90+
};
91+
if let Some(at) = client.at.as_mut() {
92+
at[0] = scale_i32(at[0] - monitor.x, monitor.scale);
93+
at[1] = scale_i32(at[1] - monitor.y, monitor.scale);
94+
}
95+
if let Some(size) = client.size.as_mut() {
96+
size[0] = scale_u32(size[0], monitor.scale);
97+
size[1] = scale_u32(size[1], monitor.scale);
98+
}
99+
}
100+
windows_from_hyprland_clients(clients)
68101
}
69102

70103
pub(crate) fn parse_hyprland_clients(json: &str) -> Result<Vec<WindowInfo>> {
71104
let clients: Vec<HyprlandClient> =
72105
serde_json::from_str(json).context("failed to parse hyprctl clients -j output")?;
106+
windows_from_hyprland_clients(clients)
107+
}
73108

109+
fn scale_i32(value: i32, scale: f64) -> i32 {
110+
(f64::from(value) * scale).round() as i32
111+
}
112+
113+
fn scale_u32(value: u32, scale: f64) -> u32 {
114+
(f64::from(value) * scale).round() as u32
115+
}
116+
117+
fn windows_from_hyprland_clients(clients: Vec<HyprlandClient>) -> Result<Vec<WindowInfo>> {
74118
let mut windows = clients
75119
.into_iter()
76120
.filter(|client| client.mapped.unwrap_or(true))
@@ -199,13 +243,22 @@ struct HyprlandInstanceCandidate {
199243
modified: SystemTime,
200244
}
201245

246+
#[derive(Debug, Deserialize)]
247+
struct HyprlandMonitor {
248+
id: i32,
249+
x: i32,
250+
y: i32,
251+
scale: f64,
252+
}
253+
202254
#[derive(Debug, Deserialize)]
203255
struct HyprlandClient {
204256
address: String,
205257
mapped: Option<bool>,
206258
hidden: Option<bool>,
207259
at: Option<[i32; 2]>,
208260
size: Option<[u32; 2]>,
261+
monitor: Option<i32>,
209262
workspace: Option<HyprlandWorkspace>,
210263
#[serde(rename = "class")]
211264
class_name: Option<String>,
@@ -271,6 +324,28 @@ mod tests {
271324
use super::*;
272325
use std::time::Duration;
273326

327+
#[test]
328+
fn rebases_global_window_coordinates_to_screenshot_space() {
329+
let clients = r#"[{
330+
"address":"0x1234",
331+
"mapped":true,
332+
"at":[4714,1494],
333+
"size":[931,1124],
334+
"monitor":0,
335+
"class":"com.mitchellh.ghostty",
336+
"title":"Ghostty"
337+
}]"#;
338+
let monitors = br#"[
339+
{"id":0,"x":3747,"y":1440,"scale":1.8},
340+
{"id":1,"x":5667,"y":0,"scale":2.0}
341+
]"#;
342+
let windows = parse_hyprland_clients_with_monitors(clients, monitors).unwrap();
343+
344+
let bounds = windows[0].bounds.as_ref().unwrap();
345+
assert_eq!((bounds.x, bounds.y), (Some(1741), Some(97)));
346+
assert_eq!((bounds.width, bounds.height), (1676, 2023));
347+
}
348+
274349
#[test]
275350
fn builds_hyprland_055_lua_focus_dispatch() {
276351
assert_eq!(

src/windowing/target.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crate::windowing::types::{WindowFocusResult, WindowInfo, WindowTarget};
33
use anyhow::{bail, Result};
44
use tokio::time::{sleep, Duration};
55

6-
const FOCUS_VERIFY_ATTEMPTS: usize = 6;
6+
const FOCUS_VERIFY_ATTEMPTS: usize = 21;
77
const FOCUS_VERIFY_DELAY: Duration = Duration::from_millis(50);
88

99
pub async fn list_windows() -> Result<Vec<WindowInfo>> {
@@ -371,3 +371,14 @@ fn same_optional_string(left: &Option<String>, right: &Option<String>) -> bool {
371371
_ => false,
372372
}
373373
}
374+
375+
#[cfg(test)]
376+
mod tests {
377+
use super::*;
378+
379+
#[test]
380+
fn focus_verification_allows_workspace_transition_latency() {
381+
let verification_budget = FOCUS_VERIFY_DELAY * (FOCUS_VERIFY_ATTEMPTS - 1) as u32;
382+
assert!(verification_budget >= Duration::from_secs(1));
383+
}
384+
}

0 commit comments

Comments
 (0)