first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+15 -8
View File
@@ -25,29 +25,36 @@ tokio = { workspace = true, features = ["rt", "macros"] }
[target.'cfg(target_os = "macos")'.dependencies]
command.workspace = true
core-foundation.workspace = true
dispatch2 = "0.3.0"
image.workspace = true
instant.workspace = true
libc.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
# `NSEvent`/`NSGraphicsContext`/`objc2-core-graphics` are needed to build window-targeted mouse
# events via `NSEvent::mouseEventWithType_...` and bridge to `CGEvent` for PID-targeted posting.
objc2-app-kit = { workspace = true, features = [
"NSEvent",
"NSGraphicsContext",
"objc2-core-graphics",
] }
objc2-core-foundation.workspace = true
objc2-core-graphics.workspace = true
# The `libc` feature is required for `CGEvent::post_to_pid`, which delivers events to a
# specific process without moving the global cursor.
objc2-core-graphics = { workspace = true, features = ["libc"] }
tempfile.workspace = true
galaxyui.workspace = true
galaxyui_core.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
ashpd.workspace = true
futures.workspace = true
image.workspace = true
url.workspace = true
galaxyui.workspace = true
galaxyui_core.workspace = true
x11rb = { workspace = true, features = ["xtest"] }
zbus.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
image.workspace = true
galaxyui.workspace = true
galaxyui_core.workspace = true
windows = { workspace = true, features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
+1 -1
View File
@@ -3,7 +3,7 @@ use cfg_aliases::cfg_aliases;
fn main() {
cfg_aliases! {
macos: { target_os = "macos" },
linux: { target_os = "linux" },
linux: { any(target_os = "linux", target_os = "freebsd") },
noop: { not(any(macos, linux, windows)) },
}
}
+68 -2
View File
@@ -4,17 +4,44 @@ use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use computer_use::{
Action, Key, MouseButton, Options, ScreenshotParams, ScreenshotRegion, Vector2I,
Action, Key, MouseButton, Options, ScreenshotParams, ScreenshotRegion, Target, TargetedAction,
Vector2I,
};
#[derive(Parser)]
#[command(name = "use_computer")]
#[command(about = "Manually test computer use actions")]
struct Cli {
/// Experimental (macOS only): target a specific background window/process instead of the
/// screen. Deliver events directly to this process ID (and `--window-id`, if given) without
/// moving the real cursor or raising the window.
#[arg(long, global = true)]
pid: Option<i32>,
/// Experimental (macOS only): the CGWindowID of the window to target. Required when `--pid`
/// is given. Use the `windows` subcommand to list window ids.
#[arg(long, global = true)]
window_id: Option<u32>,
#[command(subcommand)]
command: Command,
}
impl Cli {
/// Resolves the per-action / screenshot target from the CLI flags. A `--pid` selects a
/// background window target; otherwise the legacy whole-screen target is used. Callers must
/// validate that `--window-id` is present whenever `--pid` is given (see `main`), so the
/// ambiguous `0` sentinel is never sent to the actor.
fn target(&self) -> Target {
match (self.pid, self.window_id) {
(Some(pid), Some(window_id)) => Target::Window { window_id, pid },
// `--pid` without `--window-id` is rejected up front in `main`; fall back to the
// screen target here so a missing id can never become a `0`-id window target.
(Some(_), None) | (None, _) => Target::Screen,
}
}
}
#[derive(Subcommand)]
enum Command {
/// Perform a mouse click (mouse down + mouse up) at a position.
@@ -46,6 +73,9 @@ enum Command {
/// The key to press. Can be a single character (e.g., "a") or a keycode (e.g., "0x24" for Return on macOS).
key: String,
},
/// Experimental (macOS only): list on-screen windows with their window number, owner PID,
/// owner name, layer, and bounds, to help identify the right target PID/window.
Windows,
}
#[derive(Clone, ValueEnum)]
@@ -94,6 +124,29 @@ fn parse_region(s: &str) -> Result<(i32, i32, i32, i32), String> {
async fn main() {
let cli = Cli::parse();
// Window listing does not go through the actor's action model; handle it up front.
if let Command::Windows = cli.command {
match computer_use::experimental_list_windows() {
Ok(text) => print!("{text}"),
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
return;
}
// A window target needs a concrete window id; require it alongside `--pid` so the ambiguous
// `0` sentinel is never sent to the actor.
if cli.pid.is_some() && cli.window_id.is_none() {
eprintln!(
"--window-id is required when --pid is given. Use the `windows` subcommand to list \
window ids."
);
std::process::exit(1);
}
let target = cli.target();
let mut actor = computer_use::create_actor();
let (actions, screenshot_params, output_path) = match cli.command {
@@ -124,6 +177,7 @@ async fn main() {
max_long_edge_px: None,
max_total_px: None,
region,
target,
}),
Some(output),
)
@@ -154,9 +208,21 @@ async fn main() {
None,
)
}
// Handled before the actor is created, above.
Command::Windows => unreachable!(),
};
let options = Options { screenshot_params };
// Pair every action with the resolved target before handing off to the actor.
let actions: Vec<TargetedAction> = actions
.into_iter()
.map(|action| TargetedAction { action, target })
.collect();
// The CLI is a developer tool for exercising window targeting, so background per-window
// control is always enabled here.
let options = Options {
screenshot_params,
background_enabled: true,
};
match actor.perform_actions(&actions, options).await {
Ok(result) => {
+145 -7
View File
@@ -7,15 +7,14 @@ mod noop;
#[cfg(any(macos, linux, windows))]
mod screenshot_utils;
use std::borrow::Cow;
use async_trait::async_trait;
// Clippy doesn't like us pulling in a file as two different modules,
// so we add this alias instead of using another cfg_attr on the imp
// module definition.
#[cfg(noop)]
use noop as imp;
use std::borrow::Cow;
use async_trait::async_trait;
pub use pathfinder_geometry::vector::Vector2I;
use serde::{Deserialize, Serialize};
use serde_with::{DurationSecondsWithFrac, serde_as};
@@ -46,6 +45,117 @@ pub fn create_actor() -> Box<dyn Actor> {
}
}
/// Returns whether background, per-window control (driving a specific window without raising it
/// or moving the cursor) is available on this client and OS. When false, callers should target
/// the whole screen / frontmost application.
pub fn background_supported() -> bool {
if cfg!(feature = "test-util") {
noop::background_supported()
} else {
imp::background_supported()
}
}
/// Enumerates the on-screen windows, returning their metadata so a caller can pick one to
/// target. Returns an empty list on platforms where window enumeration is unsupported.
pub fn enumerate_windows() -> Vec<WindowInfo> {
#[cfg(macos)]
{
imp::enumerate_windows()
}
#[cfg(not(macos))]
{
Vec::new()
}
}
/// Experimental: lists on-screen windows as a formatted diagnostic string. macOS only.
///
/// Unlike [`enumerate_windows`], which returns slim [`WindowInfo`] records for window selection
/// and wire serialization, this function returns richer data including window bounds, formatted
/// as a human-readable table for CLI debugging. The two use separate types intentionally:
/// [`WindowInfo`] is kept wire-safe and bounds-free; the diagnostic output carries bounds that
/// are not part of the API representation.
#[cfg(macos)]
pub fn experimental_list_windows() -> Result<String, String> {
Ok(imp::list_windows())
}
/// Experimental: lists on-screen windows. Unsupported on this platform.
#[cfg(not(macos))]
pub fn experimental_list_windows() -> Result<String, String> {
Err("Window listing is only supported on macOS.".to_string())
}
/// The surface that a computer-use action or screenshot targets.
///
/// `Screen` reproduces the legacy behavior of acting on the whole screen / frontmost
/// application. `Window` drives a specific background window of a specific process without
/// raising it or moving the global cursor.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize)]
pub enum Target {
/// Target the whole screen / frontmost application (legacy behavior).
#[default]
Screen,
/// Target a specific background window of a specific process.
Window {
/// The platform window id (a `CGWindowID` on macOS). Must be a concrete, non-zero id
/// selected from the enumerated window list. `0` is the "unknown" sentinel and is
/// rejected by the actor, since coordinate remapping and window capture both require a
/// known window.
window_id: u32,
/// The pid of the process that owns the window.
pid: i32,
},
}
/// An action paired with the surface it targets.
///
/// The target is carried per-action so a single batch can, in principle, drive more than one
/// window. An absent / `Screen` target reproduces the legacy whole-screen behavior.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct TargetedAction {
pub action: Action,
#[serde(default)]
pub target: Target,
}
impl TargetedAction {
/// Builds a screen-targeted action (legacy behavior).
pub fn screen(action: Action) -> Self {
Self {
action,
target: Target::Screen,
}
}
}
/// Metadata about an on-screen window, so a caller can select a window to target.
/// Mirrors the fields of the `WindowInfo` API message.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct WindowInfo {
/// The platform window id (a `CGWindowID` on macOS).
pub window_id: u32,
/// The pid of the process that owns the window.
pub pid: i32,
/// The owning application's name (e.g. "Arc", "Notes").
pub app_name: String,
/// The window title, if available.
pub title: String,
/// The window layer (0 is a normal application window).
pub layer: i32,
}
/// Metadata describing a captured window screenshot.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct CapturedWindow {
/// The platform window id that was captured.
pub window_id: u32,
/// The width of the native captured image, in pixels.
pub width_px: i32,
/// The height of the native captured image, in pixels.
pub height_px: i32,
}
#[async_trait]
pub trait Actor: Send + Sync + 'static {
/// Returns the platform that this actor is running on, if known.
@@ -53,7 +163,7 @@ pub trait Actor: Send + Sync + 'static {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String>;
}
@@ -124,7 +234,7 @@ pub enum ScrollDistance {
}
/// A rectangular region defined by top-left and bottom-right corners.
/// Coordinates are in physical screen pixels (same coordinate space as mouse actions).
/// Coordinates are physical pixels relative to the selected screenshot target.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub struct ScreenshotRegion {
#[serde(with = "Vector2IDef")]
@@ -173,15 +283,24 @@ pub struct ScreenshotParams {
pub max_long_edge_px: Option<usize>,
/// The maximum total number of pixels in the screenshot.
pub max_total_px: Option<usize>,
/// Optional region to capture. If `None`, captures the full display.
/// Optional sub-region of `target` to capture, in target-relative physical pixels.
/// If `None`, captures the full target.
#[serde(default)]
pub region: Option<ScreenshotRegion>,
/// The surface to capture. `Screen` captures the main display (legacy); `Window` captures
/// a specific window's image.
#[serde(default)]
pub target: Target,
}
pub struct Options {
/// If set, a screenshot will be captured after the actions are executed.
/// The parameters specify what constraints, if any, to apply to the screenshot.
pub screenshot_params: Option<ScreenshotParams>,
/// Whether background, per-window computer use is enabled. When false, actors must behave
/// exactly like the legacy full-screen path: any window target is ignored, only the main
/// display is captured, and no window list or captured-window metadata is returned.
pub background_enabled: bool,
}
/// The buttons of a mouse.
@@ -201,6 +320,25 @@ pub enum MouseButton {
pub struct ActionResult {
pub screenshot: Option<Screenshot>,
pub cursor_position: Option<Vector2I>,
/// The on-screen windows, refreshed after the actions run, so the caller always has a fresh
/// list to target next. Empty on platforms without window enumeration.
pub windows: Vec<WindowInfo>,
/// Metadata about the captured window, populated only when a window target was
/// screenshotted, so window-local coordinates map onto the screenshot image.
pub captured_window: Option<CapturedWindow>,
}
impl ActionResult {
/// Builds a result that carries no window list or captured-window metadata (used by
/// platforms and code paths that do not support per-window targeting).
pub fn legacy(screenshot: Option<Screenshot>, cursor_position: Option<Vector2I>) -> Self {
Self {
screenshot,
cursor_position,
windows: Vec::new(),
captured_window: None,
}
}
}
/// A simple representation of a screenshot.
+8 -2
View File
@@ -4,7 +4,7 @@ mod x11;
use async_trait::async_trait;
use crate::{Action, ActionResult, Options};
use crate::{ActionResult, Options, TargetedAction};
/// Returns true if a Wayland environment is available.
fn is_wayland_available() -> bool {
@@ -24,6 +24,12 @@ pub fn is_supported_on_current_platform() -> bool {
is_wayland_available() || is_x11_available()
}
/// Reports whether background, per-window control is available. The Linux input stack drives the
/// whole screen / frontmost application, so per-window background control is unsupported.
pub fn background_supported() -> bool {
false
}
pub struct Actor {
inner: ActorInner,
}
@@ -77,7 +83,7 @@ impl super::Actor for Actor {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String> {
match &mut self.inner {
+10 -11
View File
@@ -11,14 +11,13 @@ mod screenshot;
mod session;
use async_trait::async_trait;
use galaxyui::r#async::Timer;
use pathfinder_geometry::vector::Vector2I;
use crate::{Action, ActionResult, Options};
use keyboard::Keyboard;
use mouse::Mouse;
use pathfinder_geometry::vector::Vector2I;
use session::PortalSession;
use galaxyui_core::r#async::Timer;
use crate::{Action, ActionResult, Options, TargetedAction};
/// An actor that performs computer use actions on Wayland via XDG portals.
pub struct Actor {
@@ -58,7 +57,7 @@ impl crate::Actor for Actor {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String> {
// Ensure we have an active session before processing actions.
@@ -67,7 +66,10 @@ impl crate::Actor for Actor {
let mut last_mouse_position: Option<Vector2I> = None;
for action in actions {
for targeted in actions {
// Per-window targeting is not supported on Wayland; act on the screen / focused
// surface regardless of the requested target.
let action: &Action = &targeted.action;
// Re-acquire session reference each iteration (borrow checker workaround).
let session = self
.session
@@ -153,9 +155,6 @@ impl crate::Actor for Actor {
self.mouse.last_position()
};
Ok(ActionResult {
screenshot,
cursor_position,
})
Ok(ActionResult::legacy(screenshot, cursor_position))
}
}
+8 -7
View File
@@ -7,12 +7,13 @@ mod screenshot;
use async_trait::async_trait;
use galaxyui::r#async::Timer;
use pathfinder_geometry::vector::Vector2I;
use galaxyui_core::r#async::Timer;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{self, ConnectionExt as _};
use x11rb::protocol::xtest::ConnectionExt as _;
use x11rb::rust_connection::RustConnection;
use crate::{Action, ActionResult, Options};
use crate::{Action, ActionResult, Options, TargetedAction};
/// An actor that performs computer use actions on X11.
pub struct Actor {
@@ -71,14 +72,17 @@ impl crate::Actor for Actor {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String> {
let mut mouse = mouse::Mouse::new(&self.conn, self.root_window());
let mut keyboard = keyboard::Keyboard::new(&self.conn, &self.keyboard_mapping);
let mut last_mouse_position: Option<Vector2I> = None;
for action in actions {
for targeted in actions {
// Per-window targeting is not supported on X11; act on the screen regardless of the
// requested target.
let action: &Action = &targeted.action;
match action {
Action::Wait(duration) => {
Timer::after(*duration).await;
@@ -135,9 +139,6 @@ impl crate::Actor for Actor {
Some(mouse.current_position()?)
};
Ok(ActionResult {
screenshot,
cursor_position,
})
Ok(ActionResult::legacy(screenshot, cursor_position))
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Background window activation without private SkyLight APIs.
//!
//! Background input (delivering clicks/keys to a window that is not frontmost, without moving
//! the real cursor) requires the target window to believe it is in the AppKit-active input
//! state. We achieve that the way a real first click on an inactive window would, but without
//! the visual side effect of bringing the app to the front:
//!
//! 1. Install per-process [`CGEvent`] event taps (`CGEventTapCreateForPid`) on both the
//! previously-frontmost app and the target app. The taps run on a dedicated run-loop thread
//! and drop the focus-change messages that macOS would otherwise deliver to the previous app
//! to switch the user's frontmost application.
//! 2. Send an `appKitDefined` activation primer event (an `NSEvent` of type `AppKitDefined`,
//! subtype `ApplicationActivated`) directly to the target process via `CGEventPostToPid`.
//! 3. Send a single "primer" left click to the exact center of the window. While the window is
//! inactive, macOS routes this first click through its activation flow instead of firing a
//! UI action, so it activates the window without otherwise affecting the app.
//!
//! State is tracked in a process-global registry keyed by `(pid, window)` rather than on the
//! per-action [`super::Actor`] (which is recreated for every computer-use turn). This lets a
//! single computer-use session activate a window once and reuse that activation across later
//! turns without re-sending the disruptive center click, and lets concurrent computer-use
//! sessions targeting different windows coexist while serializing each activation handshake
//! through the registry lock.
use std::collections::HashMap;
use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventSubtype, NSEventType};
use objc2_core_foundation::{CFMachPort, CFRunLoop, CGPoint, kCFRunLoopDefaultMode};
use objc2_core_graphics::{
CGEvent, CGEventTapOptions, CGEventTapPlacement, CGEventTapProxy, CGEventType, CGMouseButton,
};
use super::window::{self, WindowInfo};
/// Delay after the `appKitDefined` activation primer, giving AppKit time to process it before
/// the center primer click arrives.
const APPKIT_PRIMER_DELAY: Duration = Duration::from_millis(20);
/// Delay between the center primer's mouse-down and mouse-up.
const PRIMER_CLICK_HOLD: Duration = Duration::from_millis(30);
/// Delay after the center primer click settles.
const PRIMER_CLICK_SETTLE: Duration = Duration::from_millis(20);
/// How long each run-loop service iteration blocks before re-checking the stop flag.
const RUN_LOOP_SERVICE_INTERVAL: f64 = 0.1;
/// Ensures the window described by `info` (owned by `target_pid`) is in the AppKit-active input
/// state so background events are accepted, without raising it or moving the cursor.
///
/// The first call for a given `(pid, window)` installs focus-suppression taps and sends the
/// activation primers (including the center primer click). Subsequent calls for the same window
/// are a no-op, so the disruptive center click is never re-sent across turns. If the target app
/// is already frontmost, no activation is needed and this is a no-op.
pub fn ensure_activated(target_pid: libc::pid_t, info: &WindowInfo) {
let window_number = info.number;
if window_number <= 0 {
return;
}
let key = (target_pid, window_number);
let mut registry = registry().lock().unwrap();
if registry.contains_key(&key) {
// Already activated this window; do not re-activate (a second center click would land
// as a real click rather than being absorbed by the activation flow).
return;
}
// The window that currently owns input focus, so we can both protect it from deactivation
// and detect when the target app is already frontmost.
let previous = window::frontmost_window();
if previous.map(|(pid, _)| pid) == Some(target_pid) {
// The target app is already frontmost: events route to it normally and no focus would
// be stolen, so there is nothing to activate or suppress.
return;
}
let suppress = Arc::new(AtomicBool::new(true));
let stop = Arc::new(AtomicBool::new(false));
// Install focus-suppression taps before sending the activation click, so the focus-change
// messages it triggers are intercepted. Skip the taps if there is no distinct previous app.
let thread = match previous {
Some((previous_pid, _)) if previous_pid != target_pid => {
spawn_tap_thread(previous_pid, target_pid, suppress.clone(), stop.clone())
}
_ => None,
};
let has_taps = thread.is_some();
// Activate: AppKit activation primer, then the center-of-window primer click.
post_appkit_activation(
target_pid,
window_number,
NSEventSubtype::ApplicationActivated.0,
);
thread::sleep(APPKIT_PRIMER_DELAY);
post_center_primer(target_pid, info);
registry.insert(
key,
ActiveSession {
suppress,
stop,
thread,
has_taps,
},
);
}
/// The process-global registry of activated windows, keyed by `(pid, window_number)`.
fn registry() -> &'static Mutex<HashMap<(libc::pid_t, i64), ActiveSession>> {
static REGISTRY: OnceLock<Mutex<HashMap<(libc::pid_t, i64), ActiveSession>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Tracks an activated window: the focus-suppression tap thread (if any) and the flags used to
/// drive and tear it down.
struct ActiveSession {
/// While set, the tap callback drops focus-change messages headed to the previous app.
suppress: Arc<AtomicBool>,
/// Signals the tap thread's run loop to exit.
stop: Arc<AtomicBool>,
/// The run-loop thread servicing the taps, joined on teardown.
thread: Option<JoinHandle<()>>,
has_taps: bool,
}
impl Drop for ActiveSession {
fn drop(&mut self) {
if self.has_taps {
self.suppress.store(false, Ordering::SeqCst);
self.stop.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
}
/// Per-tap data handed to the C event-tap callback via its `user_info` pointer.
struct TapContext {
/// Shared with [`ActiveSession`]; gates focus-message suppression.
suppress: Arc<AtomicBool>,
/// Whether this tap is attached to the previously-frontmost app (whose focus messages we
/// drop) versus the target app (which we always let through).
is_previous: bool,
}
/// Spawns the run-loop thread that installs and services the focus-suppression taps for
/// `previous_pid` and `target_pid`. Returns the thread handle once both taps are installed, or
/// `None` if tap creation failed (e.g. missing permission).
fn spawn_tap_thread(
previous_pid: libc::pid_t,
target_pid: libc::pid_t,
suppress: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
) -> Option<JoinHandle<()>> {
let (ready_tx, ready_rx) = mpsc::channel();
let handle = thread::Builder::new()
.name("cu-bg-activation".to_string())
.spawn(move || run_tap_loop(previous_pid, target_pid, suppress, stop, ready_tx))
.ok()?;
match ready_rx.recv() {
Ok(true) => Some(handle),
_ => {
// Tap installation failed (or the thread died); reclaim it and report no taps.
let _ = handle.join();
None
}
}
}
/// Body of the tap thread: creates the taps on this thread's run loop, signals readiness, then
/// services the run loop until asked to stop, tearing the taps down on exit.
fn run_tap_loop(
previous_pid: libc::pid_t,
target_pid: libc::pid_t,
suppress: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
ready_tx: mpsc::Sender<bool>,
) {
let Some(run_loop) = CFRunLoop::current() else {
let _ = ready_tx.send(false);
return;
};
let mode = unsafe { kCFRunLoopDefaultMode };
// Keep the taps, their run-loop sources, and the heap-allocated contexts alive for as long
// as the run loop runs. The contexts are reclaimed (and dropped) after the loop exits.
let mut taps = Vec::new();
let mut sources = Vec::new();
let mut contexts: Vec<*mut TapContext> = Vec::new();
for (pid, is_previous) in [(previous_pid, true), (target_pid, false)] {
let context = Box::into_raw(Box::new(TapContext {
suppress: suppress.clone(),
is_previous,
}));
// SAFETY: `tap_callback` matches the `CGEventTapCallBack` ABI and `context` is a valid,
// owned `TapContext` pointer that outlives the tap (it is dropped only after the run
// loop stops, below).
let tap = unsafe {
CGEvent::tap_create_for_pid(
pid,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
u64::MAX,
Some(tap_callback),
context as *mut c_void,
)
};
let Some(tap) = tap else {
// Reclaim the context this tap would have owned.
drop(unsafe { Box::from_raw(context) });
continue;
};
let Some(source) = CFMachPort::new_run_loop_source(None, Some(&tap), 0) else {
tap.invalidate();
drop(unsafe { Box::from_raw(context) });
continue;
};
run_loop.add_source(Some(&source), mode);
CGEvent::tap_enable(&tap, true);
taps.push(tap);
sources.push(source);
contexts.push(context);
}
let installed = !taps.is_empty();
let _ = ready_tx.send(installed);
if !installed {
return;
}
// Service the taps until teardown. `run_in_mode` blocks up to the interval (sleeping when
// idle), so re-checking the flag this way costs only a brief teardown latency.
while !stop.load(Ordering::SeqCst) {
CFRunLoop::run_in_mode(mode, RUN_LOOP_SERVICE_INTERVAL, false);
}
for tap in &taps {
tap.invalidate();
}
drop(sources);
drop(taps);
for context in contexts {
drop(unsafe { Box::from_raw(context) });
}
}
/// The C event-tap callback. Returns the event to pass it through, or null to drop it.
///
/// Focus-change messages do not have a stable public `CGEventType` across macOS versions, so
/// they are identified by their raw values (13, 19, 20). When suppression is active we drop
/// those headed to the previous app (keeping it from being deactivated, i.e. keeping it the
/// user's frontmost app) while letting the target app's activation through.
unsafe extern "C-unwind" fn tap_callback(
_proxy: CGEventTapProxy,
event_type: CGEventType,
event: NonNull<CGEvent>,
user_info: *mut c_void,
) -> *mut CGEvent {
let event_ptr = event.as_ptr();
let Some(context) = (unsafe { (user_info as *const TapContext).as_ref() }) else {
return event_ptr;
};
let is_focus_message = matches!(event_type.0, 13 | 19 | 20);
if is_focus_message && context.is_previous && context.suppress.load(Ordering::SeqCst) {
return std::ptr::null_mut();
}
event_ptr
}
/// Sends an `appKitDefined` application-activation event to the target process. `subtype`
/// selects activate ([`NSEventSubtype::ApplicationActivated`]) vs. deactivate
/// ([`NSEventSubtype::ApplicationDeactivated`]).
fn post_appkit_activation(target_pid: libc::pid_t, window_number: i64, subtype: i16) {
let Some(event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
NSEventType::AppKitDefined,
CGPoint { x: 0.0, y: 0.0 },
NSEventModifierFlags::empty(),
0.0,
window_number as isize,
None,
subtype,
0,
0,
) else {
return;
};
let Some(cg_event) = event.CGEvent() else {
return;
};
// Associate the event with the target window so AppKit applies the activation to it.
super::mouse::set_window_addressing_fields(&cg_event, window_number);
CGEvent::post_to_pid(target_pid, Some(&cg_event));
}
/// Sends a single left click to the exact center of the window. On an inactive window this is
/// absorbed by the activation flow rather than firing a UI action; the center avoids the
/// title-bar traffic-light controls (which respond even when inactive).
fn post_center_primer(target_pid: libc::pid_t, info: &WindowInfo) {
let center = CGPoint {
x: info.x + info.width / 2.0,
y: info.y + info.height / 2.0,
};
super::mouse::post_window_mouse_event(
target_pid,
info,
CGEventType::LeftMouseDown,
CGMouseButton::Left,
center,
1,
1.0,
);
thread::sleep(PRIMER_CLICK_HOLD);
super::mouse::post_window_mouse_event(
target_pid,
info,
CGEventType::LeftMouseUp,
CGMouseButton::Left,
center,
1,
0.0,
);
thread::sleep(PRIMER_CLICK_SETTLE);
}
+115 -26
View File
@@ -1,37 +1,86 @@
use std::collections::HashMap;
use objc2_core_graphics::{
CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGKeyCode,
};
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGKeyCode};
use super::keycode_cache;
use crate::Key;
use super::post::PostTarget;
use super::{activation, keycode_cache, window};
use crate::{Key, Target};
/// Manages keyboard state and posts keyboard events to the system.
pub struct Keyboard {
/// Cache of character-to-keycode mappings for the current keyboard layout.
cache: HashMap<char, CGKeyCode>,
/// Where synthesized events are delivered.
post_target: PostTarget,
/// The window id and pid of the current window target, used to activate the window before
/// keyboard events are posted. `None` when targeting the HID tap (screen/frontmost behavior).
///
/// Mouse events activate the target window lazily from the event location; keyboard events
/// carry no coordinates, so activation must be triggered explicitly here instead.
window_context: Option<(u32, libc::pid_t)>,
/// The currently-held modifier flags, accumulated from modifier key-down/up events.
///
/// Synthetic modifier key events posted via `CGEventPostToPid` do not update the session's
/// modifier state, so we track it ourselves and stamp it onto every key event. Without this,
/// a shortcut sent as discrete events (e.g. Command-down, n-down, n-up, Command-up) arrives as
/// a plain "n" and is treated as text rather than as Cmd+N.
current_flags: CGEventFlags,
}
impl Keyboard {
pub fn new() -> Self {
pub fn new(target: PostTarget) -> Self {
Self {
cache: keycode_cache::build_cache(),
post_target: target,
window_context: None,
current_flags: CGEventFlags::empty(),
}
}
/// Sets where subsequent synthesized key events are delivered. Called per-action so typing
/// can be routed to a specific background process.
pub fn set_target(&mut self, target: Target) {
self.post_target = match target {
Target::Screen => PostTarget::HidTap,
Target::Window { pid, .. } => PostTarget::Pid(pid as libc::pid_t),
};
self.window_context = match target {
Target::Window { window_id, pid } => Some((window_id, pid as libc::pid_t)),
Target::Screen => None,
};
}
/// Sends a key down event for the given key.
pub fn key_down(&self, key: &Key) -> Result<(), String> {
post_key_down(self.resolve_keycode(key)?)
///
/// If the key is a modifier, its mask is folded into the held-modifier flags first so the
/// key-down itself carries the now-active modifier; the flags are then stamped on the event.
pub fn key_down(&mut self, key: &Key) -> Result<(), String> {
self.ensure_window_activated();
let keycode = self.resolve_keycode(key)?;
if let Some(mask) = modifier_mask(keycode) {
self.current_flags |= mask;
}
post_key_event(keycode, true, self.current_flags, self.post_target)
}
/// Sends a key up event for the given key.
pub fn key_up(&self, key: &Key) -> Result<(), String> {
post_key_up(self.resolve_keycode(key)?)
///
/// If the key is a modifier, its mask is removed from the held-modifier flags so the key-up
/// reflects the modifier being released; the updated flags are then stamped on the event.
///
/// Activation is not triggered here: `KeyUp` always follows a `KeyDown` that already
/// activated the window, and a lone `KeyUp` with no prior `KeyDown` is a no-op regardless.
pub fn key_up(&mut self, key: &Key) -> Result<(), String> {
let keycode = self.resolve_keycode(key)?;
if let Some(mask) = modifier_mask(keycode) {
self.current_flags &= !mask;
}
post_key_event(keycode, false, self.current_flags, self.post_target)
}
/// Simulates typing text by sending Quartz events.
pub fn type_text(&self, text: &str) -> Result<(), String> {
self.ensure_window_activated();
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
// Send one character at a time for better compatibility with various applications.
@@ -41,12 +90,27 @@ impl Keyboard {
//
// TODO(vorporeal): when sending an ASCII character, send it using virtual key codes
// for better compatibility.
type_unicode_char(ch, source.as_deref())?;
type_unicode_char(ch, source.as_deref(), self.post_target)?;
}
Ok(())
}
/// Ensures the target window is activated before keyboard events are posted.
///
/// Mouse events activate the target window lazily from the event location (see
/// [`super::mouse::Mouse`]); keyboard events carry no coordinates, so activation must be
/// triggered here instead. The call is idempotent per `(pid, window)` pair, so calling it
/// before each keyboard event is cheap and never re-sends the activation primer.
fn ensure_window_activated(&self) {
let Some((window_id, pid)) = self.window_context else {
return;
};
if let Some(info) = window::window_by_id(window_id) {
activation::ensure_activated(pid, &info);
}
}
/// Resolves a Key to a CGKeyCode.
///
/// The key can be:
@@ -69,26 +133,51 @@ impl Keyboard {
}
}
/// Posts a key down event for the given virtual keycode.
fn post_key_down(keycode: CGKeyCode) -> Result<(), String> {
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
let event = CGEvent::new_keyboard_event(source.as_deref(), keycode, true)
.ok_or_else(|| format!("Failed to create key down event for keycode {}", keycode))?;
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
Ok(())
/// Maps a modifier virtual keycode to its `CGEventFlags` mask, handling both the left and right
/// variants. Returns `None` for non-modifier keys.
fn modifier_mask(keycode: CGKeyCode) -> Option<CGEventFlags> {
Some(match keycode {
// Command: left 0x37 (55), right 0x36 (54).
54 | 55 => CGEventFlags::MaskCommand,
// Shift: left 0x38 (56), right 0x3C (60).
56 | 60 => CGEventFlags::MaskShift,
// Control: left 0x3B (59), right 0x3E (62).
59 | 62 => CGEventFlags::MaskControl,
// Option/Alt: left 0x3A (58), right 0x3D (61).
58 | 61 => CGEventFlags::MaskAlternate,
// Fn: 0x3F (63).
63 => CGEventFlags::MaskSecondaryFn,
// Caps Lock: 0x39 (57).
57 => CGEventFlags::MaskAlphaShift,
_ => return None,
})
}
/// Posts a key up event for the given virtual keycode.
fn post_key_up(keycode: CGKeyCode) -> Result<(), String> {
/// Posts a key event (down or up) for the given virtual keycode, stamping the currently-held
/// modifier flags so shortcuts route through the app's key-equivalent handling.
fn post_key_event(
keycode: CGKeyCode,
is_down: bool,
flags: CGEventFlags,
target: PostTarget,
) -> Result<(), String> {
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
let event = CGEvent::new_keyboard_event(source.as_deref(), keycode, false)
.ok_or_else(|| format!("Failed to create key up event for keycode {}", keycode))?;
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
let event =
CGEvent::new_keyboard_event(source.as_deref(), keycode, is_down).ok_or_else(|| {
let direction = if is_down { "down" } else { "up" };
format!("Failed to create key {direction} event for keycode {keycode}")
})?;
CGEvent::set_flags(Some(&event), flags);
target.post(&event);
Ok(())
}
/// Generates a Quartz event signifying the typing of a single Unicode character.
fn type_unicode_char(ch: char, source: Option<&CGEventSource>) -> Result<(), String> {
fn type_unicode_char(
ch: char,
source: Option<&CGEventSource>,
target: PostTarget,
) -> Result<(), String> {
let mut buf = [0u16; 2];
let encoded = ch.encode_utf16(&mut buf);
@@ -110,13 +199,13 @@ fn type_unicode_char(ch: char, source: Option<&CGEventSource>) -> Result<(), Str
CGEvent::set_flags(Some(&key_down), CGEventFlags::empty());
// Post the key down event.
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
target.post(&key_down);
// Create and post a corresponding key up event.
let key_up = CGEvent::new_keyboard_event(source, 0, false)
.ok_or("Failed to create key up event for TypeText.")?;
CGEvent::set_flags(Some(&key_up), CGEventFlags::empty());
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
target.post(&key_up);
Ok(())
}
+37 -32
View File
@@ -4,28 +4,26 @@
//! The translation depends on the current keyboard layout.
//!
//! The Carbon APIs used for translation (`TISCopyCurrentKeyboardInputSource`,
//! `UCKeyTranslate`) are not thread-safe, so we build the cache on the main thread
//! using GCD dispatch.
//! `UCKeyTranslate`) are not thread-safe, so all cache builds are serialized through a
//! process-wide mutex. This matters because concurrent computer-use agents can build the cache
//! from different worker threads at the same time.
use std::collections::HashMap;
use std::ptr::NonNull;
use std::sync::Mutex;
use core_foundation::base::{CFType, CFTypeRef, TCFType};
use core_foundation::data::CFData;
use dispatch2::run_on_main;
use objc2_core_foundation::{CFData, CFRetained, CFString, CFType};
use objc2_core_graphics::CGKeyCode;
// Carbon Text Input Services types and functions.
#[allow(non_camel_case_types)]
type TISInputSourceRef = CFTypeRef;
#[link(name = "Carbon", kind = "framework")]
unsafe extern "C" {
fn TISCopyCurrentKeyboardInputSource() -> TISInputSourceRef;
fn TISCopyCurrentKeyboardLayoutInputSource() -> TISInputSourceRef;
fn TISGetInputSourceProperty(source: TISInputSourceRef, key: CFTypeRef) -> CFTypeRef;
fn TISCopyCurrentKeyboardInputSource() -> Option<NonNull<CFType>>;
fn TISCopyCurrentKeyboardLayoutInputSource() -> Option<NonNull<CFType>>;
fn TISGetInputSourceProperty(source: &CFType, key: &CFString) -> *mut CFType;
// Property key for getting the keyboard layout data.
static kTISPropertyUnicodeKeyLayoutData: CFTypeRef;
static kTISPropertyUnicodeKeyLayoutData: &'static CFString;
fn LMGetKbdType() -> u8;
}
@@ -62,14 +60,20 @@ const SHIFT_MODIFIER: u32 = 1 << 1;
/// Builds a character-to-keycode cache for the current keyboard layout.
///
/// This function dispatches to the main thread to call Carbon APIs safely.
/// The Carbon translation APIs are not thread-safe, so the build is serialized through a
/// process-wide mutex. The work runs on the calling thread rather than being dispatched to the
/// main thread: in the headless `agent run` CLI the main thread never services the GCD main
/// queue, so a synchronous main-queue dispatch would deadlock.
/// TODO(QUALITY-271): Store the modifier keys as well.
pub fn build_cache() -> HashMap<char, CGKeyCode> {
run_on_main(|_| build_cache_on_main_thread())
static BUILD_LOCK: Mutex<()> = Mutex::new(());
let _guard = BUILD_LOCK.lock().unwrap();
build_cache_locked()
}
/// Builds the cache on the main thread where Carbon APIs are safe to call.
fn build_cache_on_main_thread() -> HashMap<char, CGKeyCode> {
/// Builds the cache while the process-wide build lock is held, serializing access to the
/// non-thread-safe Carbon APIs.
fn build_cache_locked() -> HashMap<char, CGKeyCode> {
let mut cache = HashMap::new();
// Get the keyboard layout data.
@@ -79,7 +83,7 @@ fn build_cache_on_main_thread() -> HashMap<char, CGKeyCode> {
return cache;
};
let layout_ptr = layout_data.as_ptr() as *const UCKeyboardLayout;
let layout_ptr = layout_data.byte_ptr() as *const UCKeyboardLayout;
let keyboard_type = unsafe { LMGetKbdType() } as u32;
// Iterate through all possible keycodes (0-127) and build the mapping.
@@ -103,35 +107,36 @@ fn build_cache_on_main_thread() -> HashMap<char, CGKeyCode> {
}
/// Gets the keyboard layout data from the current input source.
unsafe fn get_keyboard_layout_data() -> Option<CFData> {
unsafe fn get_keyboard_layout_data() -> Option<CFRetained<CFData>> {
// TISCopy* functions follow CF "Copy" semantics - caller owns the reference.
// Wrap in CFType so they're released when dropped.
let source = unsafe { CFType::wrap_under_create_rule(TISCopyCurrentKeyboardInputSource()) };
let mut layout_data = unsafe {
TISGetInputSourceProperty(source.as_CFTypeRef(), kTISPropertyUnicodeKeyLayoutData)
// Wrap in CFRetained so they're released when dropped.
let source = unsafe {
CFRetained::from_raw(
TISCopyCurrentKeyboardInputSource().expect("Attempted to create a NULL object."),
)
};
let mut layout_data =
unsafe { TISGetInputSourceProperty(&source, kTISPropertyUnicodeKeyLayoutData) };
// Some keyboard layouts (e.g., Japanese, Chinese) don't have layout data on the
// regular input source. Try the keyboard layout input source instead.
let _layout_source;
if layout_data.is_null() {
// Keep this alive until we're done with layout_data.
_layout_source =
unsafe { CFType::wrap_under_create_rule(TISCopyCurrentKeyboardLayoutInputSource()) };
layout_data = unsafe {
TISGetInputSourceProperty(
_layout_source.as_CFTypeRef(),
kTISPropertyUnicodeKeyLayoutData,
_layout_source = unsafe {
CFRetained::from_raw(
TISCopyCurrentKeyboardLayoutInputSource()
.expect("Attempted to create a NULL object."),
)
};
layout_data =
unsafe { TISGetInputSourceProperty(&_layout_source, kTISPropertyUnicodeKeyLayoutData) };
}
if layout_data.is_null() {
return None;
}
let layout_data = NonNull::new(layout_data)?;
// The returned CFData is not retained, so we need to retain it.
Some(unsafe { CFData::wrap_under_get_rule(layout_data as _) })
Some(unsafe { CFRetained::retain(layout_data.cast::<CFData>()) })
}
/// Translates a keycode to a character using UCKeyTranslate.
+153 -11
View File
@@ -1,18 +1,112 @@
mod activation;
mod keyboard;
mod keycode_cache;
mod mouse;
mod post;
mod screenshot;
mod util;
mod window;
use async_trait::async_trait;
use galaxyui::r#async::Timer;
use pathfinder_geometry::vector::Vector2I;
use post::PostTarget;
use util::{display_scale_factor_for_window, main_display_scale_factor};
use galaxyui_core::r#async::Timer;
use crate::{Action, ActionResult, Options};
use crate::{Action, ActionResult, Options, Target, TargetedAction};
pub fn is_supported_on_current_platform() -> bool {
true
}
/// Reports whether background, per-window control is available. On macOS the background input
/// stack (focus-without-raise + window-targeted posting) is present, so this is always true.
pub fn background_supported() -> bool {
true
}
/// Enumerates the on-screen windows as crate-level [`crate::WindowInfo`] records.
pub fn enumerate_windows() -> Vec<crate::WindowInfo> {
window::enumerate_windows()
}
/// Maps a computer-use [`Target`] to the lower-level [`PostTarget`] used for event delivery.
fn post_target_for(target: Target) -> PostTarget {
match target {
Target::Screen => PostTarget::HidTap,
Target::Window { pid, .. } => PostTarget::Pid(pid as libc::pid_t),
}
}
/// Returns a copy of `action` with its coordinates remapped for the given target.
///
/// For a `Window` target the incoming coordinates are window-local pixels in the captured window
/// screenshot's space; they are translated to global points using the backing scale of the
/// display containing the window, then encoded for the existing screen-pixel mouse pipeline.
/// `Screen` targets retain the legacy global-pixel behavior.
fn remap_action_for_target(action: &Action, target: Target) -> Result<Action, String> {
let Target::Window { window_id, .. } = target else {
return Ok(action.clone());
};
let remap = |p: Vector2I| -> Result<Vector2I, String> {
let info = window::window_by_id(window_id)
.ok_or_else(|| format!("Failed to resolve target window {window_id}."))?;
let pixels_per_point =
display_scale_factor_for_window(info.x, info.y, info.width, info.height).ok_or_else(
|| {
format!(
"Target window {window_id} is not fully contained on one display with a known scale factor."
)
},
)?;
let screen_scale = main_display_scale_factor();
let global_point_x = info.x + f64::from(p.x()) / pixels_per_point;
let global_point_y = info.y + f64::from(p.y()) / pixels_per_point;
let global = Vector2I::new(
(global_point_x * screen_scale).round() as i32,
(global_point_y * screen_scale).round() as i32,
);
Ok(global)
};
Ok(match action {
Action::MouseMove { to } => Action::MouseMove { to: remap(*to)? },
Action::MouseDown { button, at } => Action::MouseDown {
button: button.clone(),
at: remap(*at)?,
},
Action::MouseWheel {
at,
direction,
distance,
} => Action::MouseWheel {
at: remap(*at)?,
direction: *direction,
distance: *distance,
},
other => other.clone(),
})
}
/// Experimental: lists on-screen windows (number, owner PID/name, layer, bounds) for
/// diagnosing PID/window targeting.
pub fn list_windows() -> String {
let mut out = String::from("window# owner_pid layer bounds(x,y,w,h) owner_name\n");
for w in window::list_windows() {
out.push_str(&format!(
"{:<7} {:<9} {:<5} ({:.0},{:.0},{:.0},{:.0}) {}\n",
w.number,
w.owner_pid,
w.layer,
w.x,
w.y,
w.width,
w.height,
w.owner_name.as_deref().unwrap_or("<unknown>"),
));
}
out
}
pub struct Actor {
keyboard: keyboard::Keyboard,
mouse: mouse::Mouse,
@@ -20,9 +114,11 @@ pub struct Actor {
impl Actor {
pub fn new() -> Self {
// The post target now defaults to the HID event tap (legacy screen/frontmost behavior)
// and is overridden per-action when an action targets a specific window.
Self {
keyboard: keyboard::Keyboard::new(),
mouse: mouse::Mouse::new(),
keyboard: keyboard::Keyboard::new(PostTarget::HidTap),
mouse: mouse::Mouse::new(PostTarget::HidTap),
}
}
}
@@ -35,11 +131,42 @@ impl super::Actor for Actor {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String> {
for action in actions {
match action {
// When background computer use is disabled, force the legacy full-screen path: ignore any
// window target, deliver events through the HID tap, and treat coordinates as global
// pixels. This keeps behavior byte-identical to the pre-existing implementation.
let background = options.background_enabled;
for targeted in actions {
let target = if background {
targeted.target
} else {
Target::Screen
};
// A window target must carry a concrete window id. `0` is the "unknown" sentinel
// produced by the CLI default and by unparseable wire ids; reject it here rather than
// failing later in window resolution with an opaque message, since a well-behaved
// caller always echoes a real window id selected from the enumerated window list.
if let Target::Window { window_id: 0, .. } = target {
return Err(
"A window target requires a non-zero window id. Select a window from the \
enumerated window list."
.to_string(),
);
}
// Route this action to its target: the HID tap for screen actions, or directly to the
// owning process for a window action (without raising it or moving the cursor).
let post_target = post_target_for(target);
self.mouse.set_target(post_target);
self.keyboard.set_target(target);
// For a window target, translate window-local coordinates through the containing
// display's point mapping.
let action = remap_action_for_target(&targeted.action, target)?;
match &action {
Action::Wait(duration) => {
Timer::after(*duration).await;
}
@@ -69,15 +196,30 @@ impl super::Actor for Actor {
}
}
let screenshot = if let Some(params) = options.screenshot_params {
Some(screenshot::take(params)?)
} else {
None
let (screenshot, captured_window) = match options.screenshot_params {
Some(mut params) => {
// With background computer use disabled, never capture a specific window: force the
// legacy main-display capture, which returns no captured-window metadata.
if !background {
params.target = Target::Screen;
}
let (screenshot, captured) = screenshot::take(params)?;
(Some(screenshot), captured)
}
None => (None, None),
};
Ok(ActionResult {
screenshot,
cursor_position: Some(self.mouse.current_position()?),
// Refresh the window list so the caller has up-to-date targets to choose from. When
// background computer use is disabled, omit it so the result matches the legacy shape.
windows: if background {
window::enumerate_windows()
} else {
Vec::new()
},
captured_window,
})
}
}
+251 -24
View File
@@ -1,17 +1,19 @@
use instant::Instant;
use std::time::Duration;
use galaxyui::r#async::Timer;
use instant::Instant;
use objc2::rc::Retained;
use objc2_core_foundation::CGPoint;
use objc2_core_graphics::{
CGEvent, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGEventType, CGMouseButton,
CGEvent, CGEventField, CGEventSource, CGEventSourceStateID, CGEventType, CGMouseButton,
CGScrollEventUnit,
};
use pathfinder_geometry::vector::Vector2I;
use galaxyui_core::r#async::Timer;
use crate::{MouseButton, ScrollDirection, ScrollDistance};
use super::post::PostTarget;
use super::util::main_display_scale_factor;
use super::window;
use crate::{MouseButton, ScrollDirection, ScrollDistance};
const POSITION_POLL_INTERVAL: Duration = Duration::from_micros(500);
const POSITION_TIMEOUT: Duration = Duration::from_millis(100);
@@ -38,15 +40,31 @@ pub fn from_cgpoint(point: CGPoint) -> Vector2I {
/// Manages mouse state and posts mouse events to the system.
pub struct Mouse {
held_buttons: HeldButtons,
/// Where synthesized events are delivered.
target: PostTarget,
/// The most recently requested cursor position, in CGEvent point coordinates.
///
/// When delivering events directly to a PID, `CGEventPostToPid` does not move the real
/// cursor, so the global cursor position cannot be used to locate clicks. We track the
/// intended position here and use it as the location for button and move events.
virtual_position: CGPoint,
}
impl Mouse {
pub fn new() -> Self {
pub fn new(target: PostTarget) -> Self {
Self {
held_buttons: HeldButtons::default(),
target,
virtual_position: CGPoint { x: 0.0, y: 0.0 },
}
}
/// Sets where subsequent synthesized events are delivered. Called per-action so a batch can
/// drive the HID tap for some actions and a specific process for others.
pub fn set_target(&mut self, target: PostTarget) {
self.target = target;
}
pub async fn move_to(&mut self, target: Vector2I) -> Result<(), String> {
let (event_type, cg_button) = if let Some(held) = self.held_buttons.primary_down() {
(mouse_dragged_event_type(&held), (&held).into())
@@ -54,23 +72,44 @@ impl Mouse {
(CGEventType::MouseMoved, CGMouseButton::Left)
};
self.post_event(event_type, to_cgpoint(target), cg_button)?;
self.wait_for_position(target).await
let point = to_cgpoint(target);
self.virtual_position = point;
// A drag is part of an active click, so it carries the click state; a plain move does
// not.
let click_state = if self.held_buttons.primary_down().is_some() {
1
} else {
0
};
self.post_event(event_type, point, cg_button, click_state)?;
// `CGEventPostToPid` does not move the real cursor, so polling the global cursor
// position would always time out. Only wait when injecting through the HID tap.
if self.target.is_pid_targeted() {
Ok(())
} else {
self.wait_for_position(target).await
}
}
pub fn button_down(&mut self, button: &MouseButton) -> Result<(), String> {
let point = self.current_position_cgpoint()?;
let point = self.event_location()?;
self.held_buttons.set_down(button, true);
self.post_event(mouse_down_event_type(button), point, button.into())
self.post_event(mouse_down_event_type(button), point, button.into(), 1)
}
pub fn button_up(&mut self, button: &MouseButton) -> Result<(), String> {
let point = self.current_position_cgpoint()?;
let point = self.event_location()?;
self.held_buttons.set_down(button, false);
self.post_event(mouse_up_event_type(button), point, button.into())
self.post_event(mouse_up_event_type(button), point, button.into(), 1)
}
pub fn current_position(&mut self) -> Result<Vector2I, String> {
// In PID-targeted mode the real cursor is never moved, so report the tracked virtual
// position instead of the (unrelated) global cursor location.
if self.target.is_pid_targeted() {
return Ok(from_cgpoint(self.virtual_position));
}
let cg_point = self.current_position_cgpoint()?;
Ok(from_cgpoint(cg_point))
}
@@ -118,13 +157,25 @@ impl Mouse {
)
})?;
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
self.target.post(&event);
Ok(())
}
}
// Private implementation details.
impl Mouse {
/// Returns the location to use for a button event.
///
/// In HID mode this is the real cursor position; in PID-targeted mode the real cursor is
/// never moved, so the tracked virtual position is used instead.
fn event_location(&mut self) -> Result<CGPoint, String> {
if self.target.is_pid_targeted() {
Ok(self.virtual_position)
} else {
self.current_position_cgpoint()
}
}
/// Waits for the mouse to reach the target position, polling until it arrives
/// or times out.
async fn wait_for_position(&mut self, target: Vector2I) -> Result<(), String> {
@@ -162,28 +213,204 @@ impl Mouse {
Ok(pos)
}
/// Posts a mouse event.
///
/// `click_state` is the click count (1 for a single click, 2 for a double click, etc.) and
/// should be 0 for non-button events like plain moves. Many applications ignore synthetic
/// clicks that lack a non-zero click state, so it is set for button-down, button-up, and
/// drag events.
fn post_event(
&mut self,
event_type: CGEventType,
point: CGPoint,
button: CGMouseButton,
click_state: i64,
) -> Result<(), String> {
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
// For a PID target with an owned window under the point, deliver a window-targeted event
// directly to the owning process via `CGEventPostToPid`, without raising the window or
// moving the cursor. Falls back to a plain CGEvent via the configured target when there
// is no PID target or no owned window under the point.
if let Some(pid) = self.target.pid()
&& let Some(info) = window::window_at(pid, point.x, point.y)
{
let is_down = matches!(
event_type,
CGEventType::LeftMouseDown
| CGEventType::RightMouseDown
| CGEventType::OtherMouseDown
);
let is_move = matches!(event_type, CGEventType::MouseMoved);
// Activate the target window in the background on a hover or a press (not on drags),
// so the pre-click mouse-moved and the press both land on an active window. This is
// idempotent per window and does not raise the window or steal the user's frontmost
// focus.
if is_down || is_move {
super::activation::ensure_activated(pid, &info);
}
post_window_mouse_event(
pid,
&info,
event_type,
button,
point,
click_state,
event_pressure(event_type),
);
return Ok(());
}
let event = CGEvent::new_mouse_event(source.as_deref(), event_type, point, button)
.ok_or_else(|| {
format!(
"Failed to create mouse event (type={:?}, position=({}, {}), button={:?}). \
The cause is unknown.",
event_type, point.x, point.y, button
)
})?;
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
// Fallback: no PID target, or no owned window under the point. Post a plain event via the
// configured target (HID tap for screen targets, CGEventPostToPid for a PID target).
let event = build_plain_mouse_event(event_type, point, button, click_state)?;
self.target.post(&event);
Ok(())
}
}
/// Builds and posts a mouse event targeted at `info` (the window under `global_point`) directly
/// to the owning process via `CGEventPostToPid`.
///
/// On the `postToPid` path the WindowServer does not run its normal hit-testing, so the event
/// must carry the fields AppKit reads to route and interpret it: the click state, the pressure,
/// the target pid, the window-under-pointer number, the private window-addressing fields, and
/// the window-local location.
pub(super) fn post_window_mouse_event(
pid: libc::pid_t,
info: &window::WindowInfo,
event_type: CGEventType,
button: CGMouseButton,
global_point: CGPoint,
click_state: i64,
pressure: f64,
) {
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
let Some(event) = CGEvent::new_mouse_event(source.as_deref(), event_type, global_point, button)
else {
log::warn!("Failed to create window-targeted mouse event (type={event_type:?}).");
return;
};
// `CGEventSetWindowLocation` wants window-local coordinates with a top-left origin (the
// global screen point translated by the window origin).
let window_local = CGPoint {
x: global_point.x - info.x,
y: global_point.y - info.y,
};
if click_state > 0 {
CGEvent::set_integer_value_field(
Some(&event),
CGEventField::MouseEventClickState,
click_state,
);
}
CGEvent::set_double_value_field(Some(&event), CGEventField::MouseEventPressure, pressure);
CGEvent::set_integer_value_field(
Some(&event),
CGEventField::EventTargetUnixProcessID,
pid as i64,
);
CGEvent::set_integer_value_field(
Some(&event),
CGEventField::MouseEventWindowUnderMousePointer,
info.number,
);
CGEvent::set_integer_value_field(
Some(&event),
CGEventField::MouseEventWindowUnderMousePointerThatCanHandleThisEvent,
info.number,
);
set_window_addressing_fields(&event, info.number);
set_window_location(&event, window_local);
CGEvent::post_to_pid(pid, Some(&event));
}
/// Stamps the private window-addressing fields the WindowServer uses to route an event to a
/// specific window on the `postToPid` path: field 51 carries the target window number, and field
/// 58 flags that the window number is valid.
pub(super) fn set_window_addressing_fields(event: &CGEvent, window_number: i64) {
CGEvent::set_integer_value_field(Some(event), CGEventField(51), window_number);
CGEvent::set_integer_value_field(Some(event), CGEventField(58), 1);
}
/// Returns the pressure value for a mouse event: full pressure while a button is held (a press
/// or drag) and zero otherwise (a move or release), mirroring what a real device reports.
fn event_pressure(event_type: CGEventType) -> f64 {
match event_type {
CGEventType::LeftMouseDown
| CGEventType::RightMouseDown
| CGEventType::OtherMouseDown
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged => 1.0,
_ => 0.0,
}
}
/// Builds a plain CGEvent mouse event at the global `point`, stamping the click state. Used by the
/// non-window fallback delivery path.
fn build_plain_mouse_event(
event_type: CGEventType,
point: CGPoint,
button: CGMouseButton,
click_state: i64,
) -> Result<Retained<CGEvent>, String> {
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
let event = CGEvent::new_mouse_event(source.as_deref(), event_type, point, button).ok_or_else(
|| {
format!(
"Failed to create mouse event (type={event_type:?}, position=({}, {}), \
button={button:?}). The cause is unknown.",
point.x, point.y
)
},
)?;
if click_state > 0 {
CGEvent::set_integer_value_field(
Some(&event),
CGEventField::MouseEventClickState,
click_state,
);
}
Ok(event.into())
}
/// Sets the window-local location on a `CGEvent` via the private `CGEventSetWindowLocation`.
///
/// There is no public setter for this field, which AppKit reads on the `postToPid` delivery
/// path. The symbol is resolved once at runtime. `location` is window-local, top-left origin.
fn set_window_location(event: &CGEvent, location: CGPoint) {
use std::ffi::c_void;
use std::sync::OnceLock;
type SetWindowLocationFn = unsafe extern "C" fn(*mut c_void, CGPoint);
// The macOS value of `RTLD_DEFAULT`, used to search all loaded images for the symbol.
const RTLD_DEFAULT: *mut c_void = -2isize as *mut c_void;
static RESOLVED: OnceLock<Option<SetWindowLocationFn>> = OnceLock::new();
let resolved = RESOLVED.get_or_init(|| unsafe {
let sym = libc::dlsym(RTLD_DEFAULT, c"CGEventSetWindowLocation".as_ptr());
if sym.is_null() {
None
} else {
Some(std::mem::transmute::<*mut c_void, SetWindowLocationFn>(sym))
}
});
match resolved {
Some(set_window_location) => {
let event_ptr = event as *const CGEvent as *mut c_void;
unsafe { set_window_location(event_ptr, location) };
}
None => {
log::warn!(
"CGEventSetWindowLocation could not be resolved; background clicks may not land."
);
}
}
}
// ----------------------------------------------------------------------------
// Button state tracking
// ----------------------------------------------------------------------------
+40
View File
@@ -0,0 +1,40 @@
use objc2_core_graphics::{CGEvent, CGEventTapLocation};
/// Describes where synthesized Quartz events are delivered.
///
/// This selects between the legacy whole-screen delivery and background, per-window delivery.
/// `HidTap` reproduces the historical behavior of injecting events as if they came from real
/// hardware, while `Pid` delivers directly to a process for background, non-interfering control.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PostTarget {
/// Inject at the HID event tap, exactly as real hardware would. This moves the real
/// cursor and the event is routed to whichever application is frontmost.
HidTap,
/// Deliver the event directly to a specific process by PID via `CGEventPostToPid`. This
/// does not move the global cursor and does not require the target to be frontmost, at
/// the cost of reduced reliability (especially for mouse events).
Pid(libc::pid_t),
}
impl PostTarget {
/// Returns true when events are delivered directly to a process rather than the HID tap.
pub fn is_pid_targeted(self) -> bool {
matches!(self, PostTarget::Pid(_))
}
/// Returns the target PID, if events are delivered directly to a process.
pub fn pid(self) -> Option<libc::pid_t> {
match self {
PostTarget::Pid(pid) => Some(pid),
PostTarget::HidTap => None,
}
}
/// Posts the given event according to this target.
pub fn post(self, event: &CGEvent) {
match self {
PostTarget::HidTap => CGEvent::post(CGEventTapLocation::HIDEventTap, Some(event)),
PostTarget::Pid(pid) => CGEvent::post_to_pid(pid, Some(event)),
}
}
}
+97 -13
View File
@@ -1,11 +1,25 @@
use command::blocking::Command;
use image::GenericImageView;
use super::util::main_display_scale_factor;
use crate::ScreenshotParams;
use crate::{CapturedWindow, ScreenshotParams, Target};
/// Captures a screenshot of the main display using the built-in macOS
/// `screencapture` CLI.
pub fn take(params: ScreenshotParams) -> Result<crate::Screenshot, String> {
/// Captures a screenshot according to `params`, using the built-in macOS `screencapture` CLI.
///
/// When the params target a window, that specific window is captured (without raising it) and the
/// returned [`CapturedWindow`] describes the image so window-local coordinates can be mapped onto
/// it. Otherwise the main display is captured and the second tuple element is `None`.
pub fn take(
params: ScreenshotParams,
) -> Result<(crate::Screenshot, Option<CapturedWindow>), String> {
match params.target {
Target::Window { window_id, .. } => take_window(window_id, params),
Target::Screen => Ok((take_screen(params)?, None)),
}
}
/// Captures the main display, optionally restricted to a region (legacy behavior).
fn take_screen(params: ScreenshotParams) -> Result<crate::Screenshot, String> {
let output_dir = tempfile::tempdir()
.map_err(|e| format!("Failed to create temporary directory for screenshot: {e}"))?;
let output_path = output_dir.path().join("screenshot.png");
@@ -34,15 +48,85 @@ pub fn take(params: ScreenshotParams) -> Result<crate::Screenshot, String> {
.output()
.map_err(|e| format!("Failed to run screencapture: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
format!("exit code {}", output.status)
} else {
format!("exit code {}: {}", output.status, stderr.trim())
};
return Err(format!("screencapture failed with {detail}"));
}
check_status(&output)?;
crate::screenshot_utils::load_and_process_screenshot(&output_path, params)
}
/// Captures a single window by its `CGWindowID` without raising it, returning the processed image
/// plus metadata describing the captured pixels.
fn take_window(
window_id: u32,
params: ScreenshotParams,
) -> Result<(crate::Screenshot, Option<CapturedWindow>), String> {
let output_dir = tempfile::tempdir()
.map_err(|e| format!("Failed to create temporary directory for screenshot: {e}"))?;
let output_path = output_dir.path().join("window.png");
let output = Command::new("/usr/sbin/screencapture")
.args([
"-x", // Do not play sounds.
"-tpng", // Capture to PNG format.
"-o", // Omit the window's drop shadow.
])
// -l <windowid> captures only the window with the given id, even when it is not frontmost.
.arg("-l")
.arg(window_id.to_string())
.arg(&output_path)
.output()
.map_err(|e| format!("Failed to run screencapture: {e}"))?;
check_status(&output)?;
let image = image::ImageReader::open(&output_path)
.map_err(|e| format!("Failed to open screenshot file: {e}"))?
.decode()
.map_err(|e| format!("Failed to decode screenshot: {e}"))?;
let (full_width_px, full_height_px) = image.dimensions();
let image = if let Some(region) = params.region {
region.validate()?;
if region.bottom_right.x() as u32 > full_width_px
|| region.bottom_right.y() as u32 > full_height_px
{
return Err(format!(
"Screenshot region ({}, {}) to ({}, {}) is outside window {window_id} dimensions {full_width_px}x{full_height_px}.",
region.top_left.x(),
region.top_left.y(),
region.bottom_right.x(),
region.bottom_right.y(),
));
}
image.crop_imm(
region.top_left.x() as u32,
region.top_left.y() as u32,
(region.bottom_right.x() - region.top_left.x()) as u32,
(region.bottom_right.y() - region.top_left.y()) as u32,
)
} else {
image
};
let screenshot = crate::screenshot_utils::process_screenshot(image, params)?;
// The captured metadata refers to the native (pre-downscale) capture, so window-local pixel
// coordinates sent by the agent map directly onto the captured window image.
let captured = CapturedWindow {
window_id,
width_px: screenshot.original_width as i32,
height_px: screenshot.original_height as i32,
};
Ok((screenshot, Some(captured)))
}
/// Returns an error describing a failed `screencapture` invocation.
fn check_status(output: &std::process::Output) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
format!("exit code {}", output.status)
} else {
format!("exit code {}: {}", output.status, stderr.trim())
};
Err(format!("screencapture failed with {detail}"))
}
+61 -7
View File
@@ -2,13 +2,67 @@
///
/// This is used to convert between pixel coordinates (as returned by screenshot tools)
/// and point coordinates (as used by CGEvent and screencapture).
///
/// This intentionally avoids `NSScreen::mainScreen`, which must run on the main thread and is
/// reached via a synchronous dispatch to the main queue. In the headless `agent run` CLI the
/// main thread never services that queue, so such a dispatch deadlocks. The backing scale factor
/// is instead derived purely from thread-safe Core Graphics calls as the ratio of the main
/// display's current mode pixel width to its point width.
pub fn main_display_scale_factor() -> f64 {
use dispatch2::run_on_main;
use objc2_app_kit::NSScreen;
use objc2_core_graphics::{CGDisplayCopyDisplayMode, CGDisplayMode, CGMainDisplayID};
run_on_main(|mtm| {
NSScreen::mainScreen(mtm)
.map(|screen| screen.backingScaleFactor())
.unwrap_or(1.0)
})
let Some(mode) = CGDisplayCopyDisplayMode(CGMainDisplayID()) else {
return 1.0;
};
let width_points = CGDisplayMode::width(Some(&mode));
if width_points == 0 {
return 1.0;
}
CGDisplayMode::pixel_width(Some(&mode)) as f64 / width_points as f64
}
/// Returns the backing scale factor of the display that fully contains a window.
///
/// A window spanning displays with different backing scale factors does not have one valid
/// target-wide pixel-to-point conversion and is therefore intentionally unsupported.
pub fn display_scale_factor_for_window(x: f64, y: f64, width: f64, height: f64) -> Option<f64> {
use objc2_core_graphics::{
CGDirectDisplayID, CGDisplayBounds, CGDisplayCopyDisplayMode, CGDisplayMode, CGError,
CGGetActiveDisplayList,
};
const MAX_ACTIVE_DISPLAYS: u32 = 32;
let mut displays: [CGDirectDisplayID; MAX_ACTIVE_DISPLAYS as usize] =
[0; MAX_ACTIVE_DISPLAYS as usize];
let mut display_count = 0;
if unsafe {
CGGetActiveDisplayList(
MAX_ACTIVE_DISPLAYS,
displays.as_mut_ptr(),
&mut display_count,
)
} != CGError::Success
{
return None;
}
for id in displays.into_iter().take(display_count as usize) {
let bounds = CGDisplayBounds(id);
let contains_window = x >= bounds.origin.x
&& y >= bounds.origin.y
&& x + width <= bounds.origin.x + bounds.size.width
&& y + height <= bounds.origin.y + bounds.size.height;
if !contains_window {
continue;
}
let mode = CGDisplayCopyDisplayMode(id)?;
let width_points = CGDisplayMode::width(Some(&mode));
if width_points == 0 {
return None;
}
return Some(CGDisplayMode::pixel_width(Some(&mode)) as f64 / width_points as f64);
}
None
}
+243
View File
@@ -0,0 +1,243 @@
//! Experimental helper for locating the on-screen window under a point for a given process.
//!
//! PID-targeted synthetic mouse events (`CGEventPostToPid`) bypass the WindowServer's
//! hit-testing, so they arrive at the target process without an associated window. To let
//! AppKit route them, we reconstruct the target window (its number and bounds) so the event
//! can be built as a window-targeted `NSEvent` with window-local coordinates.
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFRetained, CFString, CFType};
use objc2_core_graphics::{
CGWindowListCopyWindowInfo, CGWindowListOption, kCGNullWindowID, kCGWindowBounds,
kCGWindowLayer, kCGWindowName, kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID,
};
type WindowDictionary = CFDictionary<CFString, CFType>;
type BoundsDictionary = CFDictionary<CFString, CFNumber>;
/// Describes an on-screen window: its window number and bounds in global screen points
/// (top-left origin), matching `kCGWindowBounds` and `CGEvent` location coordinates.
#[derive(Clone, Copy, Debug)]
pub struct WindowInfo {
pub number: i64,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl WindowInfo {
fn contains(&self, x: f64, y: f64) -> bool {
x >= self.x && y >= self.y && x < self.x + self.width && y < self.y + self.height
}
}
/// Finds the on-screen window owned by `pid` that contains the given point.
///
/// The point is in global screen points with a top-left origin. When no owned window's bounds
/// contain the point, this falls back to the frontmost normal window owned by `pid`.
pub fn window_at(pid: libc::pid_t, x: f64, y: f64) -> Option<WindowInfo> {
// The returned list is ordered front-to-back.
let info = window_list()?;
// Read the window-info keys once; accessing the framework statics is unsafe.
let owner_pid_key = unsafe { kCGWindowOwnerPID };
let layer_key = unsafe { kCGWindowLayer };
let number_key = unsafe { kCGWindowNumber };
let bounds_key = unsafe { kCGWindowBounds };
let mut fallback: Option<WindowInfo> = None;
for dict in info.iter() {
// Only consider windows owned by the target process.
if dict_i64(&dict, owner_pid_key) != Some(pid as i64) {
continue;
}
// Only consider normal (layer 0) windows; menus and similar live on other layers.
if dict_i64(&dict, layer_key) != Some(0) {
continue;
}
let (Some(number), Some((bx, by, bw, bh))) = (
dict_i64(&dict, number_key),
dict_bounds(&dict, bounds_key).and_then(|b| read_bounds(&b)),
) else {
continue;
};
let window = WindowInfo {
number,
x: bx,
y: by,
width: bw,
height: bh,
};
// Remember the frontmost owned window in case nothing contains the point.
if fallback.is_none() {
fallback = Some(window);
}
if window.contains(x, y) {
return Some(window);
}
}
fallback
}
/// Finds the on-screen window with the given `window_id`, returning its number and bounds in
/// global screen points (top-left origin). Used to resolve a `Target::Window` to concrete
/// geometry for window-local coordinate remapping and window-scoped screenshot scaling.
pub fn window_by_id(window_id: u32) -> Option<WindowInfo> {
let info = window_list()?;
let number_key = unsafe { kCGWindowNumber };
let bounds_key = unsafe { kCGWindowBounds };
for dict in info.iter() {
if dict_i64(&dict, number_key) != Some(window_id as i64) {
continue;
}
let (bx, by, bw, bh) = dict_bounds(&dict, bounds_key).and_then(|b| read_bounds(&b))?;
return Some(WindowInfo {
number: window_id as i64,
x: bx,
y: by,
width: bw,
height: bh,
});
}
None
}
/// Returns the `(owner_pid, window_number)` of the frontmost on-screen normal window, i.e. the
/// window that currently has input focus. Used to deactivate the previous window when moving
/// focus to a target without raising it.
pub fn frontmost_window() -> Option<(libc::pid_t, i64)> {
let info = window_list()?;
let owner_pid_key = unsafe { kCGWindowOwnerPID };
let layer_key = unsafe { kCGWindowLayer };
let number_key = unsafe { kCGWindowNumber };
// The list is front-to-back; the first normal (layer 0) window is the focused one.
for dict in info.iter() {
if dict_i64(&dict, layer_key) != Some(0) {
continue;
}
if let (Some(pid), Some(number)) =
(dict_i64(&dict, owner_pid_key), dict_i64(&dict, number_key))
{
return Some((pid as libc::pid_t, number));
}
}
None
}
/// A description of an on-screen window, for diagnostics and enumeration.
pub struct WindowDescription {
pub number: i64,
pub owner_pid: i64,
pub owner_name: Option<String>,
pub title: Option<String>,
pub layer: i64,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
/// Enumerates on-screen windows as crate-level [`crate::WindowInfo`] records, so the agent can
/// pick a window to target. Ordered front-to-back, excluding desktop elements.
pub fn enumerate_windows() -> Vec<crate::WindowInfo> {
list_windows()
.into_iter()
.map(|w| crate::WindowInfo {
window_id: w.number.max(0) as u32,
pid: w.owner_pid as i32,
app_name: w.owner_name.unwrap_or_default(),
title: w.title.unwrap_or_default(),
layer: w.layer as i32,
})
.collect()
}
/// Lists on-screen windows (excluding desktop elements), front-to-back, for diagnostics.
pub fn list_windows() -> Vec<WindowDescription> {
let Some(info) = window_list() else {
return Vec::new();
};
let owner_pid_key = unsafe { kCGWindowOwnerPID };
let owner_name_key = unsafe { kCGWindowOwnerName };
let name_key = unsafe { kCGWindowName };
let layer_key = unsafe { kCGWindowLayer };
let number_key = unsafe { kCGWindowNumber };
let bounds_key = unsafe { kCGWindowBounds };
let mut windows = Vec::new();
for dict in info.iter() {
let (Some(number), Some(owner_pid)) =
(dict_i64(&dict, number_key), dict_i64(&dict, owner_pid_key))
else {
continue;
};
let (bx, by, bw, bh) = dict_bounds(&dict, bounds_key)
.and_then(|b| read_bounds(&b))
.unwrap_or((0.0, 0.0, 0.0, 0.0));
windows.push(WindowDescription {
number,
owner_pid,
owner_name: dict_string(&dict, owner_name_key),
// The window title requires the Screen Recording permission to be readable; it is
// often empty otherwise.
title: dict_string(&dict, name_key),
layer: dict_i64(&dict, layer_key).unwrap_or(0),
x: bx,
y: by,
width: bw,
height: bh,
});
}
windows
}
/// Returns the on-screen window list with its documented key and value types.
fn window_list() -> Option<CFRetained<CFArray<WindowDictionary>>> {
let option =
CGWindowListOption::OptionOnScreenOnly | CGWindowListOption::ExcludeDesktopElements;
let info = CGWindowListCopyWindowInfo(option, kCGNullWindowID)?;
// SAFETY: Core Graphics documents the result as an array of dictionaries with CFString
// keys and heterogeneous CFType values.
Some(unsafe { CFRetained::cast_unchecked(info) })
}
/// Reads a string value from a window dictionary.
fn dict_string(dict: &WindowDictionary, key: &CFString) -> Option<String> {
Some(dict.get(key)?.downcast::<CFString>().ok()?.to_string())
}
/// Reads an integer value from a window dictionary.
fn dict_i64(dict: &WindowDictionary, key: &CFString) -> Option<i64> {
dict.get(key)?.downcast::<CFNumber>().ok()?.as_i64()
}
/// Reads a bounds dictionary from a window dictionary.
fn dict_bounds(dict: &WindowDictionary, key: &CFString) -> Option<CFRetained<BoundsDictionary>> {
let bounds = dict.get(key)?.downcast::<CFDictionary>().ok()?;
// SAFETY: Core Graphics documents kCGWindowBounds as a dictionary with CFString keys and
// CFNumber values.
Some(unsafe { CFRetained::cast_unchecked(bounds) })
}
/// Reads the `X`, `Y`, `Width`, `Height` numbers from a `kCGWindowBounds` dictionary.
fn read_bounds(bounds: &BoundsDictionary) -> Option<(f64, f64, f64, f64)> {
let get = |name: &'static str| -> Option<f64> {
let key = CFString::from_static_str(name);
bounds.get(&key)?.as_f64()
};
Some((get("X")?, get("Y")?, get("Width")?, get("Height")?))
}
+8 -5
View File
@@ -6,6 +6,12 @@ pub fn is_supported_on_current_platform() -> bool {
false
}
/// Reports whether background, per-window control is available. The noop backend performs no
/// real actions, so per-window background control is unsupported.
pub fn background_supported() -> bool {
false
}
pub struct Actor;
impl Actor {
@@ -22,12 +28,9 @@ impl super::Actor for Actor {
async fn perform_actions(
&mut self,
_actions: &[super::Action],
_actions: &[super::TargetedAction],
_options: super::Options,
) -> Result<ActionResult, String> {
Ok(ActionResult {
screenshot: None,
cursor_position: None,
})
Ok(ActionResult::legacy(None, None))
}
}
+16 -7
View File
@@ -7,12 +7,12 @@ mod mouse;
mod screenshot;
use async_trait::async_trait;
use galaxyui::r#async::Timer;
use galaxyui_core::r#async::Timer;
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, OpenInputDesktop,
};
use crate::{Action, ActionResult, Options};
use crate::{Action, ActionResult, Options, TargetedAction};
/// Returns whether computer_use can drive input on this machine right now.
///
@@ -24,6 +24,12 @@ pub fn is_supported_on_current_platform() -> bool {
probe_input_desktop_available()
}
/// Reports whether background, per-window control is available. The Windows input stack drives the
/// screen / foreground window, so per-window background control is unsupported.
pub fn background_supported() -> bool {
false
}
/// Shared probe used by both [`is_supported_on_current_platform`] and [`Actor::new`] so the
/// "can we drive input right now?" logic lives in one place. This still runs the probe on each
/// call (it's a cheap `OpenInputDesktop` / `CloseDesktop` round-trip) — we don't cache it because
@@ -103,7 +109,7 @@ impl super::Actor for Actor {
async fn perform_actions(
&mut self,
actions: &[Action],
actions: &[TargetedAction],
options: Options,
) -> Result<ActionResult, String> {
// Probe at the top of every call so transient loss of the input desktop (workstation
@@ -115,7 +121,10 @@ impl super::Actor for Actor {
let keyboard = &mut self.keyboard;
let mouse = &mut self.mouse;
for action in actions {
for targeted in actions {
// Per-window targeting is not supported on Windows; act on the screen / foreground
// window regardless of the requested target.
let action: &Action = &targeted.action;
match action {
Action::Wait(duration) => {
Timer::after(*duration).await;
@@ -152,9 +161,9 @@ impl super::Actor for Actor {
None
};
Ok(ActionResult {
Ok(ActionResult::legacy(
screenshot,
cursor_position: Some(mouse.current_position()?),
})
Some(mouse.current_position()?),
))
}
}