
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.
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.
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:
Plus: on-screen numpad PIN login, screen wake-lock, fullscreen landscape PWA, and brute-force lockout on the login.
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 in deckd/actions.py before anything executes. Even if
the web app were fully compromised, the blast radius is exactly the allowlisted
actions, with no argument injection. The socket is root:<group> mode 0660.tailscale serve exposes it on your
MagicDNS name with a real Let's Encrypt cert; it never binds to 0.0.0.0.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.
pactl/PulseAudio compat)hyprctl, pactl / pw-record / pw-play, playerctl, ddcutil,
wf-recorder, grim, cpupower, nvidia-smi, kitty (or your terminal)input group (for /dev/uinput) and i2c group (for ddcutil)aiortc + PyAV (installed via uv) for audio/video streaminggit 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
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).
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
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=1 in the web-app environment once HTTPS is live.