Low-latency Xbox controller forwarding over the network. Captures physical controller input on one machine and injects it as a virtual Xbox 360 controller on another. It handles input the way Moonlight and Sunshine do, but as a standalone tool.
Runs as a system tray application with a built-in web UI for configuration. No console window required.
┌──────────────┐ UDP (12 bytes) ┌──────────────────┐
│ Sender │ ─────────────────────────► │ Receiver │
│ (XInput) │ XUSB_REPORT packet │ (ViGEmBus) │
│ │ │ │
│ Physical Xbox │ ◄───────────────────────── │ Virtual Xbox 360 │
│ controller │ MSG_RUMBLE (7 bytes) │ controller │
└──────────────┘ └──────────────────┘
Sender polls a physical Xbox controller via XInput at ~250 Hz and streams 12-byte XUSB_REPORT packets over UDP.
Receiver runs as a system tray app. It listens for those packets and injects them into Windows as a virtual Xbox 360 controller through the ViGEmBus kernel driver, talking to it directly via DeviceIoControl with no DLLs in between.
The hot path is three syscalls with zero allocations: recvfrom() → memcpy() → DeviceIoControl().
The return path carries rumble the other direction. When a game on the receiver host calls XInputSetState (or the equivalent on Linux's evdev FF subsystem), the platform backend fires a notification, the receiver maps it to the originating dish session, and forwards a MSG_RUMBLE packet back over the encrypted UDP channel. See Rumble (return path) below.
- System tray icon: right-click for Check for Updates, Open Web UI, Exit
- Web-based configuration: local dashboard at
http://localhost:9877 - Live status: packet count, sender IP, listening state
- Configurable UDP port, changed via the web UI
- Optional start with Windows (via registry)
- In-app OTA updates: the check/download/install/restart loop hits the
GitHub Releases API, verifies SHA-256 against
SHA256SUMS, then hands off to the platform-native installer (Inno Setup on Windows,.appbundle swap on macOS, AppImage in-place replace on Linux;.debusers get a copyableapt upgradecommand instead). See OTA Updates below. - Statically linked single exe, no DLLs needed
- Config persisted to
%APPDATA%\satellite\config.json
- Windows 10/11 with the ViGEmBus driver installed. The
SatelliteSetup.exeinstaller bundles ViGEmBus 1.22.0 and installs it for you if it's missing (see Installation). Or: - Linux with the in-tree
uinputkernel module and write access to/dev/uinput(see Building → Linux for the udev/group setup). Or: - macOS 10.15+ with a build carrying the
com.apple.developer.hid.virtual.deviceentitlement (production builds): controllers are synthesized as virtual DualShock 4 pads viaIOHIDUserDevice. Unentitled builds (CI artifacts, local compiles) run as development/web-UI servers only; controller descriptors apply asbackendUnavailable.
- dish-android: an Android phone running the Dish app (Bluetooth/USB controllers, touch overlay, motion). Other Dish clients implement the same contract (
docs/contract.md).
- Windows: MinGW-w64 (g++), or any C++17 compiler targeting Windows. Inno Setup 6 is needed only to build the installer.
- Linux / macOS:
cmake,pkg-config, a C++17 compiler, and the libsodium development headers. See the per-platform sections in Building for distro-specific package names.
Download SatelliteSetup.exe from the Releases page and run it. The installer will:
- Install Satellite to
Program Files\Satellite - Create a Start Menu shortcut
- Optionally create a Desktop shortcut
- Optionally set Satellite to start with Windows
- Register in Settings → Apps → Installed Apps with an uninstaller
- Detect the ViGEmBus driver and, by default, install the bundled v1.22.0 if it's missing or older. ViGEmBus 1.22.0 is the final upstream release, so newer installations are left untouched. The Components page shows the detected status before you continue.
The installer accepts a /VIGEM= switch alongside Inno Setup's standard
/SILENT / /VERYSILENT:
| Switch | Behavior |
|---|---|
(none) / /VIGEM=auto |
Default. Install the bundled ViGEmBus only if missing or older than 1.22.0. |
/VIGEM=bundled |
Force-run the bundled installer regardless of what's already there. |
/VIGEM=skip |
Don't touch the driver. Use this on locked-down machines or when ViGEmBus is managed externally. |
A reboot is sometimes required on first ViGEmBus install (MSI exit code 3010). The Satellite installer surfaces this as a "Restart now / later" prompt on the final wizard page. Until you reboot, virtual-gamepad output may not work even though the driver is installed.
Use Settings → Apps → Installed Apps → Satellite → Uninstall, or run the uninstaller from the Start Menu. The uninstaller will:
- Stop
satellite.exeif it's running. - Remove the Windows Firewall rules the installer added (UDP input 9876, discovery 9879, mDNS 5353, HTTPS client API 9443) — plus the stale "Satellite HTTP" (TCP 9877; the admin server binds loopback, so no rule is added anymore) and "Satellite Pairing" (TCP 9878) rules if an older install left them behind.
- Delete the program files, Start Menu and Desktop shortcuts, and the HKCU "Run at login" registry entry.
- Ask whether to also uninstall the ViGEmBus driver. The default is No, because other apps (DS4Windows, BetterJoy, MoonDeck-Buddy, etc.) commonly share it and removing it would break them silently.
User configuration (%APPDATA%\satellite\config.json and the pairing
keyfile) is left in place, so a reinstall preserves your paired
clients. Delete that folder by hand if you want a clean wipe.
The uninstaller accepts a /REMOVEVIGEM= switch alongside Inno Setup's
standard /SILENT:
| Switch | Behavior |
|---|---|
(none) / /REMOVEVIGEM=auto |
Default. Prompt the user; on /SILENT uninstall, do not touch the driver. |
/REMOVEVIGEM=yes |
Always uninstall the ViGEmBus driver as part of removing Satellite. |
/REMOVEVIGEM=no |
Never uninstall the ViGEmBus driver. Suppresses the prompt. |
build-satellite.batmacOS receivers synthesize virtual DualShock 4 controllers through
IOHIDUserDevice (macOS 10.15+). Creating the kernel HID device requires the
com.apple.developer.hid.virtual.device entitlement, which production
(signed) builds carry. A locally-compiled or CI build is unentitled: the
runtime probe detects that and the server falls back to the historical inert
backend — it still pairs, authenticates, serves the web UI, and reports
status to clients, but applies every controller descriptor as
backendUnavailable. Unentitled builds are useful for development, protocol
testing, and running the web dashboard from a Mac; they are not receivers for
game input. See docs/architecture.md → "macOS backend" for the design.
Prerequisites:
- Xcode Command Line Tools (
xcode-select --install) - Homebrew packages:
brew install cmake pkg-config libsodium
Build:
./build-satellite.shThe script runs cmake -S . -B build and cmake --build build, producing a
satellite.app bundle at the repo root. Config lives at
~/Library/Application Support/satellite/config.json; the DPAPI-equivalent
keyfile lives at ~/.config/satellite/keyfile (mode 0600). "Run at login"
is a LaunchAgents plist.
Linux is a full receiver: virtual gamepads are synthesized via
/dev/uinput (the in-tree uinput kernel module, no out-of-tree driver
needed). Pairing, encryption, the web UI, and config persistence all match
the Windows behavior.
Prerequisites:
cmake,g++(orclang++),pkg-config- libsodium development headers
- Debian/Ubuntu:
sudo apt install libsodium-dev - Fedora/RHEL:
sudo dnf install libsodium-devel - Arch:
sudo pacman -S libsodium
- Debian/Ubuntu:
- (Optional, for the system tray icon) libayatana-appindicator + GTK3 dev
headers. Without these, the binary builds headless and runs a
sigwaitloop. The web UI athttp://localhost:9877is the primary control surface either way.- Debian/Ubuntu:
sudo apt install libayatana-appindicator3-dev libgtk-3-dev - Fedora/RHEL:
sudo dnf install libayatana-appindicator-gtk3-devel gtk3-devel - Arch:
sudo pacman -S libayatana-appindicator gtk3
- Debian/Ubuntu:
Vanilla GNOME users: GNOME has no built-in tray. Install the AppIndicator and KStatusNotifierItem Support extension to see the icon. KDE, XFCE, Cinnamon, MATE, Budgie, and Pantheon work out of the box.
There are six install paths, in order of preference for desktop users:
- APT repository (Debian / Ubuntu):
apt install satellite, with automatic future upgrades. Recommended. - Standalone
.deb: one-shot install from the Releases page. - DNF/YUM repository (Fedora / RHEL / openSUSE):
dnf install satellite, with automatic future upgrades. - Standalone
.rpm: one-shot install from the Releases page. - Arch Linux AUR:
yay -S satellite-bin. - Portable build: works anywhere; you handle udev/group setup by hand (one-time).
Flatpak and Snap manifests also live in packaging/, but they
are sideload/self-host only — neither Flathub nor the Snap Store is a viable
route for an app that needs /dev/uinput, and neither format can install the
udev rule that grants it. They are worth using on Steam Deck, Bazzite and
Nobara, where the host already carries that rule. See
docs/packaging.md.
To get satellite the same way as any other system package, updated
automatically by apt upgrade alongside your kernel and browser, add our
hosted APT repository:
curl -fsSL https://tinkernorth.github.io/satellite/gpg.key \
| sudo gpg --dearmor -o /usr/share/keyrings/satellite-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/satellite-archive-keyring.gpg] \
https://tinkernorth.github.io/satellite/debian stable main" \
| sudo tee /etc/apt/sources.list.d/satellite.list
sudo apt update && sudo apt install satelliteSetup details (signing key fingerprint, how publishing works) are at
packaging/repo/README.md.
Download satellite_<version>_amd64.deb from the
Releases page and
install it directly:
sudo apt install ./satellite_*.deb…or build it yourself with the same CPack flow CI uses:
sudo apt install build-essential cmake pkg-config dpkg-dev rpm \
libsodium-dev libcurl4-openssl-dev \
libayatana-appindicator3-dev libgtk-3-dev # optional tray
./build-deb.shThe postinstall script reloads udev, loads the uinput kernel module, adds
$SUDO_USER to the input group, and registers the autostart-friendly
.desktop file. Log out and back in once for the group change to take
effect, then launch Satellite from your application menu (or run
satellite from a terminal). Uninstall with sudo apt remove satellite.
The CPack DEB generator is wired into the top-level CMakeLists.txt; the
control files and post-{install,remove} scripts live in
packaging/debian/.
sudo curl -fsSL -o /etc/yum.repos.d/satellite.repo \
https://tinkernorth.github.io/satellite/rpm/satellite.repo
sudo dnf install satelliteThe .repo file references the same signing key as the APT repo, so
dnf upgrade picks up new versions automatically.
Download satellite-<version>-1.x86_64.rpm from the
Releases page:
sudo dnf install ./satellite-*.x86_64.rpmCPack RPM generator config lives next to the DEB config in
CMakeLists.txt; post-{install,uninstall} scriptlets are in
packaging/rpm/.
The satellite-bin
package wraps the official AppImage:
yay -S satellite-bin
# or
paru -S satellite-binPKGBUILD source: packaging/aur/.
Build:
./build-satellite.shThe same script handles macOS and Linux (it dispatches on uname -s). On
Linux it produces a satellite binary at the repo root.
Grant /dev/uinput access (one-time setup; the .deb postinstall does
this for you):
sudo modprobe uinput # load the kernel module
echo 'KERNEL=="uinput", GROUP="input", MODE="0660"' \
| sudo tee /etc/udev/rules.d/99-uinput.rules # persistent udev rule
sudo udevadm control --reload-rules && sudo udevadm trigger
sudo usermod -aG input "$USER" # then log out / back inRun:
./satelliteOpen http://localhost:9877 in your browser to pair a sender and start the
receiver. Config lives at $XDG_CONFIG_HOME/satellite/config.json (falls
back to ~/.config/satellite/config.json); the keyfile is named keyfile
in the same directory (mode 0600). "Start with login" is wired up via the
XDG autostart spec: a .desktop file under $XDG_CONFIG_HOME/autostart/
toggled from the web UI.
After build-satellite.bat produces satellite.exe, run:
build-installer.batThis fetches the bundled ViGEmBus prerequisite (verifying SHA-256 against
the pin in redist/SHA256SUMS) and compiles the installer with
Inno Setup, producing
dist\SatelliteSetup.exe. That one installer packages the app, web
UI, ViGEmBus 1.22.0 prerequisite, and uninstaller. redist/ is
.gitignored; see redist/README.md for the
vendoring policy and how to bump the pin.
build-installer.bat is a thin wrapper around
scripts/fetch-redist.ps1 + iscc installer.iss. Invoke either piece
directly if you're scripting around it (the PowerShell script accepts
-Force to re-download even when the existing copy matches the pin).
Run satellite.exe. It starts minimized to the system tray and begins
listening immediately; there is no start/stop button.
- Right-click the tray icon and choose Open Web UI for the dashboard
- Or open
http://localhost:9877in your browser - Enter the rotating PIN shown on the dashboard (or approve the client-shown PIN) to pair a Dish client; clients then connect on their own
- Enable Start with Windows to auto-launch on boot
Clients (dish-android first) discover the satellite via mDNS, pair over
HTTPS 9443, and stream input over UDP 9876. The full client/server
contract is docs/contract.md.
Satellite ships with a built-in update checker that hits the GitHub Releases API on a 24-hour cadence (configurable in Settings → Updates). When a newer release is published, the dashboard shows a banner with Download, Remind Me Later, and Skip This Version actions. The tray menu's Check for Updates… item reflects the same state and toggles to Install Update X.Y.Z once the artifact has been fetched and verified.
The full state machine (idle → checking → update-available → downloading → verifying → downloaded → installing) is described in
src/core/update_service.h. Platform IO
(HTTPS, SHA-256, install hand-off) lives behind IUpdaterPort:
| Platform | HTTPS | Artifact | Install method |
|---|---|---|---|
| Windows | WinHTTP | SatelliteSetup-X.Y.Z.exe |
Inno Setup /VERYSILENT /OTA then auto-relaunch |
| macOS | NSURLSession | satellite-macos-stub-X.Y.Z.zip |
ditto -xk unpack + atomic bundle swap via helper, open relaunch |
| Linux (AppImage) | libcurl | satellite-X.Y.Z-x86_64.AppImage |
chmod +x + atomic mv over $APPIMAGE, setsid relaunch |
Linux (.deb) |
libcurl | n/a (surfaces command) | sudo apt upgrade satellite (auto-pulled from our APT repo if added) |
Linux (.rpm) |
libcurl | n/a (surfaces command) | sudo dnf upgrade --refresh satellite (auto-pulled from our DNF repo) |
| Linux (AUR) | libcurl | n/a (surfaces command) | yay -Syu satellite-bin |
| Linux (Snap) | libcurl | n/a (surfaces command) | sudo snap refresh satellite |
| Linux (Flatpak) | libcurl | n/a (surfaces command) | flatpak update io.github.tinkernorth.satellite |
| Linux (portable) | libcurl | n/a (surfaces command) | Manual: chmod +x and replace the binary |
Verification is SHA-256 against the SHA256SUMS asset published in every
release (signed by cosign at release time; verifying the cosign signature
in-app is tracked as a follow-up). Transit is HTTPS-pinned to github.com.
Only Windows, macOS and the Linux AppImage install updates themselves. Every other channel is package-manager territory: Satellite checks for new versions and shows you the command, but never downloads or replaces anything — self-replacing would desync the package database, and for Snap and Flatpak the payload is mounted read-only. On those builds the Download in background and Install & restart automatically settings are disabled, because they could never fire.
- Channel: Stable (only
X.Y.Zreleases) or Pre-release (includesX.Y.Z-rc.1,-beta.2, etc.). - Check automatically: runs the 24-hour timer thread (skip if disabled).
- Download in background: auto-download an available update so it's ready to install on the next visit.
- Install & restart automatically: only effective with the above two enabled; turns the dashboard banner into a direct restart prompt.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/version |
{version, platformId}, unauthenticated |
| GET | /api/updates/status |
Full UpdateStatusSnapshot JSON |
| POST | /api/updates/check |
Trigger a check now |
| POST | /api/updates/download |
Fetch the artifact |
| POST | /api/updates/install |
Apply the downloaded artifact (will restart) |
| POST | /api/updates/cancel |
Cancel an in-flight download |
| POST | /api/updates/dismiss |
"Remind me later"; hides the banner |
| POST | /api/updates/skip |
{version}; never notify about this version again |
| POST | /api/updates/preferences |
{channel, autoCheck, autoDownload, autoInstall} |
The /api/events SSE stream carries an update event channel that
pushes the same snapshot every tick, so the web UI stays current without
polling.
Edit two files in lockstep:
/VERSION: consumed by CMake (MACOSX_BUNDLE_*,CPACK_PACKAGE_VERSION) and byinstaller.issat preprocess time.src/core/version.h: consumed by C++ and bysatellite.rc(throughwindres's preprocessor).
The version-consistency CI gate fails if the two diverge.
The receiver runs six long-lived threads on every platform:
| Thread | Role |
|---|---|
| Main | Platform UI loop (tray icon, native pairing prompts); a headless signal wait on Linux without a tray |
| Receiver | UDP input socket → decrypt → gamepad-backend submit (the hot path); spawns the session reaper |
| Client API | HTTPS 9443 (cpp-httplib): pairing, sessions, catalog |
| Admin HTTP | Loopback 9877: web UI, admin API, SSE |
| Discovery beacon | Legacy UDP broadcast on 9879 for senders that predate the mDNS responder (toggleable; slated for removal in 2027) |
| mDNS responder | Advertises _satellite._udp.local. on 5353 |
Platforms add small helpers on top: a netlink interface watcher on Linux, an address-change watcher and the tray tooltip ticker on Windows. The updater and the per-device rumble listeners (see below) run on their own worker threads.
TCP's reliability guarantees cause head-of-line blocking: if one packet is lost, all subsequent packets are held until retransmission completes. For controller input, only the latest state matters, so a lost packet is better than a delayed one. Moonlight, Parsec, and Steam Remote Play take the same approach.
Games drive controller vibration by writing to the virtual gamepad device's output channel. Satellite snapshots those writes from the platform backend and forwards them to the dish that owns the session, which then actuates the matching physical controller (or, on dish-android, the phone itself).
| Platform | Source | Mechanism |
|---|---|---|
| Windows | ViGEmBus | IOCTL_XUSB_REQUEST_NOTIFICATION (X360) / IOCTL_DS4_REQUEST_NOTIFICATION (DS4), long-running async I/O. One worker thread per plugged virtual device blocks on the IOCTL until the driver completes it with the new motor/lightbar values. |
| Linux | uinput | EV_FF events on the device fd. Game uploads an FF_RUMBLE effect via UI_FF_UPLOAD, kernel hands us the descriptor, then sends an EV_FF event when the game presses play/stop. One reader thread per plugged device. |
| macOS | IOHIDUserDevice | The game writes DS4 output report 0x05; the kernel delivers it to the device's set-report block on a per-device dispatch queue. The adapter parses motors + lightbar RGB (valid-flag gated) and fans out to the rumble and lightbar callbacks. Entitled builds only: an unentitled build has no virtual pads, so no rumble events exist to forward. |
MSG_RUMBLE = 0x0009, satellite to dish, encrypted in the same ChaCha20-Poly1305 envelope as every other message:
inner header inner payload (7 bytes)
┌──────────┬──────────┐ ┌──────────┬─────────────┬─────────────┬──────────┐
│ msgType │ payload │ │ ctrlIdx │ strongMag │ weakMag │ durMs │
│ 0x0009 │ length │ │ u8 │ u16 BE │ u16 BE │ u16 BE │
└──────────┴──────────┘ └──────────┴─────────────┴─────────────┴──────────┘
2 bytes 2 bytes 1 2 2 2
| Field | Meaning |
|---|---|
ctrlIdx |
Controller index within the dish session that owns this rumble. |
strongMag |
Low-frequency / large-motor magnitude. 0..65535, XInput scale. |
weakMag |
High-frequency / small-motor magnitude. 0..65535. |
durMs |
Wire-side refresh deadline. Stamped by the satellite (default 500 ms); the dish clamps before driving its actuator. 0 is a stop sentinel. |
Rumble carries motor vibration only; the lightbar LED is a separate message (Lightbar return path).
The producer side lives in src/adapters/client_adapter.cpp. The dish-side parsers (SatelliteClient::parseRumbleMessage in dish-linux/windows, SatelliteClient.parseRumblePayload in dish-mac, the JNI dispatch in RumbleBridge.dispatchRumble on dish-android) all consume this layout verbatim.
Games can spam the same magnitudes 60+ Hz across many frames. The
SessionService::handleRumbleFromBackend path keeps the most recent
(strong, weak) per controller and suppresses re-emits when nothing changed.
Duration-only changes never defeat the suppression: the wire-side refresh
deadline is a knob the satellite owns, not a player-perceptible value.
| Dish | Actuator | Notes |
|---|---|---|
| dish-windows | SDL_GameControllerRumble |
Magnitudes pass through verbatim (XInput scale matches). Vibration only; the lightbar LED is a decoupled path as of Task 1.4. |
| dish-linux | Same SDL2 entry point | Underlying call is evdev EVIOCSFF. Works for any pad SDL recognises. |
| dish-mac | GCController.haptics + CHHapticEngine per locator |
Magnitudes mapped to CHHapticIntensity 0..1. Engines kept started; per-call CHHapticPattern for sustained drive. Falls back to no-op on legacy MFi pads with no haptics surface. Vibration only; lightbar is decoupled. |
| dish-android | VibratorManager (API 31+) / Vibrator (legacy), phone body |
All rumble routed to the device's own actuator(s). On dual-actuator phones the strong motor goes to vibrator id 0 and weak to id 1; on single-actuator phones the peak of the two is used. No fallback to physical-controller actuators by design (see RumbleBridge.kt). |
Task 1.4. A game can drive a DualSense / DualShock 4's RGB lightbar
independently of vibration, and plenty of games set a colour and never rumble.
Satellite forwards those colour changes on a dedicated stream, decoupled from
MSG_RUMBLE.
A dish advertises lightbar in its controller descriptor's caps object
(the session PUT; see docs/contract.md) when the bound
physical controller has an addressable RGB LED (a DualSense or DS4, not an
Xbox pad, and never on dish-android, which has no controller-LED API). The
satellite emits MSG_LIGHTBAR only to a controller that advertised the
capability; a dish that did not receives no lightbar traffic.
MSG_LIGHTBAR = 0x000D, satellite to dish, in the same ChaCha20-Poly1305
envelope as every other message:
inner header inner payload (4 bytes)
┌──────────┬──────────┐ ┌──────────┬──────────┬──────────┬──────────┐
│ msgType │ payload │ │ ctrlIdx │ R │ G │ B │
│ 0x000D │ length │ │ u8 │ u8 │ u8 │ u8 │
└──────────┴──────────┘ └──────────┴──────────┴──────────┴──────────┘
2 bytes 2 bytes 1 1 1 1
On Windows the ViGEm DS4 driver delivers rumble and lightbar in the same
IOCTL_DS4_REQUEST_NOTIFICATION completion. vigem_adapter's notification
worker fans that one event out to two sinks: the rumble callback (motors) and
the lightbar callback (colour). SessionService::handleLightbarFromBackend
resolves the backend serial to (connection, ctrlIdx), coalesces identical
back-to-back colours, and, for a CAP_LIGHTBAR dish, calls
IClientPort::sendLightbar. The colour is cached on the Controller
regardless of the capability, so the web dashboard's per-controller lightbar
swatch stays live for every controller type.
On macOS, the same DS4 output report 0x05 that carries the motors also
carries the lightbar colour; the IOHIDUserDevice set-report path fans it out
to the lightbar callback when the report's colour-valid flag is set (entitled
builds only). The Linux uinput backend has no host-driven lightbar channel
(EV_LED is single-bit), so it never emits MSG_LIGHTBAR. Lightbar emission
is a Windows / ViGEm-DS4 and macOS / IOHID-DS4 feature today.
| Dish | Actuator | Notes |
|---|---|---|
| dish-windows / dish-linux | SDL_GameControllerSetLED |
Marshalled onto the SDL thread; a no-op on a pad SDL reports no LED for. |
| dish-mac | GCDeviceLight.color (GCColor) |
Applied on the main actor. |
| dish-android | none | No controller-LED API: an arriving MSG_LIGHTBAR is logged and dropped, and CAP_LIGHTBAR is never advertised. |
Formatting, linting, and static analysis run through standard C++ tooling.
| Tool | Purpose | Config file | Equivalent in JS |
|---|---|---|---|
| clang-format | Auto-formatting | .clang-format |
Prettier |
| clang-tidy | Linting & modernization | .clang-tidy |
ESLint |
| cppcheck | Deep static analysis (leaks, UB, bounds) | n/a | n/a |
All three tools are available via winget:
winget install LLVM.ClangFormat # clang-format
winget install LLVM.LLVM # clang-tidy (included in full LLVM)
winget install Cppcheck.Cppcheck # cppcheck
clang-formatinstalls standalone viaLLVM.ClangFormatif you don't need the full LLVM toolchain.clang-tidyrequires the fullLLVM.LLVMpackage.
After installing, restart your terminal so the tools are on your PATH.
Format all source files:
clang-format -i src/core/*.cpp src/core/*.h src/net/*.cpp src/net/*.h src/adapters/*.cpp src/adapters/*.h src/platform/windows/*.cpp src/platform/windows/*.hLint with clang-tidy:
clang-tidy src/core/*.cpp src/net/*.cpp src/adapters/*.cpp src/platform/windows/*.cpp -- -std=c++17 -Ivigem/include -Isrc -Ilib -Ilib/libsodium/libsodium-win64/include -D_WIN32_WINNT=0x0A00 -DCPPHTTPLIB_NO_EXCEPTIONSRun cppcheck:
cppcheck --enable=all --std=c++17 --suppress=missingIncludeSystem -Ivigem/include -Isrc -Ilib -Ilib/libsodium/libsodium-win64/include src/- VS Code: Install the clang-format extension and set it as your default C++ formatter. Format-on-save will use the
.clang-formatconfig automatically. - clang-tidy warnings appear inline if you use clangd as your language server.
Unit tests use mock implementations of the port interfaces; no external test framework is required. CMake registers every suite with CTest:
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure| Suite | Covers |
|---|---|
session_service |
Declarative session upsert/converge, transactional replug, serial round-robin + quarantine, epoch/bitmap, close-notify ordering, liveness grace + reap, host-feature grants, data streams |
receiver |
Inner-message length guards, wire decode, deleted-opcode drop |
test_catalog |
Catalog JSON, Accept-Language resolution, ETag, locale completeness gate |
test_json |
Shared nlohmann/json helpers (tolerant accessors, ordered output) |
pairing |
Path-B pairing request registry lifecycle |
mdns_protocol / test_discovery / test_machine_id |
Discovery + identity |
test_codecs / test_ipv4_util |
Pure codecs (touchpad pack, IPv4 fast path) |
test_github_release / test_update_service |
OTA updater |
windows_platform |
Config persistence, autostart, hex/JSON helpers, HKDF + hmacProof + packet-AEAD vectors |
vigem_adapter |
Synchronous submit policy, DS4 EX fallback, observable unplug |
linux_platform (Linux only) |
Linux config/adapters |
├── src/
│ ├── core/ # Pure domain logic (no platform deps)
│ │ ├── types.h # Data structs, protocol constants, enums
│ │ ├── ports.h # Outbound port interfaces (hexagonal)
│ │ ├── session_service.* # Declarative session/controller converge
│ │ ├── catalog.* # /api/catalog builder + locale resolution
│ │ ├── json.h # Shared nlohmann/json facade (build + parse)
│ │ ├── config_json.h # Portable Config (de)serialization
│ │ └── update_service.* # OTA update state machine
│ ├── net/ # Shared transport layer (all platforms)
│ │ ├── receiver.cpp # UDP recv/decrypt hot loop
│ │ ├── inner_dispatch.cpp # Inner-message parser + length guards
│ │ ├── webserver.cpp # Server shells: HTTPS client API + loopback admin
│ │ ├── routes_client.cpp # Sender-facing routes: pairing, sessions, catalog
│ │ ├── routes_admin.cpp # Admin web UI routes + SSE
│ │ ├── routes_common.cpp # Helpers shared by both route tables
│ │ ├── session_crypto.* # HKDF session keys, hmacProof, packet AEAD
│ │ ├── pairing*.{h,cpp} # PIN pairing + Path-B request registry
│ │ ├── pin_rotation.cpp # Rotating pair PIN + shared crypto helpers
│ │ └── discovery / mdns_* # mDNS responder + legacy beacon
│ ├── adapters/ # Portable adapters
│ │ ├── client_adapter.* # IClientPort: encrypted UDP downstream
│ │ └── log_adapter.* # ILogPort: ring buffer logger
│ └── platform/
│ ├── windows/ # main, tray, config, ViGEm adapter (vigem*.{h,cpp})
│ ├── linux/ # main, tray, config, uinput adapter
│ ├── macos/ # main, tray, config, IOHIDUserDevice DS4 adapter
│ └── posix/ # config/crypto shared by the linux + macos builds
├── tests/ # Self-contained suites (no framework), one per area
├── docs/
│ ├── contract.md # THE client ↔ server protocol contract
│ └── architecture.md # Server internals
├── lib/ # cpp-httplib + libsodium (vendored)
├── vigem/include/ViGEm/ # ViGEm driver headers
├── web/ # Admin web UI + lang catalogs + catalog images
├── .clang-format # Code formatting rules
├── .clang-tidy # Linting checks
├── LICENSE # LGPL-3.0-or-later
└── README.md
PR-time security gates (.github/workflows/security.yml
and .github/workflows/codeql.yml) run on
every PR: action-pin lint, OSV-Scanner against vendored components,
gitleaks, GitHub dependency-review-action, allowlist-expiry check, and
CodeQL cpp analysis. See CONTRIBUTING.md
for the local-equivalent commands and the per-release verification recipe
(cosign verify-blob + slsa-verifier verify-artifact). Vulnerability
disclosure: SECURITY.md.
Branch protection. GitHub's branch-protection and repository- ruleset features are not available for private repositories on the free org plan this repo lives under, so direct pushes to
mainare not blocked at the platform level. The PR-based flow is a convention; the CI workflows (linux-ci.yml,macos-ci.yml,windows-ci.yml,security.yml,codeql.yml) are the quality gate.
Distributed under the terms of the GNU Lesser General Public License v3.0
or later. See LICENSE (LGPL) and COPYING.GPL3
(the GPL v3 the LGPL incorporates by reference).
The following components retain their original (MIT-compatible) licenses and are redistributed unchanged under their own terms:
- cpp-httplib by Yuji Hirose (MIT)
- ViGEm header definitions derived from nefarius/ViGEmBus (MIT)