Skip to content

Dish Linux

Linux CI

Turns the gamepads attached to a Linux machine into wireless controllers for another machine on the same network. Dish finds Satellite servers on the LAN, pairs with a PIN over HTTPS, and streams encrypted controller input over UDP; the satellite plugs a matching virtual pad into the host, so games there see a real controller.

Dish needs a Satellite server running on your LAN. It is one half of a pair and does nothing on its own. It is the Linux sibling of dish-android, dish-mac and dish-windows; all four speak the same protocol to the same server and look identical to it.

Physical controllers only. There is no on-screen touch gamepad; that belongs to dish-android, where the form factor makes sense.

What it does

  • LAN discovery over mDNS (_satellite._udp.local.) with a UDP broadcast fallback for older satellites
  • PIN pairing over HTTPS against the satellite's self-signed certificate, pinned trust-on-first-use so a swapped certificate aborts the request
  • ChaCha20-Poly1305 input streaming over UDP, sent straight off the input thread
  • SDL2 for every pad, plus an opt-in USB-direct hidraw path for DualSense, DualShock 4, Switch Pro, 8BitDo and Steam Controller class pads
  • Motion, battery and touchpad forwarded up; rumble and light bar driven back down by the host
  • Several satellites side by side, with per-slot controller binding
  • Per-device deadzones, button remapping, and a guided setup wizard
  • Keeps the computer awake while a slot is streaming — never, only while a controller is actually being used, or for as long as the link is up — with an optional idle timeout and an opt-in for the display too
  • Keeps streaming with the window closed, behind a tray icon that quits it
  • Closes its sessions on suspend and re-opens them on resume
  • Light and dark themes that follow the desktop, six UI languages

Install and run

You need a 64-bit Linux desktop, a gamepad, and a reachable Satellite server.

The comfortable path is a package repository: add it once and your package manager owns updates from then on. Debian 13+, Fedora-family, and Arch (pacman repo) instructions all live at tinkernorth.github.io/dish-linux.

Alternatively, every release publishes four standalone packages; pick the one that matches your distro.

You run Take Then
Debian 13+, or a derivative with Qt 6.7+ dish_<version>_amd64.deb sudo apt install ./dish_<version>_amd64.deb
Fedora, RHEL, openSUSE dish-<version>-x86_64.rpm sudo dnf install ./dish-<version>-x86_64.rpm
Anything else, or an LTS whose Qt is too old Dish-<version>-x86_64.AppImage chmod +x it and run it
You would rather have the sandbox Dish-<version>-x86_64.flatpak flatpak install ./Dish-<version>-x86_64.flatpak

The .deb and .rpm install the udev rule below for you and pull in the Qt runtime. The AppImage and the Flatpak carry their own Qt but cannot install the rule — see the next section.

Ubuntu 24.04 LTS ships Qt 6.4, below this project's 6.7 floor, so there is no .deb for it: use the AppImage or the Flatpak. docs/PACKAGING.md has the full per-distro table and what each package lays down.

Settings persist under ~/.config/com.tinkernorth.Dish/dish.conf; a crash writes a backtrace to $XDG_STATE_HOME/dish/crash.log.

USB-direct needs a udev rule

/dev/hidraw* is root-only by default, so the opt-in USB-direct path needs one rule installed before it can claim a pad:

sudo install -m 644 packaging/udev/70-dish-hidraw.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm trigger

The .deb and .rpm install it and reload udev for you; cmake --install places it too. An AppImage or a Flatpak cannot — they install nothing outside themselves — so both carry the rule inside at usr/share/dish/ for you to copy out. Without the rule every claim fails with a permission error and Dish keeps the pad on the SDL path, which still works — it is just rate-capped. Dish never asks for root.

Updates

Dish does not update itself. Your package manager owns the binary, so the updater checks and stops: about 15 seconds after launch and every four hours after that it asks GitHub for latest.json, and if there is a newer release it shows a pill linking to the release page. Nothing is downloaded and nothing is applied. Check for updates automatically in Settings stops every update-related network request when off; inside a Flatpak it starts off, since the store that installed Dish also updates it. The AppImage additionally carries zsync update metadata, so AppImageUpdate or Gear Lever can delta-update it in place. What the check sends is spelled out in PRIVACY.md.

