Skip to main content

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