Remote multimedia keyboard for your Linux desktop, driven from a phone's browser.
A single-binary Rust tool that serves a token-guarded web UI over the local network so a phone can send media keys (play/pause, stop, next, prev, volume, mute, arrows, fullscreen) to the local Linux host. No phone app, no Bluetooth, no login — just scan a QR code and press buttons.
cargo build --release
# No sudo — works on a stock GNOME/Wayland install (default sink):
./target/release/web-remote --input wayland
# Any compositor, needs root (drops back to your user after opening uinput):
sudo ./target/release/web-remote --input evdevA QR code is printed to the terminal. Scan it with your phone to open the UI — no app, no login, same Wi‑Fi.
--input |
How it works | Needs root? |
|---|---|---|
wayland (default) |
enigo via the Wayland virtual-keyboard protocol |
No |
evdev |
uinput virtual keyboard (open → drop privileges → serve) |
Yes (sudo) |
null |
no-op, for testing / headless dev | No |
| Flag | Default | Description |
|---|---|---|
--input <method> |
wayland |
Sink: wayland, evdev, or null |
--bind <addr> |
0.0.0.0 |
Listen address |
--port <port> |
40443 |
Listen port (fixed, not random) |
--http |
off | Serve plain HTTP (debugging only; default is HTTPS) |
--host <ip> |
auto-detected | Host IP to print in the QR/URL |
--user <name|uid> |
from SUDO_UID |
evdev sink: who to drop to after opening uinput |
- Token is the auth. A 32-byte random secret (43-char URL-safe base64,
~232 bits) is generated fresh each start and embedded in the URL path
(
/p/<token>/...). No cookie, no session, no rotation. The token dies with the process — killing the tool invalidates it and unregisters the keyboard. - HTTPS by default. Self-signed cert via
rcgen(in-memory, nothing on disk). The phone accepts the warning once.--httpis explicit opt-in for debugging. - Media keys only. The API accepts a closed set of twelve wire keys
(
play,stop,next,prev,volup,voldn,mute,up,down,left,right,fullscreen). There is no code path to emit a generic typing key — a leaked secret cannot type into the desktop. - Anything without a valid token gets a 404. No distinguishable "exists" vs "not found"; the service doesn't leak that it's running.
- QR is terminal-only. The QR code is printed to the operator's terminal,
not served over HTTP. A
GET /QR route would let any LAN device pull the token, defeating the secret-URL model.
With the evdev sink, the web server runs as the original invoking user after the privilege drop. The only root-equivalent thing left is the inherited uinput fd, which can only emit key events. With the wayland sink, the server runs as your normal user with no root window at all. Either way, R4 (media-keys-only) is the security boundary, not the uid.
-
Language: Rust, single binary, no external runtime.
-
Key injection: pluggable
Sinktrait (emit(key, hold_ms)). Two real backends:evdev(uinput, root, any compositor) andwayland(enigo virtual-keyboard + X11 fallback, no root, GNOME). Anullsink logs keys for testing. -
Web server: axum (tokio-native), token-in-path auth, self-signed TLS by default. Routes:
GET /p/<token>(UI),POST /p/<token>/key(emit),GET /p/<token>/ping(health). Everything else → 404. -
UI: hand-written HTML/CSS/JS, compiled in with
include_str!. No framework, no build step, no external assets. Eleven buttons in a flat 5×3 grid: transport row (prev/next/fullscreen), volume row (vol-down/mute/vol-up), arrow cross with play/pause at center. Stop is long-press on play/pause (hold > 600 ms → stop; tap → play/pause). -
QR code:
qrcodecrate, rendered as ANSI half-blocks to the terminal. Encodes the fullhttps://<host>:<port>/p/<token>URL. -
Privilege drop (evdev only): open
/dev/uinputas root → register device →setresuid/setresgidto the invoking user (fromSUDO_UID, or--user, ornobody) → serve. The uinput fd is inherited across the drop.
| Concern | Choice |
|---|---|
| Language | Rust |
| Key injection | evdev + enigo |
| Web framework | axum |
| TLS (default) | rcgen self-signed, in-memory |
| QR | qrcode |
| Privilege drop | nix setresuid/setresgid |
| UI | hand-written HTML/CSS/JS, include_str! |
cargo build --releaseNo system dependencies beyond a standard Fedora userspace. The evdev sink
needs /dev/uinput (root or input group). The wayland sink needs a
Wayland compositor that implements the virtual-keyboard protocol (GNOME does).
./ci.shRuns: cargo fmt --check, cargo clippy -D warnings, cargo doc,
cargo package, cargo test, UI HTML structure check, and cognitive
complexity threshold.
Items that can't be verified in the dev container:
- Wayland sink (no sudo): press each of the 11 keys on the phone; verify the active GNOME session reacts (player toggles, volume changes, mute, arrows, fullscreen). Stop via long-press on play/pause (hold > 600 ms).
- Volume long-press: hold a volume key for ~2 s; volume should ramp up (kernel auto-repeat). On the wayland sink this is best-effort; the evdev sink ramps for real.
- Phone UX (iOS Safari + Android Chrome): tap works, hold-fill grows on volume keys, press-glow + haptic fire, failed request shows error + re- surfaced hint. Long-press → stop works on iPhone.
- Evdev sink under sudo: keys still arrive and the process shows non-root
after the drop (
cat /proc/self/status | grep uid). - Evdev sink as non-root: clean error + non-zero exit, no hang, hint to
--input wayland.
--cert <file> --key <file>: user-provided cert/key (R10, not yet implemented).host.rs::bind_addruses.expect()on user-supplied--bind: a malformed address panics instead of erroring. Low value vs churn — revisit when more bind-related flags land.- No integration test for privilege drop (needs root); left to manual testing.
- Dependency pinning:
axumis pinned at 0.8.3 in the lockfile; later 0.8.x releases fixed aserve_staticpath-traversal CVE that we don't touch, but any futurecargo updateshould land deliberately on ≥ 0.8.10.