Build from source

  • GCC 12+ or Clang 15+, CMake 3.21+, Ninja
  • Qt 6.7+ (Core, Gui, Network, DBus, Svg, Quick, Qml, QuickControls2; Linguist tools for the translation catalogues)
  • libsodium, SDL2, Catch2 v3
  • Optional: rsvg-convert, which renders the rest of the launcher-icon ladder from the SVG. Without it the build says so and installs only the scalable and 512x512 icons, which is enough for a working menu entry.

On Debian and Ubuntu:

sudo apt install build-essential cmake ninja-build pkg-config \
  qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \
  qt6-tools-dev qt6-l10n-tools \
  libsodium-dev libsdl2-dev libdbus-1-dev catch2 \
  librsvg2-bin

Then:

scripts/build.sh release
./build-release/dish

scripts/build.sh debug builds into build-debug/ instead, and scripts/build.sh test runs ctest after the build. CONTRIBUTING.md has the long-form CMake invocation and the hook, format and lint setup.

How it works

The app is a unidirectional-dataflow core with a Qt Quick projection on top. Sources of truth own state, pure composers derive from it, QML binds and renders, and QML sends commands back. src/qml/ could be deleted and replaced with a different front end without touching anything below it.

src/qml/          Qt Quick UI: AppViewModel facade, role models, theme bridges
src/composer/     Composers (pure derive), Controllers (effects), Coordinators
src/source/       StateSources and IO gateways: discovery, HTTP, USB, stores
src/repository/   Durable keyed storage over QSettings
src/core/         Pure, Qt-free: reducers and FSMs, wire crypto, input math
src/architecture/ The kernel: Observable, StateSource, Composer, Controller, Repository
src/Input/        SDL bridge, XUSB packing, output command queue
src/Network/      POSIX UDP session, REST client, pairing, connection pool
src/UI/           Theme palettes, font probes, crash handler, license manifest
src/update/       The update check. No download, no staging, no apply.

The window manager draws the decorations: Dish paints no title bar of its own, so the shell's rail and header are the whole chrome.

The input hot path is the deliberate exception and is not routed through the kernel. An SDL controller event runs GamepadInputProcessor and then SatelliteClient::sendReport inline on the SDL thread: pack the XUSB report, encrypt it, and call sendto() on a raw POSIX socket. No queue, no Qt event hop, no cross-thread signal. The UI thread never appears on the path; it only reads counters the input thread publishes lock-free. The USB-direct read loop feeds the same publish entry point on its own thread.

Layer rules, the state-capture doctrine (AsyncState<T> versus a reducer FSM), the UI binding contract and the hardening roadmap are in docs/ARCHITECTURE.md, the kernel primitives in src/architecture/README.md, the QML surface in docs/QML_CONTRACT.md and docs/QML_UI_KIT.md, the design tokens in DESIGN.md, and how a build becomes an installed Dish in docs/PACKAGING.md.

Running in the background

Closing the window leaves Dish running behind a tray icon; quit it from that icon's menu. Nothing is hidden that cannot be reached: the item registers with org.kde.StatusNotifierWatcher, and if no StatusNotifier host owns that name the close falls back to quitting rather than stranding a running process with no window and no menu. Keep running in the background in Settings turns the whole behaviour off, and the switch reads as unavailable on a desktop with no tray.

Bare GNOME is that desktop. It ships no StatusNotifier host, so the tray needs the AppIndicator extension; KDE, XFCE, Cinnamon, MATE, LXQt, sway and Waybar all carry one already. The .deb and .rpm name the extension as a suggestion rather than a dependency, because Dish works without it — it just keeps quitting on close.

Suspend, resume and a closed lid

Dish holds a logind sleep/delay lock, so on PrepareForSleep it closes its satellite sessions before the machine goes down, and re-opens them on resume rather than waiting out the ~10 s heartbeat death. A resume also rescans, so a laptop that wakes on a different network relearns a moved satellite instead of retrying a stale address.

Closing the lid still suspends, and no application can change that. LidSwitchIgnoreInhibited defaults to yes in logind.conf, which means logind executes the lid action even when an app holds a handle-lid-switch inhibitor — the lid is treated as a deliberate user action, like the power key. Keeping Dish streaming with the lid shut is therefore a system setting, not an app one:

# /etc/systemd/logind.conf — pick the ones you want
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore

