Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7563dd1
feat: Moonlight-host (Sunshine/Apollo/Wolf) client support
emir-hasanbegovic Aug 25, 2026
b92d231
feat: Moonlight connect flow screens and the native SLOT_MOONLIGHT pa…
emir-hasanbegovic Aug 25, 2026
a00046c
test: mint the Moonlight pairing identities at runtime, not from comm…
emir-hasanbegovic Aug 25, 2026
2ae156d
fix: unblock Moonlight PIN pairing (plaintext phases, TLS-capable cli…
emir-hasanbegovic Aug 25, 2026
20eccdf
fix: give pairing phase 1 a human-length read timeout
emir-hasanbegovic Aug 25, 2026
e2b2919
fix: stop the XML hardening from disabling the parser outright on And…
emir-hasanbegovic Aug 25, 2026
ce9cbf3
fix: give the mutual-TLS half its own socket per request
emir-hasanbegovic Aug 26, 2026
cf70502
docs: correct the cleartext config's account of the Moonlight halves
emir-hasanbegovic Aug 26, 2026
d6ba469
fix: stop the mutual-TLS half offering the host a session to resume
emir-hasanbegovic Aug 26, 2026
abfa9c4
fix: get the RTSP stream setup through to a live host's control channel
emir-hasanbegovic Aug 26, 2026
9226925
fix: keep a live Moonlight session up instead of losing it in the fir…
emir-hasanbegovic Aug 26, 2026
31ce7d4
fix: bound the media-ping drain and stop reporting a success as a fai…
emir-hasanbegovic Aug 26, 2026
e76d803
docs: say what the ENet port actually took from the reference
emir-hasanbegovic Aug 26, 2026
fffc020
refactor: drop the give-up flag nothing reads
emir-hasanbegovic Aug 26, 2026
d73226b
feat: move the Moonlight session into the binding, where the controll…
emir-hasanbegovic Aug 26, 2026
36c00ad
docs: comment the Moonlight session surface like the code beside it
emir-hasanbegovic Aug 26, 2026
51012d4
docs: make the trust-chip note true of both callers
emir-hasanbegovic Aug 26, 2026
190ddad
fix: make a Moonlight pairing stick, and stop a binding outliving its…
emir-hasanbegovic Aug 26, 2026
7ccf380
fix: take forget off the caller's thread, and cancel before the pin goes
emir-hasanbegovic Aug 26, 2026
1e503a3
fix: ask the only thing that can answer whether we are paired
emir-hasanbegovic Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions THIRD_PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,61 @@ project's parsers:

The Linux kernel is licensed GPL-2.0-only. Upstream:
https://github.com/torvalds/linux

## Wolf (Games on Whales): Moonlight protocol facts

The Moonlight (GameStream) client path in `app/src/main/java/com/tinkernorth/dish/core/net/moonlight/`
and `app/src/main/java/com/tinkernorth/dish/source/connection/moonlight/` derives its wire
formats, struct layouts, pairing crypto steps, control-stream packet framing, and RTSP handshake
from Wolf's documentation and its host-side (server) implementation:

- Control packet framing and AES-GCM sealing, plus the input/event struct layouts, follow
`src/moonlight-protocol/moonlight/control.hpp` and `docs/.../control-specs.adoc` /
`input-data.adoc`. The unit tests pin our encoder and sealer byte-for-byte against Wolf's
captured vectors in `tests/testControl.cpp` and `tests/testCrypto.cpp`.
- The 5-phase PIN pairing crypto (AES key derivation, ECB challenge exchange, SHA-256 hashes,
RSA signatures) mirrors Wolf's server logic in `src/moonlight-protocol/moonlight.cpp` and
`src/moonlight-server/rest/endpoints.hpp`, implemented as the client counterpart.
- The RTSP request/response shapes follow `docs/.../rtsp.adoc` and
`src/moonlight-protocol/rtsp/parser.hpp`.

No Wolf code is compiled into this project; only protocol facts and struct layouts were reused
and reimplemented in Kotlin. Wolf is licensed MIT. Upstream:
https://github.com/games-on-whales/wolf

## cgutman/enet: ENet client subset (ported to Kotlin)

`app/src/main/java/com/tinkernorth/dish/core/net/moonlight/enet/` is a minimal pure-Kotlin port
of the ENet reliable-UDP client subset the Moonlight control stream needs (the connect
handshake, reliable send/receive on one channel, acknowledgements, ping and disconnect). It was
ported from the MIT-licensed C source of the cgutman/enet fork (the fork and commit Wolf pins,
`44c85e16279553d9c052e572bcbfcd745fb74abf`): `host.c`, `peer.c`, `protocol.c`, and
`include/enet/protocol.h`. Also ported: the peer liveness rules, meaning the round-trip
estimate and retransmission timeout of `enet_protocol_handle_acknowledge` and the give-up
conditions of `enet_protocol_check_timeouts`, along with `protocol.c`'s `commandSizes` table.

Only the needed subset is reproduced. This client never *sends* a fragmented, unsequenced,
throttle or bandwidth command, and does not compress; it does measure and acknowledge all of
them on receive, because a peer packs several commands into one datagram and a command whose
size is unknown costs every command behind it. ENet is licensed MIT.

```
Copyright (c) 2002-2020 Lee Salzman

Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

Upstream: https://github.com/cgutman/enet
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
// Mints the throwaway self-signed certs the Moonlight pairing test pairs
// against, so no key material is committed to the repo.
testImplementation(libs.okhttp.tls)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.core)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@
android:exported="false"
android:foregroundServiceType="connectedDevice" />

<service
android:name=".composer.MoonlightSessionService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />

</application>

</manifest>
144 changes: 99 additions & 45 deletions app/src/main/cpp/satellite_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,19 @@ static bool sendEncrypted(Session* s, uint16_t msgType, const uint8_t* payload,

using gamepad::DeviceState;

enum SlotKind : uint8_t { SLOT_NONE = 0, SLOT_SATELLITE = 1, SLOT_BLUETOOTH = 2 };
enum SlotKind : uint8_t {
SLOT_NONE = 0,
SLOT_SATELLITE = 1,
SLOT_BLUETOOTH = 2,
SLOT_MOONLIGHT = 3
};

struct SlotBinding {
SlotKind kind = SLOT_NONE;
int sessionHandle = -1;
int controllerIndex = -1;
std::string btConnectionId;
// Kotlin-side connection id for the bridge kinds (Bluetooth / Moonlight).
std::string bridgeConnectionId;
};

static std::mutex g_devicesMtx;
Expand All @@ -145,68 +151,88 @@ static JavaVM* g_jvm = nullptr;
static jclass g_btBridgeClass = nullptr;
static jmethodID g_btDispatchMethod = nullptr;

static jclass g_moonlightBridgeClass = nullptr;
static jmethodID g_moonlightDispatchMethod = nullptr;

static jclass g_rumbleBridgeClass = nullptr;
static jmethodID g_rumbleDispatchMethod = nullptr;

// BT path runs off the UI thread because BluetoothHidDevice.sendReport is Binder IPC.
struct BtReport {
// Bridge kinds (Bluetooth, Moonlight) run off the UI thread: BluetoothHidDevice.sendReport is
// Binder IPC, and the Moonlight path encrypts + frames in Kotlin. One queue + thread serves both;
// the report's kind picks the Kotlin bridge to upcall.
struct BridgeReport {
SlotKind kind;
std::string connectionId;
int32_t controllerNumber;
uint16_t wButtons;
uint8_t bLT, bRT;
int16_t sLX, sLY, sRX, sRY;
};

static std::mutex g_btQueueMtx;
static std::condition_variable g_btQueueCv;
static std::deque<BtReport> g_btQueue;
static std::thread g_btDispatchThread;
static std::atomic<bool> g_btDispatchRunning{false};
static constexpr size_t BT_QUEUE_MAX = 64;
static std::mutex g_bridgeQueueMtx;
static std::condition_variable g_bridgeQueueCv;
static std::deque<BridgeReport> g_bridgeQueue;
static std::thread g_bridgeDispatchThread;
static std::atomic<bool> g_bridgeDispatchRunning{false};
static constexpr size_t BRIDGE_QUEUE_MAX = 64;

static void enqueueBtReport(BtReport&& r) {
static void enqueueBridgeReport(BridgeReport&& r) {
{
std::lock_guard<std::mutex> lock(g_btQueueMtx);
if (g_btQueue.size() >= BT_QUEUE_MAX) g_btQueue.pop_front();
g_btQueue.push_back(std::move(r));
std::lock_guard<std::mutex> lock(g_bridgeQueueMtx);
if (g_bridgeQueue.size() >= BRIDGE_QUEUE_MAX) g_bridgeQueue.pop_front();
g_bridgeQueue.push_back(std::move(r));
}
g_btQueueCv.notify_one();
g_bridgeQueueCv.notify_one();
}

static void btDispatchLoop() {
static void bridgeDispatchLoop() {
JNIEnv* env = nullptr;
if (!g_jvm || g_jvm->AttachCurrentThread(&env, nullptr) != JNI_OK || env == nullptr) {
LOGE("btDispatchLoop: AttachCurrentThread failed");
LOGE("bridgeDispatchLoop: AttachCurrentThread failed");
return;
}
dish::elevateCurrentThreadToInputPriority();
LOGI("BT dispatch thread started");
while (g_btDispatchRunning.load(std::memory_order_relaxed)) {
BtReport r;
LOGI("Bridge dispatch thread started");
while (g_bridgeDispatchRunning.load(std::memory_order_relaxed)) {
BridgeReport r;
{
std::unique_lock<std::mutex> lock(g_btQueueMtx);
g_btQueueCv.wait(lock, [] {
return !g_btDispatchRunning.load(std::memory_order_relaxed) || !g_btQueue.empty();
std::unique_lock<std::mutex> lock(g_bridgeQueueMtx);
g_bridgeQueueCv.wait(lock, [] {
return !g_bridgeDispatchRunning.load(std::memory_order_relaxed) ||
!g_bridgeQueue.empty();
});
if (!g_btDispatchRunning.load(std::memory_order_relaxed) && g_btQueue.empty()) break;
r = std::move(g_btQueue.front());
g_btQueue.pop_front();
if (!g_bridgeDispatchRunning.load(std::memory_order_relaxed) && g_bridgeQueue.empty())
break;
r = std::move(g_bridgeQueue.front());
g_bridgeQueue.pop_front();
}
if (g_btBridgeClass == nullptr || g_btDispatchMethod == nullptr) continue;
jclass cls = r.kind == SLOT_MOONLIGHT ? g_moonlightBridgeClass : g_btBridgeClass;
jmethodID method =
r.kind == SLOT_MOONLIGHT ? g_moonlightDispatchMethod : g_btDispatchMethod;
if (cls == nullptr || method == nullptr) continue;
jstring connId = env->NewStringUTF(r.connectionId.c_str());
env->CallStaticVoidMethod(g_btBridgeClass, g_btDispatchMethod, connId, (jint)r.wButtons,
(jint)r.bLT, (jint)r.bRT, (jint)r.sLX, (jint)r.sLY, (jint)r.sRX,
(jint)r.sRY);
// A Moonlight session carries up to four pads on one stream, so its upcall also
// names which pad the report belongs to; the Bluetooth link is one pad by nature.
if (r.kind == SLOT_MOONLIGHT) {
env->CallStaticVoidMethod(cls, method, connId, (jint)r.controllerNumber,
(jint)r.wButtons, (jint)r.bLT, (jint)r.bRT, (jint)r.sLX,
(jint)r.sLY, (jint)r.sRX, (jint)r.sRY);
} else {
env->CallStaticVoidMethod(cls, method, connId, (jint)r.wButtons, (jint)r.bLT,
(jint)r.bRT, (jint)r.sLX, (jint)r.sLY, (jint)r.sRX,
(jint)r.sRY);
}
env->DeleteLocalRef(connId);
if (env->ExceptionCheck()) env->ExceptionClear();
}
g_jvm->DetachCurrentThread();
LOGI("BT dispatch thread stopped");
LOGI("Bridge dispatch thread stopped");
}

static void startBtDispatchThread() {
bool was = g_btDispatchRunning.exchange(true, std::memory_order_relaxed);
static void startBridgeDispatchThread() {
bool was = g_bridgeDispatchRunning.exchange(true, std::memory_order_relaxed);
if (was) return;
g_btDispatchThread = std::thread(btDispatchLoop);
g_bridgeDispatchThread = std::thread(bridgeDispatchLoop);
}

static inline float axisCur(const GameActivityMotionEvent* ev, int axis) {
Expand Down Expand Up @@ -239,10 +265,12 @@ static void publishIfChanged(int32_t deviceId, DeviceState& s) {
r->sThumbRY = s.sRY;
sendEncrypted(session.get(), MSG_GAMEPAD_DATA, payload, sizeof(payload));
hotpath::markGamepadSent(); // stage-1 end: the URB-driven packet has left sendto()
} else if (binding.kind == SLOT_BLUETOOTH) {
if (binding.btConnectionId.empty()) return;
enqueueBtReport(BtReport{
binding.btConnectionId,
} else if (binding.kind == SLOT_BLUETOOTH || binding.kind == SLOT_MOONLIGHT) {
if (binding.bridgeConnectionId.empty()) return;
enqueueBridgeReport(BridgeReport{
binding.kind,
binding.bridgeConnectionId,
binding.controllerIndex,
s.wButtons,
s.bLT,
s.bRT,
Expand Down Expand Up @@ -938,27 +966,37 @@ JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPh
b.kind = SLOT_SATELLITE;
b.sessionHandle = sessionHandle;
b.controllerIndex = controllerIndex;
b.btConnectionId.clear();
b.bridgeConnectionId.clear();
}
syncSlotBaseline(deviceId);
}

JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotBluetooth(
JNIEnv* env, jobject, jint deviceId, jstring connectionId) {
static void bindPhysicalSlotBridge(JNIEnv* env, jint deviceId, jstring connectionId, SlotKind kind,
jint controllerIndex) {
const char* cstr = env->GetStringUTFChars(connectionId, nullptr);
std::string copy = cstr ? std::string(cstr) : std::string();
if (cstr) env->ReleaseStringUTFChars(connectionId, cstr);
{
std::lock_guard<std::mutex> lock(g_slotsMtx);
auto& b = g_slots[deviceId];
b.kind = SLOT_BLUETOOTH;
b.kind = kind;
b.sessionHandle = -1;
b.controllerIndex = -1;
b.btConnectionId = std::move(copy);
b.controllerIndex = controllerIndex;
b.bridgeConnectionId = std::move(copy);
}
syncSlotBaseline(deviceId);
}

JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotBluetooth(
JNIEnv* env, jobject, jint deviceId, jstring connectionId) {
bindPhysicalSlotBridge(env, deviceId, connectionId, SLOT_BLUETOOTH, -1);
}

JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_bindPhysicalSlotMoonlight(
JNIEnv* env, jobject, jint deviceId, jstring connectionId, jint controllerNumber) {
bindPhysicalSlotBridge(env, deviceId, connectionId, SLOT_MOONLIGHT, controllerNumber);
}

JNIEXPORT void JNICALL Java_com_tinkernorth_dish_core_jni_SatelliteNative_unbindPhysicalSlot(
JNIEnv*, jobject, jint deviceId) {
std::lock_guard<std::mutex> lock(g_slotsMtx);
Expand Down Expand Up @@ -1064,7 +1102,23 @@ JNIEXPORT void JNICALL Java_com_tinkernorth_dish_hotpath_input_BluetoothGamepadB
env->ExceptionClear();
}
}
startBtDispatchThread();
startBridgeDispatchThread();
}

JNIEXPORT void JNICALL Java_com_tinkernorth_dish_hotpath_input_MoonlightGamepadBridge_nativeInstall(
JNIEnv* env, jclass bridgeCls) {
if (g_moonlightBridgeClass == nullptr) {
g_moonlightBridgeClass = (jclass)env->NewGlobalRef(bridgeCls);
}
if (g_moonlightDispatchMethod == nullptr) {
g_moonlightDispatchMethod = env->GetStaticMethodID(g_moonlightBridgeClass, "dispatchReport",
"(Ljava/lang/String;IIIIIIII)V");
if (g_moonlightDispatchMethod == nullptr) {
LOGE("MoonlightGamepadBridge.dispatchReport not found");
env->ExceptionClear();
}
}
startBridgeDispatchThread();
}

JNIEXPORT void JNICALL
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/tinkernorth/dish/DishApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import com.tinkernorth.dish.bench.HotPathBenchController
import com.tinkernorth.dish.composer.CatalogPrewarmer
import com.tinkernorth.dish.composer.CrashReportingController
import com.tinkernorth.dish.composer.DiagnosticsLogRecorder
import com.tinkernorth.dish.composer.MoonlightSessionController
import com.tinkernorth.dish.composer.SlotTopologyController
import com.tinkernorth.dish.composer.StreamingServiceController
import com.tinkernorth.dish.composer.WakeStateController
import com.tinkernorth.dish.core.jni.PhysicalInputNative
import com.tinkernorth.dish.hotpath.input.BluetoothGamepadBridge
import com.tinkernorth.dish.hotpath.input.MoonlightGamepadBridge
import com.tinkernorth.dish.hotpath.input.PhysicalGamepadRegistry
import com.tinkernorth.dish.hotpath.input.PhysicalSlotBindingObserver
import com.tinkernorth.dish.hotpath.input.RumbleBridge
Expand Down Expand Up @@ -69,6 +71,8 @@ class DishApplication : Application() {

@Inject lateinit var slotTopologyController: SlotTopologyController

@Inject lateinit var moonlightSessionController: MoonlightSessionController

@Inject lateinit var crashReportingController: CrashReportingController

@Inject lateinit var themePreferenceStore: ThemePreferenceStore
Expand All @@ -81,6 +85,8 @@ class DishApplication : Application() {

@Inject lateinit var rumbleRouter: RumbleRouter

@Inject lateinit var moonlightManager: com.tinkernorth.dish.source.connection.moonlight.MoonlightConnectionManager

@Inject lateinit var physicalInputNative: PhysicalInputNative

@Inject lateinit var latencyProfilingStore: LatencyProfilingStore
Expand Down Expand Up @@ -146,6 +152,7 @@ class DishApplication : Application() {
val lifecycle = ProcessLifecycleOwner.get().lifecycle
lifecycle.addObserver(connectionForegroundObserver)
lifecycle.addObserver(slotTopologyController)
lifecycle.addObserver(moonlightSessionController)
// Process-scoped so bindings survive the MainActivity → GamepadOverlayActivity handoff.
physicalGamepadRegistry.install()
usbGamepadManager.install()
Expand All @@ -157,6 +164,7 @@ class DishApplication : Application() {
lifecycle.addObserver(physicalMotionSource)
lifecycle.addObserver(wakeStateController)
BluetoothGamepadBridge.install(btRegistry)
MoonlightGamepadBridge.install(moonlightManager)
lifecycle.addObserver(bluetoothBondMonitor)
lifecycle.addObserver(bluetoothAdapterStateObserver)
lifecycle.addObserver(bluetoothPermissionStateObserver)
Expand Down
Loading
Loading