feat: Moonlight-host (Sunshine/Apollo/Wolf) client support - #46
Open
emir-hasanbegovic wants to merge 14 commits into
Open
feat: Moonlight-host (Sunshine/Apollo/Wolf) client support#46emir-hasanbegovic wants to merge 14 commits into
emir-hasanbegovic wants to merge 14 commits into
Conversation
The IPv4/IPv6-capable ENet fork Moonlight hosts speak on the control stream, at the commit Wolf pins (4cde9cc). Unmodified upstream sources; wired into the build by a follow-up commit.
…XML, session FSM The GameStream client protocol as pure, testable modules under core/moonlight/: - MoonlightProtocol.h: ports, packet/input types, controller types, capability and button bitfields, per Wolf's docs and control.hpp. - MoonlightWire: fixed-offset encoders for CONTROLLER_MULTI (the hot path), CONTROLLER_ARRIVAL/MOTION/BATTERY, MOUSE_MOVE_REL, the periodic ping and TERMINATION, plus decoders for the four host->client events (rumble, trigger rumble, motion request, RGB LED). No allocations. - MoonlightControlCipher: AES-128-GCM seal/open of control packets with both EVP contexts reused across packets; IV construction matches the host side byte-for-byte and is pinned by captured-session fixtures. - MoonlightPairingCrypto + MoonlightPairing: the 5-phase PIN handshake client-side (AES-128-ECB, SHA-256, RSA-SHA256, self-signed identity generation), with all randomness injected so tests are deterministic. - MoonlightXml: serverinfo/applist/launch/pair response parsing. - MoonlightRtsp: request formatting, response parsing, the ANNOUNCE SDP. - MoonlightSessionMachine: the launch lifecycle as a total reducer FSM in the UsbPathMachine mould. Crypto semantics are ported from Wolf (MIT); THIRD_PARTY.md follows in the build wiring commit. 65 test cases pin byte-exact fixtures from the protocol docs and a captured session, malformed-input handling, tamper rejection and the FSM's full decision space.
…ild wiring The Qt/IO layer that turns the protocol core into a working second connection path, sitting beside the satellite pool as a sibling: - source/moonlight: MoonlightHttp (GameStream HTTP/HTTPS with the client cert and pairing-time cert pinning), MoonlightRtspClient (the RTSP handshake transport), MoonlightControlStream (the ENet control link; the CONTROLLER_MULTI hot path seals into a preallocated ring slot and hands ENet a NO_ALLOCATE packet, reusing one GCM context), a one-shot _nvstream._tcp mDNS discovery, the 5-phase PIN pairing flow, the MoonlightSession launch coordinator that runs the reducer FSM's effects, and MoonlightManager that owns identity, hosts, discovery, pairing and the live sessions. - repository: MoonlightIdentityRepository (one self-signed client identity per install) and MoonlightHostRepository (remembered hosts with their pairing anchor and picks), co-tenant in the connection store, with the host repo passing the shared repository contract. - Build: vendored ENet as the dish_enet static library, OpenSSL libcrypto and Qt Concurrent linked, all new sources wired into dish_core, and THIRD_PARTY.md documenting ENet (MIT), OpenSSL and the Wolf protocol reference plus the deliberately-avoided GPL sources. Local verification (this Windows host has no Qt-Linux build; WSL absent): every new Qt-free and Qt-based module below the app layer syntax-checks clean against msys2's gcc 16 + Qt 6.11, and the ENet C sources compile.
…l and the UI - AppModel owns the MoonlightManager beside the satellite pool, kicks a scan on start, folds its changes into stateChanged(), and routes the hot path: a slot bound to a Moonlight host falls through the satellite routing table into a Moonlight sender (CONTROLLER_MULTI + motion), guarded by the same routingMtx_. Inbound rumble and RGB LED reuse the SDL output plumbing the satellite path uses. - AppViewModel exposes the host list, scan/pairing state and the pair, connect, disconnect, forget, bind and emulated-type intents. - ConnectionsPage gains a Moonlight section: discovered plus manually added hosts, the PIN-to-type-into-the-host callout, and per-row pair/ connect/disconnect, all through the existing design kit. - Translations: the ten new user-facing strings, complete across all six catalogues (the sync + completeness gate passes locally).
…them periodically
The media-port hole punch had two robustness gaps:
- The SETUP responses' X-SS-Ping-Payload was parsed but never used, so a
host that matches sessions by it (Wolf's IP-less-connection extension,
SS_PING{payload[16], seq}) could not attribute our pings. The ping now
echoes that payload when the host supplied one, zero-padded/truncated
to the fixed 16-byte field with the sequence little-endian behind it;
the legacy 4-byte "PING" remains the fallback when none was given.
- A single datagram per port meant one lost packet left the host blind
to our media address. The session now re-pings both ports every 500 ms
from a QTimer until teardown (off the input hot path), owns the two
UDP sockets as members, and drains inbound datagrams without copying.
encodeRtpPing lives in the Qt-free wire module; three new test cases pin
the fallback bytes, the SS_PING layout and the pad/truncate behaviour
(574 assertions in 72 cases green in the portable harness).
…e build jobs Two remote-CI failures on the Moonlight branch, neither reproducible on the Windows dev host that wrote it: - clang-tidy (gcc leg, exit 123). AppModel::bindMoonlightSlot moved into QHash::insert, whose value parameter is a const reference, so the move never happened and performance-move-const-arg flagged both calls. Now assigns through operator[], which returns T& and does move. The three ENet forward declarations tripped bugprone-reserved-identifier and its two cert aliases. Including <enet/enet.h> is not open to us: dish_enet is a PRIVATE link dependency of dish_core, so its include directory does not reach the library's consumers, and this header rides up to AppModel.h, which would pull the socket headers into most of the tree. Suppressed the way SDLGamepadBridge.h already suppresses SDL2's struct tags, with the reasoning written down beside it. - Package (.deb) configure failed on "Could NOT find OpenSSL": the job's apt list was never updated when libcrypto became a dependency. Added libssl-dev there, in the shared setup-build-deps action, in CodeQL and in all three release build jobs (openssl-devel for the Fedora/RPM one), and libssl-dev to the README's dependency list. The .deb's runtime Depends needs no change: CPACK_DEBIAN_PACKAGE_SHLIBDEPS derives libssl3 from DT_NEEDED exactly as it already does for libsodium and SDL2. Local verification: clang-tidy 22.1.8 against the fleet .clang-tidy with --warnings-as-errors='*' reproduces all three reserved-identifier errors on the old header and reports none on the new one; a QHash-shaped reproducer shows insert(k, std::move(v)) flagged and h[k] = std::move(v) clean. The header syntax-checks under gcc and clang alongside the real <enet/enet.h> in the .cpp's include order. Portable Moonlight harness 574 assertions in 72 cases green; clang-format 22.1.4 clean over src and tests; QML literal scanner, translation sync/completeness and the action pin lint all pass.
The remaining five findings from the same CI sweep, none of which the
first pass covered:
- MoonlightHostRepository::load moved into QHash::insert on both the
object and the legacy-array branch, where the const-reference value
parameter turns the move into a copy. Assigns through operator[]
instead, hoisting the key on the array branch so it is not read out of
the host the same statement moves from.
- MoonlightManager took its shared QSettings by value and then only
copied it into three collaborators. Takes it by const reference now;
it shares the pointer rather than sinking it, unlike the repositories.
- stepStreamId's default arm repeated its return type; returns {}.
- pinFromRandom dropped snprintf's return. Marked (void) with the reason,
matching main.cpp's (void)std::fprintf and UnixSignalWatcher's
(void)::signal: "%04u" of a value below 10000 writes exactly four
digits into a five-byte buffer, so there is nothing to check.
Local verification: clang-tidy 22.1.8 now runs on the real sources here,
driving it at the mingw target over msys2's gcc 16 and Qt 6.11 headers.
All five files come back clean under the fleet .clang-tidy with
--warnings-as-errors='*', as do MoonlightControlStream.{h,cpp}. The four
files also compile clean under -Wall -Wextra -Wold-style-cast -Wshadow
-Wconversion -Werror against that Qt. Portable harness still 574
assertions in 72 cases green, which covers pinFromRandom; clang-format
22.1.4, the QML literal scanner and the translation gates all pass.
dish-android hit six real protocol bugs against a live Sunshine host and fixed them there. Each one was checked against this client as it actually is; four applied, two were already right, and one does not apply at all. WHAT WAS BROKEN HERE RTSP reused one socket for the whole handshake. A Moonlight host answers exactly one message per TCP connection and then hangs up on its own, so everything after OPTIONS was written into a socket the host had already closed. Every request now dials its own socket, and the reply is framed by Content-length when the host sends one and by the close when it does not, which is the shape the DESCRIBE reply arrives in. The ANNOUNCE SDP carried 17 attributes. A host looks each one up by name and a lookup that misses is fatal: a minimal set is answered 400 BAD REQUEST. It now carries the full 32, matching what a real client sends. CONTROLLER_ARRIVAL was seven body bytes. The host reads it out of a naturally aligned struct, so the u32 button mask starts at offset 4 and offset 3 is reserved. Seven bytes shifted every field after the type by one and the host logged capabilities [FF03] and supportedButtonFlags [000000FF] instead of [0003] and [0000FFFF]. The RTP media pings only started once the ENet control link came up. The host counts its initial-ping deadline from its own session start, so the sockets now open and the pings start the moment SETUP names the ports. localAudioPlayMode was 0, which asks the host not to play audio locally. That is right for a remote viewer and wrong for a dish, whose user is sitting at the host with the pad in their hands: it silenced their own speakers for the length of the session. It is 1 now. sops stays 0. The launch mode was a fixed 1280x720x30. A virtual-display host follows what the client asks for, so a small request resized the user's desktop under them. serverinfo's SupportedDisplayMode rows are parsed and the largest is requested, with the fastest refresh offered at that size. A refusal was read from the HTTP status line. A Moonlight host says no in the BODY: a second /launch is answered HTTP 200 carrying status_code="400" and "An app is already running on this host", plus a <resume> flag. The body is parsed now: a resumable refusal promotes the attempt to /resume, an unresumable one ends it with its own reason token and a message that says what happened. Every XML endpoint was audited for the same mistake, and a reply that names no status_code at all is read as success, which is what a plainly-answering host sends. Qt shares and persists TLS sessions across the connections one QNetworkAccessManager makes, and a resumed session skips a Moonlight host's verify callback, which Sunshine answers with a fatal internal_error alert and no log line at all. Session tickets, sharing and persistence are all off now. WHAT WAS ALREADY RIGHT, NOW PINNED The control-stream GCM IV already kept only the low byte of the sequence, the way the host builds it. There is now a byte-exact test that sequences 256 apart seal identically, which a four-byte IV would fail; that is the fault that ends a session just past two minutes. X-SS-Connect-Data already parsed unsigned. It now reads 64 bits wide and narrows to the 32 ENet puts on the wire rather than rejecting an over-wide value into a silent zero, and is pinned above and below INT32_MAX including the live host's own 4270471497. WHAT DOES NOT APPLY ENet unknown-command acknowledgement. That was a fault in a hand-written port. This repo vendors the real MIT ENet, whose protocol.c handles BANDWIDTH_LIMIT explicitly and queues an acknowledgement for any command carrying the ACKNOWLEDGE flag, and the service loop already runs continuously rather than only when input changes. LOGGING One dish.moonlight category at the QtInfoMsg floor covers the whole path: every RTSP request and reply with the step it belongs to, the step a mid-handshake hang-up died on, each pairing phase with what the host said in the body, the launch status and the parsed ports, the control link coming up and going down, and every give-up branch by name. TESTS Byte-exact wire fixtures for the 20-byte SS_PING built from the live host's own payload, with a regression that the encoder never emits a datagram in the 5..19 dead zone the host drops silently; the CONTROLLER_ARRIVAL alignment read back the way the host reads it; the GCM IV wrap across the 256 boundary; the connect token above and below INT32_MAX; the whole ANNOUNCE attribute set line by line; the in-body refusal in every shape including the resumable one; the display-mode pick; the reducer's launch-to-resume promotion and its no-loop guard; and a new socket-level suite that drives the transport against a loopback host, asserting three requests are three connections and that a reply with no Content-length is read to the close.
The one guarantee the mutual-TLS calls make that leaves no trace anywhere else. A resumed session skips a Moonlight host's verify callback, and Sunshine answers that with a fatal internal_error alert and no log line at all, so a regression here would look exactly like an unreachable host. Lifts the configuration out of perform() into a static MoonlightHttp::tlsConfiguration so it can be asserted directly: the three switches are set, peer verification is off because trust is the pairing pin, and an unparsable identity is dropped rather than half-applied. The non-vacuity check asks only that Qt's own default would still offer a session by at least one route, so which switch Qt leaves on stays Qt's business.
…e binding Connecting to a Moonlight host was modelled as a connection, and it is not one. Moonlight has no bidirectional liveness: pairing is one-time trust, checkable only when the client asks (a /serverinfo PairStatus, or a mutual-TLS handshake that succeeds, which is itself proof), and a host-side unpair is discovered on the next call and never announced. The hosts screen drew that as a pulsing status dot beside a Connect button, which promised a link nobody can observe. The session was modelled per binding, and it is not that either. One Moonlight session carries up to four controllers behind a single launch, so it belongs to the HOST. Two pads on one host used to mean two /launch calls, the second of which the host answers with "an app is already running". WHAT MOVED The Moonlight inventory leaves ConnectionsPage for a page of its own, the way it already is in dish-windows: the two host kinds pair differently, so one merged list made "Pair" and the status column each mean two things. Connections keeps a labelled doorway to it. That page now owns pairing, forgetting, and one Quit session escape hatch, and nothing else. The emulated-device picker, the app picker and Connect are gone from it. Connect is implicit: binding a controller starts or joins the host's session, and unbinding the last one ends it. A row states TRUST, in three words, and never liveness: paired when this visit confirmed it, remembered when a certificate is stored and the host did not answer, not paired otherwise. It is re-asked on entering the screen and before a session starts, and never polled. THE BINDING FLOW The wizard gains a conditional sixth page, WizardSessionPage, between Type and Feel, shown only for a Moonlight destination and skipped entirely otherwise. Configure binding renders the same file inline, so the two editors cannot tell a user two different stories about one host. It renders exactly one of twenty-one states, and applying is NEVER blocked by any of them except a host already carrying four controllers. A binding is a durable intent: the session is attempted when the pad is used, not when the user presses Apply, so an unpaired, unreachable, refusing or dropped host is a state with an action in it and not a refusal of the answer the user gave. The app is per SESSION. The binding that creates one picks it from /applist; every later binding shows what is running and is offered no picker at all, because a disabled picker implies a choice that does not exist. The controller type is per BINDING, so one host can carry a PlayStation pad and an Xbox pad at once. /applist finally has a caller. It is HTTPS and paired-only, so a refusal is reported as a failure rather than as an empty list: reading a 404 as "no apps" would present it as a fact about the host, and the two states say different things to the user. WHAT THE HOST WILL NOT TELL US No Moonlight host reports what its emulated controllers can do. /serverinfo carries no controller element; plaintext /applist answers 404 and its rows carry only title, id and an HDR flag; Wolf's serverinfo() signature has no field to put it in. The data flows the other way: the host builds its virtual device FROM the type byte and capability bitfield we declare, and never reports the choice back, and it may override it silently. So the capability table is client-side knowledge, hard-coded in core/moonlight/MoonlightPadSlots.h from what the reference host actually builds per type. Xbox and Nintendo are sticks, buttons, analog triggers and body rumble; PlayStation is the only one that carries trigger rumble, touch, motion, battery and an LED. Nintendo has NO motion over Moonlight, unlike the satellite switchpro type: the two are different type systems that happen to share names. The type cards say so, and the copy never promises the host will honour the pick. Auto resolves on the CLIENT, before the wire: a pad reporting gyro or an accelerometer becomes PlayStation, everything else Xbox. It is the only rule that both matches the reference host's own promotion of an unknown-with-motion pad and lets the card state what the pad will support. The declared bitfield is the type's ceiling intersected with what the source can actually deliver, because declaring a capability the pad does not have makes the host request reports that never arrive. TWO WIRE CHANGES supportedButtonFlags is 0x0000FFFF, not 0xF7FF. Bit 0x0800 is named in neither Wolf's table nor its control.hpp, and the live host logs the whole low half back; three Dish clients advertising three different masks is drift nothing gains from. The Auto sentinel is 0xFF in the store, and a persisted 0 migrates on read. 0 is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to pick: a different promise from "match the pad". REFERENCE COUNTING MoonlightSession owns the controller numbers and the CONTROLLER_MULTI active mask. The first binding on a host starts or joins the session and announces its pad; later bindings make no HTTP call at all and only send their own CONTROLLER_ARRIVAL. An unbind clears the pad's bit and sends the unplug; the last one hands the app back with /cancel, because nothing is riding it any more. A live session with controllers still on it is left alone: closing somebody's game out from under them is worse than the tidying is worth. The mask is published to the hot path through one atomic, so sendControllerState still costs no lookup and no allocation. Host events now carry the controller number through, because one session drives four pads and an event that named none of them could only be applied to the wrong one. TESTS The twenty-one states walked one by one with the token each renders, and the guarantee that exactly one of them blocks Apply. The capability ceiling per type, including the Nintendo motion rule and the intersection with the source. Auto resolution both ways, the 0xFF sentinel and the migration from a persisted 0 and from anything out of range. Controller numbers: the lowest free index, no reuse while live, the mask, and the fifth pad refused. The reference count end to end against the real coordinator: four pads on one host, one session object, three unbinds that leave the launch alone and a fourth that stops it. And the /applist caller reporting a paired-only refusal as failed rather than empty.
…the QHash QHash::size() is a signed qsizetype, so returning it as the std::size_t the refcount contract is written in trips -Wsign-conversion, which this repo builds with -Werror. The controller numbers are the authority for how many pads ride a session anyway; the declaration map is a lookup beside it, and the two can only ever agree.
…s own title DishTests links Catch2 and not Qt6::Test, so <QSignalSpy> does not resolve there; every other suite that watches a signal counts it with a small local struct instead, and the applist test now does the same. The local Qt harness this was written against had linked Qt6::Test, which is exactly why the fault reached CI, and it no longer does. The session section also drew a heading above the empty state and the error banner, both of which already carry a title of their own, so those four states said the same sentence twice on one screen.
A Moonlight host refuses for reasons of its own and phrases them itself, in the status_message of a body it answers HTTP 200 with. The refused state paraphrased that away and printed only which host had said no, dropping the one detail the user could act on. The session keeps the message verbatim now, falling back to the status code when a host names one without words, and the banner reads it. Also stops four states saying the same sentence twice: the empty state, the error banner and the loading spinner each draw their own title, so the heading above them stands down rather than repeating it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
Adds a second connection path to the Dish client: alongside the existing
Satellite (protocol-1) flow, a controller can now stream to any Moonlight
(GameStream) host, i.e. Sunshine, Apollo/Vibepollo, or Wolf. The Moonlight path
sits beside the Satellite session code as a sibling and shares the same UI and
controller plumbing.
Scope: discovery, PIN pairing, the app pick, session launch, controller input
out, and rumble / trigger-rumble / motion-request / RGB-LED events back. There
is no video or audio decoding: the media streams are negotiated at the floor
bitrate and their payloads are discarded: the client only hole-punches the media
ports so the host sees them open, and drains whatever arrives without decoding
it.
The work lands in two passes, both in this PR: a protocol-parity pass against
the faults dish-android measured on a live Sunshine host, and a UX restructure
that puts pairing and the session where the protocol actually puts them.
Protocol scope
resume, cancel. The client presents a self-signed cert; the host's cert is
pinned at pairing time and every later TLS reply is checked against it.
per message. Ports and the control connect-data are taken from the SETUP
responses, never hardcoded.
ways. CONTROLLER_MULTI is the hot path; PERIODIC_PING keeps the session alive;
TERMINATION on quit. Inbound RUMBLE_DATA, RUMBLE_TRIGGERS, MOTION_EVENT and
RGB_LED are handled; unknown types are ignored gracefully.
long-lived socket per stream, started the moment SETUP names the port and
repeated every 500 ms until teardown. Inbound media is drained and discarded.
_nvstream._tcp.local.plus manual host entry.Protocol parity pass
dish-android found six real protocol faults against a live Sunshine host
(2026.516.143833). Every one was checked against this client as it actually is
before anything was changed. Four applied and are fixed, two were already
correct and are now pinned by tests, and one does not apply to this repo at all.
Fixed here
socket for the whole handshake, so everything after OPTIONS was written into a
socket the host had already closed. Every request now dials its own socket.
The reply is framed by
Content-lengthwhen the host sends one and by theclose when it does not, which is the shape the DESCRIBE reply arrives in.
each attribute up by name and a lookup that misses is fatal, so a minimal set
is answered
400 BAD REQUEST. It now carries the full 32, matching what areal client sends.
seven bytes. The u32 button mask starts at offset 4 and offset 3 is reserved,
so seven bytes shifted every field after the type by one and the host logged
capabilities [FF03]andsupportedButtonFlags [000000FF]instead of[0003]and[0000FFFF]. The body is eight bytes now, packet_len 16 anddata_size 12.
link came up, but the host counts its initial-ping deadline from its own
session start. The sockets now open and the pings start at SETUP.
localAudioPlayModewas 0. That asks the host not to play audio locally,which is right for a remote viewer and wrong for a dish, whose user is sitting
at the host with the pad in their hands: it silenced their own speakers for
the length of the session. It is 1 now.
sopsstays 0.what the client asks for, so a small request resized the user's desktop under
them. serverinfo's
SupportedDisplayModerows are parsed and the largest isrequested, with the fastest refresh offered at that size, falling back to
1920x1080x60 when a host advertises none.
/launchisanswered HTTP 200 carrying
status_code="400",status_message="An app is already running on this host"and a<resume>flag. The body is parsed now:a resumable refusal promotes the attempt to
/resume, an unresumable one endsit with its own reason token and a message that says what happened, and the
manager exposes
/cancelfor the flow that needs it. Every XML endpoint wasaudited for the same mistake, and a reply that names no
status_codeat allis read as success, which is what a plainly-answering host sends. A launch
that never reaches Streaming is handed back with
/cancelso no app isstranded for the next attempt to trip over.
connections one
QNetworkAccessManagermakes, and a resumed session skips aMoonlight host's verify callback, which Sunshine answers with a fatal
internal_erroralert (RFC 8446 alert 80) and no log line at all. Sessiontickets, sharing and persistence are all off now.
Already correct, now pinned
the host builds it. A test now asserts byte-for-byte that sequences 256 apart
seal identically, which a four-byte IV would fail. That is the fault that ends
a session just past two minutes.
X-SS-Connect-Dataalready parsed unsigned. It now reads 64 bits wide andnarrows to the 32 ENet puts on the wire, rather than rejecting an over-wide
value into a silent zero, and is pinned above and below INT32_MAX including
the live host's own 4270471497.
Does not apply
port. This repo vendors the real MIT ENet, whose
protocol.chandlesBANDWIDTH_LIMITexplicitly and queues an acknowledgement for every commandcarrying
ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE, and the service loopalready runs continuously on a 2 ms tick rather than only when input changes,
so
peer->lastReceiveTimekeeps advancing.UX restructure: connecting is pairing, and the session is per host
Two things were modelled wrongly, and the UI promised both of them.
Connecting to a Moonlight host is PAIRING. Moonlight has no bidirectional
liveness. Pairing is one-time trust, checkable only when the client asks (a
/serverinfoPairStatus, or a mutual-TLS handshake that succeeds, which isitself proof), and a host-side unpair is discovered on the next call and never
announced. The old hosts section drew that as a pulsing status dot beside a
Connect button, which promised a link nobody can observe.
The session belongs to the HOST, not to a binding. One Moonlight session
carries up to four controllers behind a single launch. Two pads on one host used
to mean two
/launchcalls, the second of which the host answers with "an appis already running".
The hosts screen
The Moonlight inventory leaves
ConnectionsPageforMoonlightHostsPage.qml,matching dish-windows: the two host kinds pair differently, so one merged list
made "Pair" and the status column each mean two things. Connections keeps a
labelled
Kit.RowButtondoorway to it.That page now owns pairing, forgetting, and one
Quit sessionescape hatch, andnothing else. The emulated-device segmented control, the app sheet and
Connectare gone. Connect is implicit: binding a controller starts or joins the host's
session, and unbinding the last one ends it.
A row states TRUST, in three words, and never liveness:
Pairedwhen this visitconfirmed it,
Rememberedwhen a certificate is stored and the host did notanswer,
Not pairedotherwise. It is re-asked on entering the screen and beforea session starts, and never polled. Amber is kept for problems: a handshake in
flight reads neutral, not warning. The row glyph is no longer the satellite
silhouette, and the PIN is minted in C++ rather than by
Math.random().The binding flow
The wizard gains a conditional sixth page,
WizardSessionPage.qml, between Typeand Feel, shown only for a Moonlight destination and skipped entirely otherwise.
lastStepis 5,pageForStepshifts Feel and Review, andKit.WizardBannertakes a
subStepCountso stage 3 draws four pips for a Moonlight binding andthree for a satellite one. Configure binding renders the same file inline, so
the two editors cannot tell a user two different stories about one host.
It renders exactly one of twenty-one states, derived in C++ as lowercase tokens
(
core/moonlight/MoonlightSessionUi.h) and localized in QML, the same rule thecapability solver and the link vocabulary already follow. The states are:
checking, not paired, pairing PIN, pairing refused, unreachable, remembered,
trust lost, host replaced, apps loading, new session, no apps, app list
unreadable, joining, host full, refused by another device, rejoin refused,
refused for the host's own reason, stream setup failed, live, dropped, ended by
host.
Applying is never blocked by any of them except a host already carrying four
controllers. A binding is a durable intent: the session is attempted when the
pad is used, not when the user presses Apply, so an unpaired, unreachable,
refusing or dropped host is a state with an action in it and not a refusal of
the answer the user gave. The four-pad ceiling is a hard protocol limit and says
so, and it is judged before anything the network could change, so it can never
sit behind a spinner while Apply is enabled for a bind that will refuse.
The app is per SESSION. The binding that creates one picks it from
/applist;every later binding shows what is running and is offered no picker at all,
because a disabled picker implies a choice that does not exist. The controller
type is per BINDING, so one host can carry a PlayStation pad and an Xbox pad at
once.
No state is modal: a flapping session would raise a dialog the user cannot
outrun.
The capability table, and why it is hard-coded
No Moonlight host reports what its emulated controllers can do.
/serverinfocarries no controller element; plaintext
/applistanswers 404 and its rowscarry only title, id and an HDR flag; Wolf's
serverinfo()signature has nofield to put it in. The data flows the other way: the host builds its virtual
device FROM the type byte and capability bitfield the client declares, never
reports the choice back, and may override it silently.
So the table is client-side knowledge, in
core/moonlight/MoonlightPadSlots.h,taken from what the reference host actually builds per type. Xbox and Nintendo
are sticks, buttons, analog triggers and body rumble; PlayStation is the only
one carrying trigger rumble, touch, motion, battery and an LED. Nintendo has
no motion over Moonlight, unlike the satellite
switchprotype: the two aredifferent type systems that happen to share names. The type cards show the same
three rows every existing card shows, through the same
Kit.CapabilityTableand the same solver, and the copy never promises the host will honour the pick.
Auto resolves on the CLIENT, before the wire: a pad reporting gyro or an
accelerometer becomes PlayStation, everything else Xbox. It is the only rule
that both matches the reference host's own promotion of an unknown-with-motion
pad and lets the card state what the pad will support. The declared bitfield is
the type's ceiling intersected with what the source can actually deliver,
because declaring a capability the pad does not have makes the host request
reports that never arrive.
Reference counting
MoonlightSessionowns the controller numbers and the CONTROLLER_MULTI activemask. The first binding on a host starts or joins the session and announces its
pad; later bindings make no HTTP call at all and only send their own
CONTROLLER_ARRIVAL. An unbind clears the pad's bit and sends the unplug; the
last one hands the app back with
/cancel. A live session with controllersstill on it is left alone: closing somebody's game out from under them is worse
than the tidying is worth.
The mask is published to the hot path through one atomic, so
sendControllerStatestill costs no lookup and no allocation. Host events carrythe controller number through, because one session drives four pads and an event
that named none of them could only be applied to the wrong one.
A Moonlight binding is now a binding everywhere: the slot list reports its
boundConnectionId, the host slot accounting counts it, and Unbind means thesame thing for both destination kinds. A Moonlight host refuses a fifth pad
rather than displacing one, so the copy that names a pad about to be unbound is
suppressed for it.
Two wire changes
supportedButtonFlagsis0x0000FFFF, not0xF7FF. Bit0x0800is named inneither Wolf's button table nor its
control.hpp, the live host logs thewhole low half back, and three Dish clients advertising three different masks
is drift nothing gains from. dish-android's value is the live-proven one.
0xFFin the store, and a persisted0migrates onread.
0is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to pick:a different promise from "match the pad". The same value is exposed to QML so
the picker cannot fork it.
/applisthas a callerIt is HTTPS and paired-only, so a refusal is reported as a failure rather than
as an empty list: reading a 404 as "no apps" would present it as a fact about
the host, and the two states say different things to the user. A readable reply
is also proof of trust, because the mutual-TLS handshake behind it is exactly
what pairing establishes; a 401 is recorded as trust lost.
No radio buttons
The ban is on radio LISTS and controls. This repo has none of the offenders (no
RadioButton,RadioDelegate,ButtonGrouporKit.RadioTile), and none wereintroduced.
Kit.RadioMarkstays inside the type card: it is a selection dotinside an ordinary
AbstractButton, and removing it from the Moonlight cardsalone would fork the one component both host kinds render through.
Accessible.role: Accessible.RadioButtonstays on the cards and rows, becausescreen readers need one-of-N semantics and that is invisible to sighted users.
On-screen controller
There is none in this repo. The README says so outright ("Physical controllers
only. There is no on-screen touch gamepad"), and no virtual slot exists in the
model. Its equivalent here is the wizard's destination step, which now offers a
Moonlight section of its own with the trust word on each row, and Configure
binding offers the same rows inline.
Session and RTSP logging
One
dish.moonlightlogging category at the QtInfoMsg floor covers the wholepath: every RTSP request and reply with the step it belongs to, the step a
mid-handshake hang-up died on, each pairing phase with what the host said in the
body, the launch status and the parsed ports, the control link coming up and
going down, and every give-up branch by name.
Key modules
src/core/moonlight/(Qt-free, unit-tested): protocol constants, byte-exactwire encoders/decoders, the AES-GCM control cipher (contexts reused, no
per-packet allocation), the pairing crypto (AES-128-ECB, SHA-256, RSA-SHA256,
self-signed identity), the pairing state machine, XML and RTSP
parsing/formatting, the session-launch reducer FSM, and the button map.
src/source/moonlight/: the HTTP/RTSP/ENet transports, discovery, the pairingflow, the session coordinator (runs the FSM's effects), the shared logging
category, and the manager that owns identity, hosts, discovery, pairing and
the live sessions.
src/repository/: the per-install client identity and the remembered-hoststore, co-tenant in the connection-store settings.
third_party/enet/: the vendored cgutman ENet fork (MIT), built asdish_enet.src/core/moonlight/MoonlightPadSlots.h: controller-number allocation, theactive mask, and the hard-coded capability table, all pure so every bind
decision is testable without a socket or a pad.
src/core/moonlight/MoonlightSessionUi.h: the twenty-one-state rendercontract as one pure total function, plus the trust and phase vocabularies.
src/qml/pages/MoonlightHostsPage.qmlandsrc/qml/wizard/WizardSessionPage.qml: the hosts inventory and the sessionstep, the second of which both editors render.
with Moonlight bindings reported through the same slot and accounting surfaces
the satellite path uses.
Hot path
CONTROLLER_MULTI encode + encrypt + send performs no per-packet heap allocation:
the encoder writes at fixed offsets into a caller buffer, the cipher seals into a
preallocated ring slot, and ENet is handed that slot with
ENET_PACKET_FLAG_NO_ALLOCATE. One GCM context is reused across packets.Test coverage
Fourteen Catch2 suites. Both subsets were built and run on this host: the
portable (Qt-free) core is 137 test cases and 1161 assertions, and the Qt-side
subset (RTSP framing over a real socket, the TLS configuration, the host
repository and the binding reference count) is 34 cases and 204 assertions.
Protocol:
captured session; decoder tests for all four host to client events including
short and malformed buffers.
SS_PINGbuilt from the live host's ownX-SS-Ping-Payload,asserted to be those 16 ASCII bytes verbatim and not their hex decoding, plus
a regression that the encoder never emits a datagram in the 5..19 byte dead
zone a host drops silently.
emulated type and the whole capability bitfield, with the advertised button
word pinned at
0x0000FFFF.the 256 boundary asserted byte-for-byte, tamper and misframing rejection.
UINT32_MAX, and its narrowing above that.
<resume>,a non-busy refusal, a 2xx that merely mentions the phrase, and a reply with no
status_codeat all; plus the display-mode pick.requests are three connections, a reply with no
Content-lengthis read tothe close, a declared body split across writes is not truncated, a
mid-handshake hang-up and a half-sent head are refused rather than misparsed.
else: all three resumption switches off, peer verification off because trust
is the pairing pin, and an unparsable identity dropped rather than
half-applied.
full 5-phase handshake round-trip in both directions, with wrong-PIN,
substituted-cert and tampered-signature rejection.
host repository contract.
The session reducer, now covering the states the flow needs:
late completion.
uniqueid we do not remember named before pairing is judged at all.
/resumedistinguished from a refused/launch, and the launch toresume promotion with its no-loop guard.
as a setup failure, so Reconnect is never offered where it cannot work.
The binding flow:
that no two states share a token, and that exactly one of them blocks Apply.
intersection with what the source can deliver, including a pad with no motors
still declaring its analog triggers.
0xFFsentinel, and migration from a persisted0and from anything out of the picker's range, at both the pure-logic andthe repository layer.
active mask across attach and release, and the fifth pad refused.
session object, a second binding that joins rather than launching again, three
unbinds that leave the launch alone and a fourth that stops it, a freed index
handed to the next binding, a re-bind of a live slot treated as a restart, a
binding to an unpaired host still recorded, and forgetting a host dropping the
bindings that rode it.
/applistcaller reporting a paired-only refusal as failed rather thanempty, and the remembered app seeding the session that a later binding joins.
Licensing
Wolf (MIT) was the protocol reference; adapted logic is attributed in
THIRD_PARTY.md. The GPL-3 Moonlight sources (moonlight-common-c, moonlight-qt,
moonlight-android, Sunshine, Apollo) were deliberately not consulted, to keep
this LGPL-3 project clear. ENet is vendored under MIT; OpenSSL libcrypto is
linked from the system for the AES/RSA/X.509 primitives libsodium does not offer.
CI status
Every check on the head commit passes. Both
Build, test, lint, formatmatrix legs (gcc and clang),
Sanitizers (address+undefined),Sanitizers (thread),Coverage,Package (.deb) and launch it, CodeQL, theversion-consistency gate, and all five security checks including gitleaks. That
covers the full Debug and Release builds, the complete ctest run under both,
qmllint with the generated
Dish.Chromemodule resolved, the QML literalscanner, the translation sync and completeness gate, clang-tidy, the
hardening-flag assertions, and installing the built .deb and launching it.
GitHub Actions was in a declared major outage for most of this work, which is
why earlier runs on this branch show
startup_failureor sat queued for hours.One run did land mid-way, on
b021dc5, and caught a real fault the developmenthost could not:
#include <QSignalSpy>in the new binding-refcount test, becauseDishTestslinks Catch2 and notQt6::Test. It is fixed with the same local spystruct every other suite in this repo uses, and the local harness that had masked
it no longer links
Qt6::Testeither.Verified locally as well
This is a Linux-target repo and the development host is Windows with no WSL
installed, so the following stand in for the parts of CI that cannot run there:
with msys2 gcc 16 + OpenSSL 3.6.2.
the RTSP framing against a real loopback QTcpServer, the TLS configuration,
the host repository, and the binding reference count driven through the real
MoonlightManagerandMoonlightSession..clang-tidyand--warnings-as-errors='*'.-Werrorover every changed source, includingAppModel.cppandAppViewModel.cppcompiled through a POSIX-header shim.It caught one real fault: a signed
qsizetypereturned asstd::size_t.are actually scanned, and the translation gate:
lupdateplus thesource-language seed is idempotent, and all six catalogues are 1079/1079
translated. Every one of the 117 new strings has real words in Bosnian,
German, Spanish, French and Brazilian Portuguese.
Still not covered by any gate
was in use by other clients' testing throughout, so nothing in either pass was
confirmed on the wire from dish-linux. This client remains unproven against a
live host.
but was never drawn, so layout and binding behaviour on the new and changed
pages is unverified by eye.
Known gaps and risks
format is pinned byte for byte, the RTSP framing is proven against a loopback
host, and the reference count is proven against the real coordinator, but no
end-to-end pairing plus stream from this client has been run against a real
Sunshine, Apollo or Wolf host. In particular the TLS session-resumption fix,
the audio play mode, the display-mode request, the four-pad session and the
CONTROLLER_ARRIVALcapability table have never been observed on the wire.on CI, but was never drawn, so layout and binding behaviour on the six new and
changed pages is unverified by eye.
the active mask published through an atomic, slot lifecycle) passes the TSan
leg, but only over the code paths the suite exercises: it still wants a
device-in-the-loop pass with a real session up.
only, never a decoder.
bit yet, so it is not mapped.
streamingSlotCount, so it does notinhibit the display sleep the way a satellite session does. Separate change.
the new contract (the advertised button mask, and a post-live control loss now
reported as a drop); nothing was weakened or removed.