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

commitaddb833dc85c8181909624c0e9cc908c9cda53c5
parent1dc7723ab6
authorLucas Galante <[email protected]>
date2026-07-21 20:19
feat: window view mode β€” see and click the focused window

πŸ–₯ toggles the pad between trackpad and a live view of the focused
window: GET /shot (PIN-gated via X-Pin; an img src can't carry a
header, so the page fetch()es to a blob URL) screenshots the focused
window through the compositor, returns the PNG with its layout rect in
X-Win, and deletes the file so ~/Pictures/screenshots never accumulates
remote captures. The page maps taps through the contain-fit letterbox
to absolute layout coords: tap = move+click (new WS msgs tap/tapr),
long-press = right click, two-finger drag = scroll; refresh every 1.5s
plus 450ms after each interaction.

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

 README.md   |  6 ++++
 index.html  | 80 +++++++++++++++++++++++++++++++++++++++++++++++++----
 src/main.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 171 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 8f37634..0af455a 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,12 @@ Home Screen for a fullscreen app feel.
 - ⌨ β€” summon the phone keyboard (typing goes through a US-layout
   char→evdev map; iOS `beforeinput` is used, so autocorrect noise is
   filtered)
+- ☰ β€” window switcher: tap a window to focus it
+- πŸ–₯ β€” window view mode: the pad shows the focused window (auto-refreshing
+  screenshot, ~1.5s + after each interaction). Tap to click that spot,
+  long-press to right-click, two-finger drag to scroll. Toggle again for
+  the trackpad. Screenshots are served PIN-gated and deleted immediately β€”
+  nothing accumulates on disk.
 
 ## Security
 
diff --git a/index.html b/index.html
index 854cdcb..5b7b4f9 100644
--- a/index.html
+++ b/index.html
@@ -28,6 +28,10 @@
                    border-radius: 10px; }
   #clicks button:active, #bar button:active { background: #2c5f69; }
   #kb { position: absolute; opacity: 0.02; width: 1px; height: 1px; left: -10px; top: -10px; }
+  #screen { display: none; position: absolute; inset: 0; width: 100%; height: 100%;
+            object-fit: contain; background: #0c0d12; border-radius: 12px; z-index: 2; }
+  #pad.view #screen { display: block; }
+  #pad.view .hint { display: none; }
   #switcher { display: none; position: absolute; inset: 8px; overflow-y: auto;
               background: rgba(20,21,28,0.96); border: 1px solid #2e3140;
               border-radius: 10px; z-index: 5; -webkit-overflow-scrolling: touch; }
@@ -62,10 +66,12 @@
   <button data-k="108">↓</button>
   <button data-k="106">β†’</button>
   <button id="wbtn">☰</button>
+  <button id="vbtn">πŸ–₯</button>
   <button id="kbtn">⌨</button>
 </div>
 <div id="preview"></div>
 <div id="pad"><div class="hint">drag = move Β· tap = click Β· 2-finger drag = scroll<br>2-finger tap = right click Β· hold = drag</div>
+  <img id="screen" draggable="false">
   <div id="switcher"></div>
 </div>
 <div id="clicks">
