remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
feat: view-mode input is the trackpad, not a touchscreen
A tap on the live window view used to warp the pointer to the tapped spot and
click there, and a long-press right-clicked there. That made view mode a
second, different input model: the same gesture meant one thing over the
trackpad and another over the picture, and the pointer jumped somewhere the
finger had only pointed at.
Input is now identical in both modes — tap clicks where the cursor already is,
two-finger tap right-clicks, press-and-hold is a held drag, one-finger drag
moves the pointer. The picture is feedback, not a target.
`mapToWindow()` and the viewMode branches in touchstart/touchend go with it,
and so do the `tap`/`tapr` verbs: they leave the translate() whitelist rather
than lingering as unused injection surface, with both now asserted in the
must-be-dropped tests. translate() keeps its Vec return — no verb expands to
two commands today, but the expansion belongs here rather than at the call
site if one ever does again.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 32 ++++++++++++++++----------------
README.md | 9 +++++----
index.html | 37 +++++++------------------------------
src/main.rs | 54 +++++++++---------------------------------------------
4 files changed, 37 insertions(+), 95 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ba881fe..d6e6ec2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,14 +43,14 @@ against a literal rather than passing the name through, and that shape is the po
Two things to keep in mind when adding a message:
-- **`translate()` is the single chokepoint — keep it that way.** It returns a *list* of
- commands precisely so the one frame that means two (`tap`/`tapr`: an absolute move
- then a click) has no reason to live inline at the call site. It used to, along with
- `wl`/`pl`, and that inline branch was just as reachable from the network while being
- untestable without a live socket. The only frames still handled in `handle_ws` are
- `wl` and `pl`, which produce a *reply* and send fixed commands carrying no
- caller-supplied content. A new message that carries any part of the frame into a
- command belongs in `translate()`, where the tests can see it.
+- **`translate()` is the single chokepoint — keep it that way.** It returns a *list*
+ of commands so a verb can expand to several without the expansion living inline at
+ the call site (the retired view-tap did: absolute move, then click — view-mode taps
+ are plain trackpad clicks since 2026-08-23, and the verbs left the whitelist rather
+ than lingering as unused injection surface). The only frames still handled in
+ `handle_ws` are `wl` and `pl`, which produce a *reply* and send fixed commands
+ carrying no caller-supplied content. A new message that carries any part of the
+ frame into a command belongs in `translate()`, where the tests can see it.
- **`cmd restart-compositor` restarts the user's whole session** from a phone, behind
nothing but a client-side `confirm()`. Compositor-side it writes
`/tmp/cce-restart-requested-$USER` and exits cleanly (state is saved, and
@@ -199,9 +199,11 @@ rects from `windows --json` are in layout coordinates. Those are the same number
because there is a single output sitting at the origin — true for this DE's eDP-1 setup,
and the same assumption `grim` ran under. Multi-output would need a real mapping here.
-Tap mapping (`mapToWindow`) goes the other way, through the `object-fit: contain`
-letterbox, and stays correct under pinch-zoom only because the zoom is a **uniform**
-CSS transform on an ancestor, so `getBoundingClientRect()` already reflects it.
+The cursor marker (`placeMarker`) maps the other way, through the `object-fit:
+contain` letterbox, and stays correct under pinch-zoom only because the zoom is a
+**uniform** CSS transform on an ancestor, so `getBoundingClientRect()` already
+reflects it. (Tap-to-spot mapping is gone: view-mode input is trackpad-identical —
+taps click where the cursor is.)
## `index.html` is the client, and iOS Safari shaped most of it
@@ -238,17 +240,15 @@ The awkward part: **there is no WebSocket client on this machine** (no `websocat
be driven from a real phone, or by writing a throwaway client.
The pure functions are the exception, and they are where the crate's invariants are
-actually enforced, so they carry all the tests (`cargo test -p cce-remote`, 26 of them,
+actually enforced, so they carry all the tests (`cargo test -p cce-remote`, 25 of them,
in `main.rs`) — `translate()` for what a paired client may say, and the three PIN gates
for who is paired at all. They cover the accepted shapes and — more to the point —
everything that
must be refused: unknown verbs *including the compositor's own command names*,
malformed and missing arguments, `wf` targets outside `safe_token`, unwhitelisted `cmd`
names, and the property that no input can make the output span two lines (an embedded
-newline would be a second command, since `control_command` appends one). A partial tap
-must emit **nothing** — not a bare move, and above all not a click at whatever position
-the pointer already had. Extend them when you touch the whitelist; they are much
-cheaper than the phone.
+newline would be a second command, since `control_command` appends one). Extend them
+when you touch the whitelist; they are much cheaper than the phone.
One of them, `non_finite_coordinates_are_dropped`, guards a hole that was live until
2026-08-22: `f64::from_str` accepts `"NaN"`/`"inf"` and `{:.2}` prints them straight back,
diff --git a/README.md b/README.md
index 8502f7a..7cda70e 100644
--- a/README.md
+++ b/README.md
@@ -37,10 +37,11 @@ Home Screen for a fullscreen app feel.
Frames come from the compositor's damage-driven window stream, falling
back to wlr-screencopy, then grim; `/stream` remains as a curl-friendly
MJPEG debug endpoint.
- Tap to click that spot, long-press to right-click; one-finger drag moves
- the pointer exactly like the trackpad (a cyan ring marks the cursor —
- compositor frames carry none), and two fingers pinch-zoom / pan the view
- itself. Toggle again for the trackpad. Both `/stream` and the one-shot
+ Input is identical to the trackpad — tap = click, two-finger tap = right
+ click, press-and-hold = held drag, one-finger drag = pointer motion (a
+ cyan ring marks the cursor — compositor frames carry none); taps never
+ warp the pointer. Two fingers pinch-zoom / pan the view itself. Toggle
+ again for the trackpad. Both `/stream` and the one-shot
`/shot` endpoint are PIN-gated; nothing accumulates on disk.
## Security
diff --git a/index.html b/index.html
index 44eab3a..22eaed8 100644
--- a/index.html
+++ b/index.html
@@ -157,7 +157,8 @@ const ACCEL = 1.6, SCROLL = 0.8;
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: live view of the focused window; taps click it.
+// ── Window view mode: live view of the focused window. Input is identical
+// to the trackpad — the picture is feedback, not a touchscreen.
// Frames arrive as JPEG binaries on a dedicated /wstream WebSocket, drawn to
// the canvas; winRect for tap mapping is kept fresh by polling `wl` over the
// input WS (the stream follows focus server-side).
@@ -261,19 +262,6 @@ function predictMarker(dLx, dLy) {
placeMarker(ptr.x, ptr.y);
}
-// 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.width) return null;
- const box = screenEl.getBoundingClientRect();
- const scale = Math.min(box.width / screenEl.width, box.height / screenEl.height);
- const dw = screenEl.width * scale, dh = screenEl.height * 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; }
@@ -295,13 +283,7 @@ pad.addEventListener("touchstart", e => {
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]); navigator.vibrate && navigator.vibrate(15); }
- } else {
- dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15);
- }
+ dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15);
}, 350);
} else { clearTimeout(dragTimer); }
}, { passive: false });
@@ -364,15 +346,10 @@ pad.addEventListener("touchend", e => {
flush();
if (dragging) { send("b left up"); dragging = false; }
else if (moved < 10 && Date.now() - startT < 300) {
- 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]);
- }
- } else {
- send(maxFingers >= 2 ? "b right click" : "b left click");
- }
+ // Identical in both modes: a tap clicks where the cursor IS — it never
+ // warps the pointer to the tapped spot. View mode is the trackpad with a
+ // picture behind it, not a touchscreen.
+ send(maxFingers >= 2 ? "b right click" : "b left click");
}
maxFingers = 0;
}, { passive: false });
diff --git a/src/main.rs b/src/main.rs
index ee467f6..db7e508 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -260,9 +260,10 @@ fn finite(v: f64) -> Option<f64> {
/// Translate one WS frame into the control-socket commands it means. 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). A list rather than one command because a window-view tap is
-/// one frame and two commands; keeping that here rather than inline at the
-/// call site is what makes this the single place input is validated.
+/// compositor). A list rather than one command so a verb can expand to
+/// several commands without the expansion living inline at the call site (the
+/// retired view-mode tap did: absolute move, then click); keeping every
+/// expansion here is what makes this the single place input is validated.
fn translate(frame: &str) -> Option<Vec<String>> {
let mut it = frame.split_ascii_whitespace();
let cmd = match it.next()? {
@@ -298,17 +299,6 @@ fn translate(frame: &str) -> Option<Vec<String>> {
}
format!("focus-window {target}")
}
- // Window-view tap: absolute move, then click. The one frame that means
- // two commands — hence the Vec return.
- verb @ ("tap" | "tapr") => {
- let btn = if verb == "tapr" { "right" } else { "left" };
- let x = finite(it.next()?.parse().ok()?)?;
- let y = finite(it.next()?.parse().ok()?)?;
- return Some(vec![
- format!("pointer-move-to {x:.1} {y:.1}"),
- format!("pointer-click {btn}"),
- ]);
- }
// Named commands, individually whitelisted — never pass-through.
"cmd" => match it.next()? {
"restart-compositor" => "restart-compositor".to_string(),
@@ -901,6 +891,10 @@ mod tests {
for frame in [
"", " ", "x 1", "exit", "spawn foot", "reload",
"pointer-click left", "keypress 28", "restart-compositor",
+ // retired 2026-08-23: view-mode taps are plain trackpad clicks,
+ // so the verbs left the whitelist rather than lingering as
+ // unused injection surface
+ "tap 100 200", "tapr 5 5",
] {
assert!(translate(frame).is_none(), "{frame:?} should be dropped");
}
@@ -915,7 +909,6 @@ mod tests {
"kd", "ku",
"b", "b left", "b left bogus", "b sideways click", "b LEFT click",
"wf", "cmd",
- "tap", "tapr", "tap 1", "tapr 1", "tap a b", "tap 1 b",
] {
assert!(translate(frame).is_none(), "{frame:?} should be dropped");
}
@@ -933,7 +926,6 @@ mod tests {
for frame in [
"m NaN 1", "m 1 NaN", "m inf 0", "m -inf 0", "m 1 infinity",
"s NaN", "s 1 inf", "s nan 0",
- "tap NaN 1", "tap 1 inf", "tapr -inf 0", "tapr 1 nan",
] {
assert!(translate(frame).is_none(), "{frame:?} should be dropped");
}
@@ -965,35 +957,6 @@ mod tests {
}
}
- #[test]
- fn a_tap_is_one_frame_and_two_commands() {
- // The window-view tap: move the pointer somewhere absolute, then click
- // it. Both commands, in that order — a click without the move lands
- // wherever the pointer happened to be.
- assert_eq!(
- translate("tap 100 200.5").unwrap(),
- ["pointer-move-to 100.0 200.5", "pointer-click left"]
- );
- assert_eq!(
- translate("tapr 0 0").unwrap(),
- ["pointer-move-to 0.0 0.0", "pointer-click right"]
- );
- // Negative coords are legal: the layout origin is not the only anchor.
- assert_eq!(
- translate("tap -5.25 -0.04").unwrap(),
- ["pointer-move-to -5.2 -0.0", "pointer-click left"]
- );
- // Trailing junk is discarded, exactly as for the one-command verbs.
- assert_eq!(
- translate("tap 1 2 pointer-press left").unwrap(),
- ["pointer-move-to 1.0 2.0", "pointer-click left"]
- );
- // A partial tap must emit NOTHING — not a bare move, and above all not
- // a click at whatever position the pointer already had.
- assert!(translate("tap 1").is_none());
- assert!(translate("tap").is_none());
- }
-
#[test]
fn a_newline_can_never_smuggle_a_second_command() {
// control_command() appends "\n", so an embedded newline in the output
@@ -1009,6 +972,7 @@ mod tests {
"m 1 2\nexit", "m 1\n2", "s 1\nexit", "b left\nclick", "b left click\nexit",
"k 28\nexit", "kd 42\nexit", "ku 42\nexit", "wf 1\nexit",
"cmd restart-compositor\nexit", "m\t1\t2", "wf\n12", " m 1 2 ",
+ "tap 1 2\nexit",
] {
for out in translate(frame).unwrap_or_default() {
assert!(!out.contains('\n'), "{frame:?} produced a multi-line command: {out:?}");