git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

commit2ba68920a3d78b0206428d51a119cb977640a144
parentf18ba7967b
authorLucas Galante <[email protected]>
date2026-07-21 20:35
feat: persistent damage-driven screencopy capture

Replace fork-grim-per-frame with a long-lived wlr-screencopy client
(src/screencopy.rs): one Wayland connection per stream, per-frame
capture_output_region of the focused window's rect, copy_with_damage so
the compositor withholds frames until the output changes, shm buffer
reuse across frames, box-downscale to <=700px and in-process JPEG
(jpeg-encoder). Measured 11 fps active vs 2.5 with grim; idle drops to
output-damage rate (~2 fps — the damage gate is per-output, not
per-region; region-filtered damage is compositor-side follow-up work).
The 20s damage timeout doubles as a keepalive so dead clients are
detected. grim loop kept as the fallback when screencopy init fails.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml        |   5 +
 README.md         |   3 +-
 src/main.rs       |   9 ++
 src/screencopy.rs | 351 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 367 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index 08b4e9e..88b3c16 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,3 +7,8 @@ license = "GPL-3.0"
 
 [dependencies]
 tungstenite = "0.21"
+wayland-client = "0.31"
+wayland-protocols-wlr = { version = "0.3", features = ["client"] }
+memmap2 = "0.9"
+jpeg-encoder = "0.6"
+rustix = { version = "0.38", features = ["event"] }
diff --git a/README.md b/README.md
index d191b95..2765b54 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,8 @@ Home Screen for a fullscreen app feel.
   filtered)
 - ☰ — window switcher: tap a window to focus it
 - 🖥 — window view mode: a live MJPEG stream of the focused window
-  (~2-3 fps, grim region captures at half scale; the stream follows focus).
+  (damage-driven wlr-screencopy: ~11 fps when the window is active, idle
+  throttled to output damage; grim remains as a fallback path).
   Tap to click that spot, long-press to right-click, two-finger drag to
   scroll. Toggle again for the trackpad. Both `/stream` and the one-shot
   `/shot` endpoint are PIN-gated; nothing accumulates on disk.
diff --git a/src/main.rs b/src/main.rs
index 37905ac..ee14411 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,8 @@ use std::net::{TcpListener, TcpStream};
 use std::os::unix::net::UnixStream;
 use std::time::Duration;
 
+mod screencopy;
+
 const INDEX_HTML: &str = include_str!("../index.html");
 const DEFAULT_PORT: u16 = 17017;
 
