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