569 lines
22 KiB
Rust
569 lines
22 KiB
Rust
// Linux: seeing and using the screen through X11 (x11rb, pure Rust): screenshots with GetImage,
|
||
// mouse and keyboard with the XTEST extension, windows from the window manager's EWMH hints,
|
||
// idle time from the MIT-SCREEN-SAVER extension. The same functions as computer.rs's macOS part,
|
||
// so the computer_use tools work the same way.
|
||
//
|
||
// Wayland sessions go through wl.rs or portal.rs instead (desk.rs picks).
|
||
|
||
use std::sync::Mutex;
|
||
use std::time::Duration;
|
||
use x11rb::connection::Connection;
|
||
use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt as _, ImageFormat, Window};
|
||
use x11rb::protocol::xtest::ConnectionExt as _;
|
||
use x11rb::rust_connection::RustConnection;
|
||
|
||
struct X {
|
||
conn: RustConnection,
|
||
root: Window,
|
||
width: u16,
|
||
height: u16,
|
||
}
|
||
|
||
static CONN: Mutex<Option<X>> = Mutex::new(None);
|
||
|
||
fn with<T>(f: impl FnOnce(&X) -> Option<T>) -> Option<T> {
|
||
let mut g = CONN.lock().unwrap_or_else(|e| e.into_inner());
|
||
if g.is_none() {
|
||
let (conn, n) = x11rb::connect(None).ok()?;
|
||
let s = &conn.setup().roots[n];
|
||
let (root, width, height) = (s.root, s.width_in_pixels, s.height_in_pixels);
|
||
*g = Some(X { conn, root, width, height });
|
||
}
|
||
let r = f(g.as_ref()?);
|
||
if r.is_none() {
|
||
// A broken connection (the X server restarted): connect again next time.
|
||
if g.as_ref().map(|x| x.conn.get_input_focus().ok().and_then(|c| c.reply().ok()).is_none()).unwrap_or(true) {
|
||
*g = None;
|
||
}
|
||
}
|
||
r
|
||
}
|
||
|
||
pub fn available() -> bool {
|
||
with(|_| Some(())).is_some()
|
||
}
|
||
|
||
/// The whole screen as RGB.
|
||
pub fn capture() -> Result<image::RgbImage, String> {
|
||
with(|x| {
|
||
let reply = x.conn.get_image(ImageFormat::Z_PIXMAP, x.root, 0, 0, x.width, x.height, !0).ok()?.reply().ok()?;
|
||
let setup = x.conn.setup();
|
||
let bpp = setup.pixmap_formats.iter().find(|f| f.depth == reply.depth).map(|f| f.bits_per_pixel).unwrap_or(32);
|
||
if bpp != 32 {
|
||
return None;
|
||
}
|
||
let lsb = setup.image_byte_order == xproto::ImageOrder::LSB_FIRST;
|
||
let (w, h) = (x.width as usize, x.height as usize);
|
||
let mut rgb = Vec::with_capacity(w * h * 3);
|
||
for px in reply.data.as_chunks::<4>().0.iter().take(w * h) {
|
||
if lsb {
|
||
rgb.extend_from_slice(&[px[2], px[1], px[0]]);
|
||
} else {
|
||
rgb.extend_from_slice(&[px[1], px[2], px[3]]);
|
||
}
|
||
}
|
||
image::RgbImage::from_raw(w as u32, h as u32, rgb)
|
||
})
|
||
.ok_or_else(|| "讀不到螢幕畫面(需要 X11 桌面;Wayland 請在登入畫面選「Xorg」工作階段)".to_string())
|
||
}
|
||
|
||
pub fn screen_points() -> (f64, f64) {
|
||
with(|x| Some((x.width as f64, x.height as f64))).unwrap_or((0.0, 0.0))
|
||
}
|
||
|
||
pub fn screen_pixels() -> (u32, u32) {
|
||
with(|x| Some((x.width as u32, x.height as u32))).unwrap_or((0, 0))
|
||
}
|
||
|
||
fn fake(x: &X, kind: u8, detail: u8, px: i16, py: i16) {
|
||
let _ = x.conn.xtest_fake_input(kind, detail, 0, x.root, px, py, 0);
|
||
}
|
||
|
||
fn flush(x: &X) {
|
||
let _ = x.conn.flush();
|
||
let _ = x.conn.get_input_focus().ok().and_then(|c| c.reply().ok());
|
||
}
|
||
|
||
fn sleep_secs(s: f64) {
|
||
std::thread::sleep(Duration::from_secs_f64(s));
|
||
}
|
||
|
||
fn gap() {
|
||
std::thread::sleep(Duration::from_millis(8));
|
||
}
|
||
|
||
pub fn move_to(px: f64, py: f64) {
|
||
with(|x| {
|
||
fake(x, xproto::MOTION_NOTIFY_EVENT, 0, px.round() as i16, py.round() as i16);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
}
|
||
|
||
fn button_no(button: &str) -> u8 {
|
||
match button {
|
||
"right" => 3,
|
||
"middle" => 2,
|
||
_ => 1,
|
||
}
|
||
}
|
||
|
||
/// Hold the modifiers in `flags` (macOS flag bits) while `f` runs.
|
||
fn with_mods(flags: u64, f: impl FnOnce()) {
|
||
let mods = modifier_keysyms(flags);
|
||
let codes: Vec<u8> = mods.iter().filter_map(|k| keycode_for(*k)).collect();
|
||
with(|x| {
|
||
for c in &codes {
|
||
fake(x, xproto::KEY_PRESS_EVENT, *c, 0, 0);
|
||
}
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
f();
|
||
with(|x| {
|
||
for c in codes.iter().rev() {
|
||
fake(x, xproto::KEY_RELEASE_EVENT, *c, 0, 0);
|
||
}
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
}
|
||
|
||
pub fn click(px: f64, py: f64, button: &str, count: i64, flags: u64) {
|
||
move_to(px, py);
|
||
gap();
|
||
let b = button_no(button);
|
||
with_mods(flags, || {
|
||
for n in 1..=count {
|
||
if n > 1 {
|
||
sleep_secs(crate::computer::CLICK_REPEAT_GAP_SECS);
|
||
}
|
||
with(|x| {
|
||
fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
sleep_secs(crate::computer::CLICK_HOLD_SECS);
|
||
with(|x| {
|
||
fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
pub fn drag(path: &[(f64, f64)], button: &str, flags: u64) {
|
||
let (x0, y0) = path[0];
|
||
move_to(x0, y0);
|
||
gap();
|
||
let b = button_no(button);
|
||
with_mods(flags, || {
|
||
with(|x| {
|
||
fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
sleep_secs(crate::computer::DRAG_HOLD_SECS);
|
||
for (px, py) in crate::computer::drag_points(path) {
|
||
gap();
|
||
move_to(px, py);
|
||
}
|
||
with(|x| {
|
||
fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Wheel clicks: one per scroll step (`SCROLL_PIXELS_PER_STEP` pixels on macOS).
|
||
pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) {
|
||
if let Some((px, py)) = at {
|
||
move_to(px, py);
|
||
gap();
|
||
}
|
||
let step = crate::computer::SCROLL_PIXELS_PER_STEP;
|
||
let (vy, vx) = (dy / step, dx / step);
|
||
with_mods(flags, || {
|
||
with(|x| {
|
||
for (n, up, down) in [(vy, 4u8, 5u8), (vx, 6, 7)] {
|
||
let b = if n > 0 { up } else { down };
|
||
for _ in 0..n.abs() {
|
||
fake(x, xproto::BUTTON_PRESS_EVENT, b, 0, 0);
|
||
fake(x, xproto::BUTTON_RELEASE_EVENT, b, 0, 0);
|
||
flush(x);
|
||
std::thread::sleep(Duration::from_millis(12));
|
||
}
|
||
}
|
||
Some(())
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------- keyboard ----------
|
||
|
||
const XK_SHIFT_L: u32 = 0xffe1;
|
||
const XK_CONTROL_L: u32 = 0xffe3;
|
||
const XK_ALT_L: u32 = 0xffe9;
|
||
|
||
/// macOS flag bits → X keysyms. ⌘ becomes Control on Linux (⌘C = Ctrl+C, ⌘V = Ctrl+V…).
|
||
pub fn modifier_keysyms(flags: u64) -> Vec<u32> {
|
||
use crate::computer::{FLAG_CMD, FLAG_CONTROL, FLAG_OPTION, FLAG_SHIFT};
|
||
let mut v = vec![];
|
||
if flags & (FLAG_CONTROL | FLAG_CMD) != 0 {
|
||
v.push(XK_CONTROL_L);
|
||
}
|
||
if flags & FLAG_SHIFT != 0 {
|
||
v.push(XK_SHIFT_L);
|
||
}
|
||
if flags & FLAG_OPTION != 0 {
|
||
v.push(XK_ALT_L);
|
||
}
|
||
v
|
||
}
|
||
|
||
/// macOS virtual key code (what `computer::key_code` gives) → X keysym.
|
||
pub fn keysym_of_mac_code(code: u16) -> Option<u32> {
|
||
let named: &[(u16, u32)] = &[
|
||
(36, 0xff0d), (48, 0xff09), (49, 0x20), (51, 0xff08), (53, 0xff1b), (117, 0xffff), (115, 0xff50), (119, 0xff57), (116, 0xff55), (121, 0xff56),
|
||
(123, 0xff51), (124, 0xff53), (125, 0xff54), (126, 0xff52), (122, 0xffbe), (120, 0xffbf), (99, 0xffc0), (118, 0xffc1), (96, 0xffc2), (97, 0xffc3),
|
||
(98, 0xffc4), (100, 0xffc5), (101, 0xffc6), (109, 0xffc7), (103, 0xffc8), (111, 0xffc9),
|
||
];
|
||
if let Some((_, k)) = named.iter().find(|(c, _)| *c == code) {
|
||
return Some(*k);
|
||
}
|
||
// Letters, digits and punctuation: the character whose key code this is.
|
||
"abcdefghijklmnopqrstuvwxyz0123456789-=[];',./\\`".chars().find(|c| crate::computer::key_code(&c.to_string()) == Some(code)).map(|c| c as u32)
|
||
}
|
||
|
||
/// The key that types a keysym in the current layout (first level only, so no Shift needed).
|
||
fn keycode_for(keysym: u32) -> Option<u8> {
|
||
with(|x| {
|
||
let setup = x.conn.setup();
|
||
let (min, max) = (setup.min_keycode, setup.max_keycode);
|
||
let m = x.conn.get_keyboard_mapping(min, max - min + 1).ok()?.reply().ok()?;
|
||
let per = m.keysyms_per_keycode as usize;
|
||
m.keysyms.chunks(per).position(|ks| ks.first() == Some(&keysym)).map(|i| min + i as u8)
|
||
})
|
||
}
|
||
|
||
pub fn press(code: u16, flags: u64, hold_secs: f64) {
|
||
let Some(sym) = keysym_of_mac_code(code) else { return };
|
||
with_mods(flags, || {
|
||
let Some(kc) = keycode_for(sym) else { return };
|
||
with(|x| {
|
||
fake(x, xproto::KEY_PRESS_EVENT, kc, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
sleep_secs(hold_secs);
|
||
with(|x| {
|
||
fake(x, xproto::KEY_RELEASE_EVENT, kc, 0, 0);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
});
|
||
}
|
||
|
||
/// The keysym that types a character.
|
||
pub fn keysym_for_char(c: char) -> u32 {
|
||
match c {
|
||
'\n' | '\r' => 0xff0d,
|
||
'\t' => 0xff09,
|
||
c if (' '..='~').contains(&c) || ('\u{a0}'..='\u{ff}').contains(&c) => c as u32,
|
||
c => 0x0100_0000 + c as u32,
|
||
}
|
||
}
|
||
|
||
/// Type any text (Chinese too): each character is put on a spare key for a moment and that key
|
||
/// is pressed — the way xdotool types characters the keyboard layout doesn't have.
|
||
pub fn type_text(text: &str) {
|
||
with(|x| {
|
||
let setup = x.conn.setup();
|
||
let (min, max) = (setup.min_keycode, setup.max_keycode);
|
||
let m = x.conn.get_keyboard_mapping(min, max - min + 1).ok()?.reply().ok()?;
|
||
let per = m.keysyms_per_keycode as usize;
|
||
// A key with nothing on it (from the top, where they usually are).
|
||
let spare = m.keysyms.chunks(per).enumerate().rev().find(|(_, ks)| ks.iter().all(|k| *k == 0)).map(|(i, _)| min + i as u8)?;
|
||
for c in text.chars() {
|
||
let sym = keysym_for_char(c);
|
||
let syms = vec![sym; per];
|
||
// On any failure stop the loop and fall through to restoring the mapping: an
|
||
// early `?` here left the typed character glued to a real key forever.
|
||
if x.conn.change_keyboard_mapping(1, spare, per as u8, &syms).is_err() {
|
||
break;
|
||
}
|
||
flush(x);
|
||
std::thread::sleep(Duration::from_millis(12));
|
||
fake(x, xproto::KEY_PRESS_EVENT, spare, 0, 0);
|
||
flush(x);
|
||
std::thread::sleep(Duration::from_millis(8));
|
||
fake(x, xproto::KEY_RELEASE_EVENT, spare, 0, 0);
|
||
flush(x);
|
||
std::thread::sleep(Duration::from_millis(8));
|
||
}
|
||
let _ = x.conn.change_keyboard_mapping(1, spare, per as u8, &vec![0; per]);
|
||
flush(x);
|
||
Some(())
|
||
});
|
||
}
|
||
|
||
// ---------- windows ----------
|
||
|
||
fn atom(x: &X, name: &str) -> Option<u32> {
|
||
Some(x.conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok()?.atom)
|
||
}
|
||
|
||
fn prop_u32s(x: &X, win: Window, name: &str, kind: AtomEnum) -> Vec<u32> {
|
||
let Some(a) = atom(x, name) else { return vec![] };
|
||
x.conn.get_property(false, win, a, kind, 0, 4096).ok().and_then(|c| c.reply().ok()).and_then(|r| r.value32().map(|v| v.collect())).unwrap_or_default()
|
||
}
|
||
|
||
/// A window's app: (name, pid). The name is the WM_CLASS class ("Firefox", "Gedit"…), else the
|
||
/// process name.
|
||
fn app_of(x: &X, win: Window) -> (String, i32) {
|
||
let pid = prop_u32s(x, win, "_NET_WM_PID", AtomEnum::CARDINAL).first().copied().unwrap_or(0) as i32;
|
||
let class = x
|
||
.conn
|
||
.get_property(false, win, AtomEnum::WM_CLASS, AtomEnum::STRING, 0, 256)
|
||
.ok()
|
||
.and_then(|c| c.reply().ok())
|
||
.map(|r| r.value.split(|b| *b == 0).filter(|s| !s.is_empty()).map(|s| String::from_utf8_lossy(s).to_string()).collect::<Vec<_>>())
|
||
.and_then(|v| v.last().cloned())
|
||
.unwrap_or_default();
|
||
let name = if !class.is_empty() { class } else if pid > 0 { std::fs::read_to_string(format!("/proc/{pid}/comm")).unwrap_or_default().trim().to_string() } else { String::new() };
|
||
(name, pid)
|
||
}
|
||
|
||
/// Top-level app windows, front first, with their place on screen.
|
||
fn windows(x: &X) -> Vec<(Window, (i32, i32, i32, i32))> {
|
||
let mut list = prop_u32s(x, x.root, "_NET_CLIENT_LIST_STACKING", AtomEnum::WINDOW);
|
||
if list.is_empty() {
|
||
list = x.conn.query_tree(x.root).ok().and_then(|c| c.reply().ok()).map(|t| t.children).unwrap_or_default();
|
||
}
|
||
list.reverse();
|
||
list.into_iter()
|
||
.filter_map(|w| {
|
||
let g = x.conn.get_geometry(w).ok()?.reply().ok()?;
|
||
let t = x.conn.translate_coordinates(w, x.root, 0, 0).ok()?.reply().ok()?;
|
||
let attrs = x.conn.get_window_attributes(w).ok()?.reply().ok()?;
|
||
(attrs.map_state == xproto::MapState::VIEWABLE && g.width > 1 && g.height > 1).then_some((w, (t.dst_x as i32, t.dst_y as i32, g.width as i32, g.height as i32)))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn window_at(px: f64, py: f64) -> Option<(String, i32)> {
|
||
with(|x| {
|
||
let (w, _) = windows(x).into_iter().find(|(_, (wx, wy, ww, wh))| px >= *wx as f64 && py >= *wy as f64 && px < (*wx + *ww) as f64 && py < (*wy + *wh) as f64)?;
|
||
Some(app_of(x, w)).filter(|a| !a.0.is_empty())
|
||
})
|
||
}
|
||
|
||
pub fn front_app() -> Option<(String, i32)> {
|
||
with(|x| {
|
||
let active = prop_u32s(x, x.root, "_NET_ACTIVE_WINDOW", AtomEnum::WINDOW).first().copied().filter(|w| *w != 0);
|
||
let w = active.or_else(|| windows(x).first().map(|w| w.0))?;
|
||
Some(app_of(x, w)).filter(|a| !a.0.is_empty())
|
||
})
|
||
}
|
||
|
||
/// Seconds since the last keyboard or mouse input.
|
||
pub fn idle_secs() -> Option<f64> {
|
||
use x11rb::protocol::screensaver::ConnectionExt as _;
|
||
with(|x| Some(x.conn.screensaver_query_info(x.root).ok()?.reply().ok()?.ms_since_user_input as f64 / 1000.0))
|
||
}
|
||
|
||
/// The X screensaver is on (blanked screen).
|
||
pub fn screensaver_on() -> bool {
|
||
use x11rb::protocol::screensaver::ConnectionExt as _;
|
||
with(|x| Some(x.conn.screensaver_query_info(x.root).ok()?.reply().ok()?.state == 1)).unwrap_or(false)
|
||
}
|
||
|
||
// ---------- watching the person's input (示範 recording) ----------
|
||
|
||
/// A mouse click or key press the person made, from the X RECORD extension.
|
||
#[derive(Debug, Clone)]
|
||
pub enum Input {
|
||
Click { x: f64, y: f64, right: bool },
|
||
/// `mods`: 1 shift, 4 control, 8 alt, 64 super (X's modifier bits).
|
||
Key { text: String, keysym: u32, mods: u16 },
|
||
/// +1 up / −1 down.
|
||
Scroll { dy: i64 },
|
||
}
|
||
|
||
/// Watch the person's clicks and keys until `keep_going` says stop. Runs on its own thread
|
||
/// with its own connections (RECORD needs one that only listens).
|
||
pub fn record(mut each: impl FnMut(Input), keep_going: impl Fn() -> bool + Send + 'static) -> Result<(), String> {
|
||
use x11rb::protocol::record::{self, ConnectionExt as _};
|
||
let (ctrl, _) = x11rb::connect(None).map_err(|e| format!("連不上 X11:{e}"))?;
|
||
let (data, _) = x11rb::connect(None).map_err(|e| format!("連不上 X11:{e}"))?;
|
||
let ctx = ctrl.generate_id().map_err(|e| e.to_string())?;
|
||
let range = record::Range {
|
||
device_events: record::Range8 { first: xproto::KEY_PRESS_EVENT, last: xproto::BUTTON_PRESS_EVENT },
|
||
..Default::default()
|
||
};
|
||
ctrl.record_create_context(ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range]).map_err(|e| e.to_string())?.check().map_err(|e| format!("這個 X 伺服器不支援錄製輸入(RECORD):{e}"))?;
|
||
// The keyboard map, to turn key codes into characters.
|
||
let setup = ctrl.setup();
|
||
let (min, max) = (setup.min_keycode, setup.max_keycode);
|
||
let map = ctrl.get_keyboard_mapping(min, max - min + 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||
let per = map.keysyms_per_keycode as usize;
|
||
let mut mods: u16 = 0;
|
||
let mod_bit = |sym: u32| -> u16 {
|
||
match sym {
|
||
0xffe1 | 0xffe2 => 1,
|
||
0xffe3 | 0xffe4 => 4,
|
||
0xffe9 | 0xffea | 0xff7e => 8,
|
||
0xffeb | 0xffec => 64,
|
||
_ => 0,
|
||
}
|
||
};
|
||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let stopper = std::thread::spawn({
|
||
let stop = stop.clone();
|
||
move || {
|
||
// Disabling the context ends the data stream below.
|
||
while keep_going() && !stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||
std::thread::sleep(Duration::from_millis(150));
|
||
}
|
||
let _ = ctrl.record_disable_context(ctx);
|
||
let _ = ctrl.flush();
|
||
std::thread::sleep(Duration::from_millis(200));
|
||
let _ = ctrl.record_free_context(ctx);
|
||
let _ = ctrl.flush();
|
||
}
|
||
});
|
||
let stream = match data.record_enable_context(ctx) {
|
||
Ok(s) => s,
|
||
// A failed start must stop the watchdog thread too — it used to spin forever
|
||
// (holding an X connection) on this early return.
|
||
Err(e) => {
|
||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let _ = stopper.join();
|
||
return Err(e.to_string());
|
||
}
|
||
};
|
||
for reply in stream {
|
||
let Ok(reply) = reply else { break };
|
||
if reply.category != 0 {
|
||
continue;
|
||
}
|
||
for ev in reply.data.as_chunks::<32>().0 {
|
||
let (kind, detail) = (ev[0] & 0x7f, ev[1]);
|
||
let rx = i16::from_ne_bytes([ev[20], ev[21]]) as f64;
|
||
let ry = i16::from_ne_bytes([ev[22], ev[23]]) as f64;
|
||
let sym_at = |level: usize| -> u32 {
|
||
let i = (detail.saturating_sub(min)) as usize * per + level;
|
||
map.keysyms.get(i).copied().unwrap_or(0)
|
||
};
|
||
match kind {
|
||
xproto::KEY_PRESS_EVENT => {
|
||
let base = sym_at(0);
|
||
let bit = mod_bit(base);
|
||
if bit != 0 {
|
||
mods |= bit;
|
||
continue;
|
||
}
|
||
let shifted = sym_at(1);
|
||
let sym = if mods & 1 != 0 && shifted != 0 { shifted } else { base };
|
||
let text = char_of_keysym(sym).map(String::from).unwrap_or_default();
|
||
each(Input::Key { text, keysym: sym, mods });
|
||
}
|
||
xproto::KEY_RELEASE_EVENT => {
|
||
mods &= !mod_bit(sym_at(0));
|
||
}
|
||
xproto::BUTTON_PRESS_EVENT => match detail {
|
||
1 | 3 => each(Input::Click { x: rx, y: ry, right: detail == 3 }),
|
||
4 => each(Input::Scroll { dy: 1 }),
|
||
5 => each(Input::Scroll { dy: -1 }),
|
||
_ => {}
|
||
},
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
// The stream broke (the X server died, the context was dropped): tell the watchdog to
|
||
// stop first — waiting on a thread that keeps polling forever is what froze the old code.
|
||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let _ = stopper.join();
|
||
Ok(())
|
||
}
|
||
|
||
pub fn char_of_keysym(sym: u32) -> Option<char> {
|
||
match sym {
|
||
0x20..=0x7e | 0xa0..=0xff => char::from_u32(sym),
|
||
0x0100_0000..=0x0110_ffff => char::from_u32(sym - 0x0100_0000),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn keysym_name(sym: u32) -> Option<&'static str> {
|
||
Some(match sym {
|
||
0xff0d => "return",
|
||
0xff09 => "tab",
|
||
0xff08 => "delete",
|
||
0xff1b => "escape",
|
||
0xffff => "forwarddelete",
|
||
0xff51 => "left",
|
||
0xff53 => "right",
|
||
0xff52 => "up",
|
||
0xff54 => "down",
|
||
0xff50 => "home",
|
||
0xff57 => "end",
|
||
0xff55 => "pageup",
|
||
0xff56 => "pagedown",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn keys_map_to_x() {
|
||
assert_eq!(keysym_of_mac_code(crate::computer::key_code("return").unwrap()), Some(0xff0d));
|
||
assert_eq!(keysym_of_mac_code(crate::computer::key_code("a").unwrap()), Some('a' as u32));
|
||
assert_eq!(keysym_of_mac_code(crate::computer::key_code("/").unwrap()), Some('/' as u32));
|
||
assert_eq!(keysym_of_mac_code(crate::computer::key_code("f5").unwrap()), Some(0xffc2));
|
||
assert_eq!(keysym_for_char('中'), 0x0100_4e2d);
|
||
assert_eq!(char_of_keysym(0x0100_4e2d), Some('中'));
|
||
assert_eq!(modifier_keysyms(crate::computer::FLAG_CMD | crate::computer::FLAG_SHIFT), vec![XK_CONTROL_L, XK_SHIFT_L]);
|
||
}
|
||
|
||
/// Needs a display: `Xvfb :99 & DISPLAY=:99 cargo test x11_live -- --ignored`.
|
||
#[test]
|
||
#[ignore]
|
||
fn x11_live() {
|
||
assert!(available(), "no X display");
|
||
let (w, h) = screen_pixels();
|
||
let shot = capture().expect("screenshot");
|
||
assert_eq!((shot.width(), shot.height()), (w, h));
|
||
// Record while clicking and typing through XTEST: the recorder must see both.
|
||
let got = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||
let g = got.clone();
|
||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let s2 = stop.clone();
|
||
let t = std::thread::spawn(move || record(move |i| g.lock().unwrap().push(format!("{i:?}")), move || !s2.load(std::sync::atomic::Ordering::SeqCst)));
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
click(30.0, 40.0, "left", 1, 0);
|
||
// Real keys (what a person presses); type_text's borrowed spare key has no symbol by the
|
||
// time the recorder looks it up.
|
||
press(crate::computer::key_code("a").unwrap(), 0, 0.03);
|
||
press(crate::computer::key_code("z").unwrap(), crate::computer::FLAG_SHIFT, 0.03);
|
||
press(crate::computer::key_code("c").unwrap(), crate::computer::FLAG_CONTROL, 0.03);
|
||
scroll(None, -crate::computer::SCROLL_PIXELS_PER_STEP, 0, 0);
|
||
std::thread::sleep(Duration::from_millis(500));
|
||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
||
t.join().unwrap().unwrap();
|
||
let got = got.lock().unwrap().join("\n");
|
||
assert!(got.contains("Click { x: 30.0, y: 40.0, right: false }"), "{got}");
|
||
assert!(got.contains("text: \"a\"") && got.contains("text: \"Z\""), "{got}");
|
||
assert!(got.contains("keysym: 99, mods: 4"), "ctrl+c: {got}");
|
||
assert!(got.contains("Scroll { dy: -1 }"), "{got}");
|
||
println!("{got}");
|
||
}
|
||
}
|