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

commit66befd242383ae4c7b3ebb43cf3ea2ce2f4af0f3
authorLucas Galante <[email protected]>
date2026-07-21 19:31
cce-remote: phone trackpad + keyboard over the compositor control socket

A single-binary LAN server: embedded HTML touch UI (trackpad with tap /
two-finger scroll / hold-drag, sticky modifiers, iOS software keyboard
via beforeinput with a US char→evdev map) bridged over WebSocket to the
cce control socket (pointer-move-by / pointer-scroll / pointer-press /
keypress / key-down / key-up). Verified end-to-end: WS frames move the
live cursor.

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

 Cargo.toml  |   9 +++
 Makefile    |  14 +++++
 README.md   |  37 ++++++++++++
 index.html  | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 426 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..08b4e9e
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "cce-remote"
+version = "0.1.0"
+edition = "2021"
+description = "Phone trackpad + keyboard for the cce desktop: serves a web UI and bridges WebSocket input events to the compositor control socket"
+license = "GPL-3.0"
+
+[dependencies]
+tungstenite = "0.21"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0175db9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+.PHONY: build install run clean
+
+build:
+	cargo build --release
+
+install: build
+	mkdir -p ~/.local/bin
+	install -m 755 ../target/release/cce-remote ~/.local/bin/cce-remote
+
+run:
+	cargo run
+
+clean:
+	cargo clean
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3ef6c32
--- /dev/null
+++ b/README.md
@@ -0,0 +1,37 @@
+# cce-remote
+
+Use a phone as a trackpad + keyboard for the cce desktop.
+
+A single small server: it serves an embedded web page (touch trackpad +
+keyboard UI) over HTTP on the LAN and bridges the page's WebSocket input
+events into the compositor's control socket — the same channel `ccectl`
+uses, so injection rides the real compositor input path.
+
+## Run
+
+```sh
+make install        # installs cce-remote to ~/.local/bin
+cce-remote          # serves on 0.0.0.0:17017 (or: cce-remote <port>)
+```
+
+Open `http://<this-machine's-LAN-IP>:17017` on the phone. Add it to the
+Home Screen for a fullscreen app feel.
+
+## Controls
+
+- one-finger drag — move the pointer
+- tap — left click; two-finger tap — right click
+- two-finger drag — scroll (natural direction)
+- press-and-hold, then drag — held drag (release on lift)
+- `left` / `right` buttons — explicit clicks
+- top bar — esc/tab/arrows; ctrl/alt/sup are sticky toggles (tap to hold,
+  tap again to release — chords work: ctrl on, tap `c`, ctrl off)
+- ⌨ — summon the phone keyboard (typing goes through a US-layout
+  char→evdev map; iOS `beforeinput` is used, so autocorrect noise is
+  filtered)
+
+## Security
+
+LAN-trust only: anyone who can reach the port controls the desktop. Run it
+on a trusted network, or bind it behind a firewall / tunnel. There is no
+authentication in v1.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..40ce3c4
--- /dev/null
+++ b/index.html
@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
+<meta name="apple-mobile-web-app-capable" content="yes">
+<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+<title>cce remote</title>
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }
+  html, body { height: 100%; overflow: hidden; background: #14151c; color: #ccccd4;
+               font-family: ui-monospace, Menlo, monospace; }
+  body { display: flex; flex-direction: column;
+         padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
+  #bar { display: flex; gap: 6px; padding: 8px; flex-wrap: wrap; }
+  #bar button { flex: 1 1 auto; min-width: 44px; padding: 10px 6px; font-size: 13px;
+                font-family: inherit; color: #ccccd4; background: #23252f;
+                border: 1px solid #2e3140; border-radius: 8px; }
+  #bar button.on { background: #25626c; border-color: #337fc0; }
+  #pad { flex: 1; margin: 0 8px; background: #191b23; border: 1px solid #2e3140;
+         border-radius: 12px; touch-action: none; position: relative; }
+  #pad .hint { position: absolute; inset: 0; display: flex; align-items: center;
+               justify-content: center; color: #4a4d5c; font-size: 12px; text-align: center;
+               pointer-events: none; }
+  #clicks { display: flex; gap: 8px; padding: 8px; }
+  #clicks button { flex: 1; padding: 16px; font-size: 13px; font-family: inherit;
+                   color: #ccccd4; background: #23252f; border: 1px solid #2e3140;
+                   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; }
+  #status { position: fixed; top: 4px; right: 10px; font-size: 10px; color: #4a4d5c; }
+</style>
+</head>
+<body>
+<div id="status">·</div>
+<div id="bar">
+  <button data-k="1">esc</button>
+  <button data-k="15">tab</button>
+  <button data-mod="29">ctrl</button>
+  <button data-mod="56">alt</button>
+  <button data-mod="125">sup</button>
+  <button data-k="105">←</button>
+  <button data-k="103">↑</button>
+  <button data-k="108">↓</button>
+  <button data-k="106">→</button>
+  <button id="kbtn">⌨</button>
+</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="clicks">
+  <button id="lclick">left</button>
+  <button id="rclick">right</button>
+</div>
+<textarea id="kb" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
+<script>
+"use strict";
+// ── WebSocket ────────────────────────────────────────────────────────
+let ws = null;
+const status = document.getElementById("status");
+function connect() {
+  ws = new WebSocket("ws://" + location.host + "/ws");
+  ws.onopen = () => { status.textContent = "●"; status.style.color = "#5fbf6f"; };
+  ws.onclose = () => { status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
+}
+connect();
+function send(s) { if (ws && ws.readyState === 1) ws.send(s); }
+
+// ── Trackpad ─────────────────────────────────────────────────────────
+const pad = document.getElementById("pad");
+const ACCEL = 1.6, SCROLL = 0.045;
+// 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 dragging = false, dragTimer = null, pendDx = 0, pendDy = 0, pendSy = 0, flushT = null;
+
+function flush() {
+  flushT = null;
+  if (pendDx || pendDy) { send("m " + (pendDx * ACCEL).toFixed(1) + " " + (pendDy * ACCEL).toFixed(1)); pendDx = pendDy = 0; }
+  if (pendSy) { send("s " + pendSy.toFixed(3) + " 0"); pendSy = 0; }
+}
+function queueFlush() { if (!flushT) flushT = setTimeout(flush, 12); }
+
+pad.addEventListener("touchstart", e => {
+  e.preventDefault();
+  for (const t of e.changedTouches) touches.set(t.identifier, { x: t.clientX, y: t.clientY });
+  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);
+  } else { clearTimeout(dragTimer); }
+}, { passive: false });
+
+pad.addEventListener("touchmove", e => {
+  e.preventDefault();
+  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;
+    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;
+    for (const t of e.changedTouches) {
+      const p = touches.get(t.identifier);
+      if (p) { dy += t.clientY - p.y; n++; touches.set(t.identifier, { x: t.clientX, y: t.clientY }); }
+    }
+    if (n) { pendSy += (-dy / n) * SCROLL; moved += Math.abs(dy); queueFlush(); }
+  }
+}, { passive: false });
+
+pad.addEventListener("touchend", e => {
+  e.preventDefault();
+  for (const t of e.changedTouches) touches.delete(t.identifier);
+  if (touches.size > 0) return;
+  clearTimeout(dragTimer);
+  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");
+  }
+  maxFingers = 0;
+}, { passive: false });
+pad.addEventListener("touchcancel", () => { touches.clear(); clearTimeout(dragTimer); if (dragging) { send("b left up"); dragging = false; } maxFingers = 0; });
+
+document.getElementById("lclick").addEventListener("click", () => send("b left click"));
+document.getElementById("rclick").addEventListener("click", () => send("b right click"));
+
+// ── Bar keys + sticky modifiers ──────────────────────────────────────
+for (const b of document.querySelectorAll("#bar button[data-k]"))
+  b.addEventListener("click", () => send("k " + b.dataset.k));
+for (const b of document.querySelectorAll("#bar button[data-mod]"))
+  b.addEventListener("click", () => {
+    const on = b.classList.toggle("on");
+    send((on ? "kd " : "ku ") + b.dataset.mod);
+  });
+
+// ── Keyboard: iOS software keyboard via a hidden textarea ────────────
+// beforeinput is the reliable path on iOS (keydown often reports 229).
+const kb = document.getElementById("kb");
+document.getElementById("kbtn").addEventListener("click", () => {
+  if (document.activeElement === kb) { kb.blur(); } else { kb.focus(); }
+});
+// US-layout char → [evdev keycode, needs shift]
+const KEYS = {};
+"abcdefghijklmnopqrstuvwxyz".split("").forEach((c, i) => {
+  KEYS[c] = [[30,48,46,32,18,33,34,35,23,36,37,38,50,49,24,25,16,19,31,20,22,47,17,45,21,44][i], false];
+  KEYS[c.toUpperCase()] = [KEYS[c][0], true];
+});
+"1234567890".split("").forEach((c, i) => { KEYS[c] = [2 + i, false]; });
+Object.assign(KEYS, {
+  " ": [57,false], "-": [12,false], "=": [13,false], "[": [26,false], "]": [27,false],
+  "\\": [43,false], ";": [39,false], "'": [40,false], "`": [41,false], ",": [51,false],
+  ".": [52,false], "/": [53,false],
+  "!": [2,true], "@": [3,true], "#": [4,true], "$": [5,true], "%": [6,true], "^": [7,true],
+  "&": [8,true], "*": [9,true], "(": [10,true], ")": [11,true], "_": [12,true], "+": [13,true],
+  "{": [26,true], "}": [27,true], "|": [43,true], ":": [39,true], "\"": [40,true],
+  "~": [41,true], "<": [51,true], ">": [52,true], "?": [53,true],
+});
+function typeChar(ch) {
+  const k = KEYS[ch];
+  if (!k) return;
+  if (k[1]) { send("kd 42"); send("k " + k[0]); send("ku 42"); }
+  else { send("k " + k[0]); }
+}
+kb.addEventListener("beforeinput", e => {
+  e.preventDefault();
+  switch (e.inputType) {
+    case "insertText":
+    case "insertReplacementText":
+      for (const ch of (e.data || "")) typeChar(ch);
+      break;
+    case "insertLineBreak": send("k 28"); break;
+    case "deleteContentBackward": send("k 14"); break;
+    case "deleteContentForward": send("k 111"); break;
+  }
+});
+// keep the textarea from accumulating anything sneaky
+setInterval(() => { if (kb.value.length > 0) kb.value = ""; }, 2000);
+</script>
+</body>
+</html>
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0ec2b28
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,184 @@
+//! cce-remote — use a phone as a trackpad + keyboard for the cce desktop.
+//!
+//! One small server, no GUI: it serves the embedded `index.html` (a touch
+//! trackpad + keyboard page) over HTTP on the LAN, accepts a WebSocket at
+//! `/ws`, and translates the page's compact input events into the
+//! compositor's line-oriented control socket (`/tmp/cce-{WAYLAND_DISPLAY}.sock`
+//! — the same channel `ccectl` uses, so injection goes through the real
+//! compositor input path: `pointer-move-by`, `pointer-scroll`,
+//! `pointer-press/release/click`, `keypress`, `key-down`/`key-up`).
+//!
+//! Wire protocol (WS text frames, space-separated, one event per frame):
+//!   m <dx> <dy>          relative pointer move (logical px)
+//!   s <dy> <dx>          scroll (wayland axis units)
+//!   b <btn> <down|up|click>   btn = left|right|middle
+//!   k <keycode>          tap an evdev keycode
+//!   kd <keycode> / ku <keycode>   hold / release (modifiers)
+//!
+//! Security model: LAN-trust. Anyone who can reach the port controls the
+//! desktop — run it on a trusted network, or bind 127.0.0.1 and tunnel.
+
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::os::unix::net::UnixStream;
+
+const INDEX_HTML: &str = include_str!("../index.html");
+const DEFAULT_PORT: u16 = 17017;
+
+fn control_socket_path() -> String {
+    let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".to_string());
+    format!("/tmp/cce-{display}.sock")
+}
+
+/// One persistent line-oriented connection to the compositor's control socket.
+struct Control {
+    stream: BufReader<UnixStream>,
+}
+
+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(())
+    }
+}
+
+/// Translate one WS frame into a control-socket command. Returns None for
+/// frames that don't parse — they're dropped, never forwarded raw (the WS
+/// payload is untrusted; only these fixed shapes reach the compositor).
+fn translate(frame: &str) -> Option<String> {
+    let mut it = frame.split_ascii_whitespace();
+    let cmd = match it.next()? {
+        "m" => {
+            let dx: f64 = it.next()?.parse().ok()?;
+            let dy: f64 = it.next()?.parse().ok()?;
+            format!("pointer-move-by {dx:.2} {dy:.2}")
+        }
+        "s" => {
+            let dy: f64 = it.next()?.parse().ok()?;
+            let dx: f64 = it.next().unwrap_or("0").parse().ok()?;
+            format!("pointer-scroll {dy:.3} {dx:.3}")
+        }
+        "b" => {
+            let btn = match it.next()? {
+                b @ ("left" | "right" | "middle") => b,
+                _ => return None,
+            };
+            match it.next()? {
+                "down" => format!("pointer-press {btn}"),
+                "up" => format!("pointer-release {btn}"),
+                "click" => format!("pointer-click {btn}"),
+                _ => return None,
+            }
+        }
+        "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()?),
+        _ => return None,
+    };
+    Some(cmd)
+}
+
+fn handle_ws(stream: TcpStream) {
+    let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
+    let mut ws = match tungstenite::accept(stream) {
+        Ok(ws) => ws,
+        Err(e) => {
+            eprintln!("[cce-remote] ws handshake failed ({peer}): {e}");
+            return;
+        }
+    };
+    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);
+                        }
+                    }
+                }
+            }
+            Err(_) => break,
+        }
+    }
+    println!("[cce-remote] client disconnected: {peer}");
+}
+
+fn handle_http(mut stream: TcpStream, request_head: &str) {
+    let ok = request_head.starts_with("GET / ") || request_head.starts_with("GET /index.html ");
+    let (status, body) = if ok {
+        ("200 OK", INDEX_HTML)
+    } else {
+        ("404 Not Found", "not found")
+    };
+    let _ = write!(
+        stream,
+        "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+        body.len(),
+    );
+}
+
+fn main() {
+    let port = std::env::args()
+        .nth(1)
+        .and_then(|a| a.parse::<u16>().ok())
+        .unwrap_or(DEFAULT_PORT);
+    let listener = match TcpListener::bind(("0.0.0.0", port)) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("[cce-remote] cannot bind port {port}: {e}");
+            std::process::exit(1);
+        }
+    };
+    println!("[cce-remote] serving on http://0.0.0.0:{port} (control socket: {})", control_socket_path());
+
+    for stream in listener.incoming() {
+        let stream = match stream {
+            Ok(s) => s,
+            Err(_) => continue,
+        };
+        std::thread::spawn(move || {
+            // Peek the request head without consuming it, so a WS upgrade can
+            // be handed to tungstenite with the handshake bytes intact.
+            let mut buf = [0u8; 1024];
+            let n = match stream.peek(&mut buf) {
+                Ok(n) if n > 0 => n,
+                _ => return,
+            };
+            let head = String::from_utf8_lossy(&buf[..n]).to_string();
+            if head.starts_with("GET /ws") {
+                handle_ws(stream);
+            } else {
+                // Consume the request before replying (keeps curl happy).
+                let mut sink = [0u8; 1024];
+                let mut s = stream;
+                let _ = s.read(&mut sink);
+                handle_http(s, &head);
+            }
+        });
+    }
+}