then sudo systemctl restart systemd-logind. GNOME and KDE expose the same choice in their power settings. Docked already works untouched: HandleLidSwitchDocked defaults to ignore.

Desktop integration

Three portal-backed facts, each with a documented fallback so a minimal desktop degrades rather than breaks: light/dark from the XDG appearance portal (falling back to dark), reduced motion from the XDG settings portal then kdeglobals (falling back to motion allowed), and keep-awake from logind's idle inhibit plus, when the display opt-in is on, org.freedesktop.ScreenSaver.Inhibit (each falling back to a silent no-op). Bluetooth presence and power come from sysfs and BlueZ directly, because the wizard needs to tell "no adapter" from "adapter off".

Protocol

Ports, byte layouts and JSON shapes match the other Dish clients so all four are interchangeable to a satellite. The authoritative contract lives in satellite/docs/contract.md; the client-side mirror is src/core/model/Protocol.h.

Protocol version 1
Discovery UDP 9879 broadcast beacons, plus mDNS _satellite._udp.local.
Pairing and REST API HTTPS 9443, self-signed certificate, TOFU-pinned
Streaming UDP 9876
REST auth X-Device-Id + X-Hmac-Proof = hex(HMAC-SHA256(pairingKey, "satellite-proof:" + deviceId))
Topology REST only: PUT /api/connections upserts the whole desired controller set
Session key HKDF-SHA256(ikm = pairingKey, salt = sessionSalt, info = "satellite-session-v1" || token)
AEAD ChaCha20-Poly1305 IETF
Nonce direction(1) | 0x00 x7 | counter(4 BE)
AAD token (4 bytes, BE)
Packet token(4) | counter(4 BE) | ciphertext+tag
Up INPUT 0x0001, HEARTBEAT 0x0002, MOTION 0x000A, BATTERY 0x000B, TOUCHPAD 0x000C
Down HEARTBEAT_ACK 0x0003, RUMBLE 0x0009, LIGHTBAR 0x000D, SESSION_CLOSE 0x000F
Input report 12 bytes XUSB, little-endian
Heartbeat every 2 s; not responding at 2 misses, dead at 5

Translations

Six catalogues in translations/: English, Bosnian, German, Spanish, French and Brazilian Portuguese. They compile to .qm files embedded in the binary at :/i18n/, and the app picks one at startup by walking QLocale::uiLanguages() so the desktop's preferred UI language wins over the regional format setting.

English is a real catalogue rather than the untranslated fallback: a %n message carries one source string but needs one form per plural category, and Bosnian has three. Vocabulary is sourced from dish-android, whose catalogues are older and reviewed.

scripts/check-translations.sh re-runs lupdate in CI and fails on any diff, so a new user-facing string cannot land without its catalogue entry. Coverage is reported but never enforced; translating a string is a separate act from extracting it.

Testing

scripts/build.sh test
# or, against an existing build tree
ctest --test-dir build-debug --output-on-failure

One DishTests executable links the dish_core library. It covers the pure core exhaustively, with no mocks and no sockets: the reducer FSMs (USB path switching, pairing, session lifecycle, capture mode, apply sequencing, the update check), AsyncState transitions, the wire encoders and decoders against interop vectors shared with the satellite and dish-android, session crypto, XUSB mapping and deadzones, HID report parsing and transport classification, the beacon and mDNS parsers, TOFU pinning, and every repository against a shared contract. The design system is tested too: palette completeness, WCAG contrast ratios in both themes, font-family probes, and placeholder integrity plus plural-form order across all six translation catalogues.

What CI cannot reach — a real window manager, a real pad, a real satellite — is covered by docs/QML_MANUAL_SMOKE_CHECKLIST.md, which is run by hand before a release.

Contributing

See CONTRIBUTING.md for the workflow, the LGPL header policy, hook setup and review expectations. Changes land on main through a pull request; Linux CI, Security and CodeQL run on every one.

Security

Vulnerability disclosure: SECURITY.md. Dish is LAN-only and talks to no TinkerNorth-operated server.

License

LGPL-3.0-or-later. See LICENSE for the LGPL and COPYING.GPL3 for the GPL v3 it incorporates by reference.

About

Native Linux client for the Satellite wireless-gamepad server. Byte-level protocol parity with dish-android and dish-mac.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages