Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
[package]
|
||||
name = "computer_use"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
default-run = "use_computer"
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "use_computer"
|
||||
path = "src/bin/use_computer.rs"
|
||||
|
||||
[features]
|
||||
test-util = []
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
cfg-if.workspace = true
|
||||
clap.workspace = true
|
||||
log.workspace = true
|
||||
pathfinder_geometry.workspace = true
|
||||
serde.workspace = true
|
||||
serde_with.workspace = true
|
||||
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
|
||||
objc2.workspace = true
|
||||
objc2-app-kit.workspace = true
|
||||
objc2-core-foundation.workspace = true
|
||||
objc2-core-graphics.workspace = true
|
||||
tempfile.workspace = true
|
||||
warpui.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ashpd.workspace = true
|
||||
futures.workspace = true
|
||||
image.workspace = true
|
||||
url.workspace = true
|
||||
warpui.workspace = true
|
||||
x11rb = { workspace = true, features = ["xtest"] }
|
||||
zbus.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
image.workspace = true
|
||||
warpui.workspace = true
|
||||
windows = { workspace = true, features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_StationsAndDesktops",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
computer_use = { path = ".", features = ["test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
cfg_aliases = "0.2.1"
|
||||
@@ -0,0 +1,9 @@
|
||||
use cfg_aliases::cfg_aliases;
|
||||
|
||||
fn main() {
|
||||
cfg_aliases! {
|
||||
macos: { target_os = "macos" },
|
||||
linux: { target_os = "linux" },
|
||||
noop: { not(any(macos, linux, windows)) },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! A CLI tool for manually testing computer use actions.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use computer_use::{
|
||||
Action, Key, MouseButton, Options, ScreenshotParams, ScreenshotRegion, Vector2I,
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "use_computer")]
|
||||
#[command(about = "Manually test computer use actions")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Perform a mouse click (mouse down + mouse up) at a position.
|
||||
Click {
|
||||
/// X coordinate.
|
||||
x: i32,
|
||||
/// Y coordinate.
|
||||
y: i32,
|
||||
/// Which mouse button to click.
|
||||
#[arg(short, long, default_value = "left")]
|
||||
button: Button,
|
||||
},
|
||||
/// Type text using the keyboard.
|
||||
Text {
|
||||
/// The text to type.
|
||||
text: String,
|
||||
},
|
||||
/// Take a screenshot and save it to a file.
|
||||
Screenshot {
|
||||
/// Output file path (PNG format).
|
||||
output: PathBuf,
|
||||
/// Optional region to capture as "x1,y1,x2,y2" (top-left and bottom-right coordinates).
|
||||
/// If not specified, captures the full display.
|
||||
#[arg(short, long, value_parser = parse_region)]
|
||||
region: Option<(i32, i32, i32, i32)>,
|
||||
},
|
||||
/// Press a key (key down + key up).
|
||||
Keypress {
|
||||
/// The key to press. Can be a single character (e.g., "a") or a keycode (e.g., "0x24" for Return on macOS).
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, ValueEnum)]
|
||||
enum Button {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
impl From<Button> for MouseButton {
|
||||
fn from(button: Button) -> Self {
|
||||
match button {
|
||||
Button::Left => MouseButton::Left,
|
||||
Button::Right => MouseButton::Right,
|
||||
Button::Middle => MouseButton::Middle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a region string "x1,y1,x2,y2" into a tuple of coordinates.
|
||||
fn parse_region(s: &str) -> Result<(i32, i32, i32, i32), String> {
|
||||
let parts: Vec<&str> = s.split(',').collect();
|
||||
if parts.len() != 4 {
|
||||
return Err("Region must be specified as 'x1,y1,x2,y2'".to_string());
|
||||
}
|
||||
let x1 = parts[0]
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|_| format!("Invalid x1: {}", parts[0]))?;
|
||||
let y1 = parts[1]
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|_| format!("Invalid y1: {}", parts[1]))?;
|
||||
let x2 = parts[2]
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|_| format!("Invalid x2: {}", parts[2]))?;
|
||||
let y2 = parts[3]
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|_| format!("Invalid y2: {}", parts[3]))?;
|
||||
Ok((x1, y1, x2, y2))
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let mut actor = computer_use::create_actor();
|
||||
|
||||
let (actions, screenshot_params, output_path) = match cli.command {
|
||||
Command::Click { x, y, button } => {
|
||||
let pos = Vector2I::new(x, y);
|
||||
let button: MouseButton = button.into();
|
||||
(
|
||||
vec![
|
||||
Action::MouseDown {
|
||||
button: button.clone(),
|
||||
at: pos,
|
||||
},
|
||||
Action::MouseUp { button },
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
Command::Text { text } => (vec![Action::TypeText { text }], None, None),
|
||||
Command::Screenshot { output, region } => {
|
||||
let region = region.map(|(x1, y1, x2, y2)| ScreenshotRegion {
|
||||
top_left: Vector2I::new(x1, y1),
|
||||
bottom_right: Vector2I::new(x2, y2),
|
||||
});
|
||||
(
|
||||
vec![],
|
||||
Some(ScreenshotParams {
|
||||
max_long_edge_px: None,
|
||||
max_total_px: None,
|
||||
region,
|
||||
}),
|
||||
Some(output),
|
||||
)
|
||||
}
|
||||
Command::Keypress { key } => {
|
||||
// Parse key: if it starts with "0x", treat as keycode; otherwise as character
|
||||
let key = if key.starts_with("0x") || key.starts_with("0X") {
|
||||
let keycode = i32::from_str_radix(&key[2..], 16).unwrap_or_else(|_| {
|
||||
eprintln!("Invalid keycode: {key}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
Key::Keycode(keycode)
|
||||
} else {
|
||||
let mut chars = key.chars();
|
||||
let ch = chars.next().unwrap_or_else(|| {
|
||||
eprintln!("Key cannot be empty");
|
||||
std::process::exit(1);
|
||||
});
|
||||
if chars.next().is_some() {
|
||||
eprintln!("Key must be a single character, got: {key}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Key::Char(ch)
|
||||
};
|
||||
(
|
||||
vec![Action::KeyDown { key: key.clone() }, Action::KeyUp { key }],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let options = Options { screenshot_params };
|
||||
|
||||
match actor.perform_actions(&actions, options).await {
|
||||
Ok(result) => {
|
||||
if let Some(pos) = result.cursor_position {
|
||||
println!("Cursor position: ({}, {})", pos.x(), pos.y());
|
||||
}
|
||||
if let Some(screenshot) = result.screenshot
|
||||
&& let Some(path) = output_path
|
||||
{
|
||||
if let Err(e) = std::fs::write(&path, &screenshot.data) {
|
||||
eprintln!("Failed to write screenshot: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"Screenshot saved to {} ({}x{})",
|
||||
path.display(),
|
||||
screenshot.width,
|
||||
screenshot.height
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
#[cfg_attr(macos, path = "mac/mod.rs")]
|
||||
#[cfg_attr(linux, path = "linux/mod.rs")]
|
||||
#[cfg_attr(windows, path = "windows/mod.rs")]
|
||||
#[cfg(not(noop))]
|
||||
mod imp;
|
||||
mod noop;
|
||||
#[cfg(any(macos, linux, windows))]
|
||||
mod screenshot_utils;
|
||||
|
||||
// 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};
|
||||
|
||||
/// The platform that computer use is running on.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mac,
|
||||
Windows,
|
||||
LinuxX11,
|
||||
LinuxWayland,
|
||||
}
|
||||
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
if cfg!(feature = "test-util") {
|
||||
noop::is_supported_on_current_platform()
|
||||
} else {
|
||||
imp::is_supported_on_current_platform()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an actor that can perform actions on the computer.
|
||||
pub fn create_actor() -> Box<dyn Actor> {
|
||||
if cfg!(feature = "test-util") {
|
||||
Box::new(noop::Actor::new())
|
||||
} else {
|
||||
Box::new(imp::Actor::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Actor: Send + Sync + 'static {
|
||||
/// Returns the platform that this actor is running on, if known.
|
||||
fn platform(&self) -> Option<Platform>;
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String>;
|
||||
}
|
||||
|
||||
/// A key that can be pressed or released.
|
||||
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Key {
|
||||
/// A platform-specific keycode. On macOS and Windows, this is a virtual keycode.
|
||||
/// On Linux, this is an X11 keysym.
|
||||
Keycode(i32),
|
||||
/// A character key (e.g., 'a', '+'). On Windows, `Key::Char` only supports characters in
|
||||
/// the Basic Multilingual Plane (BMP, `U+0000`–`U+FFFF`). Supplementary-plane characters
|
||||
/// (emoji, some CJK extension blocks, etc.) will return an error; use `TypeText` instead for
|
||||
/// those.
|
||||
Char(char),
|
||||
}
|
||||
|
||||
/// The actions that an actor can perform on the computer.
|
||||
#[serde_as]
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
Wait(#[serde_as(as = "DurationSecondsWithFrac<f64>")] std::time::Duration),
|
||||
MouseDown {
|
||||
button: MouseButton,
|
||||
#[serde(with = "Vector2IDef")]
|
||||
at: Vector2I,
|
||||
},
|
||||
MouseUp {
|
||||
button: MouseButton,
|
||||
},
|
||||
MouseMove {
|
||||
#[serde(with = "Vector2IDef")]
|
||||
to: Vector2I,
|
||||
},
|
||||
MouseWheel {
|
||||
#[serde(with = "Vector2IDef")]
|
||||
at: Vector2I,
|
||||
direction: ScrollDirection,
|
||||
distance: ScrollDistance,
|
||||
},
|
||||
TypeText {
|
||||
text: String,
|
||||
},
|
||||
KeyDown {
|
||||
key: Key,
|
||||
},
|
||||
KeyUp {
|
||||
key: Key,
|
||||
},
|
||||
}
|
||||
|
||||
/// The direction of a scroll action.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ScrollDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The distance of a scroll action.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ScrollDistance {
|
||||
/// Scroll by a number of pixels.
|
||||
Pixels(i32),
|
||||
/// Scroll by a number of discrete "clicks" (wheel notches).
|
||||
Clicks(i32),
|
||||
}
|
||||
|
||||
/// A rectangular region defined by top-left and bottom-right corners.
|
||||
/// Coordinates are in physical screen pixels (same coordinate space as mouse actions).
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotRegion {
|
||||
#[serde(with = "Vector2IDef")]
|
||||
pub top_left: Vector2I,
|
||||
#[serde(with = "Vector2IDef")]
|
||||
pub bottom_right: Vector2I,
|
||||
}
|
||||
|
||||
impl ScreenshotRegion {
|
||||
/// Validates that the region has valid coordinates for screenshot capture.
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - `top_left` has negative coordinates
|
||||
/// - `bottom_right` is not strictly greater than `top_left` in both dimensions
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.top_left.x() < 0 || self.top_left.y() < 0 {
|
||||
return Err(format!(
|
||||
"Screenshot region top_left must be non-negative, got ({}, {})",
|
||||
self.top_left.x(),
|
||||
self.top_left.y()
|
||||
));
|
||||
}
|
||||
if self.bottom_right.x() <= self.top_left.x() {
|
||||
return Err(format!(
|
||||
"Screenshot region must have positive width (bottom_right.x {} must be > top_left.x {})",
|
||||
self.bottom_right.x(),
|
||||
self.top_left.x()
|
||||
));
|
||||
}
|
||||
if self.bottom_right.y() <= self.top_left.y() {
|
||||
return Err(format!(
|
||||
"Screenshot region must have positive height (bottom_right.y {} must be > top_left.y {})",
|
||||
self.bottom_right.y(),
|
||||
self.top_left.y()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for taking a screenshot after actions.
|
||||
/// If provided, a screenshot will be taken; if `None`, no screenshot is taken.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ScreenshotParams {
|
||||
/// The maximum length of the long edge of the screenshot in pixels.
|
||||
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.
|
||||
#[serde(default)]
|
||||
pub region: Option<ScreenshotRegion>,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
/// The buttons of a mouse.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum MouseButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
/// Mouse button 3 (Back).
|
||||
Back,
|
||||
/// Mouse button 4 (Forward).
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// The result of performing an action.
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct ActionResult {
|
||||
pub screenshot: Option<Screenshot>,
|
||||
pub cursor_position: Option<Vector2I>,
|
||||
}
|
||||
|
||||
/// A simple representation of a screenshot.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct Screenshot {
|
||||
/// The width of the screenshot image data in pixels.
|
||||
pub width: usize,
|
||||
/// The height of the screenshot image data in pixels.
|
||||
pub height: usize,
|
||||
/// The original width of the screenshot before any downscaling was applied.
|
||||
pub original_width: usize,
|
||||
/// The original height of the screenshot before any downscaling was applied.
|
||||
pub original_height: usize,
|
||||
// TODO(AGENT-2283): consider making this a type that is cheap to clone
|
||||
// (e.g.: `Arc<[u8]>`)
|
||||
pub data: Vec<u8>,
|
||||
pub mime_type: Cow<'static, str>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Screenshot {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Screenshot")
|
||||
.field("width", &self.width)
|
||||
.field("height", &self.height)
|
||||
.field("original_width", &self.original_width)
|
||||
.field("original_height", &self.original_height)
|
||||
.field("num_data_bytes", &self.data.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remote derive helper for `Vector2I` from `pathfinder_geometry`.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "Vector2I")]
|
||||
struct Vector2IDef {
|
||||
#[serde(getter = "get_vector2i_x")]
|
||||
x: i32,
|
||||
#[serde(getter = "get_vector2i_y")]
|
||||
y: i32,
|
||||
}
|
||||
|
||||
fn get_vector2i_x(v: &Vector2I) -> i32 {
|
||||
v.x()
|
||||
}
|
||||
|
||||
fn get_vector2i_y(v: &Vector2I) -> i32 {
|
||||
v.y()
|
||||
}
|
||||
|
||||
impl From<Vector2IDef> for Vector2I {
|
||||
fn from(def: Vector2IDef) -> Self {
|
||||
Vector2I::new(def.x, def.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Shared X11 keysym utilities for Linux keyboard input.
|
||||
//!
|
||||
//! Both X11 and Wayland (via the RemoteDesktop portal) use X11 keysyms
|
||||
//! for keyboard input, so this module provides common conversion functions.
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
/// Keysym range for uppercase ASCII letters (A-Z).
|
||||
pub const UPPERCASE_KEYSYMS: RangeInclusive<u32> = 0x41..=0x5A;
|
||||
|
||||
/// X11 keysym for left shift key.
|
||||
pub const XK_SHIFT_L: u32 = 0xFFE1;
|
||||
|
||||
/// Converts a Unicode character to an X11 keysym.
|
||||
pub fn char_to_keysym(ch: char) -> u32 {
|
||||
let code = ch as u32;
|
||||
|
||||
// ASCII characters map directly to keysyms for the printable range.
|
||||
if (0x20..=0x7E).contains(&code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// Latin-1 supplement (0x80-0xFF) also maps directly.
|
||||
if (0xA0..=0xFF).contains(&code) {
|
||||
return code;
|
||||
}
|
||||
|
||||
// For other Unicode characters, X11 uses the Unicode value + 0x01000000.
|
||||
0x01000000 | code
|
||||
}
|
||||
|
||||
/// Returns true if the keysym requires shift to be pressed.
|
||||
///
|
||||
/// This is a simple heuristic based on uppercase letters. Callers with access
|
||||
/// to keyboard mapping data may want to use more sophisticated logic.
|
||||
pub fn keysym_needs_shift(keysym: u32) -> bool {
|
||||
UPPERCASE_KEYSYMS.contains(&keysym)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
mod keysym;
|
||||
mod wayland;
|
||||
mod x11;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{Action, ActionResult, Options};
|
||||
|
||||
/// Returns true if a Wayland environment is available.
|
||||
fn is_wayland_available() -> bool {
|
||||
std::env::var("WAYLAND_DISPLAY")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns true if an X11 environment is available.
|
||||
fn is_x11_available() -> bool {
|
||||
std::env::var("DISPLAY")
|
||||
.map(|v| !v.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
is_wayland_available() || is_x11_available()
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
inner: ActorInner,
|
||||
}
|
||||
|
||||
enum ActorInner {
|
||||
/// Wayland environment (uses XDG portals for input and screenshots).
|
||||
Wayland(Box<wayland::Actor>),
|
||||
/// X11 environment (uses XTEST for input).
|
||||
X11(Box<x11::Actor>),
|
||||
/// No supported display server available.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Self {
|
||||
let inner = if is_wayland_available() {
|
||||
// On Wayland, use native XDG portals for input and screenshots.
|
||||
match wayland::Actor::new() {
|
||||
Ok(actor) => ActorInner::Wayland(Box::new(actor)),
|
||||
Err(e) => {
|
||||
log::error!("Failed to create Wayland actor: {e}");
|
||||
ActorInner::Unsupported
|
||||
}
|
||||
}
|
||||
} else if is_x11_available() {
|
||||
// Pure X11 environment.
|
||||
match x11::Actor::new() {
|
||||
Ok(actor) => ActorInner::X11(Box::new(actor)),
|
||||
Err(e) => {
|
||||
log::error!("Failed to create X11 actor: {e}");
|
||||
ActorInner::Unsupported
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ActorInner::Unsupported
|
||||
};
|
||||
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::Actor for Actor {
|
||||
fn platform(&self) -> Option<super::Platform> {
|
||||
match &self.inner {
|
||||
ActorInner::Wayland(actor) => actor.platform(),
|
||||
ActorInner::X11(actor) => actor.platform(),
|
||||
ActorInner::Unsupported => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
match &mut self.inner {
|
||||
ActorInner::Wayland(actor) => actor.perform_actions(actions, options).await,
|
||||
ActorInner::X11(actor) => actor.perform_actions(actions, options).await,
|
||||
ActorInner::Unsupported => Err(
|
||||
"Computer use is not available: No supported display server detected. \
|
||||
X11 or Wayland is required."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Keyboard input handling for Wayland via the RemoteDesktop portal.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ashpd::desktop::Session;
|
||||
use ashpd::desktop::remote_desktop::{KeyState, RemoteDesktop};
|
||||
|
||||
use super::super::keysym::{XK_SHIFT_L, char_to_keysym, keysym_needs_shift};
|
||||
use crate::Key;
|
||||
|
||||
/// Keyboard state for tracking auto-shifted keys.
|
||||
pub struct Keyboard {
|
||||
/// Keysyms currently held that required auto-shift.
|
||||
/// When non-empty, shift is being held by us.
|
||||
auto_shift_keys: HashSet<i32>,
|
||||
}
|
||||
|
||||
impl Keyboard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
auto_shift_keys: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Types a string of text by sending keysym events.
|
||||
pub async fn type_text<'a>(
|
||||
&mut self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
for ch in text.chars() {
|
||||
self.type_char(remote_desktop, session, ch).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a key down event for the given key.
|
||||
///
|
||||
/// For `Key::Char`, this will automatically press shift if needed.
|
||||
pub async fn key_down<'a>(
|
||||
&mut self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
key: &Key,
|
||||
) -> Result<(), String> {
|
||||
let (keysym, needs_shift) = self.resolve_key(key);
|
||||
|
||||
if needs_shift {
|
||||
// Press shift only if this is the first auto-shifted key.
|
||||
if self.auto_shift_keys.is_empty() {
|
||||
self.press_shift(remote_desktop, session).await?;
|
||||
}
|
||||
self.auto_shift_keys.insert(keysym);
|
||||
}
|
||||
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, keysym, KeyState::Pressed)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send key down: {e}"))
|
||||
}
|
||||
|
||||
/// Sends a key up event for the given key.
|
||||
///
|
||||
/// For `Key::Char`, this will automatically release shift if it was auto-pressed
|
||||
/// and this is the last auto-shifted key being released.
|
||||
pub async fn key_up<'a>(
|
||||
&mut self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
key: &Key,
|
||||
) -> Result<(), String> {
|
||||
let (keysym, _) = self.resolve_key(key);
|
||||
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, keysym, KeyState::Released)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send key up: {e}"))?;
|
||||
|
||||
// Release shift only if this key was auto-shifted and it's the last one.
|
||||
if self.auto_shift_keys.remove(&keysym) && self.auto_shift_keys.is_empty() {
|
||||
self.release_shift(remote_desktop, session).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn type_char<'a>(
|
||||
&mut self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
ch: char,
|
||||
) -> Result<(), String> {
|
||||
let keysym = char_to_keysym(ch) as i32;
|
||||
let needs_shift = keysym_needs_shift(keysym as u32);
|
||||
|
||||
// Press shift if needed, then press and release the key, then release shift.
|
||||
if needs_shift {
|
||||
self.press_shift(remote_desktop, session).await?;
|
||||
}
|
||||
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, keysym, KeyState::Pressed)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send key press: {e}"))?;
|
||||
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, keysym, KeyState::Released)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send key release: {e}"))?;
|
||||
|
||||
if needs_shift {
|
||||
self.release_shift(remote_desktop, session).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a `Key` to a keysym and shift requirement.
|
||||
fn resolve_key(&self, key: &Key) -> (i32, bool) {
|
||||
match key {
|
||||
Key::Keycode(keysym) => {
|
||||
// Key::Keycode uses X11 keysyms, which we can use directly.
|
||||
(*keysym, false)
|
||||
}
|
||||
Key::Char(ch) => {
|
||||
let keysym = char_to_keysym(*ch) as i32;
|
||||
let needs_shift = keysym_needs_shift(keysym as u32);
|
||||
(keysym, needs_shift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn press_shift<'a>(
|
||||
&self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
) -> Result<(), String> {
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Pressed)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to press shift: {e}"))
|
||||
}
|
||||
|
||||
async fn release_shift<'a>(
|
||||
&self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
) -> Result<(), String> {
|
||||
remote_desktop
|
||||
.notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Released)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to release shift: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Wayland implementation of computer use actions.
|
||||
//!
|
||||
//! This module handles the Wayland environment using native XDG portals:
|
||||
//! - RemoteDesktop portal for input injection (keyboard, mouse)
|
||||
//! - ScreenCast portal for absolute pointer positioning (stream IDs)
|
||||
//! - Screenshot portal for taking screenshots
|
||||
|
||||
mod keyboard;
|
||||
mod mouse;
|
||||
mod screenshot;
|
||||
mod session;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::{Action, ActionResult, Options};
|
||||
|
||||
use keyboard::Keyboard;
|
||||
use mouse::Mouse;
|
||||
use session::PortalSession;
|
||||
|
||||
/// An actor that performs computer use actions on Wayland via XDG portals.
|
||||
pub struct Actor {
|
||||
/// The portal session, created lazily on first use.
|
||||
session: Option<PortalSession<'static>>,
|
||||
/// Keyboard state for tracking shift and other modifiers.
|
||||
keyboard: Keyboard,
|
||||
/// Mouse state for tracking position.
|
||||
mouse: Mouse,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
session: None,
|
||||
keyboard: Keyboard::new(),
|
||||
mouse: Mouse::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures a portal session is available, creating one if needed.
|
||||
async fn ensure_session(&mut self) -> Result<(), String> {
|
||||
if self.session.is_none() {
|
||||
self.session = Some(PortalSession::new().await?);
|
||||
// Wait for the permission dialog to fully dismiss before returning.
|
||||
Timer::after(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::Actor for Actor {
|
||||
fn platform(&self) -> Option<crate::Platform> {
|
||||
Some(crate::Platform::LinuxWayland)
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
// Ensure we have an active session before processing actions.
|
||||
// This may show a permission dialog on first use.
|
||||
self.ensure_session().await?;
|
||||
|
||||
let mut last_mouse_position: Option<Vector2I> = None;
|
||||
|
||||
for action in actions {
|
||||
// Re-acquire session reference each iteration (borrow checker workaround).
|
||||
let session = self
|
||||
.session
|
||||
.as_ref()
|
||||
.expect("session must exist after ensure_session");
|
||||
let remote_desktop = session.remote_desktop();
|
||||
let portal_session = session.session();
|
||||
let stream_id = session.stream_id();
|
||||
|
||||
match action {
|
||||
Action::Wait(duration) => {
|
||||
Timer::after(*duration).await;
|
||||
}
|
||||
Action::MouseDown { button, at } => {
|
||||
session.require_pointer()?;
|
||||
self.mouse
|
||||
.move_to(remote_desktop, portal_session, stream_id, *at)
|
||||
.await?;
|
||||
self.mouse
|
||||
.button_down(remote_desktop, portal_session, button)
|
||||
.await?;
|
||||
last_mouse_position = Some(*at);
|
||||
}
|
||||
Action::MouseUp { button } => {
|
||||
session.require_pointer()?;
|
||||
self.mouse
|
||||
.button_up(remote_desktop, portal_session, button)
|
||||
.await?;
|
||||
}
|
||||
Action::MouseMove { to } => {
|
||||
session.require_pointer()?;
|
||||
self.mouse
|
||||
.move_to(remote_desktop, portal_session, stream_id, *to)
|
||||
.await?;
|
||||
last_mouse_position = Some(*to);
|
||||
}
|
||||
Action::MouseWheel {
|
||||
at,
|
||||
direction,
|
||||
distance,
|
||||
} => {
|
||||
session.require_pointer()?;
|
||||
self.mouse
|
||||
.move_to(remote_desktop, portal_session, stream_id, *at)
|
||||
.await?;
|
||||
self.mouse
|
||||
.scroll(remote_desktop, portal_session, direction, distance)
|
||||
.await?;
|
||||
last_mouse_position = Some(*at);
|
||||
}
|
||||
Action::TypeText { text } => {
|
||||
session.require_keyboard()?;
|
||||
self.keyboard
|
||||
.type_text(remote_desktop, portal_session, text)
|
||||
.await?;
|
||||
}
|
||||
Action::KeyDown { key } => {
|
||||
session.require_keyboard()?;
|
||||
self.keyboard
|
||||
.key_down(remote_desktop, portal_session, key)
|
||||
.await?;
|
||||
}
|
||||
Action::KeyUp { key } => {
|
||||
session.require_keyboard()?;
|
||||
self.keyboard
|
||||
.key_up(remote_desktop, portal_session, key)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take screenshot if requested.
|
||||
let screenshot = if let Some(params) = options.screenshot_params {
|
||||
Some(screenshot::take(params).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the final cursor position.
|
||||
let cursor_position = if let Some(pos) = last_mouse_position {
|
||||
Some(pos)
|
||||
} else {
|
||||
self.mouse.last_position()
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
screenshot,
|
||||
cursor_position,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Mouse input handling for Wayland via the RemoteDesktop portal.
|
||||
|
||||
use ashpd::desktop::Session;
|
||||
use ashpd::desktop::remote_desktop::{Axis, KeyState, RemoteDesktop};
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
|
||||
use crate::{MouseButton, ScrollDirection, ScrollDistance};
|
||||
|
||||
/// Linux evdev button codes.
|
||||
const BTN_LEFT: i32 = 0x110;
|
||||
const BTN_RIGHT: i32 = 0x111;
|
||||
const BTN_MIDDLE: i32 = 0x112;
|
||||
const BTN_SIDE: i32 = 0x113; // Back.
|
||||
const BTN_EXTRA: i32 = 0x114; // Forward.
|
||||
|
||||
/// Mouse state for the Wayland portal.
|
||||
pub struct Mouse {
|
||||
/// The last known mouse position (for returning in ActionResult).
|
||||
last_position: Option<Vector2I>,
|
||||
}
|
||||
|
||||
impl Mouse {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
last_position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the mouse to an absolute position.
|
||||
pub async fn move_to<'a>(
|
||||
&mut self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
stream_id: u32,
|
||||
target: Vector2I,
|
||||
) -> Result<(), String> {
|
||||
remote_desktop
|
||||
.notify_pointer_motion_absolute(
|
||||
session,
|
||||
stream_id,
|
||||
target.x() as f64,
|
||||
target.y() as f64,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to move mouse: {e}"))?;
|
||||
|
||||
self.last_position = Some(target);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Presses a mouse button down.
|
||||
pub async fn button_down<'a>(
|
||||
&self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
button: &MouseButton,
|
||||
) -> Result<(), String> {
|
||||
let evdev_button = mouse_button_to_evdev(button);
|
||||
|
||||
remote_desktop
|
||||
.notify_pointer_button(session, evdev_button, KeyState::Pressed)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to press mouse button: {e}"))
|
||||
}
|
||||
|
||||
/// Releases a mouse button.
|
||||
pub async fn button_up<'a>(
|
||||
&self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
button: &MouseButton,
|
||||
) -> Result<(), String> {
|
||||
let evdev_button = mouse_button_to_evdev(button);
|
||||
|
||||
remote_desktop
|
||||
.notify_pointer_button(session, evdev_button, KeyState::Released)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to release mouse button: {e}"))
|
||||
}
|
||||
|
||||
/// Performs a scroll action.
|
||||
pub async fn scroll<'a>(
|
||||
&self,
|
||||
remote_desktop: &RemoteDesktop<'a>,
|
||||
session: &Session<'a, RemoteDesktop<'a>>,
|
||||
direction: &ScrollDirection,
|
||||
distance: &ScrollDistance,
|
||||
) -> Result<(), String> {
|
||||
match distance {
|
||||
ScrollDistance::Clicks(clicks) => {
|
||||
// Use discrete scrolling for click-based scrolling.
|
||||
let (axis, steps) = match direction {
|
||||
ScrollDirection::Up => (Axis::Vertical, -*clicks),
|
||||
ScrollDirection::Down => (Axis::Vertical, *clicks),
|
||||
ScrollDirection::Left => (Axis::Horizontal, -*clicks),
|
||||
ScrollDirection::Right => (Axis::Horizontal, *clicks),
|
||||
};
|
||||
|
||||
remote_desktop
|
||||
.notify_pointer_axis_discrete(session, axis, steps)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scroll: {e}"))
|
||||
}
|
||||
ScrollDistance::Pixels(pixels) => {
|
||||
// Use smooth scrolling for pixel-based scrolling.
|
||||
let (dx, dy) = match direction {
|
||||
ScrollDirection::Up => (0.0, -(*pixels as f64)),
|
||||
ScrollDirection::Down => (0.0, *pixels as f64),
|
||||
ScrollDirection::Left => (-(*pixels as f64), 0.0),
|
||||
ScrollDirection::Right => (*pixels as f64, 0.0),
|
||||
};
|
||||
|
||||
remote_desktop
|
||||
.notify_pointer_axis(session, dx, dy, true)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to scroll: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the last known mouse position.
|
||||
pub fn last_position(&self) -> Option<Vector2I> {
|
||||
self.last_position
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a MouseButton to a Linux evdev button code.
|
||||
fn mouse_button_to_evdev(button: &MouseButton) -> i32 {
|
||||
match button {
|
||||
MouseButton::Left => BTN_LEFT,
|
||||
MouseButton::Right => BTN_RIGHT,
|
||||
MouseButton::Middle => BTN_MIDDLE,
|
||||
MouseButton::Back => BTN_SIDE,
|
||||
MouseButton::Forward => BTN_EXTRA,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Screenshot capture for Wayland using the XDG Desktop Portal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use futures::StreamExt as _;
|
||||
use zbus::zvariant;
|
||||
|
||||
use crate::{Screenshot, ScreenshotParams};
|
||||
|
||||
/// A D-Bus proxy for the Screenshot portal.
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.portal.Screenshot",
|
||||
default_service = "org.freedesktop.portal.Desktop",
|
||||
default_path = "/org/freedesktop/portal/desktop"
|
||||
)]
|
||||
trait ScreenshotPortal {
|
||||
/// Takes a screenshot.
|
||||
///
|
||||
/// Returns an object path for a Request object that will receive the response.
|
||||
fn screenshot(
|
||||
&self,
|
||||
parent_window: &str,
|
||||
options: HashMap<&str, zvariant::Value<'_>>,
|
||||
) -> zbus::fdo::Result<zvariant::OwnedObjectPath>;
|
||||
}
|
||||
|
||||
/// A D-Bus proxy for portal Request objects.
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.portal.Request",
|
||||
default_service = "org.freedesktop.portal.Desktop"
|
||||
)]
|
||||
trait PortalRequest {
|
||||
/// Signal emitted when the request completes.
|
||||
#[zbus(signal)]
|
||||
fn response(
|
||||
&self,
|
||||
response: u32,
|
||||
results: HashMap<String, zvariant::OwnedValue>,
|
||||
) -> zbus::fdo::Result<()>;
|
||||
}
|
||||
|
||||
/// Takes a screenshot using the XDG Desktop Portal.
|
||||
pub async fn take(params: ScreenshotParams) -> Result<Screenshot, String> {
|
||||
let connection = zbus::Connection::session()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to D-Bus session bus: {e}"))?;
|
||||
|
||||
let screenshot_proxy = ScreenshotPortalProxy::new(&connection)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create screenshot portal proxy: {e}"))?;
|
||||
|
||||
// Request a non-interactive screenshot.
|
||||
let mut options: HashMap<&str, zvariant::Value> = HashMap::new();
|
||||
options.insert("interactive", zvariant::Value::Bool(false));
|
||||
|
||||
let request_path = screenshot_proxy
|
||||
.screenshot("", options)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request screenshot: {e}"))?;
|
||||
|
||||
// Wait for the response signal.
|
||||
let request_proxy = PortalRequestProxy::builder(&connection)
|
||||
.path(request_path)
|
||||
.map_err(|e| format!("Failed to build request proxy: {e}"))?
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create request proxy: {e}"))?;
|
||||
|
||||
let mut response_stream = request_proxy
|
||||
.receive_response()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to subscribe to response signal: {e}"))?;
|
||||
|
||||
// Wait for the response.
|
||||
let response = response_stream
|
||||
.next()
|
||||
.await
|
||||
.ok_or("Screenshot request was cancelled or timed out")?;
|
||||
|
||||
let args = response
|
||||
.args()
|
||||
.map_err(|e| format!("Failed to get response arguments: {e}"))?;
|
||||
|
||||
// Response code 0 means success, 1 means cancelled, 2 means other error.
|
||||
if args.response != 0 {
|
||||
return Err(format!(
|
||||
"Screenshot request failed with response code: {}",
|
||||
args.response
|
||||
));
|
||||
}
|
||||
|
||||
// Extract the URI from the results.
|
||||
let uri_value = args
|
||||
.results
|
||||
.get("uri")
|
||||
.ok_or("Screenshot response missing 'uri' field")?;
|
||||
|
||||
let uri: &str = uri_value
|
||||
.downcast_ref()
|
||||
.map_err(|e| format!("Failed to get URI from response: {e}"))?;
|
||||
|
||||
// Parse the file:// URI and read the file.
|
||||
let path = url::Url::parse(uri)
|
||||
.map_err(|e| format!("Failed to parse screenshot URI: {e}"))?
|
||||
.to_file_path()
|
||||
.map_err(|_| format!("Screenshot URI is not a valid file path: {uri}"))?;
|
||||
|
||||
// Load the image from the temporary file.
|
||||
let img = image::ImageReader::open(&path)
|
||||
.map_err(|e| format!("Failed to open screenshot file: {e}"))?
|
||||
.decode()
|
||||
.map_err(|e| format!("Failed to decode screenshot: {e}"))?;
|
||||
|
||||
// Clean up the temporary file created by the portal.
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// If capturing a region, crop the full-display image.
|
||||
// The XDG Portal doesn't support region capture natively.
|
||||
let img = if let Some(region) = params.region {
|
||||
region.validate()?;
|
||||
crate::screenshot_utils::crop_to_region(img, region.top_left, region.bottom_right)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
crate::screenshot_utils::process_screenshot(img, params)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Session management for the XDG RemoteDesktop and ScreenCast portals.
|
||||
//!
|
||||
//! This module handles creating and maintaining a portal session that enables
|
||||
//! input emulation via the RemoteDesktop portal and provides stream IDs for
|
||||
//! absolute pointer positioning via the ScreenCast portal.
|
||||
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop};
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SourceType};
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
/// A portal session that provides input emulation capabilities.
|
||||
///
|
||||
/// This session combines RemoteDesktop (for input) and ScreenCast (for absolute
|
||||
/// positioning coordinates) into a single session with one permission dialog.
|
||||
pub struct PortalSession<'a> {
|
||||
remote_desktop: RemoteDesktop<'a>,
|
||||
session: ashpd::desktop::Session<'a, RemoteDesktop<'a>>,
|
||||
/// The PipeWire stream node ID for the primary monitor.
|
||||
/// Used for absolute pointer positioning.
|
||||
stream_id: u32,
|
||||
/// The device types that were granted by the user.
|
||||
granted_devices: BitFlags<DeviceType>,
|
||||
}
|
||||
|
||||
impl<'a> PortalSession<'a> {
|
||||
/// Creates and starts a new portal session.
|
||||
///
|
||||
/// This will show a permission dialog to the user (unless permissions are
|
||||
/// already persisted from a previous session).
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let remote_desktop = RemoteDesktop::new()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create RemoteDesktop proxy: {e}"))?;
|
||||
|
||||
let screencast = Screencast::new()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create Screencast proxy: {e}"))?;
|
||||
|
||||
// Create a RemoteDesktop session. This session is shared with ScreenCast.
|
||||
let session = remote_desktop
|
||||
.create_session()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create RemoteDesktop session: {e}"))?;
|
||||
|
||||
// Select input devices (keyboard and pointer).
|
||||
// Note: Some portals don't support persistence for remote desktop sessions,
|
||||
// so we use DoNot to avoid errors.
|
||||
remote_desktop
|
||||
.select_devices(
|
||||
&session,
|
||||
DeviceType::Keyboard | DeviceType::Pointer,
|
||||
None, // No restore token.
|
||||
PersistMode::DoNot,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to select devices: {e}"))?;
|
||||
|
||||
// Select screencast sources (monitors) to get stream IDs for absolute positioning.
|
||||
// We use CursorMode::Metadata since we only need the stream ID, not the video.
|
||||
screencast
|
||||
.select_sources(
|
||||
&session,
|
||||
CursorMode::Metadata,
|
||||
SourceType::Monitor.into(),
|
||||
true, // Allow multiple monitors.
|
||||
None, // No restore token.
|
||||
PersistMode::DoNot,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to select screencast sources: {e}"))?;
|
||||
|
||||
// Start the session. This shows the permission dialog to the user.
|
||||
let response = remote_desktop
|
||||
.start(&session, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start session request: {e}"))?
|
||||
.response()
|
||||
.map_err(|e| format!("Session start failed: {e}"))?;
|
||||
|
||||
// Extract the stream ID from the response.
|
||||
let streams = response
|
||||
.streams()
|
||||
.ok_or("No streams returned from ScreenCast")?;
|
||||
|
||||
if streams.is_empty() {
|
||||
return Err("No monitors available for screen casting".to_string());
|
||||
}
|
||||
|
||||
// Use the first stream (primary monitor) for now.
|
||||
let stream_id = streams[0].pipe_wire_node_id();
|
||||
|
||||
// Get the devices that were actually granted by the user.
|
||||
let granted_devices = response.devices();
|
||||
|
||||
Ok(Self {
|
||||
remote_desktop,
|
||||
session,
|
||||
stream_id,
|
||||
granted_devices,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a reference to the RemoteDesktop proxy.
|
||||
pub fn remote_desktop(&self) -> &RemoteDesktop<'a> {
|
||||
&self.remote_desktop
|
||||
}
|
||||
|
||||
/// Returns a reference to the session.
|
||||
pub fn session(&self) -> &ashpd::desktop::Session<'a, RemoteDesktop<'a>> {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// Returns the stream ID for the primary monitor.
|
||||
///
|
||||
/// This ID is used for absolute pointer positioning via
|
||||
/// `notify_pointer_motion_absolute`.
|
||||
pub fn stream_id(&self) -> u32 {
|
||||
self.stream_id
|
||||
}
|
||||
|
||||
/// Validates that keyboard input permission was granted.
|
||||
///
|
||||
/// Returns an error with a clear message for the agent if permission was denied.
|
||||
pub fn require_keyboard(&self) -> Result<(), String> {
|
||||
if self.granted_devices.contains(DeviceType::Keyboard) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"Keyboard input permission was not granted. \
|
||||
The user must allow keyboard input in the portal dialog to perform keyboard actions."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that pointer/mouse input permission was granted.
|
||||
///
|
||||
/// Returns an error with a clear message for the agent if permission was denied.
|
||||
pub fn require_pointer(&self) -> Result<(), String> {
|
||||
if self.granted_devices.contains(DeviceType::Pointer) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Mouse input permission was not granted. \
|
||||
The user must allow mouse input in the portal dialog to perform mouse actions."
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Keyboard input handling for X11 using XTEST.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto;
|
||||
use x11rb::protocol::xtest::ConnectionExt as _;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use super::super::keysym::{UPPERCASE_KEYSYMS, XK_SHIFT_L, char_to_keysym};
|
||||
use crate::Key;
|
||||
|
||||
/// A resolved key with its keycode and shift requirement.
|
||||
struct ResolvedKey {
|
||||
keycode: u8,
|
||||
needs_shift: bool,
|
||||
}
|
||||
|
||||
/// Keyboard state tracking for an X11 connection.
|
||||
pub struct Keyboard<'a> {
|
||||
conn: &'a RustConnection,
|
||||
keyboard_mapping: &'a xproto::GetKeyboardMappingReply,
|
||||
/// Keycodes currently held that required auto-shift.
|
||||
/// When non-empty, shift is being held by us.
|
||||
auto_shift_keys: HashSet<u8>,
|
||||
}
|
||||
|
||||
impl<'a> Keyboard<'a> {
|
||||
pub fn new(
|
||||
conn: &'a RustConnection,
|
||||
keyboard_mapping: &'a xproto::GetKeyboardMappingReply,
|
||||
) -> Self {
|
||||
Self {
|
||||
conn,
|
||||
keyboard_mapping,
|
||||
auto_shift_keys: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn type_text(&mut self, text: &str) -> Result<(), String> {
|
||||
// For each character, we need to find a keycode that produces it.
|
||||
// This is complex because X11 keycodes depend on the keyboard layout.
|
||||
for ch in text.chars() {
|
||||
self.type_char(ch)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a key down event for the given key.
|
||||
///
|
||||
/// For `Key::Char`, this will automatically press shift if needed.
|
||||
pub fn key_down(&mut self, key: &Key) -> Result<(), String> {
|
||||
let resolved = self.resolve_key(key)?;
|
||||
if resolved.needs_shift {
|
||||
// Press shift only if this is the first auto-shifted key.
|
||||
if self.auto_shift_keys.is_empty() {
|
||||
self.press_shift()?;
|
||||
}
|
||||
self.auto_shift_keys.insert(resolved.keycode);
|
||||
}
|
||||
self.key_press(resolved.keycode)
|
||||
}
|
||||
|
||||
/// Sends a key up event for the given key.
|
||||
///
|
||||
/// For `Key::Char`, this will automatically release shift if it was auto-pressed
|
||||
/// and this is the last auto-shifted key being released.
|
||||
pub fn key_up(&mut self, key: &Key) -> Result<(), String> {
|
||||
let resolved = self.resolve_key(key)?;
|
||||
self.key_release(resolved.keycode)?;
|
||||
// Release shift only if this key was auto-shifted and it's the last one.
|
||||
if self.auto_shift_keys.remove(&resolved.keycode) && self.auto_shift_keys.is_empty() {
|
||||
self.release_shift()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn type_char(&mut self, ch: char) -> Result<(), String> {
|
||||
let resolved = self.resolve_key_for_char(ch)?;
|
||||
|
||||
// Press shift if needed, then press and release the key, then release shift.
|
||||
if resolved.needs_shift {
|
||||
self.press_shift()?;
|
||||
}
|
||||
self.key_press(resolved.keycode)?;
|
||||
self.key_release(resolved.keycode)?;
|
||||
if resolved.needs_shift {
|
||||
self.release_shift()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a `Key` to a keycode and shift requirement.
|
||||
fn resolve_key(&self, key: &Key) -> Result<ResolvedKey, String> {
|
||||
match key {
|
||||
Key::Keycode(keysym) => {
|
||||
if *keysym < 0 {
|
||||
return Err(format!("Invalid keysym: {keysym} (must be non-negative)"));
|
||||
}
|
||||
let keysym = *keysym as u32;
|
||||
let keycode = self.find_keycode_for_keysym(keysym)?;
|
||||
// For explicit keysyms, caller manages modifiers.
|
||||
Ok(ResolvedKey {
|
||||
keycode,
|
||||
needs_shift: false,
|
||||
})
|
||||
}
|
||||
Key::Char(ch) => self.resolve_key_for_char(*ch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a character to a keycode and shift requirement.
|
||||
fn resolve_key_for_char(&self, ch: char) -> Result<ResolvedKey, String> {
|
||||
let keysym = char_to_keysym(ch);
|
||||
let keycode = self.find_keycode_for_keysym(keysym)?;
|
||||
let needs_shift = self.keysym_needs_shift(keysym, keycode);
|
||||
Ok(ResolvedKey {
|
||||
keycode,
|
||||
needs_shift,
|
||||
})
|
||||
}
|
||||
|
||||
/// Presses the shift key.
|
||||
fn press_shift(&mut self) -> Result<(), String> {
|
||||
let shift_keycode = self.find_keycode_for_keysym(XK_SHIFT_L)?;
|
||||
self.key_press(shift_keycode)
|
||||
}
|
||||
|
||||
/// Releases the shift key.
|
||||
fn release_shift(&mut self) -> Result<(), String> {
|
||||
let shift_keycode = self.find_keycode_for_keysym(XK_SHIFT_L)?;
|
||||
self.key_release(shift_keycode)
|
||||
}
|
||||
|
||||
fn key_press(&mut self, keycode: u8) -> Result<(), String> {
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::KEY_PRESS_EVENT,
|
||||
keycode,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send key press: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn key_release(&mut self, keycode: u8) -> Result<(), String> {
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::KEY_RELEASE_EVENT,
|
||||
keycode,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send key release: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_keycode_for_keysym(&self, keysym: u32) -> Result<u8, String> {
|
||||
let setup = self.conn.setup();
|
||||
let min_keycode = setup.min_keycode;
|
||||
let max_keycode = setup.max_keycode;
|
||||
|
||||
let keysyms_per_keycode = self.keyboard_mapping.keysyms_per_keycode as usize;
|
||||
|
||||
// Search for a keycode that produces the desired keysym.
|
||||
for keycode in min_keycode..=max_keycode {
|
||||
let offset = (keycode - min_keycode) as usize * keysyms_per_keycode;
|
||||
for i in 0..keysyms_per_keycode {
|
||||
if offset + i < self.keyboard_mapping.keysyms.len()
|
||||
&& self.keyboard_mapping.keysyms[offset + i] == keysym
|
||||
{
|
||||
return Ok(keycode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find the unshifted version for uppercase letters.
|
||||
if UPPERCASE_KEYSYMS.contains(&keysym) {
|
||||
let lower = keysym + 0x20;
|
||||
return self.find_keycode_for_keysym(lower);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"No keycode found for keysym 0x{:x} (char: {:?})",
|
||||
keysym,
|
||||
char::from_u32(keysym)
|
||||
))
|
||||
}
|
||||
|
||||
fn keysym_needs_shift(&self, keysym: u32, keycode: u8) -> bool {
|
||||
let setup = self.conn.setup();
|
||||
let min_keycode = setup.min_keycode;
|
||||
|
||||
let keysyms_per_keycode = self.keyboard_mapping.keysyms_per_keycode as usize;
|
||||
let offset = (keycode - min_keycode) as usize * keysyms_per_keycode;
|
||||
|
||||
// If the keysym is in position 0, no shift needed.
|
||||
// If it's in position 1, shift is needed.
|
||||
if offset < self.keyboard_mapping.keysyms.len()
|
||||
&& self.keyboard_mapping.keysyms[offset] == keysym
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if offset + 1 < self.keyboard_mapping.keysyms.len()
|
||||
&& self.keyboard_mapping.keysyms[offset + 1] == keysym
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// For uppercase letters, assume shift is needed.
|
||||
UPPERCASE_KEYSYMS.contains(&keysym)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! X11 implementation of computer use actions using the XTEST extension.
|
||||
|
||||
mod keyboard;
|
||||
mod mouse;
|
||||
mod screenshot;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use warpui::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};
|
||||
|
||||
/// An actor that performs computer use actions on X11.
|
||||
pub struct Actor {
|
||||
conn: RustConnection,
|
||||
screen_index: usize,
|
||||
/// Cached keyboard mapping for this connection. This avoids querying the server on every
|
||||
/// character typed.
|
||||
keyboard_mapping: xproto::GetKeyboardMappingReply,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let (conn, screen_index) =
|
||||
RustConnection::connect(None).map_err(|e| format!("Failed to connect to X11: {e}"))?;
|
||||
|
||||
// Verify XTEST extension is available. XTEST is part of the Xorg server and is
|
||||
// typically present by default. On Wayland or X servers without XTEST, this will
|
||||
// fail and computer use will not be available on X11.
|
||||
conn.xtest_get_version(2, 2)
|
||||
.map_err(|e| format!("XTEST extension not available: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("XTEST extension query failed: {e}"))?;
|
||||
|
||||
// Pre-fetch and cache the keyboard mapping for this connection to avoid
|
||||
// round-trips for every character typed.
|
||||
let setup = conn.setup();
|
||||
let min_keycode = setup.min_keycode;
|
||||
let max_keycode = setup.max_keycode;
|
||||
let keyboard_mapping = conn
|
||||
.get_keyboard_mapping(min_keycode, max_keycode - min_keycode + 1)
|
||||
.map_err(|e| format!("Failed to get keyboard mapping: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("Failed to get keyboard mapping reply: {e}"))?;
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
screen_index,
|
||||
keyboard_mapping,
|
||||
})
|
||||
}
|
||||
|
||||
fn root_window(&self) -> xproto::Window {
|
||||
self.conn.setup().roots[self.screen_index].root
|
||||
}
|
||||
|
||||
fn screen(&self) -> &xproto::Screen {
|
||||
&self.conn.setup().roots[self.screen_index]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::Actor for Actor {
|
||||
fn platform(&self) -> Option<crate::Platform> {
|
||||
Some(crate::Platform::LinuxX11)
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
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 {
|
||||
match action {
|
||||
Action::Wait(duration) => {
|
||||
Timer::after(*duration).await;
|
||||
}
|
||||
Action::MouseDown { button, at } => {
|
||||
mouse.move_to(*at)?;
|
||||
mouse.focus_window_under_pointer()?;
|
||||
mouse.button_down(button)?;
|
||||
last_mouse_position = Some(*at);
|
||||
}
|
||||
Action::MouseUp { button } => {
|
||||
mouse.button_up(button)?;
|
||||
}
|
||||
Action::MouseMove { to } => {
|
||||
mouse.move_to(*to)?;
|
||||
last_mouse_position = Some(*to);
|
||||
}
|
||||
Action::MouseWheel {
|
||||
at,
|
||||
direction,
|
||||
distance,
|
||||
} => {
|
||||
mouse.move_to(*at)?;
|
||||
mouse.scroll(direction, distance)?;
|
||||
last_mouse_position = Some(*at);
|
||||
}
|
||||
Action::TypeText { text } => {
|
||||
keyboard.type_text(text)?;
|
||||
}
|
||||
Action::KeyDown { key } => {
|
||||
keyboard.key_down(key)?;
|
||||
}
|
||||
Action::KeyUp { key } => {
|
||||
keyboard.key_up(key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot = if let Some(params) = options.screenshot_params {
|
||||
Some(screenshot::take(
|
||||
&self.conn,
|
||||
self.screen(),
|
||||
self.root_window(),
|
||||
params,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get the final mouse position.
|
||||
let cursor_position = if let Some(pos) = last_mouse_position {
|
||||
Some(pos)
|
||||
} else {
|
||||
Some(mouse.current_position()?)
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
screenshot,
|
||||
cursor_position,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Mouse input handling for X11 using XTEST.
|
||||
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::{self, ConnectionExt as _};
|
||||
use x11rb::protocol::xtest::ConnectionExt as _;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use crate::{MouseButton, ScrollDirection, ScrollDistance};
|
||||
|
||||
/// Mouse state tracking for an X11 connection.
|
||||
pub struct Mouse<'a> {
|
||||
conn: &'a RustConnection,
|
||||
root_window: xproto::Window,
|
||||
held_buttons: HeldButtons,
|
||||
}
|
||||
|
||||
impl<'a> Mouse<'a> {
|
||||
pub fn new(conn: &'a RustConnection, root_window: xproto::Window) -> Self {
|
||||
Self {
|
||||
conn,
|
||||
root_window,
|
||||
held_buttons: HeldButtons::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, target: Vector2I) -> Result<(), String> {
|
||||
// Use WarpPointer to move the pointer. Unlike XTEST MotionNotify, this
|
||||
// reliably updates the server's pointer position, ensuring that subsequent
|
||||
// button events are delivered to the correct window.
|
||||
self.conn
|
||||
.warp_pointer(
|
||||
x11rb::NONE, // src_window (unconstrained)
|
||||
self.root_window, // dst_window (absolute coordinates)
|
||||
0, // src_x (unused)
|
||||
0, // src_y (unused)
|
||||
0, // src_width (unused)
|
||||
0, // src_height (unused)
|
||||
target.x() as i16,
|
||||
target.y() as i16,
|
||||
)
|
||||
.map_err(|e| format!("Failed to warp pointer: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn button_down(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let x11_button = mouse_button_to_x11(button);
|
||||
self.held_buttons.set_down(button, true);
|
||||
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::BUTTON_PRESS_EVENT,
|
||||
x11_button,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send button down: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn button_up(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let x11_button = mouse_button_to_x11(button);
|
||||
self.held_buttons.set_down(button, false);
|
||||
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::BUTTON_RELEASE_EVENT,
|
||||
x11_button,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send button up: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn scroll(
|
||||
&mut self,
|
||||
direction: &ScrollDirection,
|
||||
distance: &ScrollDistance,
|
||||
) -> Result<(), String> {
|
||||
// In X11, scroll is done via button presses. Buttons 4/5 are vertical scroll,
|
||||
// buttons 6/7 are horizontal scroll.
|
||||
let (button, count) = match (direction, distance) {
|
||||
(ScrollDirection::Up, ScrollDistance::Clicks(n)) => (4u8, *n),
|
||||
(ScrollDirection::Down, ScrollDistance::Clicks(n)) => (5u8, *n),
|
||||
(ScrollDirection::Left, ScrollDistance::Clicks(n)) => (6u8, *n),
|
||||
(ScrollDirection::Right, ScrollDistance::Clicks(n)) => (7u8, *n),
|
||||
// For pixel scrolling, approximate with clicks. X11 doesn't have native pixel scroll.
|
||||
(ScrollDirection::Up, ScrollDistance::Pixels(px)) => (4u8, (*px / 15).max(1)),
|
||||
(ScrollDirection::Down, ScrollDistance::Pixels(px)) => (5u8, (*px / 15).max(1)),
|
||||
(ScrollDirection::Left, ScrollDistance::Pixels(px)) => (6u8, (*px / 15).max(1)),
|
||||
(ScrollDirection::Right, ScrollDistance::Pixels(px)) => (7u8, (*px / 15).max(1)),
|
||||
};
|
||||
|
||||
for _ in 0..count.abs() {
|
||||
// Button press.
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::BUTTON_PRESS_EVENT,
|
||||
button,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send scroll button press: {e}"))?;
|
||||
|
||||
// Button release.
|
||||
self.conn
|
||||
.xtest_fake_input(
|
||||
xproto::BUTTON_RELEASE_EVENT,
|
||||
button,
|
||||
x11rb::CURRENT_TIME,
|
||||
x11rb::NONE,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.map_err(|e| format!("Failed to send scroll button release: {e}"))?;
|
||||
}
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets input focus to the deepest window under the current pointer position.
|
||||
/// This simulates the click-to-focus behavior that a window manager would
|
||||
/// normally provide. Without a WM (e.g. in Xvfb), windows do not receive
|
||||
/// focus automatically, so keyboard and some button events may not be
|
||||
/// delivered correctly.
|
||||
pub fn focus_window_under_pointer(&self) -> Result<(), String> {
|
||||
let mut window = self.root_window;
|
||||
|
||||
// Walk down the window tree to find the deepest child under the pointer.
|
||||
loop {
|
||||
let reply = self
|
||||
.conn
|
||||
.query_pointer(window)
|
||||
.map_err(|e| format!("Failed to query pointer: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("Failed to get pointer reply: {e}"))?;
|
||||
|
||||
if reply.child == x11rb::NONE {
|
||||
break;
|
||||
}
|
||||
window = reply.child;
|
||||
}
|
||||
|
||||
// Set input focus to the deepest window found.
|
||||
self.conn
|
||||
.set_input_focus(xproto::InputFocus::PARENT, window, x11rb::CURRENT_TIME)
|
||||
.map_err(|e| format!("Failed to set input focus: {e}"))?;
|
||||
|
||||
self.conn
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush X11 connection: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn current_position(&mut self) -> Result<Vector2I, String> {
|
||||
let reply = self
|
||||
.conn
|
||||
.query_pointer(self.root_window)
|
||||
.map_err(|e| format!("Failed to query pointer: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("Failed to get pointer reply: {e}"))?;
|
||||
|
||||
let pos = Vector2I::new(reply.root_x as i32, reply.root_y as i32);
|
||||
Ok(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct HeldButtons {
|
||||
left: bool,
|
||||
right: bool,
|
||||
middle: bool,
|
||||
back: bool,
|
||||
forward: bool,
|
||||
}
|
||||
|
||||
impl HeldButtons {
|
||||
fn set_down(&mut self, button: &MouseButton, down: bool) {
|
||||
match button {
|
||||
MouseButton::Left => self.left = down,
|
||||
MouseButton::Right => self.right = down,
|
||||
MouseButton::Middle => self.middle = down,
|
||||
MouseButton::Back => self.back = down,
|
||||
MouseButton::Forward => self.forward = down,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_button_to_x11(button: &MouseButton) -> u8 {
|
||||
match button {
|
||||
MouseButton::Left => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Right => 3,
|
||||
MouseButton::Back => 8,
|
||||
MouseButton::Forward => 9,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Screenshot capture for X11.
|
||||
|
||||
use x11rb::protocol::xproto::{self, ConnectionExt as _, ImageFormat};
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
use crate::{Screenshot, ScreenshotParams};
|
||||
|
||||
/// Takes a screenshot of the root window or a region of it.
|
||||
pub fn take(
|
||||
conn: &RustConnection,
|
||||
screen: &xproto::Screen,
|
||||
root: xproto::Window,
|
||||
params: ScreenshotParams,
|
||||
) -> Result<Screenshot, String> {
|
||||
// Determine the capture region.
|
||||
let (x, y, width, height) = if let Some(region) = params.region {
|
||||
region.validate()?;
|
||||
let x = region.top_left.x() as i16;
|
||||
let y = region.top_left.y() as i16;
|
||||
let width = (region.bottom_right.x() - region.top_left.x()) as u16;
|
||||
let height = (region.bottom_right.y() - region.top_left.y()) as u16;
|
||||
(x, y, width, height)
|
||||
} else {
|
||||
(0, 0, screen.width_in_pixels, screen.height_in_pixels)
|
||||
};
|
||||
|
||||
// Get the image from the root window.
|
||||
// TODO: Consider compositing the cursor into the screenshot in the future.
|
||||
let image = conn
|
||||
.get_image(
|
||||
ImageFormat::Z_PIXMAP,
|
||||
root,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
!0, // plane_mask: all planes
|
||||
)
|
||||
.map_err(|e| format!("Failed to request screenshot: {e}"))?
|
||||
.reply()
|
||||
.map_err(|e| format!("Failed to get screenshot reply: {e}"))?;
|
||||
|
||||
// Convert the X11 image data to an image::RgbImage.
|
||||
// X11 typically returns BGRA or BGR depending on depth.
|
||||
let depth = image.depth;
|
||||
let data = image.data;
|
||||
|
||||
let rgb_data = convert_x11_image_to_rgb(&data, width as usize, height as usize, depth)?;
|
||||
|
||||
let img = image::RgbImage::from_raw(width as u32, height as u32, rgb_data)
|
||||
.ok_or("Failed to create image from raw data")?;
|
||||
|
||||
let img = image::DynamicImage::ImageRgb8(img);
|
||||
|
||||
crate::screenshot_utils::process_screenshot(img, params)
|
||||
}
|
||||
|
||||
/// Converts X11 image data (typically BGRA or BGR) to RGB.
|
||||
fn convert_x11_image_to_rgb(
|
||||
data: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
depth: u8,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut rgb = Vec::with_capacity(width * height * 3);
|
||||
|
||||
match depth {
|
||||
24 => {
|
||||
// 24-bit: BGR format, 3 bytes per pixel (but often padded to 4).
|
||||
// X11 often uses 32-bit alignment even for 24-bit depth.
|
||||
let bytes_per_pixel = if data.len() >= width * height * 4 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let offset = (y * width + x) * bytes_per_pixel;
|
||||
if offset + 2 < data.len() {
|
||||
let b = data[offset];
|
||||
let g = data[offset + 1];
|
||||
let r = data[offset + 2];
|
||||
rgb.push(r);
|
||||
rgb.push(g);
|
||||
rgb.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32 => {
|
||||
// 32-bit: BGRA format, 4 bytes per pixel.
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let offset = (y * width + x) * 4;
|
||||
if offset + 2 < data.len() {
|
||||
let b = data[offset];
|
||||
let g = data[offset + 1];
|
||||
let r = data[offset + 2];
|
||||
// Skip alpha at offset + 3.
|
||||
rgb.push(r);
|
||||
rgb.push(g);
|
||||
rgb.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Unsupported screen depth: {depth}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(rgb)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use objc2_core_graphics::{
|
||||
CGEvent, CGEventFlags, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGKeyCode,
|
||||
};
|
||||
|
||||
use super::keycode_cache;
|
||||
use crate::Key;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
impl Keyboard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: keycode_cache::build_cache(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?)
|
||||
}
|
||||
|
||||
/// 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)?)
|
||||
}
|
||||
|
||||
/// Simulates typing text by sending Quartz events.
|
||||
pub fn type_text(&self, text: &str) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
|
||||
// Send one character at a time for better compatibility with various applications.
|
||||
for ch in text.chars() {
|
||||
// For now, send each character using the unicode method. This is easier than using
|
||||
// virtual key codes, but may not be supported in all applications.
|
||||
//
|
||||
// TODO(vorporeal): when sending an ASCII character, send it using virtual key codes
|
||||
// for better compatibility.
|
||||
type_unicode_char(ch, source.as_deref())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a Key to a CGKeyCode.
|
||||
///
|
||||
/// The key can be:
|
||||
/// - A keycode (platform-specific virtual keycode)
|
||||
/// - A character (looked up via the current keyboard layout)
|
||||
fn resolve_keycode(&self, key: &Key) -> Result<CGKeyCode, String> {
|
||||
match key {
|
||||
Key::Keycode(code) => CGKeyCode::try_from(*code).map_err(|_| {
|
||||
format!(
|
||||
"Invalid keycode {code}: must be in range 0..={}",
|
||||
CGKeyCode::MAX
|
||||
)
|
||||
}),
|
||||
Key::Char(ch) => self
|
||||
.cache
|
||||
.get(ch)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("No keycode found for character '{}'", ch)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Posts a key up event for the given virtual keycode.
|
||||
fn post_key_up(keycode: CGKeyCode) -> 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));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a Quartz event signifying the typing of a single Unicode character.
|
||||
fn type_unicode_char(ch: char, source: Option<&CGEventSource>) -> Result<(), String> {
|
||||
let mut buf = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut buf);
|
||||
|
||||
// Create a key down event (virtual key code 0 is used as a placeholder).
|
||||
let key_down = CGEvent::new_keyboard_event(source, 0, true)
|
||||
.ok_or("Failed to create key down event for TypeText.")?;
|
||||
|
||||
// Set the unicode string on the event.
|
||||
// Safety: encoded is a valid UTF-16 buffer with the correct length.
|
||||
unsafe {
|
||||
CGEvent::keyboard_set_unicode_string(
|
||||
Some(&key_down),
|
||||
encoded.len() as u64,
|
||||
encoded.as_ptr(),
|
||||
);
|
||||
}
|
||||
|
||||
// Clear any modifier flags that might interfere.
|
||||
CGEvent::set_flags(Some(&key_down), CGEventFlags::empty());
|
||||
|
||||
// Post the key down event.
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&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));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Character-to-keycode cache builder.
|
||||
//!
|
||||
//! This module provides a way to translate characters to macOS virtual keycodes.
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use core_foundation::base::{CFType, CFTypeRef, TCFType};
|
||||
use core_foundation::data::CFData;
|
||||
use dispatch2::run_on_main;
|
||||
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;
|
||||
|
||||
// Property key for getting the keyboard layout data.
|
||||
static kTISPropertyUnicodeKeyLayoutData: CFTypeRef;
|
||||
|
||||
fn LMGetKbdType() -> u8;
|
||||
}
|
||||
|
||||
// Unicode Utilities types and functions.
|
||||
#[repr(C)]
|
||||
#[allow(non_camel_case_types)]
|
||||
struct UCKeyboardLayout {
|
||||
_opaque: [u8; 0],
|
||||
}
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn UCKeyTranslate(
|
||||
layout: *const UCKeyboardLayout,
|
||||
virtual_key_code: u16,
|
||||
key_action: u16,
|
||||
modifier_key_state: u32,
|
||||
keyboard_type: u32,
|
||||
key_translate_options: u32,
|
||||
dead_key_state: *mut u32,
|
||||
max_string_length: usize,
|
||||
actual_string_length: *mut usize,
|
||||
unicode_string: *mut u16,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
// UCKeyTranslate constants.
|
||||
const K_UC_KEY_ACTION_DOWN: u16 = 0;
|
||||
const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT: u32 = 1 << 0;
|
||||
|
||||
// Shift modifier for UCKeyTranslate (shift bit position is 1 in the modifier state).
|
||||
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.
|
||||
/// TODO(QUALITY-271): Store the modifier keys as well.
|
||||
pub fn build_cache() -> HashMap<char, CGKeyCode> {
|
||||
run_on_main(|_| build_cache_on_main_thread())
|
||||
}
|
||||
|
||||
/// Builds the cache on the main thread where Carbon APIs are safe to call.
|
||||
fn build_cache_on_main_thread() -> HashMap<char, CGKeyCode> {
|
||||
let mut cache = HashMap::new();
|
||||
|
||||
// Get the keyboard layout data.
|
||||
let layout_data = unsafe { get_keyboard_layout_data() };
|
||||
let Some(layout_data) = layout_data else {
|
||||
log::warn!("Failed to get keyboard layout data for keycode cache");
|
||||
return cache;
|
||||
};
|
||||
|
||||
let layout_ptr = layout_data.as_ptr() as *const UCKeyboardLayout;
|
||||
let keyboard_type = unsafe { LMGetKbdType() } as u32;
|
||||
|
||||
// Iterate through all possible keycodes (0-127) and build the mapping.
|
||||
for keycode in 0u16..128 {
|
||||
// Get the character for this keycode without modifiers.
|
||||
if let Some(ch) = translate_keycode(layout_ptr, keycode, 0, keyboard_type)
|
||||
&& !is_control_char(ch)
|
||||
{
|
||||
cache.entry(ch).or_insert(keycode as CGKeyCode);
|
||||
}
|
||||
|
||||
// Get the character for this keycode with shift held.
|
||||
if let Some(ch) = translate_keycode(layout_ptr, keycode, SHIFT_MODIFIER, keyboard_type)
|
||||
&& !is_control_char(ch)
|
||||
{
|
||||
cache.entry(ch).or_insert(keycode as CGKeyCode);
|
||||
}
|
||||
}
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
/// Gets the keyboard layout data from the current input source.
|
||||
unsafe fn get_keyboard_layout_data() -> Option<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)
|
||||
};
|
||||
|
||||
// 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,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
if layout_data.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The returned CFData is not retained, so we need to retain it.
|
||||
Some(unsafe { CFData::wrap_under_get_rule(layout_data as _) })
|
||||
}
|
||||
|
||||
/// Translates a keycode to a character using UCKeyTranslate.
|
||||
fn translate_keycode(
|
||||
layout: *const UCKeyboardLayout,
|
||||
keycode: u16,
|
||||
modifier_state: u32,
|
||||
keyboard_type: u32,
|
||||
) -> Option<char> {
|
||||
let mut dead_key_state: u32 = 0;
|
||||
let mut string_length: usize = 0;
|
||||
let mut unicode_string = [0u16; 4];
|
||||
|
||||
let result = unsafe {
|
||||
UCKeyTranslate(
|
||||
layout,
|
||||
keycode,
|
||||
K_UC_KEY_ACTION_DOWN,
|
||||
modifier_state,
|
||||
keyboard_type,
|
||||
K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT,
|
||||
&mut dead_key_state,
|
||||
unicode_string.len(),
|
||||
&mut string_length,
|
||||
unicode_string.as_mut_ptr(),
|
||||
)
|
||||
};
|
||||
|
||||
if result != 0 || string_length == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert the first UTF-16 code unit to a char.
|
||||
// We only handle single-code-unit characters for simplicity.
|
||||
char::decode_utf16(unicode_string[..string_length].iter().copied())
|
||||
.next()
|
||||
.and_then(|r| r.ok())
|
||||
}
|
||||
|
||||
/// Returns true if the character is a control character (non-printable).
|
||||
fn is_control_char(ch: char) -> bool {
|
||||
// C0 control characters (0x00-0x1F) and C1 control characters (0x7F-0x9F)
|
||||
let code = ch as u32;
|
||||
code <= 0x1F || (0x7F..=0x9F).contains(&code)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
mod keyboard;
|
||||
mod keycode_cache;
|
||||
mod mouse;
|
||||
mod screenshot;
|
||||
mod util;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::{Action, ActionResult, Options};
|
||||
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub struct Actor {
|
||||
keyboard: keyboard::Keyboard,
|
||||
mouse: mouse::Mouse,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
keyboard: keyboard::Keyboard::new(),
|
||||
mouse: mouse::Mouse::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::Actor for Actor {
|
||||
fn platform(&self) -> Option<super::Platform> {
|
||||
Some(super::Platform::Mac)
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Wait(duration) => {
|
||||
Timer::after(*duration).await;
|
||||
}
|
||||
Action::MouseDown { button, at } => {
|
||||
self.mouse.move_to(*at).await?;
|
||||
self.mouse.button_down(button)?;
|
||||
}
|
||||
Action::MouseUp { button } => self.mouse.button_up(button)?,
|
||||
Action::MouseMove { to } => self.mouse.move_to(*to).await?,
|
||||
Action::MouseWheel {
|
||||
at,
|
||||
direction,
|
||||
distance,
|
||||
} => {
|
||||
self.mouse.move_to(*at).await?;
|
||||
self.mouse.scroll(direction, distance)?;
|
||||
}
|
||||
Action::TypeText { text } => {
|
||||
self.keyboard.type_text(text)?;
|
||||
}
|
||||
Action::KeyDown { key } => {
|
||||
self.keyboard.key_down(key)?;
|
||||
}
|
||||
Action::KeyUp { key } => {
|
||||
self.keyboard.key_up(key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot = if let Some(params) = options.screenshot_params {
|
||||
Some(screenshot::take(params)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
screenshot,
|
||||
cursor_position: Some(self.mouse.current_position()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
use instant::Instant;
|
||||
use std::time::Duration;
|
||||
|
||||
use objc2_core_foundation::CGPoint;
|
||||
use objc2_core_graphics::{
|
||||
CGEvent, CGEventSource, CGEventSourceStateID, CGEventTapLocation, CGEventType, CGMouseButton,
|
||||
CGScrollEventUnit,
|
||||
};
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::{MouseButton, ScrollDirection, ScrollDistance};
|
||||
|
||||
use super::util::main_display_scale_factor;
|
||||
|
||||
const POSITION_POLL_INTERVAL: Duration = Duration::from_micros(500);
|
||||
const POSITION_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Converts physical coordinates to CGEvent point coordinates.
|
||||
///
|
||||
/// On Retina/HiDPI displays, physical coordinates differ from the "point" coordinates
|
||||
/// used by macOS APIs like CGEvent. This function scales physical coordinates down
|
||||
/// by the display's backing scale factor.
|
||||
pub fn to_cgpoint(target: Vector2I) -> CGPoint {
|
||||
let scale = main_display_scale_factor();
|
||||
CGPoint {
|
||||
x: target.x() as f64 / scale,
|
||||
y: target.y() as f64 / scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts CGEvent point coordinates to physical coordinates.
|
||||
pub fn from_cgpoint(point: CGPoint) -> Vector2I {
|
||||
let scale = main_display_scale_factor();
|
||||
Vector2I::new((point.x * scale) as i32, (point.y * scale) as i32)
|
||||
}
|
||||
|
||||
/// Manages mouse state and posts mouse events to the system.
|
||||
pub struct Mouse {
|
||||
held_buttons: HeldButtons,
|
||||
}
|
||||
|
||||
impl Mouse {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
held_buttons: HeldButtons::default(),
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
} else {
|
||||
(CGEventType::MouseMoved, CGMouseButton::Left)
|
||||
};
|
||||
|
||||
self.post_event(event_type, to_cgpoint(target), cg_button)?;
|
||||
self.wait_for_position(target).await
|
||||
}
|
||||
|
||||
pub fn button_down(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let point = self.current_position_cgpoint()?;
|
||||
self.held_buttons.set_down(button, true);
|
||||
self.post_event(mouse_down_event_type(button), point, button.into())
|
||||
}
|
||||
|
||||
pub fn button_up(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let point = self.current_position_cgpoint()?;
|
||||
self.held_buttons.set_down(button, false);
|
||||
self.post_event(mouse_up_event_type(button), point, button.into())
|
||||
}
|
||||
|
||||
pub fn current_position(&mut self) -> Result<Vector2I, String> {
|
||||
let cg_point = self.current_position_cgpoint()?;
|
||||
Ok(from_cgpoint(cg_point))
|
||||
}
|
||||
|
||||
/// Scrolls the mouse wheel in the given direction by the given distance.
|
||||
pub fn scroll(
|
||||
&mut self,
|
||||
direction: &ScrollDirection,
|
||||
distance: &ScrollDistance,
|
||||
) -> Result<(), String> {
|
||||
let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState);
|
||||
|
||||
// Determine scroll unit and amount based on distance type.
|
||||
let (unit, amount) = match distance {
|
||||
ScrollDistance::Pixels(pixels) => (CGScrollEventUnit::Pixel, *pixels),
|
||||
ScrollDistance::Clicks(clicks) => (CGScrollEventUnit::Line, *clicks),
|
||||
};
|
||||
|
||||
// Determine which axis and sign to use based on direction.
|
||||
// Positive values scroll up/left, negative values scroll down/right.
|
||||
let (wheel1, wheel2) = match direction {
|
||||
ScrollDirection::Up => (amount, 0),
|
||||
ScrollDirection::Down => (-amount, 0),
|
||||
ScrollDirection::Left => (0, amount),
|
||||
ScrollDirection::Right => (0, -amount),
|
||||
};
|
||||
|
||||
// The function signature is:
|
||||
// new_scroll_wheel_event2(source, units, wheel_count, wheel1, wheel2, wheel3)
|
||||
// wheel_count indicates how many wheel values are valid (1, 2, or 3).
|
||||
let wheel_count = if wheel2 != 0 { 2 } else { 1 };
|
||||
let event = CGEvent::new_scroll_wheel_event2(
|
||||
source.as_deref(),
|
||||
unit,
|
||||
wheel_count,
|
||||
wheel1,
|
||||
wheel2,
|
||||
0,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to create scroll wheel event (direction={:?}, distance={:?}). \
|
||||
The cause is unknown.",
|
||||
direction, distance
|
||||
)
|
||||
})?;
|
||||
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Private implementation details.
|
||||
impl Mouse {
|
||||
/// 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> {
|
||||
let start = Instant::now();
|
||||
|
||||
loop {
|
||||
let current = self.current_position()?;
|
||||
if current == target {
|
||||
return Ok(());
|
||||
}
|
||||
if start.elapsed() >= POSITION_TIMEOUT {
|
||||
log::warn!(
|
||||
"Mouse position wait timed out. Target: ({}, {}), Current: ({}, {})",
|
||||
target.x(),
|
||||
target.y(),
|
||||
current.x(),
|
||||
current.y()
|
||||
);
|
||||
return Err(format!(
|
||||
"Timed out waiting for mouse to move to ({}, {}). Current position: ({}, {})",
|
||||
target.x(),
|
||||
target.y(),
|
||||
current.x(),
|
||||
current.y()
|
||||
));
|
||||
}
|
||||
Timer::after(POSITION_POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn current_position_cgpoint(&mut self) -> Result<CGPoint, String> {
|
||||
let event = CGEvent::new(None)
|
||||
.ok_or("Failed to query current cursor position. The cause is unknown.")?;
|
||||
let pos = CGEvent::location(Some(&event));
|
||||
Ok(pos)
|
||||
}
|
||||
|
||||
fn post_event(
|
||||
&mut self,
|
||||
event_type: CGEventType,
|
||||
point: CGPoint,
|
||||
button: CGMouseButton,
|
||||
) -> Result<(), 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={:?}, position=({}, {}), button={:?}). \
|
||||
The cause is unknown.",
|
||||
event_type, point.x, point.y, button
|
||||
)
|
||||
})?;
|
||||
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&event));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Button state tracking
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct HeldButtons {
|
||||
left: bool,
|
||||
right: bool,
|
||||
middle: bool,
|
||||
back: bool,
|
||||
forward: bool,
|
||||
}
|
||||
|
||||
impl HeldButtons {
|
||||
/// Returns the "primary" held button (preferring left > right > middle).
|
||||
fn primary_down(self) -> Option<MouseButton> {
|
||||
if self.left {
|
||||
Some(MouseButton::Left)
|
||||
} else if self.right {
|
||||
Some(MouseButton::Right)
|
||||
} else if self.middle {
|
||||
Some(MouseButton::Middle)
|
||||
} else if self.back {
|
||||
Some(MouseButton::Back)
|
||||
} else if self.forward {
|
||||
Some(MouseButton::Forward)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn set_down(&mut self, button: &MouseButton, down: bool) {
|
||||
match button {
|
||||
MouseButton::Left => self.left = down,
|
||||
MouseButton::Right => self.right = down,
|
||||
MouseButton::Middle => self.middle = down,
|
||||
MouseButton::Back => self.back = down,
|
||||
MouseButton::Forward => self.forward = down,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Event type helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
impl From<&MouseButton> for CGMouseButton {
|
||||
fn from(button: &MouseButton) -> Self {
|
||||
match button {
|
||||
MouseButton::Left => CGMouseButton::Left,
|
||||
MouseButton::Right => CGMouseButton::Right,
|
||||
MouseButton::Middle => CGMouseButton::Center,
|
||||
MouseButton::Back => CGMouseButton(3),
|
||||
MouseButton::Forward => CGMouseButton(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_down_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseDown,
|
||||
MouseButton::Right => CGEventType::RightMouseDown,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => {
|
||||
CGEventType::OtherMouseDown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_up_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseUp,
|
||||
MouseButton::Right => CGEventType::RightMouseUp,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => CGEventType::OtherMouseUp,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_dragged_event_type(button: &MouseButton) -> CGEventType {
|
||||
match button {
|
||||
MouseButton::Left => CGEventType::LeftMouseDragged,
|
||||
MouseButton::Right => CGEventType::RightMouseDragged,
|
||||
MouseButton::Middle | MouseButton::Back | MouseButton::Forward => {
|
||||
CGEventType::OtherMouseDragged
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use command::blocking::Command;
|
||||
|
||||
use super::util::main_display_scale_factor;
|
||||
use crate::ScreenshotParams;
|
||||
|
||||
/// Captures a screenshot of the main display using the built-in macOS
|
||||
/// `screencapture` CLI.
|
||||
pub fn take(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");
|
||||
|
||||
let mut cmd = Command::new("/usr/sbin/screencapture");
|
||||
cmd.args([
|
||||
"-x", // Do not play sounds.
|
||||
"-tpng", // Capture to PNG format.
|
||||
"-m", // Only capture the main display (not all displays).
|
||||
]);
|
||||
|
||||
if let Some(region) = params.region {
|
||||
region.validate()?;
|
||||
// -R x,y,w,h captures a specific rectangle in point coordinates.
|
||||
// Convert from physical pixel coordinates to point coordinates.
|
||||
let scale = main_display_scale_factor();
|
||||
let x = (region.top_left.x() as f64 / scale) as i32;
|
||||
let y = (region.top_left.y() as f64 / scale) as i32;
|
||||
let w = ((region.bottom_right.x() - region.top_left.x()) as f64 / scale) as i32;
|
||||
let h = ((region.bottom_right.y() - region.top_left.y()) as f64 / scale) as i32;
|
||||
cmd.arg("-R").arg(format!("{x},{y},{w},{h}"));
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.arg(&output_path)
|
||||
.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}"));
|
||||
}
|
||||
|
||||
crate::screenshot_utils::load_and_process_screenshot(&output_path, params)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// Returns the backing scale factor of the main display.
|
||||
///
|
||||
/// This is used to convert between pixel coordinates (as returned by screenshot tools)
|
||||
/// and point coordinates (as used by CGEvent and screencapture).
|
||||
pub fn main_display_scale_factor() -> f64 {
|
||||
use dispatch2::run_on_main;
|
||||
use objc2_app_kit::NSScreen;
|
||||
|
||||
run_on_main(|mtm| {
|
||||
NSScreen::mainScreen(mtm)
|
||||
.map(|screen| screen.backingScaleFactor())
|
||||
.unwrap_or(1.0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::ActionResult;
|
||||
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub struct Actor;
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::Actor for Actor {
|
||||
fn platform(&self) -> Option<super::Platform> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
_actions: &[super::Action],
|
||||
_options: super::Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
Ok(ActionResult {
|
||||
screenshot: None,
|
||||
cursor_position: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Shared utilities for screenshot processing.
|
||||
|
||||
use std::io::Cursor;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::Path;
|
||||
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
#[cfg(linux)]
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
|
||||
use crate::{Screenshot, ScreenshotParams};
|
||||
|
||||
/// Loads an image from a file, processes it according to the given parameters, and returns a
|
||||
/// Screenshot.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn load_and_process_screenshot(
|
||||
path: &Path,
|
||||
params: ScreenshotParams,
|
||||
) -> Result<Screenshot, String> {
|
||||
let img = image::ImageReader::open(path)
|
||||
.map_err(|e| format!("Failed to open screenshot file: {e}"))?
|
||||
.decode()
|
||||
.map_err(|e| format!("Failed to decode screenshot: {e}"))?;
|
||||
|
||||
process_screenshot(img, params)
|
||||
}
|
||||
|
||||
/// Processes a DynamicImage according to the given parameters and returns a Screenshot.
|
||||
///
|
||||
/// This validates dimensions, applies scaling if needed, and encodes the result to PNG.
|
||||
pub fn process_screenshot(
|
||||
img: DynamicImage,
|
||||
params: ScreenshotParams,
|
||||
) -> Result<Screenshot, String> {
|
||||
let (original_width, original_height) = img.dimensions();
|
||||
if original_width == 0 || original_height == 0 {
|
||||
return Err(format!(
|
||||
"Screenshot has invalid dimensions (width: {original_width}, height: {original_height})"
|
||||
));
|
||||
}
|
||||
|
||||
// Apply scaling if the image is larger than the constraints.
|
||||
let scale_factor = get_scale_factor(original_width, original_height, params);
|
||||
let img = if scale_factor < 1.0 {
|
||||
let new_width = (original_width as f64 * scale_factor).max(1.0).round() as u32;
|
||||
let new_height = (original_height as f64 * scale_factor).max(1.0).round() as u32;
|
||||
img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
|
||||
let (width, height) = img.dimensions();
|
||||
|
||||
// Encode to PNG.
|
||||
let mut data = Vec::new();
|
||||
let mut writer = Cursor::new(&mut data);
|
||||
img.write_to(&mut writer, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Failed to encode screenshot to PNG: {e}"))?;
|
||||
|
||||
Ok(Screenshot {
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
original_width: original_width as usize,
|
||||
original_height: original_height as usize,
|
||||
data,
|
||||
mime_type: "image/png".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Crops a `DynamicImage` to the specified region.
|
||||
///
|
||||
/// The coordinates are in pixels, with (0, 0) at the top-left of the image.
|
||||
#[cfg(linux)]
|
||||
pub fn crop_to_region(
|
||||
img: DynamicImage,
|
||||
top_left: Vector2I,
|
||||
bottom_right: Vector2I,
|
||||
) -> DynamicImage {
|
||||
let x = top_left.x() as u32;
|
||||
let y = top_left.y() as u32;
|
||||
let width = (bottom_right.x() - top_left.x()) as u32;
|
||||
let height = (bottom_right.y() - top_left.y()) as u32;
|
||||
img.crop_imm(x, y, width, height)
|
||||
}
|
||||
|
||||
/// Returns the scaling factor to apply to a screenshot to meet the size constraints.
|
||||
///
|
||||
/// The scale factor is chosen to ensure that:
|
||||
/// 1. The longer edge is at most `max_long_edge_px` pixels (if specified)
|
||||
/// 2. The total number of pixels is at most `max_total_px` (if specified)
|
||||
/// 3. The scale factor is at most 1.0 (no upscaling)
|
||||
///
|
||||
/// This must stay in sync with the server-side logic in logic/ai/computer_use/utils.go.
|
||||
pub fn get_scale_factor(width: u32, height: u32, params: ScreenshotParams) -> f64 {
|
||||
let long_edge = width.max(height);
|
||||
let total_pixels = width * height;
|
||||
|
||||
let long_edge_scale = params
|
||||
.max_long_edge_px
|
||||
.map(|max| max as f64 / long_edge as f64)
|
||||
.unwrap_or(1.0);
|
||||
let total_pixels_scale = params
|
||||
.max_total_px
|
||||
.map(|max| (max as f64 / total_pixels as f64).sqrt())
|
||||
.unwrap_or(1.0);
|
||||
|
||||
long_edge_scale.min(total_pixels_scale).min(1.0)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Thread-local DPI-awareness helper for Windows computer_use actions.
|
||||
//!
|
||||
//! Several Win32 APIs this crate calls (`SetCursorPos`, `GetCursorPos`,
|
||||
//! `GetSystemMetrics(SM_*VIRTUALSCREEN)`, `BitBlt`, …) return *logical* coordinates if the
|
||||
//! calling thread is not DPI-aware, which causes mis-located clicks and scaled/cropped screenshots
|
||||
//! on HiDPI monitors.
|
||||
//!
|
||||
//! Rather than relying on the host process manifest, we opt every computer_use operation into
|
||||
//! per-monitor-v2 awareness for the duration of the call via [`DpiAwarenessGuard`].
|
||||
|
||||
use windows::Win32::UI::HiDpi::{
|
||||
DPI_AWARENESS_CONTEXT, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetThreadDpiAwarenessContext,
|
||||
};
|
||||
|
||||
/// RAII guard that requests per-monitor-v2 DPI awareness for the current thread and restores the
|
||||
/// previous context when dropped.
|
||||
///
|
||||
/// Requires Windows 10 version 1703 or newer (when `PER_MONITOR_AWARE_V2` shipped — 1607 only
|
||||
/// had V1). On older systems (or when the process awareness cannot be overridden)
|
||||
/// `SetThreadDpiAwarenessContext` returns a null context; in that case this guard is a no-op.
|
||||
pub(super) struct DpiAwarenessGuard {
|
||||
previous: Option<DPI_AWARENESS_CONTEXT>,
|
||||
}
|
||||
|
||||
impl DpiAwarenessGuard {
|
||||
/// Enters per-monitor-v2 DPI awareness for the calling thread.
|
||||
pub(super) fn enter_per_monitor_v2() -> Self {
|
||||
// SAFETY: `SetThreadDpiAwarenessContext` has no preconditions and mutates only
|
||||
// thread-local state.
|
||||
let prev =
|
||||
unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
|
||||
let previous = if prev.0.is_null() { None } else { Some(prev) };
|
||||
Self { previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DpiAwarenessGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(prev) = self.previous {
|
||||
// SAFETY: `prev` was returned by a prior successful call to
|
||||
// `SetThreadDpiAwarenessContext` on this same thread.
|
||||
unsafe {
|
||||
let _ = SetThreadDpiAwarenessContext(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Keyboard input handling for Windows using SendInput.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::mem::size_of;
|
||||
use std::ptr;
|
||||
|
||||
use windows::Win32::Foundation::GetLastError;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
GetKeyboardLayout, HKL, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT,
|
||||
KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP, KEYEVENTF_SCANCODE, KEYEVENTF_UNICODE, MAPVK_VK_TO_VSC,
|
||||
MapVirtualKeyExW, SendInput, VIRTUAL_KEY, VK_LSHIFT, VK_RSHIFT, VK_SHIFT, VkKeyScanExW,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
|
||||
|
||||
use crate::Key;
|
||||
|
||||
/// How a logical [`Key`] was resolved for dispatch.
|
||||
enum ResolvedKey {
|
||||
/// Dispatch via virtual-key code / scan code. May auto-press `VK_SHIFT`.
|
||||
Vk { vk: u16, needs_shift: bool },
|
||||
/// Dispatch as a UTF-16 code unit via `KEYEVENTF_UNICODE`. Used as a fallback when the layout
|
||||
/// would require ctrl/alt to produce the character (e.g., AltGr-accessed keys on European
|
||||
/// layouts). Unicode input bypasses the keyboard layout entirely, so no modifier handling is
|
||||
/// needed.
|
||||
Unicode(u16),
|
||||
}
|
||||
|
||||
/// Bookkeeping for a logical key we've sent a down event for. We store the *resolved* state at
|
||||
/// `key_down` time so that `key_up` can release exactly what we pressed, even if the active
|
||||
/// keyboard layout has since changed (e.g., the user hit the IME/layout-switch hotkey between down
|
||||
/// and up).
|
||||
enum PressedKey {
|
||||
/// Key was dispatched via virtual-key code / scan code.
|
||||
Vk {
|
||||
/// Virtual-key code that was dispatched on `key_down`.
|
||||
vk: u16,
|
||||
/// Whether we auto-pressed `VK_SHIFT` for this key; the matching `key_up` is responsible
|
||||
/// for releasing shift when the last auto-shifted entry goes away.
|
||||
auto_shifted: bool,
|
||||
},
|
||||
/// Key was dispatched as a UTF-16 code unit via `KEYEVENTF_UNICODE`. Release sends the same
|
||||
/// unit up. No shift bookkeeping because Unicode input bypasses the keyboard layout.
|
||||
Unicode(u16),
|
||||
}
|
||||
|
||||
/// Manages keyboard state and posts keyboard events to the system.
|
||||
///
|
||||
/// Callers must pair each `key_down` with a matching `key_up` for the same logical key before the
|
||||
/// next `key_down` on that key. `pressed_keys` is keyed by the original logical `Key`, so repeated
|
||||
/// `key_down` of the same key without an intervening `key_up` overwrites the earlier bookkeeping.
|
||||
///
|
||||
/// **Auto-shift contract**: only the *first* `Key::Char` that requires shift while no shift is
|
||||
/// already held is recorded as the shift "owner" (`auto_shifted: true`). Subsequent shifted chars
|
||||
/// pressed while shift remains held ride on that first press (`auto_shifted: false`). Releasing
|
||||
/// the first char releases `VK_SHIFT`, leaving the later chars physically held without shift — the
|
||||
/// OS will produce unshifted output for them on their eventual `key_up`. Callers that need
|
||||
/// multiple shifted chars held simultaneously should use `Key::Keycode(VK_SHIFT.0)` directly.
|
||||
pub struct Keyboard {
|
||||
/// Logical keys currently pressed, keyed by the caller-supplied `Key`. Storing the resolved VK
|
||||
/// and auto-shift flag here — rather than re-resolving in `key_up` — ensures we release the
|
||||
/// exact key we pressed even if the active keyboard layout changes between the two calls.
|
||||
pressed_keys: HashMap<Key, PressedKey>,
|
||||
/// Set to `true` when a synthetic `VK_SHIFT` release dispatch failed, meaning shift may still
|
||||
/// be held in the OS with no `pressed_keys` entry to release it. We retry the release at the
|
||||
/// top of every subsequent `key_down` / `key_up` until it succeeds.
|
||||
pending_shift_release: bool,
|
||||
}
|
||||
|
||||
impl Default for Keyboard {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Keyboard {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pressed_keys: HashMap::new(),
|
||||
pending_shift_release: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries a previously-failed synthetic shift release if one is outstanding. Called at the
|
||||
/// top of every public mutating entrypoint so a transient `SendInput` failure can't leave
|
||||
/// shift stuck across the rest of the session.
|
||||
fn flush_pending_shift_release(&mut self, hkl: HKL) {
|
||||
if self.pending_shift_release && send_vk(VK_SHIFT.0, true, hkl).is_ok() {
|
||||
self.pending_shift_release = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any currently-pressed logical key has `auto_shifted: true` — i.e., this
|
||||
/// `Keyboard` is responsible for the `VK_SHIFT` currently held down. Short-circuits on the
|
||||
/// first match, unlike a count-based check.
|
||||
fn has_auto_shifted_press(&self) -> bool {
|
||||
self.pressed_keys.values().any(|p| {
|
||||
matches!(
|
||||
p,
|
||||
PressedKey::Vk {
|
||||
auto_shifted: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any currently-pressed logical key is an explicit shift keycode
|
||||
/// (`Key::Keycode(VK_SHIFT | VK_LSHIFT | VK_RSHIFT)`). Used to avoid
|
||||
/// synthesizing shift presses/releases on top of the caller's own shift state.
|
||||
fn explicit_shift_held(&self) -> bool {
|
||||
self.pressed_keys.values().any(|p| {
|
||||
matches!(
|
||||
p,
|
||||
PressedKey::Vk { vk, auto_shifted: false } if is_shift_vk(*vk)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a key down event for the given key.
|
||||
///
|
||||
/// For `Key::Char`, this will automatically press shift if needed. We skip the synthetic
|
||||
/// shift press if the caller is already holding shift explicitly (via
|
||||
/// `Key::Keycode(VK_SHIFT)`), and conversely `key_up` won't synthesize a shift release while
|
||||
/// that explicit shift entry is still tracked. The shift press and the main VK press are
|
||||
/// batched into a single `SendInput` call so other input can't be interleaved in the common
|
||||
/// case. `SendInput` can still partially succeed (e.g., UIPI blocks the main-VK entry after
|
||||
/// shift was already delivered); when that happens we best-effort release shift before
|
||||
/// returning, and if that release itself fails we mark it pending so a subsequent call can
|
||||
/// retry.
|
||||
pub fn key_down(&mut self, key: &Key) -> Result<(), String> {
|
||||
// Resolve the foreground window's keyboard layout once per public call so every
|
||||
// `SendInput` entry we build against it (shift + main VK) sees a consistent snapshot and
|
||||
// we avoid three redundant Win32 queries per INPUT.
|
||||
let hkl = foreground_keyboard_layout();
|
||||
self.flush_pending_shift_release(hkl);
|
||||
|
||||
let resolved = resolve_key(key, hkl)?;
|
||||
match resolved {
|
||||
ResolvedKey::Vk { vk, needs_shift } => self.key_down_vk(key, vk, needs_shift, hkl),
|
||||
ResolvedKey::Unicode(unit) => {
|
||||
// Unicode dispatch bypasses the keyboard layout, so no shift bookkeeping is
|
||||
// required. A single down event is sufficient; `key_up` will send the matching
|
||||
// up event using the unit we record here.
|
||||
send_inputs(&[make_unicode_input(unit, false)])?;
|
||||
self.pressed_keys
|
||||
.insert(key.clone(), PressedKey::Unicode(unit));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared `Key::Keycode` / shift-auto `Key::Char` path for [`key_down`].
|
||||
fn key_down_vk(
|
||||
&mut self,
|
||||
key: &Key,
|
||||
vk: u16,
|
||||
needs_shift: bool,
|
||||
hkl: HKL,
|
||||
) -> Result<(), String> {
|
||||
// Only send a fresh shift press if no other pressed key is already holding shift, whether
|
||||
// auto-shifted by us or explicitly pressed by the caller.
|
||||
let shift_already_held = self.has_auto_shifted_press() || self.explicit_shift_held();
|
||||
let pressed_shift_now = needs_shift && !shift_already_held;
|
||||
|
||||
let mut inputs: Vec<INPUT> = Vec::with_capacity(2);
|
||||
if pressed_shift_now {
|
||||
inputs.push(build_vk_input(VK_SHIFT.0, false, hkl));
|
||||
}
|
||||
inputs.push(build_vk_input(vk, false, hkl));
|
||||
|
||||
// Dispatch first; only record the press after `SendInput` succeeds so `pressed_keys`
|
||||
// never reflects a press we didn't actually send.
|
||||
let (sent, result) = send_inputs_tracked(&inputs);
|
||||
if let Err(e) = result {
|
||||
// Partial-send: only compensate for shift if it was actually dispatched. When
|
||||
// `sent == 0` `SendInput` failed before queueing anything (e.g., UIPI block on the
|
||||
// first event), so the shift `INPUT` never reached the OS and synthesizing a
|
||||
// `VK_SHIFT` up here would spuriously release the real user's shift if they happen
|
||||
// to be holding it. The shift entry is always `inputs[0]` when `pressed_shift_now`,
|
||||
// so `sent >= 1` tells us it went through.
|
||||
if pressed_shift_now && sent >= 1 && send_vk(VK_SHIFT.0, true, hkl).is_err() {
|
||||
self.pending_shift_release = true;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
// `auto_shifted` records whether *we* actually pressed shift for this key, not whether
|
||||
// the key needed shift. Otherwise if another source of shift (explicit `VK_SHIFT`
|
||||
// keycode, earlier auto-shifted key) was already down and released before this key,
|
||||
// `key_up` would synthesize a spurious `VK_SHIFT` release that the OS never asked for.
|
||||
//
|
||||
// If a caller violates the pair-each-down-with-an-up contract and issues two `key_down`s
|
||||
// for the same logical key, preserve the `auto_shifted: true` bit so the matching
|
||||
// `key_up` still releases the shift we pressed originally.
|
||||
let already_auto_shifted = matches!(
|
||||
self.pressed_keys.get(key),
|
||||
Some(PressedKey::Vk {
|
||||
auto_shifted: true,
|
||||
..
|
||||
})
|
||||
);
|
||||
self.pressed_keys.insert(
|
||||
key.clone(),
|
||||
PressedKey::Vk {
|
||||
vk,
|
||||
auto_shifted: pressed_shift_now || already_auto_shifted,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a key up event for the given key.
|
||||
///
|
||||
/// Uses the VK recorded at `key_down` time (not a fresh resolution against the current
|
||||
/// keyboard layout), so a mid-action layout switch still releases the key we originally
|
||||
/// pressed. If no prior `key_down` is tracked, we fall back to resolving now for best-effort
|
||||
/// delivery.
|
||||
///
|
||||
/// The shift release is attempted even when the primary key-up fails so that a single
|
||||
/// `SendInput` failure does not leave shift stuck. If the shift release itself fails, we
|
||||
/// mark it pending so the next `key_down` / `key_up` retries it.
|
||||
pub fn key_up(&mut self, key: &Key) -> Result<(), String> {
|
||||
let hkl = foreground_keyboard_layout();
|
||||
self.flush_pending_shift_release(hkl);
|
||||
|
||||
let Some(pressed) = self.pressed_keys.remove(key) else {
|
||||
// No recorded press; resolve against the current layout as a best-effort fallback so a
|
||||
// stray `key_up` still reaches the OS.
|
||||
return match resolve_key(key, hkl)? {
|
||||
ResolvedKey::Vk { vk, .. } => send_vk(vk, true, hkl),
|
||||
ResolvedKey::Unicode(unit) => send_inputs(&[make_unicode_input(unit, true)]),
|
||||
};
|
||||
};
|
||||
|
||||
match pressed {
|
||||
PressedKey::Unicode(unit) => {
|
||||
// Unicode dispatch has no shift bookkeeping; just send the matching up.
|
||||
send_inputs(&[make_unicode_input(unit, true)])
|
||||
}
|
||||
PressedKey::Vk { vk, auto_shifted } => {
|
||||
let primary = send_vk(vk, true, hkl);
|
||||
|
||||
// Attempt shift release regardless of whether the primary key-up succeeded, so a
|
||||
// single `SendInput` failure can't leave shift stuck. Only release if this was
|
||||
// an auto-shifted key, no other auto-shifted keys remain, and the caller isn't
|
||||
// holding shift explicitly.
|
||||
let should_release_shift =
|
||||
auto_shifted && !self.has_auto_shifted_press() && !self.explicit_shift_held();
|
||||
let shift_result = if should_release_shift {
|
||||
match send_vk(VK_SHIFT.0, true, hkl) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Mark the release as pending so the next call retries. Without
|
||||
// this, shift stays held in the OS with nothing left in
|
||||
// `pressed_keys` to release it.
|
||||
self.pending_shift_release = true;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Report the primary failure first, falling back to the shift-release failure
|
||||
// if the primary succeeded.
|
||||
primary.and(shift_result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulates typing text by sending Unicode keyboard events.
|
||||
///
|
||||
/// Using `KEYEVENTF_UNICODE` bypasses the keyboard layout and works with any character the
|
||||
/// target application can accept as Unicode input. The entire string is batched into a single
|
||||
/// `SendInput` call so the OS cannot interleave other input between characters.
|
||||
///
|
||||
/// Takes `&mut self` so it can `flush_pending_shift_release` like `key_down`/`key_up`,
|
||||
/// otherwise a stuck auto-shift from a prior failed release would persist through the whole
|
||||
/// typing call and on into any non-keyboard actions that follow.
|
||||
pub fn type_text(&mut self, text: &str) -> Result<(), String> {
|
||||
self.flush_pending_shift_release(foreground_keyboard_layout());
|
||||
// Each UTF-16 code unit produces one down + one up INPUT. The UTF-8 byte length is a
|
||||
// valid upper bound on the UTF-16 unit count (single-byte ASCII → 1 unit, 2-byte → 1
|
||||
// unit, 3-byte BMP → 1 unit, 4-byte supplementary → 2 units), so `bytes * 2` never
|
||||
// under-counts. This over-allocates ~3x for 3-byte UTF-8 strings (CJK, Cyrillic) but
|
||||
// avoids an extra O(n) `chars().count()` pass just to size the buffer.
|
||||
let mut inputs: Vec<INPUT> = Vec::with_capacity(text.len().saturating_mul(2));
|
||||
for ch in text.chars() {
|
||||
let mut buf = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut buf);
|
||||
// Emit a down/up pair per UTF-16 unit. `KEYEVENTF_UNICODE` delivered via
|
||||
// `TranslateMessage` / `WM_CHAR` expects each surrogate as its own down/up pair;
|
||||
// emitting all downs then all ups has been observed to drop half of the sequence in
|
||||
// some targets.
|
||||
for &unit in encoded.iter() {
|
||||
inputs.push(make_unicode_input(unit, false));
|
||||
inputs.push(make_unicode_input(unit, true));
|
||||
}
|
||||
}
|
||||
send_inputs(&inputs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a `Key` to either a virtual-key dispatch (with optional auto-shift) or a Unicode
|
||||
/// code-unit dispatch.
|
||||
fn resolve_key(key: &Key, hkl: HKL) -> Result<ResolvedKey, String> {
|
||||
match key {
|
||||
Key::Keycode(code) => {
|
||||
let vk = u16::try_from(*code).map_err(|_| {
|
||||
format!(
|
||||
"Invalid virtual-key code {code}: must be in range 0..={}",
|
||||
u16::MAX
|
||||
)
|
||||
})?;
|
||||
// For explicit VKs, the caller manages modifiers.
|
||||
Ok(ResolvedKey::Vk {
|
||||
vk,
|
||||
needs_shift: false,
|
||||
})
|
||||
}
|
||||
Key::Char(ch) => resolve_char(*ch, hkl),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a character to either a VK (with optional shift) or a Unicode code-unit dispatch,
|
||||
/// using the given keyboard layout handle (typically the foreground window's). This matches what
|
||||
/// a real keystroke would look like to the target application when the user is running a
|
||||
/// different input language / IME than Warp's thread.
|
||||
///
|
||||
/// Falls back to `ResolvedKey::Unicode` when the layout would require ctrl/alt to produce the
|
||||
/// character (e.g., AltGr-accessed keys on several European layouts) so `Key::Char` remains
|
||||
/// portable across layouts instead of erroring out.
|
||||
fn resolve_char(ch: char, hkl: HKL) -> Result<ResolvedKey, String> {
|
||||
// VkKeyScanExW only supports characters in the BMP (single UTF-16 unit). Supplementary-plane
|
||||
// characters still work via the Unicode path (they'd need a surrogate pair there, which is
|
||||
// what `type_text` handles); `Key::Char` is a single `char` so callers can't currently
|
||||
// express a supplementary-plane key event here.
|
||||
let mut buf = [0u16; 2];
|
||||
let encoded = ch.encode_utf16(&mut buf);
|
||||
if encoded.len() != 1 {
|
||||
return Err(format!(
|
||||
"Character '{ch}' is outside the Basic Multilingual Plane (BMP); use TypeText for emoji and other supplementary-plane characters"
|
||||
));
|
||||
}
|
||||
let unit = encoded[0];
|
||||
|
||||
// SAFETY: `VkKeyScanExW` is a pure query and is safe to call from any thread; `hkl` is
|
||||
// either a valid HKL or null (null falls back to the calling thread's layout).
|
||||
let result = unsafe { VkKeyScanExW(unit, hkl) };
|
||||
if result == -1 {
|
||||
// No VK mapping at all in this layout; fall back to Unicode dispatch.
|
||||
return Ok(ResolvedKey::Unicode(unit));
|
||||
}
|
||||
|
||||
// Low byte is the VK code; high byte is the shift state.
|
||||
// bit 0: shift, bit 1: ctrl, bit 2: alt.
|
||||
let bytes = result.to_le_bytes();
|
||||
let vk = bytes[0] as u16;
|
||||
let shift_state = bytes[1];
|
||||
let needs_shift = (shift_state & 0x01) != 0;
|
||||
let needs_ctrl = (shift_state & 0x02) != 0;
|
||||
let needs_alt = (shift_state & 0x04) != 0;
|
||||
|
||||
if needs_ctrl || needs_alt {
|
||||
// Character requires ctrl and/or alt (e.g., AltGr on European layouts). Synthesizing
|
||||
// those modifiers can also trigger unwanted shortcuts in the target app, so fall back
|
||||
// to layout-bypassing Unicode dispatch instead.
|
||||
return Ok(ResolvedKey::Unicode(unit));
|
||||
}
|
||||
|
||||
Ok(ResolvedKey::Vk { vk, needs_shift })
|
||||
}
|
||||
|
||||
/// Builds the `INPUT` record for a single key down or key up event on the given virtual-key
|
||||
/// code, without dispatching it. See [`send_vk`] for the full description of the scan-code
|
||||
/// translation. The caller supplies the target keyboard layout so shift + main-VK entries built
|
||||
/// for the same public call can share a consistent snapshot.
|
||||
fn build_vk_input(vk: u16, is_up: bool, hkl: HKL) -> INPUT {
|
||||
// SAFETY: `MapVirtualKeyExW` has no preconditions; reads the given HKL (null = calling
|
||||
// thread's layout) and returns 0 if no mapping exists.
|
||||
let scan = unsafe { MapVirtualKeyExW(vk as u32, MAPVK_VK_TO_VSC, Some(hkl)) } as u16;
|
||||
|
||||
let mut flag_bits: u32 = 0;
|
||||
let (w_vk, w_scan) = if scan != 0 {
|
||||
flag_bits |= KEYEVENTF_SCANCODE.0;
|
||||
if is_extended_vk(vk) {
|
||||
flag_bits |= KEYEVENTF_EXTENDEDKEY.0;
|
||||
}
|
||||
(0u16, scan)
|
||||
} else {
|
||||
// No scan-code mapping for this VK; dispatch by virtual-key code.
|
||||
(vk, 0u16)
|
||||
};
|
||||
if is_up {
|
||||
flag_bits |= KEYEVENTF_KEYUP.0;
|
||||
}
|
||||
|
||||
INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(w_vk),
|
||||
wScan: w_scan,
|
||||
dwFlags: KEYBD_EVENT_FLAGS(flag_bits),
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a single key down or key up event for the given virtual-key code, resolved against the
|
||||
/// given keyboard layout.
|
||||
///
|
||||
/// We translate the virtual-key code to a hardware scan code via `MapVirtualKeyExW` and dispatch
|
||||
/// with `KEYEVENTF_SCANCODE` (plus `KEYEVENTF_EXTENDEDKEY` for keys that require the 0xE0
|
||||
/// prefix). This reaches targets that filter synthesized VK-only events (games, some
|
||||
/// remote-desktop clients). The OS still translates the scan code back into the corresponding
|
||||
/// virtual-key code for standard window messages, so VK-reading consumers are unaffected. If no
|
||||
/// scan-code mapping exists we fall back to VK-only dispatch.
|
||||
fn send_vk(vk: u16, is_up: bool, hkl: HKL) -> Result<(), String> {
|
||||
send_inputs(&[build_vk_input(vk, is_up, hkl)])
|
||||
}
|
||||
|
||||
/// Returns true if `vk` is one of the shift virtual-key codes (generic / left / right).
|
||||
fn is_shift_vk(vk: u16) -> bool {
|
||||
vk == VK_SHIFT.0 || vk == VK_LSHIFT.0 || vk == VK_RSHIFT.0
|
||||
}
|
||||
|
||||
/// Returns the keyboard layout (`HKL`) currently active on the foreground window's thread,
|
||||
/// falling back to the calling thread's layout (HKL `0`) if there is no foreground window. Using
|
||||
/// the foreground window's HKL makes `Key::Char` resolution match what a real keystroke would
|
||||
/// produce for the target application, which matters in multilingual setups where Warp's thread
|
||||
/// layout can differ from the app's.
|
||||
fn foreground_keyboard_layout() -> HKL {
|
||||
// SAFETY: `GetForegroundWindow` has no preconditions; returns null if no foreground window.
|
||||
let hwnd = unsafe { GetForegroundWindow() };
|
||||
if hwnd.0.is_null() {
|
||||
// SAFETY: `GetKeyboardLayout(0)` returns the calling thread's layout.
|
||||
return unsafe { GetKeyboardLayout(0) };
|
||||
}
|
||||
// SAFETY: `hwnd` is a valid window handle; we pass a null `lpdwProcessId`.
|
||||
let thread_id = unsafe { GetWindowThreadProcessId(hwnd, Some(ptr::null_mut())) };
|
||||
// SAFETY: `GetKeyboardLayout` has no preconditions; 0 means "calling thread's layout".
|
||||
unsafe { GetKeyboardLayout(thread_id) }
|
||||
}
|
||||
|
||||
/// Returns true if the given virtual-key code is an "extended" key (scan code prefixed with
|
||||
/// 0xE0). `MapVirtualKeyW(MAPVK_VK_TO_VSC)` strips the 0xE0 prefix, so we set
|
||||
/// `KEYEVENTF_EXTENDEDKEY` ourselves for these VKs.
|
||||
fn is_extended_vk(vk: u16) -> bool {
|
||||
// Values from <winuser.h>. See "About Keyboard Input" on MSDN for the canonical list of
|
||||
// extended keys.
|
||||
const VK_PRIOR: u16 = 0x21;
|
||||
const VK_NEXT: u16 = 0x22;
|
||||
const VK_END: u16 = 0x23;
|
||||
const VK_HOME: u16 = 0x24;
|
||||
const VK_LEFT: u16 = 0x25;
|
||||
const VK_UP: u16 = 0x26;
|
||||
const VK_RIGHT: u16 = 0x27;
|
||||
const VK_DOWN: u16 = 0x28;
|
||||
const VK_SNAPSHOT: u16 = 0x2C;
|
||||
const VK_INSERT: u16 = 0x2D;
|
||||
const VK_DELETE: u16 = 0x2E;
|
||||
const VK_LWIN: u16 = 0x5B;
|
||||
const VK_RWIN: u16 = 0x5C;
|
||||
const VK_APPS: u16 = 0x5D;
|
||||
const VK_DIVIDE: u16 = 0x6F;
|
||||
const VK_NUMLOCK: u16 = 0x90;
|
||||
const VK_RCONTROL: u16 = 0xA3;
|
||||
const VK_RMENU: u16 = 0xA5;
|
||||
|
||||
matches!(
|
||||
vk,
|
||||
VK_PRIOR
|
||||
| VK_NEXT
|
||||
| VK_END
|
||||
| VK_HOME
|
||||
| VK_LEFT
|
||||
| VK_UP
|
||||
| VK_RIGHT
|
||||
| VK_DOWN
|
||||
| VK_SNAPSHOT
|
||||
| VK_INSERT
|
||||
| VK_DELETE
|
||||
| VK_LWIN
|
||||
| VK_RWIN
|
||||
| VK_APPS
|
||||
| VK_DIVIDE
|
||||
| VK_NUMLOCK
|
||||
| VK_RCONTROL
|
||||
| VK_RMENU
|
||||
)
|
||||
}
|
||||
|
||||
fn make_unicode_input(unit: u16, is_up: bool) -> INPUT {
|
||||
let flags = if is_up {
|
||||
KEYBD_EVENT_FLAGS(KEYEVENTF_UNICODE.0 | KEYEVENTF_KEYUP.0)
|
||||
} else {
|
||||
KEYEVENTF_UNICODE
|
||||
};
|
||||
INPUT {
|
||||
r#type: INPUT_KEYBOARD,
|
||||
Anonymous: INPUT_0 {
|
||||
ki: KEYBDINPUT {
|
||||
wVk: VIRTUAL_KEY(0),
|
||||
wScan: unit,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches a batch of `INPUT` events via `SendInput`.
|
||||
fn send_inputs(inputs: &[INPUT]) -> Result<(), String> {
|
||||
send_inputs_tracked(inputs).1
|
||||
}
|
||||
|
||||
/// Dispatches a batch of `INPUT` events via `SendInput`, returning the number of events the OS
|
||||
/// actually queued alongside the pass/fail `Result`. Callers that need to take compensating
|
||||
/// action keyed off partial delivery (e.g., "did the shift entry get through?") can branch on
|
||||
/// the `sent` count; callers that only care about pass/fail can use [`send_inputs`] directly.
|
||||
fn send_inputs_tracked(inputs: &[INPUT]) -> (u32, Result<(), String>) {
|
||||
if inputs.is_empty() {
|
||||
return (0, Ok(()));
|
||||
}
|
||||
|
||||
// SAFETY: `inputs` is a valid slice of `INPUT` with the correct element size, and `SendInput`
|
||||
// does not retain the pointer beyond the call.
|
||||
let sent = unsafe { SendInput(inputs, size_of::<INPUT>() as i32) };
|
||||
if sent as usize != inputs.len() {
|
||||
// SAFETY: `GetLastError` has no preconditions; reads the calling thread's last-error.
|
||||
let last_error = unsafe { GetLastError() }.0;
|
||||
return (
|
||||
sent,
|
||||
Err(format!(
|
||||
"SendInput dispatched only {sent}/{} keyboard events \
|
||||
(GetLastError={last_error}, blocked by UIPI or other input?)",
|
||||
inputs.len(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
(sent, Ok(()))
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Windows implementation of computer use actions using the Win32 SendInput
|
||||
//! API for input and GDI for screenshots.
|
||||
|
||||
mod dpi;
|
||||
mod keyboard;
|
||||
mod mouse;
|
||||
mod screenshot;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use warpui::r#async::Timer;
|
||||
use windows::Win32::System::StationsAndDesktops::{
|
||||
CloseDesktop, DESKTOP_ACCESS_FLAGS, DESKTOP_CONTROL_FLAGS, HDESK, OpenInputDesktop,
|
||||
};
|
||||
|
||||
use crate::{Action, ActionResult, Options};
|
||||
|
||||
/// Returns whether computer_use can drive input on this machine right now.
|
||||
///
|
||||
/// Reports `false` when there is no accessible input desktop (e.g., the process is running under
|
||||
/// Session 0 as a Windows service, the workstation is locked, or the user has switched to a
|
||||
/// different secure desktop). In those cases `SendInput` silently no-ops and GDI desktop capture
|
||||
/// fails, so we'd rather fail fast here than surface the error mid-action.
|
||||
pub fn is_supported_on_current_platform() -> bool {
|
||||
probe_input_desktop_available()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// availability can change at runtime (workstation lock, secure desktop swap, Remote Desktop
|
||||
/// reconnect).
|
||||
fn probe_input_desktop_available() -> bool {
|
||||
InputDesktop::acquire().is_some()
|
||||
}
|
||||
|
||||
/// RAII wrapper for an `HDESK` returned by `OpenInputDesktop`. Guarantees the handle is closed
|
||||
/// (or at least that a close attempt is made and logged on failure) even if the caller returns
|
||||
/// early. Modeled after the GDI handle guards in `screenshot.rs`.
|
||||
struct InputDesktop(HDESK);
|
||||
|
||||
impl InputDesktop {
|
||||
fn acquire() -> Option<Self> {
|
||||
// SAFETY: `OpenInputDesktop` has no preconditions. We pass `false` for inheritance and
|
||||
// request no specific access (just probing for existence).
|
||||
let handle =
|
||||
unsafe { OpenInputDesktop(DESKTOP_CONTROL_FLAGS(0), false, DESKTOP_ACCESS_FLAGS(0)) };
|
||||
handle.ok().map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputDesktop {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is a valid HDESK returned by `OpenInputDesktop` and has not been
|
||||
// closed yet.
|
||||
unsafe {
|
||||
if let Err(e) = CloseDesktop(self.0) {
|
||||
log::warn!("CloseDesktop failed in InputDesktop::drop: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor holds Keyboard/Mouse state unconditionally — both are cheap to construct and have no
|
||||
/// side effects — so a `perform_actions` call can recover as soon as an input desktop is
|
||||
/// reachable again, even if `Actor::new` ran while the desktop was temporarily inaccessible
|
||||
/// (workstation locked at startup, RDP disconnect, etc.). Supportability is decided per call by
|
||||
/// [`probe_input_desktop_available`] rather than being cached in the actor's shape.
|
||||
pub struct Actor {
|
||||
keyboard: keyboard::Keyboard,
|
||||
mouse: mouse::Mouse,
|
||||
}
|
||||
|
||||
impl Actor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
keyboard: keyboard::Keyboard::new(),
|
||||
mouse: mouse::Mouse::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Actor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned by `perform_actions` when the input desktop is inaccessible at call time
|
||||
/// (workstation lock, secure desktop swap, RDP reconnect, Session 0 service, …).
|
||||
const NO_INPUT_DESKTOP_ERROR: &str = "Computer use is not available: no accessible input desktop";
|
||||
|
||||
#[async_trait]
|
||||
impl super::Actor for Actor {
|
||||
fn platform(&self) -> Option<super::Platform> {
|
||||
// Live probe so callers can use `platform().is_some()` as a current "can drive input"
|
||||
// signal. Matches the Linux `Unsupported`-returns-None convention.
|
||||
if probe_input_desktop_available() {
|
||||
Some(super::Platform::Windows)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_actions(
|
||||
&mut self,
|
||||
actions: &[Action],
|
||||
options: Options,
|
||||
) -> Result<ActionResult, String> {
|
||||
// Probe at the top of every call so transient loss of the input desktop (workstation
|
||||
// lock, secure desktop swap, RDP reconnect) surfaces as a descriptive error instead of
|
||||
// letting `SendInput` silently no-op. Cheap `OpenInputDesktop`/`CloseDesktop` round-trip.
|
||||
if !probe_input_desktop_available() {
|
||||
return Err(NO_INPUT_DESKTOP_ERROR.to_string());
|
||||
}
|
||||
let keyboard = &mut self.keyboard;
|
||||
let mouse = &mut self.mouse;
|
||||
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Wait(duration) => {
|
||||
Timer::after(*duration).await;
|
||||
}
|
||||
Action::MouseDown { button, at } => {
|
||||
mouse.move_to(*at)?;
|
||||
mouse.button_down(button)?;
|
||||
}
|
||||
Action::MouseUp { button } => mouse.button_up(button)?,
|
||||
Action::MouseMove { to } => mouse.move_to(*to)?,
|
||||
Action::MouseWheel {
|
||||
at,
|
||||
direction,
|
||||
distance,
|
||||
} => {
|
||||
mouse.move_to(*at)?;
|
||||
mouse.scroll(direction, distance)?;
|
||||
}
|
||||
Action::TypeText { text } => {
|
||||
keyboard.type_text(text)?;
|
||||
}
|
||||
Action::KeyDown { key } => {
|
||||
keyboard.key_down(key)?;
|
||||
}
|
||||
Action::KeyUp { key } => {
|
||||
keyboard.key_up(key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let screenshot = if let Some(params) = options.screenshot_params {
|
||||
Some(screenshot::take(params)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ActionResult {
|
||||
screenshot,
|
||||
cursor_position: Some(mouse.current_position()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Mouse input handling for Windows.
|
||||
//!
|
||||
//! Absolute positioning is done via `SetCursorPos` (physical pixel coordinates on DPI-aware
|
||||
//! processes; logical coordinates otherwise), which avoids the normalized-coordinate math
|
||||
//! required by `SendInput` with `MOUSEEVENTF_ABSOLUTE`. Button presses, releases, and wheel
|
||||
//! scrolls go through `SendInput`.
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::mem::size_of;
|
||||
|
||||
use pathfinder_geometry::vector::Vector2I;
|
||||
use windows::Win32::Foundation::{GetLastError, POINT};
|
||||
use windows::Win32::Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromPoint};
|
||||
use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{
|
||||
INPUT, INPUT_0, INPUT_MOUSE, MOUSE_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL,
|
||||
MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP,
|
||||
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
|
||||
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetCursorPos, GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN,
|
||||
SM_YVIRTUALSCREEN, SPI_GETWHEELSCROLLCHARS, SPI_GETWHEELSCROLLLINES,
|
||||
SYSTEM_PARAMETERS_INFO_ACTION, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SetCursorPos,
|
||||
SystemParametersInfoW,
|
||||
};
|
||||
|
||||
use super::dpi::DpiAwarenessGuard;
|
||||
use crate::{MouseButton, ScrollDirection, ScrollDistance};
|
||||
|
||||
/// One wheel "click" in `MOUSEEVENTF_WHEEL`/`MOUSEEVENTF_HWHEEL` units.
|
||||
/// See the Win32 `WHEEL_DELTA` constant.
|
||||
const WHEEL_DELTA: i32 = 120;
|
||||
|
||||
/// `XBUTTON1` / `XBUTTON2` values for `mouseData` when sending X-button events. These match the
|
||||
/// Win32 header values and are not currently exposed through the `windows` crate's
|
||||
/// `KeyboardAndMouse` module.
|
||||
const XBUTTON1: u32 = 0x0001;
|
||||
const XBUTTON2: u32 = 0x0002;
|
||||
|
||||
/// Nominal line height (in logical pixels at 100% scale) used as the baseline when translating
|
||||
/// the user's `SPI_GETWHEELSCROLLLINES` setting into a pixel-per-click factor. The actual line
|
||||
/// height we use is this value scaled by the cursor-monitor DPI over `USER_DEFAULT_SCREEN_DPI`,
|
||||
/// so `ScrollDistance::Pixels` stays proportional to what the user sees on HiDPI displays (~20px
|
||||
/// at 125% scale, ~24px at 150%) — including secondary monitors in mixed-DPI setups.
|
||||
const NOMINAL_LINE_HEIGHT_PX: i32 = 16;
|
||||
|
||||
/// The "default" (1x) DPI value Windows reports; matches `USER_DEFAULT_SCREEN_DPI`.
|
||||
const DEFAULT_DPI: u32 = 96;
|
||||
|
||||
/// Fallback used if `SPI_GETWHEELSCROLLLINES` is unavailable or returns a sentinel value (e.g.,
|
||||
/// `WHEEL_PAGESCROLL`). Matches the documented Windows default of three lines per wheel click.
|
||||
const DEFAULT_WHEEL_SCROLL_LINES: u32 = 3;
|
||||
|
||||
/// Upper bound applied to the user's `SPI_GETWHEELSCROLL{LINES,CHARS}` setting before it's
|
||||
/// multiplied by `NOMINAL_LINE_HEIGHT_PX`. Without this clamp an unusually large configured value
|
||||
/// (or a corrupt value written by a partial `SystemParametersInfoW` call) would produce a
|
||||
/// huge pixels-per-click factor, forcing every small pixel scroll to round up to a single click.
|
||||
const MAX_WHEEL_SCROLL_LINES: u32 = 100;
|
||||
|
||||
/// Manages mouse state and posts mouse events to the system.
|
||||
pub struct Mouse;
|
||||
|
||||
impl Default for Mouse {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mouse {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, target: Vector2I) -> Result<(), String> {
|
||||
// Ensure this thread is per-monitor-v2 DPI aware so `SetCursorPos` receives coordinates in
|
||||
// physical pixels rather than being scaled.
|
||||
let _dpi_guard = DpiAwarenessGuard::enter_per_monitor_v2();
|
||||
// SAFETY: SetCursorPos accepts any i32 coordinates; it will clamp to the available display
|
||||
// region. This has no preconditions.
|
||||
unsafe { SetCursorPos(target.x(), target.y()) }.map_err(|e| {
|
||||
format!(
|
||||
"Failed to move cursor to ({}, {}): {e}",
|
||||
target.x(),
|
||||
target.y()
|
||||
)
|
||||
})?;
|
||||
// Also emit a `SendInput` mouse-move so consumers of raw input (`WM_INPUT`) and low-level
|
||||
// mouse hooks (`WH_MOUSE_LL`) — common in games, anti-cheat, and some remote-desktop
|
||||
// clients — see the motion. `SetCursorPos` alone only posts `WM_MOUSEMOVE` to the window
|
||||
// under the cursor. Best-effort: we ignore a `SendInput` failure here because the cursor
|
||||
// is already at the target position from `SetCursorPos` above.
|
||||
match normalized_virtual_desk_coords(target) {
|
||||
Some((dx, dy)) => {
|
||||
let _ = send_mouse_event_with_coords(
|
||||
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK,
|
||||
0,
|
||||
dx,
|
||||
dy,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Skipping WM_INPUT-visible cursor move for ({}, {}): invalid virtual-screen \
|
||||
metrics",
|
||||
target.x(),
|
||||
target.y(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn button_down(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let (flags, mouse_data) = button_down_event(button);
|
||||
send_mouse_event(flags, mouse_data)
|
||||
}
|
||||
|
||||
pub fn button_up(&mut self, button: &MouseButton) -> Result<(), String> {
|
||||
let (flags, mouse_data) = button_up_event(button);
|
||||
send_mouse_event(flags, mouse_data)
|
||||
}
|
||||
|
||||
pub fn current_position(&mut self) -> Result<Vector2I, String> {
|
||||
// Match the DPI awareness used by `move_to` so the reported position is in the same
|
||||
// coordinate space as the coordinates we send.
|
||||
let _dpi_guard = DpiAwarenessGuard::enter_per_monitor_v2();
|
||||
let mut point = POINT { x: 0, y: 0 };
|
||||
// SAFETY: `point` is a valid, writable `POINT`.
|
||||
unsafe { GetCursorPos(&mut point) }
|
||||
.map_err(|e| format!("Failed to get cursor position: {e}"))?;
|
||||
Ok(Vector2I::new(point.x, point.y))
|
||||
}
|
||||
|
||||
pub fn scroll(
|
||||
&mut self,
|
||||
direction: &ScrollDirection,
|
||||
distance: &ScrollDistance,
|
||||
) -> Result<(), String> {
|
||||
// Match the DPI awareness used by `move_to` / `current_position` so
|
||||
// `cursor_monitor_dpi` (invoked via `pixels_per_click` → `scaled_line_height_px`) resolves
|
||||
// the cursor's monitor in physical pixels even when the host process is not manifest-
|
||||
// declared per-monitor-v2 DPI aware.
|
||||
let _dpi_guard = DpiAwarenessGuard::enter_per_monitor_v2();
|
||||
// Windows expresses wheel amounts in multiples of WHEEL_DELTA (120 per "click"). Positive
|
||||
// values scroll forward (up/right); negative values scroll backward (down/left).
|
||||
// Both `Clicks` and `Pixels` are treated as unsigned magnitudes here; the direction is
|
||||
// encoded separately in `ScrollDirection`, so we `saturating_abs()` either branch to avoid
|
||||
// a negative distance canceling out `ScrollDirection` and scrolling the wrong way.
|
||||
//
|
||||
// Resolve axis flags and sign from `direction` in a single match so the
|
||||
// vertical/horizontal decision lives in exactly one place.
|
||||
let (flags, sign) = match direction {
|
||||
ScrollDirection::Up => (MOUSEEVENTF_WHEEL, 1),
|
||||
ScrollDirection::Down => (MOUSEEVENTF_WHEEL, -1),
|
||||
// Horizontal wheel: positive = right, negative = left.
|
||||
ScrollDirection::Right => (MOUSEEVENTF_HWHEEL, 1),
|
||||
ScrollDirection::Left => (MOUSEEVENTF_HWHEEL, -1),
|
||||
};
|
||||
let is_horizontal = flags == MOUSEEVENTF_HWHEEL;
|
||||
|
||||
let magnitude: i32 = match distance {
|
||||
ScrollDistance::Clicks(clicks) => clicks.saturating_abs().saturating_mul(WHEEL_DELTA),
|
||||
ScrollDistance::Pixels(pixels) => {
|
||||
// Derive pixels-per-click from the user's actual system setting
|
||||
// (`SPI_GETWHEELSCROLLLINES` for vertical, `SPI_GETWHEELSCROLLCHARS` for
|
||||
// horizontal) so we respect mouse / trackpad driver configuration instead of a
|
||||
// hard-coded constant.
|
||||
//
|
||||
// `pixels` is treated as a magnitude because the direction is encoded separately
|
||||
// in `ScrollDirection`. A zero-pixel request is a no-op; non-zero requests below
|
||||
// `pixels_per_click` round up to a single click so the scroll is still observable.
|
||||
let abs_pixels = pixels.saturating_abs();
|
||||
if abs_pixels == 0 {
|
||||
0
|
||||
} else {
|
||||
let per_click = pixels_per_click(is_horizontal);
|
||||
let clicks = (abs_pixels / per_click).clamp(1, i32::MAX / WHEEL_DELTA);
|
||||
clicks.saturating_mul(WHEEL_DELTA)
|
||||
}
|
||||
}
|
||||
};
|
||||
let signed_amount = magnitude.saturating_mul(sign);
|
||||
|
||||
// Skip zero-delta wheel events (e.g., `Clicks(0)` or `Pixels(0)`). Windows would still
|
||||
// dispatch them as observable `WM_MOUSEWHEEL`s even though no scrolling happens.
|
||||
if signed_amount == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
// `mouseData` is declared as a `u32` but `MOUSEEVENTF_WHEEL`/`HWHEEL` reinterpret the
|
||||
// bits as a signed `i32` (positive scrolls up/right, negative scrolls down/left). `as u32`
|
||||
// on an `i32` is the well-defined two's-complement reinterpretation we want here.
|
||||
send_mouse_event(flags, signed_amount as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of pixels that correspond to one wheel "click" on the requested axis,
|
||||
/// derived from the user's `SPI_GETWHEELSCROLL{LINES,CHARS}` setting. Horizontal wheel on Windows
|
||||
/// is conventionally driven by `SPI_GETWHEELSCROLLCHARS`, not `SPI_GETWHEELSCROLLLINES`. Falls
|
||||
/// back to the Windows default (3 lines/chars) if the setting is unavailable or set to the
|
||||
/// `WHEEL_PAGESCROLL` sentinel.
|
||||
fn pixels_per_click(is_horizontal: bool) -> i32 {
|
||||
let spi: SYSTEM_PARAMETERS_INFO_ACTION = if is_horizontal {
|
||||
SPI_GETWHEELSCROLLCHARS
|
||||
} else {
|
||||
SPI_GETWHEELSCROLLLINES
|
||||
};
|
||||
let mut units: u32 = DEFAULT_WHEEL_SCROLL_LINES;
|
||||
// SAFETY: `units` is a valid writable u32 and we pass its size implicitly via the fixed-layout
|
||||
// `SPI_GETWHEELSCROLL*` contract. The call does not retain the pointer beyond the call.
|
||||
let result = unsafe {
|
||||
SystemParametersInfoW(
|
||||
spi,
|
||||
0,
|
||||
Some(&mut units as *mut u32 as *mut c_void),
|
||||
SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0),
|
||||
)
|
||||
};
|
||||
// If the call fails, or the user has configured page scrolling (WHEEL_PAGESCROLL == u32::MAX),
|
||||
// fall back to the documented default.
|
||||
if result.is_err() || units == 0 || units == u32::MAX {
|
||||
units = DEFAULT_WHEEL_SCROLL_LINES;
|
||||
}
|
||||
// Clamp to `[1, MAX_WHEEL_SCROLL_LINES]` so this function's caller can always divide by the
|
||||
// result without risking a divide-by-zero (even if a future refactor drops the `== 0` guard
|
||||
// above) and so an unusually large setting can't produce a huge pixels-per-click factor.
|
||||
let units = units.clamp(1, MAX_WHEEL_SCROLL_LINES);
|
||||
(units as i32).saturating_mul(scaled_line_height_px())
|
||||
}
|
||||
|
||||
/// Returns the nominal line height in *physical* pixels for the monitor the cursor is currently
|
||||
/// on, so `ScrollDistance::Pixels` translations stay proportional to the user's display scaling
|
||||
/// even on mixed-DPI multi-monitor setups (which `GetDpiForSystem` can't express).
|
||||
fn scaled_line_height_px() -> i32 {
|
||||
let dpi = cursor_monitor_dpi();
|
||||
// `NOMINAL_LINE_HEIGHT_PX * dpi / 96`, saturating; integer math is sufficient at the
|
||||
// precision we care about here.
|
||||
let scaled = (NOMINAL_LINE_HEIGHT_PX as i64).saturating_mul(dpi as i64) / DEFAULT_DPI as i64;
|
||||
// Re-clamp back into `i32` range and ensure at least 1 so callers can divide safely.
|
||||
scaled.clamp(1, i32::MAX as i64) as i32
|
||||
}
|
||||
|
||||
/// Returns the effective DPI of the monitor currently containing the cursor, falling back to
|
||||
/// `DEFAULT_DPI` if any step of the query fails. Using the cursor's monitor (rather than the
|
||||
/// primary) keeps `ScrollDistance::Pixels` proportional to the display the user is actually
|
||||
/// scrolling on.
|
||||
fn cursor_monitor_dpi() -> u32 {
|
||||
let mut point = POINT { x: 0, y: 0 };
|
||||
// SAFETY: `point` is a valid, writable `POINT`.
|
||||
if unsafe { GetCursorPos(&mut point) }.is_err() {
|
||||
return DEFAULT_DPI;
|
||||
}
|
||||
// SAFETY: `MonitorFromPoint` has no preconditions; `MONITOR_DEFAULTTONEAREST` guarantees a
|
||||
// non-null handle when any monitor exists.
|
||||
let hmonitor = unsafe { MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST) };
|
||||
if hmonitor.is_invalid() {
|
||||
return DEFAULT_DPI;
|
||||
}
|
||||
let mut dpi_x: u32 = 0;
|
||||
let mut dpi_y: u32 = 0;
|
||||
// SAFETY: `hmonitor` is valid; `dpi_x`/`dpi_y` are writable u32s.
|
||||
if unsafe { GetDpiForMonitor(hmonitor, MDT_EFFECTIVE_DPI, &mut dpi_x, &mut dpi_y) }.is_err() {
|
||||
return DEFAULT_DPI;
|
||||
}
|
||||
// Guard against the (unexpected) 0 return so we never produce a 0-pixel line height.
|
||||
if dpi_x == 0 { DEFAULT_DPI } else { dpi_x }
|
||||
}
|
||||
|
||||
/// Translates a virtual-screen pixel coordinate into the `[0, 65535]` normalized absolute
|
||||
/// coordinates `SendInput` expects when `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` is set.
|
||||
/// Returns `None` if the virtual screen metrics are unusable.
|
||||
fn normalized_virtual_desk_coords(target: Vector2I) -> Option<(i32, i32)> {
|
||||
// SAFETY: `GetSystemMetrics` has no preconditions.
|
||||
let virt_x = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
|
||||
let virt_y = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
|
||||
let virt_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
let virt_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
if virt_w <= 0 || virt_h <= 0 {
|
||||
return None;
|
||||
}
|
||||
// Normalize into `[0, 65535]` across the virtual desktop. Use i64 to avoid overflow when the
|
||||
// virtual screen is large.
|
||||
let dx = (target.x() as i64 - virt_x as i64) * 65535 / virt_w as i64;
|
||||
let dy = (target.y() as i64 - virt_y as i64) * 65535 / virt_h as i64;
|
||||
Some((dx.clamp(0, 65535) as i32, dy.clamp(0, 65535) as i32))
|
||||
}
|
||||
|
||||
/// Returns the `(flags, mouseData)` pair for a mouse button-down event.
|
||||
fn button_down_event(button: &MouseButton) -> (MOUSE_EVENT_FLAGS, u32) {
|
||||
match button {
|
||||
MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, 0),
|
||||
MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, 0),
|
||||
MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, 0),
|
||||
MouseButton::Back => (MOUSEEVENTF_XDOWN, XBUTTON1),
|
||||
MouseButton::Forward => (MOUSEEVENTF_XDOWN, XBUTTON2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `(flags, mouseData)` pair for a mouse button-up event.
|
||||
fn button_up_event(button: &MouseButton) -> (MOUSE_EVENT_FLAGS, u32) {
|
||||
match button {
|
||||
MouseButton::Left => (MOUSEEVENTF_LEFTUP, 0),
|
||||
MouseButton::Right => (MOUSEEVENTF_RIGHTUP, 0),
|
||||
MouseButton::Middle => (MOUSEEVENTF_MIDDLEUP, 0),
|
||||
MouseButton::Back => (MOUSEEVENTF_XUP, XBUTTON1),
|
||||
MouseButton::Forward => (MOUSEEVENTF_XUP, XBUTTON2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches a single mouse event via `SendInput` with `dx` = `dy` = 0 (i.e., at the current
|
||||
/// cursor position). Use [`send_mouse_event_with_coords`] for absolute-positioned events such as
|
||||
/// `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE`.
|
||||
fn send_mouse_event(flags: MOUSE_EVENT_FLAGS, mouse_data: u32) -> Result<(), String> {
|
||||
send_mouse_event_with_coords(flags, mouse_data, 0, 0)
|
||||
}
|
||||
|
||||
/// Dispatches a single mouse event via `SendInput`. `dx`/`dy` are interpreted per Win32 docs:
|
||||
/// absolute `[0, 65535]` normalized coordinates when `MOUSEEVENTF_ABSOLUTE` is set, otherwise
|
||||
/// relative movement.
|
||||
fn send_mouse_event_with_coords(
|
||||
flags: MOUSE_EVENT_FLAGS,
|
||||
mouse_data: u32,
|
||||
dx: i32,
|
||||
dy: i32,
|
||||
) -> Result<(), String> {
|
||||
let input = INPUT {
|
||||
r#type: INPUT_MOUSE,
|
||||
Anonymous: INPUT_0 {
|
||||
mi: MOUSEINPUT {
|
||||
dx,
|
||||
dy,
|
||||
mouseData: mouse_data,
|
||||
dwFlags: flags,
|
||||
time: 0,
|
||||
dwExtraInfo: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// SAFETY: `input` is a valid `INPUT` of mouse type, with the correct size passed to
|
||||
// `SendInput`. The call does not retain any pointer beyond the call.
|
||||
let sent = unsafe { SendInput(&[input], size_of::<INPUT>() as i32) };
|
||||
if sent != 1 {
|
||||
// SAFETY: `GetLastError` has no preconditions; reads the calling thread's last-error.
|
||||
let last_error = unsafe { GetLastError() }.0;
|
||||
return Err(format!(
|
||||
"SendInput failed to dispatch mouse event (flags={:#x}, GetLastError={last_error})",
|
||||
flags.0,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Screenshot capture for Windows using GDI.
|
||||
//!
|
||||
//! Captures the full virtual screen (all monitors) or a sub-region of it by compositing the screen
|
||||
//! contents into an offscreen DIB via `BitBlt`, then reading the pixel data out with `GetDIBits`.
|
||||
//! The resulting BGRA data is converted to RGBA and handed off to the shared screenshot processing
|
||||
//! pipeline.
|
||||
|
||||
use std::mem::size_of;
|
||||
|
||||
use image::{DynamicImage, RgbaImage};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BI_RGB, BITMAPINFO, BITMAPINFOHEADER, BitBlt, CreateCompatibleBitmap, CreateCompatibleDC,
|
||||
DIB_RGB_COLORS, DeleteDC, DeleteObject, GetDC, GetDIBits, HBITMAP, HDC, HGDIOBJ, ReleaseDC,
|
||||
SRCCOPY, SelectObject,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
|
||||
};
|
||||
|
||||
use super::dpi::DpiAwarenessGuard;
|
||||
use crate::{Screenshot, ScreenshotParams};
|
||||
|
||||
/// Captures a screenshot of the full virtual screen (or a region of it).
|
||||
///
|
||||
/// On multi-monitor setups the virtual screen spans every display and its origin may be at
|
||||
/// negative coordinates (e.g., if a secondary monitor is positioned left of the primary).
|
||||
/// `ScreenshotRegion::validate` currently requires non-negative region coordinates, so callers
|
||||
/// cannot reach areas with negative virtual-screen coordinates via region captures; those areas
|
||||
/// are still included in the full-screen capture.
|
||||
///
|
||||
/// TODO: relax the non-negative check in `ScreenshotRegion::validate`
|
||||
/// (`crates/computer_use/src/lib.rs`) so the region path can reach monitors positioned above /
|
||||
/// left of the primary. The Win32 side of this module already supports negative coordinates; the
|
||||
/// restriction is shared across Mac / Linux / Windows, so this is a platform-neutral follow-up.
|
||||
pub fn take(params: ScreenshotParams) -> Result<Screenshot, String> {
|
||||
// Opt this thread into per-monitor-v2 DPI awareness so the virtual-screen metrics and `BitBlt`
|
||||
// all operate in physical pixels, regardless of the host process manifest. Dropped at end of
|
||||
// scope to restore prior context.
|
||||
let _dpi_guard = DpiAwarenessGuard::enter_per_monitor_v2();
|
||||
|
||||
// SAFETY: GetSystemMetrics has no preconditions.
|
||||
let virt_x = unsafe { GetSystemMetrics(SM_XVIRTUALSCREEN) };
|
||||
let virt_y = unsafe { GetSystemMetrics(SM_YVIRTUALSCREEN) };
|
||||
let virt_w = unsafe { GetSystemMetrics(SM_CXVIRTUALSCREEN) };
|
||||
let virt_h = unsafe { GetSystemMetrics(SM_CYVIRTUALSCREEN) };
|
||||
if virt_w <= 0 || virt_h <= 0 {
|
||||
return Err(format!(
|
||||
"Virtual screen has invalid dimensions ({virt_w}x{virt_h})"
|
||||
));
|
||||
}
|
||||
let max_x = virt_x.saturating_add(virt_w);
|
||||
let max_y = virt_y.saturating_add(virt_h);
|
||||
|
||||
// Determine the region to capture. Coordinates are in the same space as the virtual screen,
|
||||
// matching `SetCursorPos` (pixel coordinates for DPI-aware processes, logical coordinates
|
||||
// otherwise).
|
||||
let (src_x, src_y, width, height) = if let Some(region) = params.region {
|
||||
region.validate()?;
|
||||
let w = region.bottom_right.x() - region.top_left.x();
|
||||
let h = region.bottom_right.y() - region.top_left.y();
|
||||
// Validate against both ends of the virtual screen. `ScreenshotRegion::validate` only
|
||||
// enforces `top_left >= 0`, which can't catch the uncommon case where the virtual-screen
|
||||
// origin itself is positive (e.g., primary monitor repositioned) — without the explicit
|
||||
// `< virt_x/virt_y` check, `BitBlt` would silently sample pixels off the virtual screen.
|
||||
if region.top_left.x() < virt_x
|
||||
|| region.top_left.y() < virt_y
|
||||
|| region.bottom_right.x() > max_x
|
||||
|| region.bottom_right.y() > max_y
|
||||
{
|
||||
return Err(format!(
|
||||
"Screenshot region ({}, {})-({}, {}) exceeds virtual screen bounds \
|
||||
({virt_x}, {virt_y})-({max_x}, {max_y})",
|
||||
region.top_left.x(),
|
||||
region.top_left.y(),
|
||||
region.bottom_right.x(),
|
||||
region.bottom_right.y(),
|
||||
));
|
||||
}
|
||||
(region.top_left.x(), region.top_left.y(), w, h)
|
||||
} else {
|
||||
(virt_x, virt_y, virt_w, virt_h)
|
||||
};
|
||||
|
||||
let rgba = capture_rgba(src_x, src_y, width, height)?;
|
||||
|
||||
let img = RgbaImage::from_raw(width as u32, height as u32, rgba)
|
||||
.ok_or_else(|| "Failed to construct image from GDI pixel data".to_string())?;
|
||||
let img = DynamicImage::ImageRgba8(img);
|
||||
|
||||
crate::screenshot_utils::process_screenshot(img, params)
|
||||
}
|
||||
|
||||
/// Captures the screen into a freshly allocated RGBA buffer.
|
||||
///
|
||||
/// The caller is responsible for providing a valid region on the virtual screen; this function
|
||||
/// does not clip coordinates itself. Source coordinates are in virtual-screen space (the same
|
||||
/// space as `SetCursorPos`).
|
||||
fn capture_rgba(src_x: i32, src_y: i32, width: i32, height: i32) -> Result<Vec<u8>, String> {
|
||||
/// `HGDI_ERROR` = `(HGDIOBJ)(LONG_PTR)-1`, returned by `SelectObject` on a type mismatch.
|
||||
/// Named here because `HGDIOBJ::is_invalid()` only checks for NULL, and the `windows` crate
|
||||
/// we're on doesn't expose an `HGDI_ERROR` constant we can compare against directly.
|
||||
const HGDI_ERROR_SENTINEL: isize = -1;
|
||||
|
||||
// Use RAII guards so every GDI handle is released even on early returns.
|
||||
let screen_dc = ScreenDc::acquire()?;
|
||||
let mem_dc = MemoryDc::create_compatible(screen_dc.handle())?;
|
||||
let bitmap = Bitmap::create_compatible(screen_dc.handle(), width, height)?;
|
||||
|
||||
// SAFETY: `mem_dc` and `bitmap` are valid GDI handles owned by the guards.
|
||||
let prev_object = unsafe { SelectObject(mem_dc.handle(), bitmap.handle().into()) };
|
||||
// `SelectObject` returns NULL on general failure or `HGDI_ERROR` on type mismatch; check both.
|
||||
if prev_object.is_invalid() || prev_object.0 as isize == HGDI_ERROR_SENTINEL {
|
||||
return Err("SelectObject failed for screenshot bitmap".to_string());
|
||||
}
|
||||
// RAII-restore the previously-selected object on drop so the `Bitmap` guard can safely
|
||||
// `DeleteObject` it even if `BitBlt` / `GetDIBits` panic (per MSDN, `DeleteObject` fails on
|
||||
// an HBITMAP still selected into a DC, which would leak both the bitmap and DC).
|
||||
let _restore_select_guard = SelectObjectGuard {
|
||||
dc: mem_dc.handle(),
|
||||
prev_object,
|
||||
};
|
||||
|
||||
// SAFETY: both DCs are valid; BitBlt reads the screen and writes into the compatible memory
|
||||
// DC we just prepared.
|
||||
unsafe {
|
||||
BitBlt(
|
||||
mem_dc.handle(),
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
Some(screen_dc.handle()),
|
||||
src_x,
|
||||
src_y,
|
||||
SRCCOPY,
|
||||
)
|
||||
}
|
||||
.map_err(|e| format!("BitBlt failed while capturing screen: {e}"))?;
|
||||
|
||||
let buffer = read_bitmap_bits(mem_dc.handle(), bitmap.handle(), width, height)?;
|
||||
Ok(convert_bgra_to_rgba(buffer))
|
||||
}
|
||||
|
||||
/// RAII guard that restores a previously-`SelectObject`'d GDI object into `dc` when dropped,
|
||||
/// making the select/restore lifecycle panic-safe.
|
||||
struct SelectObjectGuard {
|
||||
dc: HDC,
|
||||
prev_object: HGDIOBJ,
|
||||
}
|
||||
|
||||
impl Drop for SelectObjectGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `dc` is the same DC the caller used with `SelectObject`; `prev_object` is the
|
||||
// handle that `SelectObject` returned. Both are still valid at this point because the
|
||||
// underlying DC / bitmap guards own them and haven't been dropped yet (Rust drops fields
|
||||
// and locals in reverse declaration order; this guard is declared before the outer
|
||||
// bitmap / DC guards go out of scope).
|
||||
unsafe { SelectObject(self.dc, self.prev_object) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `width x height` pixels from `bitmap` as 32-bit top-down BGRA.
|
||||
fn read_bitmap_bits(
|
||||
mem_dc: HDC,
|
||||
bitmap: HBITMAP,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// BITMAPINFO has a flexible-array of color entries at the end; for 32bpp BI_RGB we don't need
|
||||
// any, and the single-element default is sufficient.
|
||||
let mut info = BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: width,
|
||||
// Negative height requests a top-down DIB, so the first row in the buffer corresponds
|
||||
// to the top of the image.
|
||||
biHeight: -height,
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: BI_RGB.0,
|
||||
biSizeImage: 0,
|
||||
biXPelsPerMeter: 0,
|
||||
biYPelsPerMeter: 0,
|
||||
biClrUsed: 0,
|
||||
biClrImportant: 0,
|
||||
},
|
||||
bmiColors: Default::default(),
|
||||
};
|
||||
|
||||
let byte_count = (width as usize)
|
||||
.checked_mul(height as usize)
|
||||
.and_then(|n| n.checked_mul(4))
|
||||
.ok_or_else(|| format!("Screenshot dimensions {width}x{height} overflow buffer size"))?;
|
||||
let mut buffer = vec![0u8; byte_count];
|
||||
|
||||
// SAFETY: `buffer` is large enough for the requested pixels; `info` is a valid BITMAPINFO
|
||||
// describing the requested format. `GetDIBits` does not retain any of the pointers after it
|
||||
// returns.
|
||||
let scanlines = unsafe {
|
||||
GetDIBits(
|
||||
mem_dc,
|
||||
bitmap,
|
||||
0,
|
||||
height as u32,
|
||||
Some(buffer.as_mut_ptr() as *mut _),
|
||||
&mut info,
|
||||
DIB_RGB_COLORS,
|
||||
)
|
||||
};
|
||||
// `GetDIBits` returns the number of scan lines actually copied. Anything less than the
|
||||
// requested height means the buffer is only partially populated; treat that as a failure so we
|
||||
// don't silently decode a truncated image.
|
||||
if scanlines != height {
|
||||
return Err(format!(
|
||||
"GetDIBits copied {scanlines} of {height} scan lines for screenshot"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Converts a tightly packed BGRA buffer (as produced by `GetDIBits` with `biBitCount = 32` and
|
||||
/// `BI_RGB`) to RGBA in-place.
|
||||
///
|
||||
/// GDI does not populate the alpha channel for `BI_RGB`, so we force it to `0xFF` to produce a
|
||||
/// fully opaque RGBA image.
|
||||
fn convert_bgra_to_rgba(mut buffer: Vec<u8>) -> Vec<u8> {
|
||||
for chunk in buffer.chunks_exact_mut(4) {
|
||||
// Swap B and R channels so the 4-byte BGRA pixel becomes RGBA.
|
||||
chunk.swap(0, 2);
|
||||
chunk[3] = 0xFF;
|
||||
}
|
||||
buffer
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RAII guards for GDI handles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RAII wrapper for the screen device context.
|
||||
///
|
||||
/// `GetDC(NULL)` returns a DC whose coordinate space spans the entire virtual screen, so `BitBlt`
|
||||
/// can source pixels from any monitor.
|
||||
struct ScreenDc(HDC);
|
||||
|
||||
impl ScreenDc {
|
||||
fn acquire() -> Result<Self, String> {
|
||||
// SAFETY: `GetDC(None)` returns a DC for the virtual screen or a null handle on failure.
|
||||
let hdc = unsafe { GetDC(None) };
|
||||
if hdc.is_invalid() {
|
||||
return Err("GetDC(NULL) returned a null handle".to_string());
|
||||
}
|
||||
Ok(Self(hdc))
|
||||
}
|
||||
|
||||
fn handle(&self) -> HDC {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScreenDc {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` is a DC returned by `GetDC(None)` and has not been released yet.
|
||||
let released = unsafe { ReleaseDC(None, self.0) };
|
||||
if released == 0 {
|
||||
// Not fatal (the process can still continue), but indicates a handle-lifetime
|
||||
// regression worth investigating.
|
||||
log::warn!("ReleaseDC returned 0 for the screen DC");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII wrapper for a memory device context created with `CreateCompatibleDC`.
|
||||
struct MemoryDc(HDC);
|
||||
|
||||
impl MemoryDc {
|
||||
fn create_compatible(screen: HDC) -> Result<Self, String> {
|
||||
// SAFETY: `screen` is a valid DC returned from `GetDC`.
|
||||
let hdc = unsafe { CreateCompatibleDC(Some(screen)) };
|
||||
if hdc.is_invalid() {
|
||||
return Err("CreateCompatibleDC failed".to_string());
|
||||
}
|
||||
Ok(Self(hdc))
|
||||
}
|
||||
|
||||
fn handle(&self) -> HDC {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MemoryDc {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` was created by `CreateCompatibleDC` and has not been deleted yet.
|
||||
unsafe {
|
||||
let _ = DeleteDC(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII wrapper for a GDI bitmap handle.
|
||||
struct Bitmap(HBITMAP);
|
||||
|
||||
impl Bitmap {
|
||||
fn create_compatible(screen: HDC, width: i32, height: i32) -> Result<Self, String> {
|
||||
// SAFETY: `screen` is a valid DC; width and height are positive.
|
||||
let hbitmap = unsafe { CreateCompatibleBitmap(screen, width, height) };
|
||||
if hbitmap.is_invalid() {
|
||||
return Err(format!(
|
||||
"CreateCompatibleBitmap failed for {width}x{height} bitmap"
|
||||
));
|
||||
}
|
||||
Ok(Self(hbitmap))
|
||||
}
|
||||
|
||||
fn handle(&self) -> HBITMAP {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bitmap {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` was created by `CreateCompatibleBitmap` and has not been deleted yet.
|
||||
// It must not be currently selected into a DC; callers restore the previous object before
|
||||
// dropping.
|
||||
let obj: HGDIOBJ = self.0.into();
|
||||
unsafe {
|
||||
let _ = DeleteObject(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user