feat(computer): unified native addon, /computer toggle, function tool
- Replaced the separate GUI-linked pi_natives.desktop.linux-x64 addon with a pure-Rust X11 backend (x11rb RustConnection capture via RandR/GetImage, XTest input with keysym mapping) compiled into the core addon on every published target; Linux arm64 and musl are now supported and headless hosts load the addon unaffected. - Removed the native-desktop-linux cargo feature, desktop_unsupported.rs, lazy desktop loader, second napi build, desktop packaging/CI steps, GUI build dependencies, and the now-unreferenced vendored libspa crate; reverted setup-system-deps to main. - Preserved the desktop input hardening semantics on the unified backend: XTest layouts reject negative origins and coordinates beyond 0..=32767, batch coordinates stay bound to the frame last returned to JS with intermediate screenshots deferred, coordinate input requires a previously returned frame, and failed chord releases still release every held key. - Enforced a 60s worker-side execute deadline (DESKTOP_DEADLINE_EXCEEDED): no input is emitted after expiry and wait-heavy batches are rejected upfront. - Added int32 fail-closed validation for coordinates, drag points, and scroll deltas at the JS ingress and gateway schema. - Exposed computer to models without native OpenAI computer-use support as a regular function tool with a typed GA action schema across OpenAI, Azure, and Codex Responses providers, including named forced choice. - Added the /computer slash command (on/off/status/toggle) for session-only enablement via runtime tool registration in SessionTools. - Updated docs, changelogs, and contract tests accordingly.
This commit is contained in:
@@ -12,8 +12,6 @@ crate-type = ["cdylib"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
native-desktop-linux = ["dep:enigo", "dep:x11rb", "dep:xcap", "dep:xkeysym", "dep:zbus"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -58,13 +56,9 @@ unicode-segmentation.workspace = true
|
||||
unicode-width.workspace = true
|
||||
xxhash-rust.workspace = true
|
||||
|
||||
|
||||
[target.'cfg(all(target_os = "linux", not(target_env = "musl")))'.dependencies]
|
||||
enigo = { version = "=0.6.1", default-features = false, features = ["libei_smol"], optional = true }
|
||||
x11rb = { version = "=0.13.2", features = ["xinput", "xtest"], optional = true }
|
||||
xcap = { version = "=0.9.6", default-features = false, optional = true }
|
||||
xkeysym = { version = "=0.2.1", optional = true }
|
||||
zbus = { version = "5.18", optional = true }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", features = ["randr", "xtest"] }
|
||||
xkeysym = "=0.2.1"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
enigo = { version = "=0.6.1", default-features = false }
|
||||
@@ -81,6 +75,9 @@ windows-sys = { workspace = true, features = ["Wdk_Storage_FileSystem", "Win32_S
|
||||
clipboard-win.workspace = true
|
||||
winreg.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
xkeysym = "=0.2.1"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
+259
-646
File diff suppressed because it is too large
Load Diff
@@ -1,322 +0,0 @@
|
||||
//! Stable N-API desktop surface for portable Linux builds without native GUI
|
||||
//! linkage.
|
||||
//!
|
||||
//! The normal addon remains portable and does not acquire xcap/enigo GUI
|
||||
//! `DT_NEEDED` entries. glibc builds can opt into the real backend with the
|
||||
//! `native-desktop-linux` Cargo feature; musl remains explicitly unsupported.
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::task;
|
||||
|
||||
#[cfg(target_env = "musl")]
|
||||
const UNSUPPORTED: &str = "DESKTOP_BACKEND_UNAVAILABLE: native desktop capture/input is \
|
||||
unavailable in the Linux musl build because xcap 0.9.6 requires \
|
||||
dynamically linked graphical-session libraries; use a Linux glibc \
|
||||
native-desktop build";
|
||||
|
||||
#[cfg(not(target_env = "musl"))]
|
||||
const UNSUPPORTED: &str = "DESKTOP_BACKEND_UNAVAILABLE: native desktop capture/input is not \
|
||||
linked into this portable Linux addon; rebuild pi-natives with the \
|
||||
native-desktop-linux Cargo feature";
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DesktopSessionOptions {
|
||||
pub backend: Option<String>,
|
||||
pub display: Option<String>,
|
||||
pub max_width: Option<u32>,
|
||||
pub max_height: Option<u32>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DesktopPoint {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DesktopAction {
|
||||
#[napi(js_name = "type")]
|
||||
pub action_type: String,
|
||||
pub x: Option<i32>,
|
||||
pub y: Option<i32>,
|
||||
pub button: Option<String>,
|
||||
pub path: Option<Vec<DesktopPoint>>,
|
||||
pub keys: Option<Vec<String>>,
|
||||
#[napi(js_name = "scroll_x")]
|
||||
pub scroll_x: Option<i32>,
|
||||
#[napi(js_name = "scroll_y")]
|
||||
pub scroll_y: Option<i32>,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DesktopDisplay {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub scale: f64,
|
||||
pub pixel_x: u32,
|
||||
pub pixel_y: u32,
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
pub is_primary: bool,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DesktopCapabilities {
|
||||
pub backend: String,
|
||||
pub display_server: Option<String>,
|
||||
pub capture: bool,
|
||||
pub input: bool,
|
||||
pub capture_permission: String,
|
||||
pub input_permission: String,
|
||||
pub display_count: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DesktopCapture {
|
||||
pub data: Uint8Array,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub displays: Vec<DesktopDisplay>,
|
||||
pub backend: String,
|
||||
pub display_server: Option<String>,
|
||||
pub capture_permission: String,
|
||||
pub input_permission: String,
|
||||
}
|
||||
|
||||
fn invalid_action(message: impl Into<String>) -> Error {
|
||||
Error::from_reason(format!("DESKTOP_INVALID_ACTION: {}", message.into()))
|
||||
}
|
||||
|
||||
fn validate_point(x: Option<i32>, y: Option<i32>, action: &str) -> Result<()> {
|
||||
let x = x.ok_or_else(|| invalid_action(format!("{action} action requires `x`")))?;
|
||||
let y = y.ok_or_else(|| invalid_action(format!("{action} action requires `y`")))?;
|
||||
if x < 0 || y < 0 {
|
||||
return Err(invalid_action(format!("{action} coordinates must be non-negative")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_actions(actions: &[DesktopAction]) -> Result<()> {
|
||||
for action in actions {
|
||||
let extra = |present: bool, field: &str| {
|
||||
if present {
|
||||
Err(invalid_action(format!(
|
||||
"{} action contains unexpected `{field}`",
|
||||
action.action_type
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
match action.action_type.as_str() {
|
||||
"click" => {
|
||||
validate_point(action.x, action.y, "click")?;
|
||||
extra(
|
||||
action.path.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some()
|
||||
|| action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
match action.button.as_deref() {
|
||||
Some("left" | "right" | "wheel" | "back" | "forward") => {},
|
||||
Some(button) => {
|
||||
return Err(invalid_action(format!("unsupported mouse button `{button}`")));
|
||||
},
|
||||
None => return Err(invalid_action("click action requires `button`")),
|
||||
}
|
||||
},
|
||||
"double_click" | "move" => {
|
||||
validate_point(action.x, action.y, &action.action_type)?;
|
||||
extra(
|
||||
action.button.is_some()
|
||||
|| action.path.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some()
|
||||
|| action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
},
|
||||
"drag" => {
|
||||
extra(
|
||||
action.x.is_some()
|
||||
|| action.y.is_some()
|
||||
|| action.button.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some()
|
||||
|| action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
let path = action
|
||||
.path
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid_action("drag action requires `path`"))?;
|
||||
if path.len() < 2 || path.iter().any(|point| point.x < 0 || point.y < 0) {
|
||||
return Err(invalid_action(
|
||||
"drag action requires at least two non-negative path points",
|
||||
));
|
||||
}
|
||||
},
|
||||
"keypress" => {
|
||||
extra(
|
||||
action.x.is_some()
|
||||
|| action.y.is_some()
|
||||
|| action.button.is_some()
|
||||
|| action.path.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some()
|
||||
|| action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
if action
|
||||
.keys
|
||||
.as_ref()
|
||||
.is_none_or(|keys| keys.is_empty() || keys.iter().any(String::is_empty))
|
||||
{
|
||||
return Err(invalid_action("keypress action requires at least one non-empty key"));
|
||||
}
|
||||
},
|
||||
"screenshot" | "wait" => {
|
||||
extra(
|
||||
action.x.is_some()
|
||||
|| action.y.is_some()
|
||||
|| action.button.is_some()
|
||||
|| action.path.is_some()
|
||||
|| action.keys.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some()
|
||||
|| action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
},
|
||||
"scroll" => {
|
||||
validate_point(action.x, action.y, "scroll")?;
|
||||
extra(
|
||||
action.button.is_some() || action.path.is_some() || action.text.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
if action.scroll_x.is_none() || action.scroll_y.is_none() {
|
||||
return Err(invalid_action("scroll action requires `scroll_x` and `scroll_y`"));
|
||||
}
|
||||
},
|
||||
"type" => {
|
||||
extra(
|
||||
action.x.is_some()
|
||||
|| action.y.is_some()
|
||||
|| action.button.is_some()
|
||||
|| action.path.is_some()
|
||||
|| action.keys.is_some()
|
||||
|| action.scroll_x.is_some()
|
||||
|| action.scroll_y.is_some(),
|
||||
"field",
|
||||
)?;
|
||||
if action.text.is_none() {
|
||||
return Err(invalid_action("type action requires `text`"));
|
||||
}
|
||||
},
|
||||
other => return Err(invalid_action(format!("unsupported desktop action type `{other}`"))),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct DesktopSession {
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DesktopSession {
|
||||
#[napi(constructor)]
|
||||
pub fn new(options: Option<DesktopSessionOptions>) -> Result<Self> {
|
||||
let options = options.unwrap_or_default();
|
||||
match options.backend.as_deref().unwrap_or("auto") {
|
||||
"auto" | "native" => {},
|
||||
other => {
|
||||
return Err(Error::from_reason(format!(
|
||||
"DESKTOP_INVALID_OPTIONS: unsupported backend `{other}`; expected `auto` or \
|
||||
`native`"
|
||||
)));
|
||||
},
|
||||
}
|
||||
if options.max_width == Some(0) || options.max_height == Some(0) {
|
||||
return Err(Error::from_reason(
|
||||
"DESKTOP_INVALID_OPTIONS: maxWidth and maxHeight must be greater than zero",
|
||||
));
|
||||
}
|
||||
if let Some(display) = options.display
|
||||
&& display != "all"
|
||||
&& display.parse::<u32>().is_err()
|
||||
{
|
||||
return Err(Error::from_reason(format!(
|
||||
"DESKTOP_INVALID_OPTIONS: display must be `all` or a numeric monitor id, got \
|
||||
`{display}`"
|
||||
)));
|
||||
}
|
||||
Ok(Self { closed: Arc::new(AtomicBool::new(false)) })
|
||||
}
|
||||
|
||||
#[napi(getter)]
|
||||
pub fn capabilities(&self) -> DesktopCapabilities {
|
||||
DesktopCapabilities {
|
||||
backend: "unavailable".to_string(),
|
||||
display_server: None,
|
||||
capture: false,
|
||||
input: false,
|
||||
capture_permission: "unavailable".to_string(),
|
||||
input_permission: "unavailable".to_string(),
|
||||
display_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn capture(&self) -> task::Promise<DesktopCapture> {
|
||||
let closed = Arc::clone(&self.closed);
|
||||
task::blocking("desktop.capture.unsupported", (), move |_| {
|
||||
if closed.load(Ordering::Acquire) {
|
||||
Err(Error::from_reason("DESKTOP_SESSION_CLOSED: desktop session is closed"))
|
||||
} else {
|
||||
Err(Error::from_reason(UNSUPPORTED))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn execute(&self, actions: Vec<DesktopAction>) -> Result<task::Promise<DesktopCapture>> {
|
||||
validate_actions(&actions)?;
|
||||
let closed = Arc::clone(&self.closed);
|
||||
Ok(task::blocking("desktop.execute.unsupported", (), move |_| {
|
||||
if closed.load(Ordering::Acquire) {
|
||||
Err(Error::from_reason("DESKTOP_SESSION_CLOSED: desktop session is closed"))
|
||||
} else {
|
||||
Err(Error::from_reason(UNSUPPORTED))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn close(&self) -> task::Promise<()> {
|
||||
let closed = Arc::clone(&self.closed);
|
||||
task::blocking("desktop.close.unsupported", (), move |_| {
|
||||
closed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
//! Pure-Rust X11 desktop capture and input backend for [`crate::desktop`].
|
||||
//!
|
||||
//! Speaks the X11 wire protocol directly through x11rb's `RustConnection`, so
|
||||
//! the core addon acquires no C GUI `DT_NEEDED` entries (libxcb, libpipewire,
|
||||
//! libxkbcommon, libwayland) and keeps `dlopen` working on headless servers.
|
||||
//! Capture uses core `GetImage` over the root window, monitor enumeration uses
|
||||
//! RandR 1.5 monitors, and input is synthesized with XTest — which also works
|
||||
//! under XWayland, where XTest coordinates land in the same X11 global space
|
||||
//! `GetImage` composites from.
|
||||
//!
|
||||
//! The types mirror the names, variants, and call shapes of the enigo/xcap
|
||||
//! surface `crate::desktop` compiles against on macOS and Windows, so the
|
||||
//! session core stays platform-agnostic.
|
||||
//!
|
||||
//! Everything protocol-facing is `cfg(target_os = "linux")`; the pure
|
||||
//! conversion and mapping helpers below compile under `cfg(test)` on every
|
||||
//! platform so they can be unit-tested without a live X server.
|
||||
|
||||
use image::RgbaImage;
|
||||
use xkeysym::Keysym;
|
||||
|
||||
/// Scroll axis, mirroring `enigo::Axis`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Axis {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Pointer button, mirroring `enigo::Button`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Button {
|
||||
Left,
|
||||
Middle,
|
||||
Right,
|
||||
Back,
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// Key selector, mirroring the `enigo::Key` variants `crate::desktop` uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
Control,
|
||||
Shift,
|
||||
Alt,
|
||||
Meta,
|
||||
Return,
|
||||
Escape,
|
||||
Tab,
|
||||
Space,
|
||||
Backspace,
|
||||
Delete,
|
||||
Insert,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
UpArrow,
|
||||
DownArrow,
|
||||
LeftArrow,
|
||||
RightArrow,
|
||||
CapsLock,
|
||||
Numlock,
|
||||
PrintScr,
|
||||
F1,
|
||||
F2,
|
||||
F3,
|
||||
F4,
|
||||
F5,
|
||||
F6,
|
||||
F7,
|
||||
F8,
|
||||
F9,
|
||||
F10,
|
||||
F11,
|
||||
F12,
|
||||
F13,
|
||||
F14,
|
||||
F15,
|
||||
F16,
|
||||
F17,
|
||||
F18,
|
||||
F19,
|
||||
F20,
|
||||
F21,
|
||||
F22,
|
||||
F23,
|
||||
F24,
|
||||
Unicode(char),
|
||||
}
|
||||
|
||||
/// Coordinate mode, mirroring `enigo::Coordinate`. `XTest` motion with detail
|
||||
/// 0 is always absolute in root coordinates, which is the only mode the session
|
||||
/// core uses.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Coordinate {
|
||||
Abs,
|
||||
}
|
||||
|
||||
/// Key/button transition, mirroring `enigo::Direction`.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
Press,
|
||||
Release,
|
||||
Click,
|
||||
}
|
||||
|
||||
/// X11 pointer button numbers for scroll emulation: 4/5 scroll up/down and
|
||||
/// 6/7 scroll left/right, one press/release pair per step.
|
||||
const fn scroll_button(axis: Axis, negative: bool) -> u8 {
|
||||
match (axis, negative) {
|
||||
(Axis::Vertical, true) => 4,
|
||||
(Axis::Vertical, false) => 5,
|
||||
(Axis::Horizontal, true) => 6,
|
||||
(Axis::Horizontal, false) => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// Core-protocol pointer button numbers (`Back`/`Forward` follow the
|
||||
/// conventional 8/9 mapping every toolkit understands).
|
||||
const fn button_detail(button: Button) -> u8 {
|
||||
match button {
|
||||
Button::Left => 1,
|
||||
Button::Middle => 2,
|
||||
Button::Right => 3,
|
||||
Button::Back => 8,
|
||||
Button::Forward => 9,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keysym for one typed character. `'\n'` intentionally maps to `Return`
|
||||
/// rather than the historical `Linefeed` keysym `xkb_utf32_to_keysym` would
|
||||
/// produce, because applications expect Enter.
|
||||
fn keysym_for_char(ch: char) -> u32 {
|
||||
match ch {
|
||||
'\n' | '\r' => Keysym::Return.raw(),
|
||||
'\t' => Keysym::Tab.raw(),
|
||||
_ => Keysym::from_char(ch).raw(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keysym for a named key or typed character.
|
||||
fn keysym_for_key(key: Key) -> u32 {
|
||||
let keysym = match key {
|
||||
Key::Control => Keysym::Control_L,
|
||||
Key::Shift => Keysym::Shift_L,
|
||||
Key::Alt => Keysym::Alt_L,
|
||||
Key::Meta => Keysym::Super_L,
|
||||
Key::Return => Keysym::Return,
|
||||
Key::Escape => Keysym::Escape,
|
||||
Key::Tab => Keysym::Tab,
|
||||
Key::Space => Keysym::space,
|
||||
Key::Backspace => Keysym::BackSpace,
|
||||
Key::Delete => Keysym::Delete,
|
||||
Key::Insert => Keysym::Insert,
|
||||
Key::Home => Keysym::Home,
|
||||
Key::End => Keysym::End,
|
||||
Key::PageUp => Keysym::Prior,
|
||||
Key::PageDown => Keysym::Next,
|
||||
Key::UpArrow => Keysym::Up,
|
||||
Key::DownArrow => Keysym::Down,
|
||||
Key::LeftArrow => Keysym::Left,
|
||||
Key::RightArrow => Keysym::Right,
|
||||
Key::CapsLock => Keysym::Caps_Lock,
|
||||
Key::Numlock => Keysym::Num_Lock,
|
||||
Key::PrintScr => Keysym::Print,
|
||||
Key::F1 => Keysym::F1,
|
||||
Key::F2 => Keysym::F2,
|
||||
Key::F3 => Keysym::F3,
|
||||
Key::F4 => Keysym::F4,
|
||||
Key::F5 => Keysym::F5,
|
||||
Key::F6 => Keysym::F6,
|
||||
Key::F7 => Keysym::F7,
|
||||
Key::F8 => Keysym::F8,
|
||||
Key::F9 => Keysym::F9,
|
||||
Key::F10 => Keysym::F10,
|
||||
Key::F11 => Keysym::F11,
|
||||
Key::F12 => Keysym::F12,
|
||||
Key::F13 => Keysym::F13,
|
||||
Key::F14 => Keysym::F14,
|
||||
Key::F15 => Keysym::F15,
|
||||
Key::F16 => Keysym::F16,
|
||||
Key::F17 => Keysym::F17,
|
||||
Key::F18 => Keysym::F18,
|
||||
Key::F19 => Keysym::F19,
|
||||
Key::F20 => Keysym::F20,
|
||||
Key::F21 => Keysym::F21,
|
||||
Key::F22 => Keysym::F22,
|
||||
Key::F23 => Keysym::F23,
|
||||
Key::F24 => Keysym::F24,
|
||||
Key::Unicode(ch) => return keysym_for_char(ch),
|
||||
};
|
||||
keysym.raw()
|
||||
}
|
||||
|
||||
/// Borrowed view of a `GetKeyboardMapping` reply for pure lookups.
|
||||
struct KeymapView<'a> {
|
||||
min_keycode: u8,
|
||||
keysyms_per_keycode: u8,
|
||||
keysyms: &'a [u32],
|
||||
}
|
||||
|
||||
/// Find a keycode producing `keysym` in the first keyboard group. Returns the
|
||||
/// keycode and whether Shift (column 1) is required. Any unshifted binding is
|
||||
/// preferred over any shifted one.
|
||||
fn keysym_position(view: &KeymapView<'_>, keysym: u32) -> Option<(u8, bool)> {
|
||||
if keysym == 0 || view.keysyms_per_keycode == 0 {
|
||||
return None;
|
||||
}
|
||||
let per = usize::from(view.keysyms_per_keycode);
|
||||
let mut shifted = None;
|
||||
for (row, chunk) in view.keysyms.chunks_exact(per).enumerate() {
|
||||
let keycode = view.min_keycode.checked_add(row as u8)?;
|
||||
if chunk[0] == keysym {
|
||||
return Some((keycode, false));
|
||||
}
|
||||
if shifted.is_none() && per > 1 && chunk[1] == keysym {
|
||||
shifted = Some(keycode);
|
||||
}
|
||||
}
|
||||
shifted.map(|keycode| (keycode, true))
|
||||
}
|
||||
|
||||
/// Find a keycode with no bound keysyms, preferring high keycodes so a
|
||||
/// temporary binding stays clear of real keyboard rows.
|
||||
fn spare_keycode(view: &KeymapView<'_>) -> Option<u8> {
|
||||
if view.keysyms_per_keycode == 0 {
|
||||
return None;
|
||||
}
|
||||
let per = usize::from(view.keysyms_per_keycode);
|
||||
let rows = view.keysyms.len() / per;
|
||||
(0..rows).rev().find_map(|row| {
|
||||
view.keysyms[row * per..(row + 1) * per]
|
||||
.iter()
|
||||
.all(|&keysym| keysym == 0)
|
||||
.then(|| view.min_keycode.checked_add(row as u8))
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a `GetImage` `ZPixmap` reply into RGBA with alpha forced to 255.
|
||||
///
|
||||
/// Assumes the ubiquitous `TrueColor` channel masks (red `0xff0000`, green
|
||||
/// `0xff00`, blue `0xff`); handles 24- and 32-bit pixel units in either image
|
||||
/// byte order and scanline padding. Depths below 24 (pseudo-color/high-color
|
||||
/// visuals) are rejected.
|
||||
fn zpixmap_to_rgba(
|
||||
data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
depth: u8,
|
||||
bits_per_pixel: u8,
|
||||
scanline_pad: u8,
|
||||
lsb_first: bool,
|
||||
) -> Result<RgbaImage, String> {
|
||||
if depth < 24 {
|
||||
return Err(format!(
|
||||
"unsupported X11 image depth {depth}; a 24- or 32-bit TrueColor visual is required"
|
||||
));
|
||||
}
|
||||
let bytes_per_pixel = match bits_per_pixel {
|
||||
24 => 3usize,
|
||||
32 => 4usize,
|
||||
other => return Err(format!("unsupported X11 pixel size of {other} bits per pixel")),
|
||||
};
|
||||
let width_usize = width as usize;
|
||||
let height_usize = height as usize;
|
||||
let pad_bits = usize::from(scanline_pad).max(8);
|
||||
let stride = (width_usize * usize::from(bits_per_pixel)).div_ceil(pad_bits) * pad_bits / 8;
|
||||
let needed = stride
|
||||
.checked_mul(height_usize)
|
||||
.filter(|&bytes| bytes <= data.len());
|
||||
if needed.is_none() {
|
||||
return Err(format!(
|
||||
"X11 image data is truncated: {width}x{height} at {bits_per_pixel} bpp needs \
|
||||
{stride}x{height} bytes, got {}",
|
||||
data.len()
|
||||
));
|
||||
}
|
||||
let mut rgba = Vec::with_capacity(width_usize * height_usize * 4);
|
||||
for row in data.chunks_exact(stride).take(height_usize) {
|
||||
for pixel in row[..width_usize * bytes_per_pixel].chunks_exact(bytes_per_pixel) {
|
||||
let value = if lsb_first {
|
||||
pixel
|
||||
.iter()
|
||||
.rev()
|
||||
.fold(0u32, |acc, &byte| acc << 8 | u32::from(byte))
|
||||
} else {
|
||||
pixel
|
||||
.iter()
|
||||
.fold(0u32, |acc, &byte| acc << 8 | u32::from(byte))
|
||||
};
|
||||
rgba.extend_from_slice(&[(value >> 16) as u8, (value >> 8) as u8, value as u8, 255]);
|
||||
}
|
||||
}
|
||||
RgbaImage::from_raw(width, height, rgba)
|
||||
.ok_or_else(|| "X11 image dimensions are inconsistent".to_string())
|
||||
}
|
||||
/// One step of last-resort keyboard cleanup when the input connection drops.
|
||||
enum X11CleanupOperation<'a> {
|
||||
/// Release a keycode a `Press` left held.
|
||||
Release(u8),
|
||||
/// Restore a temporarily rebound keycode row to all-zero keysyms; the row
|
||||
/// must span exactly the server's `keysyms_per_keycode` width.
|
||||
Restore(u8, &'a [u32]),
|
||||
}
|
||||
|
||||
/// Release every held keycode in reverse order, then restore every listed
|
||||
/// keycode row. A failed step never stops the remaining cleanup; the first
|
||||
/// error is surfaced after everything has been attempted.
|
||||
fn cleanup_x11_keyboard_state<E>(
|
||||
held_keycodes: &[u8],
|
||||
mapped_keycodes: &[u8],
|
||||
keysyms_per_code: u8,
|
||||
mut perform: impl FnMut(X11CleanupOperation<'_>) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
let mut first_error = None;
|
||||
for &keycode in held_keycodes.iter().rev() {
|
||||
if let Err(error) = perform(X11CleanupOperation::Release(keycode))
|
||||
&& first_error.is_none()
|
||||
{
|
||||
first_error = Some(error);
|
||||
}
|
||||
}
|
||||
let empty_row = vec![0; usize::from(keysyms_per_code)];
|
||||
for &keycode in mapped_keycodes {
|
||||
if let Err(error) = perform(X11CleanupOperation::Restore(keycode, &empty_row))
|
||||
&& first_error.is_none()
|
||||
{
|
||||
first_error = Some(error);
|
||||
}
|
||||
}
|
||||
match first_error {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11 {
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::RgbaImage;
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{
|
||||
BUTTON_PRESS_EVENT, BUTTON_RELEASE_EVENT, ConnectionExt as _, ImageFormat, ImageOrder,
|
||||
KEY_PRESS_EVENT, KEY_RELEASE_EVENT, MOTION_NOTIFY_EVENT, Window,
|
||||
},
|
||||
xtest::ConnectionExt as _,
|
||||
},
|
||||
rust_connection::RustConnection,
|
||||
wrapper::ConnectionExt as _,
|
||||
};
|
||||
use xkeysym::Keysym;
|
||||
|
||||
use super::{
|
||||
Axis, Button, Coordinate, Direction, Key, KeymapView, X11CleanupOperation, button_detail,
|
||||
cleanup_x11_keyboard_state, keysym_for_char, keysym_for_key, keysym_position, scroll_button,
|
||||
spare_keycode, zpixmap_to_rgba,
|
||||
};
|
||||
|
||||
/// Capture/metadata-side failure. Stringly typed on purpose: the session
|
||||
/// core only forwards it through `Display`-generic error mappers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct X11Error(String);
|
||||
|
||||
impl std::fmt::Display for X11Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Input-side failure, kept distinct so call sites map it to
|
||||
/// `DESKTOP_INPUT_FAILED` rather than a capture error.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct X11InputError(String);
|
||||
|
||||
impl std::fmt::Display for X11InputError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn connect_error(error: impl std::fmt::Display) -> String {
|
||||
format!(
|
||||
"X11 connection failed; ensure DISPLAY points at a reachable X server (Wayland sessions \
|
||||
need XWayland): {error}"
|
||||
)
|
||||
}
|
||||
|
||||
fn capture_error(error: impl std::fmt::Display) -> X11Error {
|
||||
X11Error(format!("X11 request failed: {error}"))
|
||||
}
|
||||
|
||||
fn input_request_error(error: impl std::fmt::Display) -> X11InputError {
|
||||
X11InputError(format!("X11 input request failed: {error}"))
|
||||
}
|
||||
|
||||
/// One `RandR` monitor plus the shared session connection used to capture
|
||||
/// it.
|
||||
///
|
||||
/// Matches the `xcap::Monitor` call shapes `crate::desktop` uses; the
|
||||
/// `Result` accessors exist purely for that signature parity.
|
||||
#[derive(Debug)]
|
||||
pub struct Monitor {
|
||||
conn: Arc<RustConnection>,
|
||||
root: Window,
|
||||
id: u32,
|
||||
name: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
primary: bool,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::unnecessary_wraps,
|
||||
reason = "accessor signatures mirror xcap::Monitor so desktop.rs call sites stay identical"
|
||||
)]
|
||||
impl Monitor {
|
||||
/// Enumerate active `RandR` monitors over a fresh connection shared by
|
||||
/// the returned monitors for their captures.
|
||||
pub fn all() -> Result<Vec<Self>, X11Error> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).map_err(|error| X11Error(connect_error(error)))?;
|
||||
let conn = Arc::new(conn);
|
||||
let screen = conn
|
||||
.setup()
|
||||
.roots
|
||||
.get(screen_num)
|
||||
.ok_or_else(|| X11Error("X11 setup reported no default screen".to_string()))?;
|
||||
let root = screen.root;
|
||||
let reply = conn
|
||||
.randr_get_monitors(root, true)
|
||||
.map_err(capture_error)?
|
||||
.reply()
|
||||
.map_err(|error| X11Error(format!("RandR monitor enumeration failed: {error}")))?;
|
||||
let mut monitors = Vec::with_capacity(reply.monitors.len().max(1));
|
||||
for info in reply.monitors {
|
||||
// The monitor name atom is stable for the X server session and
|
||||
// doubles as the stable display id.
|
||||
let name = conn
|
||||
.get_atom_name(info.name)
|
||||
.ok()
|
||||
.and_then(|cookie| cookie.reply().ok())
|
||||
.map_or_else(
|
||||
|| format!("Monitor {}", info.name),
|
||||
|atom| String::from_utf8_lossy(&atom.name).into_owned(),
|
||||
);
|
||||
monitors.push(Self {
|
||||
conn: Arc::clone(&conn),
|
||||
root,
|
||||
id: info.name,
|
||||
name,
|
||||
x: i32::from(info.x),
|
||||
y: i32::from(info.y),
|
||||
width: u32::from(info.width),
|
||||
height: u32::from(info.height),
|
||||
primary: info.primary,
|
||||
});
|
||||
}
|
||||
if monitors.is_empty() {
|
||||
// RandR-less or misconfigured servers (some Xvfb/Xvnc setups)
|
||||
// still expose the core screen geometry.
|
||||
monitors.push(Self {
|
||||
conn: Arc::clone(&conn),
|
||||
root,
|
||||
id: 0,
|
||||
name: "Screen".to_string(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: u32::from(screen.width_in_pixels),
|
||||
height: u32::from(screen.height_in_pixels),
|
||||
primary: true,
|
||||
});
|
||||
}
|
||||
Ok(monitors)
|
||||
}
|
||||
|
||||
pub const fn id(&self) -> Result<u32, X11Error> {
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Result<String, X11Error> {
|
||||
Ok(self.name.clone())
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> Result<String, X11Error> {
|
||||
Ok(self.name.clone())
|
||||
}
|
||||
|
||||
pub const fn x(&self) -> Result<i32, X11Error> {
|
||||
Ok(self.x)
|
||||
}
|
||||
|
||||
pub const fn y(&self) -> Result<i32, X11Error> {
|
||||
Ok(self.y)
|
||||
}
|
||||
|
||||
pub const fn width(&self) -> Result<u32, X11Error> {
|
||||
Ok(self.width)
|
||||
}
|
||||
|
||||
pub const fn height(&self) -> Result<u32, X11Error> {
|
||||
Ok(self.height)
|
||||
}
|
||||
|
||||
/// X11 global coordinates are physical pixels, and `XTest` input uses
|
||||
/// the very same space, so the capture/input correlation is exact at
|
||||
/// scale 1.0 regardless of any client-side `HiDPI` scaling.
|
||||
pub const fn scale_factor(&self) -> Result<f32, X11Error> {
|
||||
Ok(1.0)
|
||||
}
|
||||
|
||||
pub const fn is_primary(&self) -> Result<bool, X11Error> {
|
||||
Ok(self.primary)
|
||||
}
|
||||
|
||||
/// Capture this monitor's rectangle from the root window as RGBA.
|
||||
pub fn capture_image(&self) -> Result<RgbaImage, X11Error> {
|
||||
let x = i16::try_from(self.x)
|
||||
.map_err(|_| X11Error("monitor origin exceeds the X11 coordinate space".into()))?;
|
||||
let y = i16::try_from(self.y)
|
||||
.map_err(|_| X11Error("monitor origin exceeds the X11 coordinate space".into()))?;
|
||||
let width = u16::try_from(self.width)
|
||||
.map_err(|_| X11Error("monitor size exceeds the X11 coordinate space".into()))?;
|
||||
let height = u16::try_from(self.height)
|
||||
.map_err(|_| X11Error("monitor size exceeds the X11 coordinate space".into()))?;
|
||||
let reply = self
|
||||
.conn
|
||||
.get_image(ImageFormat::Z_PIXMAP, self.root, x, y, width, height, !0)
|
||||
.map_err(capture_error)?
|
||||
.reply()
|
||||
.map_err(|error| X11Error(format!("X11 GetImage failed: {error}")))?;
|
||||
let setup = self.conn.setup();
|
||||
let format = setup
|
||||
.pixmap_formats
|
||||
.iter()
|
||||
.find(|format| format.depth == reply.depth)
|
||||
.ok_or_else(|| {
|
||||
X11Error(format!("X server advertises no pixmap format for depth {}", reply.depth))
|
||||
})?;
|
||||
zpixmap_to_rgba(
|
||||
&reply.data,
|
||||
self.width,
|
||||
self.height,
|
||||
reply.depth,
|
||||
format.bits_per_pixel,
|
||||
format.scanline_pad,
|
||||
setup.image_byte_order == ImageOrder::LSB_FIRST,
|
||||
)
|
||||
.map_err(X11Error)
|
||||
}
|
||||
}
|
||||
|
||||
/// XTest-backed input synthesizer owning its own connection, mirroring the
|
||||
/// `enigo::Enigo` call shapes.
|
||||
///
|
||||
/// The keyboard mapping is cached; it only changes underneath us if the
|
||||
/// user swaps layouts mid-session, and our own temporary bindings are
|
||||
/// written back before anyone else can observe them.
|
||||
#[derive(Debug)]
|
||||
pub struct Input {
|
||||
conn: RustConnection,
|
||||
root: Window,
|
||||
min_keycode: u8,
|
||||
keysyms_per_keycode: u8,
|
||||
keysyms: Vec<u32>,
|
||||
/// Keysyms currently held down through a temporary spare-keycode
|
||||
/// binding; the binding must survive until the matching release.
|
||||
held_temp: Vec<(u32, u8)>,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// Connect and verify the XTEST extension is usable.
|
||||
pub fn new() -> Result<Self, X11InputError> {
|
||||
let (conn, screen_num) =
|
||||
x11rb::connect(None).map_err(|error| X11InputError(connect_error(error)))?;
|
||||
let setup = conn.setup();
|
||||
let root = setup
|
||||
.roots
|
||||
.get(screen_num)
|
||||
.ok_or_else(|| X11InputError("X11 setup reported no default screen".to_string()))?
|
||||
.root;
|
||||
let (min_keycode, max_keycode) = (setup.min_keycode, setup.max_keycode);
|
||||
conn
|
||||
.xtest_get_version(2, 2)
|
||||
.map_err(input_request_error)?
|
||||
.reply()
|
||||
.map_err(|error| {
|
||||
X11InputError(format!(
|
||||
"the X server does not support the XTEST extension required for native input: \
|
||||
{error}"
|
||||
))
|
||||
})?;
|
||||
let mapping = conn
|
||||
.get_keyboard_mapping(min_keycode, max_keycode - min_keycode + 1)
|
||||
.map_err(input_request_error)?
|
||||
.reply()
|
||||
.map_err(input_request_error)?;
|
||||
Ok(Self {
|
||||
conn,
|
||||
root,
|
||||
min_keycode,
|
||||
keysyms_per_keycode: mapping.keysyms_per_keycode,
|
||||
keysyms: mapping.keysyms,
|
||||
held_temp: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Absolute pointer motion in root (global desktop) coordinates.
|
||||
pub fn move_mouse(
|
||||
&mut self,
|
||||
x: i32,
|
||||
y: i32,
|
||||
_coordinate: Coordinate,
|
||||
) -> Result<(), X11InputError> {
|
||||
let x = i16::try_from(x).map_err(|_| {
|
||||
X11InputError(format!("pointer x coordinate {x} exceeds the X11 coordinate space"))
|
||||
})?;
|
||||
let y = i16::try_from(y).map_err(|_| {
|
||||
X11InputError(format!("pointer y coordinate {y} exceeds the X11 coordinate space"))
|
||||
})?;
|
||||
self.fake_input(MOTION_NOTIFY_EVENT, 0, x, y)
|
||||
}
|
||||
|
||||
pub fn button(&mut self, button: Button, direction: Direction) -> Result<(), X11InputError> {
|
||||
let detail = button_detail(button);
|
||||
if matches!(direction, Direction::Press | Direction::Click) {
|
||||
self.fake_input(BUTTON_PRESS_EVENT, detail, 0, 0)?;
|
||||
}
|
||||
if matches!(direction, Direction::Release | Direction::Click) {
|
||||
self.fake_input(BUTTON_RELEASE_EVENT, detail, 0, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scroll by whole steps: one button 4/5/6/7 click pair per step.
|
||||
pub fn scroll(&mut self, steps: i32, axis: Axis) -> Result<(), X11InputError> {
|
||||
let detail = scroll_button(axis, steps < 0);
|
||||
for _ in 0..steps.unsigned_abs() {
|
||||
self.fake_input(BUTTON_PRESS_EVENT, detail, 0, 0)?;
|
||||
self.fake_input(BUTTON_RELEASE_EVENT, detail, 0, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn key(&mut self, key: Key, direction: Direction) -> Result<(), X11InputError> {
|
||||
self.send_keysym(keysym_for_key(key), direction)
|
||||
}
|
||||
|
||||
pub fn text(&mut self, text: &str) -> Result<(), X11InputError> {
|
||||
for ch in text.chars() {
|
||||
self.send_keysym(keysym_for_char(ch), Direction::Click)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn view(&self) -> KeymapView<'_> {
|
||||
KeymapView {
|
||||
min_keycode: self.min_keycode,
|
||||
keysyms_per_keycode: self.keysyms_per_keycode,
|
||||
keysyms: &self.keysyms,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_input(
|
||||
&self,
|
||||
event_type: u8,
|
||||
detail: u8,
|
||||
x: i16,
|
||||
y: i16,
|
||||
) -> Result<(), X11InputError> {
|
||||
self
|
||||
.conn
|
||||
.xtest_fake_input(event_type, detail, x11rb::CURRENT_TIME, self.root, x, y, 0)
|
||||
.map_err(input_request_error)?;
|
||||
self.conn.flush().map_err(input_request_error)
|
||||
}
|
||||
|
||||
fn send_keysym(&mut self, keysym: u32, direction: Direction) -> Result<(), X11InputError> {
|
||||
if keysym == 0 {
|
||||
return Err(X11InputError(
|
||||
"character cannot be represented as an X11 keysym".to_string(),
|
||||
));
|
||||
}
|
||||
// A release must reuse the temporary keycode its press bound.
|
||||
if matches!(direction, Direction::Release)
|
||||
&& let Some(index) = self.held_temp.iter().position(|&(held, _)| held == keysym)
|
||||
{
|
||||
let (_, keycode) = self.held_temp.remove(index);
|
||||
let released = self.fake_input(KEY_RELEASE_EVENT, keycode, 0, 0);
|
||||
let restored = self.write_keycode_row(keycode, 0);
|
||||
released.and(restored)
|
||||
} else {
|
||||
match keysym_position(&self.view(), keysym) {
|
||||
Some((keycode, false)) => self.send_key_transition(keycode, direction),
|
||||
Some((keycode, true)) => self.send_shifted(keycode, direction),
|
||||
None => self.send_temp_bound(keysym, direction),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_key_transition(
|
||||
&self,
|
||||
keycode: u8,
|
||||
direction: Direction,
|
||||
) -> Result<(), X11InputError> {
|
||||
if matches!(direction, Direction::Press | Direction::Click) {
|
||||
self.fake_input(KEY_PRESS_EVENT, keycode, 0, 0)?;
|
||||
}
|
||||
if matches!(direction, Direction::Release | Direction::Click) {
|
||||
self.fake_input(KEY_RELEASE_EVENT, keycode, 0, 0)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The keysym only exists at shift level 1, so wrap the transition in
|
||||
/// a synthetic Shift press/release.
|
||||
fn send_shifted(&self, keycode: u8, direction: Direction) -> Result<(), X11InputError> {
|
||||
let (shift, _) = keysym_position(&self.view(), Keysym::Shift_L.raw())
|
||||
.ok_or_else(|| X11InputError("keyboard mapping has no Shift keycode".to_string()))?;
|
||||
self.fake_input(KEY_PRESS_EVENT, shift, 0, 0)?;
|
||||
let result = self.send_key_transition(keycode, direction);
|
||||
let release = self.fake_input(KEY_RELEASE_EVENT, shift, 0, 0);
|
||||
result.and(release)
|
||||
}
|
||||
|
||||
/// The keysym is not bound anywhere: temporarily bind it to a spare
|
||||
/// keycode, synthesize the transition, and restore the mapping.
|
||||
fn send_temp_bound(
|
||||
&mut self,
|
||||
keysym: u32,
|
||||
direction: Direction,
|
||||
) -> Result<(), X11InputError> {
|
||||
let keycode = spare_keycode(&self.view()).ok_or_else(|| {
|
||||
X11InputError(
|
||||
"no spare X11 keycode is available to bind an unmapped keysym".to_string(),
|
||||
)
|
||||
})?;
|
||||
self.write_keycode_row(keycode, keysym)?;
|
||||
match direction {
|
||||
Direction::Click => {
|
||||
let pressed = self
|
||||
.fake_input(KEY_PRESS_EVENT, keycode, 0, 0)
|
||||
.and_then(|()| self.fake_input(KEY_RELEASE_EVENT, keycode, 0, 0));
|
||||
let restored = self.write_keycode_row(keycode, 0);
|
||||
pressed.and(restored)
|
||||
},
|
||||
Direction::Press => {
|
||||
self.held_temp.push((keysym, keycode));
|
||||
self.fake_input(KEY_PRESS_EVENT, keycode, 0, 0)
|
||||
},
|
||||
Direction::Release => {
|
||||
let released = self.fake_input(KEY_RELEASE_EVENT, keycode, 0, 0);
|
||||
let restored = self.write_keycode_row(keycode, 0);
|
||||
released.and(restored)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one keycode's keysym row verbatim and synchronize so the
|
||||
/// server observes the mapping before any following fake event.
|
||||
fn write_row(&self, keycode: u8, row: &[u32]) -> Result<(), X11InputError> {
|
||||
self
|
||||
.conn
|
||||
.change_keyboard_mapping(1, keycode, self.keysyms_per_keycode, row)
|
||||
.map_err(input_request_error)?;
|
||||
self.conn.sync().map_err(input_request_error)
|
||||
}
|
||||
|
||||
/// Rewrite one keycode's keysym row (all columns) to a single keysym
|
||||
/// and mirror the change into the cached mapping.
|
||||
fn write_keycode_row(&mut self, keycode: u8, keysym: u32) -> Result<(), X11InputError> {
|
||||
let per = usize::from(self.keysyms_per_keycode);
|
||||
self.write_row(keycode, &vec![keysym; per])?;
|
||||
let start = (usize::from(keycode) - usize::from(self.min_keycode)) * per;
|
||||
self.keysyms[start..start + per].fill(keysym);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Input {
|
||||
fn drop(&mut self) {
|
||||
// Release anything a Press left held, then restore its temporary
|
||||
// binding — continuing past failures so one broken step cannot
|
||||
// leave later keys stuck or rows rebound. A dead connection makes
|
||||
// these no-ops, which is fine: the bindings die with the session's
|
||||
// X server resources anyway.
|
||||
let held = std::mem::take(&mut self.held_temp);
|
||||
let held_keycodes: Vec<u8> = held.iter().map(|&(_, keycode)| keycode).collect();
|
||||
let mut mapped = held_keycodes.clone();
|
||||
mapped.sort_unstable();
|
||||
mapped.dedup();
|
||||
let _ = cleanup_x11_keyboard_state(
|
||||
&held_keycodes,
|
||||
&mapped,
|
||||
self.keysyms_per_keycode,
|
||||
|operation| match operation {
|
||||
X11CleanupOperation::Release(keycode) => {
|
||||
self.fake_input(KEY_RELEASE_EVENT, keycode, 0, 0)
|
||||
},
|
||||
X11CleanupOperation::Restore(keycode, row) => self.write_row(keycode, row),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use x11::{Input, Monitor, X11Error, X11InputError};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scroll_steps_map_to_x11_wheel_buttons() {
|
||||
assert_eq!(scroll_button(Axis::Vertical, true), 4);
|
||||
assert_eq!(scroll_button(Axis::Vertical, false), 5);
|
||||
assert_eq!(scroll_button(Axis::Horizontal, true), 6);
|
||||
assert_eq!(scroll_button(Axis::Horizontal, false), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_buttons_map_to_core_protocol_details() {
|
||||
assert_eq!(button_detail(Button::Left), 1);
|
||||
assert_eq!(button_detail(Button::Middle), 2);
|
||||
assert_eq!(button_detail(Button::Right), 3);
|
||||
assert_eq!(button_detail(Button::Back), 8);
|
||||
assert_eq!(button_detail(Button::Forward), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_keysyms_cover_latin1_control_and_unicode_planes() {
|
||||
assert_eq!(keysym_for_char('a'), 0x61);
|
||||
assert_eq!(keysym_for_char('A'), 0x41);
|
||||
assert_eq!(keysym_for_char('é'), 0xe9);
|
||||
// Enter must type Return, not the historical Linefeed keysym.
|
||||
assert_eq!(keysym_for_char('\n'), 0xff0d);
|
||||
assert_eq!(keysym_for_char('\r'), 0xff0d);
|
||||
assert_eq!(keysym_for_char('\t'), 0xff09);
|
||||
// Codepoints outside the legacy tables use the Unicode keysym offset.
|
||||
assert_eq!(keysym_for_char('あ'), 0x0100_0000 + 0x3042);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_keys_resolve_to_expected_keysyms() {
|
||||
assert_eq!(keysym_for_key(Key::Return), 0xff0d);
|
||||
assert_eq!(keysym_for_key(Key::Escape), 0xff1b);
|
||||
assert_eq!(keysym_for_key(Key::Space), 0x20);
|
||||
assert_eq!(keysym_for_key(Key::PageUp), 0xff55);
|
||||
assert_eq!(keysym_for_key(Key::PageDown), 0xff56);
|
||||
assert_eq!(keysym_for_key(Key::Control), 0xffe3);
|
||||
assert_eq!(keysym_for_key(Key::Meta), 0xffeb);
|
||||
assert_eq!(keysym_for_key(Key::PrintScr), 0xff61);
|
||||
assert_eq!(keysym_for_key(Key::F1), 0xffbe);
|
||||
assert_eq!(keysym_for_key(Key::F24), 0xffd5);
|
||||
assert_eq!(keysym_for_key(Key::Unicode('x')), 0x78);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keysym_lookup_prefers_unshifted_bindings_and_reports_shift_levels() {
|
||||
// keycode 8: (a, A); keycode 9: (b, a); keycode 10: (0, 0)
|
||||
let keysyms = [0x61, 0x41, 0x62, 0x61, 0, 0];
|
||||
let view = KeymapView {
|
||||
min_keycode: 8,
|
||||
keysyms_per_keycode: 2,
|
||||
keysyms: &keysyms,
|
||||
};
|
||||
// 'a' is shifted on keycode 9 but unshifted on keycode 8; unshifted wins.
|
||||
assert_eq!(keysym_position(&view, 0x61), Some((8, false)));
|
||||
assert_eq!(keysym_position(&view, 0x41), Some((8, true)));
|
||||
assert_eq!(keysym_position(&view, 0x62), Some((9, false)));
|
||||
assert_eq!(keysym_position(&view, 0x63), None);
|
||||
assert_eq!(keysym_position(&view, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spare_keycode_scan_prefers_the_highest_unbound_row() {
|
||||
let keysyms = [0, 0, 0x61, 0x41, 0, 0, 0, 0];
|
||||
let view = KeymapView {
|
||||
min_keycode: 8,
|
||||
keysyms_per_keycode: 2,
|
||||
keysyms: &keysyms,
|
||||
};
|
||||
assert_eq!(spare_keycode(&view), Some(11));
|
||||
let full = [0x61, 0x41];
|
||||
let view =
|
||||
KeymapView { min_keycode: 8, keysyms_per_keycode: 2, keysyms: &full };
|
||||
assert_eq!(spare_keycode(&view), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth24_lsb_bgrx_converts_to_rgba() {
|
||||
// Two pixels: pure red and pure blue, BGRX byte order (LSB-first).
|
||||
let data = [0x00, 0x00, 0xff, 0x00, 0xff, 0x00, 0x00, 0x00];
|
||||
let image = zpixmap_to_rgba(&data, 2, 1, 24, 32, 32, true).unwrap();
|
||||
assert_eq!(image.get_pixel(0, 0).0, [0xff, 0x00, 0x00, 0xff]);
|
||||
assert_eq!(image.get_pixel(1, 0).0, [0x00, 0x00, 0xff, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth24_msb_xrgb_converts_to_rgba() {
|
||||
let data = [0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff];
|
||||
let image = zpixmap_to_rgba(&data, 2, 1, 24, 32, 32, false).unwrap();
|
||||
assert_eq!(image.get_pixel(0, 0).0, [0xff, 0x00, 0x00, 0xff]);
|
||||
assert_eq!(image.get_pixel(1, 0).0, [0x00, 0x00, 0xff, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth32_alpha_is_forced_opaque() {
|
||||
// BGRA with a transparent alpha byte that must be overridden.
|
||||
let data = [0x10, 0x20, 0x30, 0x00];
|
||||
let image = zpixmap_to_rgba(&data, 1, 1, 32, 32, 32, true).unwrap();
|
||||
assert_eq!(image.get_pixel(0, 0).0, [0x30, 0x20, 0x10, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_24bpp_rows_honor_scanline_padding() {
|
||||
// Width 1 at 24 bpp pads each row to 4 bytes (scanline_pad 32).
|
||||
let data = [0x01, 0x02, 0x03, 0x00, 0x04, 0x05, 0x06, 0x00];
|
||||
let image = zpixmap_to_rgba(&data, 1, 2, 24, 24, 32, true).unwrap();
|
||||
assert_eq!(image.get_pixel(0, 0).0, [0x03, 0x02, 0x01, 0xff]);
|
||||
assert_eq!(image.get_pixel(0, 1).0, [0x06, 0x05, 0x04, 0xff]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_depths_and_short_buffers_are_rejected() {
|
||||
assert!(
|
||||
zpixmap_to_rgba(&[0; 8], 2, 1, 16, 16, 16, true)
|
||||
.unwrap_err()
|
||||
.contains("depth 16")
|
||||
);
|
||||
assert!(
|
||||
zpixmap_to_rgba(&[0; 4], 2, 1, 24, 32, 32, true)
|
||||
.unwrap_err()
|
||||
.contains("truncated")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn x11_cleanup_continues_after_errors_and_restores_exact_row_width() {
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Observed {
|
||||
Release(u8),
|
||||
Restore(u8, Vec<u32>),
|
||||
}
|
||||
|
||||
let mut observed = Vec::new();
|
||||
let error = cleanup_x11_keyboard_state(&[1, 2, 3], &[8, 9], 4, |operation| {
|
||||
match operation {
|
||||
X11CleanupOperation::Release(keycode) => {
|
||||
observed.push(Observed::Release(keycode));
|
||||
if keycode == 3 {
|
||||
return Err("first cleanup failure");
|
||||
}
|
||||
},
|
||||
X11CleanupOperation::Restore(keycode, row) => {
|
||||
observed.push(Observed::Restore(keycode, row.to_vec()));
|
||||
if keycode == 8 {
|
||||
return Err("later cleanup failure");
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error, "first cleanup failure");
|
||||
assert_eq!(observed, vec![
|
||||
Observed::Release(3),
|
||||
Observed::Release(2),
|
||||
Observed::Release(1),
|
||||
Observed::Restore(8, vec![0, 0, 0, 0]),
|
||||
Observed::Restore(9, vec![0, 0, 0, 0]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -27,15 +27,13 @@ pub mod ast;
|
||||
pub mod block;
|
||||
pub mod clipboard;
|
||||
pub mod crash_handler;
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
all(target_os = "linux", not(target_env = "musl"), feature = "native-desktop-linux")
|
||||
))]
|
||||
pub mod desktop;
|
||||
#[cfg(all(target_os = "linux", any(target_env = "musl", not(feature = "native-desktop-linux"))))]
|
||||
#[path = "desktop_unsupported.rs"]
|
||||
pub mod desktop;
|
||||
/// Pure-Rust X11 backend for `desktop`.
|
||||
///
|
||||
/// Compiled on Linux for real use and under `cfg(test)` everywhere so its
|
||||
/// pure conversion helpers stay unit-testable without a live X server.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub mod desktop_x11;
|
||||
pub mod diff;
|
||||
pub mod fd;
|
||||
pub mod glob;
|
||||
|
||||
Reference in New Issue
Block a user