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//! The [Anthropic Computer Use](https://github.com/anthropics/claude-quickstarts/tree/main/computer-use-demo)
8//! actions on the captured desktop, and the recorder's commands beside them, for whatever
9//! transport a host serves them over.
10//!
11//! [`action`] runs one request body (screenshots, mouse/keyboard injection, scrolling, cursor
12//! position queries) against a [`CuBackend`], resolved per call: the Wayland compositor owned by
13//! this process when one is registered, otherwise the X server named by `DISPLAY` (XTEST
14//! injection on a private connection, no active capture required). [`record_start`],
15//! [`record_stop`], and [`record_status`] drive the built-in MP4 recorder; a recording the
16//! caller names is a file name inside `PIXELFLUX_RECORD_DIR`, the directory the operator gives
17//! callers, and nowhere else.
18//!
19//! Nothing here listens, reads a credential, or knows who called: a caller drives the session
20//! with its owner's full authority, so what serves these entry points answers for who may. The
21//! host's own server does; `server`, behind the `computer-use-server` feature, is the Python
22//! package's.
23
24#[cfg(feature = "computer-use-server")]
25pub mod server;
26
27use std::collections::HashMap;
28use std::io::Cursor;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::mpsc;
31use std::sync::{Arc, Mutex, OnceLock};
32use std::thread;
33use std::time::{Duration, Instant};
34
35use smithay::input::keyboard::xkb;
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD as BASE64;
39use image::{ImageBuffer, ImageFormat, Rgba};
40use serde::Deserialize;
41
42use crate::ThreadCommand;
43use crate::wayland::keymap::keysym_for_char;
44
45fn clamp<T: PartialOrd>(v: T, lo: T, hi: T) -> T {
46    if v < lo {
47        lo
48    } else if v > hi {
49        hi
50    } else {
51        v
52    }
53}
54
55/// Turn a raw framebuffer into a PNG the Computer Use agent can actually look at.
56///
57/// The `screenshot` and `zoom` crop paths have to hand the agent an image, and the API carries it
58/// as base64 PNG, so this encodes a flat RGBA buffer (with its dimensions) through the `image`
59/// crate and returns the PNG bytes for that response payload.
60pub fn encode_png_rgba(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
61    let img = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, data)
62        .ok_or("Failed to create image buffer")?;
63    let mut png = Vec::new();
64    img.write_to(&mut Cursor::new(&mut png), ImageFormat::Png)
65        .map_err(|e| format!("PNG encode error: {}", e))?;
66    Ok(png)
67}
68
69pub(crate) const MAX_SCREENSHOT_REQUESTS: usize = 4;
70const MAX_SCREENSHOT_REQUESTS_PER_DISPLAY: usize = 2;
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum ScreenshotPixelFormat {
74    Rgba,
75    Bgra,
76    Rgbx,
77    Bgrx,
78}
79
80/// The pixel buffers screenshots hand to their callers, kept when a caller is done with one so
81/// the next screenshot neither allocates nor faults in a whole frame (33 MB at 4K) on the
82/// compositor. Two are kept, as many as one display admits at once.
83pub(crate) struct ScreenshotBuffers {
84    kept: Mutex<Vec<Vec<u8>>>,
85}
86
87const SCREENSHOT_BUFFERS_KEPT: usize = MAX_SCREENSHOT_REQUESTS_PER_DISPLAY;
88
89static SCREENSHOT_BUFFERS: ScreenshotBuffers = ScreenshotBuffers::new();
90
91impl ScreenshotBuffers {
92    const fn new() -> Self {
93        Self {
94            kept: Mutex::new(Vec::new()),
95        }
96    }
97
98    fn take(&'static self, len: usize) -> ScreenshotBuffer {
99        let mut pixels = self.kept.lock().unwrap().pop().unwrap_or_default();
100        // Only bytes past a kept buffer's length are written: every caller fills it whole.
101        if pixels.len() > len {
102            pixels.truncate(len);
103        } else {
104            pixels.resize(len, 0);
105        }
106        ScreenshotBuffer { pixels, home: self }
107    }
108
109    fn keep(&self, pixels: Vec<u8>) {
110        let mut kept = self.kept.lock().unwrap();
111        if kept.len() < SCREENSHOT_BUFFERS_KEPT && pixels.capacity() > 0 {
112            kept.push(pixels);
113        }
114    }
115}
116
117/// One screenshot's pixels, returned to the kept buffers when dropped.
118pub struct ScreenshotBuffer {
119    pixels: Vec<u8>,
120    home: &'static ScreenshotBuffers,
121}
122
123impl ScreenshotBuffer {
124    /// A buffer of `len` bytes for the caller to fill whole.
125    pub(crate) fn with_len(len: usize) -> Self {
126        SCREENSHOT_BUFFERS.take(len)
127    }
128
129    /// A buffer holding a copy of `pixels`.
130    pub(crate) fn copy_of(pixels: &[u8]) -> Self {
131        let mut buffer = Self::with_len(pixels.len());
132        buffer.copy_from_slice(pixels);
133        buffer
134    }
135}
136
137impl From<Vec<u8>> for ScreenshotBuffer {
138    fn from(pixels: Vec<u8>) -> Self {
139        Self {
140            pixels,
141            home: &SCREENSHOT_BUFFERS,
142        }
143    }
144}
145
146impl std::ops::Deref for ScreenshotBuffer {
147    type Target = [u8];
148    fn deref(&self) -> &[u8] {
149        &self.pixels
150    }
151}
152
153impl std::ops::DerefMut for ScreenshotBuffer {
154    fn deref_mut(&mut self) -> &mut [u8] {
155        &mut self.pixels
156    }
157}
158
159impl Drop for ScreenshotBuffer {
160    fn drop(&mut self) {
161        self.home.keep(std::mem::take(&mut self.pixels));
162    }
163}
164
165/// A pixel-pack buffer the compositor's GL readback lands in, mapped persistently so the
166/// screenshot's caller copies the pixels out on its own thread. The mapping is valid while
167/// `mapped` is set; the compositor clears it, under the lock a copy holds, before it deletes the
168/// buffer or its GL context goes away.
169pub(crate) struct ReadbackSlot {
170    pub(crate) id: u32,
171    pub(crate) size: usize,
172    ptr: usize,
173    mapped: Mutex<bool>,
174}
175
176impl ReadbackSlot {
177    /// # Safety
178    /// `ptr` must address `size` readable bytes until [`ReadbackSlot::unmap`] returns.
179    pub(crate) unsafe fn new(id: u32, ptr: *const u8, size: usize) -> Self {
180        Self {
181            id,
182            size,
183            ptr: ptr as usize,
184            mapped: Mutex::new(true),
185        }
186    }
187
188    /// Whether a screenshot still holds the slot, which the compositor then neither reuses nor
189    /// deletes.
190    pub(crate) fn in_use(self: &Arc<Self>) -> bool {
191        Arc::strong_count(self) > 1
192    }
193
194    /// Forget the mapping, waiting out a copy under way.
195    pub(crate) fn unmap(&self) {
196        *self.mapped.lock().unwrap() = false;
197    }
198
199    pub(crate) fn copy_out(&self, len: usize) -> Result<ScreenshotBuffer, String> {
200        let mapped = self.mapped.lock().unwrap();
201        if !*mapped {
202            return Err("Screenshot compositor stopped".to_string());
203        }
204        if len > self.size {
205            return Err("Screenshot readback produced incomplete pixels".to_string());
206        }
207        // SAFETY: the mapping stays readable while `mapped` is held set (see `new`).
208        let pixels = unsafe { std::slice::from_raw_parts(self.ptr as *const u8, len) };
209        Ok(ScreenshotBuffer::copy_of(pixels))
210    }
211}
212
213/// A screenshot the GPU is still reading back into a [`ReadbackSlot`].
214pub struct GpuReadback {
215    pub(crate) slot: Arc<ReadbackSlot>,
216    pub(crate) sync: smithay::backend::renderer::sync::SyncPoint,
217    pub(crate) len: usize,
218}
219
220impl GpuReadback {
221    /// Wait for the readback until `deadline`, then copy the pixels out of the slot.
222    fn resolve(self, deadline: Instant) -> Result<ScreenshotBuffer, String> {
223        while !self.sync.is_reached() {
224            if Instant::now() >= deadline {
225                return Err("Screenshot timed out".to_string());
226            }
227            thread::sleep(Duration::from_micros(200));
228        }
229        self.slot.copy_out(self.len)
230    }
231}
232
233/// A screenshot's pixels: in the caller's buffer, or still on their way from the GPU.
234pub enum ScreenshotPixels {
235    Ready(ScreenshotBuffer),
236    Gpu(GpuReadback),
237}
238
239impl From<Vec<u8>> for ScreenshotPixels {
240    fn from(pixels: Vec<u8>) -> Self {
241        Self::Ready(pixels.into())
242    }
243}
244
245/// An owned copy of one rendered output, independent of the streaming buffer pool.
246/// Channel conversion and PNG compression run in the requesting thread.
247pub struct ScreenshotFrame {
248    pub pixels: ScreenshotPixels,
249    pub width: u32,
250    pub height: u32,
251    pub format: ScreenshotPixelFormat,
252}
253
254impl ScreenshotFrame {
255    /// The frame with its pixels in the caller's buffer, a GPU readback waited for until
256    /// `deadline` and copied out.
257    fn resolve(self, deadline: Instant) -> Result<Self, String> {
258        let pixels = match self.pixels {
259            ScreenshotPixels::Ready(pixels) => pixels,
260            ScreenshotPixels::Gpu(readback) => readback.resolve(deadline)?,
261        };
262        Ok(Self {
263            pixels: ScreenshotPixels::Ready(pixels),
264            ..self
265        })
266    }
267
268    fn encode_png(self) -> Result<Vec<u8>, String> {
269        let ScreenshotPixels::Ready(mut pixels) = self.pixels else {
270            return Err("Screenshot pixels are still being read back".to_string());
271        };
272        let expected = (self.width as usize)
273            .checked_mul(self.height as usize)
274            .and_then(|n| n.checked_mul(4));
275        if self.width == 0 || self.height == 0 || expected != Some(pixels.len()) {
276            return Err("Screenshot render produced an invalid framebuffer".to_string());
277        }
278        let swap = matches!(
279            self.format,
280            ScreenshotPixelFormat::Bgra | ScreenshotPixelFormat::Bgrx
281        );
282        let opaque = matches!(
283            self.format,
284            ScreenshotPixelFormat::Rgbx | ScreenshotPixelFormat::Bgrx
285        );
286        if swap || opaque {
287            for px in pixels.as_chunks_mut::<4>().0 {
288                if swap {
289                    px.swap(0, 2);
290                }
291                if opaque {
292                    px[3] = 255;
293                }
294            }
295        }
296        encode_png_rgba(&pixels, self.width, self.height)
297    }
298}
299
300/// Bound raw snapshots and PNG work together, including callers still compressing a reply.
301/// Two requests per output allow a readback while its previous response is compressed;
302/// four across all outputs bound concurrent frame copies without allocating worker threads.
303#[derive(Default)]
304struct ScreenshotAdmission {
305    counts: Mutex<HashMap<u32, usize>>,
306}
307
308impl ScreenshotAdmission {
309    fn acquire(&self, display: u32) -> Result<ScreenshotPermit<'_>, String> {
310        let mut counts = self.counts.lock().unwrap();
311        if counts.values().sum::<usize>() >= MAX_SCREENSHOT_REQUESTS
312            || counts.get(&display).copied().unwrap_or(0) >= MAX_SCREENSHOT_REQUESTS_PER_DISPLAY
313        {
314            return Err("Screenshot busy".to_string());
315        }
316        *counts.entry(display).or_default() += 1;
317        Ok(ScreenshotPermit {
318            admission: self,
319            display,
320            canceled: Arc::new(AtomicBool::new(false)),
321        })
322    }
323}
324
325struct ScreenshotPermit<'a> {
326    admission: &'a ScreenshotAdmission,
327    display: u32,
328    canceled: Arc<AtomicBool>,
329}
330
331impl Drop for ScreenshotPermit<'_> {
332    fn drop(&mut self) {
333        self.canceled.store(true, Ordering::Release);
334        let mut counts = self.admission.counts.lock().unwrap();
335        if let Some(count) = counts.get_mut(&self.display) {
336            *count -= 1;
337            if *count == 0 {
338                counts.remove(&self.display);
339            }
340        }
341    }
342}
343
344static SCREENSHOT_ADMISSION: OnceLock<ScreenshotAdmission> = OnceLock::new();
345
346fn scancode_for_keyname(name: &str) -> Option<u32> {
347    Some(match name.to_lowercase().as_str() {
348        "return" | "enter" => 36,
349        "tab" => 23,
350        "escape" | "esc" => 9,
351        "backspace" => 22,
352        "delete" => 119,
353        "insert" => 118,
354        "home" => 110,
355        "end" => 115,
356        "pageup" => 112,
357        "pagedown" => 117,
358        "up" => 111,
359        "down" => 116,
360        "left" => 113,
361        "right" => 114,
362        "space" => 65,
363        "capslock" => 66,
364        "numlock" => 77,
365        "scrolllock" => 78,
366        "printscreen" | "sysrq" | "print" => 107,
367        "pause" | "break" => 127,
368        "f1" => 67,
369        "f2" => 68,
370        "f3" => 69,
371        "f4" => 70,
372        "f5" => 71,
373        "f6" => 72,
374        "f7" => 73,
375        "f8" => 74,
376        "f9" => 75,
377        "f10" => 76,
378        "f11" => 95,
379        "f12" => 96,
380        "f13" => 191,
381        "f14" => 192,
382        "f15" => 193,
383        "f16" => 194,
384        "f17" => 195,
385        "f18" => 196,
386        "f19" => 197,
387        "f20" => 198,
388        "f21" => 199,
389        "f22" => 200,
390        "f23" => 201,
391        "f24" => 202,
392        "ctrl" | "lctrl" | "leftctrl" => 37,
393        "rctrl" | "rightctrl" => 105,
394        "shift" | "lshift" | "leftshift" => 50,
395        "rshift" | "rightshift" => 62,
396        "alt" | "lalt" | "leftalt" => 64,
397        "ralt" | "rightalt" => 108,
398        "super" | "meta" | "lsuper" | "leftmeta" | "leftsuper" | "windows" | "leftwindows" => 133,
399        "rsuper" | "rightmeta" | "rightsuper" | "rightwindows" => 134,
400        "menu" | "compose" => 135,
401        "kp_0" | "kp0" => 90,
402        "kp_1" | "kp1" => 87,
403        "kp_2" | "kp2" => 88,
404        "kp_3" | "kp3" => 89,
405        "kp_4" | "kp4" => 83,
406        "kp_5" | "kp5" => 84,
407        "kp_6" | "kp6" => 85,
408        "kp_7" | "kp7" => 79,
409        "kp_8" | "kp8" => 80,
410        "kp_9" | "kp9" => 81,
411        "kp_decimal" | "kp_dot" => 91,
412        "kp_divide" | "kp_slash" => 106,
413        "kp_multiply" | "kp_asterisk" => 63,
414        "kp_subtract" | "kp_minus" => 82,
415        "kp_add" | "kp_plus" => 86,
416        "kp_enter" => 104,
417        _ => return None,
418    })
419}
420
421/// Keysym for one component of an xdotool-style key spec that is not a named key: a single
422/// literal character (`a`, `7`, `-`, `#`) via the Unicode mapping, or a keysym NAME
423/// (`minus`, `bracketleft`, `udiaeresis`, …) via xkb's name lookup (case-tolerant fallback
424/// so `Minus` still resolves). Returns 0 when the spec names no keysym.
425fn keysym_for_key_spec(name: &str) -> u32 {
426    let mut chars = name.chars();
427    if let (Some(c), None) = (chars.next(), chars.next()) {
428        return keysym_for_char(c);
429    }
430    let sym = xkb::keysym_from_name(name, xkb::KEYSYM_NO_FLAGS).raw();
431    if sym != 0 {
432        return sym;
433    }
434    xkb::keysym_from_name(name, xkb::KEYSYM_CASE_INSENSITIVE).raw()
435}
436
437fn is_modifier(scancode: u32) -> bool {
438    matches!(scancode, 37 | 105 | 50 | 62 | 64 | 108 | 133 | 134)
439}
440
441/// Semantic mouse-button vocabulary of the action layer; each backend maps it to its native
442/// codes (evdev `BTN_` on Wayland, X core buttons 1/2/3 on X11).
443#[derive(Clone, Copy)]
444pub enum CuButton {
445    Left,
446    Right,
447    Middle,
448}
449
450/// The desktop-side primitives one Computer Use action decomposes into. Keycodes are X/xkb
451/// keycodes (evdev + 8) — the numbering both the smithay seat and XTEST consume — and
452/// coordinates are framebuffer/root pixels, already clamped by the action layer.
453pub trait CuBackend {
454    fn name(&self) -> &'static str;
455    fn fb_size(&self) -> Result<(i32, i32), String>;
456    /// One display's framebuffer size; 0 = the primary. Unknown ids are an error. The
457    /// X11 backend serves only the root (display 0); Wayland resolves any live output id.
458    fn display_fb_size(&self, display: u32) -> Result<(i32, i32), String> {
459        if display == 0 {
460            self.fb_size()
461        } else {
462            Err(format!("Unknown display: {display}"))
463        }
464    }
465    fn key(&self, scancode: u32, pressed: bool);
466    fn mouse_move(&self, x: f64, y: f64);
467    fn button(&self, btn: CuButton, pressed: bool);
468    fn scroll(&self, dx: f64, dy: f64);
469    /// PNG of one display's framebuffer; 0 = the primary. Unknown ids are an error.
470    fn screenshot_png(&self, display: u32) -> Result<Vec<u8>, String>;
471    fn cursor_pos(&self) -> Result<(f64, f64), String>;
472    /// Run `seq` with every keysym in `keysyms` (which `resolve_keysyms` could not place)
473    /// made temporarily typeable, when the backend can arrange that; `seq` receives
474    /// keysym -> keycode for the transient bindings (empty when none could be arranged,
475    /// in which case those keysyms simply stay untypeable). Wayland's `resolve_keysyms`
476    /// already overlay-binds anything the base keymap lacks, so the default runs `seq`
477    /// with no bindings; the X11 backend overrides this with a grab-atomic spare-keycode
478    /// remap.
479    fn with_transient_keysyms(&self, keysyms: &[u32], seq: &mut dyn FnMut(&HashMap<u32, u32>)) {
480        let _ = keysyms;
481        seq(&HashMap::new());
482    }
483    /// Resolve keysyms against the backend's ACTIVE keymap: one `(keycode, shift level)`
484    /// per input keysym, `(0, 0)` where the keymap cannot produce it. Level bit 0 = Shift,
485    /// bit 1 = AltGr — the order xkb two/four-level key types use. Wayland resolves through
486    /// the compositor's keymap policy (overlay-binding what the base lacks); X11 through
487    /// the server's own `GetKeyboardMapping`.
488    fn resolve_keysyms(&self, keysyms: &[u32]) -> Vec<(u32, u32)>;
489    /// Keycode synthesized around AltGr-level hits (level bit 1). The default is the
490    /// pc105 right-Alt position the compositor's seat keymap binds to `ISO_Level3_Shift`;
491    /// the X11 backend overrides it with the level-3 modifier key found in the server's
492    /// keymap (0 = none, in which case `resolve_keysyms` reports no AltGr levels).
493    fn altgr_keycode(&self) -> u32 {
494        KC_ALTGR
495    }
496}
497
498const BTN_LEFT: u32 = 0x110;
499const BTN_RIGHT: u32 = 0x111;
500const BTN_MIDDLE: u32 = 0x112;
501
502/// Reply deadline for compositor round-trips (screenshot readback, cursor position, fb size).
503/// Bounded so a wedged render path turns into a JSON error instead of holding the caller,
504/// and every request waiting behind it, forever.
505const REPLY_TIMEOUT: Duration = Duration::from_secs(5);
506
507/// Wayland implementation: every primitive is a `ThreadCommand` on the compositor's calloop
508/// channel, so injection and readback serialize naturally with rendering and encoding.
509#[derive(Clone)]
510pub struct CuWaylandBackend {
511    tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
512    wake_tx: smithay::reexports::calloop::channel::Sender<()>,
513}
514
515impl CuBackend for CuWaylandBackend {
516    fn name(&self) -> &'static str {
517        "wayland"
518    }
519
520    fn fb_size(&self) -> Result<(i32, i32), String> {
521        self.display_fb_size(0)
522    }
523
524    fn display_fb_size(&self, display: u32) -> Result<(i32, i32), String> {
525        let (resp_tx, resp_rx) = mpsc::channel();
526        self.tx
527            .send(ThreadCommand::CuGetInfo {
528                display_id: display,
529                resp: resp_tx,
530            })
531            .map_err(|_| "Failed to request compositor info".to_string())?;
532        let (w, h, _) = resp_rx
533            .recv_timeout(REPLY_TIMEOUT)
534            .map_err(|_| "Compositor info request failed".to_string())?;
535        // CuGetInfo reports zeros for output ids that don't exist (and for an output
536        // with no mode, which live outputs always have).
537        if w <= 0 || h <= 0 {
538            return Err(format!("Unknown display: {display}"));
539        }
540        Ok((w, h))
541    }
542
543    fn key(&self, scancode: u32, pressed: bool) {
544        let _ = self.tx.send(ThreadCommand::KeyboardKey {
545            scancode,
546            state: if pressed { 1 } else { 0 },
547        });
548    }
549
550    fn mouse_move(&self, x: f64, y: f64) {
551        let _ = self
552            .tx
553            .send(ThreadCommand::PointerMotion { x, y, motion: 0 });
554    }
555
556    fn button(&self, btn: CuButton, pressed: bool) {
557        let btn = match btn {
558            CuButton::Left => BTN_LEFT,
559            CuButton::Right => BTN_RIGHT,
560            CuButton::Middle => BTN_MIDDLE,
561        };
562        let _ = self.tx.send(ThreadCommand::PointerButton {
563            btn,
564            state: if pressed { 1 } else { 0 },
565        });
566    }
567
568    fn scroll(&self, dx: f64, dy: f64) {
569        let _ = self.tx.send(ThreadCommand::PointerAxis { x: dx, y: dy });
570    }
571
572    fn screenshot_png(&self, display: u32) -> Result<Vec<u8>, String> {
573        let permit = SCREENSHOT_ADMISSION
574            .get_or_init(ScreenshotAdmission::default)
575            .acquire(display)?;
576        let deadline = Instant::now() + REPLY_TIMEOUT;
577        let (resp_tx, resp_rx) = mpsc::channel();
578        self.tx
579            .send(ThreadCommand::CuScreenshot {
580                display_id: display,
581                resp: resp_tx,
582                canceled: Arc::clone(&permit.canceled),
583                deadline,
584            })
585            .map_err(|_| "Failed to request screenshot".to_string())?;
586        let _ = self.wake_tx.send(());
587        let frame = resp_rx
588            .recv_timeout(deadline.saturating_duration_since(Instant::now()))
589            .map_err(|error| match error {
590                mpsc::RecvTimeoutError::Timeout => "Screenshot timed out".to_string(),
591                mpsc::RecvTimeoutError::Disconnected => {
592                    "Screenshot compositor disconnected".to_string()
593                }
594            })??;
595        let frame = frame.resolve(deadline)?;
596        if Instant::now() >= deadline {
597            return Err("Screenshot timed out".to_string());
598        }
599        frame.encode_png()
600    }
601
602    fn cursor_pos(&self) -> Result<(f64, f64), String> {
603        let (resp_tx, resp_rx) = mpsc::channel();
604        self.tx
605            .send(ThreadCommand::CuCursorPosition { resp: resp_tx })
606            .map_err(|_| "Failed to request cursor position".to_string())?;
607        resp_rx
608            .recv_timeout(REPLY_TIMEOUT)
609            .map_err(|_| "Cursor position failed".to_string())
610    }
611
612    fn resolve_keysyms(&self, keysyms: &[u32]) -> Vec<(u32, u32)> {
613        let (resp_tx, resp_rx) = mpsc::channel();
614        if self
615            .tx
616            .send(ThreadCommand::BindKeysyms {
617                keysyms: keysyms.to_vec(),
618                reply: resp_tx,
619            })
620            .is_err()
621        {
622            return vec![(0, 0); keysyms.len()];
623        }
624        resp_rx
625            .recv_timeout(REPLY_TIMEOUT)
626            .unwrap_or_else(|_| vec![(0, 0); keysyms.len()])
627    }
628}
629
630/// Distinct characters a `type` request binds and types at a time: each run's binds then fit
631/// the seat's text spares (`keymap::TEXT_SPARE_KEYCODES`; 9 free on a Japanese layout, 11 on
632/// most), on which every client types, where a bind past them lands above 255 and Chromium
633/// drops it.
634const TYPE_RUN_KEYSYMS: usize = 8;
635
636/// `syms` cut into consecutive runs of at most `max` distinct keysyms.
637fn distinct_runs(syms: &[u32], max: usize) -> Vec<&[u32]> {
638    let mut runs = Vec::new();
639    let (mut start, mut seen) = (0, std::collections::HashSet::new());
640    for (i, sym) in syms.iter().enumerate() {
641        if !seen.contains(sym) && seen.len() == max {
642            runs.push(&syms[start..i]);
643            start = i;
644            seen.clear();
645        }
646        seen.insert(*sym);
647    }
648    if start < syms.len() {
649        runs.push(&syms[start..]);
650    }
651    runs
652}
653
654/// Per-request literal-key resolver: batches keysym lookups against the backend's active
655/// keymap and caches them for the request's burst of key events (a fresh backend — and thus
656/// a fresh cache — is resolved per request, so a runtime layout switch is picked up by
657/// the next request).
658struct KeyResolver<'a> {
659    backend: &'a dyn CuBackend,
660    cache: HashMap<u32, (u32, u32)>,
661}
662
663impl<'a> KeyResolver<'a> {
664    fn new(backend: &'a dyn CuBackend) -> Self {
665        Self {
666            backend,
667            cache: HashMap::new(),
668        }
669    }
670
671    /// Resolve a batch up front so a `type` action costs one backend round trip.
672    fn prefetch(&mut self, keysyms: &[u32]) {
673        let missing: Vec<u32> = {
674            let mut seen = std::collections::HashSet::new();
675            keysyms
676                .iter()
677                .copied()
678                .filter(|&s| s != 0 && !self.cache.contains_key(&s) && seen.insert(s))
679                .collect()
680        };
681        if missing.is_empty() {
682            return;
683        }
684        let out = self.backend.resolve_keysyms(&missing);
685        for (sym, hit) in missing.into_iter().zip(out) {
686            self.cache.insert(sym, hit);
687        }
688    }
689
690    /// `(keycode, shift level)` for `keysym`, or `None` when the active keymap cannot
691    /// produce it.
692    fn resolve(&mut self, keysym: u32) -> Option<(u32, u32)> {
693        if keysym == 0 {
694            return None;
695        }
696        self.prefetch(&[keysym]);
697        let hit = self.cache.get(&keysym).copied().unwrap_or((0, 0));
698        (hit.0 != 0).then_some(hit)
699    }
700}
701
702/// Shift and default AltGr (pc105 right Alt / ISO_Level3_Shift) keycodes, held to reach a
703/// resolved key's shift level: bit 0 = Shift, bit 1 = AltGr.
704const KC_SHIFT: u32 = 50;
705const KC_ALTGR: u32 = 108;
706
707/// The level modifiers `level` requires beyond whatever is already held (`held_mask` uses
708/// the same bit layout), in press order. `altgr` is the backend's AltGr keycode
709/// ([`CuBackend::altgr_keycode`]).
710fn level_modifiers(level: u32, held_mask: u32, altgr: u32) -> Vec<u32> {
711    let mut out = Vec::new();
712    if level & 1 != 0 && held_mask & 1 == 0 {
713        out.push(KC_SHIFT);
714    }
715    if level & 2 != 0 && held_mask & 2 == 0 && altgr != 0 {
716        out.push(altgr);
717    }
718    out
719}
720
721#[derive(Deserialize)]
722struct CuActionRequest {
723    action: String,
724    coordinate: Option<[f64; 2]>,
725    start_coordinate: Option<[f64; 2]>,
726    text: Option<String>,
727    key: Option<String>,
728    scroll_direction: Option<String>,
729    scroll_amount: Option<i32>,
730    duration: Option<f64>,
731    region: Option<[f64; 4]>,
732    /// Display id for `screenshot` / `zoom` (default 0, the primary; [`record_start`] uses
733    /// its own `display` field with the same meaning). Unknown ids get an error reply.
734    display: Option<u32>,
735}
736
737fn ok_json() -> String {
738    "{\"result\":\"ok\"}".to_string()
739}
740
741/// The reply a request that could not run is answered with.
742fn error_json(error: &str) -> String {
743    serde_json::json!({ "error": error }).to_string()
744}
745
746fn handle_action(req: CuActionRequest, backend: &dyn CuBackend) -> String {
747    let result = handle_action_inner(req, backend);
748    match result {
749        Ok(response) => response,
750        Err(e) => error_json(&e),
751    }
752}
753
754/// Turn one parsed Computer Use request into a real action on the captured desktop — the
755/// bridge that lets an external AI agent see and drive the session.
756///
757/// Every action ultimately becomes a backend input primitive or a framebuffer read, so this is
758/// where the API's vocabulary meets the running desktop. Coordinates are clamped to the current
759/// framebuffer so a mistaken agent click cannot address off-screen pixels; keyboard and pointer
760/// actions translate into input events; and actions that must look at the screen (`screenshot`,
761/// `zoom`) request a fresh capture first, so the agent reasons about the current frame rather than
762/// a stale one. Returns the JSON response, or an error string explaining why the action could not
763/// run.
764fn handle_action_inner(req: CuActionRequest, b: &dyn CuBackend) -> Result<String, String> {
765    let sleep_ms = |ms: u64| thread::sleep(Duration::from_millis(ms));
766
767    let (fb_w, fb_h) = b.fb_size()?;
768
769    let handle_coord = |coord: [f64; 2]| -> (f64, f64) {
770        (
771            clamp(coord[0], 0.0, f64::from(fb_w - 1)),
772            clamp(coord[1], 0.0, f64::from(fb_h - 1)),
773        )
774    };
775
776    let handle_modifier = |mod_name: &str| -> Option<u32> {
777        let sc = scancode_for_keyname(mod_name)?;
778        if is_modifier(sc) { Some(sc) } else { None }
779    };
780
781    match req.action.as_str() {
782        "screenshot" => {
783            let png = b.screenshot_png(req.display.unwrap_or(0))?;
784            let b64 = BASE64.encode(&png);
785            Ok(format!("{{\"data\":\"{}\"}}", b64))
786        }
787
788        "mouse_move" => {
789            let coord = req.coordinate.ok_or("Missing coordinate")?;
790            let (fx, fy) = handle_coord(coord);
791            b.mouse_move(fx, fy);
792            Ok(ok_json())
793        }
794
795        "left_click" | "right_click" | "middle_click" => {
796            let btn = match req.action.as_str() {
797                "left_click" => CuButton::Left,
798                "right_click" => CuButton::Right,
799                _ => CuButton::Middle,
800            };
801            if let Some(coord) = req.coordinate {
802                let (fx, fy) = handle_coord(coord);
803                b.mouse_move(fx, fy);
804                sleep_ms(30);
805            }
806            if let Some(ref mod_name) = req.text
807                && let Some(sc) = handle_modifier(mod_name)
808            {
809                b.key(sc, true);
810                sleep_ms(20);
811            }
812            b.button(btn, true);
813            sleep_ms(20);
814            b.button(btn, false);
815            if let Some(ref mod_name) = req.text
816                && let Some(sc) = handle_modifier(mod_name)
817            {
818                sleep_ms(10);
819                b.key(sc, false);
820            }
821            Ok(ok_json())
822        }
823
824        "double_click" | "triple_click" => {
825            let n = if req.action == "double_click" { 2 } else { 3 };
826            if let Some(coord) = req.coordinate {
827                let (fx, fy) = handle_coord(coord);
828                b.mouse_move(fx, fy);
829                sleep_ms(30);
830            }
831            if let Some(ref mod_name) = req.text
832                && let Some(sc) = handle_modifier(mod_name)
833            {
834                b.key(sc, true);
835                sleep_ms(20);
836            }
837            for _ in 0..n {
838                b.button(CuButton::Left, true);
839                sleep_ms(10);
840                b.button(CuButton::Left, false);
841                sleep_ms(10);
842            }
843            if let Some(ref mod_name) = req.text
844                && let Some(sc) = handle_modifier(mod_name)
845            {
846                sleep_ms(10);
847                b.key(sc, false);
848            }
849            Ok(ok_json())
850        }
851
852        "left_click_drag" => {
853            let start = req.start_coordinate.ok_or("Missing start_coordinate")?;
854            let end = req.coordinate.ok_or("Missing coordinate")?;
855            let (sx, sy) = handle_coord(start);
856            let (ex, ey) = handle_coord(end);
857            b.mouse_move(sx, sy);
858            sleep_ms(30);
859            b.button(CuButton::Left, true);
860            sleep_ms(30);
861            b.mouse_move(ex, ey);
862            sleep_ms(30);
863            b.button(CuButton::Left, false);
864            Ok(ok_json())
865        }
866
867        "left_mouse_down" => {
868            b.button(CuButton::Left, true);
869            Ok(ok_json())
870        }
871
872        "left_mouse_up" => {
873            b.button(CuButton::Left, false);
874            Ok(ok_json())
875        }
876
877        "type" => {
878            let text = req.text.as_deref().ok_or("Missing text")?;
879            // A nested app compositor owns the apps on its own socket; type there as
880            // a virtual-keyboard client, since keys on pixelflux's own seat carry an
881            // overlay keymap the inner compositor never sees. Falls through to the
882            // local seat if the app socket is unreachable.
883            if b.name() == "wayland"
884                && let Some(sock) = app_wayland_socket_path()
885            {
886                // Failures log once per socket value; every request still
887                // retries, so a compositor that comes back is used again
888                // immediately (and re-arms the logging).
889                static FAILED_SOCK: Mutex<Option<String>> = Mutex::new(None);
890                match crate::wayland::vkclient::type_text_to(&sock, text) {
891                    Ok(()) => {
892                        *FAILED_SOCK.lock().unwrap() = None;
893                        return Ok(ok_json());
894                    }
895                    Err(e) => {
896                        let mut last = FAILED_SOCK.lock().unwrap();
897                        if last.as_deref() != Some(sock.as_str()) {
898                            eprintln!(
899                                "[ComputerUse] app-compositor type via {sock} failed ({e}); using local seat until it is reachable"
900                            );
901                            *last = Some(sock);
902                        }
903                    }
904                }
905            }
906            let all: Vec<u32> = text.chars().map(keysym_for_char).collect();
907            for syms in distinct_runs(&all, TYPE_RUN_KEYSYMS) {
908                let mut resolver = KeyResolver::new(b);
909                resolver.prefetch(syms);
910                // Base+AltGr resolution stays the preferred path; only what the active keymap
911                // cannot reach at all goes through the backend's transient-bind fallback.
912                let mut unresolved: Vec<u32> = Vec::new();
913                for &sym in syms {
914                    if sym != 0 && resolver.resolve(sym).is_none() && !unresolved.contains(&sym) {
915                        unresolved.push(sym);
916                    }
917                }
918                b.with_transient_keysyms(&unresolved, &mut |bound| {
919                    for (i, &sym) in syms.iter().enumerate() {
920                        if i > 0 && i % 50 == 0 {
921                            sleep_ms(20);
922                        }
923                        if let Some((sc, level)) = resolver.resolve(sym) {
924                            let level_mods = level_modifiers(level, 0, b.altgr_keycode());
925                            for &m in &level_mods {
926                                b.key(m, true);
927                                sleep_ms(5);
928                            }
929                            b.key(sc, true);
930                            sleep_ms(10);
931                            b.key(sc, false);
932                            for &m in level_mods.iter().rev() {
933                                b.key(m, false);
934                            }
935                            sleep_ms(8);
936                        } else if let Some(&kc) = bound.get(&sym) {
937                            // Transient binds sit at the plain level: no modifiers needed.
938                            b.key(kc, true);
939                            sleep_ms(10);
940                            b.key(kc, false);
941                            sleep_ms(8);
942                        }
943                    }
944                });
945            }
946            Ok(ok_json())
947        }
948
949        "key" => {
950            let text = req.text.as_deref().ok_or("Missing text")?;
951            let mut resolver = KeyResolver::new(b);
952            let mut mods: Vec<u32> = Vec::new();
953            let mut main_key: Option<(u32, u32)> = None;
954            // Keysym of the last main-key spec the active keymap could not place (cleared
955            // when a later part resolves): typed via the transient-bind fallback.
956            let mut unresolved_sym: Option<u32> = None;
957            for part in text.split('+') {
958                let trimmed = part.trim();
959                if let Some(sc) = scancode_for_keyname(trimmed) {
960                    if is_modifier(sc) {
961                        mods.push(sc);
962                    } else {
963                        main_key = Some((sc, 0));
964                        unresolved_sym = None;
965                    }
966                } else {
967                    let sym = keysym_for_key_spec(trimmed);
968                    if let Some(hit) = resolver.resolve(sym) {
969                        main_key = Some(hit);
970                        unresolved_sym = None;
971                    } else if sym != 0 {
972                        main_key = None;
973                        unresolved_sym = Some(sym);
974                    }
975                }
976            }
977            let unresolved: Vec<u32> = unresolved_sym.into_iter().collect();
978            b.with_transient_keysyms(&unresolved, &mut |bound| {
979                let main_key = main_key.or_else(|| {
980                    // Transient binds sit at the plain level: no modifiers needed.
981                    unresolved_sym
982                        .and_then(|s| bound.get(&s))
983                        .map(|&kc| (kc, 0))
984                });
985                for &sc in &mods {
986                    b.key(sc, true);
987                    sleep_ms(10);
988                }
989                if let Some((sc, level)) = main_key {
990                    // Modifiers reaching the key's shift level are added unless the spec
991                    // already asked for them (Shift as 50/62, AltGr as 108 or the backend's
992                    // resolved level-3 keycode).
993                    let altgr = b.altgr_keycode();
994                    let held = mods.iter().fold(0u32, |m, &sc| match sc {
995                        50 | 62 => m | 1,
996                        sc if sc == KC_ALTGR || sc == altgr => m | 2,
997                        _ => m,
998                    });
999                    let level_mods = level_modifiers(level, held, altgr);
1000                    for &m in &level_mods {
1001                        b.key(m, true);
1002                        sleep_ms(10);
1003                    }
1004                    b.key(sc, true);
1005                    sleep_ms(30);
1006                    b.key(sc, false);
1007                    for &m in level_mods.iter().rev() {
1008                        sleep_ms(10);
1009                        b.key(m, false);
1010                    }
1011                } else if let Some(&last_mod) = mods.last() {
1012                    b.key(last_mod, true);
1013                    sleep_ms(30);
1014                    b.key(last_mod, false);
1015                }
1016                for &sc in mods.iter().rev() {
1017                    sleep_ms(10);
1018                    b.key(sc, false);
1019                }
1020            });
1021            Ok(ok_json())
1022        }
1023
1024        "hold_key" => {
1025            let text = req.text.as_deref().ok_or("Missing text")?;
1026            let duration = req.duration.ok_or("Missing duration")?;
1027            let duration = duration.min(100.0);
1028            let trimmed = text.trim();
1029            let mut resolver = KeyResolver::new(b);
1030            let hit = match scancode_for_keyname(trimmed) {
1031                Some(sc) => Some((sc, 0)),
1032                None => resolver.resolve(keysym_for_key_spec(trimmed)),
1033            };
1034            if let Some((sc, level)) = hit {
1035                let level_mods = level_modifiers(level, 0, b.altgr_keycode());
1036                for &m in &level_mods {
1037                    b.key(m, true);
1038                    sleep_ms(10);
1039                }
1040                b.key(sc, true);
1041                sleep_ms((duration * 1000.0) as u64);
1042                b.key(sc, false);
1043                for &m in level_mods.iter().rev() {
1044                    sleep_ms(10);
1045                    b.key(m, false);
1046                }
1047            }
1048            Ok(ok_json())
1049        }
1050
1051        "scroll" => {
1052            let dir = req
1053                .scroll_direction
1054                .as_deref()
1055                .ok_or("Missing scroll_direction")?;
1056            let amount = f64::from(req.scroll_amount.unwrap_or(1).max(0));
1057            if let Some(coord) = req.coordinate {
1058                let (fx, fy) = handle_coord(coord);
1059                b.mouse_move(fx, fy);
1060                sleep_ms(30);
1061            }
1062            if let Some(ref mod_name) = req.text
1063                && let Some(sc) = handle_modifier(mod_name)
1064            {
1065                b.key(sc, true);
1066                sleep_ms(20);
1067            }
1068            let (dx, dy) = match dir {
1069                "up" => (0.0, -amount),
1070                "down" => (0.0, amount),
1071                "left" => (-amount, 0.0),
1072                "right" => (amount, 0.0),
1073                _ => return Err(format!("Invalid scroll_direction: {}", dir)),
1074            };
1075            b.scroll(dx, dy);
1076            sleep_ms(30);
1077            if let Some(ref mod_name) = req.text
1078                && let Some(sc) = handle_modifier(mod_name)
1079            {
1080                sleep_ms(10);
1081                b.key(sc, false);
1082            }
1083            Ok(ok_json())
1084        }
1085
1086        "cursor_position" => {
1087            let (x, y) = b.cursor_pos()?;
1088            Ok(format!(
1089                "{{\"text\":\"X={},Y={}\"}}",
1090                x.round() as i32,
1091                y.round() as i32
1092            ))
1093        }
1094
1095        "wait" => {
1096            let duration = req.duration.unwrap_or(1.0).min(100.0);
1097            sleep_ms((duration * 1000.0) as u64);
1098            Ok(ok_json())
1099        }
1100
1101        "zoom" => {
1102            let region = req.region.ok_or("Missing region")?;
1103            let display = req.display.unwrap_or(0);
1104            // The region clamps against the TARGET display's own framebuffer, which need
1105            // not match the primary's.
1106            let (dw, dh) = b.display_fb_size(display)?;
1107            let (x0, y0, x1, y1) = (region[0], region[1], region[2], region[3]);
1108            let left = clamp(x0.round() as u32, 0, dw as u32 - 1);
1109            let top = clamp(y0.round() as u32, 0, dh as u32 - 1);
1110            let right = clamp(x1.round() as u32, left + 1, dw as u32);
1111            let bottom = clamp(y1.round() as u32, top + 1, dh as u32);
1112            let crop_w = right - left;
1113            let crop_h = bottom - top;
1114            let png = b.screenshot_png(display)?;
1115            if crop_w > 0
1116                && crop_h > 0
1117                && let Ok(img) = image::load_from_memory(&png)
1118            {
1119                let cropped = img.crop_imm(left, top, crop_w, crop_h);
1120                let mut out = Vec::new();
1121                cropped
1122                    .write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
1123                    .map_err(|e| format!("Crop/encode error: {}", e))?;
1124                let b64 = BASE64.encode(&out);
1125                return Ok(format!("{{\"data\":\"{}\"}}", b64));
1126            }
1127            let b64 = BASE64.encode(&png);
1128            Ok(format!("{{\"data\":\"{}\"}}", b64))
1129        }
1130
1131        _ => Err(format!("Unknown action: {}", req.action)),
1132    }
1133}
1134
1135static WAYLAND_BACKEND: Mutex<Option<CuWaylandBackend>> = Mutex::new(None);
1136
1137/// Body of a [`record_start`] request. All fields are optional; unset ones fall back to the
1138/// `PIXELFLUX_RECORD_*` environment variables and built-in defaults.
1139#[derive(Deserialize, Default)]
1140struct RecordStartRequest {
1141    /// Output file name inside `PIXELFLUX_RECORD_DIR` (default: `$PIXELFLUX_RECORD`, else
1142    /// `pixelflux-record-<unix_ts>.mp4` in that directory or `/tmp`).
1143    path: Option<String>,
1144    /// Wayland output id to record (default 0; ignored on X11).
1145    display: Option<u32>,
1146    /// Capture fps cap for a recorder-owned capture.
1147    fps: Option<f64>,
1148    /// Bitrate override in kbps for a recorder-owned capture.
1149    bitrate_kbps: Option<i32>,
1150    /// Unix socket serving an Ogg Opus stream to record as the audio track.
1151    audio_socket: Option<String>,
1152}
1153
1154/// The path of a recording the caller names `name`: a plain file name inside `dir`, so a request
1155/// can neither leave the operator's directory nor name a file anywhere when none was given.
1156fn named_record_path(dir: Option<&str>, name: &str) -> Result<String, String> {
1157    let dir = dir.ok_or("a recording named by the caller needs PIXELFLUX_RECORD_DIR")?;
1158    let mut parts = std::path::Path::new(name).components();
1159    match (parts.next(), parts.next()) {
1160        (Some(std::path::Component::Normal(file)), None) => Ok(std::path::Path::new(dir)
1161            .join(file)
1162            .to_string_lossy()
1163            .into_owned()),
1164        _ => Err(format!("'{name}' is not a file name")),
1165    }
1166}
1167
1168/// Start a recording: `body` is the request's JSON
1169/// ([`RecordStartRequest`]; empty takes every default), the reply the recorder's status or
1170/// the error that kept it from starting.
1171pub fn record_start(body: &str) -> String {
1172    let req: RecordStartRequest = if body.trim().is_empty() {
1173        RecordStartRequest::default()
1174    } else {
1175        match serde_json::from_str(body) {
1176            Ok(r) => r,
1177            Err(e) => return error_json(&format!("Invalid JSON: {e}")),
1178        }
1179    };
1180    let record_dir = std::env::var("PIXELFLUX_RECORD_DIR")
1181        .ok()
1182        .filter(|d| !d.is_empty());
1183    let path = match req.path {
1184        Some(name) => match named_record_path(record_dir.as_deref(), &name) {
1185            Ok(path) => path,
1186            Err(e) => return error_json(&e),
1187        },
1188        None => std::env::var("PIXELFLUX_RECORD")
1189            .ok()
1190            .filter(|p| !p.is_empty())
1191            .unwrap_or_else(|| {
1192                let ts = std::time::SystemTime::now()
1193                    .duration_since(std::time::UNIX_EPOCH)
1194                    .map(|d| d.as_secs())
1195                    .unwrap_or(0);
1196                // The session's runtime directory is its own; /tmp is shared.
1197                let runtime = std::env::var("XDG_RUNTIME_DIR")
1198                    .ok()
1199                    .filter(|d| !d.is_empty());
1200                let dir = record_dir
1201                    .as_deref()
1202                    .or(runtime.as_deref())
1203                    .unwrap_or("/tmp");
1204                format!("{dir}/pixelflux-record-{ts}.mp4")
1205            }),
1206    };
1207    let mut opts = crate::recorder::RecordOptions::from_env(path);
1208    if let Some(d) = req.display {
1209        opts.display_id = d;
1210    }
1211    if let Some(f) = req.fps {
1212        opts.fps = f;
1213    }
1214    if let Some(b) = req.bitrate_kbps {
1215        opts.bitrate_kbps = b;
1216    }
1217    if let Some(a) = req.audio_socket {
1218        opts.audio_socket = a;
1219    }
1220    match crate::recorder::start(opts) {
1221        Ok(s) => crate::recorder::status_to_json(&s).to_string(),
1222        Err(e) => error_json(&e),
1223    }
1224}
1225
1226/// Stop the recording: its final status with `stopped` set, or the
1227/// error where none runs.
1228pub fn record_stop() -> String {
1229    match crate::recorder::stop() {
1230        Ok(s) => {
1231            let mut v = crate::recorder::status_to_json(&s);
1232            v["stopped"] = serde_json::Value::Bool(true);
1233            v.to_string()
1234        }
1235        Err(e) => error_json(&e),
1236    }
1237}
1238
1239/// The status of the recording under way or of the last one finished;
1240/// `{"active":false}` before any.
1241pub fn record_status() -> String {
1242    match crate::recorder::status() {
1243        Some(s) => crate::recorder::status_to_json(&s).to_string(),
1244        None => serde_json::json!({ "active": false }).to_string(),
1245    }
1246}
1247
1248/// Socket of the compositor apps run under when it is nested under pixelflux
1249/// (labwc/kwin): keys injected into pixelflux's own seat carry an overlay keymap
1250/// the inner compositor never sees, so CU text is typed as a client of this
1251/// socket instead. Set by selkies over the ScreenCapture ABI.
1252static CU_APP_WAYLAND_DISPLAY: Mutex<Option<String>> = Mutex::new(None);
1253
1254/// Set (or clear, with None/empty) the app compositor socket for CU text injection
1255/// and for relative pointer motion into a nested KWin session.
1256pub fn set_app_wayland_display(display: Option<String>) {
1257    *CU_APP_WAYLAND_DISPLAY.lock().unwrap() = display.filter(|s| !s.is_empty());
1258    crate::wayland::ficlient::arm(app_wayland_socket_path());
1259}
1260
1261/// Resolve the app compositor socket PATH for CU typing, or None to type on the
1262/// local seat. The ABI value selkies set wins; a standalone CU (no selkies) falls
1263/// back to PIXELFLUX_APP_WAYLAND_DISPLAY. A value naming pixelflux's own
1264/// compositor means nothing is nested.
1265pub(crate) fn app_wayland_socket_path() -> Option<String> {
1266    let name = CU_APP_WAYLAND_DISPLAY
1267        .lock()
1268        .unwrap()
1269        .clone()
1270        .or_else(|| std::env::var("PIXELFLUX_APP_WAYLAND_DISPLAY").ok())
1271        .filter(|s| !s.is_empty())?;
1272    if crate::wait_socket_name(Duration::from_millis(0)).as_deref() == Some(name.as_str()) {
1273        return None;
1274    }
1275    crate::wayland::wlclient::socket_path(&name)
1276}
1277
1278/// Make the Wayland compositor the preferred CU backend: once a live calloop sender is
1279/// registered, every subsequent request routes to it instead of an X11 connection.
1280pub fn register_wayland_backend(
1281    tx: smithay::reexports::calloop::channel::Sender<ThreadCommand>,
1282    wake_tx: smithay::reexports::calloop::channel::Sender<()>,
1283) {
1284    *WAYLAND_BACKEND.lock().unwrap() = Some(CuWaylandBackend { tx, wake_tx });
1285}
1286
1287/// The registered compositor's command channel, if a Wayland compositor is running in this
1288/// process. The recorder uses it to attach to (or start) a capture without any Python client.
1289pub(crate) fn wayland_command_sender()
1290-> Option<smithay::reexports::calloop::channel::Sender<ThreadCommand>> {
1291    WAYLAND_BACKEND
1292        .lock()
1293        .unwrap()
1294        .as_ref()
1295        .map(|be| be.tx.clone())
1296}
1297
1298/// Pick the backend for one request: the registered Wayland compositor when present,
1299/// otherwise a fresh private connection to `DISPLAY`. The X11 connection is per-request so a
1300/// restarted X server never leaves the CU thread holding a dead connection.
1301pub(crate) fn resolve_backend() -> Result<Box<dyn CuBackend>, String> {
1302    if let Some(backend) = WAYLAND_BACKEND.lock().unwrap().clone() {
1303        return Ok(Box::new(backend));
1304    }
1305    crate::x11::computer_use::CuX11Backend::connect().map(|be| Box::new(be) as Box<dyn CuBackend>)
1306}
1307
1308/// A request body that is no Computer Use action, with the JSON reply saying why; a transport
1309/// answers it as a bad request.
1310#[derive(Debug)]
1311pub struct InvalidRequest(pub String);
1312
1313/// The backend the last action ran on, logged when it changes.
1314static LAST_BACKEND: Mutex<&'static str> = Mutex::new("");
1315
1316/// Run one Computer Use action: `body` is the JSON naming it, the reply
1317/// the JSON it is answered with, an action that failed included. The backend and the
1318/// framebuffer dimensions are resolved on every call, since the compositor can start, the
1319/// stream can resize, or the X server can restart underneath the agent. It checks no token, so
1320/// what calls it answers for who may.
1321pub fn action(body: &str) -> Result<String, InvalidRequest> {
1322    let parsed: CuActionRequest = serde_json::from_str(body)
1323        .map_err(|e| InvalidRequest(error_json(&format!("Invalid JSON: {e}"))))?;
1324    Ok(match resolve_backend() {
1325        Ok(backend) => {
1326            let name = backend.name();
1327            if std::mem::replace(&mut *LAST_BACKEND.lock().unwrap(), name) != name {
1328                println!("[ComputerUse] Using {name} backend");
1329            }
1330            handle_action(parsed, backend.as_ref())
1331        }
1332        Err(e) => error_json(&e),
1333    })
1334}
1335
1336/// [`action`] for a caller inside the process, which reads why a body was refused from the
1337/// reply as it reads any other error.
1338pub fn act(body: &str) -> String {
1339    action(body).unwrap_or_else(|invalid| invalid.0)
1340}
1341
1342#[cfg(test)]
1343mod api_tests {
1344    use super::{InvalidRequest, act, action, record_start, record_status};
1345
1346    /// A body that is no action is told apart from an action that failed, and its reply is
1347    /// JSON whatever the parser quotes in it.
1348    #[test]
1349    fn a_body_that_is_no_action_is_an_invalid_request() {
1350        for body in ["", "{", "[]", r#"{"action":7}"#, r#"{"coordinate":[1,2]}"#] {
1351            let InvalidRequest(reply) = action(body).expect_err(body);
1352            let reply: serde_json::Value = serde_json::from_str(&reply).expect(body);
1353            let error = reply["error"].as_str().expect(body);
1354            assert!(error.starts_with("Invalid JSON: "), "{error}");
1355            assert_eq!(act(body), reply.to_string());
1356        }
1357    }
1358
1359    /// The recorder's endpoints answer without a recording: none is active, and a start that
1360    /// is not JSON is an error in the reply.
1361    #[test]
1362    fn the_recorder_answers_with_no_recording() {
1363        assert_eq!(record_status(), r#"{"active":false}"#);
1364        let reply: serde_json::Value = serde_json::from_str(&record_start("{")).unwrap();
1365        assert!(
1366            reply["error"]
1367                .as_str()
1368                .is_some_and(|e| e.starts_with("Invalid JSON: ")),
1369            "{reply}"
1370        );
1371    }
1372}
1373
1374#[cfg(test)]
1375mod run_tests {
1376    use super::distinct_runs;
1377
1378    /// A text is cut where a run would name one distinct character too many, and a
1379    /// character repeated in a run does not count again.
1380    #[test]
1381    fn a_text_is_typed_in_runs_of_few_distinct_characters() {
1382        let syms = [1, 2, 1, 3, 4, 4, 5, 1, 6];
1383        assert_eq!(
1384            distinct_runs(&syms, 3),
1385            vec![&[1, 2, 1, 3][..], &[4, 4, 5, 1][..], &[6][..]]
1386        );
1387        assert_eq!(distinct_runs(&syms, 9), vec![&syms[..]]);
1388        assert!(distinct_runs(&[], 3).is_empty());
1389    }
1390}
1391
1392#[cfg(test)]
1393mod record_path_tests {
1394    use super::named_record_path;
1395
1396    #[test]
1397    fn a_named_recording_stays_in_the_operators_directory() {
1398        assert_eq!(
1399            named_record_path(Some("/rec"), "take.mp4").unwrap(),
1400            "/rec/take.mp4"
1401        );
1402        for name in ["/etc/passwd", "../x.mp4", "a/b.mp4", "..", ".", ""] {
1403            assert!(named_record_path(Some("/rec"), name).is_err(), "{name:?}");
1404        }
1405        assert!(
1406            named_record_path(None, "take.mp4").is_err(),
1407            "no directory, no named file"
1408        );
1409    }
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::{
1415        GpuReadback, ReadbackSlot, ScreenshotAdmission, ScreenshotBuffers, ScreenshotFrame,
1416        ScreenshotPixelFormat, ScreenshotPixels, encode_png_rgba,
1417    };
1418    use std::io::Cursor;
1419    use std::sync::Arc;
1420    use std::sync::atomic::Ordering;
1421    use std::time::{Duration, Instant};
1422
1423    #[test]
1424    fn screenshot_png_preserves_channels_alpha_and_encoding() {
1425        let mut rgba: Vec<u8> = (0..4 * 17 * 11).map(|n| (n * 37) as u8).collect();
1426        rgba[3] = 0;
1427        rgba[7] = 128;
1428        rgba[11] = 255;
1429        let mut previous_png = Vec::new();
1430        image::ImageBuffer::<image::Rgba<u8>, _>::from_raw(17, 11, rgba.clone())
1431            .unwrap()
1432            .write_to(&mut Cursor::new(&mut previous_png), image::ImageFormat::Png)
1433            .unwrap();
1434        assert_eq!(encode_png_rgba(&rgba, 17, 11).unwrap(), previous_png);
1435        for format in [ScreenshotPixelFormat::Rgba, ScreenshotPixelFormat::Bgra] {
1436            let mut pixels = rgba.clone();
1437            if matches!(format, ScreenshotPixelFormat::Bgra) {
1438                for px in pixels.as_chunks_mut::<4>().0 {
1439                    px.swap(0, 2);
1440                }
1441            }
1442            let png = ScreenshotFrame {
1443                pixels: pixels.into(),
1444                width: 17,
1445                height: 11,
1446                format,
1447            }
1448            .encode_png()
1449            .unwrap();
1450            assert_eq!(png, previous_png);
1451            let decoded = image::load_from_memory(&png).unwrap().into_rgba8();
1452            assert_eq!(decoded.dimensions(), (17, 11));
1453            assert_eq!(decoded.into_raw(), rgba);
1454        }
1455    }
1456
1457    #[test]
1458    fn screenshot_rejects_incomplete_oversized_and_overflowing_frames() {
1459        for (width, height, len) in [
1460            (0, 1, 0),
1461            (1, 0, 0),
1462            (2, 2, 15),
1463            (2, 2, 17),
1464            (u32::MAX, u32::MAX, 0),
1465        ] {
1466            assert!(
1467                ScreenshotFrame {
1468                    pixels: vec![0; len].into(),
1469                    width,
1470                    height,
1471                    format: ScreenshotPixelFormat::Rgba,
1472                }
1473                .encode_png()
1474                .is_err()
1475            );
1476        }
1477    }
1478
1479    #[test]
1480    fn screenshot_opaque_formats_ignore_unused_alpha_bytes() {
1481        for format in [ScreenshotPixelFormat::Rgbx, ScreenshotPixelFormat::Bgrx] {
1482            let mut pixels = vec![11, 22, 33, 0, 44, 55, 66, 128, 77, 88, 99, 255];
1483            if format == ScreenshotPixelFormat::Bgrx {
1484                for px in pixels.as_chunks_mut::<4>().0 {
1485                    px.swap(0, 2);
1486                }
1487            }
1488            let png = ScreenshotFrame {
1489                pixels: pixels.into(),
1490                width: 3,
1491                height: 1,
1492                format,
1493            }
1494            .encode_png()
1495            .unwrap();
1496            let decoded = image::load_from_memory(&png).unwrap().into_rgba8();
1497            assert_eq!(
1498                decoded.into_raw(),
1499                [11, 22, 33, 255, 44, 55, 66, 255, 77, 88, 99, 255]
1500            );
1501        }
1502    }
1503
1504    /// A fence the test signals by hand.
1505    #[derive(Debug, Default)]
1506    struct TestFence(std::sync::atomic::AtomicBool);
1507
1508    impl smithay::backend::renderer::sync::Fence for TestFence {
1509        fn is_signaled(&self) -> bool {
1510            self.0.load(Ordering::Acquire)
1511        }
1512        fn wait(&self) -> Result<(), smithay::backend::renderer::sync::Interrupted> {
1513            Ok(())
1514        }
1515        fn is_exportable(&self) -> bool {
1516            false
1517        }
1518        fn export(&self) -> Option<std::os::fd::OwnedFd> {
1519            None
1520        }
1521    }
1522
1523    #[test]
1524    fn screenshot_readback_slot_copies_while_mapped_and_refuses_after() {
1525        let mapping: Vec<u8> = (0..16).collect();
1526        // SAFETY: `mapping` outlives every copy below.
1527        let slot = Arc::new(unsafe { ReadbackSlot::new(7, mapping.as_ptr(), mapping.len()) });
1528        assert!(!slot.in_use());
1529        let held = Arc::clone(&slot);
1530        assert!(slot.in_use());
1531        assert_eq!(&*held.copy_out(8).unwrap(), &mapping[..8]);
1532        assert!(held.copy_out(17).is_err());
1533        slot.unmap();
1534        assert_eq!(
1535            held.copy_out(8).err().unwrap(),
1536            "Screenshot compositor stopped"
1537        );
1538        drop(held);
1539        assert!(!slot.in_use());
1540    }
1541
1542    #[test]
1543    fn screenshot_gpu_readback_waits_for_its_fence_until_the_deadline() {
1544        let mapping = [9u8; 12];
1545        // SAFETY: `mapping` outlives the readbacks below.
1546        let slot = Arc::new(unsafe { ReadbackSlot::new(1, mapping.as_ptr(), mapping.len()) });
1547        let fence = Arc::new(TestFence::default());
1548        let pending = |fence: &Arc<TestFence>| GpuReadback {
1549            slot: Arc::clone(&slot),
1550            sync: smithay::backend::renderer::sync::SyncPoint::from(TestFenceRef(Arc::clone(
1551                fence,
1552            ))),
1553            len: 12,
1554        };
1555        let late = pending(&fence).resolve(Instant::now() + Duration::from_millis(5));
1556        assert_eq!(late.err().unwrap(), "Screenshot timed out");
1557        assert!(!slot.in_use(), "a timed-out readback releases its slot");
1558        fence.0.store(true, Ordering::Release);
1559        let ready = pending(&fence)
1560            .resolve(Instant::now() + Duration::from_secs(1))
1561            .unwrap();
1562        assert_eq!(&*ready, &mapping[..]);
1563        let frame = ScreenshotFrame {
1564            pixels: ScreenshotPixels::Gpu(pending(&fence)),
1565            width: 3,
1566            height: 1,
1567            format: ScreenshotPixelFormat::Rgba,
1568        };
1569        assert!(
1570            ScreenshotFrame {
1571                pixels: ScreenshotPixels::Gpu(pending(&fence)),
1572                ..frame.resolve(Instant::now()).unwrap()
1573            }
1574            .encode_png()
1575            .is_err(),
1576            "pixels still on their way are never encoded"
1577        );
1578    }
1579
1580    /// Shares a [`TestFence`] with the sync point that takes it.
1581    #[derive(Debug)]
1582    struct TestFenceRef(Arc<TestFence>);
1583
1584    impl smithay::backend::renderer::sync::Fence for TestFenceRef {
1585        fn is_signaled(&self) -> bool {
1586            self.0.is_signaled()
1587        }
1588        fn wait(&self) -> Result<(), smithay::backend::renderer::sync::Interrupted> {
1589            Ok(())
1590        }
1591        fn is_exportable(&self) -> bool {
1592            false
1593        }
1594        fn export(&self) -> Option<std::os::fd::OwnedFd> {
1595            None
1596        }
1597    }
1598
1599    #[test]
1600    fn screenshot_buffers_are_kept_for_the_next_screenshot_and_bounded() {
1601        static BUFFERS: ScreenshotBuffers = ScreenshotBuffers::new();
1602        let first = BUFFERS.take(64);
1603        assert_eq!(first.len(), 64);
1604        let address = first.as_ptr();
1605        drop(first);
1606        // The kept buffer comes back, cut to a smaller frame or grown for a larger one.
1607        let again = BUFFERS.take(16);
1608        assert_eq!((again.len(), again.as_ptr()), (16, address));
1609        drop(again);
1610        let grown = BUFFERS.take(4096);
1611        assert_eq!(grown.len(), 4096);
1612        let held: Vec<_> = (0..4).map(|_| BUFFERS.take(8)).collect();
1613        drop((grown, held));
1614        assert_eq!(BUFFERS.kept.lock().unwrap().len(), 2);
1615    }
1616
1617    #[test]
1618    fn screenshot_capacity_spans_readback_and_encoding_until_release() {
1619        let admission = ScreenshotAdmission::default();
1620        let first = admission.acquire(0).unwrap();
1621        let canceled = Arc::clone(&first.canceled);
1622        let second = admission.acquire(0).unwrap();
1623        assert!(admission.acquire(0).is_err());
1624        let third = admission.acquire(1).unwrap();
1625        let fourth = admission.acquire(2).unwrap();
1626        assert!(admission.acquire(3).is_err());
1627        assert!(!canceled.load(Ordering::Acquire));
1628        drop(first);
1629        assert!(canceled.load(Ordering::Acquire));
1630        let replacement = admission.acquire(3).unwrap();
1631        assert!(admission.acquire(0).is_err());
1632        drop((second, third, fourth, replacement));
1633        assert!(admission.counts.lock().unwrap().is_empty());
1634        assert!(admission.acquire(0).is_ok());
1635    }
1636}