@@ -115,9 +121,41 @@ function swHide() { document.getElementById("switcher").classList.remove("show")
 const ACCEL = 1.6, SCROLL = 0.8;
 // Touch state: one finger moves, two fingers scroll; short still touches
 // click; a long-press starts a held drag (release on lift).
-let touches = new Map(), moved = 0, startT = 0, maxFingers = 0;
+let touches = new Map(), moved = 0, startT = 0, maxFingers = 0, startX = 0, startY = 0;
 let dragging = false, dragTimer = null, pendDx = 0, pendDy = 0, pendSy = 0, flushT = null;
 
+// ── Window view mode: the pad shows the focused window; taps click it ──
+let viewMode = false, winRect = null, shotTimer = null, shotBusy = false;
+const screenEl = document.getElementById("screen");
+async function refreshShot() {
+  if (!viewMode || shotBusy) return;
+  shotBusy = true;
+  try {
+    const r = await fetch("/shot", { headers: { "X-Pin": localStorage.getItem("cce-remote-pin") || "" }, cache: "no-store" });
+    if (r.ok) {
+      const xw = (r.headers.get("X-Win") || "").split(" ").map(Number);
+      if (xw.length === 5) winRect = { id: xw[0], x: xw[1], y: xw[2], w: xw[3], h: xw[4] };
+      const url = URL.createObjectURL(await r.blob());
+      const old = screenEl.src;
+      screenEl.onload = () => { if (old && old.startsWith("blob:")) URL.revokeObjectURL(old); };
+      screenEl.src = url;
+    }
+  } catch (_) {}
+  shotBusy = false;
+}
+// touch point (viewport px) β†’ layout px inside the focused window,
+// accounting for the contain-fit letterbox. null when off the image.
+function mapToWindow(cx, cy) {
+  if (!winRect || !screenEl.naturalWidth) return null;
+  const box = screenEl.getBoundingClientRect();
+  const scale = Math.min(box.width / screenEl.naturalWidth, box.height / screenEl.naturalHeight);
+  const dw = screenEl.naturalWidth * scale, dh = screenEl.naturalHeight * scale;
+  const ox = box.left + (box.width - dw) / 2, oy = box.top + (box.height - dh) / 2;
+  const u = (cx - ox) / dw, v = (cy - oy) / dh;
+  if (u < 0 || u > 1 || v < 0 || v > 1) return null;
+  return [(winRect.x + u * winRect.w).toFixed(1), (winRect.y + v * winRect.h).toFixed(1)];
+}
+
 function flush() {
   flushT = null;
   if (pendDx || pendDy) { send("m " + (pendDx * ACCEL).toFixed(1) + " " + (pendDy * ACCEL).toFixed(1)); pendDx = pendDy = 0; }
@@ -135,7 +173,18 @@ pad.addEventListener("touchstart", e => {
   maxFingers = Math.max(maxFingers, touches.size);
   if (touches.size === 1) {
     moved = 0; startT = Date.now();
-    dragTimer = setTimeout(() => { if (moved < 8) { dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15); } }, 350);
+    const t0 = e.changedTouches[0];
+    startX = t0.clientX; startY = t0.clientY;
+    dragTimer = setTimeout(() => {
+      if (moved >= 8) return;
+      if (viewMode) {
+        // long-press in view mode = right click at the spot
+        const m = mapToWindow(startX, startY);
+        if (m) { send("tapr " + m[0] + " " + m[1]); setTimeout(refreshShot, 450); navigator.vibrate && navigator.vibrate(15); }
+      } else {
+        dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15);
+      }
+    }, 350);
   } else { clearTimeout(dragTimer); }
 }, { passive: false });
 
