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