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

CLAUDE.md (22.2K)

  1 # CLAUDE.md
  2 
  3 > This is the `cce-remote` crate, inside the larger **`cce` Cargo workspace** —
  4 > read `../cce-compositor/WORKSPACE.md` first for the multi-repo layout, the
  5 > standalone-build rule, `ccebuild`, and the `cce-ui` toolkit. This file covers only
  6 > what is specific to this crate.
  7 
  8 `cce-remote` turns a phone into a trackpad, keyboard, window switcher and live window
  9 viewer for the cce desktop. It is **the odd crate in this workspace**: not a compositor
 10 and not a Wayland GUI client. It has no `cce-ui` dependency, draws nothing, and opens no
 11 Wayland surface of its own (it *does* connect to Wayland, but only as a screencopy
 12 client). It is a headless tailnet server (LAN with `--lan`) — HTTP + WebSocket in, compositor control socket
 13 out — and its entire user interface is one hand-written `index.html` compiled into the
 14 binary with `include_str!`.
 15 
 16 Mirroring a sibling app for structure is therefore the wrong instinct here. There is no
 17 `Application` trait, no `Message` enum, no widget tree. Four files:
 18 
 19 - **`src/main.rs`** — PIN auth + rate limiting, the HTTP/WS dispatch, the
 20   frame→command translator.
 21 - **`src/stream.rs`** — live-view delivery: the latest-wins `Slot`, the ack-clocked
 22   sender and its adaptation ladder, and the producer that picks a frame source.
 23 - **`src/screencopy.rs`** — a persistent `wlr-screencopy` client (frame source #2), and
 24   `downscale_encode`, shared by both raw frame sources.
 25 - **`src/winstream.rs`** — consumer of the compositor's window-stream socket (source #1).
 26 
 27 ## The invariant: the control socket is a full-privilege injection channel
 28 
 29 Everything this server does, it does by writing lines to
 30 `/tmp/cce-{WAYLAND_DISPLAY}.sock` — the same channel `ccectl` uses. Anything that can
 31 put a line on that socket can move the pointer, click, and **type arbitrary keystrokes
 32 into whatever the user has focused**. There is no sandbox between a WebSocket frame and
 33 the user's session except the code in this crate.
 34 
 35 So the discipline is: **nothing from the network is ever forwarded raw.** `translate()`
 36 is a whitelist that matches a fixed set of shapes and *rebuilds* the command string from
 37 re-parsed values — an unrecognized verb returns `None` and the frame is dropped.
 38 Numbers go through `parse::<f64>()`/`parse::<u32>()`, so they cannot smuggle a newline
 39 and inject a second command. The one place a caller-supplied *string* reaches a command
 40 (`wf <target>` → `focus-window`) is gated on `safe_token`. Named actions are whitelisted
 41 individually by name — note that `cmd` deliberately matches `"restart-compositor"`
 42 against a literal rather than passing the name through, and that shape is the point.
 43 
 44 Two things to keep in mind when adding a message:
 45 
 46 - **`translate()` is the single chokepoint — keep it that way.** It returns a *list*
 47   of commands so a verb can expand to several without the expansion living inline at
 48   the call site (the retired view-tap did: absolute move, then click — view-mode taps
 49   are plain trackpad clicks since 2026-08-23, and the verbs left the whitelist rather
 50   than lingering as unused injection surface). The only frames still handled in
 51   `handle_ws` are `wl` and `pl`, which produce a *reply* and send fixed commands
 52   carrying no caller-supplied content. A new message that carries any part of the
 53   frame into a command belongs in `translate()`, where the tests can see it.
 54 - **`cmd restart-compositor` restarts the user's whole session** from a phone, behind
 55   nothing but a client-side `confirm()`. Compositor-side it writes
 56   `/tmp/cce-restart-requested-$USER` and exits cleanly (state is saved, and
 57   `cce-display-manager`'s daemon relaunches greeter-free). Anything added to that `cmd`
 58   whitelist gets the same reach.
 59 
 60 ### What the PIN does and does not buy
 61 
 62 A persistent 6-digit PIN (`~/.config/cce/cce-remote.pin`, 0600, generated from
 63 `/dev/urandom` on first run, honoring `XDG_CONFIG_HOME`) must arrive as the **first**
 64 WebSocket frame or the connection closes — with a 10s read timeout so unauthenticated
 65 peers can't sit on a socket. The HTTP frame endpoints are gated separately, and
 66 differently, because of a browser constraint that shaped them when the page still
 67 used both: `/shot` takes an `X-Pin` header, but `/stream` accepts `?pin=` in the query
 68 string, because an `<img src>` cannot carry headers. The page uses NEITHER today — the
 69 live view rides `/wstream` — so both are debug endpoints now, and the split survives
 70 for the curl recipes below rather than for a browser.
 71 
 72 All three gates are pure functions — `auth_frame_ok`, `header_pin_ok`, `query_pin_ok`,
 73 all over `pin_matches` — so they are unit-tested rather than only reachable through a
 74 socket. `pin_matches` refuses an **empty** PIN outright: `load_or_create_pin`
 75 regenerates on an empty file so it should be unreachable, but that is a property of a
 76 *different* function, and if it lapsed, a bare `X-Pin:` header would authenticate
 77 everything. Gate on the dangerous state, don't trust the caller.
 78 
 79 ### The rate limiter is what makes 20 bits a credential
 80 
 81 A 6-digit PIN is ~20 bits compared with `==`. What keeps that from being walked in an
 82 afternoon is not the comparison, it is `RateLimiter`: a **per-source-IP token bucket
 83 over failed attempts**, 5 back-to-back then one recovered per 30s. That caps sustained
 84 guessing at ~2/min, which turns a couple of hours into the order of a year. All three
 85 gates consult it, and an unresolvable peer address is refused rather than exempted.
 86 
 87 Three properties it must keep, each with a test:
 88 
 89 - **Only failures are charged, and a success clears the record.** The page reconnects
 90   its stream on every hiccup — a WS close, the no-frame watchdog — each time presenting
 91   a correct PIN. If those consumed budget, a working client would throttle itself off.
 92 - **Refill caps at the burst.** Otherwise an idle attacker banks attempts and the limit
 93   is only an average. Note the test asserts this on `refilled()` *directly*: going
 94   through the public API hides a missing cap, because `record_failure` prunes recovered
 95   peers and re-creates them at full.
 96 - **The table cannot grow without bound**, or the limiter becomes its own
 97   memory-exhaustion vector. Recovered peers are pruned on write (a full bucket is
 98   indistinguishable from an absent one) with a hard cap behind that, evicting whoever is
 99   closest to recovered.
100 
101 On the WS side, a rate-limited connection is closed **without** sending `auth fail` —
102 that message makes the page discard its stored PIN and prompt, so sending it would
103 punish a correctly-paired client for someone else's guessing from the same address. The
104 page reconnects on close and succeeds once the bucket refills. HTTP answers `429` with
105 `Retry-After`.
106 
107 Behind the per-peer buckets is one **global** bucket (20 failures, then one per 30 s)
108 that every failure drains, because per-IP limiting alone gave a peer with many
109 addresses many budgets — a /22 LAN is a thousand addresses, hours to walk the PIN
110 space instead of a year. The price is that whoever drains it locks out a paired phone
111 too until it refills; `many_addresses_share_one_global_budget` pins both halves.
112 
113 Be honest about what is left rather than treating the PIN as security: it still travels
114 over **plain HTTP**, is compared non-constant-time, is cached in `localStorage`, and for
115 `/stream` it rides in a URL, where it lands in any proxy or history that sees it. On a
116 network someone else can watch, one observed pairing is keyboard control of the
117 desktop. So since 2026-10-01 the tunnel is the default rather than advice: the listener
118 still binds 0.0.0.0, but `reachable()` serves only connections whose peer AND local
119 address are loopback or Tailscale's (100.64.0.0/10, fd7a:115c:a1e0::/48); anything else
120 gets a 403 before the request is parsed. Both ends, because Linux accepts a packet for
121 the tailnet address off the Wi-Fi interface (the peer gives that away), and a LAN peer
122 spoofing a 100.x source never sees the SYN-ACK, which routes into the tunnel. Filtering
123 rather than binding the tailnet address keeps working when Tailscale comes up after
124 this service. `--lan` / `CCE_REMOTE_LAN=1` restores every interface for a trusted
125 network.
126 
127 ## Framing: the control socket is one-shot
128 
129 `control_command()` opens a **fresh `UnixStream` per command** and reads the reply to
130 EOF. That is not wasteful, it is the protocol: the compositor's IPC server is
131 read → reply → close. An earlier version held one persistent stream and silently raced
132 reconnects, dropping commands; reading to EOF is also what makes multi-line replies like
133 `windows --json` work at all.
134 
135 The page therefore does the coalescing: `queueFlush()` batches pointer deltas on a 12ms
136 timer so a fast drag becomes ~80 commands/sec, not one per touch event. One thread is
137 spawned per accepted connection, uncapped, and a `/stream` connection holds its thread
138 for the life of the stream.
139 
140 ## The live view: latest-wins delivery, three frame sources
141 
142 **Delivery and capture are separate concerns since the 2026-08-22 rework.** The
143 original MJPEG path pushed every frame, in order, into a blocking TCP write; nothing on
144 this side ever dropped one, so the kernel's send buffer (~10-30 frames) became a queue,
145 and the moment wifi throughput dipped below the frame rate the view fell seconds behind
146 and never recovered — "fine at first, unusable after a short time".
147 
148 The delivery design (`stream.rs`) makes that failure structurally impossible:
149 
150 - A **`Slot`** holds only the newest frame; the producer overwrites it. Overwriting IS
151   the frame-dropping — stale frames cease to exist before they cost encode or network.
152 - The page's live view rides **`/wstream`**, a dedicated WebSocket (same `auth <pin>`
153   first-frame gate): the server sends one frame, the page renders it and acks `n`, and
154   only then does the newest frame go out. **At most one frame is ever in flight**, so a
155   degraded link costs frame *rate*, never accumulating latency. The ack is sent after
156   `drawImage`, not on receipt — so the measured send→ack time covers network + decode +
157   paint, which is what the user experiences.
158 - That measurement drives an **adaptation ladder** (`LADDER`/`adapt()`): resolution up
159   to 1400px edge when the link is fast, downgrades immediate, upgrades requiring
160   sustained headroom. Encoding happens per *sent* frame at the chosen level. The ladder
161   alternates rather than sacrificing one axis first — `(1400,68) → (1120,68) →
162   (1120,55) → (840,58) → (840,46) → (560,48)`, a size drop first, and quality rising
163   again where size falls. `ladder_prefers_resolution_over_quality` does NOT assert the
164   preference its name claims: it only checks that the edge never increases, which a
165   ladder that dropped size at every step would also satisfy.
166 - `/wstream` is deliberately a **separate socket from the input WS**: frames are
167   30-150KB and input events are bytes; one TCP stream would head-of-line-block pointer
168   motion behind every frame.
169 - `/stream` (MJPEG over HTTP) survives as the **curl-debuggable endpoint**, thin over
170   the same slot at fixed 560/q60. Without acks its TCP buffer can still hold a few
171   frames — fine for debugging, which is all it is for now.
172 
173 Every accepted socket gets `TCP_NODELAY` — before the rework nothing set it, so Nagle
174 was batching tiny input events behind delayed ACKs.
175 
176 The ceiling above this design is hardware H.264 + WebCodecs/WebRTC (~5-10× fewer bytes),
177 at the cost of VAAPI/GStreamer deps and Safari codec quirks. Ack-clocked adaptive JPEG
178 is the right cost/benefit for a single-window view on a LAN; revisit only if it proves
179 bandwidth-starved in practice.
180 
181 ### The three frame sources
182 
183 `spawn_producer` tries each in turn. All three are live code — the fallbacks exist
184 because the first two have real preconditions.
185 
186 1. **`winstream`** — subscribe `window focused` on `/tmp/cce-stream-{WAYLAND_DISPLAY}.sock`
187    and read `frame <w> <h> <len>` + packed RGBA. Best source: damage is *per window*, it
188    follows focus server-side, it streams occluded and off-viewport windows, and a truly
189    idle window sends nothing but a ≤15s keepalive. Requires a compositor built with
190    `stream_server.rs`; against an older running `cce-fx` the connect fails and we drop to
191    screencopy, which is why this is a fallback chain and not a choice.
192 2. **`screencopy`** — one long-lived `wlr-screencopy` connection, per-frame
193    `capture_output_region` of the focused window's rect, throttled by `copy_with_damage`.
194    ~11 fps active. Its damage gate is **per output, not per region**, so an idle window
195    still wakes on unrelated screen activity.
196 3. **`grim`** — fork per frame, `-s 0.5 -q 65`. ~2.5 fps. The floor.
197 
198 Frames are box-downscaled and JPEG'd by the shared `downscale_encode`, at whatever
199 (edge, quality) the ladder picked for the link — not a fixed size anymore.
200 
201 **The cursor differs between sources, and the page compensates for the worst case.**
202 Compositor window-stream frames are surface textures with no cursor composited, so the
203 page draws its own cyan ring. Since that stream is *damage-driven*, moving the pointer
204 produces no repaint at all — which is why the marker is predicted client-side from the
205 finger delta (same `ACCEL` as the sent move) and only *reconciled* by a 120ms
206 `pl`/`ploc` poll. Polling alone updated it 4×/s and felt broken. The screencopy path
207 passes `overlay_cursor=1`, so on that fallback you see the real cursor *and* the ring.
208 
209 `/shot` (the one-shot PNG) screenshots through the compositor and then **deletes the
210 file** — verified: a `/shot` leaves nothing new in `~/Pictures/screenshots`. Remote
211 viewing must not accumulate captures on disk.
212 
213 ## Geometry assumes one output at 0,0
214 
215 The region passed to screencopy is in output-local logical coordinates, and the window
216 rects from `windows --json` are in layout coordinates. Those are the same number only
217 because there is a single output sitting at the origin — true for this DE's eDP-1 setup,
218 and the same assumption `grim` ran under. Multi-output would need a real mapping here.
219 
220 The cursor marker (`placeMarker`) maps the other way, through the `object-fit:
221 contain` letterbox, and stays correct under pinch-zoom only because the zoom is a
222 **uniform** CSS transform on an ancestor, so `getBoundingClientRect()` already
223 reflects it. (Tap-to-spot mapping is gone: view-mode input is trackpad-identical —
224 taps click where the cursor is.)
225 
226 ## `index.html` is the client, and iOS Safari shaped most of it
227 
228 The page is versioned here and baked in at compile time, so **a UI change needs a
229 rebuild, reinstall and restart of the server** — there is no asset path to edit live. It
230 is vanilla JS, no build step, no dependencies.
231 
232 Four of its non-obvious constructs are scar tissue. Do not "clean them up":
233 
234 - **The hidden textarea keeps sentinel padding** (`········`, cursor at the end, re-armed
235   on focus plus a 1s drift-repair timer). iOS never fires `deleteContentBackward` on an
236   empty field, so without something to delete, backspace silently does nothing.
237   `beforeinput` is used throughout because iOS `keydown` reports keyCode 229.
238 - **The zoom/pan transform lives on `#screenwrap`, not the frame element.** Uniform
239   ancestor transform means `getBoundingClientRect` reflects it, keeping the cursor
240   ring correctly placed while zoomed. (It also used to dodge an iOS bug where a
241   transformed multipart-MJPEG `<img>` stopped repainting; the view is a `<canvas>`
242   since the 2026-08-22 rework, but the structure stays.)
243 - **The `overflow: hidden` clip lives on `#pad`, the non-transformed ancestor.** A clip
244   on the transformed element scales with its own content and clips nothing.
245 - **The stream self-heals: reconnect on WS close plus a 30s no-frame watchdog** (the
246   frame sources force keepalives ≤20s, so 30s of silence is a dead connection, not an
247   idle window). One guard worth keeping: the page only auto-reconnects `/wstream` if
248   that connection *paired successfully* — retry-looping a stale PIN would feed the
249   rate limiter and lock the phone's address out of the input socket too.
250 
251 **Every symbol on the page is a cce-icons glyph**, as in every cce app: the arrow,
252 windows (`menu`), menu (`more-horizontal`) and keyboard buttons, and the `#status`
253 mark (`circle` paired, `circle-outline` disconnected and, dimmed, while connecting).
254 The page cannot call cce-ui's `upload_icon`, so each SVG is inlined from
255 `../cce-icons/svg/<name>.svg` with its `#ffffff` swapped for `currentColor` (so CSS
256 colours it) and a comment naming the file — copy again if a glyph is redrawn. Never
257 a symbol character (they were `← ☰ ⋯ ⌨ ● ○ ·` until 2026-10-05); a button with only
258 a glyph carries an `aria-label`.
259 
260 `SCROLL = 0.8`, not the 0.045 it started as: axis values reach clients as surface-px
261 deltas, so near-unity is the trackpad-like 1:1 feel. A 300px swipe used to scroll one line.
262 
263 ## Verifying
264 
265 The awkward part: **there is no WebSocket client on this machine** (no `websocat`, no
266 `wscat`, no python `websockets`), so the WS path — which is most of the logic — can only
267 be driven from a real phone, or by writing a throwaway client.
268 
269 The pure functions are the exception, and they are where the crate's invariants are
270 actually enforced, so they carry all the tests (`cargo test -p cce-remote`, 25 of them:
271 19 in `main.rs`, 6 in `stream.rs`) — `translate()` for what a paired client may say, the
272 three PIN gates for who is paired at all, and in `stream.rs` the `Slot`'s latest-wins
273 semantics plus `adapt()`/`LADDER`. They cover the accepted shapes and — more to the point —
274 everything that
275 must be refused: unknown verbs *including the compositor's own command names*,
276 malformed and missing arguments, `wf` targets outside `safe_token`, unwhitelisted `cmd`
277 names, and the property that no input can make the output span two lines (an embedded
278 newline would be a second command, since `control_command` appends one). Extend them
279 when you touch the whitelist; they are much cheaper than the phone.
280 
281 One of them, `non_finite_coordinates_are_dropped`, guards a hole that was live until
282 2026-08-22: `f64::from_str` accepts `"NaN"`/`"inf"` and `{:.2}` prints them straight back,
283 so `m NaN 1` used to reach the compositor's pointer math verbatim. `parse().ok()` is not
284 sufficient validation for a float — hence `finite()`.
285 
286 What can be checked from the desktop, and is confirmed working:
287 
288 ```sh
289 curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:17017/          # 200, the page
290 curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:17017/shot      # 403, no PIN
291 curl -D- -o /tmp/shot.png -H "X-Pin: $(cat ~/.config/cce/cce-remote.pin)" \
292      http://127.0.0.1:17017/shot                                          # 200 + X-Win rect
293 ```
294 
295 `X-Win: <id> <x> <y> <w> <h>` in that reply is the focused window's layout rect — the
296 same rect the frame sources capture and the page places its cursor ring inside, so it
297 is the quickest check that focus resolution and geometry agree. (The page does not read
298 this header; nothing in the page fetches `/shot` at all.)
299 
300 **Injected input lands in the live session** — pointer moves steer the user's real
301 cursor and keystrokes go into whatever they have focused. To exercise the input path
302 safely, point the server at the **shadow session** instead: run it with
303 `WAYLAND_DISPLAY=` set to the shadow display (see `cce-shadow` in
304 `../cce-compositor/CLAUDE.md`) on a spare port. Both sockets this crate needs exist
305 there — `/tmp/cce-{display}.sock` and `/tmp/cce-stream-{display}.sock` — so even the
306 live-view path is reachable. Note the display name is read from the environment at
307 startup, so a server is bound to whichever session launched it for its whole life.
308 
309 ## Build and lifecycle
310 
311 `make install` → `ccebuild install --no-build cce-remote`. Never hand-list binaries in
312 the Makefile — `cargo metadata` already knows them. No `Cargo.lock` is tracked here, so
313 dependency changes need no lockfile refresh.
314 
315 **Committing is not publishing — pushing is.** This directory is its own git repository
316 whose `origin` is the local *bare* repo `~/git/cce-remote.git` (a real, pushable
317 remote). `published` is the old fetch-only static mirror
318 `https://git.lucas.co/cce-remote.git`, kept for reference; it never accepted a push
319 (dumb HTTP, no receive-pack) and that is exactly why the bare layer exists — see
320 `~/.local/bin/git-bare-sync.sh`. `repos.conf` lists the **bare** path, and
321 `gitsite.timer` republishes when a listed bare repo's HEAD moves. So the chain is:
322 
323 ```sh
324 git commit ...                 # local only
325 git push origin master         # this is the publishing step
326                                # gitsite.timer then mirrors it to git.lucas.co
327 ```
328 
329 An unpushed commit looks published on this machine and is not on the site. That gap was
330 workspace-wide on 2026-09-18 — 21 crates held unpushed commits, because
331 `git-bare-sync.sh` (the bulk pusher) read `repos.conf` field 2 as a work tree when it
332 holds the bare path, and silently skipped every repo. It is fixed and now versioned in
333 the gitsite repo, so `git-bare-sync.sh` pushes the whole set in one go; the backlog
334 stands until someone runs it.
335 
336 **The server does not run in the foreground — it is a user service.** `cce-remote.service`
337 ships from this crate root and is installed by `ccebuild` (classified as a user unit by
338 its `WantedBy=cce-session.target`), so it starts with the session and inherits the
339 session's `WAYLAND_DISPLAY` — which is the point, since that variable is read once at
340 startup and fixes which session the process can drive for its whole life.
341 
342 Because installs unlink-before-write, the running process stays on the old inode after
343 `ccebuild install` and keeps serving the old code until restarted:
344 
345 ```sh
346 ccebuild restart                  # picks it up automatically — see below
347 systemctl --user restart cce-remote
348 ```
349 
350 `ccebuild restart` does reach it, but not because the unit ships from here: it
351 enumerates *running* user services matching `^(cce|gpu-watcher)` and restarts the ones
352 whose `/proc/<pid>/exe` reads `(deleted)`. Being named `cce-remote.service` is the whole
353 qualification. Editing `index.html` counts as a code change for this purpose — it is
354 `include_str!`'d, so the page only updates once the binary is rebuilt, reinstalled *and*
355 the service restarted.
356 
357 The unit was unversioned until 2026-08-22 — a hand-written file that existed only in
358 `~/.config/systemd/user/`, in no repo, and so lost on a fresh clone with nothing here to
359 recreate it. Same failure the `.desktop` entries and `cce-keyring-selftest` had before
360 they were moved in-repo. It now installs to that same path from this crate.