Skip to main content

pixelflux/
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//! HTTP server implementing the [Anthropic Computer Use](https://github.com/anthropics/claude-quickstarts/tree/main/computer-use-demo) specification.
8//!
9//! Enabled by setting the `PIXELFLUX_CU` environment variable to the listen port. The server
10//! handles `POST /computer-use` requests for screenshots, mouse/keyboard injection, scrolling,
11//! and cursor position queries. Actions run against a [`CuBackend`], resolved per request: the
12//! Wayland compositor owned by this process when one is registered, otherwise the X server named
13//! by `DISPLAY` (XTEST injection on a private connection, no active capture required).
14//!
15//! The same server also exposes the built-in MP4 recorder at `/record_start`, `/record_stop`
16//! and `/record_status`, so a headless script can drive a session and record it over plain HTTP.
17
18use std::collections::HashMap;
19use std::sync::mpsc;
20use std::sync::{Mutex, OnceLock};
21use std::thread;
22use std::time::Duration;
23use std::io::Cursor;
24use std::io::Read;
25
26use smithay::input::keyboard::xkb;
27
28use base64::Engine;
29use base64::engine::general_purpose::STANDARD as BASE64;
30use image::{ImageBuffer, Rgba, ImageFormat};
31use serde::Deserialize;
32use tiny_http;
33
34use crate::wayland::keymap::keysym_for_char;
35use crate::ThreadCommand;
36
37fn clamp<T: PartialOrd>(v: T, lo: T, hi: T) -> T {
38    if v < lo { lo } else if v > hi { hi } else { v }
39}
40
41/// Turn a raw framebuffer into a PNG the Computer Use agent can actually look at.
42///
43/// The `screenshot` and `zoom` crop paths have to hand the agent an image, and the API carries it
44/// as base64 PNG, so this encodes a flat RGBA buffer (with its dimensions) through the `image`
45/// crate and returns the PNG bytes for that response payload.
46pub fn encode_png_rgba(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
47    let img = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, data.to_vec())
48        .ok_or("Failed to create image buffer")?;
49    let mut png = Vec::new();
50    img.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
51        .map_err(|e| format!("PNG encode error: {}", e))?;
52    Ok(png)
53}
54
55fn scancode_for_keyname(name: &str) -> Option<u32> {
56    Some(match name.to_lowercase().as_str() {
57        "return" | "enter" => 36,
58        "tab" => 23,
59        "escape" | "esc" => 9,
60        "backspace" => 22,
61        "delete" => 119,
62        "insert" => 118,
63        "home" => 110,
64        "end" => 115,
65        "pageup" => 112,
66        "pagedown" => 117,
67        "up" => 111,
68        "down" => 116,
69        "left" => 113,
70        "right" => 114,
71        "space" => 65,
72        "capslock" => 66,
73        "numlock" => 77,
74        "scrolllock" => 78,
75        "printscreen" | "sysrq" | "print" => 107,
76        "pause" | "break" => 127,
77        "f1" => 67, "f2" => 68, "f3" => 69, "f4" => 70,
78        "f5" => 71, "f6" => 72, "f7" => 73, "f8" => 74,
79        "f9" => 75, "f10" => 76, "f11" => 95, "f12" => 96,
80        "f13" => 191, "f14" => 192, "f15" => 193, "f16" => 194,
81        "f17" => 195, "f18" => 196, "f19" => 197, "f20" => 198,
82        "f21" => 199, "f22" => 200, "f23" => 201, "f24" => 202,
83        "ctrl" | "lctrl" | "leftctrl" => 37,
84        "rctrl" | "rightctrl" => 105,
85        "shift" | "lshift" | "leftshift" => 50,
86        "rshift" | "rightshift" => 62,
87        "alt" | "lalt" | "leftalt" => 64,
88        "ralt" | "rightalt" => 108,
89        "super" | "meta" | "lsuper" | "leftmeta" | "leftsuper" | "windows" | "leftwindows" => 133,
90        "rsuper" | "rightmeta" | "rightsuper" | "rightwindows" => 134,
91        "menu" | "compose" => 135,
92        "kp_0" | "kp0" => 90, "kp_1" | "kp1" => 87,
93        "kp_2" | "kp2" => 88, "kp_3" | "kp3" => 89,
94        "kp_4" | "kp4" => 83, "kp_5" | "kp5" => 84,
95        "kp_6" | "kp6" => 85, "kp_7" | "kp7" => 79,
96        "kp_8" | "kp8" => 80, "kp_9" | "kp9" => 81,
97        "kp_decimal" | "kp_dot" => 91,
98        "kp_divide" | "kp_slash" => 106,
99        "kp_multiply" | "kp_asterisk" => 63,
100        "kp_subtract" | "kp_minus" => 82,
101        "kp_add" | "kp_plus" => 86,
102        "kp_enter" => 104,
103        _ => return None,
104    })
105}
106
107/// Keysym for one component of an xdotool-style key spec that is not a named key: a single
108/// literal character (`a`, `7`, `-`, `#`) via the Unicode mapping, or a keysym NAME
109/// (`minus`, `bracketleft`, `udiaeresis`, …) via xkb's name lookup (case-tolerant fallback
110/// so `Minus` still resolves). Returns 0 when the spec names no keysym.
111fn keysym_for_key_spec(name: &str) -> u32 {
112    let mut chars = name.chars();
113    if let (Some(c), None) = (chars.next(), chars.next()) {
114        return keysym_for_char(c);
115    }
116    let sym = xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw();
117    if sym != 0 {
118        return sym;
119    }
120    xkb::keysym_from_name(name, xkb::KEYSYM_CASE_INSENSITIVE).raw()
121}
122
123fn is_modifier(scancode: u32) -> bool {
124    matches!(scancode, 37 | 105 | 50 | 62 | 64 | 108 | 133 | 134)
125}
126
127/// Semantic mouse-button vocabulary of the action layer; each backend maps it to its native
128/// codes (evdev `BTN_` on Wayland, X core buttons 1/2/3 on X11).
129#[derive(Clone, Copy)]
130pub enum CuButton {
131    Left,
132    Right,
133    Middle,
134}
135
136/// The desktop-side primitives one Computer Use action decomposes into. Keycodes are X/xkb
137/// keycodes (evdev + 8) — the numbering both the smithay seat and XTEST consume — and
138/// coordinates are framebuffer/root pixels, already clamped by the action layer.
139pub trait CuBackend {
140    fn name(&self) -> &'static str;
141    fn fb_size(&self) -> Result<(i32, i32), String>;
142    /// One display's framebuffer size; 0 = the primary. Unknown ids are an error. The
143    /// X11 backend serves only the root (display 0); Wayland resolves any live output id.
144    fn display_fb_size(&self, display: u32) -> Result<(i32, i32), String> {
145        if display == 0 {
146            self.fb_size()
147        } else {
148            Err(format!("Unknown display: {display}"))
149        }
150    }
151    fn key(&self, scancode: u32, pressed: bool);
152    fn mouse_move(&self, x: f64, y: f64);
153    fn button(&self, btn: CuButton, pressed: bool);
154    fn scroll(&self, dx: f64, dy: f64);
155    /// PNG of one display's framebuffer; 0 = the primary. Unknown ids are an error.
156    fn screenshot_png(&self, display: u32) -> Result<Vec<u8>, String>;
157    fn cursor_pos(&self) -> Result<(f64, f64), String>;
158    /// Run `seq` with every keysym in `keysyms` (which `resolve_keysyms` could not place)
159    /// made temporarily typeable, when the backend can arrange that; `seq` receives
160    /// keysym -> keycode for the transient bindings (empty when none could be arranged,
161    /// in which case those keysyms simply stay untypeable). Wayland's `resolve_keysyms`
162    /// already overlay-binds anything the base keymap lacks, so the default runs `seq`
163    /// with no bindings; the X11 backend overrides this with a grab-atomic spare-keycode
164    /// remap.
165    fn with_transient_keysyms(&self, keysyms: &[u32], seq: &mut dyn FnMut(&HashMap<u32, u32>)) {
166        let _ = keysyms;
167        seq(&HashMap::new());
168    }
169    /// Resolve keysyms against the backend's ACTIVE keymap: one `(keycode, shift level)`
170    /// per input keysym, `(0, 0)` where the keymap cannot produce it. Level bit 0 = Shift,
171    /// bit 1 = AltGr — the order xkb two/four-level key types use. Wayland resolves through
172    /// the compositor's keymap policy (overlay-binding what the base lacks); X11 through
173    /// the server's own `GetKeyboardMapping`.
174    fn resolve_keysyms(&self, keysyms: &[u32]) -> Vec<(u32, u32)>;
175    /// Keycode synthesized around AltGr-level hits (level bit 1). The default is the
176    /// pc105 right-Alt position the compositor's seat keymap binds to `ISO_Level3_Shift`;
177    /// the X11 backend overrides it with the level-3 modifier key found in the server's
178    /// keymap (0 = none, in which case `resolve_keysyms` reports no AltGr levels).
179    fn altgr_keycode(&self) -> u32 {
180        KC_ALTGR
181    }
182}
183
184const BTN_LEFT: u32 = 0x110;
185const BTN_RIGHT: u32 = 0x111;
186const BTN_MIDDLE: u32 = 0x112;
187
188/// Reply deadline for compositor round-trips (screenshot readback, cursor position, fb size).
189/// Bounded so a wedged render path turns into a JSON error instead of hanging the sequential
190/// HTTP loop — and every request behind it — forever.
191const REPLY_TIMEOUT: Duration = Duration::from_secs(5);
192
193/// Wayland implementation: every primitive is a `ThreadCommand` on the compositor's calloop
194/// channel, so injection and readback serialize naturally with rendering and encoding.
195pub struct CuWaylandBackend {
196    tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
197}
198
199impl CuBackend for CuWaylandBackend {
200    fn name(&self) -> &'static str {
201        "wayland"
202    }
203
204    fn fb_size(&self) -> Result<(i32, i32), String> {
205        self.display_fb_size(0)
206    }
207
208    fn display_fb_size(&self, display: u32) -> Result<(i32, i32), String> {
209        let (resp_tx, resp_rx) = mpsc::channel();
210        self.tx.send(ThreadCommand::CuGetInfo { display_id: display, resp: resp_tx })
211            .map_err(|_| "Failed to request compositor info".to_string())?;
212        let (w, h, _) = resp_rx.recv_timeout(REPLY_TIMEOUT)
213            .map_err(|_| "Compositor info request failed".to_string())?;
214        // CuGetInfo reports zeros for output ids that don't exist (and for an output
215        // with no mode, which live outputs always have).
216        if w <= 0 || h <= 0 {
217            return Err(format!("Unknown display: {display}"));
218        }
219        Ok((w, h))
220    }
221
222    fn key(&self, scancode: u32, pressed: bool) {
223        let _ = self.tx.send(ThreadCommand::KeyboardKey {
224            scancode,
225            state: if pressed { 1 } else { 0 },
226        });
227    }
228
229    fn mouse_move(&self, x: f64, y: f64) {
230        let _ = self.tx.send(ThreadCommand::PointerMotion { x, y });
231    }
232
233    fn button(&self, btn: CuButton, pressed: bool) {
234        let btn = match btn {
235            CuButton::Left => BTN_LEFT,
236            CuButton::Right => BTN_RIGHT,
237            CuButton::Middle => BTN_MIDDLE,
238        };
239        let _ = self.tx.send(ThreadCommand::PointerButton {
240            btn,
241            state: if pressed { 1 } else { 0 },
242        });
243    }
244
245    fn scroll(&self, dx: f64, dy: f64) {
246        let _ = self.tx.send(ThreadCommand::PointerAxis { x: dx, y: dy });
247    }
248
249    fn screenshot_png(&self, display: u32) -> Result<Vec<u8>, String> {
250        let (resp_tx, resp_rx) = mpsc::channel();
251        self.tx.send(ThreadCommand::CuScreenshot { display_id: display, resp: resp_tx })
252            .map_err(|_| "Failed to request screenshot".to_string())?;
253        resp_rx.recv_timeout(REPLY_TIMEOUT).map_err(|_| "Screenshot failed".to_string())?
254    }
255
256    fn cursor_pos(&self) -> Result<(f64, f64), String> {
257        let (resp_tx, resp_rx) = mpsc::channel();
258        self.tx.send(ThreadCommand::CuCursorPosition { resp: resp_tx })
259            .map_err(|_| "Failed to request cursor position".to_string())?;
260        resp_rx.recv_timeout(REPLY_TIMEOUT).map_err(|_| "Cursor position failed".to_string())
261    }
262
263    fn resolve_keysyms(&self, keysyms: &[u32]) -> Vec<(u32, u32)> {
264        let (resp_tx, resp_rx) = mpsc::channel();
265        if self
266            .tx
267            .send(ThreadCommand::BindKeysyms { keysyms: keysyms.to_vec(), reply: resp_tx })
268            .is_err()
269        {
270            return vec![(0, 0); keysyms.len()];
271        }
272        resp_rx
273            .recv_timeout(REPLY_TIMEOUT)
274            .unwrap_or_else(|_| vec![(0, 0); keysyms.len()])
275    }
276}
277
278/// Per-request literal-key resolver: batches keysym lookups against the backend's active
279/// keymap and caches them for the request's burst of key events (a fresh backend — and thus
280/// a fresh cache — is resolved per HTTP request, so a runtime layout switch is picked up by
281/// the next request).
282struct KeyResolver<'a> {
283    backend: &'a dyn CuBackend,
284    cache: HashMap<u32, (u32, u32)>,
285}
286
287impl<'a> KeyResolver<'a> {
288    fn new(backend: &'a dyn CuBackend) -> Self {
289        Self { backend, cache: HashMap::new() }
290    }
291
292    /// Resolve a batch up front so a `type` action costs one backend round trip.
293    fn prefetch(&mut self, keysyms: &[u32]) {
294        let missing: Vec<u32> = {
295            let mut seen = std::collections::HashSet::new();
296            keysyms
297                .iter()
298                .copied()
299                .filter(|&s| s != 0 && !self.cache.contains_key(&s) && seen.insert(s))
300                .collect()
301        };
302        if missing.is_empty() {
303            return;
304        }
305        let out = self.backend.resolve_keysyms(&missing);
306        for (sym, hit) in missing.into_iter().zip(out) {
307            self.cache.insert(sym, hit);
308        }
309    }
310
311    /// `(keycode, shift level)` for `keysym`, or `None` when the active keymap cannot
312    /// produce it.
313    fn resolve(&mut self, keysym: u32) -> Option<(u32, u32)> {
314        if keysym == 0 {
315            return None;
316        }
317        self.prefetch(&[keysym]);
318        let hit = self.cache.get(&keysym).copied().unwrap_or((0, 0));
319        (hit.0 != 0).then_some(hit)
320    }
321}
322
323/// Shift and default AltGr (pc105 right Alt / ISO_Level3_Shift) keycodes, held to reach a
324/// resolved key's shift level: bit 0 = Shift, bit 1 = AltGr.
325const KC_SHIFT: u32 = 50;
326const KC_ALTGR: u32 = 108;
327
328/// The level modifiers `level` requires beyond whatever is already held (`held_mask` uses
329/// the same bit layout), in press order. `altgr` is the backend's AltGr keycode
330/// ([`CuBackend::altgr_keycode`]).
331fn level_modifiers(level: u32, held_mask: u32, altgr: u32) -> Vec<u32> {
332    let mut out = Vec::new();
333    if level & 1 != 0 && held_mask & 1 == 0 {
334        out.push(KC_SHIFT);
335    }
336    if level & 2 != 0 && held_mask & 2 == 0 && altgr != 0 {
337        out.push(altgr);
338    }
339    out
340}
341
342#[derive(Deserialize)]
343struct CuActionRequest {
344    action: String,
345    coordinate: Option<[f64; 2]>,
346    start_coordinate: Option<[f64; 2]>,
347    text: Option<String>,
348    key: Option<String>,
349    scroll_direction: Option<String>,
350    scroll_amount: Option<i32>,
351    duration: Option<f64>,
352    region: Option<[f64; 4]>,
353    /// Display id for `screenshot` / `zoom` (default 0, the primary; `record_start` uses
354    /// its own `display` field with the same meaning). Unknown ids get an error reply.
355    display: Option<u32>,
356}
357
358fn ok_json() -> String {
359    "{\"result\":\"ok\"}".to_string()
360}
361
362fn handle_action(req: CuActionRequest, backend: &dyn CuBackend) -> String {
363    let result = handle_action_inner(req, backend);
364    match result {
365        Ok(response) => response,
366        Err(e) => format!("{{\"error\":\"{}\"}}", e.replace('"', "\\\"")),
367    }
368}
369
370/// Turn one parsed Computer Use request into a real action on the captured desktop — the
371/// bridge that lets an external AI agent see and drive the session.
372///
373/// Every action ultimately becomes a backend input primitive or a framebuffer read, so this is
374/// where the API's vocabulary meets the running desktop. Coordinates are clamped to the current
375/// framebuffer so a mistaken agent click cannot address off-screen pixels; keyboard and pointer
376/// actions translate into input events; and actions that must look at the screen (`screenshot`,
377/// `zoom`) request a fresh capture first, so the agent reasons about the current frame rather than
378/// a stale one. Returns the JSON response, or an error string explaining why the action could not
379/// run.
380fn handle_action_inner(req: CuActionRequest, b: &dyn CuBackend) -> Result<String, String> {
381    let sleep_ms = |ms: u64| thread::sleep(Duration::from_millis(ms));
382
383    let (fb_w, fb_h) = b.fb_size()?;
384
385    let handle_coord = |coord: [f64; 2]| -> (f64, f64) {
386        (
387            clamp(coord[0], 0.0, (fb_w - 1) as f64),
388            clamp(coord[1], 0.0, (fb_h - 1) as f64),
389        )
390    };
391
392    let handle_modifier = |mod_name: &str| -> Option<u32> {
393        let sc = scancode_for_keyname(mod_name)?;
394        if is_modifier(sc) { Some(sc) } else { None }
395    };
396
397    match req.action.as_str() {
398        "screenshot" => {
399            let png = b.screenshot_png(req.display.unwrap_or(0))?;
400            let b64 = BASE64.encode(&png);
401            Ok(format!("{{\"data\":\"{}\"}}", b64))
402        }
403
404        "mouse_move" => {
405            let coord = req.coordinate.ok_or("Missing coordinate")?;
406            let (fx, fy) = handle_coord(coord);
407            b.mouse_move(fx, fy);
408            Ok(ok_json())
409        }
410
411        "left_click" | "right_click" | "middle_click" => {
412            let btn = match req.action.as_str() {
413                "left_click" => CuButton::Left,
414                "right_click" => CuButton::Right,
415                _ => CuButton::Middle,
416            };
417            if let Some(coord) = req.coordinate {
418                let (fx, fy) = handle_coord(coord);
419                b.mouse_move(fx, fy);
420                sleep_ms(30);
421            }
422            if let Some(ref mod_name) = req.text
423                && let Some(sc) = handle_modifier(mod_name) {
424                    b.key(sc, true);
425                    sleep_ms(20);
426                }
427            b.button(btn, true);
428            sleep_ms(20);
429            b.button(btn, false);
430            if let Some(ref mod_name) = req.text
431                && let Some(sc) = handle_modifier(mod_name) {
432                    sleep_ms(10);
433                    b.key(sc, false);
434                }
435            Ok(ok_json())
436        }
437
438        "double_click" | "triple_click" => {
439            let n = if req.action == "double_click" { 2 } else { 3 };
440            if let Some(coord) = req.coordinate {
441                let (fx, fy) = handle_coord(coord);
442                b.mouse_move(fx, fy);
443                sleep_ms(30);
444            }
445            if let Some(ref mod_name) = req.text
446                && let Some(sc) = handle_modifier(mod_name) {
447                    b.key(sc, true);
448                    sleep_ms(20);
449                }
450            for _ in 0..n {
451                b.button(CuButton::Left, true);
452                sleep_ms(10);
453                b.button(CuButton::Left, false);
454                sleep_ms(10);
455            }
456            if let Some(ref mod_name) = req.text
457                && let Some(sc) = handle_modifier(mod_name) {
458                    sleep_ms(10);
459                    b.key(sc, false);
460                }
461            Ok(ok_json())
462        }
463
464        "left_click_drag" => {
465            let start = req.start_coordinate.ok_or("Missing start_coordinate")?;
466            let end = req.coordinate.ok_or("Missing coordinate")?;
467            let (sx, sy) = handle_coord(start);
468            let (ex, ey) = handle_coord(end);
469            b.mouse_move(sx, sy);
470            sleep_ms(30);
471            b.button(CuButton::Left, true);
472            sleep_ms(30);
473            b.mouse_move(ex, ey);
474            sleep_ms(30);
475            b.button(CuButton::Left, false);
476            Ok(ok_json())
477        }
478
479        "left_mouse_down" => {
480            b.button(CuButton::Left, true);
481            Ok(ok_json())
482        }
483
484        "left_mouse_up" => {
485            b.button(CuButton::Left, false);
486            Ok(ok_json())
487        }
488
489        "type" => {
490            let text = req.text.as_deref().ok_or("Missing text")?;
491            // A nested app compositor owns the apps on its own socket; type there as
492            // a virtual-keyboard client, since keys on pixelflux's own seat carry an
493            // overlay keymap the inner compositor never sees. Falls through to the
494            // local seat if the app socket is unreachable.
495            if b.name() == "wayland"
496                && let Some(sock) = app_wayland_socket_path() {
497                    // Failures log once per socket value; every request still
498                    // retries, so a compositor that comes back is used again
499                    // immediately (and re-arms the logging).
500                    static FAILED_SOCK: Mutex<Option<String>> = Mutex::new(None);
501                    match crate::wayland::vkclient::type_text_to(&sock, text) {
502                        Ok(()) => {
503                            *FAILED_SOCK.lock().unwrap() = None;
504                            return Ok(ok_json());
505                        }
506                        Err(e) => {
507                            let mut last = FAILED_SOCK.lock().unwrap();
508                            if last.as_deref() != Some(sock.as_str()) {
509                                eprintln!(
510                                    "[ComputerUse] app-compositor type via {sock} failed ({e}); using local seat until it is reachable"
511                                );
512                                *last = Some(sock);
513                            }
514                        }
515                    }
516                }
517            let mut resolver = KeyResolver::new(b);
518            let syms: Vec<u32> = text.chars().map(keysym_for_char).collect();
519            resolver.prefetch(&syms);
520            // Base+AltGr resolution stays the preferred path; only what the active keymap
521            // cannot reach at all goes through the backend's transient-bind fallback.
522            let mut unresolved: Vec<u32> = Vec::new();
523            for &sym in &syms {
524                if sym != 0 && resolver.resolve(sym).is_none() && !unresolved.contains(&sym) {
525                    unresolved.push(sym);
526                }
527            }
528            b.with_transient_keysyms(&unresolved, &mut |bound| {
529                for (i, &sym) in syms.iter().enumerate() {
530                    if i > 0 && i % 50 == 0 {
531                        sleep_ms(20);
532                    }
533                    if let Some((sc, level)) = resolver.resolve(sym) {
534                        let level_mods = level_modifiers(level, 0, b.altgr_keycode());
535                        for &m in &level_mods {
536                            b.key(m, true);
537                            sleep_ms(5);
538                        }
539                        b.key(sc, true);
540                        sleep_ms(10);
541                        b.key(sc, false);
542                        for &m in level_mods.iter().rev() {
543                            b.key(m, false);
544                        }
545                        sleep_ms(8);
546                    } else if let Some(&kc) = bound.get(&sym) {
547                        // Transient binds sit at the plain level: no modifiers needed.
548                        b.key(kc, true);
549                        sleep_ms(10);
550                        b.key(kc, false);
551                        sleep_ms(8);
552                    }
553                }
554            });
555            Ok(ok_json())
556        }
557
558        "key" => {
559            let text = req.text.as_deref().ok_or("Missing text")?;
560            let mut resolver = KeyResolver::new(b);
561            let mut mods: Vec<u32> = Vec::new();
562            let mut main_key: Option<(u32, u32)> = None;
563            // Keysym of the last main-key spec the active keymap could not place (cleared
564            // when a later part resolves): typed via the transient-bind fallback.
565            let mut unresolved_sym: Option<u32> = None;
566            for part in text.split('+') {
567                let trimmed = part.trim();
568                if let Some(sc) = scancode_for_keyname(trimmed) {
569                    if is_modifier(sc) {
570                        mods.push(sc);
571                    } else {
572                        main_key = Some((sc, 0));
573                        unresolved_sym = None;
574                    }
575                } else {
576                    let sym = keysym_for_key_spec(trimmed);
577                    if let Some(hit) = resolver.resolve(sym) {
578                        main_key = Some(hit);
579                        unresolved_sym = None;
580                    } else if sym != 0 {
581                        main_key = None;
582                        unresolved_sym = Some(sym);
583                    }
584                }
585            }
586            let unresolved: Vec<u32> = unresolved_sym.into_iter().collect();
587            b.with_transient_keysyms(&unresolved, &mut |bound| {
588                let main_key = main_key.or_else(|| {
589                    // Transient binds sit at the plain level: no modifiers needed.
590                    unresolved_sym.and_then(|s| bound.get(&s)).map(|&kc| (kc, 0))
591                });
592                for &sc in &mods {
593                    b.key(sc, true);
594                    sleep_ms(10);
595                }
596                if let Some((sc, level)) = main_key {
597                    // Modifiers reaching the key's shift level are added unless the spec
598                    // already asked for them (Shift as 50/62, AltGr as 108 or the backend's
599                    // resolved level-3 keycode).
600                    let altgr = b.altgr_keycode();
601                    let held = mods.iter().fold(0u32, |m, &sc| match sc {
602                        50 | 62 => m | 1,
603                        sc if sc == KC_ALTGR || sc == altgr => m | 2,
604                        _ => m,
605                    });
606                    let level_mods = level_modifiers(level, held, altgr);
607                    for &m in &level_mods {
608                        b.key(m, true);
609                        sleep_ms(10);
610                    }
611                    b.key(sc, true);
612                    sleep_ms(30);
613                    b.key(sc, false);
614                    for &m in level_mods.iter().rev() {
615                        sleep_ms(10);
616                        b.key(m, false);
617                    }
618                } else if let Some(&last_mod) = mods.last() {
619                    b.key(last_mod, true);
620                    sleep_ms(30);
621                    b.key(last_mod, false);
622                }
623                for &sc in mods.iter().rev() {
624                    sleep_ms(10);
625                    b.key(sc, false);
626                }
627            });
628            Ok(ok_json())
629        }
630
631        "hold_key" => {
632            let text = req.text.as_deref().ok_or("Missing text")?;
633            let duration = req.duration.ok_or("Missing duration")?;
634            let duration = duration.min(100.0);
635            let trimmed = text.trim();
636            let mut resolver = KeyResolver::new(b);
637            let hit = match scancode_for_keyname(trimmed) {
638                Some(sc) => Some((sc, 0)),
639                None => resolver.resolve(keysym_for_key_spec(trimmed)),
640            };
641            if let Some((sc, level)) = hit {
642                let level_mods = level_modifiers(level, 0, b.altgr_keycode());
643                for &m in &level_mods {
644                    b.key(m, true);
645                    sleep_ms(10);
646                }
647                b.key(sc, true);
648                sleep_ms((duration * 1000.0) as u64);
649                b.key(sc, false);
650                for &m in level_mods.iter().rev() {
651                    sleep_ms(10);
652                    b.key(m, false);
653                }
654            }
655            Ok(ok_json())
656        }
657
658        "scroll" => {
659            let dir = req.scroll_direction.as_deref().ok_or("Missing scroll_direction")?;
660            let amount = req.scroll_amount.unwrap_or(1).max(0) as f64;
661            if let Some(coord) = req.coordinate {
662                let (fx, fy) = handle_coord(coord);
663                b.mouse_move(fx, fy);
664                sleep_ms(30);
665            }
666            if let Some(ref mod_name) = req.text
667                && let Some(sc) = handle_modifier(mod_name) {
668                    b.key(sc, true);
669                    sleep_ms(20);
670                }
671            let (dx, dy) = match dir {
672                "up" => (0.0, -amount),
673                "down" => (0.0, amount),
674                "left" => (-amount, 0.0),
675                "right" => (amount, 0.0),
676                _ => return Err(format!("Invalid scroll_direction: {}", dir)),
677            };
678            b.scroll(dx, dy);
679            sleep_ms(30);
680            if let Some(ref mod_name) = req.text
681                && let Some(sc) = handle_modifier(mod_name) {
682                    sleep_ms(10);
683                    b.key(sc, false);
684                }
685            Ok(ok_json())
686        }
687
688        "cursor_position" => {
689            let (x, y) = b.cursor_pos()?;
690            Ok(format!("{{\"text\":\"X={},Y={}\"}}", x.round() as i32, y.round() as i32))
691        }
692
693        "wait" => {
694            let duration = req.duration.unwrap_or(1.0).min(100.0);
695            sleep_ms((duration * 1000.0) as u64);
696            Ok(ok_json())
697        }
698
699        "zoom" => {
700            let region = req.region.ok_or("Missing region")?;
701            let display = req.display.unwrap_or(0);
702            // The region clamps against the TARGET display's own framebuffer, which need
703            // not match the primary's.
704            let (dw, dh) = b.display_fb_size(display)?;
705            let (x0, y0, x1, y1) = (region[0], region[1], region[2], region[3]);
706            let left = clamp(x0.round() as u32, 0, dw as u32 - 1);
707            let top = clamp(y0.round() as u32, 0, dh as u32 - 1);
708            let right = clamp(x1.round() as u32, left + 1, dw as u32);
709            let bottom = clamp(y1.round() as u32, top + 1, dh as u32);
710            let crop_w = right - left;
711            let crop_h = bottom - top;
712            let png = b.screenshot_png(display)?;
713            if crop_w > 0 && crop_h > 0
714                && let Ok(img) = image::load_from_memory(&png) {
715                    let cropped = img.crop_imm(left, top, crop_w, crop_h);
716                    let mut out = Vec::new();
717                    cropped.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
718                        .map_err(|e| format!("Crop/encode error: {}", e))?;
719                    let b64 = BASE64.encode(&out);
720                    return Ok(format!("{{\"data\":\"{}\"}}", b64));
721                }
722            let b64 = BASE64.encode(&png);
723            Ok(format!("{{\"data\":\"{}\"}}", b64))
724        }
725
726        _ => Err(format!("Unknown action: {}", req.action)),
727    }
728}
729
730static WAYLAND_TX: Mutex<Option<smithay::reexports::calloop::channel::Sender<ThreadCommand>>> =
731    Mutex::new(None);
732
733/// Body of `POST /record_start`. All fields are optional; unset ones fall back to the
734/// `PIXELFLUX_RECORD_*` environment variables and built-in defaults.
735#[derive(Deserialize, Default)]
736struct RecordStartRequest {
737    /// Output MP4 path (default: `$PIXELFLUX_RECORD`, else `/tmp/pixelflux-record-<unix_ts>.mp4`).
738    path: Option<String>,
739    /// Wayland output id to record (default 0; ignored on X11).
740    display: Option<u32>,
741    /// Capture fps cap for a recorder-owned capture.
742    fps: Option<f64>,
743    /// Bitrate override in kbps for a recorder-owned capture.
744    bitrate_kbps: Option<i32>,
745}
746
747/// Handle the recorder REST endpoints sharing the CU server: `record_start`,
748/// `record_stop` and `record_status`, all one JSON round-trip into the same recorder
749/// implementation the Python API and env vars use. Returns `None` for any other URL.
750fn handle_record_endpoint(url: &str, body: &str) -> Option<String> {
751    let reply = match url {
752        "/record_start" => {
753            let req: RecordStartRequest = if body.trim().is_empty() {
754                RecordStartRequest::default()
755            } else {
756                match serde_json::from_str(body) {
757                    Ok(r) => r,
758                    Err(e) => return Some(format!("{{\"error\":\"Invalid JSON: {}\"}}", e)),
759                }
760            };
761            let path = req
762                .path
763                .or_else(|| std::env::var("PIXELFLUX_RECORD").ok().filter(|p| !p.is_empty()))
764                .unwrap_or_else(|| {
765                    let ts = std::time::SystemTime::now()
766                        .duration_since(std::time::UNIX_EPOCH)
767                        .map(|d| d.as_secs())
768                        .unwrap_or(0);
769                    format!("/tmp/pixelflux-record-{ts}.mp4")
770                });
771            let mut opts = crate::recorder::RecordOptions::from_env(path);
772            if let Some(d) = req.display {
773                opts.display_id = d;
774            }
775            if let Some(f) = req.fps {
776                opts.fps = f;
777            }
778            if let Some(b) = req.bitrate_kbps {
779                opts.bitrate_kbps = b;
780            }
781            match crate::recorder::start(opts) {
782                Ok(s) => crate::recorder::status_to_json(&s).to_string(),
783                Err(e) => serde_json::json!({ "error": e }).to_string(),
784            }
785        }
786        "/record_stop" => match crate::recorder::stop() {
787            Ok(s) => {
788                let mut v = crate::recorder::status_to_json(&s);
789                v["stopped"] = serde_json::Value::Bool(true);
790                v.to_string()
791            }
792            Err(e) => serde_json::json!({ "error": e }).to_string(),
793        },
794        "/record_status" => match crate::recorder::status() {
795            Some(s) => crate::recorder::status_to_json(&s).to_string(),
796            None => serde_json::json!({ "active": false }).to_string(),
797        },
798        _ => return None,
799    };
800    Some(reply)
801}
802
803/// Socket of the compositor apps run under when it is nested under pixelflux
804/// (labwc/kwin): keys injected into pixelflux's own seat carry an overlay keymap
805/// the inner compositor never sees, so CU text is typed as a client of this
806/// socket instead. Set by selkies over the ScreenCapture ABI.
807static CU_APP_WAYLAND_DISPLAY: Mutex<Option<String>> = Mutex::new(None);
808
809/// Set (or clear, with None/empty) the app compositor socket for CU text injection.
810pub fn set_app_wayland_display(display: Option<String>) {
811    *CU_APP_WAYLAND_DISPLAY.lock().unwrap() = display.filter(|s| !s.is_empty());
812}
813
814/// Resolve the app compositor socket PATH for CU typing, or None to type on the
815/// local seat. The ABI value selkies set wins; a standalone CU (no selkies) falls
816/// back to PIXELFLUX_APP_WAYLAND_DISPLAY. A value naming pixelflux's own
817/// compositor means nothing is nested.
818fn app_wayland_socket_path() -> Option<String> {
819    let name = CU_APP_WAYLAND_DISPLAY
820        .lock()
821        .unwrap()
822        .clone()
823        .or_else(|| std::env::var("PIXELFLUX_APP_WAYLAND_DISPLAY").ok())
824        .filter(|s| !s.is_empty())?;
825    if crate::wait_socket_name(Duration::from_millis(0)).as_deref() == Some(name.as_str()) {
826        return None;
827    }
828    crate::wayland::wlclient::socket_path(&name)
829}
830
831/// Make the Wayland compositor the preferred CU backend: once a live calloop sender is
832/// registered, every subsequent request routes to it instead of an X11 connection.
833pub fn register_wayland_backend(tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>) {
834    *WAYLAND_TX.lock().unwrap() = Some(tx);
835}
836
837/// The registered compositor's command channel, if a Wayland compositor is running in this
838/// process. The recorder uses it to attach to (or start) a capture without any Python client.
839pub(crate) fn wayland_command_sender(
840) -> Option<smithay::reexports::calloop::channel::Sender<ThreadCommand>> {
841    WAYLAND_TX.lock().unwrap().clone()
842}
843
844/// Start the CU server if `PIXELFLUX_CU` names a bind (the standalone fallback;
845/// a selkies-managed session passes the setting through [`start_cu_server`]).
846pub fn spawn_cu_from_env() {
847    if let Ok(bind) = std::env::var("PIXELFLUX_CU") {
848        start_cu_server(&bind);
849    }
850}
851
852/// Start the CU server on `bind`: a bare port listens on all interfaces (the
853/// container-deployment default), `host:port` scopes it. Guarded so that exactly
854/// one server binds per process no matter how many call sites (module import,
855/// Wayland compositor init, the selkies setting) race to spawn it; backend
856/// selection stays per-request, so a server bound at import serves a compositor
857/// that only starts later.
858pub fn start_cu_server(bind: &str) {
859    let addr = if bind.contains(':') {
860        bind.to_string()
861    } else {
862        match bind.parse::<u16>() {
863            Ok(port) => format!("0.0.0.0:{port}"),
864            Err(_) => {
865                println!("[ComputerUse] Invalid bind '{bind}' - expected a port or host:port");
866                return;
867            }
868        }
869    };
870    static SPAWNED: OnceLock<()> = OnceLock::new();
871    let mut first = false;
872    SPAWNED.get_or_init(|| {
873        first = true;
874    });
875    if first {
876        thread::spawn(move || run_cu_server(addr));
877    }
878}
879
880/// Pick the backend for one request: the registered Wayland compositor when present,
881/// otherwise a fresh private connection to `DISPLAY`. The X11 connection is per-request so a
882/// restarted X server never leaves the CU thread holding a dead connection.
883fn resolve_backend() -> Result<Box<dyn CuBackend>, String> {
884    if let Some(tx) = WAYLAND_TX.lock().unwrap().clone() {
885        return Ok(Box::new(CuWaylandBackend { tx }));
886    }
887    crate::x11::computer_use::CuX11Backend::connect()
888        .map(|be| Box::new(be) as Box<dyn CuBackend>)
889}
890
891/// Expose the captured desktop to an AI agent over HTTP, so a Computer Use client can drive
892/// the session much as a human viewer would.
893///
894/// It runs on its own thread listening on `addr` for POST `/computer-use` JSON actions
895/// (screenshot, mouse_move, click, key, scroll, …). The backend and the framebuffer dimensions
896/// are re-resolved on every request rather than cached, because the compositor can start, the
897/// stream can resize, or the X server can restart underneath the agent — a stale size would
898/// misplace every coordinate. On Wayland a screenshot forces a one-frame GPU readback when the
899/// pipeline is in zero-copy mode; on X11 it is a one-shot `GetImage` of the root window.
900pub fn run_cu_server(addr: String) {
901    println!("[ComputerUse] Server listening on {}", addr);
902
903    let server = match tiny_http::Server::http(addr.as_str()) {
904        Ok(s) => s,
905        Err(e) => {
906            eprintln!("[ComputerUse] Failed to start server: {}", e);
907            return;
908        }
909    };
910
911    let mut last_backend = "";
912    for mut request in server.incoming_requests() {
913        // A CU body is a single input command: cap its size so a hostile client
914        // of the (unauthenticated) endpoint cannot exhaust memory with a giant POST.
915        const MAX_CU_BODY: u64 = 4 * 1024 * 1024;
916        let mut body = String::new();
917        if let Err(e) = request
918            .as_reader()
919            .take(MAX_CU_BODY + 1)
920            .read_to_string(&mut body)
921        {
922            let _ = request.respond(tiny_http::Response::from_string(format!(
923                "{{\"error\":\"{}\"}}", e
924            ))
925            .with_status_code(400)
926            .with_header(
927                "Content-Type: application/json".parse::<tiny_http::Header>().unwrap()
928            ));
929            continue;
930        }
931
932        if body.len() as u64 > MAX_CU_BODY {
933            let resp = tiny_http::Response::from_string(
934                "{\"error\":\"request body too large\"}".to_string(),
935            )
936            .with_status_code(413)
937            .with_header(
938                "Content-Type: application/json".parse::<tiny_http::Header>().unwrap()
939            );
940            let _ = request.respond(resp);
941            continue;
942        }
943
944        if let Some(json) = handle_record_endpoint(request.url(), &body) {
945            let _ = request.respond(
946                tiny_http::Response::from_string(json)
947                    .with_status_code(200)
948                    .with_header(
949                        "Content-Type: application/json".parse::<tiny_http::Header>().unwrap()
950                    )
951            );
952            continue;
953        }
954
955        let parsed: CuActionRequest = match serde_json::from_str(&body) {
956            Ok(r) => r,
957            Err(e) => {
958                let _ = request.respond(tiny_http::Response::from_string(format!(
959                    "{{\"error\":\"Invalid JSON: {}\"}}", e
960                ))
961                .with_status_code(400)
962                .with_header(
963                    "Content-Type: application/json".parse::<tiny_http::Header>().unwrap()
964                ));
965                continue;
966            }
967        };
968
969        let json_response = match resolve_backend() {
970            Ok(backend) => {
971                if backend.name() != last_backend {
972                    last_backend = backend.name();
973                    println!("[ComputerUse] Using {} backend", last_backend);
974                }
975                handle_action(parsed, backend.as_ref())
976            }
977            Err(e) => format!("{{\"error\":\"{}\"}}", e.replace('"', "\\\"")),
978        };
979        let _ = request.respond(
980            tiny_http::Response::from_string(json_response)
981                .with_status_code(200)
982                .with_header(
983                    "Content-Type: application/json".parse::<tiny_http::Header>().unwrap()
984                )
985        );
986    }
987}