pixelflux/x11/computer_use.rs
1/*
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5 */
6
7//! X11 backend for the Computer Use HTTP API: XTEST injection and one-shot root screenshots
8//! over a private x11rb connection, so the agent can drive an X session with no active capture
9//! and no shared state with any streaming capture thread (the X server itself serializes).
10//!
11//! Keysyms the active layout cannot produce (after base+AltGr resolution, which stays the
12//! preferred path) are typed through a transient remap: under `XGrabServer` the keymap is
13//! re-fetched, the needed keysyms are bound onto all-NoSymbol spare keycodes with ONE
14//! `XChangeKeyboardMapping`, and the key sequence is injected; after a settle window (which
15//! lets the focused client re-fetch the bound map — see `type_with_transient_binds`) one
16//! conditional `XChangeKeyboardMapping` under a second grab puts the spares back — two
17//! MappingNotify broadcasts per action. This coexists with a selkies session's own
18//! spare-keycode overlay allocator on the same server: spares here are chosen DESCENDING
19//! from the top of the keycode range while selkies allocates ASCENDING, each grab re-fetches
20//! the keymap so every binding selkies already made is respected, the restore clears only
21//! keycodes still carrying our content, and selkies' foreign-change invalidation (fired by
22//! each MappingNotify) is self-healing because its held keys live on BOUND (non-NoSymbol)
23//! keycodes an all-NoSymbol spare can never steal.
24
25use std::cell::RefCell;
26use std::collections::HashMap;
27use std::thread;
28use std::time::Duration;
29
30use x11rb::connection::Connection;
31use x11rb::protocol::xfixes::ConnectionExt as XfixesExt;
32use x11rb::protocol::xproto::{
33 BUTTON_PRESS_EVENT, BUTTON_RELEASE_EVENT, ConnectionExt as XprotoExt, ImageFormat,
34 KEY_PRESS_EVENT, KEY_RELEASE_EVENT, MOTION_NOTIFY_EVENT,
35};
36use x11rb::protocol::xtest::ConnectionExt as XtestExt;
37use x11rb::rust_connection::RustConnection;
38
39use crate::computer_use::{CuBackend, CuButton, encode_png_rgba};
40
41/// One wheel "click" of scroll per unit of CU `scroll_amount`, capped so a hostile amount
42/// cannot flood the server with press/release pairs.
43const MAX_SCROLL_CLICKS: i32 = 100;
44
45const KEYSYM_ISO_LEVEL3_SHIFT: u32 = 0xfe03;
46const KEYSYM_MODE_SWITCH: u32 = 0xff7e;
47
48/// Reverse view of the server's current keymap.
49struct ServerKeymap {
50 /// keysym -> (keycode, shift level); level bit 0 = Shift, bit 1 = AltGr, lowest
51 /// level wins across ALL keys so a keysym reachable unshifted never resolves to a
52 /// modified position.
53 by_sym: HashMap<u32, (u32, u32)>,
54 /// Keycode carrying `ISO_Level3_Shift` (or, failing that, `Mode_switch`) in the core
55 /// map — the key synthesized around AltGr-level hits. 0 when the layout has neither,
56 /// in which case no AltGr levels are offered at all.
57 altgr_keycode: u32,
58}
59
60pub struct CuX11Backend {
61 conn: RustConnection,
62 root: u32,
63 has_xfixes: bool,
64 /// Reverse map of the SERVER keymap, built lazily from `GetKeyboardMapping` and kept
65 /// for this backend's lifetime — one HTTP request (the backend is per-request), so a
66 /// `setxkbmap` switch is seen by the next request.
67 reverse_keymap: RefCell<Option<ServerKeymap>>,
68}
69
70impl CuX11Backend {
71 /// Connect to the X server named by `DISPLAY` and negotiate XTEST (required for any
72 /// injection). XFixes is optional and only gates cursor compositing in screenshots.
73 pub fn connect() -> Result<Self, String> {
74 let (conn, screen_num) =
75 x11rb::connect(None).map_err(|e| format!("X11 connect failed: {e}"))?;
76 let root = conn.setup().roots[screen_num].root;
77 conn.xtest_get_version(2, 2)
78 .map_err(|e| format!("xtest_get_version: {e}"))?
79 .reply()
80 .map_err(|e| format!("XTEST unavailable: {e}"))?;
81 let has_xfixes = conn
82 .xfixes_query_version(5, 0)
83 .ok()
84 .and_then(|c| c.reply().ok())
85 .is_some();
86 Ok(Self {
87 conn,
88 root,
89 has_xfixes,
90 reverse_keymap: RefCell::new(None),
91 })
92 }
93
94 /// Build the reverse view of the server's current keymap from `GetKeyboardMapping`.
95 ///
96 /// Column -> (group, level) convention, as observed on XKB-compat servers (Xvfb/Xorg
97 /// under `setxkbmap de`: keycode 24 = `q Q q Q at Greek_OMEGA at`): columns 0/1 are
98 /// group-1 plain/Shift, columns 2/3 mirror them as core group 2, and columns 4/5
99 /// carry group-1 levels 3/4 (AltGr, Shift+AltGr). Columns 4/5 are consulted only
100 /// when the map carries a level-3 modifier key to synthesize; columns 2/3 (group 2)
101 /// never are.
102 fn build_reverse_keymap(&self) -> ServerKeymap {
103 let mut km = ServerKeymap {
104 by_sym: HashMap::new(),
105 altgr_keycode: 0,
106 };
107 let setup = self.conn.setup();
108 let (lo, hi) = (setup.min_keycode, setup.max_keycode);
109 let Some(reply) = self
110 .conn
111 .get_keyboard_mapping(lo, hi - lo + 1)
112 .ok()
113 .and_then(|c| c.reply().ok())
114 else {
115 return km;
116 };
117 let per = reply.keysyms_per_keycode as usize;
118 if per == 0 {
119 return km;
120 }
121 let keycode_of = |wanted: u32| {
122 reply
123 .keysyms
124 .chunks_exact(per)
125 .position(|syms| syms.contains(&wanted))
126 .map(|i| lo as u32 + i as u32)
127 };
128 km.altgr_keycode = keycode_of(KEYSYM_ISO_LEVEL3_SHIFT)
129 .or_else(|| keycode_of(KEYSYM_MODE_SWITCH))
130 .unwrap_or(0);
131 // Ascending level order so lower levels win; level bit 0 = Shift, bit 1 = AltGr.
132 let columns: [(usize, u32); 4] = [(0, 0), (1, 1), (4, 2), (5, 3)];
133 for (col, level) in columns {
134 if col >= per || (level & 2 != 0 && km.altgr_keycode == 0) {
135 continue;
136 }
137 for (i, syms) in reply.keysyms.chunks_exact(per).enumerate() {
138 let sym = syms[col];
139 if sym != 0 {
140 km.by_sym
141 .entry(sym)
142 .or_insert((lo as u32 + i as u32, level));
143 }
144 }
145 }
146 km
147 }
148
149 /// Fire one XTEST fake event and flush so it reaches the server before the action
150 /// layer's inter-event pacing sleep, matching the immediacy of real input.
151 fn fake_input(&self, kind: u8, detail: u8, root: u32, x: i16, y: i16) {
152 let _ = self
153 .conn
154 .xtest_fake_input(kind, detail, x11rb::CURRENT_TIME, root, x, y, 0)
155 .map(|c| c.ignore_error());
156 let _ = self.conn.flush();
157 }
158
159 fn root_geometry(&self) -> Result<(u16, u16), String> {
160 let geo = self
161 .conn
162 .get_geometry(self.root)
163 .map_err(|e| format!("get_geometry: {e}"))?
164 .reply()
165 .map_err(|e| format!("get_geometry reply: {e}"))?;
166 Ok((geo.width, geo.height))
167 }
168
169 /// One server round trip, forcing everything already sent on this connection to be
170 /// processed before the next request is issued (the XSync idiom).
171 fn sync(&self) -> Result<(), String> {
172 self.conn
173 .get_input_focus()
174 .map_err(|e| format!("sync: {e}"))?
175 .reply()
176 .map_err(|e| format!("sync reply: {e}"))?;
177 Ok(())
178 }
179
180 /// Bind `keysyms` onto spare keycodes and run `seq` with the keysym -> keycode map,
181 /// choose/bind/inject atomically under a server grab; then, after a settle window,
182 /// restore the spares under a second grab. The settle exists because clients
183 /// translate a keycode by RE-FETCHING the keymap when they process the bind's
184 /// MappingNotify — a fetch the grab itself blocks — so a restore issued inside the
185 /// first grab would be what they read back and every transient key would translate
186 /// to nothing (observed with xterm). Once `seq` has run, every failure is logged and
187 /// swallowed so the caller never re-runs the sequence.
188 fn type_with_transient_binds(
189 &self,
190 keysyms: &[u32],
191 seq: &mut dyn FnMut(&HashMap<u32, u32>),
192 ) -> Result<(), String> {
193 let setup = self.conn.setup();
194 let (lo, hi) = (setup.min_keycode, setup.max_keycode);
195 let mut chosen: Vec<u32> = Vec::with_capacity(keysyms.len());
196 let (span_lo, count, per, bound_syms) = {
197 self.conn
198 .grab_server()
199 .map_err(|e| format!("grab_server: {e}"))?;
200 let _guard = ServerGrabGuard { conn: &self.conn };
201 // Re-fetched UNDER the grab: a spare keycode any other client bound before
202 // the grab is visible as bound here and never chosen.
203 let reply = self
204 .conn
205 .get_keyboard_mapping(lo, hi - lo + 1)
206 .map_err(|e| format!("get_keyboard_mapping: {e}"))?
207 .reply()
208 .map_err(|e| format!("get_keyboard_mapping reply: {e}"))?;
209 let per = reply.keysyms_per_keycode as usize;
210 if per == 0 {
211 return Err("empty keymap".to_string());
212 }
213 // Spares are all-NoSymbol keycodes taken DESCENDING from the top of the
214 // range. selkies' overlay allocator scans ASCENDING, so the two only meet
215 // when nearly every spare on the server is taken; and any held key
216 // (selkies' overlay binds included) lives on a BOUND, non-NoSymbol keycode,
217 // so an all-NoSymbol spare can never steal a key that is currently down.
218 for (i, syms) in reply.keysyms.chunks_exact(per).enumerate().rev() {
219 if syms.iter().all(|&s| s == 0) {
220 chosen.push(lo as u32 + i as u32);
221 if chosen.len() == keysyms.len() {
222 break;
223 }
224 }
225 }
226 if chosen.len() < keysyms.len() {
227 return Err(format!(
228 "only {} spare keycodes for {} unresolved keysyms",
229 chosen.len(),
230 keysyms.len()
231 ));
232 }
233 // ONE ChangeKeyboardMapping over the span from the lowest chosen spare
234 // upward. Every all-NoSymbol keycode above the lowest chosen one was itself
235 // chosen, so the span's other keycodes are bound ones, rewritten with their
236 // existing content (a content no-op). Each transient key carries its keysym
237 // at the plain and Shift levels so a stray held Shift cannot change what it
238 // types.
239 let span_lo = *chosen.last().unwrap();
240 let span_hi = chosen[0];
241 let base = ((span_lo - lo as u32) as usize) * per;
242 let end = ((span_hi - lo as u32) as usize + 1) * per;
243 let mut bound_syms = reply.keysyms[base..end].to_vec();
244 let mut map = HashMap::new();
245 for (&sym, &kc) in keysyms.iter().zip(chosen.iter()) {
246 let off = ((kc - span_lo) as usize) * per;
247 bound_syms[off] = sym;
248 if per > 1 {
249 bound_syms[off + 1] = sym;
250 }
251 map.insert(sym, kc);
252 }
253 let count = (span_hi - span_lo + 1) as u8;
254 self.conn
255 .change_keyboard_mapping(count, span_lo as u8, per as u8, &bound_syms)
256 .map_err(|e| format!("change_keyboard_mapping: {e}"))?
257 .check()
258 .map_err(|e| format!("change_keyboard_mapping check: {e}"))?;
259 // The binding must be live server-side before the first fake press resolves
260 // against it.
261 self.sync()?;
262 seq(&map);
263 (span_lo, count, per, bound_syms)
264 // Guard drops: ungrab + flush, releasing clients to process the injected
265 // events against the still-live bindings.
266 };
267 // Settle: clients consume the queued MappingNotify + key events and re-fetch the
268 // BOUND map before the spares disappear again.
269 thread::sleep(TRANSIENT_BIND_SETTLE);
270 if let Err(e) = self.restore_transient_binds(span_lo, count, per, &bound_syms, &chosen) {
271 eprintln!("[ComputerUse] transient keysym restore failed: {e}");
272 }
273 Ok(())
274 }
275
276 /// Return the transiently bound spares to all-NoSymbol with ONE conditional
277 /// `ChangeKeyboardMapping` under its own grab. The span is re-fetched under that
278 /// grab and only spares still carrying OUR content are cleared; a keycode another
279 /// client (selkies' allocator) claimed during the settle window keeps that client's
280 /// content — this restore can never clobber a foreign binding.
281 fn restore_transient_binds(
282 &self,
283 span_lo: u32,
284 count: u8,
285 per: usize,
286 bound_syms: &[u32],
287 chosen: &[u32],
288 ) -> Result<(), String> {
289 self.conn
290 .grab_server()
291 .map_err(|e| format!("grab_server: {e}"))?;
292 let _guard = ServerGrabGuard { conn: &self.conn };
293 let reply = self
294 .conn
295 .get_keyboard_mapping(span_lo as u8, count)
296 .map_err(|e| format!("get_keyboard_mapping: {e}"))?
297 .reply()
298 .map_err(|e| format!("get_keyboard_mapping reply: {e}"))?;
299 let cur_per = reply.keysyms_per_keycode as usize;
300 if cur_per == 0 || per == 0 {
301 return Err("empty keymap".to_string());
302 }
303 let mut restore = reply.keysyms.clone();
304 let mut changed = false;
305 for &kc in chosen {
306 let sym = bound_syms[((kc - span_lo) as usize) * per];
307 let cur = &mut restore[((kc - span_lo) as usize) * cur_per..][..cur_per];
308 // Still ours when every populated level carries OUR keysym: the server's XKB
309 // integration mirrors a core single-group binding into the group-2 columns,
310 // so the refetch shows `sym` at more levels than the bind wrote.
311 let still_ours = cur.contains(&sym) && cur.iter().all(|&s| s == 0 || s == sym);
312 if still_ours {
313 cur.fill(0);
314 changed = true;
315 }
316 }
317 if changed {
318 self.conn
319 .change_keyboard_mapping(count, span_lo as u8, cur_per as u8, &restore)
320 .map_err(|e| format!("change_keyboard_mapping: {e}"))?
321 .check()
322 .map_err(|e| format!("change_keyboard_mapping check: {e}"))?;
323 }
324 Ok(())
325 }
326}
327
328/// How long transient binds outlive the injected key events before being restored: the
329/// focused client has to wake up, see the bind's MappingNotify, and re-fetch the keymap
330/// while the bindings are still live, or the presses translate against the restored map
331/// and type nothing.
332const TRANSIENT_BIND_SETTLE: Duration = Duration::from_millis(50);
333
334/// RAII server grab release: the grab is dropped (and the request flushed) on every exit
335/// path, early error returns and panics included — a leaked server grab freezes every
336/// client on the display.
337struct ServerGrabGuard<'a> {
338 conn: &'a RustConnection,
339}
340
341impl Drop for ServerGrabGuard<'_> {
342 fn drop(&mut self) {
343 let _ = self.conn.ungrab_server().map(|c| c.ignore_error());
344 let _ = self.conn.flush();
345 }
346}
347
348impl Drop for CuX11Backend {
349 /// Closing the connection can race the server's client teardown against still-buffered
350 /// fake-input requests (observed as lost button releases); one round trip forces the
351 /// server to consume everything sent on this connection before it goes away.
352 fn drop(&mut self) {
353 if let Ok(cookie) = self.conn.get_input_focus() {
354 let _ = cookie.reply();
355 }
356 }
357}
358
359impl CuBackend for CuX11Backend {
360 fn name(&self) -> &'static str {
361 "x11"
362 }
363
364 fn fb_size(&self) -> Result<(i32, i32), String> {
365 let (w, h) = self.root_geometry()?;
366 Ok((w as i32, h as i32))
367 }
368
369 fn key(&self, scancode: u32, pressed: bool) {
370 if scancode > u8::MAX as u32 {
371 return;
372 }
373 let kind = if pressed {
374 KEY_PRESS_EVENT
375 } else {
376 KEY_RELEASE_EVENT
377 };
378 self.fake_input(kind, scancode as u8, x11rb::NONE, 0, 0);
379 }
380
381 fn mouse_move(&self, x: f64, y: f64) {
382 // detail = 0 makes the motion absolute in root coordinates.
383 self.fake_input(
384 MOTION_NOTIFY_EVENT,
385 0,
386 self.root,
387 x.round() as i16,
388 y.round() as i16,
389 );
390 }
391
392 fn button(&self, btn: CuButton, pressed: bool) {
393 let detail = match btn {
394 CuButton::Left => 1,
395 CuButton::Middle => 2,
396 CuButton::Right => 3,
397 };
398 let kind = if pressed {
399 BUTTON_PRESS_EVENT
400 } else {
401 BUTTON_RELEASE_EVENT
402 };
403 self.fake_input(kind, detail, x11rb::NONE, 0, 0);
404 }
405
406 fn scroll(&self, dx: f64, dy: f64) {
407 // X has no smooth axis over XTEST: a scroll is N discrete clicks of the wheel
408 // buttons (4 = up, 5 = down, 6 = left, 7 = right), one press/release pair each.
409 let emit = |button: u8, clicks: i32| {
410 for _ in 0..clicks.min(MAX_SCROLL_CLICKS) {
411 self.fake_input(BUTTON_PRESS_EVENT, button, x11rb::NONE, 0, 0);
412 self.fake_input(BUTTON_RELEASE_EVENT, button, x11rb::NONE, 0, 0);
413 }
414 };
415 let vy = dy.round() as i32;
416 let vx = dx.round() as i32;
417 if vy != 0 {
418 emit(if vy < 0 { 4 } else { 5 }, vy.abs());
419 }
420 if vx != 0 {
421 emit(if vx < 0 { 6 } else { 7 }, vx.abs());
422 }
423 }
424
425 fn screenshot_png(&self, display: u32) -> Result<Vec<u8>, String> {
426 // One X server, one root: only display 0 exists on this backend.
427 if display != 0 {
428 return Err(format!("Unknown display: {display}"));
429 }
430 let (w, h) = self.root_geometry()?;
431 let img = self
432 .conn
433 .get_image(ImageFormat::Z_PIXMAP, self.root, 0, 0, w, h, !0u32)
434 .map_err(|e| format!("get_image: {e}"))?
435 .reply()
436 .map_err(|e| format!("get_image reply: {e}"))?;
437 let mut data = img.data;
438 let expected = w as usize * h as usize * 4;
439 if data.len() != expected {
440 return Err(format!(
441 "unexpected image size {} for {}x{} (only 32-bpp roots are supported)",
442 data.len(),
443 w,
444 h
445 ));
446 }
447 // The agent needs to see the pointer; the stream's cursor settings do not apply here.
448 if self.has_xfixes
449 && let Some(c) = self
450 .conn
451 .xfixes_get_cursor_image()
452 .ok()
453 .and_then(|c| c.reply().ok())
454 && c.width > 0
455 && c.height > 0
456 {
457 let (img_x, img_y) = super::cursor_image_origin(c.x, c.y, c.xhot, c.yhot, 0, 0);
458 super::overlay_cursor(
459 &mut data,
460 w as usize * 4,
461 w as i32,
462 h as i32,
463 c.width as i32,
464 c.height as i32,
465 &c.cursor_image,
466 img_x,
467 img_y,
468 );
469 }
470 // The grab is BGRX; the padding byte is undefined for depth-24 roots, so alpha is
471 // forced opaque or the PNG would come out transparent.
472 for px in data.as_chunks_mut::<4>().0 {
473 px.swap(0, 2);
474 px[3] = 0xFF;
475 }
476 encode_png_rgba(&data, w as u32, h as u32)
477 }
478
479 fn cursor_pos(&self) -> Result<(f64, f64), String> {
480 let ptr = self
481 .conn
482 .query_pointer(self.root)
483 .map_err(|e| format!("query_pointer: {e}"))?
484 .reply()
485 .map_err(|e| format!("query_pointer reply: {e}"))?;
486 Ok((ptr.root_x as f64, ptr.root_y as f64))
487 }
488
489 fn resolve_keysyms(&self, keysyms: &[u32]) -> Vec<(u32, u32)> {
490 let mut cached = self.reverse_keymap.borrow_mut();
491 let km = cached.get_or_insert_with(|| self.build_reverse_keymap());
492 keysyms
493 .iter()
494 .map(|sym| km.by_sym.get(sym).copied().unwrap_or((0, 0)))
495 .collect()
496 }
497
498 fn altgr_keycode(&self) -> u32 {
499 let mut cached = self.reverse_keymap.borrow_mut();
500 cached
501 .get_or_insert_with(|| self.build_reverse_keymap())
502 .altgr_keycode
503 }
504
505 fn with_transient_keysyms(&self, keysyms: &[u32], seq: &mut dyn FnMut(&HashMap<u32, u32>)) {
506 // Dedup defensively; a duplicate would burn a spare keycode for nothing.
507 let mut unique: Vec<u32> = Vec::with_capacity(keysyms.len());
508 for &s in keysyms {
509 if s != 0 && !unique.contains(&s) {
510 unique.push(s);
511 }
512 }
513 if unique.is_empty() {
514 seq(&HashMap::new());
515 return;
516 }
517 if let Err(e) = self.type_with_transient_binds(&unique, seq) {
518 eprintln!("[ComputerUse] transient keysym bind failed ({e}); typing without it");
519 seq(&HashMap::new());
520 }
521 }
522}