@@ -278,6 +280,13 @@ fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str) {
     {
         return;
     }
+    // Primary: persistent damage-driven screencopy (idle = zero frames,
+    // active = compositor-paced). Fallback: the original grim loop.
+    let rect = || focused_window().map(|(_, x, y, w, h)| (x as i32, y as i32, w as i32, h as i32));
+    match screencopy::stream_mjpeg(&mut stream, rect) {
+        Ok(()) => return, // client disconnected
+        Err(e) => eprintln!("[cce-remote] screencopy stream failed ({e}), falling back to grim"),
+    }
     let mut win = focused_window();
     let mut tick = 0u32;
     loop {
diff --git a/src/screencopy.rs b/src/screencopy.rs
new file mode 100644
index 0000000..b7295dc
--- /dev/null
+++ b/src/screencopy.rs
@@ -0,0 +1,351 @@
+//! Persistent wlr-screencopy capture — the robust replacement for forking
+//! `grim` per frame.
+//!
+//! One long-lived Wayland connection per stream: each frame is a
+//! `capture_output_region` of the focused window's rect, throttled by
+//! `copy_with_damage` — the compositor withholds the frame until the region
+//! actually changes, so an idle desktop costs nothing and an active one
+//! streams at compositor pace instead of fork/exec pace. Shm buffers are
+//! reused across frames; pixels are box-downscaled and JPEG-encoded
+//! in-process (`jpeg-encoder`).
+//!
+//! Single-output assumption: the region is passed in output-local logical
+//! coordinates, which equals layout coordinates when the (only) output sits
+//! at 0,0 — true for this DE's eDP-1 setup, same assumption grim ran under.
+
+use std::io::Write;
+use std::os::fd::AsFd;
+use std::time::{Duration, Instant};
+
+use wayland_client::globals::{registry_queue_init, GlobalListContents};
+use wayland_client::protocol::{wl_buffer, wl_output, wl_registry, wl_shm, wl_shm_pool};
+use wayland_client::{delegate_noop, Connection, Dispatch, EventQueue, QueueHandle, WEnum};
+use wayland_protocols_wlr::screencopy::v1::client::{
+    zwlr_screencopy_frame_v1::{self, ZwlrScreencopyFrameV1},
+    zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1,
+};
+
+/// Longest edge of the encoded frame, in px — the downscale factor is chosen
+/// per frame to stay under this.
+const MAX_EDGE: u32 = 700;
+const JPEG_QUALITY: u8 = 70;
+
+#[derive(Default)]
+struct CapState {
+    // per-frame handshake state, reset before each request
+    buffer_meta: Option<(u32, u32, u32, wl_shm::Format)>, // w, h, stride, format
+    buffer_done: bool,
+    ready: bool,
+    failed: bool,
+}
+
+impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for CapState {
+    fn event(
+        _: &mut Self,
+        _: &wl_registry::WlRegistry,
+        _: wl_registry::Event,
+        _: &GlobalListContents,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+delegate_noop!(CapState: ignore wl_shm::WlShm);
+delegate_noop!(CapState: ignore wl_output::WlOutput);
+delegate_noop!(CapState: ignore wl_shm_pool::WlShmPool);
+delegate_noop!(CapState: ignore wl_buffer::WlBuffer);
+delegate_noop!(CapState: ignore ZwlrScreencopyManagerV1);
+
+impl Dispatch<ZwlrScreencopyFrameV1, ()> for CapState {
+    fn event(
+        state: &mut Self,
+        _: &ZwlrScreencopyFrameV1,
+        event: zwlr_screencopy_frame_v1::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        match event {
+            zwlr_screencopy_frame_v1::Event::Buffer { format, width, height, stride } => {
+                if let WEnum::Value(f) = format {
+                    // prefer 32-bit formats we know how to swizzle
+                    if matches!(
+                        f,
+                        wl_shm::Format::Xrgb8888
+                            | wl_shm::Format::Argb8888
+                            | wl_shm::Format::Xbgr8888
+                            | wl_shm::Format::Abgr8888
+                    ) {
+                        state.buffer_meta = Some((width, height, stride, f));
+                    }
+                }
+            }
+            zwlr_screencopy_frame_v1::Event::BufferDone => state.buffer_done = true,
+            zwlr_screencopy_frame_v1::Event::Ready { .. } => state.ready = true,
+            zwlr_screencopy_frame_v1::Event::Failed => state.failed = true,
+            _ => {}
+        }
+    }
+}
+
+struct ShmSlot {
+    _file: std::fs::File,
+    map: memmap2::MmapMut,
+    pool: wl_shm_pool::WlShmPool,
+    buffer: wl_buffer::WlBuffer,
+    meta: (u32, u32, u32, wl_shm::Format),
+}
+
+pub struct CaptureSession {
+    conn: Connection,
+    queue: EventQueue<CapState>,
+    qh: QueueHandle<CapState>,
+    state: CapState,
+    manager: ZwlrScreencopyManagerV1,
+    output: wl_output::WlOutput,
+    shm: wl_shm::WlShm,
+    slot: Option<ShmSlot>,
+}
+
+impl CaptureSession {
+    pub fn new() -> Result<Self, String> {
+        let conn = Connection::connect_to_env().map_err(|e| e.to_string())?;
+        let (globals, queue) =
+            registry_queue_init::<CapState>(&conn).map_err(|e| e.to_string())?;
+        let qh = queue.handle();
+        let manager: ZwlrScreencopyManagerV1 = globals
+            .bind(&qh, 3..=3, ())
+            .map_err(|e| format!("screencopy v3 unavailable: {e}"))?;
+        let shm: wl_shm::WlShm = globals.bind(&qh, 1..=1, ()).map_err(|e| e.to_string())?;
+        let output: wl_output::WlOutput =
+            globals.bind(&qh, 1..=4, ()).map_err(|e| e.to_string())?;
+        Ok(Self {
+            conn,
+            queue,
+            qh,
+            state: CapState::default(),
+            manager,
+            output,
+            shm,
+            slot: None,
+        })
+    }
+
+    /// Pump the event queue until `pred(state)` holds or the deadline passes.
+    /// Returns false on timeout (Wayland connection still healthy).
+    fn wait_until(
+        &mut self,
+        deadline: Instant,
+        pred: impl Fn(&CapState) -> bool,
+    ) -> Result<bool, String> {
+        loop {
+            self.queue
+                .dispatch_pending(&mut self.state)
+                .map_err(|e| e.to_string())?;
+            if pred(&self.state) {
+                return Ok(true);
+            }
+            if Instant::now() >= deadline {
+                return Ok(false);
+            }
+            self.conn.flush().map_err(|e| e.to_string())?;
+            if let Some(guard) = self.queue.prepare_read() {
+                let fd = guard.connection_fd();
+                let mut fds = [rustix::event::PollFd::new(
+                    &fd,
+                    rustix::event::PollFlags::IN,
+                )];
+                let remaining = deadline.saturating_duration_since(Instant::now());
+                let ms = remaining.as_millis().min(200) as i32;
+                let _ = rustix::event::poll(&mut fds, ms.max(1));
+                let readable = fds[0].revents().contains(rustix::event::PollFlags::IN);
+                drop(fds);
+                if readable {
+                    let _ = guard.read();
+                } // else: drop the guard without reading and re-check
+            }
+        }
+    }
+
+    fn ensure_slot(&mut self, meta: (u32, u32, u32, wl_shm::Format)) -> Result<(), String> {
+        if let Some(s) = &self.slot {
+            if s.meta == meta {
+                return Ok(());
+            }
+        }
+        if let Some(old) = self.slot.take() {
+            old.buffer.destroy();
+            old.pool.destroy();
+        }
+        let (w, h, stride, format) = meta;
+        let size = (stride * h) as usize;
+        let dir = std::path::Path::new("/dev/shm");
+        let dir = if dir.is_dir() { dir } else { std::path::Path::new("/tmp") };
+        let path = dir.join(format!("cce-remote-shm-{}", std::process::id()));
+        let file = std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .create(true)
+            .truncate(true)
+            .open(&path)
+            .map_err(|e| e.to_string())?;
+        // unlink immediately — the fd keeps it alive, nothing lingers on disk
+        let _ = std::fs::remove_file(&path);
+        file.set_len(size as u64).map_err(|e| e.to_string())?;
+        let map = unsafe { memmap2::MmapMut::map_mut(&file) }.map_err(|e| e.to_string())?;
+        let pool = self.shm.create_pool(file.as_fd(), size as i32, &self.qh, ());
+        let buffer = pool.create_buffer(
+            0,
+            w as i32,
+            h as i32,
+            stride as i32,
+            format,
+            &self.qh,
+            (),
+        );
+        self.slot = Some(ShmSlot { _file: file, map, pool, buffer, meta });
+        Ok(())
+    }
+
+    /// Capture one frame of `rect` (output-local logical px). With
+    /// `use_damage`, blocks until the region changes or `timeout` — a timeout
+    /// returns Ok(None) so the caller can force a keepalive frame. The frame
+    /// is returned already JPEG-encoded.
+    pub fn next_frame(
+        &mut self,
+        rect: (i32, i32, i32, i32),
+        use_damage: bool,
+        timeout: Duration,
+    ) -> Result<Option<Vec<u8>>, String> {
+        self.state = CapState::default();
+        let frame = self.manager.capture_output_region(
+            1, // overlay the cursor — the remote wants to see it
+            &self.output,
+            rect.0,
+            rect.1,
+            rect.2,
+            rect.3,
+            &self.qh,
+            (),
+        );
+        // Phase 1: buffer negotiation (always fast).
+        let ok = self.wait_until(Instant::now() + Duration::from_secs(5), |s| {
+            s.buffer_done || s.failed
+        })?;
+        let Some(meta) = self.state.buffer_meta else {
+            frame.destroy();
+            return Err("no usable shm format offered".into());
+        };
+        if !ok || self.state.failed {
+            frame.destroy();
+            return if self.state.failed { Err("capture failed".into()) } else { Ok(None) };
+        }
+        self.ensure_slot(meta)?;
+        let buffer = &self.slot.as_ref().unwrap().buffer;
+        if use_damage {
+            frame.copy_with_damage(buffer);
+        } else {
+            frame.copy(buffer);
+        }
+        // Phase 2: damage-gated (this is the idle throttle).
+        let ok = self.wait_until(Instant::now() + timeout, |s| s.ready || s.failed)?;
+        let failed = self.state.failed;
+        if !ok || failed {
+            frame.destroy();
+            return if failed { Err("copy failed".into()) } else { Ok(None) };
+        }
+        frame.destroy();
+        let slot = self.slot.as_ref().unwrap();
+        Ok(Some(encode_jpeg(&slot.map, slot.meta)?))
+    }
+}
+
+/// Box-downscale the 32-bit shm pixels to ≤ MAX_EDGE and encode as JPEG.
+fn encode_jpeg(
+    map: &memmap2::MmapMut,
+    (w, h, stride, format): (u32, u32, u32, wl_shm::Format),
+) -> Result<Vec<u8>, String> {
+    // byte offsets of R,G,B within each little-endian 32-bit pixel
+    let (ri, gi, bi) = match format {
+        wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888 => (2usize, 1usize, 0usize),
+        _ => (0usize, 1usize, 2usize), // Xbgr8888 / Abgr8888
+    };
+    let f = ((w.max(h) + MAX_EDGE - 1) / MAX_EDGE).max(1);
+    let (ow, oh) = (w / f, h / f);
+    let mut rgb = Vec::with_capacity((ow * oh * 3) as usize);
+    let fsq = (f * f) as u32;
+    for oy in 0..oh {
+        for ox in 0..ow {
+            let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
+            for sy in 0..f {
+                let row = ((oy * f + sy) * stride) as usize;
+                for sx in 0..f {
+                    let px = row + ((ox * f + sx) * 4) as usize;
+                    r += map[px + ri] as u32;
+                    g += map[px + gi] as u32;
+                    b += map[px + bi] as u32;
+                }
+            }
+            rgb.push((r / fsq) as u8);
+            rgb.push((g / fsq) as u8);
+            rgb.push((b / fsq) as u8);
+        }
+    }
+    let mut out = Vec::new();
+    let encoder = jpeg_encoder::Encoder::new(&mut out, JPEG_QUALITY);
+    encoder
+        .encode(&rgb, ow as u16, oh as u16, jpeg_encoder::ColorType::Rgb)
+        .map_err(|e| e.to_string())?;
+    Ok(out)
+}
+
+/// Drive `session` frames into an MJPEG multipart writer until the client
+/// disconnects. `rect_of_focused` re-resolves the focused window (layout px).
+pub fn stream_mjpeg(
+    tcp: &mut std::net::TcpStream,
+    rect_of_focused: impl Fn() -> Option<(i32, i32, i32, i32)>,
+) -> Result<(), String> {
+    let mut session = CaptureSession::new()?;
+    let mut rect = rect_of_focused();
+    let mut rect_at = Instant::now();
+    let mut force_full = true; // first frame immediately; also after timeouts
+    loop {
+        if rect_at.elapsed() > Duration::from_millis(500) {
+            if let Some(r) = rect_of_focused() {
+                if Some(r) != rect {
+                    force_full = true; // focus moved: don't wait for damage
+                }
+                rect = Some(r);
+            }
+            rect_at = Instant::now();
+        }
+        let Some(r) = rect else {
+            std::thread::sleep(Duration::from_millis(400));
+            rect = rect_of_focused();
+            continue;
+        };
+        // 20s damage timeout doubles as a keepalive: the forced frame's write
+        // is what detects a silently-gone client.
+        let timeout = if force_full { Duration::from_secs(5) } else { Duration::from_secs(20) };
+        match session.next_frame(r, !force_full, timeout) {
+            Ok(Some(jpeg)) => {
+                force_full = false;
+                let head = format!(
+                    "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
+                    jpeg.len()
+                );
+                if tcp.write_all(head.as_bytes()).is_err()
+                    || tcp.write_all(&jpeg).is_err()
+                    || tcp.write_all(b"\r\n").is_err()
+                {
+                    return Ok(()); // client gone
+                }
+                // cap runaway damage bursts (~30 fps)
+                std::thread::sleep(Duration::from_millis(33));
+            }
+            Ok(None) => force_full = true,
+            Err(e) => return Err(e),
+        }
+    }
+}