remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
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
+ }
+ }
+}