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

commit1dc7723ab69d95af5f3ac80f0e1fb5064e579add
parentc9623b7f5a
authorLucas Galante <[email protected]>
date2026-07-21 20:12
feat: window switcher panel

☰ opens an overlay listing real app windows (status-bar modules, child
surfaces, and minimized windows filtered out; focused row highlighted);
tapping a row focuses it via focus-window <id>. New WS messages: wl
(list request — the one message with a reply, windows <json-array>) and
wf <id> (validated token, focus).

Also rework the control-socket bridge to match its actual framing: the
compositor's IPC server is one-shot per connection (read -> reply ->
close), so the old persistent stream was silently racing reconnects and
could drop commands. Every command now uses a fresh connection and
reads the reply to EOF (which multi-line replies like windows --json
need anyway).

Panel touches are routed around the trackpad handlers (which
preventDefault) so row taps and list scrolling work; any touch on the
pad proper dismisses the panel.

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

 index.html  | 59 ++++++++++++++++++++++++++++++++++++++++++++++--
 src/main.rs | 74 +++++++++++++++++++++++++++++++------------------------------
 2 files changed, 95 insertions(+), 38 deletions(-)

diff --git a/index.html b/index.html
index 727f8fc..854cdcb 100644
--- a/index.html
+++ b/index.html
@@ -28,6 +28,18 @@
                    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; }
+  #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; }
+  #switcher.show { display: block; }
+  #switcher .win { padding: 14px 14px; border-bottom: 1px solid #23252f;
+                   display: flex; gap: 10px; align-items: baseline; }
+  #switcher .win .t { flex: 1; overflow: hidden; text-overflow: ellipsis;
+                      white-space: nowrap; font-size: 14px; }
+  #switcher .win .a { color: #6a6d7c; font-size: 11px; }
+  #switcher .win.focused { background: #1d3a44; }
+  #switcher .win.focused .t { color: #7dffff; }
+  #switcher .empty { padding: 20px; color: #4a4d5c; text-align: center; font-size: 12px; }
   #preview { display: none; margin: 0 8px 6px; padding: 8px 10px; min-height: 36px;
              background: #191b23; border: 1px solid #2e3140; border-radius: 8px;
              font-size: 15px; color: #cfe8e8; white-space: pre-wrap; word-break: break-all; }
@@ -49,10 +61,13 @@
   <button data-k="103">↑</button>
   <button data-k="108">↓</button>
   <button data-k="106">→</button>
+  <button id="wbtn">☰</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></div>
+<div id="pad"><div class="hint">drag = move · tap = click · 2-finger drag = scroll<br>2-finger tap = right click · hold = drag</div>
+  <div id="switcher"></div>
+</div>
 <div id="clicks">
   <button id="lclick">left</button>
   <button id="rclick">right</button>
