Skip to content

Architecture

dnaidoo621 edited this page Jun 15, 2026 · 2 revisions

Architecture

System overview

┌──────────────────────────────────────────────────────────────────┐
│  HTPC (Linux or Windows)                                         │
│                                                                  │
│  ┌─────────────────┐     ┌──────────────────────────────────┐   │
│  │  QR Overlay     │     │  FastAPI server  :8765            │   │
│  │  GTK3 (Linux)   │◀────│  WebSocket  /ws                  │   │
│  │  tkinter (Win)  │     └──────────────┬───────────────────┘   │
│  └─────────────────┘                    │                        │
│                              ┌──────────┴──────────┐            │
│                              ▼                     ▼            │
│                      ┌──────────────┐   ┌───────────────────┐   │
│                      │ Linux backend │   │  Windows backend   │   │
│                      │ X11: pynput  │   │  pynput media_*    │   │
│                      │ Wayland: evdev│  │  Win32 VK codes    │   │
│                      └──────┬───────┘   └────────┬──────────┘   │
│                             │                    │               │
│                             ▼                    ▼               │
│                    Xlib / /dev/uinput       Win32 / HID          │
└──────────────────────────────────────────────────────────────────┘
                              ▲
                              │  WebSocket (LAN)
                              │
┌─────────────────────────────┴────────────────────────────────────┐
│  Phone — any browser                                              │
│                                                                   │
│  Service Worker  ──▶  (cache-first for UI assets)                 │
│  ws.js  ──▶  glide-connect.jsx  ──▶  glide-controller.jsx        │
└───────────────────────────────────────────────────────────────────┘

Components

server/main.py — Entry point

  • Detects platform (sys.platform) and $XDG_SESSION_TYPE, 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() (Linux) or root.mainloop() (Windows tkinter)
  • 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 overlay. Maintains client_count and server_url. Subscribers (the overlay) are notified on every change via a simple callback list.

server/overlay.py — QR popup

Linux: GTK3 window (gi.repository.Gtk) — uses GLib.idle_add() to schedule state callbacks safely from the WebSocket thread. Falls back to a terminal-only mode if GTK is unavailable.

Windows: tkinter window (stdlib) — uses root.after(0, ...) for thread-safety. Closing the window hides it (root.withdraw) rather than killing the server process.

Show policy (both platforms):

  • Shows on startup if no device has ever connected
  • Hides immediately when client_count > 0
  • On disconnect, starts an IDLE_TIMEOUT_SECONDS countdown (default 7200 s / 2 h)
  • Timer cancels if a device reconnects before it fires
  • Timer fires → shows popup only if client_count is still 0

server/input/ — Input backends

base.py — Abstract InputBackend class + shared launch_app() dispatcher that routes to _launch_app_linux() (xdg-open) or _launch_app_win() (os.startfile()).

linux.py — X11 (pynput) and Wayland (evdev/uinput) backends

  • X11: pynput.mouse.Controller + pynput.keyboard.Controller with XF86 keysyms
  • Wayland: virtual UInput device; text via wtypeydotoolwl-copy + Ctrl+V

windows.py — Windows backend using pynput Win32 VK codes

  • Key.media_play_pause, Key.media_next, Key.media_volume_up, etc.
  • press_key("sleep") calls rundll32.exe powrprof.dll,SetSuspendState 0,1,0
  • seek keys mapped to Left/Right arrows (universal across VLC, Kodi, browsers)

__init__.py — Factory function

def get_backend() -> InputBackend:
    if sys.platform == "win32":
        return _init_windows()
    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, JSX; registers service worker
manifest.json Web App Manifest — enables PWA install (name, icons, display: standalone)
sw.js Service Worker — cache-first for 14 static assets, pass-through for WS/QR
static/glide-tokens.css Design tokens: OLED dark (#07090c), teal accent (#00C896)
static/glide-ui.jsx GIcon SVG icon set, GSlider component, GlideMark logo
static/ws.js WebSocket manager — window.WS singleton, 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
static/icons/ PWA icons: SVG, 192×512 PNG (maskable), Apple touch icon 180×180

Service Worker cache strategy

  • Install: pre-caches all 14 static assets + skipWaiting() (activates immediately)
  • Activate: deletes stale caches from previous versions + clients.claim()
  • Fetch: cache-first for everything; pass-through for WS upgrades, /qr.png, /health, and cross-origin requests

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

Threading model

Linux:

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)

Windows:

Main thread:    tkinter main loop (root.mainloop)
                  └── root.after(0, ...) callbacks (overlay state changes)
                  └── root.after(ms, ...) (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 threading.Lock) and the platform-appropriate main-thread scheduler (GLib.idle_add or root.after).


Packaging & CI

The GitHub Actions release workflow (.github/workflows/release.yml) triggers on any v* tag push and runs 4 parallel build jobs:

Job Docker image Output
build-deb debian:bookworm-slim htpc-remote_VERSION_all.deb
build-rpm fedora:latest htpc-remote-VERSION-1.noarch.rpm
build-windows-zip GitHub runner (ubuntu) htpc-remote-VERSION-windows.zip
build-linux-tar GitHub runner (ubuntu) htpc-remote-VERSION-linux.tar.gz

All 4 artifacts are attached to the GitHub Release automatically.

See Building from Source for the full build process.

Clone this wiki locally