Back to updates
New releaseJul 14, 2026

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.

Share

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):

TabWhat it does
WorkspacesLive per-monitor workspace grid (tap to switch), live window list (tap to focus), per-monitor DPMS toggle + ddcutil brightness
RemoteOn-screen trackpad (drag/tap/two-finger) + virtual keyboard with modifiers & combos, via a kernel uinput device
ModesOne-tap scene macros — launch/close/arrange whole app layouts across monitors (e.g. "Work" / "Free")
VoicePush-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
AudioMic/speaker mute, volume, output and input device pickers, playerctl transport + cover art (tracks the active MPRIS player)
StreamBidirectional 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
SystemPerformance-mode toggle, live theme switching, Tailscale status, top processes (tap to kill), lock, NetworkManager restart, suspend/reboot/poweroff
FilesScreenshot a monitor → view/download on the phone; drop a file phone→PC
ConfigEdit 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 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.

Security model

  • Reached only inside your tailnet — tailscale serve exposes it on your MagicDNS name with a real Let's Encrypt cert; it never binds to 0.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 input group (for /dev/uinput) and i2c group (for ddcutil)
  • aiortc + PyAV (installed via uv) 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

  1. Enable certificates once: Tailscale admin console → DNS → Enable HTTPS Certificates.

  2. Front the app with a real cert:

    sudo tailscale serve --bg 127.0.0.1:8765
    
  3. Set DECK_SECURE_COOKIES=1 in the web-app environment once HTTPS is live.

4. Install on the phone

Categories