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