
phone-deck v3.0.1
Self-hosted control deck and remote desktop for Linux workstations over Tailscale, featuring WebRTC streaming, voice control with local LLM, scene macros, virtual input, file transfer, and live system telemetry.
phone-deck
Turn a spare Android phone (or any device with a browser) into a self-hosted control deck and remote desktop for your Linux workstation — over your private Tailscale network, with no third-party apps.
It started as "what do I do with an old phone?" and became a control plane for a Hyprland rig: window/workspace control, scene macros, a push-to-talk voice router with a local-LLM assistant, context-aware in-app controls, two-way audio + screen streaming, a touch remote-desktop, a virtual keyboard/mouse, file transfer + phone→PC sharing, and live telemetry — all served as an installed PWA with a phosphor-terminal look.
Note on portability. This was built for one specific setup: Arch-based Linux (Garuda) + Hyprland + PipeWire + NVIDIA + Tailscale, with the end-4 / illogical-impulse dotfiles. The architecture is generic, but several integrations are environment-specific (monitor names, workspace bindings, the matugen theme path,
cpupower/nvidia-smi). Treat it as a working reference to adapt, not a turnkey package.
Features
The UI is a tabbed, dark, landscape web app (theme-synced to your desktop):
| Tab | What it does |
|---|---|
| Workspaces | Live per-monitor workspace grid (tap to switch), live window list (tap to focus), per-monitor DPMS toggle + ddcutil brightness |
| Remote | On-screen trackpad (drag/tap/two-finger) + virtual keyboard with modifiers & combos, via a kernel uinput device |
| Modes | One-tap scene macros — launch/close/arrange whole app layouts across monitors (e.g. "Work" / "Free") |
| Voice | Push-to-talk voice router (faster-whisper, CPU): lead-word lanes — macro (run a mode/command), type (dictate), input (phrase → key chord) — with confirm-before-execute, plus "friday", a read-only local-LLM answerer (Ollama + Qwen) with self-hosted web search and live system-state awareness |
| Audio | Mic/speaker mute, volume, output and input device pickers, playerctl transport + cover art (tracks the active MPRIS player) |
| Stream | Bidirectional WebRTC audio (PC↔phone, with phone-as-mic and phone-only output) + screen video, and tap-to-control the streamed screen = a real remote desktop |
| System | Performance-mode toggle, live theme switching, Tailscale status, top processes (tap to kill), lock, NetworkManager restart, suspend/reboot/poweroff |
| Files | Screenshot a monitor → view/download on the phone; drop a file phone→PC |
| Config | Edit the commands.json / modes.json (and context/voice) config from the phone (JSON-validated) |
Always on, above the tabs — a context strip that surfaces what's happening right now: an active call (mute / jump-to it), media now-playing + transport, and in-app keyboard controls for the focused app (YouTube / Brave / Teams), delivered without stealing focus.
Two more, beyond the tabs:
- Send-to-Rig — the PWA registers as an Android share target: share an image, some text, or a link from any app on your phone and it lands on the rig — images saved to a drop dir and placed on the clipboard as paste-ready PNG, text to the clipboard, a bare link opened.
- Ambient Cogitator — after a few idle minutes the deck becomes a phosphor instrument panel: needle-dial telemetry with peak-hold ("was it pegged while I was away?"), clock, now-playing.
Plus: on-screen numpad PIN login, screen wake-lock, fullscreen landscape PWA, and brute-force lockout on the login.
Architecture
phone / laptop (browser PWA)
│ HTTPS + WSS (Tailscale-only)
▼
tailscale serve ──► FastAPI web app ── Unix socket ──► deckd
(real TLS cert) (runs as your user) (action names) (runs as root)
│ │
hyprctl · pactl · ddcutil · grim · fixed allowlist of
wf-recorder · uinput · WebRTC privileged commands
(cpupower, nvidia-smi,
systemctl, …)
Two processes:
app/— the web app runs as your normal user. It does everything that doesn't need root: Hyprland control, audio, brightness, screen capture, virtual input, WebRTC streaming, file transfer.deckd/— a tiny root helper (stdlib only, no dependencies) for the few privileged actions. The web app never sends it shell strings — only action names from a fixed allowlist (governor_performance,gpu_power_limit,suspend, …), validated indeckd/actions.pybefore anything executes. Even if the web app were fully compromised, the blast radius is exactly the allowlisted actions, with no argument injection. The socket isroot:<group>mode0660.
Security model
- Reached only inside your tailnet —
tailscale serveexposes it on your MagicDNS name with a real Let's Encrypt cert; it never binds to0.0.0.0. - JWT login (PIN → signed cookie), with exponential-backoff lockout after repeated failures.
- Privileged actions are isolated behind the allowlisted helper daemon.
- Uploads are basename-sanitized (can't escape the drop directory).
The web app can run your configured shell commands and inject input as your user — it is, by design, a remote control for your machine. Keep it on your tailnet, behind the PIN, and don't expose it publicly.
Requirements
- Linux with Hyprland (wlroots), PipeWire (with
pactl/PulseAudio compat) - Python ≥ 3.11 and uv
- Tailscale (with HTTPS certificates enabled for your tailnet)
- CLI tools used by various features (install what you want to use):
hyprctl,pactl/pw-record/pw-play,playerctl,ddcutil,wf-recorder,grim,cpupower,nvidia-smi,kitty(or your terminal) - Your user in the
inputgroup (for/dev/uinput) andi2cgroup (forddcutil) aiortc+ PyAV (installed viauv) for audio/video streaming
Installation
git clone <your-repo-url> phone-deck
cd phone-deck
uv sync # creates .venv and installs dependencies
uv run python -m app.set_pin # set your unlock PIN
1. The root helper (deckd)
Edit systemd/deckd.service first — set the paths and DECK_SOCKET_GROUP to a group
your user belongs to (usually your primary group):
sudo cp systemd/deckd.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now deckd
deckd runs the system Python (stdlib only — no venv needed).
2. The web app
Run it directly for development:
uv run uvicorn app.main:app --host 127.0.0.1 --port 8765
…or as a user service. Edit systemd/phone-deck.service paths first, then install it.
Important: on a setup where Hyprland is not launched via uwsm, the systemd
graphical-session.target never activates, so the user unit won't auto-start on login.
The reliable fix is to start it from Hyprland with the session environment imported —
add to your Hyprland autostart (e.g. end-4's ~/.config/hypr/custom/execs.conf):
exec-once = systemctl --user import-environment WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS XDG_CURRENT_DESKTOP && systemctl --user start phone-deck
3. HTTPS over Tailscale
-
Enable certificates once: Tailscale admin console → DNS → Enable HTTPS Certificates.
-
Front the app with a real cert:
sudo tailscale serve --bg 127.0.0.1:8765 -
Set
DECK_SECURE_COOKIES=1in the web-app environment once HTTPS is live.