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    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}