Skip to content

Architecture

dnaidoo621 edited this page May 30, 2026 · 2 revisions

Architecture

System overview

┌─────────────────────────────────────────────────────────┐
│  HTPC — Pop!_OS / Debian                                │
│                                                         │
│  ┌──────────────┐     ┌──────────────────────────────┐  │
│  │  GTK3 Overlay│     │  FastAPI server  :7000        │  │
│  │  (QR popup)  │◀────│  WebSocket  /ws              │  │
│  └──────────────┘     └──────────────┬───────────────┘  │
│                                      │                  │
│                         ┌────────────┴────────────┐     │
│                         ▼                         ▼     │
│                  ┌─────────────┐        ┌──────────────┐│
│                  │  X11 backend│        │Wayland backend││
│                  │  (pynput)   │        │(evdev/uinput) ││
│                  └──────┬──────┘        └──────┬───────┘│
│                         │                      │        │
│                         ▼                      ▼        │
│                    Xlib / X11           /dev/uinput      │
└─────────────────────────────────────────────────────────┘
                          ▲
                          │  WebSocket (LAN)
                          │
┌─────────────────────────┴───────────────────────────────┐
│  Phone — any browser                                     │
│                                                          │
│  ws.js  ──▶  glide-connect.jsx  ──▶  glide-controller   │
└──────────────────────────────────────────────────────────┘

Components

server/main.py — Entry point

  • Detects $XDG_SESSION_TYPE and initialises the right input backend
  • Starts uvicorn (FastAPI) in a daemon thread with its own asyncio event loop
  • Blocks the main thread on Gtk.main() (GTK requirement)
  • Shuts down cleanly on SIGTERM / SIGINT

server/app.py — FastAPI application

  • GET /qr.png — serves the QR code image
  • GET /health — returns {"status": "ok", "clients": N}
  • WS /ws — WebSocket endpoint; calls state.client_connected() on open, state.client_disconnected() on close
  • Mounts web/ as a static file directory for the phone UI
  • Routes all incoming WebSocket messages to the input backend

WebSocket message types:

type Required fields Action
mouse_move dx, dy Move cursor
mouse_click button (left/right/middle) Click
scroll dy Scroll
key key (string) Press a named key
text text Type a string
launch app Launch an app by name

server/state.py — Shared state

A thread-safe AppState object passed to both the FastAPI app and the GTK overlay. Maintains client_count and server_url. Subscribers (the overlay) are notified on every change via a simple callback list.

server/overlay.py — GTK3 QR popup

  • Runs on the GTK main thread — never blocked or written from another thread
  • Uses GLib.idle_add() to schedule state-change callbacks safely from the WebSocket thread
  • Show policy:
    • Shows on startup if no device has ever connected
    • Hides immediately when client_count > 0
    • On disconnect, starts a GLib.timeout_add_seconds(IDLE_TIMEOUT_SECONDS) timer
    • Timer cancels if a device reconnects before it fires
    • Timer fires → show popup only if client_count is still 0

server/input/ — Input backends

base.py — Abstract InputBackend class defining the interface:

  • move_mouse(dx, dy)
  • click(button)
  • scroll(dy)
  • type_text(text)
  • press_key(key)
  • launch_app(name) — shared implementation using APP_COMMANDS

x11.py — pynput backend

  • Mouse via pynput.mouse.Controller
  • Keyboard via pynput.keyboard.Controller with XF86 keysyms

wayland.py — evdev/uinput backend

  • Creates a virtual UInput device on init with all required capabilities
  • Mouse as relative mouse events (REL_X, REL_Y)
  • Keyboard as KEY_* Linux keycodes
  • Text input: wtypeydotoolwl-copy + Ctrl+V

__init__.py — Factory function

def get_backend() -> InputBackend:
    session = os.environ.get("XDG_SESSION_TYPE", "x11").lower()
    return _init_wayland() if session == "wayland" else _init_x11()

Phone UI (web/)

Built with React 18 + Babel standalone (in-browser JSX transform). All dependencies served locally — no CDN, works fully offline on the LAN.

File Role
index.html Entry point — loads React, Babel, ws.js, then the JSX files
static/glide-tokens.css Design tokens: OLED dark (#07090c), Pop!_OS teal accent
static/glide-ui.jsx GIcon SVG icon set, GSlider component
static/ws.js WebSocket manager — window.WS singleton with rAF-batched mouse moves
static/glide-connect.jsx Connection flow UI (connecting → connected → controller)
static/glide-controller.jsx Full controller: trackpad, scroll strip, drawer, keyboard sheet

ws.js key behaviour

  • Connects to ws://<host>/ws on page load
  • Reconnects automatically with exponential backoff on drop
  • Mouse move events are batched with requestAnimationFrame — at most one WS message per frame (~60/s) regardless of touch event rate
  • Exposes window.WS.send(), window.WS.queueMove(), window.WS.queueScroll(), window.WS.onConnect(), window.WS.onDisconnect()

Threading model

Main thread:          GTK main loop (Gtk.main)
                        └── GLib.idle_add callbacks (overlay state changes)
                        └── GLib.timeout_add_seconds (idle timer)

Daemon thread:        asyncio event loop (uvicorn)
                        └── WebSocket handler (async)
                        └── Input backend calls (synchronous, brief)

All cross-thread communication goes through AppState (thread-safe with a threading.Lock) and GLib.idle_add() (posts work safely to the GTK thread).


Packaging

The .deb is built inside a debian:bookworm-slim Docker container using dpkg-deb. This ensures reproducible builds regardless of the build host OS.

build-deb.sh
    └── assembles the deb tree under /tmp/htpc-remote-pkg/
    └── runs: docker run debian:bookworm-slim dpkg-deb --build ...
    └── outputs: htpc-remote_<version>_all.deb

See Building from Source for the full build process.

Clone this wiki locally