Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions libs/cua-driver/rust/crates/platform-macos/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ pub fn screenshot_window_bytes(window_id: u32) -> anyhow::Result<Vec<u8>> {
.status()?;

if !status.success() {
if crate::display_state::main_display_asleep() {
anyhow::bail!(
"screencapture failed for window {window_id}: {}",
crate::display_state::ASLEEP_CAPTURE_HINT
);
}
anyhow::bail!("screencapture failed for window {window_id}");
}

Expand Down Expand Up @@ -59,6 +65,12 @@ pub fn screenshot_display_bytes() -> anyhow::Result<Vec<u8>> {
.status()?;

if !status.success() {
if crate::display_state::main_display_asleep() {
anyhow::bail!(
"screencapture failed for main display: {}",
crate::display_state::ASLEEP_CAPTURE_HINT
);
}
anyhow::bail!("screencapture failed for main display");
}

Expand Down
68 changes: 68 additions & 0 deletions libs/cua-driver/rust/crates/platform-macos/src/display_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! Main-display power state.
//!
//! When the display is asleep (idle sleep, lid closed without an awake
//! external display), two things break silently:
//! * `screencapture` fails for every window, so `get_window_state` /
//! `zoom` / `debug_image_out` return opaque errors, and
//! * posted CGEvents may never be rendered by the target app, and the
//! agent has no screenshot to verify them against.
//!
//! An agent that doesn't know the display is asleep burns its budget
//! re-deriving coordinates and re-posting clicks into the void. Capture
//! errors and action results on macOS therefore carry an explicit
//! "display is asleep" marker while this state holds.

use std::os::raw::c_uint;

extern "C" {
fn CGMainDisplayID() -> c_uint;
fn CGDisplayIsAsleep(display: c_uint) -> u32;
}

/// True when the main display is asleep. Pure WindowServer query — cheap
/// enough to call on every action/capture result.
pub fn main_display_asleep() -> bool {
unsafe { CGDisplayIsAsleep(CGMainDisplayID()) != 0 }
}

/// Hint appended to capture errors while the display sleeps.
pub const ASLEEP_CAPTURE_HINT: &str =
"the main display is asleep, so macOS cannot capture windows. Wake it \
(user presence or `caffeinate -u -t 1`) and retry";

const ASLEEP_ACTION_SUFFIX: &str =
"\n\n😴 Main display is ASLEEP: the event was posted, but the app may \
never render it and no screenshot can verify it. Wake the display \
(user presence or `caffeinate -u -t 1`) before retrying or verifying — \
do NOT re-derive coordinates from this failure.";

/// Suffix for action success messages: empty while the display is awake.
pub fn asleep_suffix() -> &'static str {
asleep_suffix_for(main_display_asleep())
}

/// Pure mapping used by `asleep_suffix` — split out for unit testing.
fn asleep_suffix_for(asleep: bool) -> &'static str {
if asleep {
ASLEEP_ACTION_SUFFIX
} else {
""
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn suffix_empty_when_awake() {
assert_eq!(asleep_suffix_for(false), "");
}

#[test]
fn suffix_warns_when_asleep() {
let s = asleep_suffix_for(true);
assert!(s.contains("ASLEEP"));
assert!(s.contains("caffeinate"));
}
}
2 changes: 2 additions & 0 deletions libs/cua-driver/rust/crates/platform-macos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub mod input;
pub mod cursor;
#[cfg(target_os = "macos")]
pub mod capture;

pub mod display_state;
#[cfg(target_os = "macos")]
pub mod browser;
#[cfg(target_os = "macos")]
Expand Down
14 changes: 9 additions & 5 deletions libs/cua-driver/rust/crates/platform-macos/src/tools/click.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,15 @@ impl Tool for ClickTool {
Ok(Ok(Some(pid))) => ToolResult::text(format!(
"✅ Sent {button_label} at desktop-pixel ({sx_shot:.0},{sy_shot:.0}) \
→ screen-point ({sx:.0},{sy:.0}) on pid {pid} (desktop scope; \
not driver-verified — confirm via screenshot)."
not driver-verified — confirm via screenshot).{}",
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({ "path": "cgevent", "verified": false, "effect": "unverifiable" })),
Ok(Ok(None)) => ToolResult::text(format!(
"✅ Sent screen-absolute {button_label} at desktop-pixel \
({sx_shot:.0},{sy_shot:.0}) → screen-point ({sx:.0},{sy:.0}) \
(desktop scope, no window under point; not driver-verified)."
(desktop scope, no window under point; not driver-verified).{}",
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({ "path": "cgevent", "verified": false, "effect": "unverifiable" })),
Ok(Err(e)) => ToolResult::error(format!("desktop-scope click failed: {e}")),
Expand Down Expand Up @@ -406,7 +408,8 @@ impl Tool for ClickTool {
return match result {
Ok(Ok(())) => ToolResult::text(format!(
"✅ Posted middle-click to pid {pid} at element [{idx}] center \
(background CGEvent; not driver-verified — confirm via screenshot)."
(background CGEvent; not driver-verified — confirm via screenshot).{}",
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({ "path": "cgevent", "verified": false, "effect": "unverifiable" })),
Ok(Err(e)) => ToolResult::error(format!("Middle-click failed: {e}")),
Expand Down Expand Up @@ -712,8 +715,9 @@ impl Tool for ClickTool {
};
ToolResult::text(format!(
"✅ Posted {button_label} to pid {pid} ({mode_label}; \
not driver-verified — confirm via screenshot).{}",
changes.result_suffix()
not driver-verified — confirm via screenshot).{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({ "path": path, "verified": false, "effect": "unverifiable" }))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ impl Tool for DoubleClickTool {

let mode_label = if fg { " (delivery_mode:foreground)" } else { "" };
match result {
Ok(Ok(())) => ToolResult::text(format!("✅ Double-clicked at ({screen_x:.1}, {screen_y:.1}){mode_label}."))
Ok(Ok(())) => ToolResult::text(format!(
"✅ Double-clicked at ({screen_x:.1}, {screen_y:.1}){mode_label}.{}",
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({
"path": if fg { "cgevent_fg" } else { "cgevent" }, "verified": false, "effect": "unverifiable"
})),
Expand Down Expand Up @@ -244,5 +247,8 @@ fn ax_double_click(pid: i32, wid: u32, element_ptr: usize, idx: usize, cursor_ke
screen coordinates as window-local for element [{idx}]."
))?;
crate::input::mouse::click_at_xy_with_window_local(pid, cx, cy, wx, wy, wid, 2, &[])?;
Ok(format!("✅ Double-clicked element [{idx}] at ({cx:.1}, {cy:.1})."))
Ok(format!(
"✅ Double-clicked element [{idx}] at ({cx:.1}, {cy:.1}).{}",
crate::display_state::asleep_suffix()
))
}
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ impl Tool for DragTool {
from_sx as i64, from_sy as i64,
to_sx as i64, to_sy as i64,
changes.result_suffix(),
))
) + crate::display_state::asleep_suffix())
.with_structured(serde_json::json!({
"path": if fg { "cgevent_fg" } else { "cgevent" }, "verified": false, "effect": "unverifiable"
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ impl Tool for GetWindowStateTool {
// screenshot_out_file). With `screenshot_out_file` set, write to disk and
// surface the path instead of embedding base64; otherwise embed base64.
let max_dim = effective_max_dim;
// Why the screenshot is missing, when it is — surfaced to the caller so
// a capture failure (permissions, display asleep, WindowServer refusal)
// is never silent while `escalation` tells the agent to act off it.
let mut screenshot_error: Option<String> = None;
// Returns (b64_or_path, final_w, final_h, Option<original_w>, is_file_path)
let screenshot = if should_capture {
let out_file = screenshot_out_file.clone();
Expand Down Expand Up @@ -215,10 +219,12 @@ impl Tool for GetWindowStateTool {
}
Ok(Err(e)) => {
tracing::warn!("Screenshot failed for window {window_id}: {e}");
screenshot_error = Some(e.to_string());
None
}
Err(e) => {
tracing::warn!("Screenshot task error for window {window_id}: {e}");
screenshot_error = Some(e.to_string());
None
}
}
Expand Down Expand Up @@ -251,13 +257,23 @@ impl Tool for GetWindowStateTool {
content.push(Content::text(summary));
} else if let Some(ref r) = tree_result {
let element_count = self.state.element_cache.element_count(pid, window_id);
let capture_note = screenshot_error
.as_ref()
.map(|e| format!("\n\n⚠️ screenshot unavailable: {e}"))
.unwrap_or_default();
content.push(Content::text(format!(
"window_id={window_id} pid={pid} elements={element_count}\n\n{}",
"window_id={window_id} pid={pid} elements={element_count}{capture_note}\n\n{}",
r.tree_markdown
)));
}

if content.is_empty() {
if crate::display_state::main_display_asleep() {
return ToolResult::error(format!(
"No content produced (neither AX tree nor screenshot succeeded) — {}",
crate::display_state::ASLEEP_CAPTURE_HINT
));
}
return ToolResult::error("No content produced (neither AX tree nor screenshot succeeded)");
}

Expand Down Expand Up @@ -335,6 +351,9 @@ impl Tool for GetWindowStateTool {
in this response (an element px action)."
});
}
if let Some(ref err) = screenshot_error {
structured["screenshot_error"] = serde_json::json!(err);
}
if let Some((sw, sh)) = screenshot_dims {
structured["screenshot_width"] = serde_json::json!(sw);
structured["screenshot_height"] = serde_json::json!(sh);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,9 @@ impl Tool for HotkeyTool {
});
}
ToolResult::text(format!(
"Pressed {key_display} on pid {pid}{label}.{}",
changes.result_suffix()
"Pressed {key_display} on pid {pid}{label}.{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
)).with_structured(structured)
}
Ok(Err(e)) => ToolResult::error(format!("hotkey failed: {e}")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,9 @@ impl Tool for PressKeyTool {
});
}
ToolResult::text(format!(
"✅ Pressed {display_key} on pid {pid}{label}.{}",
changes.result_suffix()
"✅ Pressed {display_key} on pid {pid}{label}.{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
)).with_structured(structured)
}
Ok(Err(e)) => ToolResult::error(format!("press_key failed: {e}")),
Expand Down
10 changes: 6 additions & 4 deletions libs/cua-driver/rust/crates/platform-macos/src/tools/scroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,9 @@ impl Tool for ScrollTool {
Ok(Ok(())) => ToolResult::text(format!(
"✅ Sent {direction} scroll by {by} × {amount} via pixel wheel at \
({screen_x:.0}, {screen_y:.0}){mode_label} (background CGEvent; not \
driver-verified — confirm via screenshot).{}",
changes.result_suffix()
driver-verified — confirm via screenshot).{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({
"path": if fg { "cgevent_fg" } else { "cgevent" }, "verified": false, "effect": "unverifiable"
Expand Down Expand Up @@ -361,8 +362,9 @@ impl Tool for ScrollTool {
match result {
Ok(Ok(())) => ToolResult::text(format!(
"✅ Sent {direction} scroll by {by} × {amount} via keystroke \
(background; not driver-verified — confirm via screenshot).{}",
changes.result_suffix()
(background; not driver-verified — confirm via screenshot).{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
))
.with_structured(serde_json::json!({ "path": "key_events", "verified": false })),
Ok(Err(e)) => ToolResult::error(format!("Scroll failed: {e}")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,9 @@ impl Tool for TypeTextTool {
and re-call with delivery_mode:\"foreground\" if it didn't.".to_string())
};
ToolResult::text(format!(
"{mark} {char_count} char(s){detail}.{note}{}",
changes.result_suffix()
"{mark} {char_count} char(s){detail}.{note}{}{}",
changes.result_suffix(),
crate::display_state::asleep_suffix()
))
.with_structured({
// `effect` mirrors `verified`'s read-back tri-state: a TRUSTED
Expand Down