@@ -78,7 +93,8 @@ function connect() {
     if (pin) ws.send("auth " + pin); else ws.close();
   };
   ws.onmessage = e => {
-    if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
+    if (typeof e.data === "string" && e.data.startsWith("windows ")) { renderSwitcher(e.data.slice(8)); }
+    else if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
     else if (e.data === "auth fail") {
       localStorage.removeItem("cce-remote-pin");
       askPin("Wrong PIN — try again:");
@@ -92,6 +108,9 @@ function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
 
 // ── Trackpad ─────────────────────────────────────────────────────────
 const pad = document.getElementById("pad");
+// forward decls used by the pad handlers; the panel is wired further down
+function swContains(el) { return document.getElementById("switcher").contains(el); }
+function swHide() { document.getElementById("switcher").classList.remove("show"); }
 // SCROLL is finger-px → wl axis units (~surface px): 0.8 ≈ natural 1:1 feel.
 const ACCEL = 1.6, SCROLL = 0.8;
 // Touch state: one finger moves, two fingers scroll; short still touches
@@ -107,6 +126,10 @@ function flush() {
 function queueFlush() { if (!flushT) flushT = setTimeout(flush, 12); }
 
 pad.addEventListener("touchstart", e => {
+  // Touches on the window-switcher panel are its own (row taps, list
+  // scrolling) — never trackpad input. Any other pad touch dismisses it.
+  if (swContains(e.target)) return;
+  swHide();
   e.preventDefault();
   for (const t of e.changedTouches) touches.set(t.identifier, { x: t.clientX, y: t.clientY });
   maxFingers = Math.max(maxFingers, touches.size);
@@ -117,6 +140,7 @@ pad.addEventListener("touchstart", e => {
 }, { passive: false });
 
 pad.addEventListener("touchmove", e => {
+  if (swContains(e.target)) return;
   e.preventDefault();
   if (touches.size === 1) {
     const t = e.changedTouches[0], p = touches.get(t.identifier);
@@ -137,6 +161,7 @@ pad.addEventListener("touchmove", e => {
 }, { passive: false });
 
 pad.addEventListener("touchend", e => {
+  if (swContains(e.target)) return;
   e.preventDefault();
   for (const t of e.changedTouches) touches.delete(t.identifier);
   if (touches.size > 0) return;
@@ -153,6 +178,36 @@ 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"));
 
+// ── Window switcher ──────────────────────────────────────────────────
+const switcher = document.getElementById("switcher");
+document.getElementById("wbtn").addEventListener("click", () => {
+  if (switcher.classList.contains("show")) { switcher.classList.remove("show"); return; }
+  send("wl"); // reply renders + shows the panel
+});
+function renderSwitcher(json) {
+  let wins = [];
+  try { wins = JSON.parse(json); } catch (_) {}
+  // real app windows only: no status-bar modules, no child surfaces
+  wins = wins.filter(w => w.mode !== "Status" && !w.has_parent && !w.minimized);
+  switcher.innerHTML = "";
+  if (!wins.length) {
+    switcher.innerHTML = '<div class="empty">no windows</div>';
+  }
+  for (const w of wins) {
+    const row = document.createElement("div");
+    row.className = "win" + (w.focused ? " focused" : "");
+    const t = document.createElement("span"); t.className = "t"; t.textContent = w.title || w.app_id;
+    const a = document.createElement("span"); a.className = "a"; a.textContent = w.app_id;
+    row.append(t, a);
+    row.addEventListener("click", () => {
+      send("wf " + w.id);
+      swHide();
+    });
+    switcher.appendChild(row);
+  }
+  switcher.classList.add("show");
+}
+
 // ── Bar keys + sticky modifiers ──────────────────────────────────────
 for (const b of document.querySelectorAll("#bar button[data-k]"))
   b.addEventListener("click", () => send("k " + b.dataset.k));
diff --git a/src/main.rs b/src/main.rs
index 32f8d50..c7c081c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,7 +21,7 @@
 //! event is accepted; anything else closes the connection. The page remembers
 //! the PIN in localStorage after the first pairing.
 
-use std::io::{BufRead, BufReader, Read, Write};
+use std::io::{Read, Write};
 use std::net::{TcpListener, TcpStream};
 use std::os::unix::net::UnixStream;
 use std::time::Duration;
@@ -73,26 +73,25 @@ fn load_or_create_pin() -> std::io::Result<String> {
     Ok(pin)
 }
 
-/// One persistent line-oriented connection to the compositor's control socket.
-struct Control {
-    stream: BufReader<UnixStream>,
+/// One control-socket command, one connection: the compositor's IPC server is
+/// one-shot (read → reply → close), so a fresh connect per command is the
+/// correct framing — the reply is everything until EOF (commands like
+/// `windows --json` reply with multiple lines).
+fn control_command(cmd: &str) -> std::io::Result<String> {
+    let mut s = UnixStream::connect(control_socket_path())?;
+    s.write_all(cmd.as_bytes())?;
+    s.write_all(b"\n")?;
+    let mut reply = String::new();
+    s.read_to_string(&mut reply)?;
+    Ok(reply)
 }
 
-impl Control {
-    fn connect() -> std::io::Result<Self> {
-        let s = UnixStream::connect(control_socket_path())?;
-        Ok(Self { stream: BufReader::new(s) })
-    }
-
-    fn send(&mut self, cmd: &str) -> std::io::Result<()> {
-        self.stream.get_mut().write_all(cmd.as_bytes())?;
-        self.stream.get_mut().write_all(b"\n")?;
-        // Drain the reply line so the socket never backs up. Errors in the
-        // reply text are ignored — input injection is fire-and-forget.
-        let mut reply = String::new();
-        self.stream.read_line(&mut reply)?;
-        Ok(())
-    }
+/// True for tokens safe to splice into a control command (window queries:
+/// numeric ids or app_ids). The WS payload is untrusted — nothing unvalidated
+/// reaches the compositor.
+fn safe_token(t: &str) -> bool {
+    !t.is_empty() && t.len() <= 128
+        && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
 }
 
 /// Translate one WS frame into a control-socket command. Returns None for
@@ -126,11 +125,26 @@ fn translate(frame: &str) -> Option<String> {
         "k" => format!("keypress {}", it.next()?.parse::<u32>().ok()?),
         "kd" => format!("key-down {}", it.next()?.parse::<u32>().ok()?),
         "ku" => format!("key-up {}", it.next()?.parse::<u32>().ok()?),
+        "wf" => {
+            let target = it.next()?;
+            if !safe_token(target) {
+                return None;
+            }
+            format!("focus-window {target}")
+        }
         _ => return None,
     };
     Some(cmd)
 }
 
+/// The `windows --json` reply (one JSON object per line) as a JSON array
+/// for the page's switcher.
+fn window_list_json() -> String {
+    let reply = control_command("windows --json").unwrap_or_default();
+    let objs: Vec<&str> = reply.lines().filter(|l| l.trim_start().starts_with('{')).collect();
+    format!("windows [{}]", objs.join(","))
+}
+
 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.
@@ -157,28 +171,16 @@ fn handle_ws(stream: TcpStream, pin: &str) {
     }
     let _ = ws.get_ref().set_read_timeout(None);
     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
-    let mut control = match Control::connect() {
-        Ok(c) => c,
-        Err(e) => {
-            eprintln!("[cce-remote] control socket unavailable: {e}");
-            let _ = ws.close(None);
-            return;
-        }
-    };
     println!("[cce-remote] client connected: {peer}");
     loop {
         match ws.read() {
             Ok(msg) => {
                 if let tungstenite::Message::Text(text) = msg {
-                    if let Some(cmd) = translate(&text) {
-                        if control.send(&cmd).is_err() {
-                            // Compositor went away; try one reconnect.
-                            match Control::connect() {
-                                Ok(c) => control = c,
-                                Err(_) => break,
-                            }
-                            let _ = control.send(&cmd);
-                        }
+                    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(cmd) = translate(&text) {
+                        let _ = control_command(&cmd);
                     }
                 }
             }