@@ -145,10 +194,10 @@ pad.addEventListener("touchmove", e => {
   if (touches.size === 1) {
     const t = e.changedTouches[0], p = touches.get(t.identifier);
     if (!p) return;
-    pendDx += t.clientX - p.x; pendDy += t.clientY - p.y;
+    // view mode: one-finger movement is not pointer motion (taps only)
+    if (!viewMode) { pendDx += t.clientX - p.x; pendDy += t.clientY - p.y; queueFlush(); }
     moved += Math.abs(t.clientX - p.x) + Math.abs(t.clientY - p.y);
     touches.set(t.identifier, { x: t.clientX, y: t.clientY });
-    queueFlush();
   } else if (touches.size === 2) {
     // scroll: average finger dy, natural direction (content follows finger)
     let dy = 0, n = 0;
@@ -169,7 +218,17 @@ pad.addEventListener("touchend", e => {
   flush();
   if (dragging) { send("b left up"); dragging = false; }
   else if (moved < 10 && Date.now() - startT < 300) {
-    send(maxFingers >= 2 ? "b right click" : "b left click");
+    if (viewMode) {
+      if (maxFingers < 2) {
+        const t = e.changedTouches[0];
+        const m = mapToWindow(t.clientX, t.clientY);
+        if (m) { send("tap " + m[0] + " " + m[1]); setTimeout(refreshShot, 450); }
+      }
+    } else {
+      send(maxFingers >= 2 ? "b right click" : "b left click");
+    }
+  } else if (viewMode && maxFingers >= 2) {
+    setTimeout(refreshShot, 450); // scrolled the window β€” show the result
   }
   maxFingers = 0;
 }, { passive: false });
@@ -178,6 +237,17 @@ pad.addEventListener("touchcancel", () => { touches.clear(); clearTimeout(dragTi
 document.getElementById("lclick").addEventListener("click", () => send("b left click"));
 document.getElementById("rclick").addEventListener("click", () => send("b right click"));
 
+// ── View-mode toggle ─────────────────────────────────────────────────
+const vbtn = document.getElementById("vbtn");
+vbtn.addEventListener("click", () => {
+  viewMode = !viewMode;
+  vbtn.classList.toggle("on", viewMode);
+  pad.classList.toggle("view", viewMode);
+  swHide();
+  if (viewMode) { refreshShot(); shotTimer = setInterval(refreshShot, 1500); }
+  else { clearInterval(shotTimer); }
+});
+
 // ── Window switcher ──────────────────────────────────────────────────
 const switcher = document.getElementById("switcher");
 document.getElementById("wbtn").addEventListener("click", () => {
diff --git a/src/main.rs b/src/main.rs
index c7c081c..c4c821e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -145,6 +145,55 @@ fn window_list_json() -> String {
     format!("windows [{}]", objs.join(","))
 }
 
+/// Pull a numeric field out of one windows-json line (no serde β€” the values
+/// are flat numbers on a single line per window).
+fn json_num(line: &str, key: &str) -> Option<f64> {
+    let pat = format!("\"{key}\":");
+    let rest = &line[line.find(&pat)? + pat.len()..];
+    let end = rest
+        .find(|c: char| !(c.is_ascii_digit() || c == '-' || c == '.'))
+        .unwrap_or(rest.len());
+    rest[..end].parse().ok()
+}
+
+/// The focused app window as (id, x, y, w, h) in layout px.
+fn focused_window() -> Option<(u64, f64, f64, f64, f64)> {
+    let reply = control_command("windows --json").ok()?;
+    for line in reply.lines() {
+        if line.contains("\"focused\":true") && !line.contains("\"mode\":\"Status\"") {
+            return Some((
+                json_num(line, "id")? as u64,
+                json_num(line, "x")?,
+                json_num(line, "y")?,
+                json_num(line, "w")?,
+                json_num(line, "h")?,
+            ));
+        }
+    }
+    None
+}
+
+/// Screenshot the focused window via the compositor (it replies with the PNG
+/// path), read the bytes, and DELETE the file β€” the remote view must not
+/// litter ~/Pictures/screenshots.
+fn take_screenshot() -> Option<(Vec<u8>, (u64, f64, f64, f64, f64))> {
+    let win = focused_window()?;
+    let reply = control_command(&format!("screenshot window {}", win.0)).ok()?;
+    let path = reply.trim().strip_prefix("ok ")?.trim().to_string();
+    let mut bytes = None;
+    for _ in 0..5 {
+        match std::fs::read(&path) {
+            Ok(b) if !b.is_empty() => {
+                bytes = Some(b);
+                break;
+            }
+            _ => std::thread::sleep(Duration::from_millis(60)),
+        }
+    }
+    let _ = std::fs::remove_file(&path);
+    Some((bytes?, win))
+}
+
 fn handle_ws(stream: TcpStream, pin: &str) {
     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
     // Unauthenticated clients can hold the socket only briefly.
@@ -179,6 +228,19 @@ fn handle_ws(stream: TcpStream, pin: &str) {
                     if text.trim() == "wl" {
                         // Window-list request: the one message with a reply.
                         let _ = ws.send(tungstenite::Message::Text(window_list_json()));
+                    } else if let Some((coords, btn)) = text
+                        .strip_prefix("tap ")
+                        .map(|r| (r, "left"))
+                        .or_else(|| text.strip_prefix("tapr ").map(|r| (r, "right")))
+                    {
+                        // Window-view tap: absolute move + click.
+                        let mut it = coords.split_ascii_whitespace();
+                        if let (Some(Ok(x)), Some(Ok(y))) =
+                            (it.next().map(str::parse::<f64>), it.next().map(str::parse::<f64>))
+                        {
+                            let _ = control_command(&format!("pointer-move-to {x:.1} {y:.1}"));
+                            let _ = control_command(&format!("pointer-click {btn}"));
+                        }
                     } else if let Some(cmd) = translate(&text) {
                         let _ = control_command(&cmd);
                     }
@@ -190,7 +252,33 @@ fn handle_ws(stream: TcpStream, pin: &str) {
     println!("[cce-remote] client disconnected: {peer}");
 }
 
-fn handle_http(mut stream: TcpStream, request_head: &str) {
+fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str) {
+    // /shot: the focused window's screenshot, PIN-gated via the X-Pin header
+    // (the page fetch()es it β€” an <img src> couldn't carry a header).
+    if request_head.starts_with("GET /shot") {
+        let pin_ok = request_head.lines().any(|l| {
+            let lower = l.to_ascii_lowercase();
+            lower.starts_with("x-pin:") && l[6..].trim() == pin
+        });
+        if !pin_ok {
+            let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+            return;
+        }
+        match take_screenshot() {
+            Some((bytes, (id, x, y, w, h))) => {
+                let _ = write!(
+                    stream,
+                    "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nX-Win: {id} {x} {y} {w} {h}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+                    bytes.len(),
+                );
+                let _ = stream.write_all(&bytes);
+            }
+            None => {
+                let _ = write!(stream, "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+            }
+        }
+        return;
+    }
     let ok = request_head.starts_with("GET / ") || request_head.starts_with("GET /index.html ");
     let (status, body) = if ok {
         ("200 OK", INDEX_HTML)
@@ -248,7 +336,7 @@ fn main() {
                 let mut sink = [0u8; 1024];
                 let mut s = stream;
                 let _ = s.read(&mut sink);
-                handle_http(s, &head);
+                handle_http(s, &head, &pin);
             }
         });
     }