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

commitef30db2dad3bc2d16c4959bca5f3d6ce71e96e6b
parent2ba68920a3
authorLucas Galante <[email protected]>
date2026-07-21 20:46
feat: consume the compositor window-stream when available

New winstream source: subscribe 'window focused' on
/tmp/cce-stream-{WAYLAND_DISPLAY}.sock, decode 'frame <w> <h> <len>'
RGBA frames, downscale+JPEG (downscale_encode extracted from screencopy
for both sources) into the MJPEG response. Per-window damage, follows
focus server-side, streams off-viewport windows, true idle silence.
Source preference: window stream -> screencopy -> grim; requires a
compositor with the stream socket (cce d0/stream commit), gracefully
falls back until the running compositor is restarted onto it.

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

 src/main.rs       |  9 ++++++--
 src/screencopy.rs | 24 ++++++++++++++++----
 src/winstream.rs  | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 93 insertions(+), 6 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index ee14411..cd0b5af 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -27,6 +27,7 @@ use std::os::unix::net::UnixStream;
 use std::time::Duration;
 
 mod screencopy;
+mod winstream;
 
 const INDEX_HTML: &str = include_str!("../index.html");
 const DEFAULT_PORT: u16 = 17017;
@@ -280,8 +281,12 @@ 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.
+    // Source preference: the compositor's window stream (per-window damage,
+    // follows focus, works off-screen) → screencopy (output damage) → grim.
+    match winstream::stream_mjpeg(&mut stream) {
+        Ok(()) => return, // client disconnected
+        Err(e) => eprintln!("[cce-remote] window stream unavailable ({e}), trying screencopy"),
+    }
     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
diff --git a/src/screencopy.rs b/src/screencopy.rs
index b7295dc..2a5b513 100644
--- a/src/screencopy.rs
+++ b/src/screencopy.rs
@@ -267,10 +267,26 @@ fn encode_jpeg(
     (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 {
+    let rgb_at = match format {
         wl_shm::Format::Xrgb8888 | wl_shm::Format::Argb8888 => (2usize, 1usize, 0usize),
         _ => (0usize, 1usize, 2usize), // Xbgr8888 / Abgr8888
     };
+    downscale_encode(map, w, h, stride, rgb_at)
+}
+
+/// Shared by both frame sources (screencopy shm and the compositor's
+/// window-stream RGBA): box-downscale 32-bit pixels to ≤ MAX_EDGE and JPEG
+/// them. `rgb_at` gives the byte offsets of R,G,B within each 4-byte pixel.
+pub fn downscale_encode(
+    data: &[u8],
+    w: u32,
+    h: u32,
+    stride: u32,
+    (ri, gi, bi): (usize, usize, usize),
+) -> Result<Vec<u8>, String> {
+    if w == 0 || h == 0 || (stride * h) as usize > data.len() {
+        return Err("bad frame dimensions".into());
+    }
     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);
@@ -282,9 +298,9 @@ fn encode_jpeg(
                 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;
+                    r += data[px + ri] as u32;
+                    g += data[px + gi] as u32;
+                    b += data[px + bi] as u32;
                 }
             }
             rgb.push((r / fsq) as u8);
diff --git a/src/winstream.rs b/src/winstream.rs
new file mode 100644
index 0000000..5fb868f
--- /dev/null
+++ b/src/winstream.rs
@@ -0,0 +1,66 @@
+//! Consumer for the compositor's window-stream socket — the first-choice
+//! frame source. The compositor pushes damage-driven RGBA frames of the
+//! focused window (`window focused` subscription follows focus server-side,
+//! works off-viewport/occluded, and a truly idle window sends nothing but a
+//! 15s keepalive). We downscale + JPEG each frame into the MJPEG response.
+
+use std::io::{BufRead, BufReader, Read, Write};
+use std::os::unix::net::UnixStream;
+use std::time::Duration;
+
+const MAX_FRAME_BYTES: u32 = 64 * 1024 * 1024;
+
+fn stream_socket_path() -> String {
+    let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+    format!("/tmp/cce-stream-{display}.sock")
+}
+
+/// Bridge the compositor stream into `tcp` as MJPEG parts. Ok(()) = the HTTP
+/// client went away; Err = the stream source is unavailable/broke (caller
+/// falls back to screencopy).
+pub fn stream_mjpeg(tcp: &mut std::net::TcpStream) -> Result<(), String> {
+    let path = stream_socket_path();
+    let sock = UnixStream::connect(&path).map_err(|e| format!("{path}: {e}"))?;
+    // The compositor keepalives every ≤15s; a 40s silence means it's gone.
+    sock.set_read_timeout(Some(Duration::from_secs(40))).ok();
+    let mut sock = BufReader::new(sock);
+    sock.get_mut()
+        .write_all(b"window focused\n")
+        .map_err(|e| e.to_string())?;
+
+    let mut header = String::new();
+    loop {
+        header.clear();
+        if sock.read_line(&mut header).map_err(|e| e.to_string())? == 0 {
+            return Err("stream socket closed".into());
+        }
+        let mut it = header.split_ascii_whitespace();
+        if it.next() != Some("frame") {
+            continue;
+        }
+        let (Some(w), Some(h), Some(len)) = (
+            it.next().and_then(|v| v.parse::<u32>().ok()),
+            it.next().and_then(|v| v.parse::<u32>().ok()),
+            it.next().and_then(|v| v.parse::<u32>().ok()),
+        ) else {
+            return Err(format!("bad frame header: {header:?}"));
+        };
+        if len != w.saturating_mul(h).saturating_mul(4) || len > MAX_FRAME_BYTES {
+            return Err(format!("implausible frame: {header:?}"));
+        }
+        let mut rgba = vec![0u8; len as usize];
+        sock.read_exact(&mut rgba).map_err(|e| e.to_string())?;
+
+        let jpeg = crate::screencopy::downscale_encode(&rgba, w, h, w * 4, (0, 1, 2))?;
+        let part = format!(
+            "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: {}\r\n\r\n",
+            jpeg.len()
+        );
+        if tcp.write_all(part.as_bytes()).is_err()
+            || tcp.write_all(&jpeg).is_err()
+            || tcp.write_all(b"\r\n").is_err()
+        {
+            return Ok(()); // HTTP client gone — unsubscribes by dropping the socket
+        }
+    }
+}