diff --git a/.github/actions/setup-build-deps/action.yml b/.github/actions/setup-build-deps/action.yml index ea8b817..f26206d 100644 --- a/.github/actions/setup-build-deps/action.yml +++ b/.github/actions/setup-build-deps/action.yml @@ -34,7 +34,7 @@ runs: # shellcheck disable=SC2086 sudo apt-get install -y --no-install-recommends \ build-essential cmake ninja-build pkg-config ccache \ - libsodium-dev libsdl2-dev \ + libsodium-dev libsdl2-dev libssl-dev \ catch2 clang-tidy ${EXTRA_PACKAGES} - name: Restore compiler cache diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5e325fb..bf8f7c8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,7 +52,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ build-essential cmake ninja-build pkg-config \ - libsodium-dev libsdl2-dev + libsodium-dev libsdl2-dev libssl-dev # Not apt: noble ships Qt 6.4.2 and this project's floor is 6.7. The # action pulls the official binaries and exports CMAKE_PREFIX_PATH. diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index 65a94a7..7cadb48 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -260,7 +260,7 @@ jobs: build-essential cmake ninja-build pkg-config \ qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \ qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \ - libsodium-dev libsdl2-dev libdbus-1-dev \ + libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev \ dpkg-dev fakeroot file gzip \ librsvg2-bin desktop-file-utils appstream lintian diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c72026d..7bd3c64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,7 +164,7 @@ jobs: build-essential cmake ninja-build pkg-config \ qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \ qt6-tools-dev qt6-tools-dev-tools qt6-l10n-tools \ - libsodium-dev libsdl2-dev libdbus-1-dev \ + libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev \ dpkg-dev fakeroot file gzip \ librsvg2-bin desktop-file-utils appstream lintian @@ -323,7 +323,7 @@ jobs: git-core ca-certificates \ gcc-c++ cmake ninja-build pkgconf-pkg-config rpm-build \ qt6-qtbase-devel qt6-qtdeclarative-devel qt6-qtsvg-devel qt6-qttools-devel \ - libsodium-devel SDL2-devel dbus-devel \ + libsodium-devel SDL2-devel openssl-devel dbus-devel \ librsvg2-tools desktop-file-utils libappstream-glib gzip - name: Checkout @@ -466,7 +466,7 @@ jobs: # resolve on the build host. sudo apt-get install -y --no-install-recommends \ build-essential cmake ninja-build pkg-config \ - libsodium-dev libudev-dev libusb-1.0-0-dev \ + libsodium-dev libssl-dev libudev-dev libusb-1.0-0-dev \ libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-image0 \ libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 \ libxcb-xinerama0 libxcb-xkb1 \ diff --git a/CMakeLists.txt b/CMakeLists.txt index a7a8370..1c32893 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.21) project(Dish VERSION 0.2.0 DESCRIPTION "Dish Linux client for the Satellite gamepad-over-LAN protocol" - LANGUAGES CXX) + # C for the vendored ENet fork (third_party/enet), the transport under the + # Moonlight-host control stream. + LANGUAGES C CXX) # -------------------------------------------------------------------------- # Global build settings @@ -126,7 +128,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # Dependencies # -------------------------------------------------------------------------- find_package(Threads REQUIRED) -find_package(Qt6 6.7 REQUIRED COMPONENTS Core Gui Network DBus Svg Quick Qml QuickControls2) +find_package(Qt6 6.7 REQUIRED COMPONENTS Core Gui Network DBus Svg Quick Qml QuickControls2 Concurrent) qt_standard_project_setup(REQUIRES 6.7) # Optional: without qttools the .ts → .qm step is skipped and every qsTr() # falls back to its English source string, which still builds a working app. @@ -135,6 +137,14 @@ find_package(Qt6 6.7 QUIET OPTIONAL_COMPONENTS LinguistTools) find_package(PkgConfig REQUIRED) pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium) pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) +# OpenSSL's libcrypto backs the Moonlight-host pairing (AES-128, RSA, self- +# signed X.509) and the control-stream AES-GCM, none of which libsodium offers. +# Only libcrypto is used; the TLS client itself is Qt Network's. +find_package(OpenSSL REQUIRED) + +# The vendored ENet fork (MIT), the transport Moonlight hosts speak on the +# control stream. See THIRD_PARTY.md. +add_subdirectory(third_party/enet) # -------------------------------------------------------------------------- @@ -151,6 +161,24 @@ set(DISH_CORE_SOURCES src/core/AsyncState.h src/core/wire/SessionCrypto.h src/core/wire/SessionCrypto.cpp + # Moonlight (Sunshine/Apollo/Wolf) host protocol: the Qt-free core. + src/core/moonlight/MoonlightProtocol.h + src/core/moonlight/MoonlightWire.h + src/core/moonlight/MoonlightWire.cpp + src/core/moonlight/MoonlightControlCipher.h + src/core/moonlight/MoonlightControlCipher.cpp + src/core/moonlight/MoonlightPairingCrypto.h + src/core/moonlight/MoonlightPairingCrypto.cpp + src/core/moonlight/MoonlightPairing.h + src/core/moonlight/MoonlightPairing.cpp + src/core/moonlight/MoonlightXml.h + src/core/moonlight/MoonlightXml.cpp + src/core/moonlight/MoonlightRtsp.h + src/core/moonlight/MoonlightRtsp.cpp + src/core/moonlight/MoonlightSessionMachine.h + src/core/moonlight/MoonlightPadSlots.h + src/core/moonlight/MoonlightSessionUi.h + src/core/moonlight/MoonlightButtonMap.h src/core/model/Protocol.h src/core/model/IdentityKey.h src/core/net/Tofu.h @@ -252,6 +280,24 @@ set(DISH_CORE_SOURCES src/source/connection/MdnsDiscovery.cpp src/source/connection/DiscoveryGateway.h src/source/connection/DiscoveryGateway.cpp + # Moonlight-host IO edge: HTTP/RTSP/ENet transports, discovery, pairing, + # the session coordinator and the subsystem manager. + src/source/moonlight/MoonlightLog.h + src/source/moonlight/MoonlightLog.cpp + src/source/moonlight/MoonlightHttp.h + src/source/moonlight/MoonlightHttp.cpp + src/source/moonlight/MoonlightRtspClient.h + src/source/moonlight/MoonlightRtspClient.cpp + src/source/moonlight/MoonlightControlStream.h + src/source/moonlight/MoonlightControlStream.cpp + src/source/moonlight/MoonlightDiscovery.h + src/source/moonlight/MoonlightDiscovery.cpp + src/source/moonlight/MoonlightPairingFlow.h + src/source/moonlight/MoonlightPairingFlow.cpp + src/source/moonlight/MoonlightSession.h + src/source/moonlight/MoonlightSession.cpp + src/source/moonlight/MoonlightManager.h + src/source/moonlight/MoonlightManager.cpp src/source/http/SatelliteTlsVerifier.h src/source/http/SatelliteTlsVerifier.cpp # An ETag cache over HTTPClient::getCatalog, not a durable Repository, @@ -330,6 +376,10 @@ set(DISH_CORE_SOURCES src/repository/SatelliteSharedKeyRepository.cpp src/repository/RememberedSatelliteRepository.h src/repository/RememberedSatelliteRepository.cpp + src/repository/MoonlightIdentityRepository.h + src/repository/MoonlightIdentityRepository.cpp + src/repository/MoonlightHostRepository.h + src/repository/MoonlightHostRepository.cpp src/repository/ConnectionStore.h src/UI/CrashReport.cpp src/repository/AppSettings.cpp @@ -411,13 +461,20 @@ target_link_libraries(dish_core Qt6::Gui # ScreenSaver inhibit, the BlueZ adapter probe and the appearance portal. Qt6::DBus + # The Moonlight mDNS scan runs off the GUI thread via QtConcurrent::run. + Qt6::Concurrent PkgConfig::SODIUM PkgConfig::SDL2 Threads::Threads PRIVATE dish_warnings dish_strict - dish_hardened) + dish_hardened + # The Moonlight-host pairing/control crypto and the vendored ENet + # transport. PRIVATE: no dish_core header exposes an OpenSSL or ENet + # type across the library boundary. + OpenSSL::Crypto + dish_enet) # -------------------------------------------------------------------------- # UI executable @@ -504,6 +561,7 @@ qt_add_qml_module(Dish src/qml/pages/LicensesPage.qml src/qml/pages/ControlsRemapPage.qml src/qml/pages/ConfigureBindingPage.qml + src/qml/pages/MoonlightHostsPage.qml src/qml/pages/PairingDialog.qml src/qml/onboarding/OnboardingFlow.qml src/qml/onboarding/WelcomeScreen.qml @@ -512,6 +570,7 @@ qt_add_qml_module(Dish src/qml/wizard/WizardInputPage.qml src/qml/wizard/WizardDestinationPage.qml src/qml/wizard/WizardTypePage.qml + src/qml/wizard/WizardSessionPage.qml src/qml/wizard/WizardFeelPage.qml src/qml/wizard/WizardReviewPage.qml) diff --git a/README.md b/README.md index 73a3b4b..30dead7 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ it in place. What the check sends is spelled out in [`PRIVACY.md`](PRIVACY.md). - GCC 12+ or Clang 15+, CMake 3.21+, Ninja - Qt 6.7+ (Core, Gui, Network, DBus, Svg, Quick, Qml, QuickControls2; Linguist tools for the translation catalogues) -- libsodium, SDL2, Catch2 v3 +- libsodium, SDL2, OpenSSL (libcrypto), Catch2 v3 - Optional: `rsvg-convert`, which renders the rest of the launcher-icon ladder from the SVG. Without it the build says so and installs only the scalable and 512x512 icons, which is enough for a working menu entry. @@ -114,7 +114,7 @@ On Debian and Ubuntu: sudo apt install build-essential cmake ninja-build pkg-config \ qt6-base-dev qt6-base-dev-tools qt6-declarative-dev qt6-svg-dev \ qt6-tools-dev qt6-l10n-tools \ - libsodium-dev libsdl2-dev libdbus-1-dev catch2 \ + libsodium-dev libsdl2-dev libssl-dev libdbus-1-dev catch2 \ librsvg2-bin ``` diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index ed11126..44738cc 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -27,12 +27,22 @@ today. | [libsodium](#libsodium) | >= 1.0.18 | `ISC` | Dynamically linked against the system libsodium. | Keep the copyright and permission notice | | [Inter](#4-inter) | 4.001 | `OFL-1.1` | Four `.ttf` faces embedded in `dish` as Qt resources under `:/fonts/`. | Ship the license text with every copy. See section 4. | | [Catch2](#5-catch2) | 3.x | `BSL-1.0` | Test binary only. Not linked into `dish`. | None for redistributors of the app | +| [ENet (cgutman fork)](#9-enet) | commit `4cde9cc` | `MIT` | Vendored C sources under `third_party/enet/`, compiled into `dish`. | Ship the copyright + permission notice | +| [OpenSSL libcrypto](#10-openssl-libcrypto) | system | `Apache-2.0` | Dynamically linked against the system libcrypto for the Moonlight-host crypto. | Keep the notice; nothing bundled | Two further items are reuse of published facts rather than of code, and are covered in [section 6](#6-reused-facts-not-reused-code): SDL's default Switch Pro motion scaling constants, and the HID input-report byte layouts documented in the Linux kernel's PlayStation and Nintendo HID drivers. +The Moonlight (GameStream) host protocol support under `src/core/moonlight/` and +`src/source/moonlight/` is an original implementation. Its wire framing, crypto +construction and pairing algorithm were learned from the documentation and the +MIT-licensed host implementation of Wolf (games-on-whales/wolf); that reuse of +adapted logic is recorded in [section 11](#11-wolf-moonlight-protocol-reference). +No GPL-licensed Moonlight code (moonlight-common-c, moonlight-qt, Sunshine, +Apollo) was consulted or copied. + Everything under `resources/brand/` and `packaging/dish.svg` is original TinkerNorth artwork, covered by this repository's own license. See [section 8](#8-first-party-artwork). @@ -274,6 +284,71 @@ license and carry no third-party attribution. --- +## 9. ENet + +The cgutman fork of ENet, SPDX `MIT`. Upstream: +, vendored at commit +`4cde9cc3dcc5c30775a80da1de87f39f98672a31` (the commit Wolf and the Moonlight +ecosystem pin). Original ENet by Lee Salzman: . + +The Moonlight control stream runs over this reliable-UDP library. The unmodified +upstream C sources live under [`third_party/enet/`](third_party/enet/) and are +compiled into `dish` as the static `dish_enet` library. Because the sources are +bundled and redistributed inside the binary, the MIT copyright and permission +notice ([`third_party/enet/LICENSE`](third_party/enet/LICENSE)) must travel with +any copy. + +``` +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 ... THE SOFTWARE IS PROVIDED "AS IS". +``` + +The fork adds IPv4/IPv6 dual-stack support over upstream ENet; no local +modifications were made to the vendored sources. + +--- + +## 10. OpenSSL libcrypto + +OpenSSL, SPDX `Apache-2.0`. Upstream: . + +The Moonlight-host support needs AES-128 (ECB and GCM), RSA sign/verify and +self-signed X.509 generation — primitives libsodium deliberately does not +provide — so `dish` links the system OpenSSL's `libcrypto` for them (only +`libcrypto`; the TLS client itself remains Qt Network's). Nothing is bundled: +this is your distribution's OpenSSL, and its notice travels with that package, +as it already does for the Qt build `Qt6::Network` runs against. + +--- + +## 11. Wolf (Moonlight protocol reference) + +Wolf, SPDX `MIT`. Upstream: . + +**No Wolf source is compiled into `dish`.** Wolf is an MIT-licensed Moonlight +*host*; its protocol documentation and source were the reference for this +project's own Moonlight *client* implementation under `src/core/moonlight/` and +`src/source/moonlight/`. Adapted logic includes the control-packet AES-GCM IV +construction, the 5-phase PIN pairing algorithm, the CONTROLLER_* wire struct +layouts and the RTSP request/response shapes. These were re-implemented against +Dish's own architecture; the byte-exact test fixtures are derived from Wolf's +protocol docs and its published test vectors. + +``` +Copyright (c) 2021-2024 Games on Whales + +Permission is hereby granted, free of charge ... THE SOFTWARE IS PROVIDED "AS IS". +``` + +Deliberately NOT consulted, to keep this LGPL-3.0 project clear of GPL-3.0 +Moonlight code: moonlight-common-c, moonlight-qt, moonlight-android, Sunshine, +Apollo. Wolf's documentation and MIT source were sufficient. + +--- + ## Keeping this in sync [`assets/licenses/licenses.json`](assets/licenses/licenses.json) is the manifest diff --git a/src/AppModel.cpp b/src/AppModel.cpp index 940cb02..81d5b0b 100644 --- a/src/AppModel.cpp +++ b/src/AppModel.cpp @@ -6,6 +6,8 @@ #include "LightbarRouting.h" #include "composer/StreamingSlotCount.h" #include "core/input/UsbReportParsers.h" +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightProtocol.h" #include "core/reducer/CatalogPrewarm.h" #include "core/reducer/PickerVisibility.h" #include "core/reducer/RumbleRouting.h" @@ -77,7 +79,8 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa motionEnabledStore_(&motionPrefRepo_), joystickRemapStore_(&joystickRemapRepo_), catalogSnapshot_(composer::CatalogSnapshot{}), catalogComposer_(catalogSnapshot_), usbPathStore_(&usbPathRepo_), usbObserver_(this), usbScanTimer_(new QTimer(this)), - inputRateTimer_(new QTimer(this)) { + inputRateTimer_(new QTimer(this)), + moonlight_(new source::moon::MoonlightManager(nullptr, this)) { QObject::connect(hub_, &net::ConnectionHub::changed, this, &AppModel::onHubChanged); QObject::connect(bridge_, &input::SDLGamepadBridge::devicesChanged, this, &AppModel::onBridgeDevicesChanged); @@ -106,6 +109,36 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa QObject::connect(autoReconnectTimer_, &QTimer::timeout, this, [this] { wifi_->autoReconnectAll(); }); + // A rebuild whenever the Moonlight subsystem changes, so its rows and link + // states reach the UI through the same stateChanged() the satellite pool + // uses. The manager already coalesces on the Qt main thread. + QObject::connect(moonlight_, &source::moon::MoonlightManager::rowsChanged, this, + &AppModel::stateChanged); + QObject::connect(moonlight_, &source::moon::MoonlightManager::sessionFailed, this, + [this](const QString&, const QString& reasonToken) { + if (reasonToken == QLatin1String("appAlreadyRunning")) { + emit errorMessage( + tr("That host is already running an app. Stop it on the host, " + "then try again.")); + return; + } + emit errorMessage(tr("The Moonlight session ended.")); + }); + // Host->local actuation shares the SDL output plumbing the satellite path + // uses. The manager has already resolved the event's controller number to + // the pad that holds it, because one session drives up to four. + moonlight_->setRumbleSink([this](const QString& slotId, std::uint16_t low, std::uint16_t high) { + if (slotId.isEmpty()) { return; } + // Moonlight sends low/high frequency magnitudes; map to the SDL + // strong/weak motors and let applyRumble marshal to the SDL thread. + bridge_->applyRumble(slotId, high, low, 0); + }); + moonlight_->setLedSink( + [this](const QString& slotId, std::uint8_t r, std::uint8_t g, std::uint8_t b) { + if (slotId.isEmpty()) { return; } + bridge_->applyLightbar(slotId, r, g, b); + }); + // Hot path, called on the SDL gamepad thread: look the sender up under a // short-held mutex, then forward outside it. processor_.setReportSender([this](const std::string& did, std::uint16_t buttons, @@ -114,7 +147,11 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa net::ConnectionHub::ReportSender sender; { std::lock_guard lock(routingMtx_); - sender = routing_.value(QString::fromStdString(did)); + const QString key = QString::fromStdString(did); + sender = routing_.value(key); + // A slot bound to a Moonlight host has no satellite sender; fall + // through to the Moonlight table. The two are mutually exclusive. + if (!sender) { sender = moonlightRouting_.value(key); } } if (sender) { sender(buttons, lt, rt, lx, ly, rx, ry); } }); @@ -125,7 +162,9 @@ AppModel::AppModel(std::unique_ptr inhibitor, QObject* pa net::ConnectionHub::MotionSender sender; { std::lock_guard lock(routingMtx_); - sender = motionRouting_.value(QString::fromStdString(did)); + const QString key = QString::fromStdString(did); + sender = motionRouting_.value(key); + if (!sender) { sender = moonlightMotionRouting_.value(key); } } if (sender) { sender(gx, gy, gz, ax, ay, az, dtUs); } }); @@ -359,6 +398,133 @@ void AppModel::installRumbleHandlers() { } } +void AppModel::bindMoonlightSlot(const QString& slotId, const QString& hostUuid) { + if (hostUuid.isEmpty()) { + unbindMoonlightSlot(slotId); + return; + } + // What the pad itself can deliver. The declared CONTROLLER_ARRIVAL bitfield + // is this intersected with the emulated type's ceiling, because declaring a + // capability the source cannot provide makes the host ask for reports that + // never arrive. + const SlotHardware hardware = slotHardware(slotId); + moonlight::SourceCapabilities source; + source.rumble = hardware.hasRumble; + source.motion = hardware.hasMotion; + source.touchpad = hardware.hasTouchpad; + source.lightbar = hardware.hasLightbar; + // The type is a property of the BINDING, so it comes from the per-slot + // override the binding flow writes; Auto resolves against the pad above. + const int storedType = typeStore_.typeFor(hostUuid.toStdString(), slotId.toStdString()) + .value_or(repository::kMoonlightControllerTypeAuto); + + const auto number = moonlight_->bindController(slotId, hostUuid, storedType, source); + + net::ConnectionHub::ReportSender reportSender; + net::ConnectionHub::MotionSender motionSender; + if (number) { + // Raw pointers: the session is parented to the manager, which outlives + // the SDL thread (stopped in ~AppModel before these tables clear). The + // controller number is resolved once here so the hot path carries it. + auto* session = moonlight_->session(hostUuid); + const std::uint8_t pad = *number; + if (session != nullptr) { + reportSender = [session, pad](std::uint16_t buttons, std::uint8_t lt, std::uint8_t rt, + std::int16_t lx, std::int16_t ly, std::int16_t rx, + std::int16_t ry) { + session->sendControllerState(pad, buttons, lt, rt, lx, ly, rx, ry); + }; + motionSender = [session, pad](std::int16_t gx, std::int16_t gy, std::int16_t gz, + std::int16_t ax, std::int16_t ay, std::int16_t az, + std::uint32_t) { + // Gyro sample first, then accel; the host asks for whichever it + // wants via MOTION_EVENT, and sendMotion no-ops until then. + session->sendMotion(pad, moonproto::kMotionGyroscope, static_cast(gx), + static_cast(gy), static_cast(gz)); + session->sendMotion(pad, moonproto::kMotionAcceleration, static_cast(ax), + static_cast(ay), static_cast(az)); + }; + } + } + { + std::lock_guard lock(routingMtx_); + // A slot routes to exactly one transport: clear any satellite route so + // the report sender's fall-through reaches the Moonlight table. + routing_.remove(slotId); + motionRouting_.remove(slotId); + if (reportSender) { + // operator[], not insert: QHash::insert takes the value by const + // reference, so a std::move into it would silently copy the + // std::function (and its captured state) instead of moving. + moonlightRouting_[slotId] = std::move(reportSender); + moonlightMotionRouting_[slotId] = std::move(motionSender); + } else { + moonlightRouting_.remove(slotId); + moonlightMotionRouting_.remove(slotId); + } + } + // rebuild() re-derives the slot list against the new binding and emits. + rebuild(); +} + +void AppModel::unbindMoonlightSlot(const QString& slotId) { + { + std::lock_guard lock(routingMtx_); + moonlightRouting_.remove(slotId); + moonlightMotionRouting_.remove(slotId); + } + // Drops this pad from the host's shared session, and tears the session down + // behind the last one so the app is not stranded. + moonlight_->unbindController(slotId); + rebuild(); +} + +QString AppModel::moonlightBoundHostFor(const QString& slotId) const { + return moonlight_->boundHostFor(slotId); +} + +void AppModel::forgetMoonlightHost(const QString& hostUuid) { + if (hostUuid.isEmpty()) { return; } + // UNBIND BEFORE FORGETTING, the order ConnectionCoordinator uses for a + // satellite. moonlightRouting_ holds lambdas that captured the session by + // raw pointer and is read on the SDL gamepad thread, so a route still in + // the table when forget() deletes that session is a use after free there. + // unbindMoonlightSlot also sends each pad its farewell CONTROLLER_MULTI and + // hands the app back behind the last one, instead of dropping the session. + for (const auto& slotId : moonlight_->boundSlots(hostUuid)) { unbindMoonlightSlot(slotId); } + // The Emulate overrides are keyed (connection, slot), and the connection + // here IS the host. Left behind, they would seed a later re-pairing of the + // same box with picks made for a trust relationship that no longer exists. + typeStore_.clearConnection(hostUuid.toStdString()); + moonlight_->forget(hostUuid); + rebuild(); +} + +std::optional AppModel::moonlightSummary(const QString& uuid) const { + const auto row = moonlight_->row(uuid); + if (!row) { return std::nullopt; } + models::ConnectionSummary summary; + summary.id = row->uuid; + summary.label = row->name; + summary.detail = row->address; + // Saved, not Disconnected, for a host that is merely not streaming: there + // is no link to have lost. Only a live control stream reads Connected. + switch (row->link) { + case source::moon::MoonlightLinkState::Live: + summary.live = models::LinkState::Connected; + break; + case source::moon::MoonlightLinkState::Linking: + summary.live = models::LinkState::Connecting; + break; + case source::moon::MoonlightLinkState::Failed: + case source::moon::MoonlightLinkState::Idle: + default: + summary.live = models::LinkState::Saved; + break; + } + return summary; +} + void AppModel::start() { bridge_->start(); wifi_->autoReconnectAll(); @@ -376,6 +542,9 @@ void AppModel::start() { // Last: the janitor pass and the staged-update scan are disk IO, and the // first check is 15 s out, so nothing here delays the first frame. updateChecker_.start(); + // A one-shot Moonlight scan beside the satellite discovery, so any + // GameStream host on the LAN shows up in the connections list. + moonlight_->startDiscovery(); } void AppModel::clearPairingTarget() { @@ -732,7 +901,14 @@ void AppModel::rebuild() { const auto bindings = hub_->bindings(); for (auto& s : next) { const auto cid = bindings.value(s.id); - if (!cid.isEmpty()) { + // A Moonlight binding is a binding: the card, the accounting and the + // apply readback all key on boundConnectionId, so a pad driving a + // GameStream host has to report one too. The two tables are exclusive. + const QString moonHost = cid.isEmpty() ? moonlight_->boundHostFor(s.id) : QString(); + if (!moonHost.isEmpty()) { + s.boundConnectionId = moonHost; + s.boundStatus = moonlightSummary(moonHost); + } else if (!cid.isEmpty()) { s.boundConnectionId = cid; s.boundStatus = hub_->summary(cid); // Server-localized catalog text; left empty, and the suffix diff --git a/src/AppModel.h b/src/AppModel.h index c684a3f..643ff18 100644 --- a/src/AppModel.h +++ b/src/AppModel.h @@ -46,6 +46,7 @@ #include "source/system/WakeInhibitor.h" #include "source/tray/StatusNotifierTrayIcon.h" #include "source/tray/TrayIcon.h" +#include "source/moonlight/MoonlightManager.h" #include "source/usb/UsbGamepadManager.h" #include "source/usb/HidrawGateway.h" #include "update/UpdateChecker.h" @@ -197,6 +198,30 @@ class AppModel : public QObject { // Linux. The lifecycle is owned here. source::usb::UsbGamepadManager* usbManager() { return usbManager_.get(); } + // The Moonlight-host subsystem: discovery, pairing, sessions. Sits beside + // the satellite wifi() pool as a sibling; the UI drives it through here. + source::moon::MoonlightManager* moonlight() { return moonlight_; } + + // Routes a controller slot's hot-path reports to a Moonlight host instead + // of a satellite: records the binding, joins or starts that host's shared + // session, and points the SDL thread at the controller number it was given. + // The reverse is unbindMoonlightSlot, which drops the pad and tears the + // session down behind the last one. Passing an empty uuid clears the route. + // + // The binding is recorded even when the host is unpaired or unreachable: a + // binding is a durable intent, and the session is attempted when the pad is + // used rather than when the user saves. + void bindMoonlightSlot(const QString& slotId, const QString& hostUuid); + void unbindMoonlightSlot(const QString& slotId); + // The Moonlight host this slot drives, or empty. The satellite equivalent + // is ConnectionHub::bindings(). + QString moonlightBoundHostFor(const QString& slotId) const; + // Drops a remembered Moonlight host and everything ABOVE the subsystem that + // was keyed on it. The satellite equivalent is + // ConnectionCoordinator::forgetConnection, and it unbinds first for the + // same reason: the routes have to go before the session they point at. + void forgetMoonlightHost(const QString& hostUuid); + signals: // Emitted after any field of state() changes. void stateChanged(); @@ -273,6 +298,11 @@ class AppModel : public QObject { }; SlotHardware slotHardware(const QString& slotId) const; + // A Moonlight host as the flat ConnectionSummary the slot list carries, so + // one binding vocabulary covers both destination kinds. The link is the + // session's, never a pairing light: a Moonlight host reports no liveness. + std::optional moonlightSummary(const QString& uuid) const; + // Warm the catalog cache once each time a satellite link goes Live, so the // type picker usually resolves instantly from cache. Silent by design: it // never drives catalogState_, so no UI spinner flickers on reconnects. @@ -432,6 +462,9 @@ class AppModel : public QObject { // from usbPollRateHz_ at rebuild(). Main-thread-only. QHash liveRatesBySlot_; + // The Moonlight-host subsystem, parented to this. + source::moon::MoonlightManager* moonlight_; + // slotId -> active sender. Read on the SDL gamepad thread, written on the Qt // main thread; routingMtx_ guards both directions. mutable std::mutex routingMtx_; @@ -441,6 +474,12 @@ class AppModel : public QObject { QHash motionRouting_; QHash batteryRouting_; QHash touchpadRouting_; + // deviceId (slotId) -> Moonlight hot-path sender, read on the SDL gamepad + // thread under routingMtx_ alongside routing_ above. A slot is routed to a + // satellite OR a Moonlight host, never both, so the report sender checks + // this table only when routing_ has no entry. + QHash moonlightRouting_; + QHash moonlightMotionRouting_; }; } // namespace dish diff --git a/src/core/moonlight/MoonlightButtonMap.h b/src/core/moonlight/MoonlightButtonMap.h new file mode 100644 index 0000000..8149710 --- /dev/null +++ b/src/core/moonlight/MoonlightButtonMap.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Maps the repo's internal gamepad report (GamepadInputProcessor's XInput-style +// button word and stick/trigger ranges) onto the Moonlight CONTROLLER_MULTI +// fields. Pure and header-only so the hot path pays no call overhead and the +// mapping is unit-testable without a controller. + +#pragma once + +#include "core/moonlight/MoonlightProtocol.h" + +#include + +namespace dish::moonmap { + +// The internal button bits (GamepadInputProcessor::Report). Restated here so +// this header stays free of the Input layer. +namespace inbtn { +inline constexpr std::uint16_t kDpadUp = 0x0001; +inline constexpr std::uint16_t kDpadDown = 0x0002; +inline constexpr std::uint16_t kDpadLeft = 0x0004; +inline constexpr std::uint16_t kDpadRight = 0x0008; +inline constexpr std::uint16_t kStart = 0x0010; +inline constexpr std::uint16_t kBack = 0x0020; +inline constexpr std::uint16_t kLeftThumb = 0x0040; +inline constexpr std::uint16_t kRightThumb = 0x0080; +inline constexpr std::uint16_t kLeftShoulder = 0x0100; +inline constexpr std::uint16_t kRightShoulder = 0x0200; +inline constexpr std::uint16_t kA = 0x1000; +inline constexpr std::uint16_t kB = 0x2000; +inline constexpr std::uint16_t kX = 0x4000; +inline constexpr std::uint16_t kY = 0x8000; +} // namespace inbtn + +// Translates the internal button word into Moonlight's effective button flags. +// The two vocabularies mostly line up (both descend from XInput) but Home/Guide +// and the stick clicks sit at different bits, so the map is explicit. +inline std::uint32_t toMoonlightButtons(std::uint16_t buttons) { + std::uint32_t out = 0; + const auto set = [&](std::uint16_t in, std::uint32_t flag) { + if ((buttons & in) != 0) { out |= flag; } + }; + set(inbtn::kDpadUp, moonproto::kBtnDpadUp); + set(inbtn::kDpadDown, moonproto::kBtnDpadDown); + set(inbtn::kDpadLeft, moonproto::kBtnDpadLeft); + set(inbtn::kDpadRight, moonproto::kBtnDpadRight); + set(inbtn::kStart, moonproto::kBtnStart); + set(inbtn::kBack, moonproto::kBtnBack); + set(inbtn::kLeftThumb, moonproto::kBtnLeftStick); + set(inbtn::kRightThumb, moonproto::kBtnRightStick); + set(inbtn::kLeftShoulder, moonproto::kBtnLeftButton); + set(inbtn::kRightShoulder, moonproto::kBtnRightButton); + set(inbtn::kA, moonproto::kBtnA); + set(inbtn::kB, moonproto::kBtnB); + set(inbtn::kX, moonproto::kBtnX); + set(inbtn::kY, moonproto::kBtnY); + return out; +} + +} // namespace dish::moonmap diff --git a/src/core/moonlight/MoonlightControlCipher.cpp b/src/core/moonlight/MoonlightControlCipher.cpp new file mode 100644 index 0000000..2b688cd --- /dev/null +++ b/src/core/moonlight/MoonlightControlCipher.cpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightControlCipher.h" + +#include "core/moonlight/MoonlightProtocol.h" + +#include + +#include + +namespace dish::mooncrypto { +namespace { + +// The wire caps len at u16; anything this client sends is far below it. +constexpr std::size_t kMaxPlaintext = 1024; +constexpr std::size_t kIvSize = 16; + +void putU16Le(std::uint8_t* dst, std::uint16_t v) noexcept { + dst[0] = static_cast(v & 0xFFU); + dst[1] = static_cast((v >> 8) & 0xFFU); +} + +void putU32Le(std::uint8_t* dst, std::uint32_t v) noexcept { + dst[0] = static_cast(v & 0xFFU); + dst[1] = static_cast((v >> 8) & 0xFFU); + dst[2] = static_cast((v >> 16) & 0xFFU); + dst[3] = static_cast((v >> 24) & 0xFFU); +} + +std::uint16_t readU16Le(const std::uint8_t* src) noexcept { + return static_cast(static_cast(src[0]) | + (static_cast(src[1]) << 8)); +} + +std::uint32_t readU32Le(const std::uint8_t* src) noexcept { + return static_cast(src[0]) | (static_cast(src[1]) << 8) | + (static_cast(src[2]) << 16) | (static_cast(src[3]) << 24); +} + +// 16 zero bytes with only the low byte of seq in iv[0]. Wolf assigns the u32 +// seq to a uint8_t slot, and interop with real clients proves both ends do the +// same, so this deliberately truncates rather than spreading seq over 4 bytes. +void buildIv(std::uint32_t seq, std::uint8_t iv[kIvSize]) noexcept { + std::memset(iv, 0, kIvSize); + iv[0] = static_cast(seq & 0xFFU); +} + +} // namespace + +ControlCipher::ControlCipher() = default; + +ControlCipher::~ControlCipher() { + if (encCtx_ != nullptr) { EVP_CIPHER_CTX_free(encCtx_); } + if (decCtx_ != nullptr) { EVP_CIPHER_CTX_free(decCtx_); } +} + +bool ControlCipher::setKey(const std::array& key) { + keySet_ = false; + if (encCtx_ == nullptr) { encCtx_ = EVP_CIPHER_CTX_new(); } + if (decCtx_ == nullptr) { decCtx_ = EVP_CIPHER_CTX_new(); } + if (encCtx_ == nullptr || decCtx_ == nullptr) { return false; } + + // Bind cipher + key once; per-packet calls below re-init with only the IV. + if (EVP_EncryptInit_ex(encCtx_, EVP_aes_128_gcm(), nullptr, nullptr, nullptr) != 1 || + EVP_CIPHER_CTX_ctrl(encCtx_, EVP_CTRL_GCM_SET_IVLEN, static_cast(kIvSize), nullptr) != + 1 || + EVP_EncryptInit_ex(encCtx_, nullptr, nullptr, key.data(), nullptr) != 1 || + EVP_CIPHER_CTX_set_padding(encCtx_, 0) != 1) { + return false; + } + if (EVP_DecryptInit_ex(decCtx_, EVP_aes_128_gcm(), nullptr, nullptr, nullptr) != 1 || + EVP_CIPHER_CTX_ctrl(decCtx_, EVP_CTRL_GCM_SET_IVLEN, static_cast(kIvSize), nullptr) != + 1 || + EVP_DecryptInit_ex(decCtx_, nullptr, nullptr, key.data(), nullptr) != 1 || + EVP_CIPHER_CTX_set_padding(decCtx_, 0) != 1) { + return false; + } + keySet_ = true; + return true; +} + +std::size_t ControlCipher::seal(std::uint32_t seq, const std::uint8_t* plaintext, std::size_t ptLen, + std::uint8_t* out) { + if (!keySet_ || plaintext == nullptr || out == nullptr || ptLen == 0 || ptLen > kMaxPlaintext) { + return 0; + } + + std::uint8_t iv[kIvSize]; + buildIv(seq, iv); + if (EVP_EncryptInit_ex(encCtx_, nullptr, nullptr, nullptr, iv) != 1) { return 0; } + + std::uint8_t* ct = out + kHeaderSize + kSeqSize + kTagSize; + int ctLen = 0; + if (EVP_EncryptUpdate(encCtx_, ct, &ctLen, plaintext, static_cast(ptLen)) != 1) { + return 0; + } + int finalLen = 0; + if (EVP_EncryptFinal_ex(encCtx_, ct + ctLen, &finalLen) != 1) { return 0; } + const std::size_t cipherLen = + static_cast(ctLen) + static_cast(finalLen); + if (EVP_CIPHER_CTX_ctrl(encCtx_, EVP_CTRL_GCM_GET_TAG, static_cast(kTagSize), + out + kHeaderSize + kSeqSize) != 1) { + return 0; + } + + putU16Le(out, moonproto::kPktEncrypted); + putU16Le(out + 2, static_cast(kSeqSize + kTagSize + cipherLen)); + putU32Le(out + kHeaderSize, seq); + return kHeaderSize + kSeqSize + kTagSize + cipherLen; +} + +std::optional ControlCipher::open(const std::uint8_t* packet, std::size_t len, + std::uint8_t* out, std::size_t outCap) { + if (!keySet_ || packet == nullptr || out == nullptr || len < kOverhead) { return std::nullopt; } + if (readU16Le(packet) != moonproto::kPktEncrypted) { return std::nullopt; } + const std::size_t declared = readU16Le(packet + 2); + if (declared < kSeqSize + kTagSize || declared + kHeaderSize > len) { return std::nullopt; } + const std::uint32_t seq = readU32Le(packet + kHeaderSize); + const std::uint8_t* tag = packet + kHeaderSize + kSeqSize; + const std::uint8_t* ct = tag + kTagSize; + const std::size_t ctLen = declared - kSeqSize - kTagSize; + if (ctLen > outCap) { return std::nullopt; } + + std::uint8_t iv[kIvSize]; + buildIv(seq, iv); + if (EVP_DecryptInit_ex(decCtx_, nullptr, nullptr, nullptr, iv) != 1) { return std::nullopt; } + int ptLen = 0; + if (ctLen > 0 && EVP_DecryptUpdate(decCtx_, out, &ptLen, ct, static_cast(ctLen)) != 1) { + return std::nullopt; + } + // SET_TAG's argument is const-correct only from OpenSSL 3; the copy keeps + // the API const on our side. + std::uint8_t tagCopy[kTagSize]; + std::memcpy(tagCopy, tag, kTagSize); + if (EVP_CIPHER_CTX_ctrl(decCtx_, EVP_CTRL_GCM_SET_TAG, static_cast(kTagSize), tagCopy) != + 1) { + return std::nullopt; + } + int finalLen = 0; + if (EVP_DecryptFinal_ex(decCtx_, out + ptLen, &finalLen) != 1) { return std::nullopt; } + return static_cast(ptLen) + static_cast(finalLen); +} + +} // namespace dish::mooncrypto diff --git a/src/core/moonlight/MoonlightControlCipher.h b/src/core/moonlight/MoonlightControlCipher.h new file mode 100644 index 0000000..44dd25a --- /dev/null +++ b/src/core/moonlight/MoonlightControlCipher.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// AES-128-GCM sealing for the Moonlight control stream. Every control message +// travels as an ENCRYPTED packet: +// +// [type u16 LE = 0x0001][len u16 LE][seq u32 LE][GCM tag 16B][ciphertext] +// +// where len covers seq + tag + ciphertext. The key is the launch request's +// rikey; the IV is 16 bytes of zero with iv[0] = seq & 0xFF — exactly the +// construction Wolf's control.hpp uses against real clients (the seq is +// truncated to one byte on both ends, so the construction only matters that it +// MATCHES; see decrypt_packet there). The unit tests pin seal() against +// packets captured from a real session. +// +// Both EVP contexts are allocated once and reused per packet, so the hot path +// performs no per-packet heap allocation inside this class. + +#pragma once + +#include +#include +#include +#include + +// OpenSSL's EVP_CIPHER_CTX without pulling evp.h into every includer. +struct evp_cipher_ctx_st; + +namespace dish::mooncrypto { + +class ControlCipher { + public: + // Packet framing constants. + static constexpr std::size_t kHeaderSize = 4; // type + len + static constexpr std::size_t kSeqSize = 4; + static constexpr std::size_t kTagSize = 16; + static constexpr std::size_t kOverhead = kHeaderSize + kSeqSize + kTagSize; + + ControlCipher(); + ~ControlCipher(); + + ControlCipher(const ControlCipher&) = delete; + ControlCipher& operator=(const ControlCipher&) = delete; + ControlCipher(ControlCipher&&) = delete; + ControlCipher& operator=(ControlCipher&&) = delete; + + // Installs the 16-byte rikey and prepares both directions. Must succeed + // before seal/open; returns false on an OpenSSL failure. + bool setKey(const std::array& key); + bool hasKey() const { return keySet_; } + + // Seals `plaintext` into a full ENCRYPTED packet at `out`, which must have + // room for ptLen + kOverhead bytes. Returns the total packet length, or 0 + // on failure (no key, oversized, or OpenSSL error). + std::size_t seal(std::uint32_t seq, const std::uint8_t* plaintext, std::size_t ptLen, + std::uint8_t* out); + + // Opens a full ENCRYPTED packet: parses the framing, verifies the GCM tag + // and writes the plaintext into `out` (capacity `outCap`). nullopt on a + // short/misframed packet, a failed authentication, or a too-small buffer. + std::optional open(const std::uint8_t* packet, std::size_t len, std::uint8_t* out, + std::size_t outCap); + + private: + evp_cipher_ctx_st* encCtx_ = nullptr; + evp_cipher_ctx_st* decCtx_ = nullptr; + bool keySet_ = false; +}; + +} // namespace dish::mooncrypto diff --git a/src/core/moonlight/MoonlightPadSlots.h b/src/core/moonlight/MoonlightPadSlots.h new file mode 100644 index 0000000..a1bbe77 --- /dev/null +++ b/src/core/moonlight/MoonlightPadSlots.h @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Controller-number allocation, the CONTROLLER_MULTI active mask, and the +// hard-coded capability table a CONTROLLER_ARRIVAL declares — pure logic, so +// every bind/unbind decision is testable without a socket or a pad. +// +// A Moonlight host identifies each virtual pad by a small controller number, +// and every CONTROLLER_MULTI carries a bitfield of the controllers present. +// Clearing a controller's bit while still naming it in `ctrl #` is how the +// protocol signals an unplug, so the last packet after an unbind has to go out +// with the bit already dropped. +// +// THE CAPABILITY TABLE IS CLIENT-SIDE KNOWLEDGE. No Moonlight host exposes what +// its emulated devices can do: /serverinfo carries no controller element, +// /applist rows carry only title/id/HDR, and 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 this client +// declares, and never reports the choice back. So the table below is what the +// reference host actually constructs per type (Wolf create_new_joypad), and no +// copy anywhere may promise the host will honour the pick. + +#pragma once + +#include "core/moonlight/MoonlightProtocol.h" + +#include +#include +#include +#include +#include + +namespace dish::moonlight { + +// Four virtual pads per session, the XInput ceiling every reference host +// implements. Named here rather than written at a call site. +inline constexpr std::uint8_t kMaxPads = 4; + +// slotId -> controller number, plus the derived active mask. Copyable, so a +// caller can snapshot it under a lock and act on the copy outside. +class PadSlots { + public: + // Assigns the lowest free controller number. nullopt when the session is + // full or the slot already holds one — Wolf skips a CONTROLLER_ARRIVAL for + // a number already present, so a live index is never reused. + std::optional assign(const std::string& slotId) { + if (assigned_.count(slotId) != 0) { return std::nullopt; } + for (std::uint8_t n = 0; n < kMaxPads; ++n) { + bool taken = false; + for (const auto& [id, num] : assigned_) { + if (num == n) { + taken = true; + break; + } + } + if (!taken) { + assigned_[slotId] = n; + return n; + } + } + return std::nullopt; + } + + std::optional numberFor(const std::string& slotId) const { + const auto it = assigned_.find(slotId); + if (it == assigned_.end()) { return std::nullopt; } + return it->second; + } + + // The slot holding `number`, if any. The host addresses rumble, trigger + // rumble and RGB by controller number, so this is how an inbound event + // finds the pad it belongs to. + std::optional slotFor(std::uint8_t number) const { + for (const auto& [id, num] : assigned_) { + if (num == number) { return id; } + } + return std::nullopt; + } + + // Releases the slot and returns the number it held, so the caller can send + // the final bit-cleared CONTROLLER_MULTI for it. + std::optional release(const std::string& slotId) { + const auto it = assigned_.find(slotId); + if (it == assigned_.end()) { return std::nullopt; } + const std::uint8_t number = it->second; + assigned_.erase(it); + return number; + } + + std::uint16_t activeMask() const { + std::uint16_t mask = 0; + for (const auto& [id, num] : assigned_) { + mask = static_cast(mask | (1U << num)); + } + return mask; + } + + bool empty() const { return assigned_.empty(); } + bool full() const { return assigned_.size() >= kMaxPads; } + std::size_t size() const { return assigned_.size(); } + + const std::map& all() const { return assigned_; } + + private: + std::map assigned_; +}; + +// What the local input source can actually deliver. Declaring a capability the +// source cannot provide makes the host ask for motion reports that never +// arrive, so the declared bitfield is always intersected with this. +struct SourceCapabilities { + bool rumble = false; + bool motion = false; + bool touchpad = false; + bool battery = false; + bool lightbar = false; +}; + +inline std::uint8_t sourceCapabilityBits(const SourceCapabilities& source) { + // Analog triggers are present on every pad Dish forwards. + std::uint8_t bits = moonproto::kCapAnalogTriggers; + if (source.rumble) { + bits = + static_cast(bits | moonproto::kCapRumble | moonproto::kCapTriggerRumble); + } + if (source.motion) { + bits = static_cast(bits | moonproto::kCapAccelerometer | moonproto::kCapGyro); + } + if (source.touchpad) { bits = static_cast(bits | moonproto::kCapTouchpad); } + if (source.battery) { bits = static_cast(bits | moonproto::kCapBattery); } + if (source.lightbar) { bits = static_cast(bits | moonproto::kCapRgbLed); } + return bits; +} + +// The most a host's emulated device of this type can carry, whatever the pad +// behind it offers. A PlayStation pad is the only one the reference host wires +// motion, touch, battery and an LED into; its Xbox and Nintendo devices are +// sticks, buttons, analog triggers and body rumble. +// +// Nintendo carries NO MOTION here, unlike the satellite `switchpro` type. The +// two are different type systems that happen to share names: the host requests +// accelerometer and gyro only for a PlayStation device and routes motion only +// into its PS5 joypad. +inline std::uint8_t typeCapabilityCeiling(std::uint8_t controllerType) { + if (controllerType == moonproto::kControllerTypePs) { + return static_cast(moonproto::kCapAnalogTriggers | moonproto::kCapRumble | + moonproto::kCapTriggerRumble | moonproto::kCapTouchpad | + moonproto::kCapAccelerometer | moonproto::kCapGyro | + moonproto::kCapBattery | moonproto::kCapRgbLed); + } + return static_cast(moonproto::kCapAnalogTriggers | moonproto::kCapRumble); +} + +// Auto resolves HERE, on the client, before anything reaches the wire: a source +// that reports gyro or 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 to PlayStation and lets the type card state what +// the pad will really support. +inline std::uint8_t resolveAutoType(bool sourceHasMotion) { + return sourceHasMotion ? moonproto::kControllerTypePs : moonproto::kControllerTypeXbox; +} + +// A stored pick, normalised. A record written before the sentinel converged +// holds 0 for Auto, which collides with the wire's CONTROLLER_TYPE_UNKNOWN, so +// it migrates on read; so does anything outside the picker's own range. +inline int migrateControllerType(int stored) { + if (stored == moonproto::kControllerTypeXbox || stored == moonproto::kControllerTypePs || + stored == moonproto::kControllerTypeNintendo) { + return stored; + } + return moonproto::kControllerTypeAuto; +} + +// The stored pick as a wire type byte, with Auto resolved against the source. +inline std::uint8_t resolveControllerType(int stored, bool sourceHasMotion) { + const int normalised = migrateControllerType(stored); + if (normalised == moonproto::kControllerTypeAuto) { return resolveAutoType(sourceHasMotion); } + return static_cast(normalised); +} + +// What CONTROLLER_ARRIVAL declares: the type's ceiling intersected with what +// the source can deliver. +inline std::uint8_t declaredCapabilities(std::uint8_t resolvedType, + const SourceCapabilities& source) { + return static_cast(typeCapabilityCeiling(resolvedType) & + sourceCapabilityBits(source)); +} + +// The advertised button set: the whole legacy 16-bit word, plus the touchpad +// click only when a touchpad is in the live set for this binding. +inline std::uint32_t declaredButtons(std::uint8_t declaredCaps) { + std::uint32_t buttons = moonproto::kStandardButtons; + if ((declaredCaps & moonproto::kCapTouchpad) != 0) { buttons |= moonproto::kBtnTouchpad; } + return buttons; +} + +} // namespace dish::moonlight diff --git a/src/core/moonlight/MoonlightPairing.cpp b/src/core/moonlight/MoonlightPairing.cpp new file mode 100644 index 0000000..11a815c --- /dev/null +++ b/src/core/moonlight/MoonlightPairing.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightPairing.h" + +#include "Util/Hex.h" + +#include +#include +#include +#include + +namespace dish::moonpair { +namespace { + +using mooncrypto::Bytes; + +// The wire convention is uppercase hex; every parser on the host side accepts +// either case, but emitting what real clients emit costs nothing. +std::string toUpperHex(const std::uint8_t* data, std::size_t len) { + std::string hex = util::toHex(data, len); + std::transform(hex.begin(), hex.end(), hex.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + return hex; +} + +std::optional fromHex(const std::string& hex) { return util::fromHex(hex); } + +} // namespace + +std::string pinFromRandom(std::uint32_t random) { + char buf[5]; + // Discarded deliberately: "%04u" of a value below 10000 always writes + // exactly four digits into this five-byte buffer, so the length it returns + // is a constant and truncation cannot occur. + (void)std::snprintf(buf, sizeof(buf), "%04u", random % 10000U); + return buf; +} + +PairingSession::PairingSession(std::string clientCertPem, std::string clientKeyPem, + const std::array& salt, const std::string& pin, + const std::array& clientChallenge, + const std::array& clientSecret) + : clientCertPem_(std::move(clientCertPem)), clientKeyPem_(std::move(clientKeyPem)), salt_(salt), + aesKey_(mooncrypto::derivePairingKey(salt, pin)), clientChallenge_(clientChallenge), + clientSecret_(clientSecret) {} + +std::string PairingSession::saltHex() const { return toUpperHex(salt_.data(), salt_.size()); } + +std::string PairingSession::clientCertHex() const { + return toUpperHex(reinterpret_cast(clientCertPem_.data()), + clientCertPem_.size()); +} + +bool PairingSession::acceptServerCert(const std::string& plaincertHex) { + const auto pemBytes = fromHex(plaincertHex); + if (!pemBytes || pemBytes->empty()) { return false; } + std::string pem(pemBytes->begin(), pemBytes->end()); + if (!mooncrypto::isValidCertPem(pem)) { return false; } + serverCertPem_ = std::move(pem); + return true; +} + +std::optional PairingSession::clientChallengeHex() const { + if (serverCertPem_.empty()) { return std::nullopt; } + const auto encrypted = + mooncrypto::aesEcbEncrypt(aesKey_, clientChallenge_.data(), clientChallenge_.size()); + if (!encrypted) { return std::nullopt; } + return toUpperHex(encrypted->data(), encrypted->size()); +} + +std::optional +PairingSession::acceptChallengeResponse(const std::string& challengeResponseHex) { + const auto encrypted = fromHex(challengeResponseHex); + if (!encrypted) { return std::nullopt; } + const auto decrypted = mooncrypto::aesEcbDecrypt(aesKey_, encrypted->data(), encrypted->size()); + // hash(32) + server challenge(16). + if (!decrypted || decrypted->size() < mooncrypto::kSha256Size + serverChallenge_.size()) { + return std::nullopt; + } + std::memcpy(serverResponseHash_.data(), decrypted->data(), serverResponseHash_.size()); + std::memcpy(serverChallenge_.data(), decrypted->data() + mooncrypto::kSha256Size, + serverChallenge_.size()); + haveChallengeResponse_ = true; + + // Client hash = SHA-256(server challenge + client cert signature + client + // secret), sent back encrypted. + const auto certSig = mooncrypto::certSignature(clientCertPem_); + if (!certSig) { return std::nullopt; } + Bytes material(serverChallenge_.begin(), serverChallenge_.end()); + material.insert(material.end(), certSig->begin(), certSig->end()); + material.insert(material.end(), clientSecret_.begin(), clientSecret_.end()); + const auto clientHash = mooncrypto::sha256(material.data(), material.size()); + const auto sealed = mooncrypto::aesEcbEncrypt(aesKey_, clientHash.data(), clientHash.size()); + if (!sealed) { return std::nullopt; } + return toUpperHex(sealed->data(), sealed->size()); +} + +bool PairingSession::acceptPairingSecret(const std::string& pairingSecretHex) { + if (!haveChallengeResponse_ || serverCertPem_.empty()) { return false; } + const auto secretAndSig = fromHex(pairingSecretHex); + if (!secretAndSig || + secretAndSig->size() < mooncrypto::kPairingSecretSize + mooncrypto::kRsaSignatureSize) { + return false; + } + const std::uint8_t* serverSecret = secretAndSig->data(); + const std::uint8_t* signature = secretAndSig->data() + mooncrypto::kPairingSecretSize; + + // The phase-2 hash must commit to OUR challenge, the server cert's own + // signature bytes and the secret the server just revealed. A wrong PIN + // breaks this (the decryptions diverge), as does a substituted cert. + const auto serverCertSig = mooncrypto::certSignature(serverCertPem_); + if (!serverCertSig) { return false; } + Bytes material(clientChallenge_.begin(), clientChallenge_.end()); + material.insert(material.end(), serverCertSig->begin(), serverCertSig->end()); + material.insert(material.end(), serverSecret, serverSecret + mooncrypto::kPairingSecretSize); + const auto expected = mooncrypto::sha256(material.data(), material.size()); + if (std::memcmp(expected.data(), serverResponseHash_.data(), expected.size()) != 0) { + return false; + } + + // And the secret must be signed by the key behind the server certificate. + return mooncrypto::rsaVerifySha256(serverCertPem_, serverSecret, mooncrypto::kPairingSecretSize, + signature, mooncrypto::kRsaSignatureSize); +} + +std::optional PairingSession::clientPairingSecretHex() const { + const auto signature = + mooncrypto::rsaSignSha256(clientKeyPem_, clientSecret_.data(), clientSecret_.size()); + if (!signature) { return std::nullopt; } + Bytes payload(clientSecret_.begin(), clientSecret_.end()); + payload.insert(payload.end(), signature->begin(), signature->end()); + return toUpperHex(payload.data(), payload.size()); +} + +} // namespace dish::moonpair diff --git a/src/core/moonlight/MoonlightPairing.h b/src/core/moonlight/MoonlightPairing.h new file mode 100644 index 0000000..4ca3c14 --- /dev/null +++ b/src/core/moonlight/MoonlightPairing.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The client side of the 5-phase Moonlight PIN pairing handshake, as pure +// computation: every random input is injected, so the whole exchange is +// deterministic and unit-testable in both directions. The HTTP transport lives +// in source/moonlight; this class only turns responses into the next request's +// parameters and verdicts. +// +// Protocol (Wolf docs/http-pairing.adoc + moonlight.cpp, mirrored client-side): +// 1. -> salt + client cert PEM (hex); <- server cert PEM (hex). +// Both ends derive AES = SHA-256(salt||PIN)[0:16]. +// 2. -> AES-ECB(client challenge) (hex); <- AES-ECB(server response +// hash(32) + server challenge(16)). +// 3. -> AES-ECB(SHA-256(server challenge + client cert signature + client +// secret)) (hex); <- server secret(16) + +// RSA-SHA256 signature(256) (hex). The client now checks the phase-2 +// hash == SHA-256(client challenge + server cert signature + server +// secret) AND that the secret's signature verifies against the server +// cert. +// 4. -> client secret(16) + RSA-SHA256 signature(256) (hex); <- paired=1. +// 5. over HTTPS with the client cert: phrase=pairchallenge; <- paired=1. + +#pragma once + +#include "core/moonlight/MoonlightPairingCrypto.h" + +#include +#include +#include +#include + +namespace dish::moonpair { + +// The PIN the user types into the host, 4 digits with leading zeros kept. +std::string pinFromRandom(std::uint32_t random); + +class PairingSession { + public: + // `salt`, `clientChallenge` and `clientSecret` are the handshake's three + // random 16-byte inputs; production callers fill them from the CSPRNG, + // tests pass fixed bytes. + PairingSession(std::string clientCertPem, std::string clientKeyPem, + const std::array& salt, const std::string& pin, + const std::array& clientChallenge, + const std::array& clientSecret); + + // Phase 1 query parameters (uppercase hex, the wire convention). + std::string saltHex() const; + std::string clientCertHex() const; + + // Phase 1 response: the server's `plaincert` (hex-encoded PEM). False on + // undecodable hex or a string that is not a certificate. + bool acceptServerCert(const std::string& plaincertHex); + const std::string& serverCertPem() const { return serverCertPem_; } + + // Phase 2 request: `clientchallenge`. nullopt before acceptServerCert or on + // a crypto failure. + std::optional clientChallengeHex() const; + + // Phase 2 response -> phase 3 request: decrypts `challengeresponse`, holds + // the server's response hash for the phase-3 check, and returns + // `serverchallengeresp`. nullopt on malformed input. + std::optional acceptChallengeResponse(const std::string& challengeResponseHex); + + // Phase 3 response: `pairingsecret`. True only when the server proves + // knowledge of the PIN-derived key (hash check) AND of its certificate's + // private key (signature check). False otherwise — treat as wrong PIN or a + // man in the middle and abort. + bool acceptPairingSecret(const std::string& pairingSecretHex); + + // Phase 4 request: `clientpairingsecret`. nullopt on a signing failure. + std::optional clientPairingSecretHex() const; + + private: + std::string clientCertPem_; + std::string clientKeyPem_; + std::array salt_{}; + std::array aesKey_{}; + std::array clientChallenge_{}; + std::array clientSecret_{}; + + std::string serverCertPem_; + std::array serverResponseHash_{}; + std::array serverChallenge_{}; + bool haveChallengeResponse_ = false; +}; + +} // namespace dish::moonpair diff --git a/src/core/moonlight/MoonlightPairingCrypto.cpp b/src/core/moonlight/MoonlightPairingCrypto.cpp new file mode 100644 index 0000000..6925244 --- /dev/null +++ b/src/core/moonlight/MoonlightPairingCrypto.cpp @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightPairingCrypto.h" + +#include "Util/Hex.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace dish::mooncrypto { +namespace { + +using CipherCtx = std::unique_ptr; +using MdCtx = std::unique_ptr; +using PkeyPtr = std::unique_ptr; +using PkeyCtx = std::unique_ptr; +using X509Ptr = std::unique_ptr; +using BioPtr = std::unique_ptr; + +X509Ptr certFromPem(const std::string& pem) { + BioPtr bio(BIO_new_mem_buf(pem.data(), static_cast(pem.size())), BIO_free); + if (!bio) { return {nullptr, X509_free}; } + return {PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr), X509_free}; +} + +PkeyPtr privateKeyFromPem(const std::string& pem) { + BioPtr bio(BIO_new_mem_buf(pem.data(), static_cast(pem.size())), BIO_free); + if (!bio) { return {nullptr, EVP_PKEY_free}; } + return {PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr), EVP_PKEY_free}; +} + +std::optional aesEcb(const std::array& key, + const std::uint8_t* data, std::size_t len, bool encrypt) { + if (data == nullptr || len == 0 || (len % kAesBlockSize) != 0) { return std::nullopt; } + CipherCtx ctx(EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_free); + if (!ctx) { return std::nullopt; } + const int initOk = + encrypt ? EVP_EncryptInit_ex(ctx.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr) + : EVP_DecryptInit_ex(ctx.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr); + if (initOk != 1) { return std::nullopt; } + if (EVP_CIPHER_CTX_set_padding(ctx.get(), 0) != 1) { return std::nullopt; } + + Bytes out(len + kAesBlockSize); + int outLen = 0; + const int updateOk = + encrypt ? EVP_EncryptUpdate(ctx.get(), out.data(), &outLen, data, static_cast(len)) + : EVP_DecryptUpdate(ctx.get(), out.data(), &outLen, data, static_cast(len)); + if (updateOk != 1) { return std::nullopt; } + int finalLen = 0; + const int finalOk = encrypt ? EVP_EncryptFinal_ex(ctx.get(), out.data() + outLen, &finalLen) + : EVP_DecryptFinal_ex(ctx.get(), out.data() + outLen, &finalLen); + if (finalOk != 1) { return std::nullopt; } + out.resize(static_cast(outLen) + static_cast(finalLen)); + return out; +} + +} // namespace + +std::array sha256(const std::uint8_t* data, std::size_t len) { + std::array digest{}; + MdCtx ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); + unsigned int digestLen = 0; + if (ctx && EVP_DigestInit_ex(ctx.get(), EVP_sha256(), nullptr) == 1 && + EVP_DigestUpdate(ctx.get(), data, len) == 1 && + EVP_DigestFinal_ex(ctx.get(), digest.data(), &digestLen) == 1) { + return digest; + } + digest.fill(0); + return digest; +} + +bool randomBytes(std::uint8_t* out, std::size_t len) { + return RAND_bytes(out, static_cast(len)) == 1; +} + +std::array +derivePairingKey(const std::array& salt, const std::string& pin) { + Bytes material(salt.begin(), salt.end()); + material.insert(material.end(), pin.begin(), pin.end()); + const auto digest = sha256(material.data(), material.size()); + std::array key{}; + std::memcpy(key.data(), digest.data(), kAesKeySize); + return key; +} + +std::optional aesEcbEncrypt(const std::array& key, + const std::uint8_t* data, std::size_t len) { + return aesEcb(key, data, len, true); +} + +std::optional aesEcbDecrypt(const std::array& key, + const std::uint8_t* data, std::size_t len) { + return aesEcb(key, data, len, false); +} + +std::optional rsaSignSha256(const std::string& privateKeyPem, const std::uint8_t* msg, + std::size_t len) { + PkeyPtr key = privateKeyFromPem(privateKeyPem); + if (!key) { return std::nullopt; } + MdCtx ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!ctx || EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, key.get()) != 1 || + EVP_DigestSignUpdate(ctx.get(), msg, len) != 1) { + return std::nullopt; + } + std::size_t sigLen = 0; + if (EVP_DigestSignFinal(ctx.get(), nullptr, &sigLen) != 1) { return std::nullopt; } + Bytes sig(sigLen); + if (EVP_DigestSignFinal(ctx.get(), sig.data(), &sigLen) != 1) { return std::nullopt; } + sig.resize(sigLen); + return sig; +} + +bool rsaVerifySha256(const std::string& certPem, const std::uint8_t* msg, std::size_t len, + const std::uint8_t* sig, std::size_t sigLen) { + X509Ptr cert = certFromPem(certPem); + if (!cert) { return false; } + PkeyPtr key(X509_get_pubkey(cert.get()), EVP_PKEY_free); + if (!key) { return false; } + MdCtx ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!ctx || EVP_DigestVerifyInit(ctx.get(), nullptr, EVP_sha256(), nullptr, key.get()) != 1 || + EVP_DigestVerifyUpdate(ctx.get(), msg, len) != 1) { + return false; + } + return EVP_DigestVerifyFinal(ctx.get(), sig, sigLen) == 1; +} + +std::optional generateClientIdentity() { + // 2048-bit RSA, the size every Moonlight host expects (the pairing + // signature length is pinned to 256 bytes). + PkeyCtx keyCtx(EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr), EVP_PKEY_CTX_free); + if (!keyCtx || EVP_PKEY_keygen_init(keyCtx.get()) <= 0 || + EVP_PKEY_CTX_set_rsa_keygen_bits(keyCtx.get(), 2048) <= 0) { + return std::nullopt; + } + EVP_PKEY* rawKey = nullptr; + if (EVP_PKEY_keygen(keyCtx.get(), &rawKey) <= 0) { return std::nullopt; } + PkeyPtr key(rawKey, EVP_PKEY_free); + + X509Ptr cert(X509_new(), X509_free); + if (!cert) { return std::nullopt; } + ASN1_INTEGER_set(X509_get_serialNumber(cert.get()), 1); + X509_set_version(cert.get(), 2); + // Valid for 20 years; hosts deliberately tolerate clock skew on either end. + constexpr long kValidSeconds = 630720000L; + X509_gmtime_adj(X509_getm_notBefore(cert.get()), 0); + X509_gmtime_adj(X509_getm_notAfter(cert.get()), kValidSeconds); + X509_set_pubkey(cert.get(), key.get()); + + X509_NAME* name = X509_get_subject_name(cert.get()); + const auto addEntry = [name](const char* field, const char* value) { + X509_NAME_add_entry_by_txt(name, field, MBSTRING_ASC, + reinterpret_cast(value), -1, -1, 0); + }; + addEntry("O", "TinkerNorth"); + addEntry("CN", "Dish Client"); + X509_set_issuer_name(cert.get(), name); + + if (X509_sign(cert.get(), key.get(), EVP_sha256()) == 0) { return std::nullopt; } + + const auto pemOf = [](auto writeFn) -> std::optional { + BioPtr bio(BIO_new(BIO_s_mem()), BIO_free); + if (!bio || writeFn(bio.get()) != 1) { return std::nullopt; } + BUF_MEM* mem = nullptr; + BIO_get_mem_ptr(bio.get(), &mem); + if (mem == nullptr || mem->data == nullptr) { return std::nullopt; } + return std::string(mem->data, mem->length); + }; + + const auto certPem = pemOf([&cert](BIO* bio) { return PEM_write_bio_X509(bio, cert.get()); }); + const auto keyPem = pemOf([&key](BIO* bio) { + return PEM_write_bio_PrivateKey(bio, key.get(), nullptr, nullptr, 0, nullptr, nullptr); + }); + if (!certPem || !keyPem) { return std::nullopt; } + return ClientIdentity{*certPem, *keyPem}; +} + +std::optional certSignature(const std::string& certPem) { + X509Ptr cert = certFromPem(certPem); + if (!cert) { return std::nullopt; } + const ASN1_BIT_STRING* sig = nullptr; + X509_get0_signature(&sig, nullptr, cert.get()); + if (sig == nullptr || sig->data == nullptr || sig->length <= 0) { return std::nullopt; } + return Bytes(sig->data, sig->data + sig->length); +} + +std::optional certFingerprintHex(const std::string& certPem) { + X509Ptr cert = certFromPem(certPem); + if (!cert) { return std::nullopt; } + unsigned char* der = nullptr; + const int derLen = i2d_X509(cert.get(), &der); + if (derLen <= 0 || der == nullptr) { return std::nullopt; } + const auto digest = sha256(der, static_cast(derLen)); + OPENSSL_free(der); + return util::toHex(digest.data(), digest.size()); +} + +bool isValidCertPem(const std::string& pem) { return static_cast(certFromPem(pem)); } + +} // namespace dish::mooncrypto diff --git a/src/core/moonlight/MoonlightPairingCrypto.h b/src/core/moonlight/MoonlightPairingCrypto.h new file mode 100644 index 0000000..2cdcc7b --- /dev/null +++ b/src/core/moonlight/MoonlightPairingCrypto.h @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The OpenSSL-backed primitives the Moonlight pairing handshake needs: SHA-256, +// AES-128-ECB, RSA-SHA256 PKCS#1 v1.5 sign/verify, and self-signed client +// identity generation. Semantics ported from Wolf's MIT-licensed crypto module +// (games-on-whales/wolf, src/moonlight-protocol/crypto) so the two ends of the +// handshake agree byte-for-byte; see THIRD_PARTY.md. +// +// This is the one core module with a crypto-library dependency beside +// wire/SessionCrypto (libsodium): the Moonlight protocol fixes AES-128 and RSA +// X.509 certificates, which libsodium deliberately does not provide, so it +// links OpenSSL's libcrypto instead. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace dish::mooncrypto { + +using Bytes = std::vector; + +inline constexpr std::size_t kAesBlockSize = 16; +inline constexpr std::size_t kAesKeySize = 16; +inline constexpr std::size_t kSha256Size = 32; +inline constexpr std::size_t kPairingSaltSize = 16; +inline constexpr std::size_t kPairingSecretSize = 16; +// 2048-bit RSA, so every pairing signature is exactly this long. +inline constexpr std::size_t kRsaSignatureSize = 256; + +std::array sha256(const std::uint8_t* data, std::size_t len); + +// CSPRNG fill; false only when the system entropy source fails. +bool randomBytes(std::uint8_t* out, std::size_t len); + +// Pairing key = first 16 bytes of SHA-256(salt bytes || PIN as ASCII digits). +std::array +derivePairingKey(const std::array& salt, const std::string& pin); + +// AES-128-ECB without padding: `len` must be a multiple of the block size. +// nullopt on a bad length or an OpenSSL failure. +std::optional aesEcbEncrypt(const std::array& key, + const std::uint8_t* data, std::size_t len); +std::optional aesEcbDecrypt(const std::array& key, + const std::uint8_t* data, std::size_t len); + +// RSA PKCS#1 v1.5 over SHA-256, the signature scheme both pairing directions +// use. Sign takes the PEM private key; verify takes the peer's PEM certificate. +std::optional rsaSignSha256(const std::string& privateKeyPem, const std::uint8_t* msg, + std::size_t len); +bool rsaVerifySha256(const std::string& certPem, const std::uint8_t* msg, std::size_t len, + const std::uint8_t* sig, std::size_t sigLen); + +// The client identity: a 2048-bit RSA key and a self-signed X.509 certificate, +// generated once and persisted. The cert authenticates every HTTPS call after +// pairing, so losing it means re-pairing every host. +struct ClientIdentity { + std::string certPem; + std::string privateKeyPem; +}; + +std::optional generateClientIdentity(); + +// The DER bytes of the certificate's signature BIT STRING — the "cert +// signature" material both pairing hashes mix in. +std::optional certSignature(const std::string& certPem); + +// Lowercase hex SHA-256 of the certificate's DER encoding, for TLS pinning. +std::optional certFingerprintHex(const std::string& certPem); + +// True when `pem` parses as an X.509 certificate. +bool isValidCertPem(const std::string& pem); + +} // namespace dish::mooncrypto diff --git a/src/core/moonlight/MoonlightProtocol.h b/src/core/moonlight/MoonlightProtocol.h new file mode 100644 index 0000000..5bbff81 --- /dev/null +++ b/src/core/moonlight/MoonlightProtocol.h @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Wire constants for the Moonlight (GameStream) host protocol spoken by +// Sunshine, Apollo and Wolf. Values follow the protocol documentation and host +// implementation of Wolf (MIT, games-on-whales/wolf): docs/modules/protocols +// and src/moonlight-protocol/moonlight/control.hpp. Where the two disagree the +// source is authoritative (TERMINATION is 0x0109 on the wire, not the 0x0100 +// the overview page lists). + +#pragma once + +#include + +namespace dish::moonproto { + +// Fixed TCP ports. Everything else (RTSP, control, RTP) is dynamic: RTSP comes +// out of the launch response, the stream ports out of RTSP SETUP. +inline constexpr int kDefaultHttpPort = 47989; +inline constexpr int kDefaultHttpsPort = 47984; + +// mDNS service Moonlight hosts advertise. +inline constexpr const char* kMdnsService = "_nvstream._tcp.local."; + +// ── Control-stream packet types (outer, and decrypted inner) ───────────────── +// All little-endian on the wire. +inline constexpr std::uint16_t kPktEncrypted = 0x0001; +inline constexpr std::uint16_t kPktTermination = 0x0109; +inline constexpr std::uint16_t kPktPeriodicPing = 0x0200; +inline constexpr std::uint16_t kPktInputData = 0x0206; +inline constexpr std::uint16_t kPktRumbleData = 0x010B; +inline constexpr std::uint16_t kPktRumbleTriggers = 0x5500; +inline constexpr std::uint16_t kPktMotionEvent = 0x5501; +inline constexpr std::uint16_t kPktRgbLed = 0x5502; + +// Graceful-quit reason, stored big-endian in the TERMINATION payload. +inline constexpr std::uint32_t kTerminateReasonGraceful = 0x80030023; + +// ── INPUT_DATA input types (u32, little-endian on the wire) ────────────────── +inline constexpr std::uint32_t kInputMouseMoveRel = 0x00000007; +inline constexpr std::uint32_t kInputControllerMulti = 0x0000000C; +inline constexpr std::uint32_t kInputControllerArrival = 0x55000004; +inline constexpr std::uint32_t kInputControllerTouch = 0x55000005; +inline constexpr std::uint32_t kInputControllerMotion = 0x55000006; +inline constexpr std::uint32_t kInputControllerBattery = 0x55000007; + +// ── CONTROLLER_ARRIVAL types (the "device to emulate" picker) ──────────────── +inline constexpr std::uint8_t kControllerTypeUnknown = 0x00; +inline constexpr std::uint8_t kControllerTypeXbox = 0x01; +inline constexpr std::uint8_t kControllerTypePs = 0x02; +inline constexpr std::uint8_t kControllerTypeNintendo = 0x03; + +// "Match the pad" — resolved against the bound input source before the wire, so +// it never travels. 0xFF and not 0x00: 0x00 is CONTROLLER_TYPE_UNKNOWN, a real +// wire value the host reads as "you decide", which is a different promise. +inline constexpr std::uint8_t kControllerTypeAuto = 0xFF; + +// ── CONTROLLER_ARRIVAL capability bitfield ─────────────────────────────────── +inline constexpr std::uint8_t kCapAnalogTriggers = 0x01; +inline constexpr std::uint8_t kCapRumble = 0x02; +inline constexpr std::uint8_t kCapTriggerRumble = 0x04; +inline constexpr std::uint8_t kCapTouchpad = 0x08; +inline constexpr std::uint8_t kCapAccelerometer = 0x10; +inline constexpr std::uint8_t kCapGyro = 0x20; +inline constexpr std::uint8_t kCapBattery = 0x40; +inline constexpr std::uint8_t kCapRgbLed = 0x80; + +// ── CONTROLLER_MULTI button flags (effective = flags | (flags2 << 16)) ─────── +inline constexpr std::uint32_t kBtnDpadUp = 0x0001; +inline constexpr std::uint32_t kBtnDpadDown = 0x0002; +inline constexpr std::uint32_t kBtnDpadLeft = 0x0004; +inline constexpr std::uint32_t kBtnDpadRight = 0x0008; +inline constexpr std::uint32_t kBtnStart = 0x0010; +inline constexpr std::uint32_t kBtnBack = 0x0020; +inline constexpr std::uint32_t kBtnLeftStick = 0x0040; +inline constexpr std::uint32_t kBtnRightStick = 0x0080; +inline constexpr std::uint32_t kBtnLeftButton = 0x0100; +inline constexpr std::uint32_t kBtnRightButton = 0x0200; +inline constexpr std::uint32_t kBtnHome = 0x0400; +inline constexpr std::uint32_t kBtnA = 0x1000; +inline constexpr std::uint32_t kBtnB = 0x2000; +inline constexpr std::uint32_t kBtnX = 0x4000; +inline constexpr std::uint32_t kBtnY = 0x8000; +inline constexpr std::uint32_t kBtnPaddle1 = 0x010000; +inline constexpr std::uint32_t kBtnPaddle2 = 0x020000; +inline constexpr std::uint32_t kBtnPaddle3 = 0x040000; +inline constexpr std::uint32_t kBtnPaddle4 = 0x080000; +inline constexpr std::uint32_t kBtnTouchpad = 0x100000; +inline constexpr std::uint32_t kBtnMisc = 0x200000; + +// Named in neither Wolf's table nor its control.hpp, and set in what every +// shipping client advertises. Carried so the advertised word is the whole low +// half rather than a hole a host might read as a missing button. +inline constexpr std::uint32_t kBtnReservedLow = 0x0800; + +// The support_button_flags a standard pad advertises in CONTROLLER_ARRIVAL: the +// whole 16-bit legacy word (dpad, Start/Back, sticks, shoulders, Home, ABXY and +// the one reserved bit). A live Sunshine host logs it back as +// supportedButtonFlags [0000FFFF]; the three Dish clients advertise this one +// value so a host cannot see three different pads. +inline constexpr std::uint32_t kStandardButtons = + kBtnDpadUp | kBtnDpadDown | kBtnDpadLeft | kBtnDpadRight | kBtnStart | kBtnBack | + kBtnLeftStick | kBtnRightStick | kBtnLeftButton | kBtnRightButton | kBtnHome | kBtnReservedLow | + kBtnA | kBtnB | kBtnX | kBtnY; + +// ── CONTROLLER_MOTION types (also carried by the host's MOTION_EVENT) ──────── +inline constexpr std::uint8_t kMotionAcceleration = 0x01; +inline constexpr std::uint8_t kMotionGyroscope = 0x02; + +// ── CONTROLLER_BATTERY states ──────────────────────────────────────────────── +inline constexpr std::uint8_t kBatteryStateUnknown = 0x00; +inline constexpr std::uint8_t kBatteryNotPresent = 0x01; +inline constexpr std::uint8_t kBatteryDischarging = 0x02; +inline constexpr std::uint8_t kBatteryCharging = 0x03; +inline constexpr std::uint8_t kBatteryNotCharging = 0x04; +inline constexpr std::uint8_t kBatteryFull = 0x05; +inline constexpr std::uint8_t kBatteryPercentageUnknown = 0xFF; + +} // namespace dish::moonproto diff --git a/src/core/moonlight/MoonlightRtsp.cpp b/src/core/moonlight/MoonlightRtsp.cpp new file mode 100644 index 0000000..97bd755 --- /dev/null +++ b/src/core/moonlight/MoonlightRtsp.cpp @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightRtsp.h" + +#include +#include + +namespace dish::moonrtsp { +namespace { + +// The client version real Moonlight 5.x clients advertise. +constexpr const char* kClientVersion = "14"; + +bool iequals(std::string_view a, std::string_view b) { + if (a.size() != b.size()) { return false; } + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +std::string_view trimmed(std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) { s.remove_prefix(1); } + while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) { + s.remove_suffix(1); + } + return s; +} + +std::optional parseInt(std::string_view text) { + text = trimmed(text); + if (text.empty()) { return std::nullopt; } + long value = 0; + for (const char c : text) { + if (c < '0' || c > '9') { return std::nullopt; } + value = value * 10 + (c - '0'); + if (value > 2147483647L) { return std::nullopt; } + } + return static_cast(value); +} + +std::string requestHead(const std::string& cmd, const std::string& target, int cseq, + const std::string& sessionId) { + std::string out = cmd + " " + target + " RTSP/1.0\r\n"; + out += "CSeq: " + std::to_string(cseq) + "\r\n"; + out += "X-GS-ClientVersion: "; + out += kClientVersion; + out += "\r\n"; + if (!sessionId.empty()) { out += "Session: " + sessionId + "\r\n"; } + return out; +} + +} // namespace + +std::string formatOptions(int cseq, const std::string& target) { + return requestHead("OPTIONS", target, cseq, "") + "\r\n"; +} + +std::string formatDescribe(int cseq, const std::string& target) { + std::string out = requestHead("DESCRIBE", target, cseq, ""); + out += "Accept: application/sdp\r\n"; + out += "\r\n"; + return out; +} + +std::string formatSetup(int cseq, const std::string& streamId, const std::string& sessionId) { + std::string out = requestHead("SETUP", "streamid=" + streamId + "/0/0", cseq, sessionId); + // The host assigns the real ports; the client-port hint rides along for + // parity with real clients. + out += "Transport: unicast;X-GS-ClientPort=50000-50001\r\n"; + out += "If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT\r\n"; + out += "\r\n"; + return out; +} + +std::string formatAnnounce(int cseq, const std::string& sessionId, const std::string& payload) { + std::string out = requestHead("ANNOUNCE", "streamid=control/13/0", cseq, sessionId); + out += "Content-type: application/sdp\r\n"; + out += "Content-length: " + std::to_string(payload.size()) + "\r\n"; + out += "\r\n"; + out += payload; + return out; +} + +std::string formatPlay(int cseq, const std::string& target, const std::string& sessionId) { + return requestHead("PLAY", target, cseq, sessionId) + "\r\n"; +} + +std::string buildAnnouncePayload(const StreamConfig& config) { + // The WHOLE attribute set a real client sends. A host builds its stream + // configuration by looking each of these up by name and a lookup that + // misses is fatal: measured against a live Sunshine host, an ANNOUNCE + // carrying only the handful of attributes this client cares about is + // answered 400 BAD REQUEST while this set is answered 200 OK, with either + // line ending. Nothing here is decoration. + std::string p; + const auto line = [&p](const std::string& text) { p += text + "\r\n"; }; + const auto arg = [&line](const std::string& key, int value) { + line("a=" + key + ":" + std::to_string(value)); + }; + line("v=0"); + line("o=android 0 14 IN IPv4 0.0.0.0"); + line("s=NVIDIA Streaming Client"); + arg("x-nv-video[0].clientViewportWd", config.width); + arg("x-nv-video[0].clientViewportHt", config.height); + arg("x-nv-video[0].maxFPS", config.fps); + arg("x-nv-video[0].packetSize", config.packetSize); + arg("x-nv-video[0].rateControlMode", 4); + arg("x-nv-video[0].timeoutLengthMs", 7000); + arg("x-nv-video[0].framesWithInvalidRefThreshold", 0); + arg("x-nv-video[0].refPicInvalidation", 0); + arg("x-nv-video[0].encoderCscMode", 0); + arg("x-nv-video[0].dynamicRangeMode", 0); + arg("x-nv-video[0].maxNumReferenceFrames", 1); + arg("x-nv-video[0].videoEncoderSlicesPerFrame", 1); + arg("x-nv-video[0].clientRefreshRateX100", config.fps * 100); + arg("x-nv-vqos[0].bitStreamFormat", 0); // H.264, every host's floor + arg("x-nv-vqos[0].bw.minimumBitrateKbps", config.bitrateKbps); + arg("x-nv-vqos[0].bw.maximumBitrateKbps", config.bitrateKbps); + arg("x-nv-vqos[0].fec.enable", 1); + arg("x-nv-vqos[0].fec.minRequiredFecPackets", 2); + arg("x-nv-vqos[0].fec.repairPercent", 20); + arg("x-nv-vqos[0].drc.enable", 0); + arg("x-nv-vqos[0].videoQualityScoreUpdateTime", 5000); + arg("x-nv-vqos[0].qosTrafficType", 5); + arg("x-nv-aqos.qosTrafficType", 4); + arg("x-nv-aqos.packetDuration", 5); + arg("x-nv-audio.surround.numChannels", config.audioChannels); + arg("x-nv-audio.surround.channelMask", 3); + arg("x-nv-audio.surround.enable", 0); + arg("x-nv-audio.surround.AudioQuality", 0); + arg("x-nv-general.useReliableUdp", 13); + arg("x-nv-general.featureFlags", 167); + arg("x-ml-general.featureFlags", 3); + arg("x-ss-general.encryptionEnabled", 0); + line("t=0 0"); + return p; +} + +std::optional Response::option(std::string_view name) const { + for (const auto& [key, value] : options) { + if (iequals(key, name)) { return value; } + } + return std::nullopt; +} + +std::optional parseResponse(std::string_view text) { + static constexpr std::string_view kProto = "RTSP/"; + if (text.substr(0, kProto.size()) != kProto) { return std::nullopt; } + + Response resp; + std::size_t lineEnd = text.find('\n'); + std::string_view statusLine = + trimmed(text.substr(0, lineEnd == std::string_view::npos ? text.size() : lineEnd)); + // "RTSP/1.0 200 OK" + const std::size_t firstSpace = statusLine.find(' '); + if (firstSpace == std::string_view::npos) { return std::nullopt; } + std::string_view afterProto = statusLine.substr(firstSpace + 1); + const std::size_t secondSpace = afterProto.find(' '); + const std::string_view codeText = + secondSpace == std::string_view::npos ? afterProto : afterProto.substr(0, secondSpace); + const auto code = parseInt(codeText); + if (!code) { return std::nullopt; } + resp.statusCode = *code; + if (secondSpace != std::string_view::npos) { + resp.statusMessage = std::string(trimmed(afterProto.substr(secondSpace + 1))); + } + if (lineEnd == std::string_view::npos) { return resp; } + + std::size_t pos = lineEnd + 1; + while (pos < text.size()) { + std::size_t end = text.find('\n', pos); + if (end == std::string_view::npos) { end = text.size(); } + const std::string_view line = trimmed(text.substr(pos, end - pos)); + pos = end + 1; + if (line.empty()) { break; } // end of options; the rest is payload + const std::size_t colon = line.find(':'); + if (colon == std::string_view::npos) { continue; } + const std::string_view key = trimmed(line.substr(0, colon)); + const std::string_view value = trimmed(line.substr(colon + 1)); + if (iequals(key, "CSeq")) { + resp.cseq = parseInt(value).value_or(0); + } else { + resp.options.emplace_back(std::string(key), std::string(value)); + } + } + if (pos < text.size()) { resp.payload = std::string(text.substr(pos)); } + return resp; +} + +std::optional transportPort(const Response& response) { + const auto transport = response.option("Transport"); + if (!transport) { return std::nullopt; } + static constexpr std::string_view kKey = "server_port="; + const std::size_t at = transport->find(kKey); + if (at == std::string::npos) { return std::nullopt; } + std::string_view rest = std::string_view(*transport).substr(at + kKey.size()); + const std::size_t end = rest.find_first_not_of("0123456789"); + if (end != std::string_view::npos) { rest = rest.substr(0, end); } + const auto port = parseInt(rest); + if (!port || *port <= 0 || *port > 65535) { return std::nullopt; } + return port; +} + +std::optional connectData(const Response& response) { + const auto value = response.option("X-SS-Connect-Data"); + if (!value) { return std::nullopt; } + const std::string_view text = trimmed(*value); + if (text.empty()) { return std::nullopt; } + // Read wide, then narrow. The token is unsigned 32-bit and a real host's + // routinely sits above INT32_MAX (4270471497 came off a live Sunshine + // host), so a signed parse yields nothing and the control stream connects + // with a token of 0. + unsigned long long parsed = 0; + for (const char c : text) { + if (c < '0' || c > '9') { return std::nullopt; } + if (parsed > (0xFFFFFFFFFFFFFFFFULL - static_cast(c - '0')) / 10ULL) { + return std::nullopt; + } + parsed = parsed * 10ULL + static_cast(c - '0'); + } + return static_cast(parsed & 0xFFFFFFFFULL); +} + +std::optional contentLength(const Response& response) { + const auto value = response.option("Content-length"); + if (!value) { return std::nullopt; } + const auto parsed = parseInt(*value); + if (!parsed || *parsed < 0) { return std::nullopt; } + return parsed; +} + +std::optional pingPayload(const Response& response) { + return response.option("X-SS-Ping-Payload"); +} + +std::optional sessionId(const Response& response) { + const auto value = response.option("Session"); + if (!value) { return std::nullopt; } + const std::size_t semi = value->find(';'); + std::string id = semi == std::string::npos ? *value : value->substr(0, semi); + const std::string_view t = trimmed(id); + if (t.empty()) { return std::nullopt; } + return std::string(t); +} + +} // namespace dish::moonrtsp diff --git a/src/core/moonlight/MoonlightRtsp.h b/src/core/moonlight/MoonlightRtsp.h new file mode 100644 index 0000000..d0b4556 --- /dev/null +++ b/src/core/moonlight/MoonlightRtsp.h @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// RTSP request formatting and response parsing for the Moonlight handshake +// (OPTIONS -> DESCRIBE -> SETUP x3 -> ANNOUNCE -> PLAY over plaintext TCP). +// The request shapes mirror what real clients send and what Wolf's PEG parser +// (src/moonlight-protocol/rtsp/parser.hpp) accepts; responses are parsed +// leniently, accepting both \r\n and bare \n line endings since hosts emit +// both. The transport socket lives in source/moonlight; this is pure +// string work. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace dish::moonrtsp { + +// What the ANNOUNCE SDP advertises. The geometry follows the host's own +// display so a virtual-display host does not resize the user's desktop; the +// bitrate stays at the floor because video and audio are discarded. +struct StreamConfig { + int width = 1920; + int height = 1080; + int fps = 60; + int bitrateKbps = 500; + int packetSize = 1024; + int audioChannels = 2; +}; + +// `target` is the rtsp://host:port string the launch response handed out, +// parroted back verbatim — Wolf matches sessions on it, so it is never +// rewritten to the real address. `sessionId` is empty until the first SETUP +// response supplies one. +std::string formatOptions(int cseq, const std::string& target); +std::string formatDescribe(int cseq, const std::string& target); +// `streamId` is "audio", "video" or "control". +std::string formatSetup(int cseq, const std::string& streamId, const std::string& sessionId); +std::string formatAnnounce(int cseq, const std::string& sessionId, const std::string& payload); +std::string formatPlay(int cseq, const std::string& target, const std::string& sessionId); + +std::string buildAnnouncePayload(const StreamConfig& config); + +struct Response { + int statusCode = 0; + std::string statusMessage; + int cseq = 0; + std::vector> options; + std::string payload; + + bool ok() const { return statusCode == 200; } + // Case-insensitive header lookup. + std::optional option(std::string_view name) const; +}; + +// nullopt when `text` is not an RTSP response status line. Options and payload +// parse leniently: an option line without ':' is skipped, everything after the +// blank line is payload. +std::optional parseResponse(std::string_view text); + +// SETUP: "Transport: server_port=NNNN[;...]" -> the port. +std::optional transportPort(const Response& response); + +// SETUP control: "X-SS-Connect-Data" -> the u32 the ENet connect carries. +// Parsed unsigned and 64 bits wide, then narrowed to the 32 bits on the wire. +std::optional connectData(const Response& response); + +// "Content-length" -> the declared payload size. Absent on the DESCRIBE reply, +// which the host frames by closing the connection instead. +std::optional contentLength(const Response& response); + +// SETUP audio/video: "X-SS-Ping-Payload" -> the 16-char payload the RTP ping +// datagrams echo. Absent on hosts that accept the legacy 4-byte PING. +std::optional pingPayload(const Response& response); + +// SETUP: "Session: DEADBEEFCAFE;timeout = 90" -> "DEADBEEFCAFE". +std::optional sessionId(const Response& response); + +} // namespace dish::moonrtsp diff --git a/src/core/moonlight/MoonlightSessionMachine.h b/src/core/moonlight/MoonlightSessionMachine.h new file mode 100644 index 0000000..2fc80c5 --- /dev/null +++ b/src/core/moonlight/MoonlightSessionMachine.h @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The Moonlight session-launch lifecycle as a pure, total (state, event) -> +// (state, effects) reducer, modelled on UsbPathMachine. The coordinator +// (source/moonlight/MoonlightSession) turns network completions into events, +// runs reduce() on the Qt main thread, and executes the effects against the +// HTTP, RTSP and ENet edges. Ports and keys are transport data and stay on the +// coordinator; this machine owns only the sequencing. +// +// Happy path: +// Idle -> CheckingInfo -> Launching -> Rtsp(Options..Play) -> +// ControlConnecting -> Streaming +// Every failure lands in Failed(reason) with teardown effects; StopRequested +// tears down from any phase and returns to Idle. + +#pragma once + +#include +#include +#include +#include + +namespace dish::moonlight { + +enum class SessionPhase : std::uint8_t { + Idle, + CheckingInfo, + Launching, + Rtsp, + ControlConnecting, + Streaming, + Failed, +}; + +enum class RtspStep : std::uint8_t { + Options, + Describe, + SetupAudio, + SetupVideo, + SetupControl, + Announce, + Play, +}; + +enum class SessionFailure : std::uint8_t { + Unreachable, // serverinfo never answered + NotPaired, // host answered but this client is not paired + TrustLost, // the host answered unpaired, and we remember pairing it + HostReplaced, // the host answered with a uniqueid we do not remember + LaunchRejected, // launch/resume did not return a session + AppAlreadyRunning, // the host holds an app and would not hand it over + ResumeFailed, // resume was offered, then would not hand the app back + RtspRejected, // an RTSP step failed or the TCP transport dropped + ControlLost, // ENet connect failed before the link was ever up + Dropped, // the LIVE link died; the host will usually resume it + HostEnded, // the host sent TERMINATION +}; + +struct SessionState { + SessionPhase phase = SessionPhase::Idle; + RtspStep rtspStep = RtspStep::Options; + // Set while Launching/later: the launch was a /resume of a running app. + bool resuming = false; + std::optional failure; // set iff phase == Failed + + bool operator==(const SessionState& o) const { + return phase == o.phase && rtspStep == o.rtspStep && resuming == o.resuming && + failure == o.failure; + } + bool operator!=(const SessionState& o) const { return !(*this == o); } +}; + +// ── Events ──────────────────────────────────────────────────────────────────── + +namespace moon_event { + +struct StartRequested { + bool operator==(const StartRequested&) const { return true; } +}; + +// GET /serverinfo answered. `paired` is PairStatus for THIS client; +// `currentGame` non-zero means an app is already running, so the launch phase +// resumes instead. `remembered` is "a server certificate is stored for this +// host", which is what separates a host we never paired with from one that has +// forgotten us; `identityChanged` is a uniqueid that is not the one we +// remember, so the stored pairing anchors nothing any more. +struct ServerInfoOk { + bool paired = false; + int currentGame = 0; + bool remembered = false; + bool identityChanged = false; + bool operator==(const ServerInfoOk& o) const { + return paired == o.paired && currentGame == o.currentGame && remembered == o.remembered && + identityChanged == o.identityChanged; + } +}; + +struct ServerInfoFailed { + bool operator==(const ServerInfoFailed&) const { return true; } +}; + +// /launch or /resume produced an RTSP endpoint. +struct LaunchOk { + bool operator==(const LaunchOk&) const { return true; } +}; + +struct LaunchFailed { + bool operator==(const LaunchFailed&) const { return true; } +}; + +// The host refused in the BODY: HTTP 200 carrying status_code="400" and "An +// app is already running on this host". `resumable` is its flag, so a +// launch that may be taken over promotes to /resume instead of failing. +struct LaunchBusy { + bool resumable = false; + bool operator==(const LaunchBusy& o) const { return resumable == o.resumable; } +}; + +// The RTSP TCP transport connected; the machine responds by sending OPTIONS. +struct RtspReady { + bool operator==(const RtspReady&) const { return true; } +}; + +// The current RTSP step got a 200. +struct RtspStepOk { + bool operator==(const RtspStepOk&) const { return true; } +}; + +// A non-200, a parse failure, or the TCP transport dropped mid-handshake. +struct RtspFailed { + bool operator==(const RtspFailed&) const { return true; } +}; + +struct ControlConnected { + bool operator==(const ControlConnected&) const { return true; } +}; + +// ENet connect timed out or the established link died. +struct ControlLost { + bool operator==(const ControlLost&) const { return true; } +}; + +// The host sent an encrypted TERMINATION message. +struct HostTerminated { + bool operator==(const HostTerminated&) const { return true; } +}; + +// User-driven teardown, from any phase. +struct StopRequested { + bool operator==(const StopRequested&) const { return true; } +}; + +} // namespace moon_event + +using SessionEvent = + std::variant; + +// ── Effects (data; the coordinator executes them) ──────────────────────────── + +enum class SessionEffect : std::uint8_t { + FetchServerInfo, + SendLaunch, // /launch, or /resume when state.resuming + OpenRtsp, // dial the RTSP TCP endpoint from the launch response + SendRtspOptions, + SendRtspDescribe, + SendRtspSetupAudio, + SendRtspSetupVideo, + SendRtspSetupControl, + SendRtspAnnounce, + SendRtspPlay, + ConnectControl, // ENet connect with the SETUP-provided port + connect data + StartStreaming, // arrivals, RTP hole-punch pings, periodic control ping + SendTermination, // graceful TERMINATION before the disconnect + Teardown, // close ENet, RTSP and RTP sockets + NotifyFailure, // surface state.failure to the UI +}; + +// A session is started only from a resting phase. THE SESSION IS PER HOST AND +// REFERENCE COUNTED: a second binding on a host that is already checking, +// launching or live JOINS it and sends only its own CONTROLLER_ARRIVAL, and +// must never launch a second one beside it. +inline bool sessionNeedsStart(SessionPhase phase) { + return phase == SessionPhase::Idle || phase == SessionPhase::Failed; +} + +// Whether a teardown owes the host a /cancel. A launch that never went live +// left the host holding an app on our behalf, and leaving it there gets every +// later attempt refused by our own leftovers. One that DID go live is left +// alone while somebody is still riding it, because closing a running game out +// from under them is worse than the tidying is worth; once the LAST controller +// unbinds there is nobody left to be rude to and the app is handed back. +inline bool shouldHandBackApp(bool launched, bool wentLive, bool lastControllerLeft) { + return launched && (!wentLive || lastControllerLeft); +} + +struct Reduction { + // nullopt = the event does not apply in this phase; state is unchanged. + std::optional next; + std::vector effects; +}; + +namespace detail { + +inline SessionEffect sendEffectFor(RtspStep step) { + switch (step) { + case RtspStep::Options: + return SessionEffect::SendRtspOptions; + case RtspStep::Describe: + return SessionEffect::SendRtspDescribe; + case RtspStep::SetupAudio: + return SessionEffect::SendRtspSetupAudio; + case RtspStep::SetupVideo: + return SessionEffect::SendRtspSetupVideo; + case RtspStep::SetupControl: + return SessionEffect::SendRtspSetupControl; + case RtspStep::Announce: + return SessionEffect::SendRtspAnnounce; + case RtspStep::Play: + default: + return SessionEffect::SendRtspPlay; + } +} + +inline Reduction fail(SessionState state, SessionFailure reason) { + state.phase = SessionPhase::Failed; + state.failure = reason; + return {state, {SessionEffect::Teardown, SessionEffect::NotifyFailure}}; +} + +} // namespace detail + +// Pure and total: every (phase x event) pair is defined; combinations that do +// not apply return {nullopt, {}} so a stray late completion can never corrupt +// the lifecycle. +inline Reduction reduce(const SessionState& state, const SessionEvent& event) { + using namespace moon_event; + + // Stop wins from every phase. + if (std::holds_alternative(event)) { + SessionState next; // back to a fresh Idle + std::vector effects; + if (state.phase == SessionPhase::Streaming || + state.phase == SessionPhase::ControlConnecting) { + effects.push_back(SessionEffect::SendTermination); + } + if (state.phase != SessionPhase::Idle) { effects.push_back(SessionEffect::Teardown); } + return {next, effects}; + } + + switch (state.phase) { + case SessionPhase::Idle: + case SessionPhase::Failed: { + if (std::holds_alternative(event)) { + SessionState next; + next.phase = SessionPhase::CheckingInfo; + return {next, {SessionEffect::FetchServerInfo}}; + } + return {std::nullopt, {}}; + } + + case SessionPhase::CheckingInfo: { + if (const auto* info = std::get_if(&event)) { + // A host that came back with a different identity is not the host + // the stored certificate anchors, so it is named before pairing is + // judged at all: re-pairing is the only way back either way, and + // "no longer recognises this device" would be the wrong reason. + if (info->identityChanged) { return detail::fail(state, SessionFailure::HostReplaced); } + if (!info->paired) { + return detail::fail(state, info->remembered ? SessionFailure::TrustLost + : SessionFailure::NotPaired); + } + SessionState next = state; + next.phase = SessionPhase::Launching; + next.resuming = info->currentGame != 0 && info->currentGame != -1; + return {next, {SessionEffect::SendLaunch}}; + } + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::Unreachable); + } + return {std::nullopt, {}}; + } + + case SessionPhase::Launching: { + if (std::holds_alternative(event)) { + SessionState next = state; + next.phase = SessionPhase::Rtsp; + next.rtspStep = RtspStep::Options; + return {next, {SessionEffect::OpenRtsp}}; + } + if (const auto* busy = std::get_if(&event)) { + if (busy->resumable && !state.resuming) { + SessionState next = state; + next.resuming = true; + return {next, {SessionEffect::SendLaunch}}; + } + return detail::fail(state, SessionFailure::AppAlreadyRunning); + } + if (std::holds_alternative(event)) { + // A /resume that fails is not a refused /launch: the host HAS the + // session and would not hand it back, which the user fixes by + // closing the app rather than by trying again. + return detail::fail(state, state.resuming ? SessionFailure::ResumeFailed + : SessionFailure::LaunchRejected); + } + return {std::nullopt, {}}; + } + + case SessionPhase::Rtsp: { + if (std::holds_alternative(event)) { + if (state.rtspStep != RtspStep::Options) { return {std::nullopt, {}}; } + return {state, {SessionEffect::SendRtspOptions}}; + } + if (std::holds_alternative(event)) { + if (state.rtspStep == RtspStep::Play) { + SessionState next = state; + next.phase = SessionPhase::ControlConnecting; + return {next, {SessionEffect::ConnectControl}}; + } + SessionState next = state; + next.rtspStep = static_cast(static_cast(state.rtspStep) + 1); + return {next, {detail::sendEffectFor(next.rtspStep)}}; + } + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::RtspRejected); + } + return {std::nullopt, {}}; + } + + case SessionPhase::ControlConnecting: { + if (std::holds_alternative(event)) { + SessionState next = state; + next.phase = SessionPhase::Streaming; + return {next, {SessionEffect::StartStreaming}}; + } + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::ControlLost); + } + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::HostEnded); + } + return {std::nullopt, {}}; + } + + case SessionPhase::Streaming: + default: { + // A link that dies after going live is a DROP, not a setup failure: the + // host keeps the app and will usually let us resume it, so the two must + // not be merged. + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::Dropped); + } + if (std::holds_alternative(event)) { + return detail::fail(state, SessionFailure::HostEnded); + } + return {std::nullopt, {}}; + } + } +} + +} // namespace dish::moonlight diff --git a/src/core/moonlight/MoonlightSessionUi.h b/src/core/moonlight/MoonlightSessionUi.h new file mode 100644 index 0000000..9d35e20 --- /dev/null +++ b/src/core/moonlight/MoonlightSessionUi.h @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The render contract for the Moonlight section of the binding flow: one pure, +// total function from what is known about a host to exactly one of twenty-one +// states, plus the lowercase tokens QML localizes. The C++ never vends a +// sentence, the same rule the capability solver and the link vocabulary follow. +// +// PAIRING IS NOT A CONNECTION. Moonlight has no bidirectional liveness: pairing +// is one-time trust, checkable only client-initiated (/serverinfo PairStatus, or +// a mutual-TLS handshake that succeeds, which is itself proof). A host never +// notifies the client, and a host-side unpair is discovered on the next call. +// So trust is REMEMBERED and VERIFIED LAZILY — on entering a screen and before +// starting a session, never polled — and a Moonlight host never draws a live +// connection light. +// +// Two protocol facts this ordering depends on: +// * `currentgame` and `state` describe OUR session, not the host's. A plain +// probe always reports free, and a session another device holds is +// discovered only by attempting /launch. That is why NewSession says "new +// session" and never "the host is idle". +// * /cancel answers 200 whether or not anything was running, so a successful +// cancel proves nothing and the caller must re-probe. + +#pragma once + +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightSessionMachine.h" + +#include +#include + +namespace dish::moonlight { + +// Declaration order is the evaluation order: the first state whose trigger +// holds is the one that renders. +enum class SessionUiState : std::uint8_t { + Checking, // probe in flight, nothing cached + NotPaired, // answered, PairStatus 0, no stored server certificate + PairingPin, // a pairing attempt is live and the PIN is on screen + PairingRefused, // the pairing attempt finished not-ok + Unreachable, // never answered, and nothing remembered + Remembered, // never answered, but the pairing is remembered + TrustLost, // answered unpaired with a certificate stored, or a 401 + HostReplaced, // the uniqueid differs from the remembered one + AppsLoading, // paired, no session of ours, /applist in flight + NewSession, // paired, no session of ours, the list is readable + NoApps, // the list came back empty + AppsFailed, // /applist failed while paired + Joining, // this device already holds a session on this host + HostFull, // four controllers already ride this host + BusyOther, // refused: an app is running for someone else, no resume + ResumeFailed, // resume was offered, then would not hand the session back + Refused, // refused for a reason of the host's own + SetupFailed, // the app started but the stream never came up + Live, // this binding is on a connected control stream + Dropped, // was live, the link closed without a host termination + EndedByHost, // the host terminated, or the app closed +}; + +// What the surfaces know about one host at render time. Every field is +// something the client observed; nothing here is inferred. +struct SessionUiInputs { + bool probeInFlight = false; + // The host has been asked at least once. False is "we have not asked yet", + // which is a spinner and not a verdict: nothing may report a host silent + // before anybody said a word to it. + bool probeAttempted = false; + // A /serverinfo answer from THIS visit. False, with a probe attempted and + // none in flight, is the honest "we asked and nothing came back". + bool probeAnswered = false; + // A server certificate is stored, so the pairing is remembered. + bool remembered = false; + // Verified this visit: PairStatus 1, or a mutual-TLS call that succeeded. + bool paired = false; + bool identityChanged = false; + bool trustRejected = false; + + bool pairingActive = false; + bool pairingRefused = false; + + bool appsInFlight = false; + bool appsRead = false; + bool appsFailed = false; + int appCount = 0; + + // The host carries a session of ours, and whether THIS binding is in it. + bool sessionLive = false; + bool bindingLive = false; + // Controllers already riding this host, this binding excluded. + int otherControllers = 0; + + std::optional failure; +}; + +namespace detail { + +// Which of the two "you are not paired" states an input is in. TrustLost +// requires something to have been LOST, which means our certificate is still on +// file: a host we never paired with refuses exactly the way a host that dropped +// us does, and telling a first-time user that a pairing they never made has +// been removed is simply false. NotPaired is the truth and carries the same +// recovery, so the split is on what we hold and on nothing else. +inline SessionUiState unpairedState(bool remembered) { + return remembered ? SessionUiState::TrustLost : SessionUiState::NotPaired; +} + +inline SessionUiState failureState(SessionFailure failure, bool remembered) { + switch (failure) { + case SessionFailure::Unreachable: + return remembered ? SessionUiState::Remembered : SessionUiState::Unreachable; + case SessionFailure::NotPaired: + return unpairedState(remembered); + case SessionFailure::TrustLost: + return SessionUiState::TrustLost; + case SessionFailure::HostReplaced: + return SessionUiState::HostReplaced; + case SessionFailure::AppAlreadyRunning: + return SessionUiState::BusyOther; + case SessionFailure::ResumeFailed: + return SessionUiState::ResumeFailed; + case SessionFailure::LaunchRejected: + return SessionUiState::Refused; + case SessionFailure::RtspRejected: + case SessionFailure::ControlLost: + return SessionUiState::SetupFailed; + case SessionFailure::Dropped: + return SessionUiState::Dropped; + case SessionFailure::HostEnded: + default: + return SessionUiState::EndedByHost; + } +} + +// TRUST IS MUTUAL AND THIS CLIENT HOLDS ONE HALF OF IT. A host reports +// PairStatus against the uniqueid on the request, and this install's uniqueid +// outlives a Forget, so a box that still has us on file answers 1 to a client +// that threw its half away. That is the host's word only: every paired-only +// call is mutual TLS pinned against the certificate the pairing handshake +// verified, and with no certificate there is nothing to pin, no app list and no +// session. A host we cannot open a channel to is NOT PAIRED however warmly it +// answers, and the way back in is the same PIN a stranger needs. +// +// A REJECTION SETTLES IT whatever else is known. A 401, or a session the host +// refused as unknown, is the host saying so in as many words, and it outranks +// the "nobody has answered yet" fallback: a host that just refused us must +// never render Remembered, which promises a session it is not going to give. +// +// This is a FUNCTION and not two copies of an expression because the host row +// and the session section answering the same question differently is exactly +// what stranded the user: the row read the host's word alone, said Paired, and +// hid the Pair button, while the section below it could not open a channel. +// Two spellings of one rule drifted once and must not be able to again. +inline bool notPaired(const SessionUiInputs& in) { + return in.trustRejected || (in.probeAnswered && !(in.paired && in.remembered)); +} + +} // namespace detail + +// Pure and total. Evaluated top to bottom in the declaration order above; the +// two triggers that would otherwise overlap are made precise rather than +// reordered: Joining requires room for this controller, so a host already +// carrying four pads reads HostFull and not an invitation to join it. +inline SessionUiState sessionUiState(const SessionUiInputs& in) { + if (in.pairingActive) { return SessionUiState::PairingPin; } + if (in.pairingRefused) { return SessionUiState::PairingRefused; } + if (in.identityChanged) { return SessionUiState::HostReplaced; } + + // The full host is judged FIRST, before anything the network could change, + // because it is the one state derived entirely from local bookkeeping and + // the one state that blocks Apply. Rendering a spinner or an unreachable + // host over it would enable an Apply the bind is going to refuse. + const bool hasRoom = in.otherControllers < kMaxPads; + if (!hasRoom) { return SessionUiState::HostFull; } + + // Judged before the fallback below, because a rejection IS an answer and + // the two arms are disjoint on probeAnswered anyway. See detail::notPaired. + if (detail::notPaired(in)) { return detail::unpairedState(in.remembered); } + + if (!in.probeAnswered && !in.bindingLive && !in.sessionLive) { + if (in.probeInFlight || !in.probeAttempted) { return SessionUiState::Checking; } + if (in.failure) { return detail::failureState(*in.failure, in.remembered); } + return in.remembered ? SessionUiState::Remembered : SessionUiState::Unreachable; + } + + if (in.bindingLive) { return SessionUiState::Live; } + if (in.failure) { return detail::failureState(*in.failure, in.remembered); } + + if (in.sessionLive) { return SessionUiState::Joining; } + + if (in.appsInFlight) { return SessionUiState::AppsLoading; } + if (in.appsFailed) { return SessionUiState::AppsFailed; } + if (in.appsRead && in.appCount == 0) { return SessionUiState::NoApps; } + return SessionUiState::NewSession; +} + +// Apply is never blocked by Moonlight host state: a binding is a durable intent +// and the session is attempted when the controller is used, not when the +// binding is saved. The one exception is a host already carrying four pads, +// which is a hard protocol limit and says so. +inline bool sessionUiBlocksApply(SessionUiState state) { return state == SessionUiState::HostFull; } + +inline const char* sessionUiToken(SessionUiState state) { + switch (state) { + case SessionUiState::Checking: + return "checking"; + case SessionUiState::NotPaired: + return "notPaired"; + case SessionUiState::PairingPin: + return "pairingPin"; + case SessionUiState::PairingRefused: + return "pairingRefused"; + case SessionUiState::Unreachable: + return "unreachable"; + case SessionUiState::Remembered: + return "remembered"; + case SessionUiState::TrustLost: + return "trustLost"; + case SessionUiState::HostReplaced: + return "hostReplaced"; + case SessionUiState::AppsLoading: + return "appsLoading"; + case SessionUiState::NewSession: + return "newSession"; + case SessionUiState::NoApps: + return "noApps"; + case SessionUiState::AppsFailed: + return "appsFailed"; + case SessionUiState::Joining: + return "joining"; + case SessionUiState::HostFull: + return "hostFull"; + case SessionUiState::BusyOther: + return "busyOther"; + case SessionUiState::ResumeFailed: + return "resumeFailed"; + case SessionUiState::Refused: + return "refused"; + case SessionUiState::SetupFailed: + return "setupFailed"; + case SessionUiState::Live: + return "live"; + case SessionUiState::Dropped: + return "dropped"; + case SessionUiState::EndedByHost: + default: + return "endedByHost"; + } +} + +// ── Host-screen vocabulary ─────────────────────────────────────────────────── +// Three words, never a liveness light: what the client remembers, and whether +// this visit confirmed it. +enum class HostTrust : std::uint8_t { Paired, Remembered, NotPaired }; + +inline HostTrust hostTrust(const SessionUiInputs& in) { + if (in.identityChanged) { return HostTrust::NotPaired; } + // THE SAME FUNCTION the session section reads, not a second spelling of it. + // This row has no TrustLost of its own: both unpaired states render here as + // the one word that offers the way back. + if (detail::notPaired(in)) { return HostTrust::NotPaired; } + // Both halves present, which is the only thing that earns the chip that + // hides the Pair button. + if (in.paired && in.remembered) { return HostTrust::Paired; } + // Nobody answered this visit, so the memory is all there is. + return in.remembered ? HostTrust::Remembered : HostTrust::NotPaired; +} + +inline const char* hostTrustToken(HostTrust trust) { + switch (trust) { + case HostTrust::Paired: + return "paired"; + case HostTrust::Remembered: + return "remembered"; + case HostTrust::NotPaired: + default: + return "notPaired"; + } +} + +// The phase the row's own chip reads, converged with dish-windows so the two +// Qt clients speak one vocabulary. It distinguishes the states the section-4 +// list needs, which a four-token idle/linking/live/failed ladder cannot. +enum class HostPhase : std::uint8_t { + Idle, + Pairing, + Paired, + Launching, + Connecting, + Streaming, + Faltering, + Closed, + Failed, +}; + +inline const char* hostPhaseToken(HostPhase phase) { + switch (phase) { + case HostPhase::Pairing: + return "pairing"; + case HostPhase::Paired: + return "paired"; + case HostPhase::Launching: + return "launching"; + case HostPhase::Connecting: + return "connecting"; + case HostPhase::Streaming: + return "streaming"; + case HostPhase::Faltering: + return "faltering"; + case HostPhase::Closed: + return "closed"; + case HostPhase::Failed: + return "failed"; + case HostPhase::Idle: + default: + return "idle"; + } +} + +// The session reducer's phase as a host-row phase. Pairing and the remembered +// resting states are the manager's to add; this is the live-session half. +inline HostPhase hostPhaseFor(const SessionState& session, bool paired, bool everStarted) { + switch (session.phase) { + case SessionPhase::CheckingInfo: + case SessionPhase::Launching: + return HostPhase::Launching; + case SessionPhase::Rtsp: + case SessionPhase::ControlConnecting: + return HostPhase::Connecting; + case SessionPhase::Streaming: + return HostPhase::Streaming; + case SessionPhase::Failed: + return session.failure == SessionFailure::Dropped ? HostPhase::Faltering + : HostPhase::Failed; + case SessionPhase::Idle: + default: + break; + } + if (everStarted) { return HostPhase::Closed; } + return paired ? HostPhase::Paired : HostPhase::Idle; +} + +} // namespace dish::moonlight diff --git a/src/core/moonlight/MoonlightWire.cpp b/src/core/moonlight/MoonlightWire.cpp new file mode 100644 index 0000000..b21aee9 --- /dev/null +++ b/src/core/moonlight/MoonlightWire.cpp @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightWire.h" + +#include "Util/Endian.h" +#include "core/moonlight/MoonlightProtocol.h" + +#include + +namespace dish::moonwire { +namespace { + +// Little-endian writers. The control stream is little-endian except where a +// field is explicitly called out as big-endian (the INPUT_DATA size prefix and +// the mouse deltas). +void putU16Le(std::uint8_t* dst, std::uint16_t v) noexcept { + dst[0] = static_cast(v & 0xFFU); + dst[1] = static_cast((v >> 8) & 0xFFU); +} + +void putU32Le(std::uint8_t* dst, std::uint32_t v) noexcept { + dst[0] = static_cast(v & 0xFFU); + dst[1] = static_cast((v >> 8) & 0xFFU); + dst[2] = static_cast((v >> 16) & 0xFFU); + dst[3] = static_cast((v >> 24) & 0xFFU); +} + +void putI16Le(std::uint8_t* dst, std::int16_t v) noexcept { + putU16Le(dst, static_cast(v)); +} + +void putF32Le(std::uint8_t* dst, float v) noexcept { + static_assert(sizeof(float) == 4, "netfloat assumes 32-bit IEEE-754 floats"); + std::uint32_t bits = 0; + std::memcpy(&bits, &v, sizeof(bits)); + putU32Le(dst, bits); +} + +std::uint16_t readU16Le(const std::uint8_t* src) noexcept { + return static_cast(static_cast(src[0]) | + (static_cast(src[1]) << 8)); +} + +// Writes the [type][len] control header plus the INPUT_DATA wrapper: the +// data size (BIG-endian, covering input type + body) and the input type +// (little-endian). Returns the offset the body starts at, which is 12. +std::size_t putInputHeader(std::uint8_t* out, std::uint32_t inputType, std::size_t bodyLen) { + const std::size_t dataSize = 4 + bodyLen; // input type + body + putU16Le(out, moonproto::kPktInputData); + putU16Le(out + 2, static_cast(4 + dataSize)); // size prefix + data + util::putU32Be(out + 4, static_cast(dataSize)); + putU32Le(out + 8, inputType); + return 12; +} + +// CONTROLLER_MULTI's fixed filler words, observed on the wire and named after +// Wolf's CONTROLLER_MULTI_PACKET fields. They are remnants of the legacy +// multi-controller framing and constant in the modern format. +constexpr std::uint16_t kMultiHeaderB = 0x001A; +constexpr std::uint16_t kMultiMidB = 0x0014; +constexpr std::uint16_t kMultiTailA = 0x009C; +constexpr std::uint16_t kMultiTailB = 0x0055; + +} // namespace + +std::size_t encodeControllerMulti(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint16_t activeMask, std::uint32_t buttonFlags, + std::uint8_t leftTrigger, std::uint8_t rightTrigger, + std::int16_t leftX, std::int16_t leftY, std::int16_t rightX, + std::int16_t rightY) { + std::size_t off = putInputHeader(out, moonproto::kInputControllerMulti, 26); + putU16Le(out + off, kMultiHeaderB); + putU16Le(out + off + 2, static_cast(controllerNumber)); + putU16Le(out + off + 4, activeMask); + putU16Le(out + off + 6, kMultiMidB); + putU16Le(out + off + 8, static_cast(buttonFlags & 0xFFFFU)); + out[off + 10] = leftTrigger; + out[off + 11] = rightTrigger; + putI16Le(out + off + 12, leftX); + putI16Le(out + off + 14, leftY); + putI16Le(out + off + 16, rightX); + putI16Le(out + off + 18, rightY); + putU16Le(out + off + 20, kMultiTailA); + putU16Le(out + off + 22, static_cast((buttonFlags >> 16) & 0xFFFFU)); + putU16Le(out + off + 24, kMultiTailB); + return kControllerMultiSize; +} + +std::size_t encodeControllerArrival(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t controllerType, std::uint8_t capabilities, + std::uint32_t supportedButtons) { + std::size_t off = putInputHeader(out, moonproto::kInputControllerArrival, 8); + out[off] = controllerNumber; + out[off + 1] = controllerType; + out[off + 2] = capabilities; + out[off + 3] = 0; + putU32Le(out + off + 4, supportedButtons); + return kControllerArrivalSize; +} + +std::size_t encodeControllerMotion(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t motionType, float x, float y, float z) { + std::size_t off = putInputHeader(out, moonproto::kInputControllerMotion, 16); + out[off] = controllerNumber; + out[off + 1] = motionType; + out[off + 2] = 0; + out[off + 3] = 0; + putF32Le(out + off + 4, x); + putF32Le(out + off + 8, y); + putF32Le(out + off + 12, z); + return kControllerMotionSize; +} + +std::size_t encodeControllerBattery(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t state, std::uint8_t percentage) { + std::size_t off = putInputHeader(out, moonproto::kInputControllerBattery, 4); + out[off] = controllerNumber; + out[off + 1] = state; + out[off + 2] = percentage; + out[off + 3] = 0; + return kControllerBatterySize; +} + +std::size_t encodeMouseMoveRel(std::uint8_t* out, std::int16_t deltaX, std::int16_t deltaY) { + std::size_t off = putInputHeader(out, moonproto::kInputMouseMoveRel, 4); + util::putU16Be(out + off, static_cast(deltaX)); + util::putU16Be(out + off + 2, static_cast(deltaY)); + return kMouseMoveRelSize; +} + +std::size_t encodePeriodicPing(std::uint8_t* out) { + // Byte-for-byte the keep-alive a real client sends (Wolf testControl.cpp's + // captured session): type 0x0200, len 8, then the fixed payload. + static constexpr std::uint8_t kPing[kPeriodicPingSize] = {0x00, 0x02, 0x08, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + std::memcpy(out, kPing, sizeof(kPing)); + return kPeriodicPingSize; +} + +std::size_t encodeTermination(std::uint8_t* out) { + putU16Le(out, moonproto::kPktTermination); + putU16Le(out + 2, 4); + // The reason rides big-endian, per Wolf's TERMINATE_REASON_GRACEFULL. + util::putU32Be(out + 4, moonproto::kTerminateReasonGraceful); + return kTerminationSize; +} + +std::size_t encodeRtpPing(std::uint8_t* out, const char* payload, std::size_t payloadLen, + std::uint32_t sequence) { + if (payload == nullptr || payloadLen == 0) { + // Legacy 4-byte ping, for hosts that never advertised a payload. + out[0] = 'P'; + out[1] = 'I'; + out[2] = 'N'; + out[3] = 'G'; + return kRtpPingLegacySize; + } + // SS_PING: the payload field is a fixed 16 bytes on the wire, so a short + // header value is zero-padded and an overlong one truncated. + constexpr std::size_t kPayloadField = 16; + std::memset(out, 0, kPayloadField); + std::memcpy(out, payload, payloadLen < kPayloadField ? payloadLen : kPayloadField); + putU32Le(out + kPayloadField, sequence); + return kRtpPingSize; +} + +std::optional decodeHostEvent(const std::uint8_t* data, std::size_t len) { + if (data == nullptr || len < 4) { return std::nullopt; } + const std::uint16_t type = readU16Le(data); + const std::uint8_t* body = data + 4; + const std::size_t bodyLen = len - 4; + + HostEvent ev; + switch (type) { + case moonproto::kPktRumbleData: { + // [unused u32][ctrl u16][low u16][high u16] + if (bodyLen < 10) { return std::nullopt; } + ev.type = HostEventType::Rumble; + ev.controllerNumber = readU16Le(body + 4); + ev.rumbleLow = readU16Le(body + 6); + ev.rumbleHigh = readU16Le(body + 8); + return ev; + } + case moonproto::kPktRumbleTriggers: { + // [ctrl u16][left u16][right u16] + if (bodyLen < 6) { return std::nullopt; } + ev.type = HostEventType::RumbleTriggers; + ev.controllerNumber = readU16Le(body); + ev.rumbleLow = readU16Le(body + 2); + ev.rumbleHigh = readU16Le(body + 4); + return ev; + } + case moonproto::kPktMotionEvent: { + // [ctrl u16][rate u16][type u8] + if (bodyLen < 5) { return std::nullopt; } + ev.type = HostEventType::MotionRequest; + ev.controllerNumber = readU16Le(body); + ev.motionRateHz = readU16Le(body + 2); + ev.motionType = body[4]; + return ev; + } + case moonproto::kPktRgbLed: { + // [ctrl u16][r][g][b] + if (bodyLen < 5) { return std::nullopt; } + ev.type = HostEventType::RgbLed; + ev.controllerNumber = readU16Le(body); + ev.red = body[2]; + ev.green = body[3]; + ev.blue = body[4]; + return ev; + } + case moonproto::kPktTermination: { + ev.type = HostEventType::Termination; + return ev; + } + default: + ev.type = HostEventType::Unknown; + return ev; + } +} + +} // namespace dish::moonwire diff --git a/src/core/moonlight/MoonlightWire.h b/src/core/moonlight/MoonlightWire.h new file mode 100644 index 0000000..51a85ee --- /dev/null +++ b/src/core/moonlight/MoonlightWire.h @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Byte-exact encoders for the Moonlight control-stream plaintexts this client +// sends, and decoders for the host->client events it handles. Layouts mirror +// Wolf's moonlight/control.hpp packed structs and the input-data protocol page; +// the unit tests pin them against the documented network fixtures. +// +// Encoders write at fixed offsets into a caller-owned buffer and never +// allocate, so the CONTROLLER_MULTI path can run on the input thread with a +// preallocated scratch buffer. Every encoder returns the bytes written. + +#pragma once + +#include +#include +#include + +namespace dish::moonwire { + +// Total plaintext sizes ([type u16][len u16] header included). +inline constexpr std::size_t kControllerMultiSize = 38; +inline constexpr std::size_t kControllerArrivalSize = 20; +inline constexpr std::size_t kControllerMotionSize = 28; +inline constexpr std::size_t kControllerBatterySize = 16; +inline constexpr std::size_t kMouseMoveRelSize = 16; +inline constexpr std::size_t kPeriodicPingSize = 12; +inline constexpr std::size_t kTerminationSize = 8; + +// Large enough for any plaintext this client encodes. +inline constexpr std::size_t kMaxPlaintextSize = 64; + +// RTP hole-punch ping datagrams (plaintext UDP, not control-stream sealed). +inline constexpr std::size_t kRtpPingLegacySize = 4; // "PING" +inline constexpr std::size_t kRtpPingSize = 20; // SS_PING{payload[16], seq} + +// The hot-path report. `buttonFlags` is the effective 32-bit word; the encoder +// splits it into the legacy 16-bit field plus the buttonFlags2 extension. +// `activeMask` has a bit set per attached controller; dropping a bit tells the +// host that pad was unplugged. Sticks are Moonlight's frame: +Y is up. +std::size_t encodeControllerMulti(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint16_t activeMask, std::uint32_t buttonFlags, + std::uint8_t leftTrigger, std::uint8_t rightTrigger, + std::int16_t leftX, std::int16_t leftY, std::int16_t rightX, + std::int16_t rightY); + +// Announces a pad with its emulated type (moonproto::kControllerType*), its +// capability bitfield (moonproto::kCap*) and the buttons it can report. +std::size_t encodeControllerArrival(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t controllerType, std::uint8_t capabilities, + std::uint32_t supportedButtons); + +// Motion sample. `motionType` is moonproto::kMotionAcceleration/Gyroscope; the +// components are IEEE-754 floats stored little-endian ("netfloat"). Units: +// m/s^2 for accel, deg/s for gyro. +std::size_t encodeControllerMotion(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t motionType, float x, float y, float z); + +// Battery report. `state` is a moonproto::kBattery* constant, `percentage` +// 0..100 or moonproto::kBatteryPercentageUnknown. +std::size_t encodeControllerBattery(std::uint8_t* out, std::uint8_t controllerNumber, + std::uint8_t state, std::uint8_t percentage); + +// Relative mouse motion; the deltas are BIG-endian on the wire, unlike +// everything else in the payload. +std::size_t encodeMouseMoveRel(std::uint8_t* out, std::int16_t deltaX, std::int16_t deltaY); + +// Keep-alive, byte-for-byte the plaintext a real client sends. +std::size_t encodePeriodicPing(std::uint8_t* out); + +// Graceful-quit notice with the standard reason code. +std::size_t encodeTermination(std::uint8_t* out); + +// The RTP hole-punch ping datagram for the video/audio ports. When the host +// supplied an X-SS-Ping-Payload in RTSP SETUP (`payloadLen` > 0), the ping is +// the 20-byte SS_PING the host matches sessions by: the payload zero-padded or +// truncated to its fixed 16 bytes, then the sequence number little-endian. +// Without one it is the 4-byte legacy "PING". `out` needs kRtpPingSize bytes. +std::size_t encodeRtpPing(std::uint8_t* out, const char* payload, std::size_t payloadLen, + std::uint32_t sequence); + +// ── Host -> client events ──────────────────────────────────────────────────── + +enum class HostEventType : std::uint8_t { + Unknown, // a type this client does not handle: ignore gracefully + Rumble, + RumbleTriggers, + MotionRequest, + RgbLed, + Termination, +}; + +struct HostEvent { + HostEventType type = HostEventType::Unknown; + + std::uint16_t controllerNumber = 0; + + // Rumble: body motor magnitudes; RumbleTriggers reuses low/high as + // left/right trigger magnitudes. + std::uint16_t rumbleLow = 0; + std::uint16_t rumbleHigh = 0; + + // MotionRequest: the host asks the client to START sending motion of + // `motionType` at `motionRateHz` (0 stops it). + std::uint16_t motionRateHz = 0; + std::uint8_t motionType = 0; + + // RgbLed. + std::uint8_t red = 0; + std::uint8_t green = 0; + std::uint8_t blue = 0; +}; + +// Decodes one decrypted control plaintext ([type u16 LE][len u16 LE][body]). +// nullopt means malformed (too short for its declared shape); a well-formed +// packet of an unhandled type comes back as HostEventType::Unknown so the +// caller can drop it without treating it as an error. +std::optional decodeHostEvent(const std::uint8_t* data, std::size_t len); + +} // namespace dish::moonwire diff --git a/src/core/moonlight/MoonlightXml.cpp b/src/core/moonlight/MoonlightXml.cpp new file mode 100644 index 0000000..9d9e6ed --- /dev/null +++ b/src/core/moonlight/MoonlightXml.cpp @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "core/moonlight/MoonlightXml.h" + +#include +#include + +namespace dish::moonxml { +namespace { + +std::string decodeEntities(std::string_view raw) { + std::string out; + out.reserve(raw.size()); + std::size_t i = 0; + while (i < raw.size()) { + if (raw[i] != '&') { + out.push_back(raw[i]); + ++i; + continue; + } + const auto tryEntity = [&](std::string_view entity, char replacement) { + if (raw.compare(i, entity.size(), entity) == 0) { + out.push_back(replacement); + i += entity.size(); + return true; + } + return false; + }; + if (tryEntity("&", '&') || tryEntity("<", '<') || tryEntity(">", '>') || + tryEntity(""", '"') || tryEntity("'", '\'')) { + continue; + } + out.push_back(raw[i]); + ++i; + } + return out; +} + +std::optional parseInt(std::string_view text) { + if (text.empty()) { return std::nullopt; } + std::size_t i = 0; + bool negative = false; + if (text[0] == '-') { + negative = true; + i = 1; + if (text.size() == 1) { return std::nullopt; } + } + long value = 0; + for (; i < text.size(); ++i) { + const char c = text[i]; + if (c < '0' || c > '9') { return std::nullopt; } + value = value * 10 + (c - '0'); + if (value > 2147483647L) { return std::nullopt; } + } + return static_cast(negative ? -value : value); +} + +// The raw inner text of the first ..., or nullopt. +std::optional rawTagValue(std::string_view xml, std::string_view tag) { + const std::string open = "<" + std::string(tag); + const std::string close = ""; + std::size_t at = 0; + while ((at = xml.find(open, at)) != std::string_view::npos) { + const std::size_t afterName = at + open.size(); + // Reject partial matches like when looking for . + if (afterName < xml.size() && xml[afterName] != '>' && xml[afterName] != ' ' && + xml[afterName] != '/') { + at = afterName; + continue; + } + const std::size_t gt = xml.find('>', afterName); + if (gt == std::string_view::npos) { return std::nullopt; } + const std::size_t end = xml.find(close, gt + 1); + if (end == std::string_view::npos) { return std::nullopt; } + return xml.substr(gt + 1, end - gt - 1); + } + return std::nullopt; +} + +// A reply that names no status_code is a plain success. +constexpr int kDefaultOk = 200; + +bool containsNoCase(std::string_view haystack, std::string_view needle) { + if (needle.size() > haystack.size()) { return false; } + const auto lower = [](char c) { + return static_cast(std::tolower(static_cast(c))); + }; + for (std::size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + std::size_t j = 0; + while (j < needle.size() && lower(haystack[i + j]) == lower(needle[j])) { ++j; } + if (j == needle.size()) { return true; } + } + return false; +} + +// Every block inside , in document order. +std::vector parseDisplayModes(std::string_view xml) { + std::vector modes; + const auto listed = rawTagValue(xml, "SupportedDisplayMode"); + if (!listed) { return modes; } + std::string_view rest = *listed; + while (true) { + const std::size_t open = rest.find(""); + if (open == std::string_view::npos) { break; } + const std::size_t close = rest.find("", open); + if (close == std::string_view::npos) { break; } + const std::string_view block = rest.substr(open, close - open); + DisplayMode mode; + mode.width = tagInt(block, "Width").value_or(0); + mode.height = tagInt(block, "Height").value_or(0); + mode.refreshRate = tagInt(block, "RefreshRate").value_or(0); + if (mode.width > 0 && mode.height > 0) { modes.push_back(mode); } + rest = rest.substr(close + 1); + } + return modes; +} + +} // namespace + +std::optional tagValue(std::string_view xml, std::string_view tag) { + const auto raw = rawTagValue(xml, tag); + if (!raw) { return std::nullopt; } + return decodeEntities(*raw); +} + +std::optional tagInt(std::string_view xml, std::string_view tag) { + const auto raw = rawTagValue(xml, tag); + if (!raw) { return std::nullopt; } + return parseInt(*raw); +} + +std::optional statusCode(std::string_view xml) { + static constexpr std::string_view kAttr = "status_code=\""; + const std::size_t at = xml.find(kAttr); + if (at == std::string_view::npos) { return std::nullopt; } + const std::size_t start = at + kAttr.size(); + const std::size_t end = xml.find('"', start); + if (end == std::string_view::npos) { return std::nullopt; } + return parseInt(xml.substr(start, end - start)); +} + +std::optional statusMessage(std::string_view xml) { + static constexpr std::string_view kAttr = "status_message=\""; + const std::size_t at = xml.find(kAttr); + if (at == std::string_view::npos) { return std::nullopt; } + const std::size_t start = at + kAttr.size(); + const std::size_t end = xml.find('"', start); + if (end == std::string_view::npos) { return std::nullopt; } + return decodeEntities(xml.substr(start, end - start)); +} + +bool Status::appAlreadyRunning() const { + return !ok() && containsNoCase(message, "already running"); +} + +std::optional parseStatus(std::string_view xml) { + if (xml.find(" preferredDisplayMode(const std::vector& modes) { + std::optional best; + for (const auto& mode : modes) { + if (mode.width <= 0 || mode.height <= 0) { continue; } + if (!best) { + best = mode; + continue; + } + const long area = static_cast(mode.width) * mode.height; + const long bestArea = static_cast(best->width) * best->height; + if (area > bestArea || (area == bestArea && mode.refreshRate > best->refreshRate)) { + best = mode; + } + } + return best; +} + +std::optional parseServerInfo(std::string_view xml) { + if (statusCode(xml).value_or(kDefaultOk) != 200) { return std::nullopt; } + const auto hostname = tagValue(xml, "hostname"); + if (!hostname || hostname->empty()) { return std::nullopt; } + ServerInfo info; + info.hostname = *hostname; + info.uuid = tagValue(xml, "uniqueid").value_or(""); + info.appVersion = tagValue(xml, "appversion").value_or(""); + info.state = tagValue(xml, "state").value_or(""); + info.httpsPort = tagInt(xml, "HttpsPort").value_or(0); + info.externalPort = tagInt(xml, "ExternalPort").value_or(0); + info.pairStatus = tagInt(xml, "PairStatus").value_or(0); + info.currentGame = tagInt(xml, "currentgame").value_or(0); + info.displayModes = parseDisplayModes(xml); + return info; +} + +std::vector parseAppList(std::string_view xml) { + std::vector apps; + if (statusCode(xml).value_or(kDefaultOk) != 200) { return apps; } + std::size_t at = 0; + while (true) { + const std::size_t open = xml.find("", at); + if (open == std::string_view::npos) { break; } + const std::size_t close = xml.find("", open); + if (close == std::string_view::npos) { break; } + const std::string_view block = xml.substr(open, close - open + 6); + const auto title = tagValue(block, "AppTitle"); + const auto id = tagValue(block, "ID"); + if (title && id && !id->empty()) { apps.push_back(AppEntry{*title, *id}); } + at = close + 6; + } + return apps; +} + +std::optional parseLaunch(std::string_view xml) { + const auto status = parseStatus(xml); + if (!status || !status->ok()) { return std::nullopt; } + const auto url = tagValue(xml, "sessionUrl0"); + if (!url || url->empty()) { return std::nullopt; } + + // scheme://host[:port] — the scheme varies by host implementation, so only + // the host and port are read. + std::string_view rest = *url; + const std::size_t schemeEnd = rest.find("://"); + if (schemeEnd != std::string_view::npos) { rest = rest.substr(schemeEnd + 3); } + LaunchResult result; + const std::size_t colon = rest.rfind(':'); + if (colon != std::string_view::npos) { + const auto port = parseInt(rest.substr(colon + 1)); + if (!port || *port <= 0 || *port > 65535) { return std::nullopt; } + result.rtspPort = *port; + result.rtspHost = std::string(rest.substr(0, colon)); + } else { + result.rtspHost = std::string(rest); + } + if (result.rtspHost.empty()) { return std::nullopt; } + result.launched = + tagInt(xml, "gamesession").value_or(0) == 1 || tagInt(xml, "resume").value_or(0) == 1; + return result; +} + +bool pairedFlag(std::string_view xml) { return tagInt(xml, "paired").value_or(0) == 1; } + +} // namespace dish::moonxml diff --git a/src/core/moonlight/MoonlightXml.h b/src/core/moonlight/MoonlightXml.h new file mode 100644 index 0000000..6087890 --- /dev/null +++ b/src/core/moonlight/MoonlightXml.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Parsers for the small, flat XML documents the GameStream HTTP API returns +// (serverinfo, pair phases, applist, launch/resume/cancel). The shapes come +// from Wolf's moonlight.cpp response builders; Sunshine and Apollo emit the +// same documents. A hand-rolled tag scanner keeps core/ Qt-free — the +// documents are machine-generated, non-nested apart from blocks, and +// carry no namespaces, so a full XML parser buys nothing here. + +#pragma once + +#include +#include +#include +#include + +namespace dish::moonxml { + +// First value occurrence, entities decoded. nullopt when absent. +std::optional tagValue(std::string_view xml, std::string_view tag); + +// tagValue parsed as a decimal integer; nullopt when absent or non-numeric. +std::optional tagInt(std::string_view xml, std::string_view tag); + +// The attribute; nullopt when absent. +std::optional statusCode(std::string_view xml); + +// The attribute; nullopt when absent. +std::optional statusMessage(std::string_view xml); + +// The application-level result every reply carries on its root element, +// independent of the HTTP status the transport reported. A host says no in the +// BODY: /launch answers HTTP 200 with status_code="400" and status_message="An +// app is already running on this host", so code that reads only the HTTP status +// treats a refusal as success and fails later, naming the wrong thing. +struct Status { + int code = 200; + std::string message; + // The flag a refusal carries: 1 means /resume would be accepted. + bool resume = false; + + bool ok() const { return code >= 200 && code <= 299; } + // The host holds an app it will not start a second one beside. + bool appAlreadyRunning() const; +}; + +// A reply that names no status_code is read as success, which is what a host +// that answers plainly sends. nullopt only when there is no root element. +std::optional parseStatus(std::string_view xml); + +// One row from /serverinfo. +struct DisplayMode { + int width = 0; + int height = 0; + int refreshRate = 0; +}; + +// GET /serverinfo. +struct ServerInfo { + std::string hostname; + std::string uuid; + std::string appVersion; + std::string state; // e.g. SUNSHINE_SERVER_FREE / _BUSY + int httpsPort = 0; + int externalPort = 0; + // 1 when THIS client (matched by uniqueid/cert) is already paired. + int pairStatus = 0; + // Running app id, 0 or -1 for none; drives launch-vs-resume. + int currentGame = 0; + // Every row, in document order. + std::vector displayModes; + + bool busy() const { return state.find("_SERVER_BUSY") != std::string::npos; } +}; + +// The advertised mode closest to the host's own display: the largest area, and +// the highest refresh rate offered at that size. nullopt when none were +// advertised, so the caller keeps its own default rather than shrinking the +// host's desktop. +std::optional preferredDisplayMode(const std::vector& modes); + +// nullopt when the document has no status_code 200 root or lacks a hostname. +std::optional parseServerInfo(std::string_view xml); + +// GET /applist rows. +struct AppEntry { + std::string title; + std::string id; +}; + +std::vector parseAppList(std::string_view xml); + +// GET /launch and /resume. +struct LaunchResult { + // The verbatim sessionUrl0 host — parroted back as the RTSP target (Wolf + // hands out a per-session fake IP and matches on it), never dialled. + std::string rtspHost; + int rtspPort = 0; + bool launched = false; // gamesession=1 or resume=1 +}; + +std::optional parseLaunch(std::string_view xml); + +// Pair phase responses share {paired, plaincert?, challengeresponse?, +// pairingsecret?}; callers pick the field for their phase via tagValue and +// check paired via this helper. +bool pairedFlag(std::string_view xml); + +} // namespace dish::moonxml diff --git a/src/qml/AppViewModel.cpp b/src/qml/AppViewModel.cpp index 5d117b9..84f9496 100644 --- a/src/qml/AppViewModel.cpp +++ b/src/qml/AppViewModel.cpp @@ -15,6 +15,11 @@ #include "UI/CrashReport.h" #include "core/catalog/BundledCatalog.h" #include "core/input/Deadzones.h" +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightProtocol.h" +#include "core/moonlight/MoonlightSessionUi.h" +#include "repository/MoonlightHostRepository.h" +#include "source/moonlight/MoonlightManager.h" #include "core/reducer/CapabilitySolver.h" #include "core/reducer/CatalogFeatureGate.h" #include "core/reducer/CarriedPads.h" @@ -293,6 +298,27 @@ AppViewModel::AppViewModel(dish::AppModel* model, QObject* parent) [this] { emit discoveredChanged(); }); QObject::connect(model_->wifi(), &net::WifiConnectionManager::scanningChanged, this, [this] { emit scanningChanged(); }); + + // The Moonlight subsystem folds all of its changes (rows, scan state, + // pairing PIN/phase) into one moonlightChanged() the QML binds against. + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::rowsChanged, this, + [this] { emit moonlightChanged(); }); + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::scanningChanged, this, + [this] { emit moonlightChanged(); }); + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::pairingChanged, this, + [this] { emit moonlightChanged(); }); + // No toast on a refusal: the binding flow and the pairing sheet both render + // the refused state with copy that says what to do about it, and a second, + // vaguer sentence over the top of it is noise. + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::pairingFinished, this, + [this](const QString&, bool, const QString&) { emit moonlightChanged(); }); + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::probeFinished, this, + [this](const QString&) { emit moonlightChanged(); }); + QObject::connect(model_->moonlight(), &source::moon::MoonlightManager::appsChanged, this, + [this](const QString& uuid) { + emit moonlightAppsChanged(uuid); + emit moonlightChanged(); + }); QObject::connect(model_->wifi(), &net::WifiConnectionManager::reversePairingChanged, this, [this] { emit reversePairingChanged(); }); QObject::connect(model_, &dish::AppModel::catalogStateChanged, this, @@ -545,7 +571,16 @@ void AppViewModel::bindSlot(const QString& slotId, const QString& connectionId) model_->hub()->bind(slotId, connectionId); } -void AppViewModel::unbindSlot(const QString& slotId) { model_->hub()->unbind(slotId); } +void AppViewModel::unbindSlot(const QString& slotId) { + // One verb for both destination kinds: a slot rides a satellite OR a + // Moonlight host, and every caller (the board, the pad card, Configure + // binding) means the same thing by Unbind. + if (!model_->moonlightBoundHostFor(slotId).isEmpty()) { + model_->unbindMoonlightSlot(slotId); + return; + } + model_->hub()->unbind(slotId); +} namespace { // A synthetic slot's id IS the packed vpKey string, so it parses. An SDL slot's @@ -701,6 +736,172 @@ void AppViewModel::startDiscovery() { model_->wifi()->startDiscovery(); } bool AppViewModel::isScanning() const { return model_->wifi()->isScanning(); } +namespace { + +QString moonlightLinkToken(source::moon::MoonlightLinkState link) { + switch (link) { + case source::moon::MoonlightLinkState::Linking: + return QStringLiteral("linking"); + case source::moon::MoonlightLinkState::Live: + return QStringLiteral("live"); + case source::moon::MoonlightLinkState::Failed: + return QStringLiteral("failed"); + case source::moon::MoonlightLinkState::Idle: + default: + return QStringLiteral("idle"); + } +} + +} // namespace + +QVariantList AppViewModel::moonlightHosts() const { + QVariantList out; + for (const auto& row : model_->moonlight()->rows()) { + QVariantMap m; + m[QStringLiteral("uuid")] = row.uuid; + m[QStringLiteral("name")] = row.name; + m[QStringLiteral("address")] = row.address; + m[QStringLiteral("paired")] = row.paired; + m[QStringLiteral("discovered")] = row.discovered; + m[QStringLiteral("link")] = moonlightLinkToken(row.link); + m[QStringLiteral("trust")] = QString::fromLatin1(moonlight::hostTrustToken(row.trust)); + m[QStringLiteral("phase")] = QString::fromLatin1(moonlight::hostPhaseToken(row.phase)); + m[QStringLiteral("controllers")] = row.controllers; + m[QStringLiteral("appId")] = row.lastAppId; + m[QStringLiteral("appName")] = row.lastAppName; + m[QStringLiteral("lastAppName")] = row.lastAppName; + m[QStringLiteral("controllerType")] = row.controllerType; + out.append(m); + } + return out; +} + +bool AppViewModel::moonlightScanning() const { return model_->moonlight()->isScanning(); } + +bool AppViewModel::moonlightPairingActive() const { return model_->moonlight()->pairingActive(); } + +QString AppViewModel::moonlightPairingPin() const { return model_->moonlight()->pairingPin(); } + +QString AppViewModel::moonlightPairingHost() const { + return model_->moonlight()->pairingHostUuid(); +} + +int AppViewModel::moonlightAutoType() const { return repository::kMoonlightControllerTypeAuto; } + +void AppViewModel::scanMoonlight() { model_->moonlight()->startDiscovery(); } + +void AppViewModel::addMoonlightHost(const QString& address, const QString& name) { + if (!address.trimmed().isEmpty()) { + model_->moonlight()->addManualHost(address.trimmed(), name.trimmed()); + } +} + +void AppViewModel::pairMoonlight(const QString& uuid) { model_->moonlight()->pair(uuid); } + +void AppViewModel::cancelMoonlightPairing() { model_->moonlight()->cancelPairing(); } + +void AppViewModel::forgetMoonlight(const QString& uuid) { model_->forgetMoonlightHost(uuid); } + +void AppViewModel::probeMoonlightHost(const QString& uuid) { model_->moonlight()->probe(uuid); } + +void AppViewModel::refreshMoonlightApps(const QString& uuid) { + model_->moonlight()->refreshApps(uuid); +} + +QVariantList AppViewModel::moonlightApps(const QString& uuid) const { + QVariantList out; + for (const auto& app : model_->moonlight()->apps(uuid)) { + QVariantMap m; + m[QStringLiteral("id")] = app.id; + m[QStringLiteral("title")] = app.title; + out.append(m); + } + return out; +} + +void AppViewModel::setMoonlightApp(const QString& uuid, const QString& appId, + const QString& appName) { + model_->moonlight()->setLastApp(uuid, appId, appName); +} + +void AppViewModel::quitMoonlightApp(const QString& uuid) { model_->moonlight()->quitHostApp(uuid); } + +bool AppViewModel::isMoonlightHost(const QString& hostId) const { + return model_->moonlight()->knows(hostId); +} + +QVariantMap AppViewModel::moonlightSession(const QString& uuid, const QString& slotId) const { + auto* manager = model_->moonlight(); + const auto inputs = manager->uiInputs(uuid, slotId); + const auto state = moonlight::sessionUiState(inputs); + + QVariantMap m; + m[QStringLiteral("state")] = QString::fromLatin1(moonlight::sessionUiToken(state)); + m[QStringLiteral("blocksApply")] = moonlight::sessionUiBlocksApply(state); + m[QStringLiteral("trust")] = + QString::fromLatin1(moonlight::hostTrustToken(moonlight::hostTrust(inputs))); + m[QStringLiteral("controllers")] = manager->controllerCount(uuid); + m[QStringLiteral("maxControllers")] = static_cast(moonlight::kMaxPads); + // PairingRefused is one state and several reasons, and they want different + // advice: a rejected PIN is "try again", a host that never answered is + // "check it is switched on". The token; the copy is QML's. + m[QStringLiteral("pairingReason")] = manager->pairingRefusedReason(uuid); + + QString hostName; + QString appId; + QString appName; + if (const auto row = manager->row(uuid)) { + hostName = row->name; + appId = row->lastAppId; + appName = row->lastAppName; + } + // The RUNNING app wins over the remembered pick: a binding that joins a + // session must name what is actually up, never what we would have started. + QString refusal; + if (const auto* session = manager->session(uuid)) { + if (!session->appId().isEmpty()) { + appId = session->appId(); + appName = session->appName(); + } + refusal = session->refusalMessage(); + } + m[QStringLiteral("refusal")] = refusal; + m[QStringLiteral("hostName")] = hostName; + m[QStringLiteral("appId")] = appId; + m[QStringLiteral("appName")] = appName; + + // 1-based, the way the copy counts: "controller 2 of 4". Zero means this + // binding holds no number yet. + int ordinal = 0; + if (const auto number = manager->controllerNumber(slotId)) { + ordinal = static_cast(*number) + 1; + } else if (!slotId.isEmpty()) { + ordinal = inputs.otherControllers + 1; + } + m[QStringLiteral("controllerNumber")] = ordinal; + return m; +} + +int AppViewModel::moonlightResolvedType(const QString& slotId, int candidateType) const { + bool hasMotion = false; + if (const auto* slot = slotById(slotId)) { hasMotion = slot->capabilities.hasMotion; } + return moonlight::resolveControllerType(candidateType, hasMotion); +} + +QString AppViewModel::moonlightBoundHost(const QString& slotId) const { + return model_->moonlightBoundHostFor(slotId); +} + +void AppViewModel::setMoonlightControllerType(const QString& uuid, int type) { + model_->moonlight()->setControllerType(uuid, type); +} + +void AppViewModel::bindMoonlight(const QString& slotId, const QString& uuid) { + model_->bindMoonlightSlot(slotId, uuid); +} + +void AppViewModel::unbindMoonlight(const QString& slotId) { model_->unbindMoonlightSlot(slotId); } + QVariantList AppViewModel::discoveredServers() const { // The one-spot rule: a satellite that already has a connections row renders // there, so FOUND offers only the un-remembered rest. Both sides key on the @@ -1060,7 +1261,44 @@ QVariantList AppViewModel::capabilityForCandidate(const QString& slotId, int typ } const bool hostIsBluetooth = hostKind == QLatin1String("bluetooth"); + const bool hostIsMoonlight = hostKind == QLatin1String("moonlight"); in.hostIsBluetooth = hostIsBluetooth; + + if (hostIsMoonlight) { + // No Moonlight host reports what its emulated devices carry, so there + // is nothing to wait on and nothing to read: the type layer is the + // hard-coded table, and the host layer always carries. Crossing out a + // row we cannot verify would mark every Moonlight binding degraded. + in.hostResolved = !hostId.isEmpty(); + in.hostMouseControl = true; + in.hostRumble = true; + const std::uint8_t resolved = moonlight::resolveControllerType(type, in.padMotion); + const std::uint8_t ceiling = moonlight::typeCapabilityCeiling(resolved); + in.typeResolved = true; + in.typeMotion = (ceiling & moonproto::kCapGyro) != 0; + in.typeTouchpad = (ceiling & moonproto::kCapTouchpad) != 0; + in.typeRumble = (ceiling & moonproto::kCapRumble) != 0; + in.typeLightbar = (ceiling & moonproto::kCapRgbLed) != 0; + in.userMotionOn = motionOn; + in.userRumbleOn = rumbleOn; + in.userTouchpadMode = touchpadMode; + + QVariantList moonRows; + for (const auto& row : reducer::solveCapabilities(in)) { + QVariantMap m; + m[QStringLiteral("feature")] = capFeatureToken(row.feature); + m[QStringLiteral("inOk")] = row.inOk; + m[QStringLiteral("linkOk")] = row.linkOk; + m[QStringLiteral("typeOk")] = row.typeOk; + m[QStringLiteral("hostOk")] = row.hostOk; + m[QStringLiteral("verdict")] = capVerdictToken(row.verdict); + m[QStringLiteral("failingLayer")] = capLayerToken(row.failingLayer); + m[QStringLiteral("hasFailingLayer")] = row.hasFailingLayer; + moonRows.append(m); + } + return moonRows; + } + // A Bluetooth destination is the system gamepad layer, with no catalog to // wait on. A satellite is resolved once its catalog lands. in.hostResolved = hostIsBluetooth ? !hostId.isEmpty() : model_->hasCatalogFor(hostId); @@ -1245,6 +1483,7 @@ void AppViewModel::applyBinding(const QString& slotId, const QString& connection applySlotId_ = resolved; applyConnectionId_ = connectionId; applyType_ = type; + applyIsMoonlight_ = model_->moonlight()->knows(connectionId); applyMotionOn_ = motionOn; applyRumbleOn_ = rumbleOn; applyTouchpadMode_ = touchpadMode; @@ -1277,7 +1516,9 @@ void AppViewModel::beginApplyBind() { // would re-attach the slot once per setting. The type goes straight into the // store because AppModel::setSlotControllerType needs an existing binding // and would bind a second time. - if (applyType_ > 0) { + if (applyType_ > 0 || (applyIsMoonlight_ && applyType_ != -1)) { + // A Moonlight binding stores its Auto sentinel too: 0xFF is a real pick + // there, resolved against the pad at CONTROLLER_ARRIVAL time. model_->typeStore()->setType(applyConnectionId_.toStdString(), applySlotId_.toStdString(), applyType_); } @@ -1286,6 +1527,21 @@ void AppViewModel::beginApplyBind() { : applyTouchpadMode_ == 1 ? QStringLiteral("pad") : QStringLiteral("off")); setRumbleEnabled(applySlotId_, applyRumbleOn_); + if (applyIsMoonlight_) { + // The host remembers the last pick so the NEXT binding on it starts + // where this one did. The binding still owns the type it sends; this is + // a seed, not the authority. + setMoonlightControllerType(applyConnectionId_, applyType_); + // A binding to a Moonlight host is a durable INTENT, not a handshake: + // there is no descriptor to PUT and nothing to be refused by. The + // session is started or joined here if the host is already trusted and + // reachable, and its state is reported by the session section rather + // than by failing the apply. Nothing about the host may block saving + // what the user asked for. + bindMoonlight(applySlotId_, applyConnectionId_); + dispatchApply(reducer::apply_event::BindAccepted{}); + return; + } bindSlot(applySlotId_, applyConnectionId_); applyBindTimer_->start(); } @@ -1361,6 +1617,16 @@ void AppViewModel::onApplyTick() { return; } + // A Moonlight bind settles the instant it is written, so there is no + // readback to wait on and no host state that could turn it into a failure. + if (applyIsMoonlight_) { + // The host remembers the last pick so the NEXT binding on it starts + // where this one did. The binding still owns the type it sends; this is + // a seed, not the authority. + setMoonlightControllerType(applyConnectionId_, applyType_); + return; + } + // The hub binds locally and the satellite answers asynchronously, so the // outcome must never be read on the tick that ENTERED this step: the local // bind is synchronous and a same-tick read reports success before the diff --git a/src/qml/AppViewModel.h b/src/qml/AppViewModel.h index d306c52..6393b1c 100644 --- a/src/qml/AppViewModel.h +++ b/src/qml/AppViewModel.h @@ -91,6 +91,27 @@ class AppViewModel : public QObject { Q_PROPERTY(QVariantList discoveredServers READ discoveredServers NOTIFY discoveredChanged) Q_PROPERTY(bool scanning READ isScanning NOTIFY scanningChanged) + // ── Moonlight hosts (Sunshine / Apollo / Wolf) ─────────────────────────── + // A sibling of the satellite pool: each entry has uuid, name, address, + // paired, discovered, link ("idle"|"linking"|"live"|"failed"), the richer + // phase token the row chip reads, the trust word + // ("paired"|"remembered"|"notPaired"), how many controllers ride it, and + // the app the session settled on. + // + // TRUST IS NOT LIVENESS. A Moonlight host never reports a connection state, + // so nothing here may be drawn as a pulsing dot: the words are what the + // client remembers and what the last client-initiated probe confirmed. + Q_PROPERTY(QVariantList moonlightHosts READ moonlightHosts NOTIFY moonlightChanged) + Q_PROPERTY(bool moonlightScanning READ moonlightScanning NOTIFY moonlightChanged) + Q_PROPERTY(bool moonlightPairingActive READ moonlightPairingActive NOTIFY moonlightChanged) + // The 4-digit PIN the user must type into the host's UI while pairing. + Q_PROPERTY(QString moonlightPairingPin READ moonlightPairingPin NOTIFY moonlightChanged) + Q_PROPERTY(QString moonlightPairingHost READ moonlightPairingHost NOTIFY moonlightChanged) + // The Auto sentinel, so the picker cannot fork the value the wire and the + // store agree on. 0xFF, and deliberately not 0: 0 is the wire's + // CONTROLLER_TYPE_UNKNOWN, which is a different promise. + Q_PROPERTY(int moonlightAutoType READ moonlightAutoType CONSTANT) + // ── Reverse (host-initiated) pairing ───────────────────────────────────── // Phase is "idle" | "awaiting" | "approved" | "declined" | "timedout". Q_PROPERTY(QString reversePairingPhase READ reversePairingPhase NOTIFY reversePairingChanged) @@ -317,6 +338,13 @@ class AppViewModel : public QObject { Q_INVOKABLE void startDiscovery(); Q_INVOKABLE bool isScanning() const; Q_INVOKABLE QVariantList discoveredServers() const; + + QVariantList moonlightHosts() const; + bool moonlightScanning() const; + bool moonlightPairingActive() const; + QString moonlightPairingPin() const; + QString moonlightPairingHost() const; + int moonlightAutoType() const; Q_INVOKABLE void forgetConnection(const QString& connectionId); // Keyed on the stable id, never a list index: the discovered list can reorder @@ -338,6 +366,52 @@ class AppViewModel : public QObject { Q_INVOKABLE void requestReversePairing(const QString& serverId); Q_INVOKABLE void cancelReversePairing(); + // ── Moonlight host commands ────────────────────────────────────────────── + Q_INVOKABLE void scanMoonlight(); + // Adds a host by IP/hostname the user typed, ports fixed at the standard + // 47989/47984 pair. + Q_INVOKABLE void addMoonlightHost(const QString& address, const QString& name = QString()); + // Shows moonlightPairingPin; the user types it into the host UI. The PIN is + // minted in C++ because it is security relevant, never in QML. + Q_INVOKABLE void pairMoonlight(const QString& uuid); + Q_INVOKABLE void cancelMoonlightPairing(); + Q_INVOKABLE void forgetMoonlight(const QString& uuid); + // Re-asks the host whether it is reachable, still paired and still itself. + // Client-initiated by definition: call it on entering a screen and before + // starting a session. Nothing polls. + Q_INVOKABLE void probeMoonlightHost(const QString& uuid); + // GET /applist. Rows are {id, title}; the read is HTTPS and paired-only, so + // an unpaired host reports a failure rather than an empty list. + Q_INVOKABLE void refreshMoonlightApps(const QString& uuid); + Q_INVOKABLE QVariantList moonlightApps(const QString& uuid) const; + // The app the NEXT session on this host will run. Per host, not per + // binding: only the binding that creates a session picks one. + Q_INVOKABLE void setMoonlightApp(const QString& uuid, const QString& appId, + const QString& appName); + // Ends whatever the host is running, ours or another device's. The only way + // out of "an app is already running" when the host will not hand it over. + Q_INVOKABLE void quitMoonlightApp(const QString& uuid); + // True when `hostId` names a Moonlight host rather than a satellite. + Q_INVOKABLE bool isMoonlightHost(const QString& hostId) const; + // Everything the Moonlight session section renders, for one host seen from + // one binding (slotId may be empty for a binding that does not exist yet): + // { state, blocksApply, hostName, appId, appName, controllers, + // controllerNumber, trust, pairingReason, refusal }. `state` is one of + // the twenty-one lowercase tokens in core/moonlight/MoonlightSessionUi.h; + // QML localizes it. `pairingReason` is "" unless `state` is pairingRefused, + // and then it is the pairingFinished token that says which refusal it was. + Q_INVOKABLE QVariantMap moonlightSession(const QString& uuid, const QString& slotId) const; + // What Auto resolves to for this pad: a source with gyro or accelerometer + // becomes PlayStation, everything else Xbox. Returns the wire type byte. + Q_INVOKABLE int moonlightResolvedType(const QString& slotId, int candidateType) const; + // The Moonlight host this slot drives, or empty. + Q_INVOKABLE QString moonlightBoundHost(const QString& slotId) const; + // The host's own seed for the next binding's type pick. + Q_INVOKABLE void setMoonlightControllerType(const QString& uuid, int type); + // Routes a controller slot's live input to a Moonlight host. + Q_INVOKABLE void bindMoonlight(const QString& slotId, const QString& uuid); + Q_INVOKABLE void unbindMoonlight(const QString& slotId); + // ── Deadzone settings page ─────────────────────────────────────────────── // Rows of {id,name,hasGyro,stickFlat,triggerFlat,forwardMotion}, re-pulled on // deadzonesChanged. setDeadzones both persists the override and pushes it @@ -372,9 +446,13 @@ class AppViewModel : public QObject { // { feature, inOk, linkOk, typeOk, hostOk, verdict, failingLayer, // hasFailingLayer }. feature / verdict / failingLayer are lowercase tokens // ("motion", "unavailable", "link"): the C++ never vends a sentence, QML - // localizes. hostKind is "satellite" or "bluetooth", hostId "" means no - // destination chosen yet, desiredPath is "standard" or "direct" and - // touchpadMode is 0=off 1=pad 2=mouse. + // localizes. hostKind is "satellite", "bluetooth" or "moonlight", hostId "" + // means no destination chosen yet, desiredPath is "standard" or "direct" + // and touchpadMode is 0=off 1=pad 2=mouse. + // + // A Moonlight destination waits on no catalog: no host reports what its + // emulated devices carry, so the type layer is the hard-coded table in + // core/moonlight/MoonlightPadSlots.h and the host layer always carries. Q_INVOKABLE QVariantList capabilityForCandidate(const QString& slotId, int type, const QString& hostKind, const QString& hostId, const QString& desiredPath, bool motionOn, @@ -469,6 +547,9 @@ class AppViewModel : public QObject { // the FOUND list excludes ids that already have a row (the one-spot rule), // so a pair landing or a forget has to re-read too. void discoveredChanged(); + void moonlightChanged(); + // The /applist read for one host moved: in flight, arrived, or failed. + void moonlightAppsChanged(const QString& uuid); void scanningChanged(); void reversePairingChanged(); @@ -577,6 +658,9 @@ class AppViewModel : public QObject { QString applySlotId_; QString applyConnectionId_; int applyType_ = 0; + // The destination this apply is writing to is a Moonlight host, so the + // satellite hub is not involved and the outcome is settled locally. + bool applyIsMoonlight_ = false; bool applyMotionOn_ = true; bool applyRumbleOn_ = true; int applyTouchpadMode_ = 0; diff --git a/src/qml/kit/WizardBanner.qml b/src/qml/kit/WizardBanner.qml index d5b2bff..a30c557 100644 --- a/src/qml/kit/WizardBanner.qml +++ b/src/qml/kit/WizardBanner.qml @@ -30,6 +30,9 @@ Item { property int stage: 1 // 0 type · 1 feel · 2 review. Only meaningful while stage === 3. property int subStep: 0 + // How many pages stage 3 holds for THIS binding. A Moonlight binding gains + // the session step, so the pip row is sized rather than assumed. + property int subStepCount: 3 property bool compact: false // Completed markers only; the page decides what a jump back means. @@ -347,10 +350,11 @@ Item { Layout.alignment: Qt.AlignVCenter | Qt.AlignRight Accessible.role: Accessible.StaticText - Accessible.name: qsTr("Sub-step %1 of 3").arg(banner.subStep + 1) + Accessible.name: qsTr("Sub-step %1 of %2").arg(banner.subStep + 1) + .arg(banner.subStepCount) Repeater { - model: 3 + model: banner.subStepCount delegate: Rectangle { id: pip diff --git a/src/qml/pages/ConfigureBindingPage.qml b/src/qml/pages/ConfigureBindingPage.qml index 61290f5..45857d7 100644 --- a/src/qml/pages/ConfigureBindingPage.qml +++ b/src/qml/pages/ConfigureBindingPage.qml @@ -151,6 +151,11 @@ Kit.Page { catalogFailed: page.catalogBroken } + // Named so a child that declares its own `draft` property can still be + // handed THIS one: an unqualified `draft` inside such a child resolves to + // the child's own property and binds to itself. + readonly property BindingDraft boundDraft: draft + // The snapshot the page opened with; `dirty` is read off it, so a rail click // never confirms on a page the user only looked at. // NOT `baseline`: Item declares that anchor line FINAL, and shadowing it @@ -191,15 +196,22 @@ Kit.Page { const boundId = page.padRow.boundConnectionId; if (boundId.length > 0) { - // The real label arrives later with the host row. Every destination - // this page can offer is a Satellite — there is no BT-host source. - draft.chooseDestination(boundId, boundId, "satellite"); + // The real label arrives later with the host row. The kind is asked + // of the subsystem that owns the id, because the two destination + // families keep separate books and only one of them will know it. + draft.chooseDestination(boundId, boundId, + App.isMoonlightHost(boundId) ? "moonlight" : "satellite"); // Keyed on the DESTINATION, never on the pad: in bind mode the pad has // no binding, and the slot-keyed read resolves through one. page.refreshCatalog(); - const current = App.emulateCurrentTypeForHost(boundId, page.slotId); - if (current >= 0) { - draft.chooseType(current, page.typeNameFor(current)); + if (draft.hostIsMoonlight) { + const moonType = page.moonlightTypeFor(boundId); + draft.chooseType(moonType, page.moonlightTypeName(moonType)); + } else { + const current = App.emulateCurrentTypeForHost(boundId, page.slotId); + if (current >= 0) { + draft.chooseType(current, page.typeNameFor(current)); + } } draft.touchpadMode = page.touchpadIndex(App.touchpadModeFor(boundId)); } @@ -210,7 +222,10 @@ Kit.Page { page.snapshot(); } - Component.onCompleted: page.seed() + Component.onCompleted: { + page.seed(); + page.refreshSession(); + } // ── The controller-type catalog ───────────────────────────────────────── // emulateTypes is a one-shot read, so it is re-pulled on every catalog move; @@ -218,16 +233,49 @@ Kit.Page { // function call is not a binding dependency. property var types: [] - readonly property bool catalogLoading: App.emulateLoading + // A Moonlight destination waits on nothing: no host reports what its + // emulated devices carry, so the four types are protocol constants. + readonly property bool catalogLoading: App.emulateLoading && !draft.hostIsMoonlight readonly property bool catalogBroken: App.emulateError.length > 0 && page.types.length === 0 + && !draft.hostIsMoonlight + + readonly property int autoType: App.moonlightAutoType + readonly property var moonlightTypes: [ + { "type": page.autoType, "name": qsTr("Auto") }, + { "type": 1, "name": "Xbox" }, + { "type": 2, "name": "PlayStation" }, + { "type": 3, "name": "Nintendo" } + ] + readonly property int autoResolved: draft.hasInput + ? App.moonlightResolvedType(page.slotId, page.autoType) : 1 + readonly property string autoResolvedName: page.autoResolved === 2 ? "PlayStation" : "Xbox" + + function moonlightTypeName(wireType) { + for (let i = 0; i < page.moonlightTypes.length; ++i) { + if (page.moonlightTypes[i].type === wireType) + return page.moonlightTypes[i].name; + } + return page.moonlightTypes[0].name; + } + + // The host's remembered seed for a binding that has not chosen yet. + function moonlightTypeFor(hostId) { + const rows = App.moonlightHosts; + for (let i = 0; i < rows.length; ++i) { + if (rows[i].uuid === hostId) + return rows[i].controllerType; + } + return page.autoType; + } function reloadTypes() { - page.types = App.emulateTypesForHost(draft.hostId); + page.types = draft.hostIsMoonlight ? page.moonlightTypes + : App.emulateTypesForHost(draft.hostId); } // A different destination is a different catalog. function refreshCatalog() { - if (draft.hasDestination && !draft.hostIsBluetooth) { + if (draft.hasDestination && !draft.hostIsBluetooth && !draft.hostIsMoonlight) { App.refreshEmulateForHost(draft.hostId); } page.reloadTypes(); @@ -238,10 +286,53 @@ Kit.Page { function onHostIdChanged() { if (page.seeded) { page.refreshCatalog(); + page.refreshSession(); } } } + // Trust is verified lazily, so entering a Moonlight destination is one of + // the three moments that re-ask the host. Nothing polls. + function refreshSession() { + if (draft.hostIsMoonlight) { + sessionSection.activated(); + } + } + + // The type layer's own answer for one candidate, in the {feature,supported} + // shape the preview pills read. `revision` is named at the call site so the + // draft is a binding dependency: a plain call is not one. + function moonlightPreview(candidateType, revision) { + const rows = draft.rowsFor(candidateType); + const out = []; + for (let i = 0; i < rows.length; ++i) { + out.push({ "feature": rows[i].feature, "supported": rows[i].typeOk }); + } + return out; + } + + function trustText(token) { + switch (token) { + case "paired": + return qsTr("Paired"); + case "remembered": + return qsTr("Remembered"); + default: + return qsTr("Not paired"); + } + } + + function trustTone(token) { + switch (token) { + case "paired": + return Kit.CapabilityChip.Ok; + case "remembered": + return Kit.CapabilityChip.Neutral; + default: + return Kit.CapabilityChip.Absent; + } + } + function typeNameFor(wireType) { for (let i = 0; i < page.types.length; ++i) { if (page.types[i].type === wireType) { @@ -452,14 +543,24 @@ Kit.Page { } // ── Apply ─────────────────────────────────────────────────────────────── - readonly property bool canApply: page.padRow !== null && draft.hasDestination && draft.hasType + // A Moonlight host already carrying four controllers is the ONE host state + // that blocks Apply, because it is a hard protocol limit. Every other one + // (not paired, unreachable, refused, dropped) still saves the binding: a + // binding is a durable intent and the session is attempted when the pad is + // used, not when the user presses Apply. + readonly property bool moonlightBlocked: draft.hostIsMoonlight && sessionSection.blocked + readonly property bool canApply: page.padRow !== null && draft.hasDestination + && draft.hasType && !page.moonlightBlocked readonly property bool noHosts: App.connectionModel.count === 0 + && App.moonlightHosts.length === 0 property bool applyRequested: false readonly property string actionHint: !draft.hasDestination ? qsTr("Pick a destination to continue.") - : !draft.hasType ? qsTr("Waiting on the controller catalog.") - : qsTr("Nothing is sent until you apply.") + : page.moonlightBlocked + ? qsTr("Unbind a controller on %1 to make room.").arg(draft.hostName) + : !draft.hasType ? qsTr("Waiting on the controller catalog.") + : qsTr("Nothing is sent until you apply.") function stepState(token) { // A skipped step is a step that will not run: drawn done, captioned so @@ -844,6 +945,38 @@ Kit.Page { } } + // Its own section, not more rows above: the two host + // kinds pair differently, and one merged column would + // make the status word mean two things. + Kit.Eyebrow { + visible: App.moonlightHosts.length > 0 + mutedTone: true + text: qsTr("Moonlight hosts") + Layout.topMargin: Tokens.s2 + } + + Repeater { + model: App.moonlightHosts + + delegate: Kit.SelectRow { + id: moonOption + + required property var modelData + + Layout.fillWidth: true + selected: draft.hostId === moonOption.modelData.uuid + title: moonOption.modelData.name + subtitle: qsTr("Moonlight host · %1") + .arg(moonOption.modelData.address) + chipText: page.trustText(moonOption.modelData.trust) + chipTone: page.trustTone(moonOption.modelData.trust) + + onPicked: draft.chooseDestination(moonOption.modelData.uuid, + moonOption.modelData.name, + "moonlight") + } + } + Kit.Callout { visible: draft.hostIsBluetooth Layout.fillWidth: true @@ -851,6 +984,19 @@ Kit.Page { text: qsTr("This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host.") } + // ── SESSION ───────────────────────────────────────────────── + // The same file the wizard's step 3 renders, so the two + // editors cannot tell a user two different stories about + // one host. + WizardSessionPage { + id: sessionSection + visible: draft.hostIsMoonlight + draft: page.boundDraft + shellApi: page.shellApi + Layout.fillWidth: true + Layout.topMargin: Tokens.s2 + } + RowLayout { visible: draft.hasDestination Layout.fillWidth: true @@ -987,13 +1133,24 @@ Kit.Page { required property var modelData - readonly property var preview: App.typeFeatureSummary(draft.hostId, - typeOption.modelData.type) + // A Moonlight host advertises nothing, so its + // pills come from the hard-coded table through + // the same solver the matrix reads. + readonly property var preview: draft.hostIsMoonlight + ? page.moonlightPreview(typeOption.modelData.type, + draft.revision) + : App.typeFeatureSummary(draft.hostId, + typeOption.modelData.type) Layout.fillWidth: true selected: draft.type === typeOption.modelData.type title: typeOption.modelData.name - subtitle: typeOption.modelData.description + subtitle: draft.hostIsMoonlight + ? (typeOption.modelData.type === page.autoType + ? qsTr("Auto sends %1 for this controller.") + .arg(page.autoResolvedName) + : "") + : typeOption.modelData.description onPicked: draft.chooseType(typeOption.modelData.type, typeOption.modelData.name) diff --git a/src/qml/pages/ConnectionsPage.qml b/src/qml/pages/ConnectionsPage.qml index 0e31cb6..a567a84 100644 --- a/src/qml/pages/ConnectionsPage.qml +++ b/src/qml/pages/ConnectionsPage.qml @@ -40,6 +40,10 @@ Kit.Page { property string currentConnectionId: "" property string currentLabel: "" + // The shell owns the detail stack the Moonlight inventory is pushed onto. + readonly property var shellView: StackView.view + readonly property var shellApi: page.shellView ? page.shellView.shellApi : null + // Scan on open: entering the destination surfaces reachable satellites // without an extra tap; startDiscovery() is guarded manager-side so an // in-flight sweep is never double-triggered. @@ -403,6 +407,21 @@ Kit.Page { } } } + + // ---- MOONLIGHT HOSTS ------------------------------------------------ + // The second connection path lives on its own page rather than as more + // rows here: a Moonlight host pairs differently, carries different + // capabilities, and never reports liveness, so one merged list would + // make "Pair" and the status column each mean two things. + Kit.RowButton { + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + title: qsTr("Moonlight hosts") + subtitle: qsTr("Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite.") + onClicked: if (page.shellApi) page.shellApi.pushDetail( + Qt.resolvedUrl("MoonlightHostsPage.qml"), + qsTr("Moonlight hosts"), {}) + } } // ---- Host overflow menu ------------------------------------------------- diff --git a/src/qml/pages/MoonlightHostsPage.qml b/src/qml/pages/MoonlightHostsPage.qml new file mode 100644 index 0000000..85eabc7 --- /dev/null +++ b/src/qml/pages/MoonlightHostsPage.qml @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The Moonlight destination — the inventory of GameStream HOSTS (Sunshine, +// Apollo, Wolf), the sibling of ConnectionsPage's satellite inventory. It is +// deliberately a SEPARATE page rather than a second section of Connections: the +// two host kinds pair differently, carry different capabilities, and a merged +// list would make "Pair" mean two different things in one column. +// +// PAIRING IS NOT CONNECTING, and this page never claims otherwise. Moonlight +// has no bidirectional liveness, so there is no light to draw: a row states the +// trust it remembers and whether this visit confirmed it, re-asked on open and +// never polled. What runs, and for whom, is the BINDING flow's question; this +// page owns pairing, forgetting, and the one escape hatch that ends a session +// from outside a binding. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import "../kit" as Kit +import Dish.Chrome + +Kit.Page { + id: page + title: qsTr("Moonlight hosts") + + readonly property string headerTitle: qsTr("Moonlight hosts") + readonly property string headerSub: page.pairedCount === 0 + ? qsTr("%n found", "", page.hostRows.length) + : qsTr("%n paired", "", page.pairedCount) + readonly property string headerDot: page.pairedCount === 0 ? "muted" : "success" + + // Where a user without a host goes to get one. + readonly property string sunshineUrl: "https://github.com/LizardByte/Sunshine" + + readonly property var hostRows: App.moonlightHosts + + property string currentHostId: "" + property string currentLabel: "" + property bool currentHasSession: false + property int currentControllers: 0 + + readonly property int pairedCount: { + let n = 0; + for (let i = 0; i < page.hostRows.length; ++i) { + if (page.hostRows[i].trust === "paired" || page.hostRows[i].trust === "remembered") + n += 1; + } + return n; + } + + // Trust is verified LAZILY: on entering this screen, and again before a + // session starts. Never on a timer — the host would not answer a question + // nobody asked, and a poll would only invent a liveness it cannot report. + Component.onCompleted: { + if (!App.moonlightScanning) + App.scanMoonlight(); + page.reprobeAll(); + } + + function reprobeAll() { + for (let i = 0; i < page.hostRows.length; ++i) + App.probeMoonlightHost(page.hostRows[i].uuid); + } + + ColumnLayout { + width: parent.width + spacing: Tokens.s5 + + // ---- FOUND + manual add --------------------------------------------- + RowLayout { + Layout.fillWidth: true + spacing: Tokens.s4 + + Kit.SectionHeader { glyph: "dish-logo"; label: qsTr("Found") } + Item { Layout.fillWidth: true } + Kit.LiveStat { + live: App.moonlightScanning + text: App.moonlightScanning ? qsTr("scanning…") + : qsTr("%n found", "", page.hostRows.length) + } + Kit.DishButton { + text: qsTr("Add by address…") + variant: Kit.DishButton.Outline + onClicked: addSheet.open() + } + Kit.DishButton { + text: App.moonlightScanning ? qsTr("Scanning…") : qsTr("Scan") + variant: Kit.DishButton.Outline + enabled: !App.moonlightScanning + onClicked: App.scanMoonlight() + } + } + + Kit.DishProgressBar { + visible: App.moonlightScanning + indeterminate: true + Layout.fillWidth: true + } + + // Empty is a real state, and it says so differently while a sweep runs. + Kit.EmptyState { + visible: page.hostRows.length === 0 && App.moonlightScanning + glyph: "satellite-broadcasting" + title: qsTr("Looking for Moonlight hosts") + body: qsTr("Scanning your network for hosts advertising GameStream. They appear here as they answer.") + showAction: false + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + Layout.bottomMargin: Tokens.s5 + } + + Kit.EmptyState { + visible: page.hostRows.length === 0 && !App.moonlightScanning + glyph: "satellite-off" + title: qsTr("No Moonlight hosts found") + body: qsTr("A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address.") + showAction: false + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + Layout.bottomMargin: Tokens.s5 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.s3 + spacing: Tokens.s4 + + Kit.DishButton { + text: qsTr("Get Sunshine ↗") + variant: Kit.DishButton.Outline + onClicked: App.openExternalUrl(page.sunshineUrl) + } + Kit.DishButton { + text: qsTr("Add by address…") + variant: Kit.DishButton.Outline + onClicked: addSheet.open() + } + } + } + + // ---- One card per host ---------------------------------------------- + Repeater { + model: page.hostRows + + delegate: Kit.Card { + id: host + required property var modelData + + readonly property string hostId: host.modelData.uuid + readonly property string label: host.modelData.name + readonly property string trust: host.modelData.trust + readonly property string phase: host.modelData.phase + readonly property int controllers: host.modelData.controllers + readonly property bool sessionUp: host.phase === "streaming" + || host.phase === "faltering" + || host.phase === "launching" + || host.phase === "connecting" + readonly property bool busy: host.phase === "pairing" + || host.phase === "launching" + || host.phase === "connecting" + + Layout.fillWidth: true + + Accessible.role: Accessible.ListItem + Accessible.name: qsTr("%1, Moonlight host, %2") + .arg(host.label).arg(page.trustText(host.trust)) + + contentItem: ColumnLayout { + spacing: Tokens.s5 + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.s4 + + Kit.BrandGlyph { + glyph: "dish-logo" + Layout.preferredWidth: Tokens.glyphSm + Layout.preferredHeight: Tokens.glyphSm + Layout.alignment: Qt.AlignVCenter + } + ColumnLayout { + spacing: Tokens.s0 + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + Label { + text: host.label + color: Theme.onSurface + font.pixelSize: Tokens.textBase + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: true + } + // The kind, in words: a glyph alone would not say it. + Label { + text: qsTr("Moonlight host (Sunshine/Apollo)") + color: Theme.muted + font.pixelSize: Tokens.textMeta + elide: Text.ElideRight + Layout.fillWidth: true + } + } + Kit.LiveStat { + text: host.modelData.address + elide: Text.ElideRight + Layout.alignment: Qt.AlignVCenter + } + // Trust, not liveness. Three words, and never a dot. + Kit.CapabilityChip { + text: page.trustText(host.trust) + tone: page.trustTone(host.trust) + Layout.alignment: Qt.AlignVCenter + } + Kit.CapabilityChip { + visible: host.controllers > 0 + text: qsTr("In use by %1").arg(page.controllerPhrase(host.controllers)) + tone: Kit.CapabilityChip.Present + Layout.alignment: Qt.AlignVCenter + } + Kit.CapabilityChip { + visible: host.phase !== "idle" && host.phase !== "paired" + text: page.phaseText(host.phase) + tone: page.phaseTone(host.phase) + Layout.alignment: Qt.AlignVCenter + } + } + + // What the session is running, once there is one. Read-only: + // the app belongs to whoever created the session, and the + // binding flow is where a new one is chosen. + RowLayout { + visible: host.sessionUp && host.modelData.appName.length > 0 + Layout.fillWidth: true + spacing: Tokens.s4 + + Kit.Eyebrow { mutedTone: true; text: qsTr("Session") } + Label { + text: host.modelData.appName + color: Theme.onSurface + font.pixelSize: Tokens.textMeta + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.s4 + + Item { Layout.fillWidth: true } + + Kit.DishButton { + visible: host.trust !== "paired" + text: host.trust === "remembered" ? qsTr("Pair again") : qsTr("Pair…") + variant: Kit.DishButton.Primary + enabled: !host.busy && !App.moonlightPairingActive + onClicked: pairSheet.openFor(host.hostId, host.label) + } + // No Connect. Binding a controller starts or joins the + // session; unbinding the last one ends it. + Kit.DishButton { + text: "⋯" + variant: Kit.DishButton.Outline + Accessible.name: qsTr("More actions for %1").arg(host.label) + onClicked: { + page.currentHostId = host.hostId; + page.currentLabel = host.label; + page.currentHasSession = host.sessionUp; + page.currentControllers = host.controllers; + hostMenu.popup(); + } + } + } + } + } + } + + Label { + visible: page.hostRows.length > 0 + text: qsTr("Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller.") + color: Theme.mutedStrong + font.pixelSize: Tokens.textMeta + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.topMargin: Tokens.s2 + } + } + + // ---- Host overflow ------------------------------------------------------ + Menu { + id: hostMenu + + background: Rectangle { + // A Menu takes its width from its BACKGROUND, not from its items: + // the style's default background carries implicitWidth 200, and + // replacing it with a bare Rectangle drops that to 0. + implicitWidth: Math.max(Tokens.menuMinWidth, + Math.max(forgetItem.implicitWidth, quitItem.implicitWidth) + + hostMenu.leftPadding + hostMenu.rightPadding) + color: Theme.surface + border.width: 1 + border.color: Theme.outline + radius: Tokens.radiusButton + } + + MenuItem { + id: quitItem + text: qsTr("Quit session") + enabled: page.currentHasSession + + contentItem: Text { + text: quitItem.text + font.pixelSize: Tokens.textSummary + color: quitItem.enabled ? Theme.onSurface : Theme.disabledFg + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: quitItem.highlighted ? Theme.primaryHover : "transparent" + radius: Tokens.radiusChip + } + onTriggered: App.quitMoonlightApp(page.currentHostId) + } + + MenuItem { + id: forgetItem + text: qsTr("Forget") + + contentItem: Text { + text: forgetItem.text + font.pixelSize: Tokens.textSummary + color: Theme.error + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: forgetItem.highlighted ? Theme.primaryHover : "transparent" + radius: Tokens.radiusChip + } + onTriggered: page.confirmForget() + } + } + + // A Forget takes the pairing AND every binding that rode it, so it names + // them first. Same manifest the satellite Forget shows, off the same join. + Kit.ConfirmDialog { + id: forgetConfirm + + property var pads: [] + + eyebrow: qsTr("Forget") + heading: qsTr("Forget %1?").arg(page.currentLabel) + // A Forget is UNILATERAL, and saying only "the pairing is deleted" + // implies otherwise. The host keeps its own record until a human + // removes this device there, which is a different screen on a + // different machine. + bodyText: qsTr("Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there.") + .arg(page.currentLabel) + + (page.currentControllers > 0 + ? "\n" + page.sessionEndsText(page.currentControllers) : "") + + (forgetConfirm.pads.length > 0 + ? "\n" + page.bindingsDroppedText(forgetConfirm.pads.length) : "") + bulletLines: forgetConfirm.pads + acceptText: qsTr("Forget") + rejectText: qsTr("Cancel") + destructiveAccept: true + onAccepted: { + App.forgetMoonlight(page.currentHostId); + forgetConfirm.close(); + } + } + + // ---- Add by address ----------------------------------------------------- + // The discovery fallback: mDNS does not cross every subnet, so a host can + // always be reached by typing where it lives. + Kit.ContentDialog { + id: addSheet + eyebrow: qsTr("Moonlight host") + heading: qsTr("Add a host by address") + acceptText: qsTr("Add") + rejectText: qsTr("Cancel") + acceptEnabled: addressField.text.trim().length > 0 + + body: [ + Label { + text: qsTr("Enter the host IP address or hostname. Dish uses the standard Moonlight ports.") + color: Theme.muted + font.pixelSize: Tokens.textSummary + lineHeight: 1.5 + wrapMode: Text.WordWrap + Layout.fillWidth: true + }, + Kit.KitTextField { + id: addressField + placeholderText: qsTr("192.168.1.20") + Layout.fillWidth: true + }, + Kit.KitTextField { + id: nameField + placeholderText: qsTr("Name (optional)") + Layout.fillWidth: true + } + ] + + onAccepted: { + App.addMoonlightHost(addressField.text.trim(), nameField.text.trim()); + addressField.clear(); + nameField.clear(); + addSheet.close(); + } + onClosed: { addressField.clear(); nameField.clear(); } + } + + // ---- Pairing ------------------------------------------------------------ + // The mirror of the satellite sheet: the PIN is generated HERE and typed + // into the host's own page, so this sheet DISPLAYS a code rather than + // asking for one. The code itself is minted in C++ — a PIN is security + // relevant, and Math.random() is not a suitable source for one. + Kit.ContentDialog { + id: pairSheet + + property string hostId: "" + property string hostName: "" + property bool rejected: false + // The pairingFinished token behind the refusal, so the line below can + // give the right advice instead of always blaming the PIN. + property string reason: "" + + readonly property string pin: App.moonlightPairingPin + + eyebrow: qsTr("Pairing") + heading: qsTr("Pair with %1").arg(pairSheet.hostName) + rejectText: qsTr("Cancel") + acceptText: qsTr("Done") + acceptEnabled: false + + function openFor(id, name) { + pairSheet.hostId = id; + pairSheet.hostName = name; + pairSheet.rejected = false; + pairSheet.reason = ""; + // Opened FIRST: pairMoonlight can refuse before it reaches the + // wire, and the refusal arrives through onMoonlightChanged, which + // a sheet that is not up yet would never see. + pairSheet.open(); + App.pairMoonlight(id); + } + + onRejected: App.cancelMoonlightPairing() + + // Escape closes a ContentDialog through closePolicy, which does NOT + // emit rejected(): only the Cancel button does. Without this, dismissing + // the sheet that way leaves the attempt walking its phases with nothing + // on screen, and a phase 1 that later succeeds writes back a pairing the + // user walked away from. Idempotent: a sheet closed by Cancel or by + // success has no attempt left to cancel. + onClosed: { + if (App.moonlightPairingActive && App.moonlightPairingHost === pairSheet.hostId) + App.cancelMoonlightPairing(); + } + + body: [ + Label { + text: qsTr("Type %1 into the Moonlight or Sunshine page on %2.") + .arg(pairSheet.pin).arg(pairSheet.hostName) + color: Theme.muted + font.pixelSize: Tokens.textSummary + lineHeight: 1.5 + wrapMode: Text.WordWrap + Layout.fillWidth: true + }, + RowLayout { + spacing: Tokens.s4 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.s3 + Layout.bottomMargin: Tokens.s3 + + Repeater { + model: 4 + delegate: Rectangle { + id: pinCell + required property int index + + implicitWidth: Tokens.s11 + implicitHeight: Tokens.hitRow + radius: Tokens.radiusButton + color: Theme.surfaceDim + border.width: 1 + border.color: pairSheet.rejected ? Theme.error : Theme.outline + + Label { + anchors.centerIn: parent + text: pinCell.index < pairSheet.pin.length + ? pairSheet.pin.charAt(pinCell.index) : "" + color: Theme.primary + font.family: Tokens.monoFamily + font.pixelSize: Tokens.textHero + } + } + } + }, + RowLayout { + spacing: Tokens.s5 + Layout.fillWidth: true + + Kit.DishProgressBar { + visible: !pairSheet.rejected + indeterminate: true + Layout.preferredWidth: Tokens.s11 * 2 + } + Label { + text: pairSheet.rejected ? page.pairFailedText(pairSheet.reason) + : qsTr("Waiting for the host to accept the PIN…") + color: pairSheet.rejected ? Theme.error : Theme.muted + font.pixelSize: Tokens.textSummary + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Kit.DishButton { + visible: pairSheet.rejected + text: qsTr("New code") + variant: Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: pairSheet.openFor(pairSheet.hostId, pairSheet.hostName) + } + } + ] + } + + Connections { + target: App + + function onMoonlightChanged() { + if (!pairSheet.visible || pairSheet.hostId.length === 0) + return; + // Read the sheet's OWN host: pairing state is global, and a + // background attempt elsewhere must not dismiss or fail this sheet. + const session = App.moonlightSession(pairSheet.hostId, ""); + if (session.trust === "paired") { + pairSheet.rejected = false; + pairSheet.close(); + // A freshly paired host can be asked what it runs. + App.refreshMoonlightApps(pairSheet.hostId); + return; + } + pairSheet.rejected = session.state === "pairingRefused"; + pairSheet.reason = session.pairingReason !== undefined ? session.pairingReason : ""; + } + } + + // ---- Helpers: tokens to localized copy ---------------------------------- + + function confirmForget() { + if (page.currentHostId.length === 0) + return; + forgetConfirm.pads = page.carryingPads(page.currentHostId); + forgetConfirm.open(); + } + + // The pads riding this host, by name. Moonlight bindings key on the host + // uuid in the slot list exactly as satellite bindings key on the + // connection id, so the same join answers for both. + function carryingPads(hostId) { + return App.carriedPads(hostId).map(function (pad) { return pad.name; }); + } + + // %n so the verb agrees where it inflects: English alternates + // rides/ride, and Bosnian needs a third form again at 2-4. + function bindingsDroppedText(n) { + return qsTr("%n bindings ride on it and will be dropped:", "", n); + } + + // Said separately from the bindings line: a session can be carrying + // controllers this device did not bind, and forgetting ends it for them. + function sessionEndsText(n) { + return qsTr("Its session ends for the %n controllers on it.", "", n); + } + + // Why the attempt ended, from the pairingFinished token. Every reason gets + // its own next step: telling someone to re-check the PIN when the host + // never answered sends them to the wrong screen. + function pairFailedText(reason) { + switch (reason) { + case "unreachable": + return qsTr("%1 did not answer. Check that it is switched on and on this network.") + .arg(pairSheet.hostName); + case "declined": + return qsTr("%1 turned the request down. Check that pairing is allowed on the host.") + .arg(pairSheet.hostName); + case "crypto": + return qsTr("Dish could not prepare its own identity for pairing. Try again."); + default: + return qsTr("Check that the code went into the right host, then try again."); + } + } + + function trustText(token) { + switch (token) { + case "paired": return qsTr("Paired"); + case "remembered": return qsTr("Remembered"); + default: return qsTr("Not paired"); + } + } + // Amber is the PROBLEM colour, never the working one, so a remembered + // pairing that simply has not been re-confirmed reads neutral. + function trustTone(token) { + switch (token) { + case "paired": return Kit.CapabilityChip.Ok; + case "remembered": return Kit.CapabilityChip.Neutral; + default: return Kit.CapabilityChip.Absent; + } + } + function controllerPhrase(n) { + return qsTr("%n controllers", "", n); + } + function phaseText(token) { + switch (token) { + case "pairing": return qsTr("Pairing…"); + case "paired": return qsTr("Paired"); + case "launching": return qsTr("Starting…"); + case "connecting": return qsTr("Connecting…"); + case "streaming": return qsTr("Streaming"); + case "faltering": return qsTr("Unsteady"); + case "failed": return qsTr("Failed"); + case "closed": return qsTr("Disconnected"); + default: return qsTr("Found"); + } + } + function phaseTone(token) { + switch (token) { + case "streaming": return Kit.CapabilityChip.Ok; + case "paired": return Kit.CapabilityChip.Present; + // Working, not wrong: a handshake in flight is not an amber state. + case "pairing": + case "launching": + case "connecting": return Kit.CapabilityChip.Neutral; + case "faltering": + case "failed": return Kit.CapabilityChip.Warn; + default: return Kit.CapabilityChip.Neutral; + } + } +} diff --git a/src/qml/shared/BindingDraft.qml b/src/qml/shared/BindingDraft.qml index 3be5fb9..f147412 100644 --- a/src/qml/shared/BindingDraft.qml +++ b/src/qml/shared/BindingDraft.qml @@ -19,13 +19,23 @@ QtObject { property string slotId: "" property string hostId: "" - property string hostKind: "satellite" // "satellite" | "bluetooth" - property int type: -1 // -1 = unresolved; never guessed + // "satellite" | "bluetooth" | "moonlight" + property string hostKind: "satellite" + // -1 = unresolved; never guessed. 0xFF is a real answer for a Moonlight + // binding: it is the Auto sentinel, resolved against the pad before the + // wire, and deliberately not 0 (which the wire reads as "host, you pick"). + property int type: -1 property string desiredPath: "standard" // "standard" | "direct" — never "auto" property bool motionOn: true property bool rumbleOn: true property int touchpadMode: 0 // 0 off · 1 pad · 2 mouse + // The app this binding will start, or the one it will join. PER SESSION, + // not per binding: only the binding that creates a session picks one, and + // every later binding on the same host inherits whatever is running. + property string appId: "" + property string appName: "" + // The solver vends tokens only, but every failure line names something. property string padName: "" property string hostName: "" @@ -45,6 +55,10 @@ QtObject { // A Bluetooth destination is the system gamepad layer: there is no // catalog and so no type to resolve. readonly property bool hostIsBluetooth: draft.hostKind === "bluetooth" + // A Moonlight destination has a type, but no catalog to read it from: the + // four types are protocol constants, so the draft is answered the moment + // one is picked and Auto counts as picked. + readonly property bool hostIsMoonlight: draft.hostKind === "moonlight" readonly property bool hasType: draft.hostIsBluetooth || draft.type >= 0 readonly property bool complete: draft.hasInput && draft.hasDestination && draft.hasType @@ -144,6 +158,9 @@ QtObject { if (row.verdict === "pending") { if (draft.hostId.length === 0) return qsTr("Waiting on a destination."); + // A Moonlight binding never waits: its type table is a protocol + // constant, so nothing here is ever pending on a fetch. + if (draft.catalogFailed) return qsTr("Couldn’t read the catalog from %1 — retry to resolve it.") .arg(draft.hostName); @@ -165,11 +182,17 @@ QtObject { case "link": return qsTr("Direct mode can’t drive it — switch the connection to Standard."); case "type": + if (draft.hostIsMoonlight) + return qsTr("A %1 controller does not carry %2 over Moonlight.") + .arg(draft.typeName).arg(draft.featureNoun(row.feature)); return qsTr("%1 doesn’t carry %2.").arg(draft.typeName) .arg(draft.featureNoun(row.feature)); case "host": if (draft.hostIsBluetooth) return qsTr("A Bluetooth host has no channel for it. Bind to a Satellite host."); + // A Moonlight host never refuses at the host layer: no host reports + // what it carries, so this branch cannot be reached for one. + if (row.feature === "mouse") return qsTr("%1 doesn’t advertise mouse control.").arg(draft.hostName); return qsTr("%1 doesn’t advertise %2.").arg(draft.hostName) @@ -216,6 +239,18 @@ QtObject { // until the new catalog resolves it. draft.type = -1; draft.typeName = ""; + // The app belongs to the destination, so it does not survive one. + draft.appId = ""; + draft.appName = ""; + draft.sanitize(); + } + + // The app the session will run. Only meaningful while this binding is the + // one that would CREATE the session; a binding that joins one shows what is + // already running and is never offered a picker. + function chooseApp(id, name) { + draft.appId = id; + draft.appName = name; draft.sanitize(); } diff --git a/src/qml/wizard/SetupWizardPage.qml b/src/qml/wizard/SetupWizardPage.qml index 09f4be6..014ba1f 100644 --- a/src/qml/wizard/SetupWizardPage.qml +++ b/src/qml/wizard/SetupWizardPage.qml @@ -52,16 +52,23 @@ Kit.Page { readonly property var shellApi: wizard.shellStack ? wizard.shellStack.shellApi : null // ── Step state ────────────────────────────────────────────────────────── - // 0 Input · 1 Destination · 2 Type · 3 Feel · 4 Review. + // 0 Input · 1 Destination · 2 Type · 3 Session · 4 Feel · 5 Review. + // + // Step 3 exists only for a Moonlight destination, where what the host runs + // is a distinct question from how the pad feels and is the only step that + // can fail. It is instantiated either way (every step is a sibling gated on + // `visible`) and simply skipped, so nothing downstream has to know. property int step: 0 - readonly property int lastStep: 4 + readonly property int lastStep: 5 + readonly property bool hasSessionStep: wizard.draft.hostKind === "moonlight" // Latched on a successful bind so the header dot and the blockers know the // wire is real before the pop lands. property bool applied: false readonly property bool applying: App.applyInFlight // Stage 1 Input, 2 Destination, 3 Binding — three pages live in stage 3, - // which is why it also carries a sub-step indicator. + // four for a Moonlight binding, which is why it also carries a sub-step + // indicator. Kit.WizardBanner already renders a variable sub-step count. readonly property int stage: wizard.step === 0 ? 1 : wizard.step === 1 ? 2 : 3 readonly property int subStep: wizard.step >= 2 ? wizard.step - 2 : 0 @@ -72,10 +79,22 @@ Kit.Page { return n === 0 ? inputPage : n === 1 ? destinationPage : n === 2 ? typePage - : n === 3 ? feelPage + : n === 3 ? sessionPage + : n === 4 ? feelPage : reviewPage; } + // The next/previous step the CURRENT draft actually has, so a satellite + // binding never lands on the session page and never has to step past it. + function stepAfter(n) { + const next = n + 1; + return (next === 3 && !wizard.hasSessionStep) ? 4 : next; + } + function stepBefore(n) { + const prev = n - 1; + return (prev === 3 && !wizard.hasSessionStep) ? 2 : prev; + } + readonly property var activePage: wizard.pageForStep(wizard.step) // What the adopted pad is, republished by page 1 so the banner and the @@ -118,7 +137,8 @@ Kit.Page { wizard.step === 0 ? qsTr("Step 1 of 3 · Input") : wizard.step === 1 ? qsTr("Step 2 of 3 · Destination") : wizard.step === 2 ? qsTr("Step 3 of 3 · Type") - : wizard.step === 3 ? qsTr("Step 3 of 3 · Feel") + : wizard.step === 3 ? qsTr("Step 3 of 3 · Session") + : wizard.step === 4 ? qsTr("Step 3 of 3 · Feel") : qsTr("Step 3 of 3 · Review") readonly property string hintText: { @@ -144,7 +164,7 @@ Kit.Page { parts.push(wizard.draft.desiredPath === "direct" ? qsTr("Direct") : qsTr("Standard")); // The rate joins only from Review onward, where the banner is the // review and the numbers are the point. - if (wizard.step >= 4) { + if (wizard.step >= wizard.lastStep) { const rate = rateFormat.rateText(wizard.padInfo.hz, wizard.padInfo.hzLive); if (rate.length > 0) parts.push(rate); @@ -157,9 +177,13 @@ Kit.Page { function hostSubText() { const free = wizard.accounting >= 0 ? App.hostSlotCapacity() - App.hostBoundSlotCount(wizard.draft.hostId) : 0; + // A Moonlight session carries the same four, and calling it a satellite + // would be the one word on this screen that is simply untrue. + const kind = wizard.draft.hostKind === "moonlight" ? qsTr("moonlight") + : qsTr("satellite"); if (free <= 0) - return qsTr("satellite · 0 slots free"); - return qsTr("satellite · %n slots free", "", free); + return qsTr("%1 · 0 slots free").arg(kind); + return qsTr("%1 · %2").arg(kind).arg(qsTr("%n slots free", "", free)); } readonly property var padSlot: !wizard.draft.hasInput @@ -190,7 +214,7 @@ Kit.Page { return qsTr("—"); if (!wizard.draft.hasType || wizard.draft.typeName.length === 0) return qsTr("as —"); - if (wizard.step >= 4 && reviewPage.extrasSummary.length > 0) + if (wizard.step >= wizard.lastStep && reviewPage.extrasSummary.length > 0) return qsTr("as %1 · %2").arg(wizard.draft.typeName).arg(reviewPage.extrasSummary); return qsTr("as %1").arg(wizard.draft.typeName); } @@ -199,7 +223,7 @@ Kit.Page { function goBack() { if (wizard.step > 0 && !wizard.applying) - wizard.step -= 1; + wizard.step = wizard.stepBefore(wizard.step); } function primaryPressed() { @@ -211,7 +235,7 @@ Kit.Page { if (page.primaryActivated() === false) return; if (wizard.step < wizard.lastStep) - wizard.step += 1; + wizard.step = wizard.stepAfter(wizard.step); } // Completed markers jump back. Back is non-destructive, so this is safe. @@ -410,6 +434,7 @@ Kit.Page { transmitting: wizard.applying stage: wizard.stage subStep: wizard.subStep + subStepCount: wizard.hasSessionStep ? 4 : 3 // Below four-fifths of the minimum window the banner drops its slot // sub-lines and marker labels rather than eating the body. compact: root.height < Tokens.minWindowHeight * 0.8 @@ -465,10 +490,19 @@ Kit.Page { height: stepHost.height } + WizardSessionPage { + id: sessionPage + draft: wizard.draft + shellApi: wizard.shellApi + visible: wizard.step === 3 + width: stepHost.width + height: stepHost.height + } + WizardFeelPage { id: feelPage draft: wizard.draft - visible: wizard.step === 3 + visible: wizard.step === 4 width: stepHost.width height: stepHost.height } @@ -476,7 +510,7 @@ Kit.Page { WizardReviewPage { id: reviewPage draft: wizard.draft - visible: wizard.step === 4 + visible: wizard.step === 5 width: stepHost.width height: stepHost.height } diff --git a/src/qml/wizard/WizardDestinationPage.qml b/src/qml/wizard/WizardDestinationPage.qml index 9e7b72e..530414d 100644 --- a/src/qml/wizard/WizardDestinationPage.qml +++ b/src/qml/wizard/WizardDestinationPage.qml @@ -46,10 +46,18 @@ ColumnLayout { function activated() { if (!App.scanning) App.startDiscovery(); + // The Moonlight sweep is a separate one-shot, and trust is re-asked on + // entering rather than watched: a Moonlight host reports no liveness. + if (!App.moonlightScanning) + App.scanMoonlight(); + for (let i = 0; i < page.moonlightRows.length; ++i) + App.probeMoonlightHost(page.moonlightRows[i].uuid); } // ── Page state ────────────────────────────────────────────────────────── + readonly property var moonlightRows: App.moonlightHosts readonly property int hostCount: App.connectionModel.count + App.discoveredServers.length + + page.moonlightRows.length property bool selectedNeedsPairing: false // The host THIS page asked the user to pair. Compared on pairingSucceeded. property string pendingHostId: "" @@ -118,6 +126,41 @@ ColumnLayout { page.draft.chooseDestination(id, name, "satellite"); } + // A Moonlight host is picked whatever its trust: pairing is remembered + // trust verified lazily, so an unpaired host is a state the session step + // renders and offers to fix, never a reason to refuse the destination. + function pickMoonlight(id, name) { + page.selectedNeedsPairing = false; + page.pendingHostId = ""; + page.draft.chooseDestination(id, name, "moonlight"); + } + + function moonlightSubText(row) { + const parts = []; + if (row.address.length > 0) + parts.push(row.address); + // A Moonlight session refuses a fifth pad rather than pushing one off, + // so the zero case says what actually happens. + const free = App.hostSlotCapacity() - App.hostBoundSlotCount(row.uuid); + parts.push(free > 0 ? qsTr("%n slots free", "", free) : qsTr("full")); + return parts.join(" · "); + } + + function trustText(token) { + switch (token) { + case "paired": return qsTr("Paired"); + case "remembered": return qsTr("Remembered"); + default: return qsTr("Not paired"); + } + } + function trustTone(token) { + switch (token) { + case "paired": return Kit.CapabilityChip.Ok; + case "remembered": return Kit.CapabilityChip.Neutral; + default: return Kit.CapabilityChip.Absent; + } + } + Connections { target: App @@ -246,13 +289,68 @@ ColumnLayout { } } + // ── Moonlight hosts ───────────────────────────────────────────────────── + // Its own section, not more rows above: the two host kinds pair + // differently, and one merged column would make the trust word and the + // Pair verb each mean two things. + RowLayout { + visible: page.moonlightRows.length > 0 + spacing: Tokens.s5 + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + + Kit.Eyebrow { + mutedTone: true + text: qsTr("Moonlight hosts") + Layout.fillWidth: true + } + Label { + text: qsTr("%n found", "", page.moonlightRows.length) + color: Theme.mutedStrong + font.family: Tokens.monoFamily + font.pixelSize: Tokens.textChip + + Accessible.role: Accessible.StaticText + Accessible.name: text + } + } + + Repeater { + model: page.moonlightRows + + delegate: Kit.SelectRow { + id: moonRow + + required property var modelData + + Layout.fillWidth: true + selected: page.draft.hostId === moonRow.modelData.uuid + title: moonRow.modelData.name + subtitle: qsTr("Moonlight host · %1").arg(page.moonlightSubText(moonRow.modelData)) + chipText: page.trustText(moonRow.modelData.trust) + chipTone: page.trustTone(moonRow.modelData.trust) + + onPicked: page.pickMoonlight(moonRow.modelData.uuid, moonRow.modelData.name) + } + } + + Label { + visible: page.moonlightRows.length === 0 && page.hostCount > 0 + text: qsTr("A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address.") + color: Theme.mutedStrong + font.pixelSize: Tokens.textMeta + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.topMargin: Tokens.s2 + } + // Empty is a STATE, with the next step in it — never a bare spinner and // never a bare "none found". Kit.EmptyState { visible: page.hostCount === 0 glyph: "satellite-off" title: qsTr("No PCs found yet") - body: qsTr("A PC shows up here once the free Satellite app is running on it and both machines are on the same network.") + body: qsTr("A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network.") Layout.fillWidth: true Layout.topMargin: Tokens.s8 } diff --git a/src/qml/wizard/WizardReviewPage.qml b/src/qml/wizard/WizardReviewPage.qml index b14d3fa..6a3944d 100644 --- a/src/qml/wizard/WizardReviewPage.qml +++ b/src/qml/wizard/WizardReviewPage.qml @@ -47,7 +47,9 @@ ColumnLayout { // binding dependency. property int accounting: 0 - readonly property string displacedPad: page.accounting >= 0 + // A Moonlight host REFUSES a fifth controller rather than displacing one, so + // there is never a pad to name for it and saying otherwise would be a lie. + readonly property string displacedPad: page.accounting >= 0 && !page.draft.hostIsMoonlight ? App.displacedSlotName(page.draft.hostId) : "" readonly property var sendChips: page.buildChips(["gamepad", "motion", "touchpad", "mouse"]) diff --git a/src/qml/wizard/WizardSessionPage.qml b/src/qml/wizard/WizardSessionPage.qml new file mode 100644 index 0000000..8e0f937 --- /dev/null +++ b/src/qml/wizard/WizardSessionPage.qml @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Wizard page 4 — Binding · session. Shown only for a Moonlight destination, +// where a binding is not just a wire but a place in somebody's session: the +// first controller on a host decides what runs, every later one joins it. +// +// This page renders exactly ONE of the twenty-one states the C++ derives, and +// its `canAdvance` is ALWAYS true. A binding is a durable intent: pairing is +// remembered trust verified lazily, so the session is attempted when the +// controller is used and never when the binding is saved. Nothing about the +// host may stop the user from saving what they asked for. The single exception +// is a host already carrying four controllers, which is a hard protocol limit +// and says so in its own words. +// +// The app picker appears for the binding that CREATES the session and for no +// other. A binding that joins one shows what is running; it is never offered a +// disabled picker, because a disabled picker implies a choice that does not +// exist. + +// Bound: the app-row delegate reads the outer `page` id alongside its modelData. +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import Dish.Chrome +import "../kit" as Kit + +ColumnLayout { + id: page + + property BindingDraft draft + // Handed down by the container (the wizard, or Configure binding): a + // StackView attached property does not reach a page nested inside the + // step host, and the one state that blocks needs a way off this screen. + property var shellApi: null + + // ── The wizard's step contract ────────────────────────────────────────── + // Never blocked, except by the four-controller ceiling. + readonly property bool canAdvance: !page.blocked + readonly property string primaryLabel: qsTr("Continue ›") + readonly property string hint: page.blocked + ? qsTr("Unbind a controller on %1 to make room.").arg(page.hostName) + : "" + + function primaryActivated() { + return true; + } + + function activated() { + page.refresh(); + if (!page.draft.hasDestination || !page.draft.hostIsMoonlight) + return; + // Re-verify on entering: trust is remembered, not watched, and the app + // list is only readable once the host answers. + App.probeMoonlightHost(page.draft.hostId); + App.refreshMoonlightApps(page.draft.hostId); + } + + // ── State ─────────────────────────────────────────────────────────────── + // A call is not a binding dependency, so the map is republished on every + // Moonlight move and read as a plain property below. + property var session: ({}) + property var appRows: [] + + readonly property string phase: page.session.state !== undefined ? page.session.state : "" + readonly property bool blocked: page.session.blocksApply === true + readonly property string hostName: page.session.hostName !== undefined + && page.session.hostName.length > 0 + ? page.session.hostName : page.draft.hostName + readonly property string appName: page.session.appName !== undefined + ? page.session.appName : "" + readonly property int controllerNumber: page.session.controllerNumber !== undefined + ? page.session.controllerNumber : 0 + // Verbatim from the host, because a host refuses for reasons of its own and + // phrases them itself. + readonly property string refusal: page.session.refusal !== undefined + ? page.session.refusal : "" + // Which refusal PairingRefused was. One state, several next steps. + readonly property string pairingReason: page.session.pairingReason !== undefined + ? page.session.pairingReason : "" + + function refresh() { + if (!page.draft.hasDestination || !page.draft.hostIsMoonlight) { + page.session = ({}); + page.appRows = []; + return; + } + page.session = App.moonlightSession(page.draft.hostId, page.draft.slotId); + page.appRows = App.moonlightApps(page.draft.hostId); + } + + spacing: Tokens.s6 + + Connections { + target: App + function onMoonlightChanged() { page.refresh(); } + } + + // ── Actions ───────────────────────────────────────────────────────────── + function pairNow() { + App.pairMoonlight(page.draft.hostId); + } + function cancelPairing() { + App.cancelMoonlightPairing(); + } + function retry() { + App.probeMoonlightHost(page.draft.hostId); + App.refreshMoonlightApps(page.draft.hostId); + } + // /cancel answers 200 whether or not anything was running, so success + // proves nothing: the C++ re-probes and this section re-renders. + function quitApp() { + App.quitMoonlightApp(page.draft.hostId); + } + function startSession() { + if (page.draft.hasInput) + App.bindMoonlight(page.draft.slotId, page.draft.hostId); + page.retry(); + } + function pickApp(id, title) { + page.draft.chooseApp(id, title); + App.setMoonlightApp(page.draft.hostId, id, title); + } + // The one state with nowhere to go inside this flow: the room has to be + // made on the Controllers board, so send the user there. + function seeBindings() { + const api = page.shellApi; + if (api) + api.requestNavigation(function () { api.selectDestination(1); }); + } + + // ── Head ──────────────────────────────────────────────────────────────── + Kit.Eyebrow { + mutedTone: true + text: qsTr("Session") + } + + Label { + // The banner and the empty state carry their own title, so the heading + // stands down rather than saying the same sentence twice on one screen. + visible: !page.selfTitled() + text: page.headingText() + color: Theme.onSurface + font.pixelSize: Tokens.textStatus + font.bold: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Label { + visible: page.bodyText().length > 0 + text: page.bodyText() + color: Theme.muted + font.pixelSize: Tokens.textSummary + lineHeight: 1.5 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // ── Working states: primary, never amber ──────────────────────────────── + Kit.LoadingSpinner { + visible: page.phase === "checking" || page.phase === "appsLoading" + running: visible + text: page.phase === "checking" + ? qsTr("Checking %1…").arg(page.hostName) + : qsTr("Reading the app list from %1…").arg(page.hostName) + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + } + + // ── Live ──────────────────────────────────────────────────────────────── + RowLayout { + visible: page.phase === "live" + Layout.fillWidth: true + spacing: Tokens.s4 + + Kit.LiveStat { + live: true + text: page.liveBody() + Layout.fillWidth: true + } + Kit.CapabilityChip { + text: qsTr("Streaming") + tone: Kit.CapabilityChip.Ok + } + } + + // ── Failures that are the host's own words ────────────────────────────── + Kit.ErrorBanner { + visible: page.phase === "appsFailed" || page.phase === "refused" + || page.phase === "setupFailed" + Layout.fillWidth: true + tone: page.phase === "appsFailed" ? Kit.ErrorBanner.Warning : Kit.ErrorBanner.Error + text: page.phase === "appsFailed" + ? qsTr("Could not read the app list from %1").arg(page.hostName) + : page.phase === "setupFailed" + ? qsTr("Could not finish the session on %1").arg(page.hostName) + : page.refusal.length > 0 + ? qsTr("%1 refused the session: %2").arg(page.hostName).arg(page.refusal) + : qsTr("%1 refused the session").arg(page.hostName) + detail: page.phase === "appsFailed" + ? qsTr("Dish will start whatever the host lists first. Retry once %1 is reachable.") + .arg(page.hostName) + : page.phase === "setupFailed" + ? qsTr("The app started but the stream did not come up, so Dish closed it again.") + : qsTr("Add the controller anyway and Dish will try again the next time you use it.") + showRetry: true + onRetryRequested: page.retry() + } + + // ── Empty: a state with the next step in it ───────────────────────────── + Kit.EmptyState { + visible: page.phase === "noApps" + Layout.fillWidth: true + Layout.topMargin: Tokens.s5 + glyph: "dish-off" + title: qsTr("No apps on this host") + body: qsTr("%1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first.") + .arg(page.hostName) + actionText: qsTr("Retry") + showAction: true + onActionRequested: page.retry() + } + + // ── Everything with an action attached ────────────────────────────────── + // Never modal: a flapping session would raise a dialog the user cannot + // outrun, and every one of these states still allows Apply. + Kit.Callout { + visible: page.calloutVisible() + Layout.fillWidth: true + tone: page.calloutTone() + text: page.calloutText() + + Kit.DishButton { + visible: page.phase === "notPaired" + text: qsTr("Pair now") + variant: Kit.DishButton.Primary + size: Kit.DishButton.Small + onClicked: page.pairNow() + } + Kit.DishButton { + visible: page.phase === "trustLost" || page.phase === "hostReplaced" + text: qsTr("Pair again") + variant: Kit.DishButton.Primary + size: Kit.DishButton.Small + onClicked: page.pairNow() + } + Kit.DishButton { + visible: page.phase === "pairingRefused" + text: qsTr("Try again") + variant: Kit.DishButton.Primary + size: Kit.DishButton.Small + onClicked: page.pairNow() + } + Kit.DishButton { + visible: page.phase === "pairingPin" + text: qsTr("New code") + variant: Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: page.pairNow() + } + Kit.DishButton { + visible: page.phase === "pairingPin" + text: qsTr("Cancel") + variant: Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: page.cancelPairing() + } + // The only destructive action in this flow, and it names the host it + // will close an app on. + Kit.DishButton { + visible: page.phase === "busyOther" || page.phase === "resumeFailed" + || page.phase === "live" + text: qsTr("Close the app on %1").arg(page.hostName) + variant: Kit.DishButton.Destructive + size: Kit.DishButton.Small + onClicked: page.quitApp() + } + Kit.DishButton { + visible: page.phase === "unreachable" || page.phase === "remembered" + || page.phase === "busyOther" || page.phase === "resumeFailed" + text: qsTr("Retry") + variant: Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: page.retry() + } + Kit.DishButton { + visible: page.phase === "dropped" + text: qsTr("Reconnect") + variant: Kit.DishButton.Primary + size: Kit.DishButton.Small + onClicked: page.startSession() + } + Kit.DishButton { + visible: page.phase === "endedByHost" + text: qsTr("Start a session") + variant: Kit.DishButton.Primary + size: Kit.DishButton.Small + onClicked: page.startSession() + } + // The only state that blocks Apply, so the way out is the only action. + Kit.DishButton { + visible: page.phase === "hostFull" + text: qsTr("See controllers on %1").arg(page.hostName) + variant: Kit.DishButton.Outline + size: Kit.DishButton.Small + onClicked: page.seeBindings() + } + } + + // ── We create the session: one row per app ────────────────────────────── + Repeater { + model: page.phase === "newSession" ? page.appRows : [] + + delegate: Kit.SelectRow { + id: appRow + required property var modelData + + Layout.fillWidth: true + selected: page.draft.appId === appRow.modelData.id + title: appRow.modelData.title.length > 0 ? appRow.modelData.title + : appRow.modelData.id + + onPicked: page.pickApp(appRow.modelData.id, appRow.modelData.title) + } + } + + Label { + visible: page.phase === "newSession" && page.draft.appId.length === 0 + text: qsTr("Without a pick, Dish starts whatever %1 lists first.").arg(page.hostName) + color: Theme.mutedStrong + font.pixelSize: Tokens.textMeta + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Item { + Layout.fillHeight: true + Layout.minimumHeight: Tokens.s5 + } + + // ── Copy: one English string per state, and the C++ names the state ───── + + // States whose own component already draws the sentence, so the heading + // stands down rather than printing it a second time above. + function selfTitled() { + return page.phase === "noApps" || page.phase === "appsFailed" + || page.phase === "refused" || page.phase === "setupFailed" + || page.phase === "checking" || page.phase === "appsLoading"; + } + + function headingText() { + switch (page.phase) { + case "checking": return qsTr("Checking %1…").arg(page.hostName); + case "notPaired": return qsTr("Not paired yet"); + case "pairingPin": return qsTr("Pair with %1").arg(page.hostName); + case "pairingRefused": return qsTr("%1 did not accept the PIN").arg(page.hostName); + case "unreachable": + case "remembered": return qsTr("%1 is not answering").arg(page.hostName); + case "trustLost": return qsTr("%1 no longer recognises this device") + .arg(page.hostName); + case "hostReplaced": return qsTr("%1 was reset").arg(page.hostName); + case "appsLoading": return qsTr("Reading the app list from %1…").arg(page.hostName); + case "newSession": return qsTr("New session"); + case "noApps": return qsTr("No apps on this host"); + case "appsFailed": return qsTr("Could not read the app list from %1") + .arg(page.hostName); + case "joining": return page.appName.length > 0 + ? qsTr("Joining %1").arg(page.appName) + : qsTr("Joining the session on %1").arg(page.hostName); + case "hostFull": return qsTr("%1 is full").arg(page.hostName); + case "busyOther": return qsTr("Another device is using %1").arg(page.hostName); + case "resumeFailed": return qsTr("Could not rejoin the session on %1").arg(page.hostName); + case "refused": return page.refusal.length > 0 + ? qsTr("%1 refused the session: %2") + .arg(page.hostName).arg(page.refusal) + : qsTr("%1 refused the session").arg(page.hostName); + case "setupFailed": return qsTr("Could not finish the session on %1").arg(page.hostName); + case "live": return qsTr("Streaming to %1").arg(page.hostName); + case "dropped": return qsTr("Session on %1 ended").arg(page.hostName); + case "endedByHost": return qsTr("%1 ended the session").arg(page.hostName); + } + return qsTr("Session"); + } + + function bodyText() { + switch (page.phase) { + case "notPaired": + return qsTr("%1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later.") + .arg(page.hostName); + case "pairingPin": + return qsTr("Type %1 into the Moonlight or Sunshine page on %2.") + .arg(App.moonlightPairingPin).arg(page.hostName); + case "pairingRefused": + return page.pairFailedText(); + case "unreachable": + return qsTr("Check that the host is switched on and on this network, then try again."); + case "remembered": + return qsTr("Dish remembers the pairing with %1 and will start a session when the host is back.") + .arg(page.hostName); + case "trustLost": + return qsTr("The host removed the pairing. Pair again to start a session."); + case "hostReplaced": + return qsTr("This host has a new identity, so the old pairing no longer works. Pair again to start a session."); + case "newSession": + return qsTr("This is the first controller on %1, so it picks what the host runs.") + .arg(page.hostName); + case "joining": + return qsTr("%1 is already running a session for this device. This controller joins it as controller %2.") + .arg(page.hostName).arg(page.controllerNumber); + case "hostFull": + return qsTr("A session carries four controllers at most, and %1 already has four. Unbind one to make room.") + .arg(page.hostName); + case "busyOther": + return qsTr("%1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later.") + .arg(page.hostName); + case "resumeFailed": + return qsTr("The host has a session but would not hand it back. Close the app on %1 and start a new one.") + .arg(page.hostName); + case "dropped": + return qsTr("The link dropped. Dish will rejoin the next time you use this controller."); + case "endedByHost": + return qsTr("The app closed on the host. Start a new session to keep using this controller."); + case "live": + return page.liveBody(); + } + return ""; + } + + // The same ladder the host screen shows, off the same token: an attempt + // that never reached the wire must not be reported as a mistyped PIN. + function pairFailedText() { + switch (page.pairingReason) { + case "unreachable": + return qsTr("%1 did not answer. Check that it is switched on and on this network.") + .arg(page.hostName); + case "declined": + return qsTr("%1 turned the request down. Check that pairing is allowed on the host.") + .arg(page.hostName); + case "crypto": + return qsTr("Dish could not prepare its own identity for pairing. Try again."); + default: + return qsTr("Check that the code went into the right host, then try again."); + } + } + + function liveBody() { + return qsTr("%1 · controller %2 of 4") + .arg(page.appName.length > 0 ? page.appName : page.hostName) + .arg(page.controllerNumber); + } + + function calloutVisible() { + switch (page.phase) { + case "notPaired": + case "pairingPin": + case "pairingRefused": + case "unreachable": + case "remembered": + case "trustLost": + case "hostReplaced": + case "hostFull": + case "busyOther": + case "resumeFailed": + case "dropped": + case "endedByHost": + case "live": + return true; + } + return false; + } + + // Amber is the PROBLEM colour, never the working one: a PIN on screen is + // information, and a live session is not a warning. + function calloutTone() { + switch (page.phase) { + case "notPaired": + case "pairingPin": + case "live": + return Kit.Callout.Info; + case "hostFull": + case "hostReplaced": + case "trustLost": + return Kit.Callout.Error; + } + return Kit.Callout.Warning; + } + + function calloutText() { + if (page.phase === "live") + return qsTr("Unbinding the last controller ends this session."); + if (page.phase === "pairingPin") + return qsTr("Waiting for the host to accept the PIN…"); + // The one state that is genuinely a dead end until something changes; + // every other one still lets the binding be saved. + if (page.phase === "hostFull") + return qsTr("This is the only Moonlight state that stops you adding the controller."); + // Every other state already said its piece in the body above; the + // callout carries the actions, and repeating the sentence inside it + // would say the same thing twice on one screen. + return qsTr("You can add the controller now and settle this later."); + } +} diff --git a/src/qml/wizard/WizardTypePage.qml b/src/qml/wizard/WizardTypePage.qml index d31cf28..0007629 100644 --- a/src/qml/wizard/WizardTypePage.qml +++ b/src/qml/wizard/WizardTypePage.qml @@ -5,6 +5,18 @@ // one capability table so the types are actually comparable. A row reads Pending // whenever the host or its catalog is unresolved — a cross is never drawn from a // catalog we could not read, and a type is never guessed. +// +// A MOONLIGHT destination has no catalog to read. No host reports what its +// emulated devices carry (there is no field for it anywhere in the protocol), +// so the four types are protocol constants and their capability rows come from +// the hard-coded table in core/moonlight/MoonlightPadSlots.h. Nothing here may +// promise the host will honour the pick: a host may override it, and it never +// tells us that it did. +// +// Auto resolves on the CLIENT, before the wire: a pad with gyro or an +// accelerometer becomes PlayStation, everything else Xbox. That 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. // Bound: the card delegate reads the outer `page` id alongside its modelData. pragma ComponentBehavior: Bound @@ -23,15 +35,21 @@ ColumnLayout { // ── The wizard's step contract ────────────────────────────────────────── readonly property bool canAdvance: page.draft.hasType && page.types.length > 0 readonly property string primaryLabel: qsTr("Continue ›") - readonly property string hint: page.draft.hostName.length > 0 - ? qsTr("Types offered by %1’s catalog.").arg(page.draft.hostName) - : "" + readonly property string hint: page.moonlight + ? qsTr("Some hosts override the choice.") + : page.draft.hostName.length > 0 + ? qsTr("Types offered by %1’s catalog.").arg(page.draft.hostName) + : "" function primaryActivated() { return true; } function activated() { + if (page.moonlight) { + page.reload(); + return; + } // Keyed on the DESTINATION, never on the pad: the pad has no binding // yet, and the slot-keyed read resolves through hub_->bindings(). if (page.draft.hasDestination) @@ -44,17 +62,43 @@ ColumnLayout { // The host's own pick for this pad — the pre-selection and the Best fit badge. property int bestFitType: -1 - readonly property bool loadingOnly: App.emulateLoading && page.types.length === 0 + readonly property bool moonlight: page.draft.hostIsMoonlight + readonly property int autoType: App.moonlightAutoType + + // The four CONTROLLER_ARRIVAL types, in the order the picker offers them. + // The three brand names are NOT translated: they are the devices the host + // plugs in, and their names are the same in every language. + readonly property var moonlightTypes: [ + { "type": page.autoType, "name": qsTr("Auto") }, + { "type": 1, "name": "Xbox" }, + { "type": 2, "name": "PlayStation" }, + { "type": 3, "name": "Nintendo" } + ] + + // What Auto would send for THIS pad, named so the Auto card can say it. + readonly property int autoResolved: page.draft.hasInput + ? App.moonlightResolvedType(page.draft.slotId, page.autoType) : 1 + readonly property string autoResolvedName: page.autoResolved === 2 ? "PlayStation" : "Xbox" + + readonly property bool loadingOnly: !page.moonlight && App.emulateLoading + && page.types.length === 0 // A failure with a cache behind it is silent: the cached types resolve the // draft and the user has nothing to act on. - readonly property bool failedOnly: !App.emulateLoading && App.emulateError.length > 0 - && page.types.length === 0 + readonly property bool failedOnly: !page.moonlight && !App.emulateLoading + && App.emulateError.length > 0 && page.types.length === 0 function reload() { if (!page.draft.hasDestination) { page.types = []; return; } + if (page.moonlight) { + page.types = page.moonlightTypes; + page.bestFitType = -1; + if (page.draft.type < 0) + page.draft.chooseType(page.autoType, page.moonlightTypes[0].name); + return; + } page.types = App.emulateTypesForHost(page.draft.hostId); page.bestFitType = App.emulateCurrentTypeForHost(page.draft.hostId, page.draft.slotId); if (page.draft.type >= 0 || page.types.length === 0) @@ -121,7 +165,8 @@ ColumnLayout { // ── Head ──────────────────────────────────────────────────────────────── Label { - text: qsTr("How should the PC see it?") + text: page.moonlight ? qsTr("How should the host see it?") + : qsTr("How should the PC see it?") color: Theme.onSurface font.pixelSize: Tokens.textStatus font.bold: true @@ -129,7 +174,13 @@ ColumnLayout { Layout.fillWidth: true } Label { - text: qsTr("Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way.") + // Honest about the one thing the protocol cannot promise: the host + // builds its virtual pad from what we declare, and may override it + // without ever telling us. + text: page.moonlight + ? qsTr("Dish asks %1 to plug in this controller. Some hosts override the choice.") + .arg(page.draft.hostName) + : qsTr("Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way.") color: Theme.muted font.pixelSize: Tokens.textSummary lineHeight: 1.5 @@ -247,6 +298,22 @@ ColumnLayout { text: qsTr("Best fit") tone: Kit.CapabilityChip.Ok } + // No "Best fit" for a Moonlight host: it does not tell us + // what fits. Auto is the one card Dish itself decides. + Kit.CapabilityChip { + visible: page.moonlight && typeCard.modelData.type === page.autoType + text: qsTr("Picked for you") + tone: Kit.CapabilityChip.Ok + } + } + + Label { + visible: page.moonlight && typeCard.modelData.type === page.autoType + text: qsTr("Auto sends %1 for this controller.").arg(page.autoResolvedName) + color: Theme.mutedStrong + font.pixelSize: Tokens.textMeta + wrapMode: Text.WordWrap + Layout.fillWidth: true } Rectangle { diff --git a/src/repository/MoonlightHostRepository.cpp b/src/repository/MoonlightHostRepository.cpp new file mode 100644 index 0000000..d383c0a --- /dev/null +++ b/src/repository/MoonlightHostRepository.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "repository/MoonlightHostRepository.h" + +#include "repository/AppSettings.h" +#include "repository/SettingsKeys.h" + +#include +#include + +#include + +namespace dish::repository { + +QJsonObject MoonlightHost::toJson() const { + QJsonObject obj; + obj.insert(QStringLiteral("uuid"), uuid); + obj.insert(QStringLiteral("name"), name); + obj.insert(QStringLiteral("address"), address); + obj.insert(QStringLiteral("httpPort"), httpPort); + obj.insert(QStringLiteral("httpsPort"), httpsPort); + obj.insert(QStringLiteral("serverCertPem"), serverCertPem); + obj.insert(QStringLiteral("lastAppId"), lastAppId); + obj.insert(QStringLiteral("lastAppName"), lastAppName); + obj.insert(QStringLiteral("controllerType"), controllerType); + return obj; +} + +std::optional MoonlightHost::fromJson(const QJsonObject& obj) { + MoonlightHost host; + host.uuid = obj.value(QLatin1String("uuid")).toString(); + host.address = obj.value(QLatin1String("address")).toString(); + if (host.uuid.isEmpty() || host.address.isEmpty()) { return std::nullopt; } + host.name = obj.value(QLatin1String("name")).toString(); + host.httpPort = obj.value(QLatin1String("httpPort")).toInt(47989); + host.httpsPort = obj.value(QLatin1String("httpsPort")).toInt(47984); + host.serverCertPem = obj.value(QLatin1String("serverCertPem")).toString(); + host.lastAppId = obj.value(QLatin1String("lastAppId")).toString(); + host.lastAppName = obj.value(QLatin1String("lastAppName")).toString(); + // Migrated, not trusted: a record from before the sentinel converged holds + // 0 for Auto, and 0 on the wire is CONTROLLER_TYPE_UNKNOWN. + host.controllerType = moonlight::migrateControllerType( + obj.value(QLatin1String("controllerType")).toInt(kMoonlightControllerTypeAuto)); + return host; +} + +MoonlightHostRepository::MoonlightHostRepository(std::shared_ptr settings) + : settings_(settings ? std::move(settings) : repository::makeSettings()) {} + +QHash MoonlightHostRepository::load() const { + QHash hosts; + const auto raw = settings_->value(QLatin1String(keys::kMoonlightHostListKey)).toByteArray(); + if (raw.isEmpty()) { return hosts; } + const auto doc = QJsonDocument::fromJson(raw); + // A JSON object keyed by storage key; the array form is the legacy shape a + // pre-release build wrote, still read so an in-place upgrade keeps rows. + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { + if (auto host = MoonlightHost::fromJson(it.value().toObject())) { + // operator[], not insert: QHash::insert takes the value by + // const reference, so a std::move into it would copy. + hosts[it.key()] = std::move(*host); + } + } + } else if (doc.isArray()) { + for (const auto& entry : doc.array()) { + if (auto host = MoonlightHost::fromJson(entry.toObject())) { + // As above; the key is copied out first so that it cannot be + // read out of the host the same statement moves from. + const QString uuid = host->uuid; + hosts[uuid] = std::move(*host); + } + } + } + return hosts; +} + +void MoonlightHostRepository::store(const QHash& hosts) { + QJsonObject obj; + for (auto it = hosts.constBegin(); it != hosts.constEnd(); ++it) { + obj.insert(it.key(), it.value().toJson()); + } + settings_->setValue(QLatin1String(keys::kMoonlightHostListKey), + QJsonDocument(obj).toJson(QJsonDocument::Compact)); + settings_->sync(); +} + +void MoonlightHostRepository::upsert(const MoonlightHost& host) { + if (host.uuid.isEmpty()) { return; } + std::lock_guard lock(mutex_); + auto hosts = load(); + const auto it = hosts.constFind(host.uuid); + if (it == hosts.constEnd()) { + hosts.insert(host.uuid, host); + } else { + MoonlightHost merged = host; + // A re-discovery carries no pairing anchor or picks; keep the stored + // ones rather than wiping them. + if (merged.serverCertPem.isEmpty()) { merged.serverCertPem = it->serverCertPem; } + if (merged.lastAppId.isEmpty()) { + merged.lastAppId = it->lastAppId; + merged.lastAppName = it->lastAppName; + } + if (merged.name.isEmpty()) { merged.name = it->name; } + hosts.insert(host.uuid, merged); + } + store(hosts); +} + +std::optional MoonlightHostRepository::get(const QString& uuid) const { + std::lock_guard lock(mutex_); + const auto hosts = load(); + const auto it = hosts.constFind(uuid); + if (it == hosts.constEnd()) { return std::nullopt; } + return *it; +} + +std::vector MoonlightHostRepository::all() const { + std::lock_guard lock(mutex_); + const auto hosts = load(); + std::vector out; + out.reserve(static_cast(hosts.size())); + for (const auto& host : hosts) { out.push_back(host); } + return out; +} + +void MoonlightHostRepository::put(const QString& uuid, const MoonlightHost& host) { + std::lock_guard lock(mutex_); + auto hosts = load(); + hosts.insert(uuid, host); // storage key authoritative, value stored verbatim + store(hosts); +} + +void MoonlightHostRepository::remove(const QString& uuid) { + std::lock_guard lock(mutex_); + auto hosts = load(); + hosts.remove(uuid); + store(hosts); +} + +void MoonlightHostRepository::clear() { + std::lock_guard lock(mutex_); + settings_->remove(QLatin1String(keys::kMoonlightHostListKey)); + settings_->sync(); +} + +} // namespace dish::repository diff --git a/src/repository/MoonlightHostRepository.h b/src/repository/MoonlightHostRepository.h new file mode 100644 index 0000000..268777e --- /dev/null +++ b/src/repository/MoonlightHostRepository.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// MoonlightHostRepository — the remembered Moonlight hosts, one JSON array in +// the shared connection-store QSettings (mirroring RememberedSatelliteRepository +// for the satellite family). A host row persists its pairing anchor — the +// server certificate PEM the pairing handshake verified — so later TLS +// connects pin against it, plus the user's per-host picks (app, emulated +// controller type). + +#pragma once + +#include "architecture/Repository.h" +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightProtocol.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace dish::repository { + +struct MoonlightHost { + // The host's serverinfo uuid — the stable identity a DHCP move keeps. + QString uuid; + QString name; + QString address; // IP or hostname, as discovered or typed + int httpPort = 47989; + int httpsPort = 47984; + // The pairing anchor. Empty means discovered-but-never-paired. + QString serverCertPem; + // The user's last app pick; empty until the first launch. + QString lastAppId; + QString lastAppName; + // The host's last-used pick, kept so a fresh binding on this host starts + // where the previous one did. The BINDING owns the type it sends; this is + // the seed, not the authority. + // moonproto::kControllerType*, or kControllerTypeAuto for "match the pad". + int controllerType = moonproto::kControllerTypeAuto; + + bool paired() const { return !serverCertPem.isEmpty(); } + + QJsonObject toJson() const; + static std::optional fromJson(const QJsonObject& obj); + + bool operator==(const MoonlightHost& o) const { + return uuid == o.uuid && name == o.name && address == o.address && httpPort == o.httpPort && + httpsPort == o.httpsPort && serverCertPem == o.serverCertPem && + lastAppId == o.lastAppId && lastAppName == o.lastAppName && + controllerType == o.controllerType; + } + bool operator!=(const MoonlightHost& o) const { return !(*this == o); } +}; + +// "Match the pad" sentinel for MoonlightHost::controllerType. Not a wire value: +// the session resolves it against the bound pad before CONTROLLER_ARRIVAL. One +// value across all three Dish clients, and deliberately not 0 — 0 is the wire's +// CONTROLLER_TYPE_UNKNOWN, which asks the HOST to pick and is a different +// promise. A record written with 0 migrates on read. +inline constexpr int kMoonlightControllerTypeAuto = moonproto::kControllerTypeAuto; + +class MoonlightHostRepository : public arch::Repository { + public: + explicit MoonlightHostRepository(std::shared_ptr settings = nullptr); + + // Insert-or-update keyed on uuid, preserving pairing/pick fields the caller + // left empty (a re-discovery must not wipe the cert or the app choice). + void upsert(const MoonlightHost& host); + + std::optional get(const QString& uuid) const override; + std::vector all() const override; + void put(const QString& uuid, const MoonlightHost& host) override; + void remove(const QString& uuid) override; + void clear() override; + + private: + // Keyed by the storage key (the uuid), value stored verbatim — the + // storage key is authoritative, mirroring RememberedSatelliteRepository. + // Callers hold mutex_. + QHash load() const; + void store(const QHash& hosts); + + std::shared_ptr settings_; + mutable std::mutex mutex_; +}; + +} // namespace dish::repository diff --git a/src/repository/MoonlightIdentityRepository.cpp b/src/repository/MoonlightIdentityRepository.cpp new file mode 100644 index 0000000..df0c4f3 --- /dev/null +++ b/src/repository/MoonlightIdentityRepository.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "repository/MoonlightIdentityRepository.h" + +#include "Util/Hex.h" +#include "repository/AppSettings.h" +#include "repository/SettingsKeys.h" + +#include +#include + +namespace dish::repository { +namespace { + +std::optional generateUniqueId() { + std::array raw{}; + if (!mooncrypto::randomBytes(raw.data(), raw.size())) { return std::nullopt; } + return QString::fromStdString(util::toHex(raw.data(), raw.size())); +} + +} // namespace + +MoonlightIdentityRepository::MoonlightIdentityRepository(std::shared_ptr settings) + : settings_(settings ? std::move(settings) : repository::makeSettings()) {} + +std::optional MoonlightIdentityRepository::identity() const { + std::lock_guard lock(mutex_); + Identity id; + id.certPem = settings_->value(QLatin1String(keys::kMoonlightCertKey)).toString(); + id.privateKeyPem = settings_->value(QLatin1String(keys::kMoonlightKeyKey)).toString(); + id.uniqueId = settings_->value(QLatin1String(keys::kMoonlightUniqueIdKey)).toString(); + if (id.certPem.isEmpty() || id.privateKeyPem.isEmpty() || id.uniqueId.isEmpty()) { + return std::nullopt; + } + if (!mooncrypto::isValidCertPem(id.certPem.toStdString())) { return std::nullopt; } + return id; +} + +std::optional MoonlightIdentityRepository::ensureIdentity() { + if (auto existing = identity()) { return existing; } + + const auto generated = mooncrypto::generateClientIdentity(); + const auto uniqueId = generateUniqueId(); + if (!generated || !uniqueId) { return std::nullopt; } + + Identity id; + id.certPem = QString::fromStdString(generated->certPem); + id.privateKeyPem = QString::fromStdString(generated->privateKeyPem); + id.uniqueId = *uniqueId; + + std::lock_guard lock(mutex_); + settings_->setValue(QLatin1String(keys::kMoonlightCertKey), id.certPem); + settings_->setValue(QLatin1String(keys::kMoonlightKeyKey), id.privateKeyPem); + settings_->setValue(QLatin1String(keys::kMoonlightUniqueIdKey), id.uniqueId); + settings_->sync(); + return id; +} + +void MoonlightIdentityRepository::clear() { + std::lock_guard lock(mutex_); + settings_->remove(QLatin1String(keys::kMoonlightCertKey)); + settings_->remove(QLatin1String(keys::kMoonlightKeyKey)); + settings_->remove(QLatin1String(keys::kMoonlightUniqueIdKey)); +} + +} // namespace dish::repository diff --git a/src/repository/MoonlightIdentityRepository.h b/src/repository/MoonlightIdentityRepository.h new file mode 100644 index 0000000..4ef1f75 --- /dev/null +++ b/src/repository/MoonlightIdentityRepository.h @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// MoonlightIdentityRepository — the per-install Moonlight client identity. +// +// One self-signed RSA cert + private key (PEM) plus the GameStream `uniqueid`, +// co-tenant in the shared connection-store QSettings beside the satellite +// trust material. The cert authenticates every HTTPS call to every paired +// Moonlight host, so it is generated exactly once and never rotated silently: +// losing it means the user re-pairs each host. +// +// The private key is stored in the settings file rather than the keyring +// because a 2048-bit PEM exceeds what several keyring backends accept per +// item, and the file already holds material of the same sensitivity class +// (the satellite shared-key fallback path). + +#pragma once + +#include "core/moonlight/MoonlightPairingCrypto.h" + +#include +#include + +#include +#include +#include + +namespace dish::repository { + +class MoonlightIdentityRepository { + public: + // Production passes the shared connection-store QSettings; nullptr → the + // default store (tests pass a scratch file). + explicit MoonlightIdentityRepository(std::shared_ptr settings = nullptr); + + struct Identity { + QString certPem; + QString privateKeyPem; + QString uniqueId; // 16 lowercase hex chars + }; + + // The stored identity, or nullopt when none was ever generated (or the + // stored blob no longer parses as a certificate). + std::optional identity() const; + + // Returns the stored identity, generating and persisting one on first + // call. nullopt only when key generation itself fails. + std::optional ensureIdentity(); + + // Drops the identity. Every paired host then requires a fresh pairing. + void clear(); + + private: + std::shared_ptr settings_; + mutable std::mutex mutex_; +}; + +} // namespace dish::repository diff --git a/src/repository/SettingsKeys.h b/src/repository/SettingsKeys.h index 9a8bb29..f9edf67 100644 --- a/src/repository/SettingsKeys.h +++ b/src/repository/SettingsKeys.h @@ -34,6 +34,17 @@ inline constexpr const char* kMotionEnabledPrefix = "motion_enabled:"; // Stable per-install device id (machineId source for X-Device-Id). One owner. inline constexpr const char* kDeviceIdKey = "deviceId"; +// The Moonlight client identity: one self-signed cert + key pair per install, +// plus the uniqueid every GameStream HTTP call carries. Generated once; +// deleting them unpairs this install from every Moonlight host. +inline constexpr const char* kMoonlightCertKey = "moonlight_identity_cert"; +inline constexpr const char* kMoonlightKeyKey = "moonlight_identity_key"; +inline constexpr const char* kMoonlightUniqueIdKey = "moonlight_identity_uniqueid"; + +// The remembered-Moonlight-host list, one JSON array under a single key. +// Disjoint from kSatelliteListKey: the two connection families never mix rows. +inline constexpr const char* kMoonlightHostListKey = "moonlight_host_list"; + // Retired key names, kept only so old installs can be upgraded in place. inline constexpr const char* kLegacyWifiListKey = "wifi_list"; inline constexpr const char* kLegacySharedKeyPrefix = "wifi_shared_key/"; diff --git a/src/source/moonlight/MoonlightControlStream.cpp b/src/source/moonlight/MoonlightControlStream.cpp new file mode 100644 index 0000000..4691e71 --- /dev/null +++ b/src/source/moonlight/MoonlightControlStream.cpp @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightControlStream.h" + +#include + +#include +#include + +namespace dish::source::moon { +namespace { + +std::int64_t steadyNowMs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// One process-wide ENet runtime, alive from first use to exit. +void ensureEnetInitialized() { + static const bool initialized = [] { return enet_initialize() == 0; }(); + (void)initialized; +} + +constexpr std::int64_t kPingIntervalMs = 500; +constexpr std::int64_t kConnectTimeoutMs = 5000; +constexpr std::uint8_t kChannel = 0; +constexpr std::size_t kChannelCount = 1; + +} // namespace + +MoonlightControlStream::MoonlightControlStream() = default; + +MoonlightControlStream::~MoonlightControlStream() { stop(false); } + +void MoonlightControlStream::releaseSlot(ENetPacket* packet) { + // Runs inside an enet call, which this class only makes under linkMtx_, + // so the flag flip is already serialized. + auto* slot = static_cast(packet->userData); + if (slot != nullptr) { slot->inUse = false; } +} + +bool MoonlightControlStream::start(const std::string& hostAddress, std::uint16_t port, + std::uint32_t connectData, + const std::array& rikey) { + stop(false); + ensureEnetInitialized(); + + ENetAddress address{}; + if (enet_address_set_host(&address, hostAddress.c_str()) != 0) { return false; } + enet_address_set_port(&address, port); + + { + std::lock_guard lock(linkMtx_); + if (!cipher_.setKey(rikey)) { return false; } + seq_ = 0; + for (auto& slot : slots_) { slot.inUse = false; } + nextSlot_ = 0; + + host_ = enet_host_create(address.address.ss_family, nullptr, 1, kChannelCount, 0, 0); + if (host_ == nullptr) { return false; } + peer_ = enet_host_connect(host_, &address, kChannelCount, connectData); + if (peer_ == nullptr) { + enet_host_destroy(host_); + host_ = nullptr; + return false; + } + lastPingMs_ = steadyNowMs(); + connectDeadlineMs_ = steadyNowMs() + kConnectTimeoutMs; + } + + stopRequested_.store(false, std::memory_order_relaxed); + connected_.store(false, std::memory_order_relaxed); + running_.store(true, std::memory_order_relaxed); + thread_ = std::thread([this] { serviceLoop(); }); + return true; +} + +void MoonlightControlStream::stop(bool sendTermination) { + if (thread_.joinable()) { + if (sendTermination && connected_.load(std::memory_order_relaxed)) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = moonwire::encodeTermination(plaintext); + sealAndSend(plaintext, len); + } + { + std::lock_guard lock(linkMtx_); + if (peer_ != nullptr) { + enet_peer_disconnect_now(peer_, 0); + peer_ = nullptr; + } + } + stopRequested_.store(true, std::memory_order_relaxed); + running_.store(false, std::memory_order_relaxed); + thread_.join(); + } + std::lock_guard lock(linkMtx_); + if (host_ != nullptr) { + enet_host_destroy(host_); + host_ = nullptr; + } + peer_ = nullptr; + connected_.store(false, std::memory_order_relaxed); +} + +void MoonlightControlStream::notifyLink(bool connected) { + if (linkHandler_) { linkHandler_(connected); } +} + +void MoonlightControlStream::serviceLoop() { + bool announcedConnect = false; + while (running_.load(std::memory_order_relaxed)) { + // Decoded events are dispatched after the lock is released, so a + // handler may call back into a sender without deadlocking. + std::vector events; + bool linkUp = false; + bool linkDown = false; + + { + std::lock_guard lock(linkMtx_); + if (host_ == nullptr) { break; } + + ENetEvent event; + int guard = 32; // bound one pass; the loop resumes in 2 ms anyway + while (guard-- > 0 && enet_host_service(host_, &event, 0) > 0) { + switch (event.type) { + case ENET_EVENT_TYPE_CONNECT: + connected_.store(true, std::memory_order_relaxed); + linkUp = true; + break; + case ENET_EVENT_TYPE_DISCONNECT: + connected_.store(false, std::memory_order_relaxed); + linkDown = true; + break; + case ENET_EVENT_TYPE_RECEIVE: { + std::uint8_t plaintext[256]; + const auto len = cipher_.open(event.packet->data, event.packet->dataLength, + plaintext, sizeof(plaintext)); + if (len) { + if (const auto decoded = moonwire::decodeHostEvent(plaintext, *len)) { + events.push_back(*decoded); + } + } + enet_packet_destroy(event.packet); + break; + } + case ENET_EVENT_TYPE_NONE: + default: + break; + } + } + + const std::int64_t now = steadyNowMs(); + if (!connected_.load(std::memory_order_relaxed) && !linkUp && !linkDown && + now > connectDeadlineMs_) { + linkDown = true; // connect timed out + } + } + + if (linkUp && !announcedConnect) { + announcedConnect = true; + notifyLink(true); + } + for (const auto& ev : events) { + if (eventHandler_) { eventHandler_(ev); } + } + if (linkDown) { + if (!stopRequested_.load(std::memory_order_relaxed)) { notifyLink(false); } + running_.store(false, std::memory_order_relaxed); + break; + } + + // Keep-alive, off the lock-held section above. + if (connected_.load(std::memory_order_relaxed)) { + bool pingDue = false; + { + std::lock_guard lock(linkMtx_); + const std::int64_t now = steadyNowMs(); + if (now - lastPingMs_ >= kPingIntervalMs) { + lastPingMs_ = now; + pingDue = true; + } + } + if (pingDue) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = moonwire::encodePeriodicPing(plaintext); + sealAndSend(plaintext, len); + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } +} + +bool MoonlightControlStream::queuePacket(const std::uint8_t* sealed, std::size_t sealedLen, + Slot* slot) { + ENetPacket* packet = nullptr; + if (slot != nullptr) { + packet = enet_packet_create(nullptr, 0, + static_cast(ENET_PACKET_FLAG_RELIABLE) | + static_cast(ENET_PACKET_FLAG_NO_ALLOCATE)); + if (packet != nullptr) { + packet->data = slot->buffer.data(); + packet->dataLength = sealedLen; + packet->userData = slot; + packet->freeCallback = &MoonlightControlStream::releaseSlot; + slot->inUse = true; + } + } else { + // Ring exhausted (deep unacked backlog): fall back to a copying send + // rather than dropping input. + packet = enet_packet_create(sealed, sealedLen, + static_cast(ENET_PACKET_FLAG_RELIABLE)); + } + if (packet == nullptr) { return false; } + if (enet_peer_send(peer_, kChannel, packet) < 0) { + enet_packet_destroy(packet); + return false; + } + // Push the datagram out on THIS thread: input latency stays flat instead + // of waiting for the next service tick. + enet_host_flush(host_); + return true; +} + +void MoonlightControlStream::sealAndSend(const std::uint8_t* plaintext, std::size_t len) { + std::lock_guard lock(linkMtx_); + if (host_ == nullptr || peer_ == nullptr || !connected_.load(std::memory_order_relaxed)) { + return; + } + + Slot* slot = nullptr; + for (std::size_t i = 0; i < kSlotCount; ++i) { + Slot& candidate = slots_[(nextSlot_ + i) % kSlotCount]; + if (!candidate.inUse) { + slot = &candidate; + nextSlot_ = ((nextSlot_ + i) % kSlotCount) + 1; + break; + } + } + + if (slot != nullptr) { + const std::size_t sealedLen = cipher_.seal(seq_, plaintext, len, slot->buffer.data()); + if (sealedLen == 0) { return; } + ++seq_; + queuePacket(slot->buffer.data(), sealedLen, slot); + return; + } + + std::uint8_t fallback[moonwire::kMaxPlaintextSize + mooncrypto::ControlCipher::kOverhead]; + const std::size_t sealedLen = cipher_.seal(seq_, plaintext, len, fallback); + if (sealedLen == 0) { return; } + ++seq_; + queuePacket(fallback, sealedLen, nullptr); +} + +void MoonlightControlStream::sendControllerMulti( + std::uint8_t controllerNumber, std::uint16_t activeMask, std::uint32_t buttonFlags, + std::uint8_t leftTrigger, std::uint8_t rightTrigger, std::int16_t leftX, std::int16_t leftY, + std::int16_t rightX, std::int16_t rightY) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = + moonwire::encodeControllerMulti(plaintext, controllerNumber, activeMask, buttonFlags, + leftTrigger, rightTrigger, leftX, leftY, rightX, rightY); + sealAndSend(plaintext, len); +} + +void MoonlightControlStream::sendControllerArrival(std::uint8_t controllerNumber, + std::uint8_t controllerType, + std::uint8_t capabilities, + std::uint32_t supportedButtons) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = moonwire::encodeControllerArrival( + plaintext, controllerNumber, controllerType, capabilities, supportedButtons); + sealAndSend(plaintext, len); +} + +void MoonlightControlStream::sendControllerMotion(std::uint8_t controllerNumber, + std::uint8_t motionType, float x, float y, + float z) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = + moonwire::encodeControllerMotion(plaintext, controllerNumber, motionType, x, y, z); + sealAndSend(plaintext, len); +} + +void MoonlightControlStream::sendControllerBattery(std::uint8_t controllerNumber, + std::uint8_t state, std::uint8_t percentage) { + std::uint8_t plaintext[moonwire::kMaxPlaintextSize]; + const std::size_t len = + moonwire::encodeControllerBattery(plaintext, controllerNumber, state, percentage); + sealAndSend(plaintext, len); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightControlStream.h b/src/source/moonlight/MoonlightControlStream.h new file mode 100644 index 0000000..db4a9f4 --- /dev/null +++ b/src/source/moonlight/MoonlightControlStream.h @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The Moonlight control-stream link: an ENet client connection carrying +// AES-GCM-sealed control messages both ways. Owns the service thread that +// pumps ENet, decrypts host events (rumble, trigger rumble, motion requests, +// RGB LED, termination) and sends the periodic keep-alive ping. +// +// Hot path: sendControllerMulti runs on the SDL input thread. It encodes and +// seals into a preallocated ring slot under the link mutex and hands ENet the +// slot's buffer with ENET_PACKET_FLAG_NO_ALLOCATE, so this layer performs no +// per-packet heap allocation and no payload copies. (ENet itself still +// allocates its small ENetPacket bookkeeping struct per send; that lives in +// the vendored library.) The GCM context is reused across packets. + +#pragma once + +#include "core/moonlight/MoonlightControlCipher.h" +#include "core/moonlight/MoonlightWire.h" + +#include +#include +#include +#include +#include +#include +#include + +// Mirrors ENet's typedefs so stays out of this header, the same +// way SDLGamepadBridge.h mirrors SDL2's. Including the real header is not an +// option here: dish_enet is linked PRIVATE to dish_core (its include directory +// therefore does not reach the library's consumers), and this header is pulled +// in by MoonlightSession.h -> MoonlightManager.h -> AppModel.h, so it would +// drag , and every ENET_ macro into the whole +// tree. The leading underscores are ENet's struct tags, not our choice. +extern "C" { +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +using ENetHost = struct _ENetHost; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +using ENetPeer = struct _ENetPeer; +// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +using ENetPacket = struct _ENetPacket; +} + +namespace dish::source::moon { + +class MoonlightControlStream { + public: + // Both handlers run on the service thread; marshal in the owner. + using EventHandler = std::function; + // true once the ENet connect completes; false when the link is lost (a + // failed connect, a disconnect event, or a service error). + using LinkHandler = std::function; + + MoonlightControlStream(); + ~MoonlightControlStream(); + + MoonlightControlStream(const MoonlightControlStream&) = delete; + MoonlightControlStream& operator=(const MoonlightControlStream&) = delete; + MoonlightControlStream(MoonlightControlStream&&) = delete; + MoonlightControlStream& operator=(MoonlightControlStream&&) = delete; + + // Install before start(); not thread-safe against a running stream. + void setEventHandler(EventHandler handler) { eventHandler_ = std::move(handler); } + void setLinkHandler(LinkHandler handler) { linkHandler_ = std::move(handler); } + + // Dials `hostAddress:port`, passing `connectData` (the RTSP SETUP + // X-SS-Connect-Data) in the connect packet, keyed with the launch rikey. + // False when ENet setup fails outright; connect success/failure is then + // reported through the link handler. + bool start(const std::string& hostAddress, std::uint16_t port, std::uint32_t connectData, + const std::array& rikey); + + // Graceful teardown: optionally sends TERMINATION, then disconnects and + // joins the service thread. Idempotent. + void stop(bool sendTermination); + + bool isConnected() const { return connected_.load(std::memory_order_relaxed); } + + // ── Senders (any thread; dropped silently while unconnected) ───────────── + void sendControllerMulti(std::uint8_t controllerNumber, std::uint16_t activeMask, + std::uint32_t buttonFlags, std::uint8_t leftTrigger, + std::uint8_t rightTrigger, std::int16_t leftX, std::int16_t leftY, + std::int16_t rightX, std::int16_t rightY); + void sendControllerArrival(std::uint8_t controllerNumber, std::uint8_t controllerType, + std::uint8_t capabilities, std::uint32_t supportedButtons); + void sendControllerMotion(std::uint8_t controllerNumber, std::uint8_t motionType, float x, + float y, float z); + void sendControllerBattery(std::uint8_t controllerNumber, std::uint8_t state, + std::uint8_t percentage); + + private: + // A sealed-packet slot ENet borrows until delivery. Sized for the largest + // plaintext plus the GCM framing. + struct Slot { + std::array buffer{}; + bool inUse = false; + }; + static constexpr std::size_t kSlotCount = 32; + + static void releaseSlot(ENetPacket* packet); + + void serviceLoop(); + void notifyLink(bool connected); + // Seals `plaintext` and queues it. Caller must NOT hold linkMtx_. + void sealAndSend(const std::uint8_t* plaintext, std::size_t len); + // linkMtx_ held. Sends the pre-sealed slot/fallback packet. + bool queuePacket(const std::uint8_t* sealed, std::size_t sealedLen, Slot* slot); + + EventHandler eventHandler_; + LinkHandler linkHandler_; + + mutable std::mutex linkMtx_; // guards everything below + ENetHost* host_ = nullptr; + ENetPeer* peer_ = nullptr; + mooncrypto::ControlCipher cipher_; + std::uint32_t seq_ = 0; + std::array slots_{}; + std::size_t nextSlot_ = 0; + std::int64_t lastPingMs_ = 0; + std::int64_t connectDeadlineMs_ = 0; + + std::thread thread_; + std::atomic running_{false}; + std::atomic connected_{false}; + std::atomic stopRequested_{false}; +}; + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightDiscovery.cpp b/src/source/moonlight/MoonlightDiscovery.cpp new file mode 100644 index 0000000..8c85569 --- /dev/null +++ b/src/source/moonlight/MoonlightDiscovery.cpp @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightDiscovery.h" + +#include "source/connection/MdnsDiscovery.h" // net::detail::skipName / readName + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace dish::source::moon { +namespace { + +constexpr const char* kMulticastGroup = "224.0.0.251"; +constexpr std::uint16_t kMulticastPort = 5353; + +constexpr std::uint16_t kTypeA = 1; +constexpr std::uint16_t kTypePtr = 12; +constexpr std::uint16_t kTypeSrv = 33; +constexpr std::uint16_t kClassInQu = 0x8001; +constexpr int kGraceMs = 600; + +std::uint16_t read16(const std::uint8_t* p) { + return static_cast((p[0] << 8) | p[1]); +} + +std::vector buildQuery() { + std::vector q; + const std::uint8_t header[12] = {0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0}; + q.insert(q.end(), header, header + 12); + for (const char* label : {"_nvstream", "_tcp", "local"}) { + const auto len = static_cast(std::strlen(label)); + q.push_back(len); + q.insert(q.end(), label, label + len); + } + q.push_back(0); + q.push_back(static_cast(kTypePtr >> 8)); + q.push_back(static_cast(kTypePtr & 0xFF)); + q.push_back(static_cast(kClassInQu >> 8)); + q.push_back(static_cast(kClassInQu & 0xFF)); + return q; +} + +} // namespace + +namespace detail { + +std::optional parseMoonlightResponse(const std::uint8_t* p, + std::size_t len) { + if (len < 12) { return std::nullopt; } + const std::uint16_t qd = read16(p + 4); + const std::uint16_t an = read16(p + 6); + std::size_t pos = 12; + + for (std::uint16_t i = 0; i < qd; ++i) { + const std::size_t consumed = net::detail::skipName(p, len, pos); + if (consumed == 0) { return std::nullopt; } + pos += consumed + 4; + if (pos > len) { return std::nullopt; } + } + + std::string instance; + std::string srvTarget; + int srvPort = 0; + // A GameStream reply packs the SRV (with the host's target name) and that + // target's A record; resolve one against the other by name. + std::unordered_map aRecords; + + for (std::uint16_t i = 0; i < an; ++i) { + std::string owner; + net::detail::readName(p, len, pos, owner); + const std::size_t nameLen = net::detail::skipName(p, len, pos); + if (nameLen == 0) { return std::nullopt; } + pos += nameLen; + if (pos + 10 > len) { return std::nullopt; } + const std::uint16_t type = read16(p + pos); + const std::uint16_t rdlen = read16(p + pos + 8); + const std::size_t rdata = pos + 10; + if (rdata + rdlen > len) { return std::nullopt; } + + if (type == kTypeA && rdlen == 4) { + char buf[INET_ADDRSTRLEN] = {}; + in_addr a{}; + std::memcpy(&a, p + rdata, 4); + if (::inet_ntop(AF_INET, &a, buf, sizeof(buf)) != nullptr) { + aRecords.emplace(owner, buf); + } + } else if (type == kTypeSrv && rdlen >= 7) { + srvPort = read16(p + rdata + 4); + std::string target; + if (net::detail::readName(p, len, rdata + 6, target)) { srvTarget = target; } + } else if (type == kTypePtr && instance.empty()) { + std::string n; + if (net::detail::readName(p, len, rdata, n)) { instance = n.substr(0, n.find('.')); } + } + pos = rdata + rdlen; + } + + std::string address; + if (!srvTarget.empty()) { + const auto it = aRecords.find(srvTarget); + if (it != aRecords.end()) { address = it->second; } + } + if (address.empty() && !aRecords.empty()) { address = aRecords.begin()->second; } + if (address.empty()) { return std::nullopt; } + + DiscoveredMoonlightHost host; + host.name = + instance.empty() ? QString::fromStdString(address) : QString::fromStdString(instance); + host.address = QString::fromStdString(address); + // The SRV port advertises the HTTPS port; the plain HTTP port is the + // GameStream default. Hosts do not advertise it, so keep the default. + host.httpPort = 47989; + (void)srvPort; + return host; +} + +} // namespace detail + +QList MoonlightDiscovery::discover(int timeoutMs) { + using namespace std::chrono; + + const int sock = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { return {}; } + + sockaddr_in local{}; + local.sin_family = AF_INET; + local.sin_addr.s_addr = INADDR_ANY; + local.sin_port = 0; + if (::bind(sock, reinterpret_cast(&local), sizeof(local)) < 0) { + ::close(sock); + return {}; + } + + timeval rcvTimeout{}; + rcvTimeout.tv_sec = 0; + rcvTimeout.tv_usec = 300'000; + ::setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &rcvTimeout, sizeof(rcvTimeout)); + int ttl = 255; + ::setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); + + sockaddr_in dest{}; + dest.sin_family = AF_INET; + dest.sin_port = htons(kMulticastPort); + ::inet_pton(AF_INET, kMulticastGroup, &dest.sin_addr); + + const auto query = buildQuery(); + ::sendto(sock, query.data(), query.size(), 0, reinterpret_cast(&dest), sizeof(dest)); + + QList result; + QSet seen; + const auto hardDeadline = steady_clock::now() + milliseconds(timeoutMs); + auto deadline = hardDeadline; + std::uint8_t buf[2048]; + + while (steady_clock::now() < deadline) { + const ssize_t n = ::recvfrom(sock, buf, sizeof(buf), 0, nullptr, nullptr); + if (n <= 0) { continue; } + const auto host = detail::parseMoonlightResponse(buf, static_cast(n)); + if (!host) { continue; } + if (seen.contains(host->address)) { continue; } + seen.insert(host->address); + result.append(*host); + deadline = std::min(hardDeadline, steady_clock::now() + milliseconds(kGraceMs)); + } + + ::close(sock); + return result; +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightDiscovery.h b/src/source/moonlight/MoonlightDiscovery.h new file mode 100644 index 0000000..4e41e4d --- /dev/null +++ b/src/source/moonlight/MoonlightDiscovery.h @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// One-shot mDNS discovery of Moonlight hosts advertised under +// `_nvstream._tcp.local.`, the sibling of net::MdnsDiscovery's satellite +// query. Reuses that module's DNS wire helpers (net::detail::skipName / +// readName) and adds an SRV-target -> A record resolve, since GameStream hosts +// advertise their address indirectly. Blocking: call from a background thread. +// Manual host entry (source/moonlight/MoonlightSession) is the fallback when a +// network drops the multicast query. + +#pragma once + +#include +#include + +#include +#include +#include + +namespace dish::source::moon { + +struct DiscoveredMoonlightHost { + QString name; // the service instance label + QString address; // resolved IPv4 + int httpPort = 47989; + + bool isValid() const { return !address.isEmpty(); } + bool operator==(const DiscoveredMoonlightHost& o) const { + return name == o.name && address == o.address && httpPort == o.httpPort; + } +}; + +class MoonlightDiscovery { + public: + static constexpr int kDefaultTimeoutMs = 3000; + + // Sends one PTR query for `_nvstream._tcp.local.` and collects the hosts + // that answer within the window. + static QList discover(int timeoutMs = kDefaultTimeoutMs); +}; + +namespace detail { + +// Parses one mDNS response packet into a host, following the SRV target to its +// A record. nullopt when the packet carries no usable _nvstream record set. +// Exposed for unit tests, which feed it hand-built packets without a socket. +std::optional parseMoonlightResponse(const std::uint8_t* p, + std::size_t len); + +} // namespace detail + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightHttp.cpp b/src/source/moonlight/MoonlightHttp.cpp new file mode 100644 index 0000000..2ed3830 --- /dev/null +++ b/src/source/moonlight/MoonlightHttp.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightHttp.h" + +#include "source/moonlight/MoonlightLog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dish::source::moon { +namespace { + +// Certificate equality that survives PEM reserialization differences: compare +// the DER bytes, not the text. +bool sameCert(const QSslCertificate& presented, const QString& pinnedPem) { + if (presented.isNull() || pinnedPem.isEmpty()) { return false; } + const auto pinned = QSslCertificate::fromData(pinnedPem.toUtf8(), QSsl::Pem); + if (pinned.isEmpty() || pinned.first().isNull()) { return false; } + return presented.toDer() == pinned.first().toDer(); +} + +} // namespace + +MoonlightHttp::MoonlightHttp(QObject* parent) + : QObject(parent), nam_(new QNetworkAccessManager(this)) {} + +MoonlightHttp::~MoonlightHttp() = default; + +void MoonlightHttp::setIdentity(const QString& certPem, const QString& privateKeyPem, + const QString& uniqueId) { + certPem_ = certPem; + privateKeyPem_ = privateKeyPem; + uniqueId_ = uniqueId; +} + +void MoonlightHttp::getPlain(const QString& address, int port, const QString& path, + const QUrlQuery& query, BodyCb cb, int timeoutMs) { + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(address); + url.setPort(port); + url.setPath(path); + url.setQuery(query); + perform(url, false, QString(), std::move(cb), timeoutMs); +} + +void MoonlightHttp::getTls(const QString& address, int port, const QString& path, + const QUrlQuery& query, const QString& pinnedServerCertPem, BodyCb cb, + int timeoutMs) { + QUrl url; + url.setScheme(QStringLiteral("https")); + url.setHost(address); + url.setPort(port); + url.setPath(path); + url.setQuery(query); + perform(url, true, pinnedServerCertPem, std::move(cb), timeoutMs); +} + +QSslConfiguration MoonlightHttp::tlsConfiguration(const QString& certPem, + const QString& privateKeyPem) { + QSslConfiguration ssl = QSslConfiguration::defaultConfiguration(); + // Self-signed on both ends; trust is the explicit pin check the reply + // handler applies. + ssl.setPeerVerifyMode(QSslSocket::VerifyNone); + // NEVER OFFER A SESSION TO RESUME. A resumed TLS session carries the peer + // identity forward instead of asking for the certificate again, so a + // Moonlight host's verify callback never runs and Sunshine kills the + // connection with a fatal internal_error alert (RFC 8446 alert 80) and logs + // nothing at all. Qt shares and persists sessions across the connections one + // QNetworkAccessManager makes, which is exactly the shape that triggers it, + // so all three switches go off together. + ssl.setSslOption(QSsl::SslOptionDisableSessionTickets, true); + ssl.setSslOption(QSsl::SslOptionDisableSessionSharing, true); + ssl.setSslOption(QSsl::SslOptionDisableSessionPersistence, true); + const auto certs = QSslCertificate::fromData(certPem.toUtf8(), QSsl::Pem); + if (!certs.isEmpty()) { ssl.setLocalCertificate(certs.first()); } + QSslKey key(privateKeyPem.toUtf8(), QSsl::Rsa, QSsl::Pem, QSsl::PrivateKey); + if (!key.isNull()) { ssl.setPrivateKey(key); } + return ssl; +} + +void MoonlightHttp::perform(const QUrl& url, bool tls, const QString& pinnedServerCertPem, + BodyCb cb, int timeoutMs) { + // Every GameStream request carries the client's uniqueid plus a per-call + // uuid nonce; hosts key caches and pairing state on the former. + QUrl full = url; + QUrlQuery query(full.query()); + query.addQueryItem(QStringLiteral("uniqueid"), uniqueId_); + query.addQueryItem(QStringLiteral("uuid"), + QUuid::createUuid().toString(QUuid::WithoutBraces).remove(QChar('-'))); + full.setQuery(query); + + QNetworkRequest request(full); + request.setTransferTimeout(timeoutMs); + // GameStream hosts speak bare HTTP/1.1 and choke on upgrade probing. + request.setAttribute(QNetworkRequest::Http2AllowedAttribute, false); + + if (tls) { request.setSslConfiguration(tlsConfiguration(certPem_, privateKeyPem_)); } + + const QString path = full.path(); + qCDebug(lcMoon) << "http ->" << (tls ? "https" : "http") << full.host() << path; + QNetworkReply* reply = nam_->get(request); + if (tls) { + QObject::connect(reply, &QNetworkReply::sslErrors, reply, + [reply](const QList&) { reply->ignoreSslErrors(); }); + } + QObject::connect(reply, &QNetworkReply::finished, this, + [reply, tls, path, pinnedServerCertPem, cb = std::move(cb)]() { + reply->deleteLater(); + if (tls) { + const auto presented = reply->sslConfiguration().peerCertificate(); + if (!sameCert(presented, pinnedServerCertPem)) { + qCWarning(lcMoon) << "http" << path + << "rejected: server certificate does not " + "match the pairing pin"; + cb(0, QByteArray()); + return; + } + } + if (reply->error() != QNetworkReply::NoError && + reply->error() != QNetworkReply::ProtocolInvalidOperationError) { + qCWarning(lcMoon) + << "http" << path << "failed:" << reply->errorString(); + cb(0, QByteArray()); + return; + } + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + qCDebug(lcMoon) << "http <-" << path << status << body.size() << "bytes"; + cb(status, body); + }); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightHttp.h b/src/source/moonlight/MoonlightHttp.h new file mode 100644 index 0000000..cbc6c67 --- /dev/null +++ b/src/source/moonlight/MoonlightHttp.h @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Async gateway to a Moonlight host's GameStream HTTP API: plain HTTP (47989) +// for serverinfo and the pairing phases, HTTPS (47984) with the client +// certificate for everything after. The host's cert is self-signed, so peer +// verification is off and trust is the pairing-time pin: every TLS reply is +// checked against the certificate the pairing handshake verified, and a +// mismatch is reported as unreachable rather than handing bytes from an +// imposter to the caller. +// +// Callbacks fire on the manager's home thread (the Qt main thread). + +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; + +namespace dish::source::moon { + +class MoonlightHttp : public QObject { + Q_OBJECT + public: + explicit MoonlightHttp(QObject* parent = nullptr); + ~MoonlightHttp() override; + + // The client identity every TLS call presents, plus the uniqueid query + // parameter every GameStream call carries. + void setIdentity(const QString& certPem, const QString& privateKeyPem, const QString& uniqueId); + QString uniqueId() const { return uniqueId_; } + + // status 0 = the transport never produced a response (includes a TLS pin + // mismatch); the body is then empty. + using BodyCb = std::function; + + // GET http://address:port/path?uniqueid=...&uuid=...&. + // `timeoutMs` exists because pairing phase 1 legitimately blocks until the + // user types the PIN into the host. + void getPlain(const QString& address, int port, const QString& path, const QUrlQuery& query, + BodyCb cb, int timeoutMs = kDefaultTimeoutMs); + + // GET https://... with the client cert; the reply is accepted only when + // the presented server certificate matches `pinnedServerCertPem`. + void getTls(const QString& address, int port, const QString& path, const QUrlQuery& query, + const QString& pinnedServerCertPem, BodyCb cb, int timeoutMs = kDefaultTimeoutMs); + + // The TLS configuration every mutual-TLS call presents. Exposed because the + // one thing it must guarantee cannot be observed any other way: a resumed + // session skips a Moonlight host's verify callback, and the host answers + // that with a fatal alert and no log line at all. + static QSslConfiguration tlsConfiguration(const QString& certPem, const QString& privateKeyPem); + + static constexpr int kDefaultTimeoutMs = 10000; + static constexpr int kPairingTimeoutMs = 120000; + + private: + void perform(const QUrl& url, bool tls, const QString& pinnedServerCertPem, BodyCb cb, + int timeoutMs); + + QNetworkAccessManager* nam_; + QString certPem_; + QString privateKeyPem_; + QString uniqueId_; +}; + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightLog.cpp b/src/source/moonlight/MoonlightLog.cpp new file mode 100644 index 0000000..35df9b6 --- /dev/null +++ b/src/source/moonlight/MoonlightLog.cpp @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightLog.h" + +namespace dish::source::moon { + +Q_LOGGING_CATEGORY(lcMoon, "dish.moonlight", QtInfoMsg) + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightLog.h b/src/source/moonlight/MoonlightLog.h new file mode 100644 index 0000000..8dadf72 --- /dev/null +++ b/src/source/moonlight/MoonlightLog.h @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// One logging category for the whole Moonlight path — pairing, HTTP, the RTSP +// handshake, the control stream and the session coordinator. A mid-handshake +// hang-up reaches the client as a bare socket failure with no reply attached, +// so the step it died on is the only thing that identifies it; the category is +// shared so one filter rule follows a session end to end. +// +// QtInfoMsg floor, matching dish.net: the per-request trace lines are qCDebug +// and stay off until someone asks for them. + +#pragma once + +#include + +namespace dish::source::moon { + +Q_DECLARE_LOGGING_CATEGORY(lcMoon) + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightManager.cpp b/src/source/moonlight/MoonlightManager.cpp new file mode 100644 index 0000000..8a246ca --- /dev/null +++ b/src/source/moonlight/MoonlightManager.cpp @@ -0,0 +1,777 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightManager.h" + +#include "core/moonlight/MoonlightXml.h" +#include "source/moonlight/MoonlightDiscovery.h" +#include "source/moonlight/MoonlightLog.h" + +#include +#include +#include +#include + +#include + +namespace dish::source::moon { +namespace { + +QString synthUuidForAddress(const QString& address) { + return QStringLiteral("addr:%1").arg(address); +} + +// Sunshine's own "Desktop" app id, and the fallback when the host offered no +// list we could read. The host still picks its default if it disagrees. +constexpr const char* kDefaultAppId = "1"; + +} // namespace + +MoonlightManager::MoonlightManager(const std::shared_ptr& settings, QObject* parent) + : QObject(parent), settings_(settings), identityRepo_(settings), hostRepo_(settings), + http_(new MoonlightHttp(this)), pairingFlow_(std::make_unique(http_)) { + deviceName_ = QStringLiteral("Dish (%1)").arg(QHostInfo::localHostName()); + + QObject::connect(pairingFlow_.get(), &MoonlightPairingFlow::pinReady, this, + [this](const QString&) { emit pairingChanged(); }); + QObject::connect( + pairingFlow_.get(), &MoonlightPairingFlow::finished, this, + [this](bool ok, const QString& reasonToken, const QString& serverCertPem) { + const QString uuid = pairingFlow_->hostUuid(); + if (ok) { + // Promote the discovered row to a remembered, paired host. + auto stored = hostRepo_.get(uuid); + repository::MoonlightHost host = stored.value_or(repository::MoonlightHost{}); + host.uuid = uuid; + if (const auto it = discovered_.constFind(uuid); it != discovered_.constEnd()) { + if (host.name.isEmpty()) { host.name = it->name; } + host.address = it->address; + } + host.serverCertPem = serverCertPem; + hostRepo_.upsert(host); + pairingRefusedUuid_.clear(); + // A successful pairing IS a verification: the handshake proved + // the trust the probe would have asked about. + HostProbe& probe = probes_[uuid]; + probe.inFlight = false; + probe.answered = true; + probe.paired = true; + probe.identityChanged = false; + probe.trustRejected = false; + } else { + pairingRefusedUuid_ = uuid; + } + qCInfo(lcMoon) << "pairing with" << uuid << (ok ? "succeeded" : "failed") + << reasonToken; + emit pairingChanged(); + emit pairingFinished(uuid, ok, reasonToken); + emit rowsChanged(); + }); +} + +MoonlightManager::~MoonlightManager() { + for (auto* session : sessions_) { session->stop(); } +} + +void MoonlightManager::ensureIdentityLoaded() { + if (identityReady_) { return; } + const auto identity = identityRepo_.ensureIdentity(); + if (!identity) { return; } + http_->setIdentity(identity->certPem, identity->privateKeyPem, identity->uniqueId); + identityReady_ = true; +} + +QList MoonlightManager::rows() const { + QList out; + QSet seen; + for (const auto& host : hostRepo_.all()) { + MoonlightRow row; + row.uuid = host.uuid; + row.name = host.name.isEmpty() ? host.address : host.name; + row.address = host.address; + row.paired = host.paired(); + row.lastAppId = host.lastAppId; + row.lastAppName = host.lastAppName; + row.controllerType = host.controllerType; + const auto* session = sessions_.value(host.uuid, nullptr); + if (session != nullptr) { + row.link = session->linkState(); + row.controllers = static_cast(session->controllerCount()); + } + if (const auto it = discovered_.constFind(host.uuid); it != discovered_.constEnd()) { + row.discovered = true; + } + const auto inputs = uiInputs(host.uuid, QString()); + row.trust = moonlight::hostTrust(inputs); + row.phase = pairingFlow_->active() && pairingFlow_->hostUuid() == host.uuid + ? moonlight::HostPhase::Pairing + : moonlight::hostPhaseFor(session != nullptr ? session->machineState() + : moonlight::SessionState{}, + row.trust != moonlight::HostTrust::NotPaired, + session != nullptr && session->everStarted()); + seen.insert(host.uuid); + out.append(row); + } + for (auto it = discovered_.constBegin(); it != discovered_.constEnd(); ++it) { + if (seen.contains(it.key())) { continue; } + MoonlightRow row = it.value(); + const auto inputs = uiInputs(row.uuid, QString()); + row.trust = moonlight::hostTrust(inputs); + row.phase = pairingFlow_->active() && pairingFlow_->hostUuid() == row.uuid + ? moonlight::HostPhase::Pairing + : moonlight::HostPhase::Idle; + out.append(row); + } + std::sort(out.begin(), out.end(), + [](const MoonlightRow& a, const MoonlightRow& b) { return a.name < b.name; }); + return out; +} + +bool MoonlightManager::knows(const QString& uuid) const { + if (uuid.isEmpty()) { return false; } + return hostRepo_.get(uuid).has_value() || discovered_.contains(uuid); +} + +std::optional MoonlightManager::row(const QString& uuid) const { + for (const auto& row : rows()) { + if (row.uuid == uuid) { return row; } + } + return std::nullopt; +} + +void MoonlightManager::startDiscovery() { + if (scanning_) { + qCDebug(lcMoon) << "discovery already running; coalesced"; + return; + } + scanning_ = true; + emit scanningChanged(); + auto* watcher = new QFutureWatcher>(this); + QObject::connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher] { + onDiscovered(watcher->result()); + watcher->deleteLater(); + scanning_ = false; + emit scanningChanged(); + }); + watcher->setFuture(QtConcurrent::run([] { return MoonlightDiscovery::discover(); })); +} + +void MoonlightManager::onDiscovered(const QList& hosts) { + for (const auto& found : hosts) { + // ONE HOST, ONE ID, and the address is it. mDNS here advertises no + // uniqueid, so the address is the only identifier both the found and + // the typed-in routes have on first contact; keying on it everywhere + // means a record, a pin and a binding never disagree about which host + // they mean. serverinfo's uuid is read for the REPLACED check and + // never promoted to a key, because a rekey would have to migrate all + // three at once. The cost is that a host which moves address arrives + // as a new row, which is the same trade the satellite pool makes. + // + // A sweep MERGES. Nothing here removes an entry a previous sweep found, + // so a pass that answers with less than the last one cannot delete a + // host the user is in the middle of using. + const QString key = synthUuidForAddress(found.address); + MoonlightRow row; + row.uuid = key; + row.name = found.name; + row.address = found.address; + row.discovered = true; + discovered_.insert(key, row); + } + qCInfo(lcMoon) << "discovery found" << hosts.size() << "host(s)"; + emit rowsChanged(); +} + +void MoonlightManager::addManualHost(const QString& address, const QString& name, int httpPort, + int httpsPort) { + const QString key = synthUuidForAddress(address); + MoonlightRow row; + row.uuid = key; + row.name = name.isEmpty() ? address : name; + row.address = address; + row.discovered = true; + discovered_.insert(key, row); + // Persist the ports on a stub host so a later pair() has them. + repository::MoonlightHost stub; + stub.uuid = key; + stub.name = row.name; + stub.address = address; + stub.httpPort = httpPort; + stub.httpsPort = httpsPort; + hostRepo_.upsert(stub); + qCInfo(lcMoon) << "added host by address" << address << httpPort << httpsPort; + emit rowsChanged(); +} + +// A Pair that ends before the wire is still an ANSWER. It records the refusal +// so hostTrust and sessionUiState can render PairingRefused, says why in the +// log, and re-emits the row set; without all three the PIN sheet sits on an +// indeterminate spinner and four empty digit cells forever, which is exactly +// what "I pressed Pair and nothing happened" looks like from the outside. +void MoonlightManager::refusePairing(const QString& uuid, const QString& reasonToken) { + pairingRefusedUuid_ = uuid; + pairingRefusedReason_ = reasonToken; + qCWarning(lcMoon) << "pairing with" << uuid << "refused before the wire:" << reasonToken; + emit pairingChanged(); + emit pairingFinished(uuid, false, reasonToken); + emit rowsChanged(); +} + +void MoonlightManager::rememberDestination(const QString& uuid) { + if (uuid.isEmpty() || hostRepo_.get(uuid)) { return; } + const auto it = discovered_.constFind(uuid); + if (it == discovered_.constEnd()) { + qCWarning(lcMoon) << "cannot remember" << uuid << ": no address on file for it"; + return; + } + // Unpaired on purpose: this records INTEREST, not trust. The anchor is + // still only ever written by a pairing handshake that verified it. + repository::MoonlightHost host; + host.uuid = uuid; + host.name = it->name; + host.address = it->address; + hostRepo_.upsert(host); + qCInfo(lcMoon) << "remembered" << uuid << "at" << it->address << "as a binding destination"; +} + +void MoonlightManager::pair(const QString& uuid) { + ensureIdentityLoaded(); + pairingRefusedUuid_.clear(); + pairingRefusedReason_.clear(); + if (uuid.isEmpty()) { + qCWarning(lcMoon) << "pair called with no host"; + return; + } + if (!identityReady_) { + refusePairing(uuid, QStringLiteral("crypto")); + return; + } + QString address; + int httpPort = 47989; + int httpsPort = 47984; + if (const auto host = hostRepo_.get(uuid)) { + address = host->address; + httpPort = host->httpPort; + httpsPort = host->httpsPort; + } else if (const auto it = discovered_.constFind(uuid); it != discovered_.constEnd()) { + address = it->address; + } + // The forgotten-then-paired case: a Forget drops both the remembered row + // and the discovered one, so a sheet still holding the old id has nowhere + // to dial. It has to SAY so rather than open on a PIN that never arrives. + if (address.isEmpty()) { + refusePairing(uuid, QStringLiteral("unreachable")); + return; + } + const auto identity = identityRepo_.identity(); + if (!identity) { + refusePairing(uuid, QStringLiteral("crypto")); + return; + } + qCInfo(lcMoon) << "pairing with" << uuid << "at" << address << httpPort << httpsPort; + pairingFlow_->start(uuid, address, httpPort, httpsPort, identity->certPem, + identity->privateKeyPem, deviceName_); + emit pairingChanged(); +} + +void MoonlightManager::cancelPairing() { + qCInfo(lcMoon) << "pairing with" << pairingFlow_->hostUuid() << "cancelled by the user"; + pairingFlow_->cancel(); + pairingRefusedUuid_.clear(); + pairingRefusedReason_.clear(); + emit pairingChanged(); +} + +bool MoonlightManager::pairingRefused(const QString& uuid) const { + return !uuid.isEmpty() && pairingRefusedUuid_ == uuid; +} + +QString MoonlightManager::pairingRefusedReason(const QString& uuid) const { + return pairingRefused(uuid) ? pairingRefusedReason_ : QString(); +} + +void MoonlightManager::probe(const QString& uuid) { + QString address; + int httpPort = 47989; + const auto stored = hostRepo_.get(uuid); + if (stored) { + address = stored->address; + httpPort = stored->httpPort; + } else if (const auto it = discovered_.constFind(uuid); it != discovered_.constEnd()) { + address = it->address; + } + if (address.isEmpty()) { + // Nothing to ask, so probeFinished has to fire anyway: a caller that + // waits for it (the host screen re-probes every row on open) would + // otherwise sit on Checking for a host that no longer exists. + qCWarning(lcMoon) << "probe of" << uuid << "skipped: no address on file"; + emit probeFinished(uuid); + return; + } + + HostProbe& probe = probes_[uuid]; + if (probe.inFlight) { + qCDebug(lcMoon) << "probe of" << address << "already in flight; coalesced"; + return; + } + probe.inFlight = true; + emit rowsChanged(); + + // PLAINTEXT, deliberately: PairStatus is the one thing an unpaired client + // can read, and `currentgame` / `state` from this port describe nobody, so + // they are not read here at all. + ensureIdentityLoaded(); + const QString rememberedUuid = stored ? stored->uuid : QString(); + const quint64 epoch = epochOf(uuid); + http_->getPlain( + address, httpPort, QStringLiteral("/serverinfo"), QUrlQuery(), + [this, uuid, rememberedUuid, address, epoch](int status, const QByteArray& body) { + if (epochOf(uuid) != epoch) { + // Forgotten while this was in flight. probes_[uuid] would + // INSERT, handing a stranger the verdict of the host it used + // to be, so the answer is dropped instead. + qCInfo(lcMoon) << "probe reply for" << address << "arrived after a forget"; + return; + } + HostProbe& result = probes_[uuid]; + result.inFlight = false; + const std::optional info = + status == 200 ? moonxml::parseServerInfo(body.toStdString()) + : std::optional{}; + if (!info) { + result.answered = false; + qCInfo(lcMoon) << "probe of" << address << "did not answer; HTTP" << status; + emit probeFinished(uuid); + emit rowsChanged(); + return; + } + result.answered = true; + result.paired = info->pairStatus == 1; + // A uuid we do not recognise means the machine behind the address + // was reset or replaced, so the stored certificate anchors nothing. + const QString reported = QString::fromStdString(info->uuid); + result.identityChanged = !rememberedUuid.isEmpty() && !reported.isEmpty() && + !rememberedUuid.startsWith(QLatin1String("addr:")) && + reported != rememberedUuid; + if (result.paired) { result.trustRejected = false; } + qCInfo(lcMoon) << "probe of" << address << "paired" << result.paired << "identity" + << (result.identityChanged ? "changed" : "same"); + emit probeFinished(uuid); + emit rowsChanged(); + }); +} + +void MoonlightManager::refreshApps(const QString& uuid) { + ensureIdentityLoaded(); + const auto host = hostRepo_.get(uuid); + AppCache& cache = appCache_[uuid]; + if (!host || !host->paired()) { + // The app list is HTTPS and paired-only; saying "no apps" here would + // present a 404 as a fact about the host. + cache.inFlight = false; + cache.read = false; + cache.failed = true; + qCInfo(lcMoon) << "applist on" << uuid << "not attempted: host is not paired"; + emit appsChanged(uuid); + return; + } + if (cache.inFlight) { + qCDebug(lcMoon) << "applist on" << host->address << "already in flight; coalesced"; + return; + } + cache.inFlight = true; + cache.failed = false; + emit appsChanged(uuid); + + const quint64 epoch = epochOf(uuid); + http_->getTls(host->address, host->httpsPort, QStringLiteral("/applist"), QUrlQuery(), + host->serverCertPem, + [this, uuid, epoch, address = host->address](int status, const QByteArray& body) { + if (epochOf(uuid) != epoch) { + // As in probe(): both appCache_ and probes_ below are + // written through operator[], so a reply that outlived + // a Forget would re-create what the Forget dropped. + qCInfo(lcMoon) << "applist reply for" << address << "outlived a forget"; + return; + } + AppCache& result = appCache_[uuid]; + result.inFlight = false; + const std::string xml = body.toStdString(); + const auto refusal = moonxml::parseStatus(xml); + if (status != 200 || (refusal && !refusal->ok())) { + result.failed = true; + // A 401 is the host saying it does not know this + // client any more, which is trust lost and not a + // transport fault. + if (status == 401 || (refusal && refusal->code == 401)) { + probes_[uuid].trustRejected = true; + } + qCWarning(lcMoon) << "applist on" << address << "HTTP" << status; + emit appsChanged(uuid); + emit rowsChanged(); + return; + } + // A reply we could read is proof of trust: the mutual-TLS + // handshake behind it is exactly what pairing establishes. + HostProbe& probe = probes_[uuid]; + probe.answered = true; + probe.paired = true; + probe.trustRejected = false; + + result.apps.clear(); + for (const auto& entry : moonxml::parseAppList(xml)) { + MoonlightApp app; + app.id = QString::fromStdString(entry.id); + app.title = QString::fromStdString(entry.title); + result.apps.append(app); + } + result.read = true; + result.failed = false; + qCInfo(lcMoon) + << "applist on" << address << "returned" << result.apps.size() << "apps"; + emit appsChanged(uuid); + emit rowsChanged(); + }); +} + +QList MoonlightManager::apps(const QString& uuid) const { + const auto it = appCache_.constFind(uuid); + if (it == appCache_.constEnd()) { return {}; } + return it->apps; +} + +MoonlightSession* MoonlightManager::ensureSession(const repository::MoonlightHost& host) { + if (auto* existing = sessions_.value(host.uuid, nullptr)) { return existing; } + auto* session = new MoonlightSession(http_, host, this); + wireSession(session, host.uuid); + sessions_.insert(host.uuid, session); + return session; +} + +void MoonlightManager::wireSession(MoonlightSession* session, const QString& uuid) { + QObject::connect(session, &MoonlightSession::linkStateChanged, this, + &MoonlightManager::rowsChanged); + QObject::connect(session, &MoonlightSession::failed, this, + [this, session, uuid](const QString& reasonToken) { + // A session already out of the table is one forget() + // is tearing down. Its verdict is about a host that no + // longer exists, and probes_[uuid] would insert it. + if (sessions_.value(uuid, nullptr) != session) { + qCInfo(lcMoon) + << "session on" << uuid << "failed after a forget:" << reasonToken; + return; + } + qCWarning(lcMoon) << "session on" << uuid << "failed:" << reasonToken; + if (reasonToken == QLatin1String("trustLost") || + reasonToken == QLatin1String("notPaired")) { + probes_[uuid].trustRejected = true; + } + emit sessionFailed(uuid, reasonToken); + emit rowsChanged(); + }); + session->setRumbleHandler( + [this, uuid](std::uint8_t number, std::uint16_t low, std::uint16_t high) { + auto* live = sessions_.value(uuid, nullptr); + if (live == nullptr || !rumbleSink_) { return; } + const QString slotId = live->slotForController(number); + if (!slotId.isEmpty()) { rumbleSink_(slotId, low, high); } + }); + session->setLedHandler( + [this, uuid](std::uint8_t number, std::uint8_t r, std::uint8_t g, std::uint8_t b) { + auto* live = sessions_.value(uuid, nullptr); + if (live == nullptr || !ledSink_) { return; } + const QString slotId = live->slotForController(number); + if (!slotId.isEmpty()) { ledSink_(slotId, r, g, b); } + }); +} + +void MoonlightManager::ensureSessionRunning(MoonlightSession* session, + const repository::MoonlightHost& host) { + if (!moonlight::sessionNeedsStart(session->machineState().phase)) { return; } + QString appId = host.lastAppId; + QString appName = host.lastAppName; + if (appId.isEmpty()) { + // Whatever the host lists first, which is what the copy promises when + // the user made no pick; the bare default id only when we read no list. + const auto listed = apps(host.uuid); + if (!listed.isEmpty()) { + appId = listed.front().id; + appName = listed.front().title; + } else { + appId = QString::fromLatin1(kDefaultAppId); + } + } + session->start(appId, appName); +} + +std::optional +MoonlightManager::bindController(const QString& slotId, const QString& uuid, int storedType, + const moonlight::SourceCapabilities& source) { + if (slotId.isEmpty() || uuid.isEmpty()) { + qCWarning(lcMoon) << "bind refused: slot" << slotId << "host" << uuid; + return std::nullopt; + } + // A slot drives exactly one destination. + if (const QString prior = bindings_.value(slotId); !prior.isEmpty() && prior != uuid) { + unbindController(slotId); + } + // The four-pad ceiling is a property of the HOST, not of the session, so it + // is enforced before a session exists too: an unpaired host that already + // carries four bindings has no room for a fifth either. + if (bindings_.value(slotId) != uuid) { + int others = 0; + for (auto it = bindings_.constBegin(); it != bindings_.constEnd(); ++it) { + if (it.value() == uuid) { ++others; } + } + if (others >= moonlight::kMaxPads) { + qCWarning(lcMoon) << "host" << uuid << "already carries" << others + << "bindings; refusing" << slotId; + return std::nullopt; + } + } + bindings_.insert(slotId, uuid); + + ensureIdentityLoaded(); + // A destination the user picked stops being a scan result and becomes a + // record. A binding on a host that lives only in the discovered set would + // name nothing the moment the sweep that found it is replaced. + rememberDestination(uuid); + const auto host = hostRepo_.get(uuid); + if (!host || !host->paired()) { + // The binding stands; the session waits for trust. Nothing about the + // host's state may refuse to record what the user asked for. + qCInfo(lcMoon) << "binding" << slotId << "to unpaired host" << uuid + << "; the session waits for pairing"; + emit rowsChanged(); + return std::nullopt; + } + auto* session = ensureSession(*host); + // Re-binding a slot that already holds a number is a RESTART, not a second + // pad: the number stands and the session is asked to run again, which is + // what Reconnect after a drop means. + auto number = session->controllerNumber(slotId); + if (!number) { number = session->attachController(slotId, storedType, source); } + if (!number) { + // Four pads already ride this host; the binding is not recorded, + // because there is no controller number for it to use. + bindings_.remove(slotId); + qCWarning(lcMoon) << "host" << uuid << "already carries" << session->controllerCount() + << "controllers; refusing" << slotId; + emit rowsChanged(); + return std::nullopt; + } + ensureSessionRunning(session, *host); + qCInfo(lcMoon) << "bound" << slotId << "to" << uuid << "as controller" << *number; + emit rowsChanged(); + return number; +} + +void MoonlightManager::unbindController(const QString& slotId) { + const QString uuid = bindings_.take(slotId); + if (uuid.isEmpty()) { + qCDebug(lcMoon) << "unbind of" << slotId << "is a no-op: no Moonlight binding on it"; + return; + } + qCInfo(lcMoon) << "unbinding" << slotId << "from" << uuid; + auto* session = sessions_.value(uuid, nullptr); + if (session == nullptr) { + // A binding that never got a session, which is every binding made + // before its host was paired. The intent is retired and that is all. + emit rowsChanged(); + return; + } + if (session->detachController(slotId) == 0) { + // The last controller has left, so nothing is riding the app any more: + // hand it back rather than strand it on the host. + session->stop(/*handBackApp=*/true); + } + emit rowsChanged(); +} + +QString MoonlightManager::boundHostFor(const QString& slotId) const { + return bindings_.value(slotId); +} + +QStringList MoonlightManager::boundSlots(const QString& uuid) const { + QStringList out; + for (auto it = bindings_.constBegin(); it != bindings_.constEnd(); ++it) { + if (it.value() == uuid) { out.append(it.key()); } + } + out.sort(); + return out; +} + +int MoonlightManager::controllerCount(const QString& uuid) const { + if (const auto* session = sessions_.value(uuid, nullptr)) { + return static_cast(session->controllerCount()); + } + return static_cast(boundSlots(uuid).size()); +} + +std::optional MoonlightManager::controllerNumber(const QString& slotId) const { + const QString uuid = bindings_.value(slotId); + if (uuid.isEmpty()) { return std::nullopt; } + if (const auto* session = sessions_.value(uuid, nullptr)) { + return session->controllerNumber(slotId); + } + return std::nullopt; +} + +moonlight::SessionUiInputs MoonlightManager::uiInputs(const QString& uuid, + const QString& slotId) const { + moonlight::SessionUiInputs in; + const auto host = hostRepo_.get(uuid); + in.remembered = host && host->paired(); + + if (const auto it = probes_.constFind(uuid); it != probes_.constEnd()) { + in.probeAttempted = true; + in.probeInFlight = it->inFlight; + in.probeAnswered = it->answered; + in.paired = it->paired; + in.identityChanged = it->identityChanged; + in.trustRejected = it->trustRejected; + } + in.pairingActive = pairingFlow_->active() && pairingFlow_->hostUuid() == uuid; + in.pairingRefused = pairingRefusedUuid_ == uuid && !uuid.isEmpty(); + + if (const auto it = appCache_.constFind(uuid); it != appCache_.constEnd()) { + in.appsInFlight = it->inFlight; + in.appsRead = it->read; + in.appsFailed = it->failed; + in.appCount = static_cast(it->apps.size()); + } + + if (const auto* session = sessions_.value(uuid, nullptr)) { + const auto& machine = session->machineState(); + in.sessionLive = machine.phase == moonlight::SessionPhase::Streaming; + in.bindingLive = + in.sessionLive && !slotId.isEmpty() && session->controllerNumber(slotId).has_value(); + if (machine.phase == moonlight::SessionPhase::Failed) { in.failure = machine.failure; } + } + // Every controller on this host except the one being edited, so a binding + // that already holds a number is never told the host is full. + int others = 0; + for (auto it = bindings_.constBegin(); it != bindings_.constEnd(); ++it) { + if (it.value() == uuid && it.key() != slotId) { ++others; } + } + in.otherControllers = others; + return in; +} + +void MoonlightManager::quitHostApp(const QString& uuid) { + ensureIdentityLoaded(); + const auto host = hostRepo_.get(uuid); + if (!host || !host->paired()) { + // /cancel is HTTPS and paired-only, so there is nothing to send. + qCWarning(lcMoon) << "cancel on" << uuid << "not attempted: host is not paired"; + emit hostAppCancelled(uuid, false); + return; + } + // Our own session first: tearing it down hands the app back through the + // same /cancel, and leaving it live would race the request. + if (auto* session = sessions_.value(uuid, nullptr)) { + if (session->machineState().phase != moonlight::SessionPhase::Idle) { + session->stop(/*handBackApp=*/true); + emit hostAppCancelled(uuid, true); + probe(uuid); + return; + } + } + http_->getTls(host->address, host->httpsPort, QStringLiteral("/cancel"), QUrlQuery(), + host->serverCertPem, + [this, uuid, address = host->address](int status, const QByteArray& body) { + const auto refusal = moonxml::parseStatus(body.toStdString()); + const bool ok = status == 200 && (!refusal || refusal->ok()); + qCInfo(lcMoon) << "cancel on" << address << "HTTP" << status << "host" + << (refusal ? refusal->code : 0) << "->" << ok; + emit hostAppCancelled(uuid, ok); + emit rowsChanged(); + // /cancel answers 200 whether or not anything was + // running, so success here proves nothing: ask again. + probe(uuid); + }); +} + +void MoonlightManager::forget(const QString& uuid) { + if (uuid.isEmpty()) { + qCWarning(lcMoon) << "forget called with no host"; + return; + } + // THE EPOCH FIRST. Every request already on the wire for this host captured + // the old one and will now drop its own reply, which is what stops a probe + // or an applist landing a moment later from re-creating the records the + // rest of this function removes. + ++epochs_[uuid]; + // A pairing still walking its phases would finish by upserting the row + // again, certificate and all: the host list would read empty while the + // pairing anchor stayed on file, and the next pair would meet a pin the + // user believes they deleted. cancel() does not emit finished(). + if (pairingFlow_->active() && pairingFlow_->hostUuid() == uuid) { + qCInfo(lcMoon) << "forget cancels the pairing in flight with" << uuid; + pairingFlow_->cancel(); + } + const QStringList dropped = boundSlots(uuid); + for (const auto& slotId : dropped) { bindings_.remove(slotId); } + auto* session = sessions_.take(uuid); + + // EVERY RECORD GOES BEFORE THE SESSION IS MADE TO SPEAK. stop() dispatches + // through the session machine and raises linkStateChanged, which reaches + // rowsChanged and every surface bound to it while this function would + // otherwise still be half done: a handler on the far side of that emit + // would resolve a host that is on its way out, and a probe asked for there + // would re-insert probes_[uuid] under the epoch this call already bumped, + // so its own reply would match and write the record back. + // + // The pairing anchor lives IN the row, so removing the row removes the pin. + // The session can be torn down after, because it carries its own COPY of + // the host record: the /cancel its teardown sends does not read the store. + hostRepo_.remove(uuid); + discovered_.remove(uuid); + probes_.remove(uuid); + appCache_.remove(uuid); + if (pairingRefusedUuid_ == uuid) { + pairingRefusedUuid_.clear(); + pairingRefusedReason_.clear(); + } + + if (session != nullptr) { + session->stop(/*handBackApp=*/true); + session->deleteLater(); + } + qCInfo(lcMoon) << "forgot" << uuid << "and the" << dropped.size() << "bindings it carried"; + emit rowsChanged(); +} + +void MoonlightManager::setLastApp(const QString& uuid, const QString& appId, + const QString& appName) { + auto host = hostRepo_.get(uuid); + if (!host) { + // Only a REMEMBERED host has somewhere to keep a pick. Dropping it + // quietly is how an app choice silently fails to stick, so it is said + // out loud instead. + qCWarning(lcMoon) << "app pick" << appId << "not stored: no remembered host" << uuid; + return; + } + host->lastAppId = appId; + host->lastAppName = appName; + hostRepo_.upsert(*host); + qCInfo(lcMoon) << "host" << uuid << "will next run" << appId; + emit rowsChanged(); +} + +void MoonlightManager::setControllerType(const QString& uuid, int type) { + auto host = hostRepo_.get(uuid); + if (!host) { + qCWarning(lcMoon) << "controller type" << type << "not stored: no remembered host" << uuid; + return; + } + host->controllerType = moonlight::migrateControllerType(type); + hostRepo_.upsert(*host); + emit rowsChanged(); +} + +MoonlightSession* MoonlightManager::session(const QString& uuid) const { + return sessions_.value(uuid, nullptr); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightManager.h b/src/source/moonlight/MoonlightManager.h new file mode 100644 index 0000000..08cb672 --- /dev/null +++ b/src/source/moonlight/MoonlightManager.h @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Owns the whole Moonlight-host subsystem beside the satellite WifiConnection +// pool: the client identity, the remembered-host store, mDNS discovery, the +// PIN pairing flow and the live sessions. Presents the same shape the rest of +// the app already consumes for satellites (a list of rows with a link state, +// pair/forget commands, a per-slot hot-path sender), so the UI and the +// controller-routing plumbing treat a Moonlight host as one more connection. +// +// ONE SESSION PER HOST, REFERENCE COUNTED. A Moonlight session carries up to +// four controllers (a controller number plus an active mask), so it belongs to +// the HOST and not to a binding. The first binding on a host starts or joins +// it and settles the app; every later binding only announces its own pad. The +// last unbind hands the app back with /cancel, so nothing is left stranded. +// +// TRUST IS REMEMBERED AND VERIFIED LAZILY. There is no bidirectional liveness +// to watch: probe() re-asks the host on entering a screen and before starting a +// session, and nothing polls. + +#pragma once + +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightSessionUi.h" +#include "repository/MoonlightHostRepository.h" +#include "repository/MoonlightIdentityRepository.h" +#include "source/moonlight/MoonlightDiscovery.h" +#include "source/moonlight/MoonlightHttp.h" +#include "source/moonlight/MoonlightPairingFlow.h" +#include "source/moonlight/MoonlightSession.h" + +#include +#include +#include +#include + +#include +#include + +class QThread; + +namespace dish::source::moon { + +// One app the host offers, as /applist returns it. +struct MoonlightApp { + QString id; + QString title; +}; + +// A row the UI renders, aligned with the satellite ConnectionSummary vocabulary. +struct MoonlightRow { + QString uuid; + QString name; + QString address; + bool paired = false; + bool discovered = false; + MoonlightLinkState link = MoonlightLinkState::Idle; + // Remembered trust, verified this visit where the host answered. Never a + // liveness light: a Moonlight host cannot report one. + moonlight::HostTrust trust = moonlight::HostTrust::NotPaired; + // The richer phase the row chip reads, converged with dish-windows. + moonlight::HostPhase phase = moonlight::HostPhase::Idle; + // Controllers currently riding this host's session. + int controllers = 0; + QString lastAppId; + QString lastAppName; + int controllerType = repository::kMoonlightControllerTypeAuto; +}; + +class MoonlightManager : public QObject { + Q_OBJECT + public: + // `settings` co-tenants the shared connection-store file; nullptr → default. + // Taken by const reference, not by value as the repositories take it: this + // shares the pointer with three collaborators rather than sinking it. + explicit MoonlightManager(const std::shared_ptr& settings = nullptr, + QObject* parent = nullptr); + ~MoonlightManager() override; + + QList rows() const; + bool isScanning() const { return scanning_; } + // A uuid this subsystem knows: a remembered host, or one discovery found. + bool knows(const QString& uuid) const; + std::optional row(const QString& uuid) const; + + // Kicks a background mDNS scan; merges results into the discovered set. + void startDiscovery(); + + // Adds (or refreshes) a host by address the user typed; it appears as an + // unpaired discovered row so the user can start pairing. + void addManualHost(const QString& address, const QString& name = QString(), + int httpPort = 47989, int httpsPort = 47984); + + // Begins PIN pairing. pairingPinChanged() then carries the PIN to show. + void pair(const QString& uuid); + void cancelPairing(); + QString pairingPin() const { return pairingFlow_->pin(); } + QString pairingHostUuid() const { return pairingFlow_->hostUuid(); } + bool pairingActive() const { return pairingFlow_->active(); } + // The last attempt finished not-ok and the user has not started another. + bool pairingRefused(const QString& uuid) const; + // Why it finished not-ok, as the pairingFinished token. Empty when the last + // attempt on this host did not fail. The surfaces need it because a refused + // PIN and an unreachable host are the same STATE and different advice. + QString pairingRefusedReason(const QString& uuid) const; + + // Re-asks the host what it is: reachable, still paired, still the same + // machine. Client-initiated by definition; call it on entering a screen and + // before starting a session, never on a timer. + void probe(const QString& uuid); + + // GET /applist over the pinned mutual-TLS channel. The list is HTTPS and + // paired-only, so an unpaired host answers 404 and this reports a failure + // the UI renders rather than an empty list it would present as truth. + void refreshApps(const QString& uuid); + QList apps(const QString& uuid) const; + + // Drops EVERYTHING this subsystem holds about one host: the remembered row + // (the pairing anchor lives in it, so the pin goes with it), the discovered + // entry, the probe verdict, the app list, the bindings and the session that + // carried them. Callers above own the routes and the per-slot picks and + // must retire those first; AppModel::forgetMoonlightHost is that caller. + void forget(const QString& uuid); + + // Tells a paired host to end whatever app it is running, tearing down our + // own session first when we hold one. The protocol's own way out of "an app + // is already running", and the only one when the host will not hand that + // session over. /cancel answers 200 either way, so this re-probes after. + void quitHostApp(const QString& uuid); + + void setLastApp(const QString& uuid, const QString& appId, const QString& appName); + void setControllerType(const QString& uuid, int type); + + // ── Bindings (the reference count) ────────────────────────────────────── + // Records the binding and, once the host is paired, starts or joins its + // session and announces this pad. Returns the controller number, or nullopt + // when the host is not paired yet (the binding is still recorded: a binding + // is a durable intent and the session is attempted when the pad is used). + std::optional bindController(const QString& slotId, const QString& uuid, + int storedType, + const moonlight::SourceCapabilities& source); + void unbindController(const QString& slotId); + QString boundHostFor(const QString& slotId) const; + QStringList boundSlots(const QString& uuid) const; + int controllerCount(const QString& uuid) const; + std::optional controllerNumber(const QString& slotId) const; + + // Everything the binding-flow render contract reads about one host, from + // the point of view of `slotId` (empty for a binding that does not exist + // yet). Pure data; MoonlightSessionUi turns it into exactly one state. + moonlight::SessionUiInputs uiInputs(const QString& uuid, const QString& slotId) const; + + // The live session for a host, or nullptr. The routing layer holds the + // returned pointer only for the duration of one call. + MoonlightSession* session(const QString& uuid) const; + + // Host->local actuation: wired to the same SDL output plumbing the + // satellite rumble/LED path uses. The slot is already resolved from the + // event's controller number, because one session drives up to four pads. + using RumbleSink = + std::function; + using LedSink = + std::function; + void setRumbleSink(RumbleSink sink) { rumbleSink_ = std::move(sink); } + void setLedSink(LedSink sink) { ledSink_ = std::move(sink); } + + signals: + void rowsChanged(); + void scanningChanged(); + void pairingChanged(); + // reasonToken: "" on success, else "unreachable"|"wrongPin"|"declined"|"crypto". + void pairingFinished(const QString& uuid, bool ok, const QString& reasonToken); + // reasonToken: one of MoonlightSession's, e.g. "unreachable"|"trustLost" + // |"appAlreadyRunning"|"resumeFailed"|"dropped"|"hostEnded". + void sessionFailed(const QString& uuid, const QString& reasonToken); + void hostAppCancelled(const QString& uuid, bool ok); + void appsChanged(const QString& uuid); + void probeFinished(const QString& uuid); + + private: + // What the last probe of one host learned. Absent means never asked. + struct HostProbe { + bool inFlight = false; + bool answered = false; + bool paired = false; + bool identityChanged = false; + bool trustRejected = false; + }; + + // The /applist read for one host. + struct AppCache { + bool inFlight = false; + bool read = false; + bool failed = false; + QList apps; + }; + + void ensureIdentityLoaded(); + void onDiscovered(const QList& hosts); + // Records the refusal, says so in the log, and tells the surfaces. Every + // way pair() can end without reaching the wire goes through here: a Pair + // that returns quietly is a Pair the user watches do nothing. + void refusePairing(const QString& uuid, const QString& reasonToken); + // Promotes a host the user has ACTED on from the scan set to the remembered + // store, still unpaired. Interest is durable: a host that exists only in a + // discovery result cannot carry a binding, keep an app pick or survive the + // next sweep, so choosing it as a destination has to write it down. + void rememberDestination(const QString& uuid); + // Bumped by forget(). A reply captures the epoch its request was made + // under and drops itself when it no longer matches, because probes_ and + // appCache_ are written through QHash::operator[], which INSERTS: a late + // callback would otherwise re-create the record forget() just dropped and + // leave a forgotten host rendering the trust it had before. + quint64 epochOf(const QString& uuid) const { return epochs_.value(uuid, 0); } + MoonlightSession* ensureSession(const repository::MoonlightHost& host); + void wireSession(MoonlightSession* session, const QString& uuid); + // Starts the session if nothing is running on it yet. The app comes from + // the remembered pick, or the first row /applist returned, or the host's + // own default. + void ensureSessionRunning(MoonlightSession* session, const repository::MoonlightHost& host); + + std::shared_ptr settings_; + repository::MoonlightIdentityRepository identityRepo_; + repository::MoonlightHostRepository hostRepo_; + MoonlightHttp* http_; + std::unique_ptr pairingFlow_; + + QString deviceName_; + bool identityReady_ = false; + + bool scanning_ = false; + // Discovered-but-not-yet-remembered hosts, keyed by a synthetic uuid + // ("addr:
") until serverinfo hands back the real one. + QHash discovered_; + QHash sessions_; + QHash probes_; + QHash appCache_; + // The host whose last pairing attempt was refused, cleared when another + // one starts or the row is forgotten, and the token that says why. + QString pairingRefusedUuid_; + QString pairingRefusedReason_; + // uuid -> forget generation. Absent is generation 0, so a host nobody has + // forgotten costs nothing; the entry SURVIVES forget() because it is the + // tombstone the in-flight replies are compared against. + QHash epochs_; + // slotId -> host uuid. The binding table; the session's own PadSlots owns + // the controller numbers. + QHash bindings_; + + RumbleSink rumbleSink_; + LedSink ledSink_; +}; + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightPairingFlow.cpp b/src/source/moonlight/MoonlightPairingFlow.cpp new file mode 100644 index 0000000..b669272 --- /dev/null +++ b/src/source/moonlight/MoonlightPairingFlow.cpp @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightPairingFlow.h" + +#include "core/moonlight/MoonlightPairingCrypto.h" +#include "core/moonlight/MoonlightXml.h" +#include "source/moonlight/MoonlightLog.h" + +#include +#include +#include + +namespace dish::source::moon { +namespace { + +bool fillRandom(std::array& out) { + return mooncrypto::randomBytes(out.data(), out.size()); +} + +// The host reports a refused pairing phase in the body, not the status line, so +// the phase log carries both. +void logPhase(const char* phase, const QString& address, int status, const std::string& xml) { + const auto refusal = moonxml::parseStatus(xml); + const QString says = refusal ? QStringLiteral("%1 %2") + .arg(refusal->code) + .arg(QString::fromStdString(refusal->message)) + : QStringLiteral("(no root element)"); + qCInfo(lcMoon) << "pair" << phase << "on" << address << "HTTP" << status << "host" << says + << "paired" << moonxml::pairedFlag(xml); +} + +} // namespace + +MoonlightPairingFlow::MoonlightPairingFlow(MoonlightHttp* http, QObject* parent) + : QObject(parent), http_(http) {} + +void MoonlightPairingFlow::start(const QString& hostUuid, const QString& address, int httpPort, + int httpsPort, const QString& clientCertPem, + const QString& clientKeyPem, const QString& deviceName) { + ++attempt_; + active_ = true; + hostUuid_ = hostUuid; + address_ = address; + httpPort_ = httpPort; + httpsPort_ = httpsPort; + deviceName_ = deviceName; + + std::array salt{}; + std::array challenge{}; + std::array secret{}; + std::uint32_t pinRandom = 0; + if (!fillRandom(salt) || !fillRandom(challenge) || !fillRandom(secret) || + !mooncrypto::randomBytes(reinterpret_cast(&pinRandom), sizeof(pinRandom))) { + fail(QStringLiteral("crypto")); + return; + } + pin_ = QString::fromStdString(moonpair::pinFromRandom(pinRandom)); + session_ = std::make_unique(clientCertPem.toStdString(), + clientKeyPem.toStdString(), salt, + pin_.toStdString(), challenge, secret); + emit pinReady(pin_); + phase1(); +} + +void MoonlightPairingFlow::cancel() { + ++attempt_; + active_ = false; + session_.reset(); + pin_.clear(); +} + +void MoonlightPairingFlow::fail(const QString& reasonToken) { + qCWarning(lcMoon) << "pairing with" << address_ << "gave up:" << reasonToken; + active_ = false; + session_.reset(); + emit finished(false, reasonToken, QString()); +} + +void MoonlightPairingFlow::phase1() { + QUrlQuery query; + query.addQueryItem(QStringLiteral("devicename"), deviceName_); + query.addQueryItem(QStringLiteral("updateState"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("phrase"), QStringLiteral("getservercert")); + query.addQueryItem(QStringLiteral("salt"), QString::fromStdString(session_->saltHex())); + query.addQueryItem(QStringLiteral("clientcert"), + QString::fromStdString(session_->clientCertHex())); + // Blocks host-side until the user types the PIN, hence the long timeout. + http_->getPlain( + address_, httpPort_, QStringLiteral("/pair"), query, + [this, attempt = attempt_](int status, const QByteArray& body) { + if (!current(attempt)) { return; } + const std::string xml = body.toStdString(); + logPhase("getservercert", address_, status, xml); + if (status != 200 || !moonxml::pairedFlag(xml)) { + fail(status == 0 ? QStringLiteral("unreachable") : QStringLiteral("declined")); + return; + } + const auto plaincert = moonxml::tagValue(xml, "plaincert"); + if (!plaincert || !session_->acceptServerCert(*plaincert)) { + fail(QStringLiteral("crypto")); + return; + } + phase2(); + }, + MoonlightHttp::kPairingTimeoutMs); +} + +void MoonlightPairingFlow::phase2() { + const auto challenge = session_->clientChallengeHex(); + if (!challenge) { + fail(QStringLiteral("crypto")); + return; + } + QUrlQuery query; + query.addQueryItem(QStringLiteral("devicename"), deviceName_); + query.addQueryItem(QStringLiteral("updateState"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("clientchallenge"), QString::fromStdString(*challenge)); + http_->getPlain(address_, httpPort_, QStringLiteral("/pair"), query, + [this, attempt = attempt_](int status, const QByteArray& body) { + if (!current(attempt)) { return; } + const std::string xml = body.toStdString(); + logPhase("clientchallenge", address_, status, xml); + const auto response = moonxml::tagValue(xml, "challengeresponse"); + if (status != 200 || !moonxml::pairedFlag(xml) || !response) { + fail(status == 0 ? QStringLiteral("unreachable") + : QStringLiteral("declined")); + return; + } + const auto next = session_->acceptChallengeResponse(*response); + if (!next) { + fail(QStringLiteral("crypto")); + return; + } + phase3(QString::fromStdString(*next)); + }); +} + +void MoonlightPairingFlow::phase3(const QString& serverChallengeResp) { + QUrlQuery query; + query.addQueryItem(QStringLiteral("devicename"), deviceName_); + query.addQueryItem(QStringLiteral("updateState"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("serverchallengeresp"), serverChallengeResp); + http_->getPlain(address_, httpPort_, QStringLiteral("/pair"), query, + [this, attempt = attempt_](int status, const QByteArray& body) { + if (!current(attempt)) { return; } + const std::string xml = body.toStdString(); + logPhase("serverchallengeresp", address_, status, xml); + const auto secret = moonxml::tagValue(xml, "pairingsecret"); + if (status != 200 || !moonxml::pairedFlag(xml) || !secret) { + fail(status == 0 ? QStringLiteral("unreachable") + : QStringLiteral("declined")); + return; + } + // The failing case here is exactly what a mistyped PIN + // produces: the phase-2 hash never matches. + if (!session_->acceptPairingSecret(*secret)) { + fail(QStringLiteral("wrongPin")); + return; + } + phase4(); + }); +} + +void MoonlightPairingFlow::phase4() { + const auto clientSecret = session_->clientPairingSecretHex(); + if (!clientSecret) { + fail(QStringLiteral("crypto")); + return; + } + QUrlQuery query; + query.addQueryItem(QStringLiteral("devicename"), deviceName_); + query.addQueryItem(QStringLiteral("updateState"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("clientpairingsecret"), + QString::fromStdString(*clientSecret)); + http_->getPlain(address_, httpPort_, QStringLiteral("/pair"), query, + [this, attempt = attempt_](int status, const QByteArray& body) { + if (!current(attempt)) { return; } + logPhase("clientpairingsecret", address_, status, body.toStdString()); + if (status != 200 || !moonxml::pairedFlag(body.toStdString())) { + fail(status == 0 ? QStringLiteral("unreachable") + : QStringLiteral("wrongPin")); + return; + } + phase5(); + }); +} + +void MoonlightPairingFlow::phase5() { + // Over HTTPS with the freshly-authorized client cert, pinned against the + // server cert phase 1 delivered: proves the secure channel end to end. + QUrlQuery query; + query.addQueryItem(QStringLiteral("devicename"), deviceName_); + query.addQueryItem(QStringLiteral("updateState"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("phrase"), QStringLiteral("pairchallenge")); + const QString serverCert = QString::fromStdString(session_->serverCertPem()); + http_->getTls(address_, httpsPort_, QStringLiteral("/pair"), query, serverCert, + [this, serverCert, attempt = attempt_](int status, const QByteArray& body) { + if (!current(attempt)) { return; } + logPhase("pairchallenge", address_, status, body.toStdString()); + if (status != 200 || !moonxml::pairedFlag(body.toStdString())) { + fail(QStringLiteral("unreachable")); + return; + } + active_ = false; + session_.reset(); + emit finished(true, QString(), serverCert); + }); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightPairingFlow.h b/src/source/moonlight/MoonlightPairingFlow.h new file mode 100644 index 0000000..2ad4835 --- /dev/null +++ b/src/source/moonlight/MoonlightPairingFlow.h @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Drives the 5-phase Moonlight PIN pairing over MoonlightHttp: generates the +// PIN and the handshake's random material, walks core/moonlight's +// PairingSession through the phase responses, and finishes with the HTTPS +// pairchallenge that proves the secure channel works end to end. One attempt +// at a time; a second start() cancels the first. +// +// The PIN is shown by THIS client and typed into the host's UI (for example +// Sunshine's web PIN page) — the reverse of the satellite flow's direction. + +#pragma once + +#include "core/moonlight/MoonlightPairing.h" +#include "source/moonlight/MoonlightHttp.h" + +#include +#include + +#include + +namespace dish::source::moon { + +class MoonlightPairingFlow : public QObject { + Q_OBJECT + public: + explicit MoonlightPairingFlow(MoonlightHttp* http, QObject* parent = nullptr); + + // Begins pairing against `address:httpPort` (phases 1-4, plain HTTP) and + // `httpsPort` (phase 5). Emits pinReady() immediately with the PIN the + // user must type into the host, then finished() once the host answers. + void start(const QString& hostUuid, const QString& address, int httpPort, int httpsPort, + const QString& clientCertPem, const QString& clientKeyPem, + const QString& deviceName); + + void cancel(); + + bool active() const { return active_; } + QString pin() const { return pin_; } + QString hostUuid() const { return hostUuid_; } + + signals: + // The 4-digit PIN to show. Fired from start(). + void pinReady(const QString& pin); + + // `reasonToken` on failure: "unreachable" | "wrongPin" | "declined" | + // "crypto". On success `serverCertPem` is the pairing anchor to persist. + void finished(bool ok, const QString& reasonToken, const QString& serverCertPem); + + private: + void phase1(); + void phase2(); + void phase3(const QString& serverChallengeResp); + void phase4(); + void phase5(); + void fail(const QString& reasonToken); + // True while this reply still belongs to the current attempt. + bool current(quint64 attempt) const { return active_ && attempt == attempt_; } + + MoonlightHttp* http_; + std::unique_ptr session_; + + bool active_ = false; + quint64 attempt_ = 0; // stale-reply guard across cancel/restart + QString hostUuid_; + QString address_; + int httpPort_ = 0; + int httpsPort_ = 0; + QString deviceName_; + QString pin_; +}; + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightRtspClient.cpp b/src/source/moonlight/MoonlightRtspClient.cpp new file mode 100644 index 0000000..516ed70 --- /dev/null +++ b/src/source/moonlight/MoonlightRtspClient.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightRtspClient.h" + +#include "source/moonlight/MoonlightLog.h" + +#include +#include +#include + +#include + +namespace dish::source::moon { +namespace { + +// Line ends spelled out, so a framing bug is readable in a log line. +QString escaped(const QByteArray& raw) { + QString out = QString::fromLatin1(raw.left(512)); + out.replace(QLatin1String("\r"), QLatin1String("\\r")); + out.replace(QLatin1String("\n"), QLatin1String("\\n")); + return out; +} + +// Offset just past the blank line that ends the head, or -1 while it is short. +int headEnd(const QByteArray& buffer) { + const int crlf = static_cast(buffer.indexOf("\r\n\r\n")); + if (crlf >= 0) { return crlf + 4; } + const int lf = static_cast(buffer.indexOf("\n\n")); + if (lf >= 0) { return lf + 2; } + return -1; +} + +} // namespace + +MoonlightRtspClient::MoonlightRtspClient(QObject* parent) + : QObject(parent), timeout_(new QTimer(this)) { + timeout_->setSingleShot(true); + QObject::connect(timeout_, &QTimer::timeout, this, [this] { + qCWarning(lcMoon) << "rtsp" << stage_ << "timed out after" << kRequestTimeoutMs << "ms"; + finish(std::nullopt); + }); +} + +MoonlightRtspClient::~MoonlightRtspClient() { dropSocket(); } + +void MoonlightRtspClient::open(const QString& address, int port) { + close(); + address_ = address; + port_ = port; + open_ = true; + const unsigned generation = generation_; + qCDebug(lcMoon) << "rtsp endpoint" << address_ << port_; + // Nothing to dial: every request brings its own socket. The ready notice + // is queued so the caller's effect loop is not re-entered. + QMetaObject::invokeMethod( + this, + [this, generation] { + if (open_ && generation == generation_) { emit connected(); } + }, + Qt::QueuedConnection); +} + +void MoonlightRtspClient::close() { + ++generation_; + open_ = false; + finish(std::nullopt); + dropSocket(); + buffer_.clear(); + outgoing_.clear(); +} + +bool MoonlightRtspClient::isOpen() const { return open_; } + +void MoonlightRtspClient::dropSocket() { + if (socket_ == nullptr) { return; } + QTcpSocket* socket = socket_; + socket_ = nullptr; + socket->disconnect(this); + socket->abort(); // no graceful RTSP TEARDOWN exists in this dialect + socket->deleteLater(); +} + +void MoonlightRtspClient::request(const QString& text, ResponseCb cb) { + finish(std::nullopt); // supersede any stalled request + dropSocket(); + buffer_.clear(); + if (!open_) { + qCWarning(lcMoon) << "rtsp request with no endpoint"; + if (cb) { cb(std::nullopt); } + return; + } + + stage_ = text.section(QLatin1Char('\n'), 0, 0).trimmed(); + outgoing_ = text.toUtf8(); + pending_ = std::move(cb); + + socket_ = new QTcpSocket(this); + QObject::connect(socket_, &QTcpSocket::connected, this, &MoonlightRtspClient::onConnected); + QObject::connect(socket_, &QTcpSocket::readyRead, this, &MoonlightRtspClient::onReadyRead); + QObject::connect(socket_, &QTcpSocket::disconnected, this, + &MoonlightRtspClient::onDisconnected); + QObject::connect( + socket_, &QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError error) { + if (error == QAbstractSocket::RemoteHostClosedError) { + return; // the hang-up that frames the reply + } + qCWarning(lcMoon) << "rtsp" << stage_ << "failed:" + << (socket_ != nullptr ? socket_->errorString() : QString()); + finish(std::nullopt); + emit transportError(); + }); + timeout_->start(kRequestTimeoutMs); + socket_->connectToHost(address_, static_cast(port_)); +} + +void MoonlightRtspClient::onConnected() { + qCDebug(lcMoon) << "rtsp ->" << stage_; + if (socket_ != nullptr) { socket_->write(outgoing_); } +} + +void MoonlightRtspClient::onReadyRead() { + if (socket_ == nullptr) { return; } + buffer_.append(socket_->readAll()); + tryComplete(false); +} + +void MoonlightRtspClient::onDisconnected() { + if (socket_ != nullptr) { buffer_.append(socket_->readAll()); } + // The host hangs up once it has answered, so end-of-stream is a framing + // signal and not by itself a failure. + tryComplete(true); +} + +void MoonlightRtspClient::tryComplete(bool atEof) { + if (!pending_) { return; } + const int bodyAt = headEnd(buffer_); + if (bodyAt < 0) { + if (!atEof) { return; } + qCWarning(lcMoon) << "rtsp" << stage_ + << "closed before a complete reply head:" << escaped(buffer_); + finish(std::nullopt); + return; + } + const auto head = moonrtsp::parseResponse( + std::string_view(buffer_.constData(), static_cast(bodyAt))); + if (!head) { + if (!atEof) { return; } + qCWarning(lcMoon) << "rtsp" << stage_ << "unparsable reply:" << escaped(buffer_); + finish(std::nullopt); + return; + } + const auto declared = moonrtsp::contentLength(*head); + if (declared) { + const int have = static_cast(buffer_.size()) - bodyAt; + if (have < *declared && !atEof) { return; } + } else if (!atEof) { + // No Content-length: the DESCRIBE shape. The close is the frame, so + // wait for it rather than truncating the payload. + return; + } + finish(moonrtsp::parseResponse( + std::string_view(buffer_.constData(), static_cast(buffer_.size())))); +} + +void MoonlightRtspClient::finish(const std::optional& response) { + timeout_->stop(); + if (!pending_) { return; } + ResponseCb cb = std::move(pending_); + pending_ = nullptr; + if (response) { + if (response->ok()) { + qCDebug(lcMoon) << "rtsp <-" << stage_ << response->statusCode + << QString::fromStdString(response->statusMessage); + } else { + qCWarning(lcMoon) << "rtsp" << stage_ << "refused:" << response->statusCode + << QString::fromStdString(response->statusMessage); + } + } + cb(response); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightRtspClient.h b/src/source/moonlight/MoonlightRtspClient.h new file mode 100644 index 0000000..5111d7a --- /dev/null +++ b/src/source/moonlight/MoonlightRtspClient.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The RTSP handshake transport: ONE TCP CONNECTION PER MESSAGE, because that +// is all a Moonlight host will give. The host answers exactly one RTSP message +// and then hangs up on its own: an idle read taken straight after the OPTIONS +// reply returns end-of-stream, and so does the same read after a DESCRIBE +// reply, so it is having answered that ends the connection and not which +// command was asked. A second message written into that socket is never seen at +// all. So every request dials its own socket, asks, reads the answer and +// closes, the same shape the HTTP half already has. +// +// That hang-up frames the body as much as Content-length does: the DESCRIBE +// reply carries no length header, so a reply without one is read to EOF. +// +// Formatting/parsing lives in core/moonlight/MoonlightRtsp; this class only +// moves bytes, frames replies and applies per-request timeouts. + +#pragma once + +#include "core/moonlight/MoonlightRtsp.h" + +#include +#include +#include + +#include +#include + +class QTcpSocket; +class QTimer; + +namespace dish::source::moon { + +class MoonlightRtspClient : public QObject { + Q_OBJECT + public: + explicit MoonlightRtspClient(QObject* parent = nullptr); + ~MoonlightRtspClient() override; + + // nullopt = timeout, transport drop, or an unparsable response. + using ResponseCb = std::function&)>; + + // Records the endpoint every later request dials — the REAL host address; + // the launch response's rtsp target string is only ever parroted inside + // requests. Nothing is dialled here, so `connected` is reported on the next + // event-loop turn and the first real reachability answer arrives with the + // first request. + void open(const QString& address, int port); + void close(); + bool isOpen() const; + + // Sends one formatted request over its own socket and delivers its + // response. A request while another is pending fails the pending one first. + void request(const QString& text, ResponseCb cb); + + // The step in flight, as it would be named in a log line. + const QString& stage() const { return stage_; } + + signals: + void connected(); + void transportError(); + + private: + void onConnected(); + void onReadyRead(); + void onDisconnected(); + void tryComplete(bool atEof); + void finish(const std::optional& response); + void dropSocket(); + + QString address_; + int port_ = 0; + bool open_ = false; + // Bumped by every open()/close() so a queued ready notice from a superseded + // endpoint cannot reach the caller. + unsigned generation_ = 0; + + QTcpSocket* socket_ = nullptr; + QTimer* timeout_; + QByteArray buffer_; + QByteArray outgoing_; + ResponseCb pending_; + QString stage_; + + static constexpr int kRequestTimeoutMs = 5000; +}; + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightSession.cpp b/src/source/moonlight/MoonlightSession.cpp new file mode 100644 index 0000000..921c12e --- /dev/null +++ b/src/source/moonlight/MoonlightSession.cpp @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. + +#include "source/moonlight/MoonlightSession.h" + +#include "Util/Hex.h" +#include "core/moonlight/MoonlightButtonMap.h" +#include "core/moonlight/MoonlightPairingCrypto.h" +#include "core/moonlight/MoonlightProtocol.h" +#include "core/moonlight/MoonlightXml.h" +#include "source/moonlight/MoonlightLog.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace dish::source::moon { +namespace { + +QString stepStreamId(moonlight::RtspStep step) { + switch (step) { + case moonlight::RtspStep::SetupAudio: + return QStringLiteral("audio"); + case moonlight::RtspStep::SetupVideo: + return QStringLiteral("video"); + case moonlight::RtspStep::SetupControl: + return QStringLiteral("control"); + default: + return {}; + } +} + +// Cadence of the RTP hole-punch pings, matching what real clients send. One +// datagram per port would be fragile: lose it and the host never learns our +// media address. +constexpr int kRtpPingIntervalMs = 500; + +// What the host said in the body of its reply, for one log line. +QString hostSays(const std::optional& status) { + if (!status) { return QStringLiteral("(no root element)"); } + return QStringLiteral("%1 %2").arg(status->code).arg(QString::fromStdString(status->message)); +} + +QString failureToken(moonlight::SessionFailure failure) { + switch (failure) { + case moonlight::SessionFailure::Unreachable: + return QStringLiteral("unreachable"); + case moonlight::SessionFailure::NotPaired: + return QStringLiteral("notPaired"); + case moonlight::SessionFailure::TrustLost: + return QStringLiteral("trustLost"); + case moonlight::SessionFailure::HostReplaced: + return QStringLiteral("hostReplaced"); + case moonlight::SessionFailure::LaunchRejected: + return QStringLiteral("launchRejected"); + case moonlight::SessionFailure::AppAlreadyRunning: + return QStringLiteral("appAlreadyRunning"); + case moonlight::SessionFailure::ResumeFailed: + return QStringLiteral("resumeFailed"); + case moonlight::SessionFailure::RtspRejected: + return QStringLiteral("rtspRejected"); + case moonlight::SessionFailure::ControlLost: + return QStringLiteral("controlLost"); + case moonlight::SessionFailure::Dropped: + return QStringLiteral("dropped"); + case moonlight::SessionFailure::HostEnded: + default: + return QStringLiteral("hostEnded"); + } +} + +} // namespace + +MoonlightSession::MoonlightSession(MoonlightHttp* http, repository::MoonlightHost host, + QObject* parent) + : QObject(parent), http_(http), host_(std::move(host)), + control_(std::make_unique()), + rtsp_(std::make_unique()) { + control_->setLinkHandler([this](bool connected) { + // Hops off the control-stream service thread onto the Qt loop. + QMetaObject::invokeMethod( + this, + [this, connected] { + qCInfo(lcMoon) << "control link to" << host_.address << (connected ? "up" : "down"); + dispatch(connected + ? moonlight::SessionEvent{moonlight::moon_event::ControlConnected{}} + : moonlight::SessionEvent{moonlight::moon_event::ControlLost{}}); + }, + Qt::QueuedConnection); + }); + control_->setEventHandler([this](const moonwire::HostEvent& event) { onHostEvent(event); }); + + QObject::connect(rtsp_.get(), &MoonlightRtspClient::connected, this, + [this] { dispatch(moonlight::moon_event::RtspReady{}); }); + QObject::connect(rtsp_.get(), &MoonlightRtspClient::transportError, this, + [this] { dispatch(moonlight::moon_event::RtspFailed{}); }); + + rtpPingTimer_ = new QTimer(this); + rtpPingTimer_->setInterval(kRtpPingIntervalMs); + QObject::connect(rtpPingTimer_, &QTimer::timeout, this, &MoonlightSession::sendRtpPings); +} + +MoonlightSession::~MoonlightSession() { + // No /cancel from here: the shared HTTP gateway is a sibling child of the + // manager and may already be gone. The manager stops every session first, + // which is where a stranded app is handed back. + launched_ = false; + teardown(); +} + +void MoonlightSession::start(const QString& appId, const QString& appName) { + appId_ = appId; + appName_ = appName; + rtspCseq_ = 1; + rikeyReady_ = false; + launched_ = false; + wentLive_ = false; + everStarted_ = true; + refusalMessage_.clear(); + stream_ = moonrtsp::StreamConfig{}; + qCInfo(lcMoon) << "session start on" << host_.address << "app" << appId_ << "pads" + << slots_.size(); + dispatch(moonlight::moon_event::StartRequested{}); +} + +void MoonlightSession::stop(bool handBackApp) { + qCInfo(lcMoon) << "session stop requested on" << host_.address << "hand back" << handBackApp; + handBackOnTeardown_ = handBackApp; + dispatch(moonlight::moon_event::StopRequested{}); + handBackOnTeardown_ = false; +} + +std::optional +MoonlightSession::attachController(const QString& slotId, int storedType, + const moonlight::SourceCapabilities& source) { + const auto number = slots_.assign(slotId.toStdString()); + if (!number) { return std::nullopt; } + PadDeclaration pad; + pad.number = *number; + pad.type = moonlight::resolveControllerType(storedType, source.motion); + pad.capabilities = moonlight::declaredCapabilities(pad.type, source); + pad.buttons = moonlight::declaredButtons(pad.capabilities); + pads_.insert(slotId, pad); + activeMask_.store(slots_.activeMask(), std::memory_order_relaxed); + qCInfo(lcMoon) << "pad" << slotId << "takes controller" << pad.number << "on" << host_.address + << "type" << pad.type << "caps" << pad.capabilities << "mask" + << activeMask_.load(std::memory_order_relaxed); + announcePad(slotId); + return number; +} + +std::size_t MoonlightSession::detachController(const QString& slotId) { + const auto released = slots_.release(slotId.toStdString()); + pads_.remove(slotId); + const std::uint16_t mask = slots_.activeMask(); + activeMask_.store(mask, std::memory_order_relaxed); + // The unplug IS the packet: the controller is still named, its bit is gone. + if (released && control_ && control_->isConnected()) { + control_->sendControllerMulti(*released, mask, 0, 0, 0, 0, 0, 0, 0); + } + qCInfo(lcMoon) << "pad" << slotId << "left" << host_.address << "mask" << mask << "remaining" + << slots_.size(); + return slots_.size(); +} + +std::optional MoonlightSession::controllerNumber(const QString& slotId) const { + return slots_.numberFor(slotId.toStdString()); +} + +QString MoonlightSession::slotForController(std::uint8_t number) const { + const auto slot = slots_.slotFor(number); + return slot ? QString::fromStdString(*slot) : QString(); +} + +void MoonlightSession::announcePad(const QString& slotId) { + if (!control_ || !control_->isConnected()) { return; } + const auto it = pads_.constFind(slotId); + if (it == pads_.constEnd()) { return; } + control_->sendControllerArrival(it->number, it->type, it->capabilities, it->buttons); +} + +void MoonlightSession::dispatch(const moonlight::SessionEvent& event) { + run(moonlight::reduce(machine_, event)); +} + +void MoonlightSession::run(const moonlight::Reduction& reduction) { + if (reduction.next) { + machine_ = *reduction.next; + switch (machine_.phase) { + case moonlight::SessionPhase::Idle: + setLinkState(MoonlightLinkState::Idle); + break; + case moonlight::SessionPhase::Streaming: + setLinkState(MoonlightLinkState::Live); + break; + case moonlight::SessionPhase::Failed: + setLinkState(MoonlightLinkState::Failed); + break; + default: + setLinkState(MoonlightLinkState::Linking); + break; + } + } + for (const auto effect : reduction.effects) { runEffect(effect); } +} + +void MoonlightSession::runEffect(moonlight::SessionEffect effect) { + using moonlight::SessionEffect; + switch (effect) { + case SessionEffect::FetchServerInfo: + fetchServerInfo(); + break; + case SessionEffect::SendLaunch: + sendLaunch(); + break; + case SessionEffect::OpenRtsp: + openRtsp(); + break; + case SessionEffect::SendRtspOptions: + sendRtspStep(moonlight::RtspStep::Options); + break; + case SessionEffect::SendRtspDescribe: + sendRtspStep(moonlight::RtspStep::Describe); + break; + case SessionEffect::SendRtspSetupAudio: + sendRtspStep(moonlight::RtspStep::SetupAudio); + break; + case SessionEffect::SendRtspSetupVideo: + sendRtspStep(moonlight::RtspStep::SetupVideo); + break; + case SessionEffect::SendRtspSetupControl: + sendRtspStep(moonlight::RtspStep::SetupControl); + break; + case SessionEffect::SendRtspAnnounce: + sendRtspStep(moonlight::RtspStep::Announce); + break; + case SessionEffect::SendRtspPlay: + sendRtspStep(moonlight::RtspStep::Play); + break; + case SessionEffect::ConnectControl: + connectControl(); + break; + case SessionEffect::StartStreaming: + startStreaming(); + break; + case SessionEffect::SendTermination: + if (control_) { control_->stop(true); } + break; + case SessionEffect::Teardown: + teardown(); + break; + case SessionEffect::NotifyFailure: + if (machine_.failure) { + const QString token = failureToken(*machine_.failure); + qCWarning(lcMoon) << "session on" << host_.address << "gave up:" << token; + emit failed(token); + } + break; + } +} + +void MoonlightSession::setLinkState(MoonlightLinkState state) { + if (linkState_ == state) { return; } + linkState_ = state; + emit linkStateChanged(); +} + +void MoonlightSession::fetchServerInfo() { + http_->getPlain(host_.address, host_.httpPort, QStringLiteral("/serverinfo"), QUrlQuery(), + [this](int status, const QByteArray& body) { + if (status != 200) { + qCWarning(lcMoon) + << "serverinfo on" << host_.address << "answered HTTP" << status; + dispatch(moonlight::moon_event::ServerInfoFailed{}); + return; + } + const std::string xml = body.toStdString(); + const auto info = moonxml::parseServerInfo(xml); + if (!info) { + const auto refusal = moonxml::parseStatus(xml); + qCWarning(lcMoon) << "serverinfo on" << host_.address + << "unusable: host" << hostSays(refusal); + dispatch(moonlight::moon_event::ServerInfoFailed{}); + return; + } + // Ask for the host's own display rather than a small mode: an + // Apollo/Vibepollo virtual display follows what the client asks + // for, and a small request resizes the user's desktop under them. + if (const auto mode = moonxml::preferredDisplayMode(info->displayModes)) { + stream_.width = mode->width; + stream_.height = mode->height; + if (mode->refreshRate > 0) { stream_.fps = mode->refreshRate; } + } + qCInfo(lcMoon) << "serverinfo on" << host_.address << "paired" + << (info->pairStatus == 1) << "currentgame" + << info->currentGame << "mode" << stream_.width << "x" + << stream_.height << "@" << stream_.fps; + moonlight::moon_event::ServerInfoOk ev; + ev.paired = info->pairStatus == 1; + ev.currentGame = info->currentGame; + dispatch(ev); + }); +} + +void MoonlightSession::sendLaunch() { + // One control-stream key per attempt (Wolf keys the control AES-GCM on + // this rikey; rikeyid feeds nothing this client must vary, so it stays 0). + // A launch that promotes to /resume keeps the key it already announced. + if (!rikeyReady_) { + if (!mooncrypto::randomBytes(rikey_.data(), rikey_.size())) { + qCWarning(lcMoon) << "launch on" << host_.address + << "aborted: no entropy for the rikey"; + dispatch(moonlight::moon_event::LaunchFailed{}); + return; + } + rikeyId_ = 0; + rikeyReady_ = true; + } + + const bool resuming = machine_.resuming; + QUrlQuery query; + if (!resuming) { + query.addQueryItem(QStringLiteral("appid"), appId_); + query.addQueryItem( + QStringLiteral("mode"), + QStringLiteral("%1x%2x%3").arg(stream_.width).arg(stream_.height).arg(stream_.fps)); + query.addQueryItem(QStringLiteral("additionalStates"), QStringLiteral("1")); + // sops=0: never let the host change the user's display settings. + query.addQueryItem(QStringLiteral("sops"), QStringLiteral("0")); + } + query.addQueryItem(QStringLiteral("rikey"), + QString::fromStdString(util::toHex(rikey_.data(), rikey_.size()))); + query.addQueryItem(QStringLiteral("rikeyid"), QString::number(rikeyId_)); + // 1, not 0. The user of a dish is sitting AT the host with the pad in their + // hands, so asking the host not to play audio locally would silence their + // own speakers for the length of the session. + query.addQueryItem(QStringLiteral("localAudioPlayMode"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("surroundAudioInfo"), QStringLiteral("196610")); + const QString path = resuming ? QStringLiteral("/resume") : QStringLiteral("/launch"); + http_->getTls(host_.address, host_.httpsPort, path, query, host_.serverCertPem, + [this, path](int status, const QByteArray& body) { + const std::string xml = body.toStdString(); + const auto refusal = moonxml::parseStatus(xml); + const auto launch = moonxml::parseLaunch(xml); + qCInfo(lcMoon) + << path << "on" << host_.address << "HTTP" << status << "host" + << hostSays(refusal) << "rtsp port" << (launch ? launch->rtspPort : 0); + if (status == 200 && launch && launch->launched) { + rtspTarget_ = QStringLiteral("rtsp://%1:%2") + .arg(QString::fromStdString(launch->rtspHost)) + .arg(launch->rtspPort); + rtspPort_ = launch->rtspPort; + // The host's launch reply may hand out a fake session IP; + // dial the host we already know, not the parroted string. + rtspHostAddress_ = host_.address; + launched_ = true; + dispatch(moonlight::moon_event::LaunchOk{}); + return; + } + // A HOST SAYS NO IN THE BODY, NOT IN THE STATUS LINE: a second + // /launch is answered HTTP 200 carrying status_code="400" and "An + // app is already running on this host". Reading only the HTTP + // status turns that into a missing sessionUrl0 further down and + // names the wrong thing. + if (refusal && refusal->appAlreadyRunning()) { + qCInfo(lcMoon) << host_.address << "already has an app running; resume" + << refusal->resume; + dispatch(moonlight::moon_event::LaunchBusy{refusal->resume}); + return; + } + qCWarning(lcMoon) << path << "refused by" << host_.address << ":" + << QString::fromUtf8(body.left(512)); + if (refusal && !refusal->message.empty()) { + refusalMessage_ = QString::fromStdString(refusal->message); + } else if (refusal) { + refusalMessage_ = QString::number(refusal->code); + } + dispatch(moonlight::moon_event::LaunchFailed{}); + }); +} + +void MoonlightSession::openRtsp() { + qCInfo(lcMoon) << "rtsp handshake to" << rtspHostAddress_ << rtspPort_ << "target" + << rtspTarget_; + rtsp_->open(rtspHostAddress_, rtspPort_); +} + +void MoonlightSession::sendRtspStep(moonlight::RtspStep step) { + QString request; + switch (step) { + case moonlight::RtspStep::Options: + request = + QString::fromStdString(moonrtsp::formatOptions(rtspCseq_++, rtspTarget_.toStdString())); + break; + case moonlight::RtspStep::Describe: + request = QString::fromStdString( + moonrtsp::formatDescribe(rtspCseq_++, rtspTarget_.toStdString())); + break; + case moonlight::RtspStep::SetupAudio: + case moonlight::RtspStep::SetupVideo: + case moonlight::RtspStep::SetupControl: + request = QString::fromStdString(moonrtsp::formatSetup( + rtspCseq_++, stepStreamId(step).toStdString(), rtspSessionId_.toStdString())); + break; + case moonlight::RtspStep::Announce: { + const auto payload = moonrtsp::buildAnnouncePayload(stream_); + request = QString::fromStdString( + moonrtsp::formatAnnounce(rtspCseq_++, rtspSessionId_.toStdString(), payload)); + break; + } + case moonlight::RtspStep::Play: + request = QString::fromStdString(moonrtsp::formatPlay( + rtspCseq_++, rtspTarget_.toStdString(), rtspSessionId_.toStdString())); + break; + } + + rtsp_->request(request, [this, step](const std::optional& response) { + if (!response || !response->ok()) { + dispatch(moonlight::moon_event::RtspFailed{}); + return; + } + // Absorb the per-step transport data the later phases need. + if (const auto id = moonrtsp::sessionId(*response); id && rtspSessionId_.isEmpty()) { + rtspSessionId_ = QString::fromStdString(*id); + } + if (step == moonlight::RtspStep::SetupAudio) { + audioPort_ = moonrtsp::transportPort(*response).value_or(0); + audioPingPayload_ = + QByteArray::fromStdString(moonrtsp::pingPayload(*response).value_or("")); + qCInfo(lcMoon) << "setup audio port" << audioPort_ << "ping payload" + << audioPingPayload_.size() << "bytes"; + ensureRtpPings(); + } else if (step == moonlight::RtspStep::SetupVideo) { + videoPort_ = moonrtsp::transportPort(*response).value_or(0); + videoPingPayload_ = + QByteArray::fromStdString(moonrtsp::pingPayload(*response).value_or("")); + qCInfo(lcMoon) << "setup video port" << videoPort_ << "ping payload" + << videoPingPayload_.size() << "bytes"; + ensureRtpPings(); + } else if (step == moonlight::RtspStep::SetupControl) { + controlPort_ = moonrtsp::transportPort(*response).value_or(0); + controlConnectData_ = moonrtsp::connectData(*response).value_or(0); + qCInfo(lcMoon) << "setup control port" << controlPort_ << "connect data" + << controlConnectData_; + } + dispatch(moonlight::moon_event::RtspStepOk{}); + }); +} + +void MoonlightSession::connectControl() { + if (controlPort_ <= 0) { + qCWarning(lcMoon) << "control setup named no port on" << host_.address; + dispatch(moonlight::moon_event::ControlLost{}); + return; + } + if (!control_->start(host_.address.toStdString(), static_cast(controlPort_), + controlConnectData_, rikey_)) { + qCWarning(lcMoon) << "control stream would not start against" << host_.address + << controlPort_; + dispatch(moonlight::moon_event::ControlLost{}); + } + // Success/failure of the ENet connect arrives via the link handler. +} + +void MoonlightSession::startStreaming() { + // Announce EVERY attached pad so the host plugs one virtual controller per + // binding. The media ports have been pinged since SETUP named them. + wentLive_ = true; + qCInfo(lcMoon) << "session live on" << host_.address << "announcing" << pads_.size() << "pads"; + for (auto it = pads_.constBegin(); it != pads_.constEnd(); ++it) { + control_->sendControllerArrival(it->number, it->type, it->capabilities, it->buttons); + } + ensureRtpPings(); +} + +void MoonlightSession::ensureRtpPings() { + const auto makeSocket = [this](int port) -> QUdpSocket* { + if (port <= 0) { return nullptr; } + auto* udp = new QUdpSocket(this); + // Whatever the host streams back is drained and dropped, so the OS + // buffer never fills and no frame is ever decoded. + QObject::connect(udp, &QUdpSocket::readyRead, udp, [udp] { + while (udp->hasPendingDatagrams()) { + udp->readDatagram(nullptr, 0); // discard without copying + } + }); + return udp; + }; + if (rtpVideoSocket_ == nullptr) { rtpVideoSocket_ = makeSocket(videoPort_); } + if (rtpAudioSocket_ == nullptr) { rtpAudioSocket_ = makeSocket(audioPort_); } + if (rtpVideoSocket_ == nullptr && rtpAudioSocket_ == nullptr) { return; } + sendRtpPings(); + if (!rtpPingTimer_->isActive()) { rtpPingTimer_->start(); } +} + +void MoonlightSession::sendRtpPings() { + std::uint8_t ping[moonwire::kRtpPingSize]; + const auto punch = [this, &ping](QUdpSocket* udp, int port, const QByteArray& payload) { + if (udp == nullptr || port <= 0) { return; } + // The SETUP-provided payload identifies our session to the host; the + // legacy 4-byte "PING" is the fallback when none was supplied. + const std::size_t len = moonwire::encodeRtpPing( + ping, payload.constData(), static_cast(payload.size()), rtpPingSequence_); + udp->writeDatagram(reinterpret_cast(ping), static_cast(len), + QHostAddress(host_.address), static_cast(port)); + }; + punch(rtpVideoSocket_, videoPort_, videoPingPayload_); + punch(rtpAudioSocket_, audioPort_, audioPingPayload_); + ++rtpPingSequence_; +} + +void MoonlightSession::teardown() { + // A launch that never reached Streaming left the host holding an app on our + // behalf. Hand it back, or every later attempt is refused by our own + // leftovers. A link that drops after going live is left alone: the host + // will let us resume it, and closing somebody's game out from under them is + // worse than the tidying is worth. + if (moonlight::shouldHandBackApp(launched_, wentLive_, handBackOnTeardown_)) { + cancelStrandedApp(); + } + launched_ = false; + wentLive_ = false; + if (control_) { control_->stop(false); } + if (rtsp_) { rtsp_->close(); } + if (rtpPingTimer_ != nullptr) { rtpPingTimer_->stop(); } + delete rtpVideoSocket_; + rtpVideoSocket_ = nullptr; + delete rtpAudioSocket_; + rtpAudioSocket_ = nullptr; + audioPingPayload_.clear(); + videoPingPayload_.clear(); + audioPort_ = 0; + videoPort_ = 0; + controlPort_ = 0; + controlConnectData_ = 0; + rtspSessionId_.clear(); + rtpPingSequence_ = 0; + motionRequested_ = false; +} + +void MoonlightSession::cancelStrandedApp() { + qCInfo(lcMoon) << "handing back the app" << host_.address << "started for us"; + http_->getTls(host_.address, host_.httpsPort, QStringLiteral("/cancel"), QUrlQuery(), + host_.serverCertPem, + [address = host_.address](int status, const QByteArray& body) { + const auto refusal = moonxml::parseStatus(body.toStdString()); + qCInfo(lcMoon) << "cancel on" << address << "HTTP" << status << "host" + << hostSays(refusal); + }); +} + +void MoonlightSession::sendControllerState(std::uint8_t controllerNumber, + std::uint16_t internalButtons, std::uint8_t lt, + std::uint8_t rt, std::int16_t lx, std::int16_t ly, + std::int16_t rx, std::int16_t ry) { + control_->sendControllerMulti(controllerNumber, activeMask_.load(std::memory_order_relaxed), + moonmap::toMoonlightButtons(internalButtons), lt, rt, lx, ly, rx, + ry); +} + +void MoonlightSession::sendMotion(std::uint8_t controllerNumber, std::uint8_t motionType, float x, + float y, float z) { + if (!motionRequested_) { return; } + control_->sendControllerMotion(controllerNumber, motionType, x, y, z); +} + +void MoonlightSession::onHostEvent(const moonwire::HostEvent& event) { + // Copy the POD event onto the Qt main thread; the handlers touch UI-thread + // plumbing (the SDL output queue) via the manager. + QMetaObject::invokeMethod( + this, + [this, event] { + switch (event.type) { + case moonwire::HostEventType::Rumble: + case moonwire::HostEventType::RumbleTriggers: + if (rumbleHandler_) { + rumbleHandler_(static_cast(event.controllerNumber), + event.rumbleLow, event.rumbleHigh); + } + break; + case moonwire::HostEventType::RgbLed: + if (ledHandler_) { + ledHandler_(static_cast(event.controllerNumber), event.red, + event.green, event.blue); + } + break; + case moonwire::HostEventType::MotionRequest: + // rate 0 stops motion; any non-zero rate starts it. + motionRequested_ = event.motionRateHz != 0; + break; + case moonwire::HostEventType::Termination: + qCInfo(lcMoon) << host_.address << "sent TERMINATION"; + dispatch(moonlight::moon_event::HostTerminated{}); + break; + case moonwire::HostEventType::Unknown: + break; + } + }, + Qt::QueuedConnection); +} + +} // namespace dish::source::moon diff --git a/src/source/moonlight/MoonlightSession.h b/src/source/moonlight/MoonlightSession.h new file mode 100644 index 0000000..a18fb02 --- /dev/null +++ b/src/source/moonlight/MoonlightSession.h @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// One live (or connecting) Moonlight streaming session to a single host. Owns +// the launch coordinator: it turns the MoonlightSessionMachine's effects into +// HTTP, RTSP and control-stream actions, threads the transport data (RTSP +// ports, the control connect token, the launch rikey) between them, opens the +// RTP hole-punch pings so the host sees the media ports, and routes inbound +// host events (rumble, trigger rumble, motion requests, RGB LED) back out. +// +// The hot path (controller state -> CONTROLLER_MULTI) is delegated straight to +// MoonlightControlStream::sendControllerMulti; this class does not sit in it. + +#pragma once + +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightRtsp.h" +#include "core/moonlight/MoonlightSessionMachine.h" +#include "core/moonlight/MoonlightWire.h" +#include "repository/MoonlightHostRepository.h" +#include "source/moonlight/MoonlightControlStream.h" +#include "source/moonlight/MoonlightHttp.h" +#include "source/moonlight/MoonlightRtspClient.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +class QTimer; +class QUdpSocket; + +namespace dish::source::moon { + +// Mirrors dish::net::SessionState so the connection row layer treats a Moonlight +// link exactly like a satellite one. +enum class MoonlightLinkState : std::uint8_t { Idle, Linking, Live, Failed }; + +class MoonlightSession : public QObject { + Q_OBJECT + public: + // `http` is shared (one QNetworkAccessManager per manager); the session + // borrows it. `controlStream` and `rtsp` are owned here. + MoonlightSession(MoonlightHttp* http, repository::MoonlightHost host, + QObject* parent = nullptr); + ~MoonlightSession() override; + + const QString& hostUuid() const { return host_.uuid; } + MoonlightLinkState linkState() const { return linkState_; } + const repository::MoonlightHost& host() const { return host_; } + const moonlight::SessionState& machineState() const { return machine_; } + // The app this session settled on, set by whoever created it. Every later + // binding joins that app; it is never asked again. + const QString& appId() const { return appId_; } + const QString& appName() const { return appName_; } + // A session has been attempted at least once since this object existed, so + // an Idle phase means closed rather than never started. + bool everStarted() const { return everStarted_; } + // What the host said in the BODY of the refusal that ended the last + // attempt, verbatim. A host refuses for reasons of its own and phrases them + // itself; paraphrasing them would drop the only detail the user can act on. + const QString& refusalMessage() const { return refusalMessage_; } + + void start(const QString& appId, const QString& appName); + // `handBackApp` forces the /cancel a normal teardown only sends for an app + // that never went live: the LAST unbind must not strand a running app. + void stop(bool handBackApp = false); + + // ── Controllers riding this session (reference counting lives here) ────── + // Assigns the lowest free controller number and announces the pad, either + // now (the stream is already up) or when it comes up. nullopt means the + // session already carries four pads, or this slot already holds one. + std::optional attachController(const QString& slotId, int storedType, + const moonlight::SourceCapabilities& source); + // Clears the pad's bit and sends the unplug, then reports how many + // controllers are left. Zero is the caller's cue to tear the session down. + std::size_t detachController(const QString& slotId); + std::size_t controllerCount() const { return slots_.size(); } + std::optional controllerNumber(const QString& slotId) const; + QString slotForController(std::uint8_t number) const; + + // Hot path (SDL input thread): forward one controller's state. The number + // is resolved once at bind time and passed in; the active mask is read from + // an atomic, so neither costs a lookup here. + void sendControllerState(std::uint8_t controllerNumber, std::uint16_t internalButtons, + std::uint8_t lt, std::uint8_t rt, std::int16_t lx, std::int16_t ly, + std::int16_t rx, std::int16_t ry); + // Motion, on the SDL sensor thread. Gated by a host MOTION_EVENT request. + void sendMotion(std::uint8_t controllerNumber, std::uint8_t motionType, float x, float y, + float z); + bool motionRequested() const { return motionRequested_; } + + // Host->client actuation, delivered on the Qt main thread. The controller + // number is carried through: a session drives up to four pads, so an event + // that named none of them could only be applied to the wrong one. + using RumbleHandler = + std::function; + using LedHandler = std::function; + void setRumbleHandler(RumbleHandler handler) { rumbleHandler_ = std::move(handler); } + void setLedHandler(LedHandler handler) { ledHandler_ = std::move(handler); } + + signals: + void linkStateChanged(); + // Terminal failure reason token, for the UI toast. + void failed(const QString& reasonToken); + + private: + void dispatch(const moonlight::SessionEvent& event); + void run(const moonlight::Reduction& reduction); + void runEffect(moonlight::SessionEffect effect); + void setLinkState(MoonlightLinkState state); + // Announces one attached pad to the host. No-op unless the control link is + // up; startStreaming() re-announces every pad when it comes up. + void announcePad(const QString& slotId); + + // Effect handlers. + void fetchServerInfo(); + void sendLaunch(); + void openRtsp(); + void sendRtspStep(moonlight::RtspStep step); + void connectControl(); + void startStreaming(); + void teardown(); + // Opens the media sockets and starts the ping timer the moment SETUP names + // a port. The host counts its initial-ping deadline from its own session + // start, not from when our control channel comes up, so waiting for the + // ENet connect is already too late. Idempotent. + void ensureRtpPings(); + // One ping per media port. Repeated every tick until teardown so a lost + // datagram cannot leave the host blind to our media address. + void sendRtpPings(); + // Hands back an app the host started for us and we could not use, so the + // next attempt is not refused by our own leftovers. + void cancelStrandedApp(); + + // Marshals a host event from the control thread onto the Qt main thread. + void onHostEvent(const moonwire::HostEvent& event); + + MoonlightHttp* http_; + repository::MoonlightHost host_; + std::unique_ptr control_; + std::unique_ptr rtsp_; + + moonlight::SessionState machine_; + MoonlightLinkState linkState_ = MoonlightLinkState::Idle; + + // Per-attempt parameters. The app is per SESSION: only the binding that + // creates it picks one, and every later binding joins whatever is running. + QString appId_; + QString appName_; + + // What one attached pad declares. Resolved once at attach time so the + // announce is a lookup and never a decision. + struct PadDeclaration { + std::uint8_t number = 0; + std::uint8_t type = moonproto::kControllerTypeXbox; + std::uint8_t capabilities = 0; + std::uint32_t buttons = moonproto::kStandardButtons; + }; + moonlight::PadSlots slots_; + QHash pads_; + // The CONTROLLER_MULTI active mask, published for the hot path. Written on + // the Qt thread by attach/detach, read on the SDL input thread. + std::atomic activeMask_{0}; + + bool everStarted_ = false; + QString refusalMessage_; + // The teardown must hand the app back even though it went live: the last + // controller has left, so nothing is riding it any more. + bool handBackOnTeardown_ = false; + + // What the launch mode and the ANNOUNCE SDP ask for: the host's own + // display, so a virtual-display host does not resize the user's desktop. + moonrtsp::StreamConfig stream_; + + // Transport data threaded between phases. The rikey is minted once per + // attempt so a launch that promotes to /resume keys the control stream + // with the same secret it already announced. + std::array rikey_{}; + bool rikeyReady_ = false; + std::uint32_t rikeyId_ = 0; + // A launch succeeded, so the host is holding an app on our behalf. + bool launched_ = false; + // The session reached Streaming, so a later drop is not a setup failure. + bool wentLive_ = false; + QString rtspTarget_; // parroted host string from the launch response + QString rtspHostAddress_; + int rtspPort_ = 0; + QString rtspSessionId_; + int controlPort_ = 0; + std::uint32_t controlConnectData_ = 0; + int audioPort_ = 0; + int videoPort_ = 0; + // The SETUP-provided X-SS-Ping-Payload per stream; empty falls back to the + // legacy 4-byte "PING". + QByteArray audioPingPayload_; + QByteArray videoPingPayload_; + int rtspCseq_ = 1; + + // The RTP hole-punch senders, alive only while Streaming. Payloads are + // discarded on readyRead; the timer re-pings so a lost datagram (or a NAT + // rebind) cannot strand the media ports. + QUdpSocket* rtpVideoSocket_ = nullptr; + QUdpSocket* rtpAudioSocket_ = nullptr; + QTimer* rtpPingTimer_ = nullptr; + std::uint32_t rtpPingSequence_ = 0; + + bool motionRequested_ = false; + + RumbleHandler rumbleHandler_; + LedHandler ledHandler_; +}; + +} // namespace dish::source::moon diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 226ac14..14f8532 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,21 @@ set(DISH_TEST_SOURCES test_kernel.cpp test_async_state.cpp test_session_crypto.cpp + test_moonlight_wire.cpp + test_moonlight_control_cipher.cpp + test_moonlight_crypto.cpp + test_moonlight_pairing.cpp + test_moonlight_xml.cpp + test_moonlight_rtsp.cpp + test_moonlight_rtsp_framing.cpp + test_moonlight_tls_config.cpp + test_moonlight_session_machine.cpp + test_moonlight_session_ui.cpp + test_moonlight_pad_slots.cpp + test_moonlight_binding_refcount.cpp + test_moonlight_host_lifecycle.cpp + test_moonlight_button_map.cpp + test_moonlight_host_repository.cpp test_atomic_counter.cpp test_hex.cpp test_endian.cpp diff --git a/tests/test_moonlight_binding_refcount.cpp b/tests/test_moonlight_binding_refcount.cpp new file mode 100644 index 0000000..6d1cf8e --- /dev/null +++ b/tests/test_moonlight_binding_refcount.cpp @@ -0,0 +1,331 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The session is PER HOST and reference counted, and this is where that is +// enforced. A Moonlight session carries up to four controllers behind one +// launch, so a second binding on a host must join what is already there rather +// than start a second session beside it, and only the LAST unbind may tear it +// down and hand the app back. +// +// Nothing here reaches the network: every assertion is about the bookkeeping +// the coordinator does before a socket is involved, which is exactly the part +// that would otherwise only be observable against a live host. + +#include "QSettingsFixture.h" +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightSessionMachine.h" +#include "repository/MoonlightHostRepository.h" +#include "source/moonlight/MoonlightManager.h" + +#include + +#include + +using namespace dish; +using namespace dish::source::moon; + +namespace { + +// QSignalSpy stand-in: DishTests links Catch2, not Qt6::Test. +struct AppsSpy { + QStringList hosts; + + explicit AppsSpy(MoonlightManager* manager) { + QObject::connect(manager, &MoonlightManager::appsChanged, + [this](const QString& uuid) { hosts.append(uuid); }); + } +}; + +// A host the manager treats as paired. The certificate is never presented here +// (nothing dials), only the "we remember pairing this" flag it stands for. +repository::MoonlightHost pairedHost(const QString& uuid = QStringLiteral("host-uuid")) { + repository::MoonlightHost host; + host.uuid = uuid; + host.name = QStringLiteral("Living room PC"); + // An address that resolves nowhere, so the probe this triggers can never + // reach a real machine on the developer's network. + host.address = QStringLiteral("192.0.2.1"); + host.serverCertPem = QStringLiteral("-----BEGIN CERTIFICATE-----\nnot-a-real-cert\n" + "-----END CERTIFICATE-----\n"); + return host; +} + +moonlight::SourceCapabilities plainPad() { + moonlight::SourceCapabilities source; + source.rumble = true; + return source; +} + +moonlight::SourceCapabilities motionPad() { + moonlight::SourceCapabilities source; + source.rumble = true; + source.motion = true; + return source; +} + +} // namespace + +TEST_CASE("a second binding joins the session instead of starting another", + "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + const auto first = manager.bindController(QStringLiteral("pad-a"), uuid, + moonproto::kControllerTypeAuto, motionPad()); + REQUIRE(first.has_value()); + CHECK(*first == 0); + + auto* session = manager.session(uuid); + REQUIRE(session != nullptr); + CHECK(session->everStarted()); + // The launch is under way, so the session is no longer resting. + CHECK_FALSE(moonlight::sessionNeedsStart(session->machineState().phase)); + + const auto second = manager.bindController(QStringLiteral("pad-b"), uuid, + moonproto::kControllerTypeXbox, plainPad()); + REQUIRE(second.has_value()); + CHECK(*second == 1); + // ONE session object, still the same one: a second would mean a second + // /launch and a host refusing it as "an app is already running". + CHECK(manager.session(uuid) == session); + CHECK(session->controllerCount() == 2); + CHECK(manager.controllerCount(uuid) == 2); +} + +TEST_CASE("four controllers ride one host and the fifth is refused", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + for (int i = 0; i < 4; ++i) { + const auto number = manager.bindController(QStringLiteral("pad-%1").arg(i), uuid, + moonproto::kControllerTypeAuto, plainPad()); + REQUIRE(number.has_value()); + CHECK(static_cast(*number) == i); + } + CHECK(manager.controllerCount(uuid) == static_cast(moonlight::kMaxPads)); + + // The hard protocol limit, and the only host state that refuses a binding. + CHECK_FALSE(manager + .bindController(QStringLiteral("pad-4"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + CHECK(manager.boundHostFor(QStringLiteral("pad-4")).isEmpty()); + CHECK(manager.controllerCount(uuid) == static_cast(moonlight::kMaxPads)); + + // And the render contract agrees with the refusal. + const auto inputs = manager.uiInputs(uuid, QStringLiteral("pad-4")); + CHECK(inputs.otherControllers == static_cast(moonlight::kMaxPads)); + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::HostFull); + CHECK(moonlight::sessionUiBlocksApply(moonlight::sessionUiState(inputs))); +} + +TEST_CASE("only the last unbind tears the session down", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + for (int i = 0; i < 4; ++i) { + REQUIRE(manager + .bindController(QStringLiteral("pad-%1").arg(i), uuid, + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + } + auto* session = manager.session(uuid); + REQUIRE(session != nullptr); + + for (int i = 0; i < 3; ++i) { + manager.unbindController(QStringLiteral("pad-%1").arg(i)); + CHECK(manager.controllerCount(uuid) == 3 - i); + // Still carrying somebody, so the launch is left exactly as it was. + CHECK_FALSE(moonlight::sessionNeedsStart(session->machineState().phase)); + } + + manager.unbindController(QStringLiteral("pad-3")); + CHECK(manager.controllerCount(uuid) == 0); + // Nobody is riding it, so the session is stopped and the app handed back. + CHECK(session->machineState().phase == moonlight::SessionPhase::Idle); + CHECK(manager.boundHostFor(QStringLiteral("pad-3")).isEmpty()); +} + +TEST_CASE("a freed controller number is handed to the next binding", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(manager.bindController(QStringLiteral("a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 0); + REQUIRE(manager.bindController(QStringLiteral("b"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 1); + REQUIRE(manager.bindController(QStringLiteral("c"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 2); + + manager.unbindController(QStringLiteral("b")); + // The lowest FREE index, which is the one just released. + CHECK(manager.bindController(QStringLiteral("d"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 1); + CHECK(manager.controllerNumber(QStringLiteral("a")) == 0); + CHECK(manager.controllerNumber(QStringLiteral("c")) == 2); + CHECK(manager.controllerNumber(QStringLiteral("d")) == 1); +} + +TEST_CASE("re-binding a slot that already holds a number is a restart", "[moonlight][binding]") { + // What Reconnect after a drop does. A second attach for the same slot would + // be skipped by the host anyway, and losing the binding would be worse. + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + REQUIRE(manager.bindController(QStringLiteral("a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 0); + CHECK(manager.bindController(QStringLiteral("a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 0); + CHECK(manager.controllerCount(uuid) == 1); +} + +TEST_CASE("a binding to an unpaired host is still recorded", "[moonlight][binding]") { + // A binding is a DURABLE INTENT: pairing is remembered trust verified + // lazily, so the session is attempted when the controller is used and never + // when the binding is saved. Nothing about the host may refuse the answer. + auto settings = test::makeSharedSettings(); + repository::MoonlightHost unpaired = pairedHost(QStringLiteral("cold-host")); + unpaired.serverCertPem.clear(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(unpaired); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("cold-host"); + + CHECK_FALSE( + manager + .bindController(QStringLiteral("pad"), uuid, moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + // No controller number, because there is no session to hold one. The + // binding stands regardless. + CHECK(manager.boundHostFor(QStringLiteral("pad")) == uuid); + CHECK(manager.session(uuid) == nullptr); + + const auto inputs = manager.uiInputs(uuid, QStringLiteral("pad")); + CHECK_FALSE(moonlight::sessionUiBlocksApply(moonlight::sessionUiState(inputs))); +} + +TEST_CASE("a slot drives exactly one destination", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost(QStringLiteral("first-host"))); + repo.upsert(pairedHost(QStringLiteral("second-host"))); + + MoonlightManager manager(settings); + REQUIRE(manager + .bindController(QStringLiteral("pad"), QStringLiteral("first-host"), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + REQUIRE(manager + .bindController(QStringLiteral("pad"), QStringLiteral("second-host"), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + + CHECK(manager.boundHostFor(QStringLiteral("pad")) == QStringLiteral("second-host")); + CHECK(manager.controllerCount(QStringLiteral("first-host")) == 0); + CHECK(manager.controllerCount(QStringLiteral("second-host")) == 1); + // Moving away emptied the first host's session, so it was torn down. + auto* first = manager.session(QStringLiteral("first-host")); + REQUIRE(first != nullptr); + CHECK(first->machineState().phase == moonlight::SessionPhase::Idle); +} + +TEST_CASE("forgetting a host drops the bindings that rode it", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + REQUIRE( + manager + .bindController(QStringLiteral("a"), uuid, moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + REQUIRE( + manager + .bindController(QStringLiteral("b"), uuid, moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + + manager.forget(uuid); + CHECK(manager.boundHostFor(QStringLiteral("a")).isEmpty()); + CHECK(manager.boundHostFor(QStringLiteral("b")).isEmpty()); + CHECK_FALSE(manager.knows(uuid)); +} + +TEST_CASE("the app list is per host and a refusal is not an empty list", "[moonlight][binding]") { + // /applist is HTTPS and paired-only, so an unpaired host answers 404. + // Reading that as "no apps" would present a refusal as a fact about the + // host, and the copy for the two states says different things. + auto settings = test::makeSharedSettings(); + repository::MoonlightHost unpaired = pairedHost(QStringLiteral("cold-host")); + unpaired.serverCertPem.clear(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(unpaired); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("cold-host"); + + AppsSpy apps(&manager); + manager.refreshApps(uuid); + REQUIRE(apps.hosts.size() == 1); + CHECK(apps.hosts.at(0) == uuid); + CHECK(manager.apps(uuid).isEmpty()); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.appsFailed); + CHECK_FALSE(inputs.appsRead); + CHECK(inputs.appCount == 0); + + // A refusal is FAILED, never EMPTY: on a paired host the two render + // different copy, and only one of them is a fact about the host. + moonlight::SessionUiInputs asPaired = inputs; + asPaired.probeAttempted = true; + asPaired.probeAnswered = true; + asPaired.paired = true; + asPaired.remembered = true; + CHECK(moonlight::sessionUiState(asPaired) == moonlight::SessionUiState::AppsFailed); +} + +TEST_CASE("the remembered app seeds the next session, not the binding", "[moonlight][binding]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + manager.setLastApp(uuid, QStringLiteral("1093255277"), QStringLiteral("Steam Big Picture")); + + REQUIRE( + manager + .bindController(QStringLiteral("a"), uuid, moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + auto* session = manager.session(uuid); + REQUIRE(session != nullptr); + CHECK(session->appId() == QStringLiteral("1093255277")); + CHECK(session->appName() == QStringLiteral("Steam Big Picture")); + + // The second binding JOINS that app; it never gets to pick again. + REQUIRE( + manager.bindController(QStringLiteral("b"), uuid, moonproto::kControllerTypePs, motionPad()) + .has_value()); + CHECK(manager.session(uuid)->appId() == QStringLiteral("1093255277")); +} diff --git a/tests/test_moonlight_button_map.cpp b/tests/test_moonlight_button_map.cpp new file mode 100644 index 0000000..565fdb0 --- /dev/null +++ b/tests/test_moonlight_button_map.cpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The internal-button word to Moonlight button-flag translation, pinned per +// bit so a mis-mapped Home/Guide or stick click is caught at build time. + +#include "core/moonlight/MoonlightButtonMap.h" +#include "core/moonlight/MoonlightProtocol.h" + +#include + +using namespace dish::moonmap; +using namespace dish; + +TEST_CASE("each internal button maps to its Moonlight flag", "[moonlight][buttonmap]") { + CHECK(toMoonlightButtons(inbtn::kDpadUp) == moonproto::kBtnDpadUp); + CHECK(toMoonlightButtons(inbtn::kDpadDown) == moonproto::kBtnDpadDown); + CHECK(toMoonlightButtons(inbtn::kDpadLeft) == moonproto::kBtnDpadLeft); + CHECK(toMoonlightButtons(inbtn::kDpadRight) == moonproto::kBtnDpadRight); + CHECK(toMoonlightButtons(inbtn::kStart) == moonproto::kBtnStart); + CHECK(toMoonlightButtons(inbtn::kBack) == moonproto::kBtnBack); + CHECK(toMoonlightButtons(inbtn::kLeftThumb) == moonproto::kBtnLeftStick); + CHECK(toMoonlightButtons(inbtn::kRightThumb) == moonproto::kBtnRightStick); + CHECK(toMoonlightButtons(inbtn::kLeftShoulder) == moonproto::kBtnLeftButton); + CHECK(toMoonlightButtons(inbtn::kRightShoulder) == moonproto::kBtnRightButton); + CHECK(toMoonlightButtons(inbtn::kA) == moonproto::kBtnA); + CHECK(toMoonlightButtons(inbtn::kB) == moonproto::kBtnB); + CHECK(toMoonlightButtons(inbtn::kX) == moonproto::kBtnX); + CHECK(toMoonlightButtons(inbtn::kY) == moonproto::kBtnY); +} + +TEST_CASE("Home/Guide moves from bit 0x0400 to Moonlight's HOME flag", "[moonlight][buttonmap]") { + // Both happen to be 0x0400 in the two vocabularies, but assert it rather + // than assume it: the internal word has no Guide bit, so Home rides Start + // + Back here only when set explicitly. This documents the current lack of + // a Guide source without silently dropping it. + CHECK((toMoonlightButtons(0) & moonproto::kBtnHome) == 0); +} + +TEST_CASE("empty and full words", "[moonlight][buttonmap]") { + CHECK(toMoonlightButtons(0) == 0); + const std::uint16_t all = inbtn::kDpadUp | inbtn::kDpadDown | inbtn::kDpadLeft | + inbtn::kDpadRight | inbtn::kStart | inbtn::kBack | inbtn::kLeftThumb | + inbtn::kRightThumb | inbtn::kLeftShoulder | inbtn::kRightShoulder | + inbtn::kA | inbtn::kB | inbtn::kX | inbtn::kY; + const std::uint32_t expected = + moonproto::kBtnDpadUp | moonproto::kBtnDpadDown | moonproto::kBtnDpadLeft | + moonproto::kBtnDpadRight | moonproto::kBtnStart | moonproto::kBtnBack | + moonproto::kBtnLeftStick | moonproto::kBtnRightStick | moonproto::kBtnLeftButton | + moonproto::kBtnRightButton | moonproto::kBtnA | moonproto::kBtnB | moonproto::kBtnX | + moonproto::kBtnY; + CHECK(toMoonlightButtons(all) == expected); +} + +TEST_CASE("ABXY combination reproduces the doc example", "[moonlight][buttonmap]") { + // A + X pressed -> 0x1000 | 0x4000 = 0x5000. + CHECK(toMoonlightButtons(inbtn::kA | inbtn::kX) == 0x5000U); +} diff --git a/tests/test_moonlight_control_cipher.cpp b/tests/test_moonlight_control_cipher.cpp new file mode 100644 index 0000000..4c12b00 --- /dev/null +++ b/tests/test_moonlight_control_cipher.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The known-answer packets are from a real Moonlight session (captured in +// Wolf's testControl.cpp, MIT); they pin the whole construction — framing, +// key handling, the IV built from the sequence number and the GCM tag — in +// both directions. The remaining cases cover seq evolution, tampering and +// framing edge cases. + +#include "core/moonlight/MoonlightControlCipher.h" + +#include "Util/Hex.h" + +#include + +#include +#include +#include +#include + +using dish::mooncrypto::ControlCipher; +namespace util = dish::util; + +namespace { + +std::array sessionKey() { + const auto bytes = util::fromHex("EDF04A215C4FBEA20934120C8480D855"); + REQUIRE(bytes.has_value()); + std::array key{}; + std::copy(bytes->begin(), bytes->end(), key.begin()); + return key; +} + +std::vector bytesOf(const std::string& hex) { + const auto decoded = util::fromHex(hex); + REQUIRE(decoded.has_value()); + return *decoded; +} + +struct Fixture { + std::uint32_t seq; + std::string plaintextHex; + std::string packetHex; +}; + +// {seq, decrypted plaintext, full encrypted packet} from the captured session. +const Fixture kFixtures[] = { + {0, "020302000000", "01001a0000000000bf0eb6da10e47c702ec8644eb87d9cf7b6fac9ff75ca"}, + {1, "0703010000", "010019000100000021dbb8dc0590af3a2b20bce5a347de31d366e5b9c5"}, + {2, "000208000400000000000000", + "0100200002000000220722fbaded58a03f2e8898f0f1dcb7c93f6235590618e4186ad990"}, + {6, "060212000000000e05000000033400c00000059f0329", + "01002a00060000005a4d999fb2542f85bdd39d99f77eb825254569d2c04e21241b5cec01bd3f93129718ecc1" + "f153"}, +}; + +} // namespace + +TEST_CASE("seal reproduces captured session packets byte-for-byte", "[moonlight][controlcipher]") { + ControlCipher cipher; + REQUIRE(cipher.setKey(sessionKey())); + + for (const auto& fx : kFixtures) { + const auto plaintext = bytesOf(fx.plaintextHex); + std::array out{}; + const std::size_t len = cipher.seal(fx.seq, plaintext.data(), plaintext.size(), out.data()); + REQUIRE(len == plaintext.size() + ControlCipher::kOverhead); + CHECK(util::toHex(out.data(), len) == fx.packetHex); + } +} + +TEST_CASE("open recovers captured session plaintexts", "[moonlight][controlcipher]") { + ControlCipher cipher; + REQUIRE(cipher.setKey(sessionKey())); + + for (const auto& fx : kFixtures) { + const auto packet = bytesOf(fx.packetHex); + std::array out{}; + const auto len = cipher.open(packet.data(), packet.size(), out.data(), out.size()); + REQUIRE(len.has_value()); + CHECK(util::toHex(out.data(), *len) == fx.plaintextHex); + } +} + +TEST_CASE("seal/open round-trips across an evolving sequence", "[moonlight][controlcipher]") { + ControlCipher sender; + ControlCipher receiver; + REQUIRE(sender.setKey(sessionKey())); + REQUIRE(receiver.setKey(sessionKey())); + + const auto plaintext = bytesOf("060222000000001e0c000000"); + std::array packet{}; + std::array recovered{}; + + // Crosses the u8 IV truncation boundary at 256 deliberately: both ends + // must keep agreeing when seq mod 256 wraps. + for (std::uint32_t seq : {0U, 1U, 2U, 255U, 256U, 257U, 511U, 70000U}) { + const std::size_t len = sender.seal(seq, plaintext.data(), plaintext.size(), packet.data()); + REQUIRE(len > 0); + const auto ptLen = receiver.open(packet.data(), len, recovered.data(), recovered.size()); + REQUIRE(ptLen.has_value()); + CHECK(util::toHex(recovered.data(), *ptLen) == util::toHex(plaintext)); + } +} + +TEST_CASE("the GCM IV keeps only the low byte of the sequence", "[moonlight][controlcipher]") { + // ONLY THE LOW BYTE, however wrong that looks. The host builds the same IV + // with `std::array iv = {0}; iv[0] = seq;`, where + // assigning a u32 into a u8 drops the top three bytes. Writing all four + // agrees with the host for exactly 256 packets and disagrees forever after: + // a live Sunshine host accepted 256 sealed packets and answered the 257th + // with "Failed to verify tag", ending the session just past two minutes. + ControlCipher cipher; + REQUIRE(cipher.setKey(sessionKey())); + const auto plaintext = bytesOf("0002080004000000"); + + const auto sealedAt = [&](std::uint32_t seq) { + std::array out{}; + const std::size_t len = cipher.seal(seq, plaintext.data(), plaintext.size(), out.data()); + REQUIRE(len > 0); + // Past the [type][len][seq] header: tag + ciphertext, the part the IV + // decides. The header still carries the FULL 32-bit sequence. + return util::toHex(out.data() + ControlCipher::kHeaderSize + ControlCipher::kSeqSize, + len - ControlCipher::kHeaderSize - ControlCipher::kSeqSize); + }; + + // Sequences 256 apart share an IV, so they seal to identical bytes. A + // four-byte IV would make every one of these differ. + CHECK(sealedAt(0) == sealedAt(256)); + CHECK(sealedAt(0) == sealedAt(512)); + CHECK(sealedAt(0) == sealedAt(0x01000000)); + CHECK(sealedAt(1) == sealedAt(257)); + CHECK(sealedAt(255) == sealedAt(511)); + CHECK(sealedAt(2) == sealedAt(70000 - (70000 % 256) + 2)); + // Neighbours inside one 256-packet cycle still differ. + CHECK(sealedAt(0) != sealedAt(1)); + CHECK(sealedAt(255) != sealedAt(0)); + + // The wire header carries the untruncated sequence either way. + std::array out{}; + const std::size_t len = cipher.seal(300, plaintext.data(), plaintext.size(), out.data()); + REQUIRE(len > 0); + CHECK(util::toHex(out.data() + ControlCipher::kHeaderSize, ControlCipher::kSeqSize) == + "2c010000"); +} + +TEST_CASE("a tampered packet is rejected", "[moonlight][controlcipher]") { + ControlCipher cipher; + REQUIRE(cipher.setKey(sessionKey())); + std::array out{}; + + SECTION("flipped ciphertext byte") { + auto packet = bytesOf(kFixtures[2].packetHex); + packet[packet.size() - 1] ^= 0x01; + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("flipped tag byte") { + auto packet = bytesOf(kFixtures[2].packetHex); + packet[8] ^= 0x80; // inside the 16-byte tag + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("altered seq changes the IV and fails authentication") { + auto packet = bytesOf(kFixtures[2].packetHex); + packet[4] ^= 0x01; + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("wrong key") { + ControlCipher wrong; + std::array other{}; + other.fill(0x42); + REQUIRE(wrong.setKey(other)); + const auto packet = bytesOf(kFixtures[2].packetHex); + CHECK_FALSE(wrong.open(packet.data(), packet.size(), out.data(), out.size())); + } +} + +TEST_CASE("misframed packets are rejected before crypto", "[moonlight][controlcipher]") { + ControlCipher cipher; + REQUIRE(cipher.setKey(sessionKey())); + std::array out{}; + + SECTION("too short for the framing") { + const auto packet = bytesOf("01001a000000"); + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("wrong outer type") { + auto packet = bytesOf(kFixtures[0].packetHex); + packet[0] = 0x02; + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("declared length overruns the buffer") { + auto packet = bytesOf(kFixtures[0].packetHex); + packet[2] = 0xFF; // len low byte + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("declared length below the seq+tag minimum") { + auto packet = bytesOf(kFixtures[0].packetHex); + packet[2] = 0x08; + packet[3] = 0x00; + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), out.size())); + } + SECTION("output buffer too small") { + const auto packet = bytesOf(kFixtures[3].packetHex); + CHECK_FALSE(cipher.open(packet.data(), packet.size(), out.data(), 4)); + } +} + +TEST_CASE("seal refuses to run without a key", "[moonlight][controlcipher]") { + ControlCipher cipher; + const auto plaintext = bytesOf("0002080004000000"); + std::array out{}; + CHECK(cipher.seal(1, plaintext.data(), plaintext.size(), out.data()) == 0); + CHECK_FALSE(cipher.hasKey()); +} diff --git a/tests/test_moonlight_crypto.cpp b/tests/test_moonlight_crypto.cpp new file mode 100644 index 0000000..493da1d --- /dev/null +++ b/tests/test_moonlight_crypto.cpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The pairing primitives against published vectors (FIPS-197 for AES-128-ECB, +// the classic "abc" SHA-256 vector), a pinned key-derivation vector, and +// generate/sign/verify round-trips over real 2048-bit identities. + +#include "core/moonlight/MoonlightPairingCrypto.h" + +#include "Util/Hex.h" + +#include + +#include +#include +#include +#include + +using namespace dish::mooncrypto; +namespace util = dish::util; + +namespace { + +std::array key16(const std::string& hex) { + const auto bytes = util::fromHex(hex); + REQUIRE(bytes.has_value()); + REQUIRE(bytes->size() == 16); + std::array out{}; + std::copy(bytes->begin(), bytes->end(), out.begin()); + return out; +} + +// One identity per suite run; RSA keygen is the slow part. +const ClientIdentity& testIdentity() { + static const ClientIdentity identity = [] { + const auto id = generateClientIdentity(); + REQUIRE(id.has_value()); + return *id; + }(); + return identity; +} + +} // namespace + +TEST_CASE("sha256 matches the published vector", "[moonlight][crypto]") { + const std::string msg = "abc"; + const auto digest = sha256(reinterpret_cast(msg.data()), msg.size()); + CHECK(util::toHex(digest.data(), digest.size()) == + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); +} + +TEST_CASE("AES-128-ECB matches the FIPS-197 vector", "[moonlight][crypto]") { + const auto key = key16("000102030405060708090a0b0c0d0e0f"); + const auto plaintext = util::fromHex("00112233445566778899aabbccddeeff"); + REQUIRE(plaintext.has_value()); + + const auto encrypted = aesEcbEncrypt(key, plaintext->data(), plaintext->size()); + REQUIRE(encrypted.has_value()); + CHECK(util::toHex(*encrypted) == "69c4e0d86a7b0430d8cdb78070b4c55a"); + + const auto decrypted = aesEcbDecrypt(key, encrypted->data(), encrypted->size()); + REQUIRE(decrypted.has_value()); + CHECK(util::toHex(*decrypted) == "00112233445566778899aabbccddeeff"); +} + +TEST_CASE("AES-128-ECB handles multi-block input without padding", "[moonlight][crypto]") { + const auto key = key16("edf04a215c4fbea20934120c8480d855"); + std::vector plaintext(48, 0xAB); // hash(32) + challenge(16) + const auto encrypted = aesEcbEncrypt(key, plaintext.data(), plaintext.size()); + REQUIRE(encrypted.has_value()); + CHECK(encrypted->size() == 48); + const auto decrypted = aesEcbDecrypt(key, encrypted->data(), encrypted->size()); + REQUIRE(decrypted.has_value()); + CHECK(*decrypted == plaintext); +} + +TEST_CASE("AES-128-ECB rejects non-block-aligned input", "[moonlight][crypto]") { + const auto key = key16("000102030405060708090a0b0c0d0e0f"); + const std::vector odd(15, 0x01); + CHECK_FALSE(aesEcbEncrypt(key, odd.data(), odd.size()).has_value()); + CHECK_FALSE(aesEcbDecrypt(key, odd.data(), odd.size()).has_value()); + CHECK_FALSE(aesEcbEncrypt(key, odd.data(), 0).has_value()); +} + +TEST_CASE("pairing key = SHA-256(salt || PIN) truncated to 16 bytes", "[moonlight][crypto]") { + std::array salt{}; + for (std::size_t i = 0; i < salt.size(); ++i) { salt[i] = static_cast(i); } + const auto key = derivePairingKey(salt, "4989"); + // Pinned: sha256(00..0f || "4989")[0:16]. + CHECK(util::toHex(key.data(), key.size()) == "1e3644c22cb825f6944041deab6c5cfb"); +} + +TEST_CASE("generated client identity is a usable self-signed cert", "[moonlight][crypto]") { + const auto& id = testIdentity(); + CHECK(id.certPem.find("BEGIN CERTIFICATE") != std::string::npos); + CHECK(id.privateKeyPem.find("PRIVATE KEY") != std::string::npos); + CHECK(isValidCertPem(id.certPem)); + + const auto sig = certSignature(id.certPem); + REQUIRE(sig.has_value()); + // 2048-bit RSA self-signature. + CHECK(sig->size() == kRsaSignatureSize); + + const auto fingerprint = certFingerprintHex(id.certPem); + REQUIRE(fingerprint.has_value()); + CHECK(fingerprint->size() == 64); +} + +TEST_CASE("RSA-SHA256 sign/verify round-trips and rejects tampering", "[moonlight][crypto]") { + const auto& id = testIdentity(); + std::vector secret(kPairingSecretSize, 0x5A); + + const auto signature = rsaSignSha256(id.privateKeyPem, secret.data(), secret.size()); + REQUIRE(signature.has_value()); + CHECK(signature->size() == kRsaSignatureSize); + + CHECK(rsaVerifySha256(id.certPem, secret.data(), secret.size(), signature->data(), + signature->size())); + + SECTION("tampered message fails") { + auto altered = secret; + altered[0] ^= 0x01; + CHECK_FALSE(rsaVerifySha256(id.certPem, altered.data(), altered.size(), signature->data(), + signature->size())); + } + SECTION("tampered signature fails") { + auto badSig = *signature; + badSig[10] ^= 0x01; + CHECK_FALSE(rsaVerifySha256(id.certPem, secret.data(), secret.size(), badSig.data(), + badSig.size())); + } + SECTION("a different identity's cert fails") { + const auto other = generateClientIdentity(); + REQUIRE(other.has_value()); + CHECK_FALSE(rsaVerifySha256(other->certPem, secret.data(), secret.size(), signature->data(), + signature->size())); + } +} + +TEST_CASE("cert helpers reject garbage input", "[moonlight][crypto]") { + CHECK_FALSE(isValidCertPem("not a pem")); + CHECK_FALSE(certSignature("not a pem").has_value()); + CHECK_FALSE(certFingerprintHex("").has_value()); +} diff --git a/tests/test_moonlight_host_lifecycle.cpp b/tests/test_moonlight_host_lifecycle.cpp new file mode 100644 index 0000000..3d9a839 --- /dev/null +++ b/tests/test_moonlight_host_lifecycle.cpp @@ -0,0 +1,930 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The Moonlight host's whole life, end to end, at the coordinator that owns it: +// found or typed in, paired, probed, bound, joined, torn down, forgotten, and +// paired again afterwards. The per-step units live beside this file; what is +// asserted HERE is the sequence, because every defect this suite was written +// for was a step that behaved correctly on its own and left something behind +// for the next one. +// +// TWO RULES CARRY MOST OF IT. +// +// A FORGET LEAVES NOTHING. A host owns nine pieces of state in this client: the +// remembered row (the pairing anchor lives inside it, so the certificate is not +// stored separately and cannot outlive the row), the discovered entry, the +// probe verdict, the app-list cache, the bindings, the controller numbers those +// bindings hold, the live session, the remembered app and the remembered +// controller type. Every one of them is asserted gone below, including the ones +// that only a REPLY STILL IN FLIGHT could put back: probes_ and appCache_ are +// written through QHash::operator[], which inserts, so a late callback is a +// resurrection unless something stops it. +// +// NOTHING FAILS QUIETLY. Every refusal a user can provoke is asserted to leave +// an observable mark, because the live failure this file answers was a Pair +// that produced no visible reaction and no log line, which made it impossible +// to diagnose from the outside. +// +// Nothing here dials a real host. The addresses are TEST-NET-1 (RFC 5737), which +// routes nowhere, and the cases that need an answer stand up a loopback origin +// and talk to that. + +#include "QSettingsFixture.h" +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightSessionMachine.h" +#include "core/moonlight/MoonlightSessionUi.h" +#include "repository/MoonlightHostRepository.h" +#include "source/moonlight/MoonlightManager.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace dish; +using namespace dish::source::moon; + +namespace { + +// RFC 5737 TEST-NET-1: guaranteed to route nowhere, so a probe this triggers +// can never reach a machine on the developer's network. +const QString kNowhere = QStringLiteral("192.0.2.1"); + +// The pairing anchor. Never presented, because nothing here dials with it, so +// its only job is to be non-empty: that is what MoonlightHost::paired() reads. +const QString kAnchor = QStringLiteral("-----BEGIN CERTIFICATE-----\n" + "not-a-real-cert\n" + "-----END CERTIFICATE-----\n"); + +repository::MoonlightHost pairedHost(const QString& uuid = QStringLiteral("host-uuid")) { + repository::MoonlightHost host; + host.uuid = uuid; + host.name = QStringLiteral("Living room PC"); + host.address = kNowhere; + host.serverCertPem = kAnchor; + return host; +} + +moonlight::SourceCapabilities plainPad() { + moonlight::SourceCapabilities source; + source.rumble = true; + return source; +} + +// Catch2 owns no event loop; spin the suite's QCoreApplication until the +// condition holds, with a ceiling so a stall fails the case instead of hanging. +bool spinUntil(const std::function& ready, int timeoutMs = 8000) { + QElapsedTimer clock; + clock.start(); + while (!ready() && clock.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 5); + } + return ready(); +} + +// A loopback origin that answers /serverinfo the way a GameStream host does. +// The probe path is plaintext by design (PairStatus is the one thing an +// unpaired client can read), so this is the whole of what it needs. +class InfoHost { + public: + explicit InfoHost(QByteArray reply) : reply_(std::move(reply)) { + listening_ = server_.listen(QHostAddress::LocalHost, 0); + QObject::connect(&server_, &QTcpServer::newConnection, &server_, [this] { accept(); }); + } + + bool listening() const { return listening_; } + int port() const { return static_cast(server_.serverPort()); } + int requests() const { return requests_; } + + private: + void accept() { + QTcpSocket* sock = server_.nextPendingConnection(); + QObject::connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater); + auto seen = std::make_shared(); + QObject::connect(sock, &QTcpSocket::readyRead, sock, [this, sock, seen] { + seen->append(sock->readAll()); + if (!seen->contains("\r\n\r\n")) { return; } + ++requests_; + sock->write(reply_); + sock->flush(); + sock->disconnectFromHost(); + }); + } + + QTcpServer server_; + QByteArray reply_; + bool listening_ = false; + int requests_ = 0; +}; + +// A /serverinfo body wrapped in the smallest HTTP/1.1 response that frames it. +// The machine identity travels as , which is the tag a GameStream +// host actually emits; is what the client calls the field it lands in. +QByteArray serverInfo(const QString& uuid, int pairStatus) { + const QByteArray body = QStringLiteral("" + "" + "Fixture" + "%1" + "7.1.431" + "SUNSHINE_SERVER_FREE" + "%2" + "0" + "") + .arg(uuid) + .arg(pairStatus) + .toUtf8(); + return QByteArray("HTTP/1.1 200 OK\r\nContent-Length: ") + QByteArray::number(body.size()) + + QByteArray("\r\nConnection: close\r\n\r\n") + body; +} + +// A remembered host pointed at a loopback fixture instead of TEST-NET-1. +repository::MoonlightHost hostAt(const InfoHost& fixture, + const QString& uuid = QStringLiteral("host-uuid")) { + auto host = pairedHost(uuid); + host.address = QStringLiteral("127.0.0.1"); + host.httpPort = fixture.port(); + return host; +} + +// Runs one probe to completion. probeFinished is the signal the host screen +// waits on for every row it re-asks on open, so it is also what the assertions +// key off: a probe that never fires it parks that row on Checking forever. +bool probeAndSettle(MoonlightManager& manager, const QString& uuid) { + bool finished = false; + const auto token = QObject::connect(&manager, &MoonlightManager::probeFinished, + [&finished](const QString&) { finished = true; }); + manager.probe(uuid); + const bool settled = spinUntil([&finished] { return finished; }); + QObject::disconnect(token); + return settled; +} + +// Everything a host can leave behind, read back through the public surface. +// Gathered in one place so a new piece of state has one obvious home and every +// case that cares asserts against the same list. +struct Residue { + bool known = false; + bool rowListed = false; + bool persisted = false; + bool anchorOnFile = false; + bool rememberedApp = false; + bool sessionAlive = false; + bool pairingInFlight = false; + bool probeRemembered = false; + bool appsRemembered = false; + int bindings = 0; +}; + +Residue residueOf(const MoonlightManager& manager, const repository::MoonlightHostRepository& repo, + const QString& uuid) { + Residue out; + out.known = manager.knows(uuid); + for (const auto& row : manager.rows()) { + if (row.uuid != uuid) { continue; } + out.rowListed = true; + out.rememberedApp = !row.lastAppId.isEmpty(); + } + if (const auto stored = repo.get(uuid)) { + out.persisted = true; + out.anchorOnFile = !stored->serverCertPem.isEmpty(); + } + out.sessionAlive = manager.session(uuid) != nullptr; + out.pairingInFlight = manager.pairingActive() && manager.pairingHostUuid() == uuid; + const auto inputs = manager.uiInputs(uuid, QString()); + // probeAttempted and the apps flags are the two records a reply landing + // after the Forget would re-create, so they are what proves it did not. + out.probeRemembered = inputs.probeAttempted; + out.appsRemembered = inputs.appsRead || inputs.appsFailed || inputs.appsInFlight; + out.bindings = static_cast(manager.boundSlots(uuid).size()); + return out; +} + +} // namespace + +// ── Arrival ────────────────────────────────────────────────────────────────── + +TEST_CASE("a host typed in by address is remembered unpaired, with its ports", + "[moonlight][lifecycle]") { + // The discovery fallback: mDNS does not cross every subnet, so the manual + // path has to reach the same place the found path does. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + REQUIRE(manager.knows(uuid)); + const auto row = manager.row(uuid); + REQUIRE(row.has_value()); + CHECK(row->name == QStringLiteral("Den")); + CHECK(row->address == kNowhere); + CHECK(row->discovered); + CHECK_FALSE(row->paired); + // Not paired is a fact, not a fault: the row states it and offers Pair. + CHECK(row->trust == moonlight::HostTrust::NotPaired); + CHECK(row->phase == moonlight::HostPhase::Idle); + + // The ports are persisted on a stub so a later pair() has them without + // waiting for another sweep. + repository::MoonlightHostRepository repo(settings); + const auto stored = repo.get(uuid); + REQUIRE(stored.has_value()); + CHECK(stored->httpPort == 47989); + CHECK(stored->httpsPort == 47984); + CHECK(stored->serverCertPem.isEmpty()); +} + +TEST_CASE("adding the same address twice keeps the pairing already on file", + "[moonlight][lifecycle]") { + // A re-add is a re-discovery, and a re-discovery carries no anchor. Letting + // it write an empty one would unpair a host by typing its address again. + auto settings = test::makeSharedSettings(); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + repository::MoonlightHostRepository repo(settings); + auto host = pairedHost(uuid); + host.lastAppId = QStringLiteral("1093255277"); + host.lastAppName = QStringLiteral("Steam Big Picture"); + repo.upsert(host); + + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QString(), 47989, 47984); + + const auto stored = repo.get(uuid); + REQUIRE(stored.has_value()); + CHECK(stored->serverCertPem == kAnchor); + CHECK(stored->lastAppId == QStringLiteral("1093255277")); + CHECK(manager.row(uuid)->paired); +} + +// ── Pairing, and every way it can refuse ───────────────────────────────────── + +TEST_CASE("pairing a host nobody has heard of refuses out loud", "[moonlight][lifecycle]") { + // THE LIVE FAILURE THIS FILE ANSWERS. A Pair that returns quietly leaves the + // PIN sheet on an indeterminate spinner and four empty digit cells with + // nothing to time it out, which is what "I pressed Pair and nothing + // happened" looks like from the outside. It has to become a STATE. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("addr:198.51.100.7"); + + manager.pair(uuid); + + CHECK_FALSE(manager.pairingActive()); + CHECK(manager.pairingRefused(uuid)); + CHECK(manager.pairingRefusedReason(uuid) == QStringLiteral("unreachable")); + // And the render contract agrees, so a surface reading it shows the + // refusal rather than a handshake that is not happening. + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.pairingRefused); + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::PairingRefused); +} + +TEST_CASE("a refusal names itself and is scoped to the host it happened on", + "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + const QString mine = QStringLiteral("addr:198.51.100.7"); + const QString other = QStringLiteral("addr:198.51.100.8"); + + manager.pair(mine); + REQUIRE(manager.pairingRefused(mine)); + + // Pairing is one global attempt, but a refusal is about ONE host: a sheet + // open on a different one must not paint itself failed. + CHECK_FALSE(manager.pairingRefused(other)); + CHECK(manager.pairingRefusedReason(other).isEmpty()); + CHECK(moonlight::sessionUiState(manager.uiInputs(other, QString())) == + moonlight::SessionUiState::Checking); + + // A cancel is the user withdrawing the question, so the refusal goes. + manager.cancelPairing(); + CHECK_FALSE(manager.pairingRefused(mine)); + CHECK(manager.pairingRefusedReason(mine).isEmpty()); +} + +TEST_CASE("pairing a known host puts a four digit PIN on screen", "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + manager.pair(uuid); + + // The attempt is live: the PIN is minted client side and shown immediately, + // and phase 1 then blocks on the host until a human types it in. + CHECK(manager.pairingActive()); + CHECK(manager.pairingHostUuid() == uuid); + CHECK(manager.pairingPin().size() == 4); + CHECK_FALSE(manager.pairingRefused(uuid)); + CHECK(manager.row(uuid)->phase == moonlight::HostPhase::Pairing); + CHECK(moonlight::sessionUiState(manager.uiInputs(uuid, QString())) == + moonlight::SessionUiState::PairingPin); + + // Nothing is left dialling TEST-NET-1 once the case ends. + manager.cancelPairing(); + CHECK_FALSE(manager.pairingActive()); +} + +// ── Binding ────────────────────────────────────────────────────────────────── + +TEST_CASE("binding a paired host creates the session and takes controller zero", + "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + const auto number = manager.bindController(QStringLiteral("pad-a"), uuid, + moonproto::kControllerTypeAuto, plainPad()); + + REQUIRE(number.has_value()); + CHECK(*number == 0); + CHECK(manager.boundHostFor(QStringLiteral("pad-a")) == uuid); + CHECK(manager.controllerNumber(QStringLiteral("pad-a")) == 0); + auto* session = manager.session(uuid); + REQUIRE(session != nullptr); + CHECK(session->everStarted()); +} + +TEST_CASE("binding a host nobody has paired records the intent and starts nothing", + "[moonlight][lifecycle]") { + // A binding is a DURABLE INTENT. Pairing is remembered trust verified + // lazily, so the session is attempted when the pad is used and never when + // the binding is saved; nothing about the host may refuse the answer. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + const auto number = manager.bindController(QStringLiteral("pad-a"), uuid, + moonproto::kControllerTypeAuto, plainPad()); + + // No controller number, because there is no session to hold one. + CHECK_FALSE(number.has_value()); + CHECK(manager.boundHostFor(QStringLiteral("pad-a")) == uuid); + CHECK(manager.session(uuid) == nullptr); + CHECK(manager.controllerCount(uuid) == 1); + // Saving is not blocked, and the section says why it is waiting. + const auto inputs = manager.uiInputs(uuid, QStringLiteral("pad-a")); + CHECK_FALSE(moonlight::sessionUiBlocksApply(moonlight::sessionUiState(inputs))); + // Retiring it is a no-op on a session that never existed, not a crash. + manager.unbindController(QStringLiteral("pad-a")); + CHECK(manager.boundHostFor(QStringLiteral("pad-a")).isEmpty()); + CHECK(manager.controllerCount(uuid) == 0); +} + +TEST_CASE("a binding with no slot or no host is refused rather than half recorded", + "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + + CHECK_FALSE(manager + .bindController(QString(), QStringLiteral("host-uuid"), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + CHECK_FALSE(manager + .bindController(QStringLiteral("pad-a"), QString(), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + CHECK(manager.boundHostFor(QStringLiteral("pad-a")).isEmpty()); + CHECK(manager.session(QStringLiteral("host-uuid")) == nullptr); +} + +TEST_CASE("bindings do not outlive the process, and the pairing does", "[moonlight][lifecycle]") { + // Pins the CONTRACT, not an aspiration. Bindings are live routing state in + // this client for satellites and Moonlight alike, and the slot list is + // rebuilt from the devices actually present at launch. What has to survive + // is the TRUST and the picks, because those are what a restart cannot + // re-derive from the hardware in front of it. + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + const QString uuid = QStringLiteral("host-uuid"); + + { + MoonlightManager first(settings); + REQUIRE(first + .bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + first.setLastApp(uuid, QStringLiteral("1093255277"), QStringLiteral("Steam Big Picture")); + first.setControllerType(uuid, moonproto::kControllerTypePs); + } + + MoonlightManager second(settings); + CHECK(second.boundHostFor(QStringLiteral("pad-a")).isEmpty()); + CHECK(second.session(uuid) == nullptr); + const auto row = second.row(uuid); + REQUIRE(row.has_value()); + CHECK(row->paired); + CHECK(row->lastAppId == QStringLiteral("1093255277")); + CHECK(row->controllerType == moonproto::kControllerTypePs); +} + +// ── The session: one per host, reference counted ───────────────────────────── + +TEST_CASE("a second binding joins, a fifth is refused, and the last one out closes up", + "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + for (int i = 0; i < 4; ++i) { + const auto number = manager.bindController(QStringLiteral("pad-%1").arg(i), uuid, + moonproto::kControllerTypeAuto, plainPad()); + REQUIRE(number.has_value()); + CHECK(static_cast(*number) == i); + } + auto* session = manager.session(uuid); + REQUIRE(session != nullptr); + // ONE session object throughout: a second would mean a second /launch, and + // the host answers that with "an app is already running". + CHECK(session->controllerCount() == 4); + + // The four pad ceiling is the protocol's, and it is the ONE host state that + // blocks Apply, because the bind behind it is going to refuse. + CHECK_FALSE(manager + .bindController(QStringLiteral("pad-4"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + CHECK(manager.boundHostFor(QStringLiteral("pad-4")).isEmpty()); + CHECK(moonlight::sessionUiState(manager.uiInputs(uuid, QStringLiteral("pad-4"))) == + moonlight::SessionUiState::HostFull); + + for (int i = 0; i < 3; ++i) { + manager.unbindController(QStringLiteral("pad-%1").arg(i)); + CHECK(manager.controllerCount(uuid) == 3 - i); + // Somebody is still riding it, so the launch is left exactly as it was. + CHECK_FALSE(moonlight::sessionNeedsStart(session->machineState().phase)); + } + manager.unbindController(QStringLiteral("pad-3")); + // Nobody is on it, so the app is handed back rather than stranded. + CHECK(session->machineState().phase == moonlight::SessionPhase::Idle); + CHECK(manager.controllerCount(uuid) == 0); +} + +// ── Probing a host that answers ────────────────────────────────────────────── + +TEST_CASE("a probe that is answered settles the trust the row states", "[moonlight][lifecycle]") { + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/1)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + // Before anybody asks, the honest state is a spinner and not a verdict. + CHECK(moonlight::sessionUiState(manager.uiInputs(uuid, QString())) == + moonlight::SessionUiState::Checking); + + REQUIRE(probeAndSettle(manager, uuid)); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.probeAttempted); + CHECK(inputs.probeAnswered); + CHECK(inputs.paired); + CHECK_FALSE(inputs.identityChanged); + CHECK(moonlight::hostTrust(inputs) == moonlight::HostTrust::Paired); + CHECK(manager.row(uuid)->trust == moonlight::HostTrust::Paired); +} + +TEST_CASE("a host that answers with a different uuid is a different machine", + "[moonlight][lifecycle]") { + // The stored certificate anchors a MACHINE. A box reset or replaced behind + // the same address anchors nothing, and re-pairing is the only way back. + InfoHost fixture(serverInfo(QStringLiteral("someone-else"), /*pairStatus=*/0)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(probeAndSettle(manager, uuid)); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.identityChanged); + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::HostReplaced); + CHECK(moonlight::hostTrust(inputs) == moonlight::HostTrust::NotPaired); +} + +TEST_CASE("a host that answers unpaired while we remember one has lost the trust", + "[moonlight][lifecycle]") { + // The disagreement the other way round from the live report: the client has + // an anchor and the host has forgotten us. Answered-and-unpaired is a fact + // about NOW, and it wins over what is remembered. + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/0)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(probeAndSettle(manager, uuid)); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.probeAnswered); + CHECK_FALSE(inputs.paired); + CHECK(inputs.remembered); + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::TrustLost); +} + +TEST_CASE("a probe with nowhere to send it still finishes", "[moonlight][lifecycle]") { + // probeFinished is what the host screen waits on for every row it re-asks + // on open. A probe that returns without firing it parks that row on + // Checking with nothing left to move it. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("addr:198.51.100.9"); + + QStringList finished; + QObject::connect(&manager, &MoonlightManager::probeFinished, + [&finished](const QString& id) { finished.append(id); }); + manager.probe(uuid); + + REQUIRE(finished.size() == 1); + CHECK(finished.at(0) == uuid); + // And it recorded nothing, because nothing was learned. + CHECK_FALSE(manager.uiInputs(uuid, QString()).probeAttempted); +} + +// ── Forget ─────────────────────────────────────────────────────────────────── + +TEST_CASE("forgetting a host leaves not one piece of it behind", "[moonlight][lifecycle]") { + // The residue check, against every piece of state a host owns. On the + // Android client the equivalent Forget emptied the host list and left the + // pinned certificate on file, and a re-pair then met a pin the user + // believed they had deleted. + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + manager.setLastApp(uuid, QStringLiteral("1093255277"), QStringLiteral("Steam Big Picture")); + manager.setControllerType(uuid, moonproto::kControllerTypePs); + REQUIRE(manager + .bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + REQUIRE(manager + .bindController(QStringLiteral("pad-b"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + manager.refreshApps(uuid); // paired-only over TLS, so this records a failed read + REQUIRE(residueOf(manager, repo, uuid).appsRemembered); + + manager.forget(uuid); + + const Residue after = residueOf(manager, repo, uuid); + CHECK_FALSE(after.known); + CHECK_FALSE(after.rowListed); + CHECK_FALSE(after.persisted); + // The pairing anchor lives inside the row, so it cannot outlive it. This is + // the assertion the Android defect would have failed. + CHECK_FALSE(after.anchorOnFile); + CHECK_FALSE(after.rememberedApp); + CHECK_FALSE(after.sessionAlive); + CHECK_FALSE(after.probeRemembered); + CHECK_FALSE(after.appsRemembered); + CHECK(after.bindings == 0); + CHECK(manager.boundHostFor(QStringLiteral("pad-a")).isEmpty()); + CHECK(manager.boundHostFor(QStringLiteral("pad-b")).isEmpty()); + CHECK(manager.controllerCount(uuid) == 0); + // The controller numbers went with the session, so the next host to use + // this uuid starts counting from zero rather than from where we left off. + CHECK_FALSE(manager.controllerNumber(QStringLiteral("pad-a")).has_value()); + // A forgotten host is a stranger, not a paired one: the render contract + // reads Checking, which is "nobody has asked yet". + CHECK(moonlight::sessionUiState(manager.uiInputs(uuid, QString())) == + moonlight::SessionUiState::Checking); +} + +TEST_CASE("forget clears the answers a probe already brought back", "[moonlight][lifecycle]") { + // A host forgotten and added again is a STRANGER. Rendering it Paired on + // the strength of a question asked before it was forgotten is exactly the + // trust the host screen exists to state honestly. + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/1)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(probeAndSettle(manager, uuid)); + REQUIRE(manager.uiInputs(uuid, QString()).paired); + + manager.forget(uuid); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK_FALSE(inputs.probeAttempted); + CHECK_FALSE(inputs.paired); + CHECK_FALSE(inputs.remembered); + CHECK(moonlight::hostTrust(inputs) == moonlight::HostTrust::NotPaired); +} + +TEST_CASE("forget cancels a pairing in flight so it cannot write the host back", + "[moonlight][lifecycle]") { + // The resurrection path. A pairing that finishes ok upserts the row with + // the certificate it just verified, and it does not ask whether the host is + // still wanted. Left running, a Forget would look done and then undo itself: + // an empty host list with the pairing anchor still on file, which is the + // shape the Android client was found in. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + manager.pair(uuid); + REQUIRE(manager.pairingActive()); + REQUIRE(manager.pairingHostUuid() == uuid); + + manager.forget(uuid); + + repository::MoonlightHostRepository repo(settings); + const Residue after = residueOf(manager, repo, uuid); + CHECK_FALSE(after.pairingInFlight); + CHECK_FALSE(after.persisted); + CHECK_FALSE(after.known); + // The PIN goes with the attempt, so nothing is left on screen to type in. + CHECK_FALSE(manager.pairingActive()); + CHECK(manager.pairingPin().isEmpty()); +} + +TEST_CASE("forgetting one host is not felt by its neighbour", "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost(QStringLiteral("goes"))); + repo.upsert(pairedHost(QStringLiteral("stays"))); + MoonlightManager manager(settings); + + REQUIRE(manager + .bindController(QStringLiteral("pad-a"), QStringLiteral("goes"), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + REQUIRE(manager + .bindController(QStringLiteral("pad-b"), QStringLiteral("stays"), + moonproto::kControllerTypeAuto, plainPad()) + .has_value()); + + manager.forget(QStringLiteral("goes")); + + CHECK(manager.knows(QStringLiteral("stays"))); + CHECK(manager.boundHostFor(QStringLiteral("pad-b")) == QStringLiteral("stays")); + CHECK(manager.controllerCount(QStringLiteral("stays")) == 1); + CHECK(manager.session(QStringLiteral("stays")) != nullptr); + CHECK(repo.get(QStringLiteral("stays")).has_value()); +} + +TEST_CASE("a reply that outlives the forget is dropped, not written back", + "[moonlight][lifecycle]") { + // probes_ is written through QHash::operator[], which INSERTS. A probe + // answered a moment after a Forget would therefore re-create the record the + // Forget removed, and the row would render Paired on the strength of a + // question asked about a host that is gone. + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/1)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + manager.probe(uuid); + // Forgotten while the request is on the wire, before any reply is read. + manager.forget(uuid); + REQUIRE(spinUntil([&fixture] { return fixture.requests() > 0; })); + // Give the reply its chance to land and be handled. There is no signal to + // wait on, because the point is that the handler does nothing. + spinUntil([] { return false; }, 300); + + const Residue after = residueOf(manager, repo, uuid); + CHECK_FALSE(after.known); + CHECK_FALSE(after.persisted); + CHECK_FALSE(after.probeRemembered); + CHECK(moonlight::sessionUiState(manager.uiInputs(uuid, QString())) == + moonlight::SessionUiState::Checking); +} + +// ── Recovery ───────────────────────────────────────────────────────────────── + +TEST_CASE("re-pairing after a forget starts a fresh attempt, not a silent no-op", + "[moonlight][lifecycle]") { + // The live sequence, in order: pair, forget, pair again. A Forget drops the + // discovered entry along with the remembered row, so the id a surface is + // still holding names nothing, and the second Pair must SAY that rather + // than do nothing. Finding the host again is what makes it pairable. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + manager.pair(uuid); + REQUIRE(manager.pairingActive()); + manager.forget(uuid); + + manager.pair(uuid); + CHECK_FALSE(manager.pairingActive()); + CHECK(manager.pairingRefused(uuid)); + CHECK(manager.pairingRefusedReason(uuid) == QStringLiteral("unreachable")); + + // Add it again the way a sweep or the address sheet would, and pairing is + // available once more. Nothing left over blocks it. + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + manager.pair(uuid); + CHECK(manager.pairingActive()); + CHECK(manager.pairingPin().size() == 4); + CHECK_FALSE(manager.pairingRefused(uuid)); + manager.cancelPairing(); +} + +TEST_CASE("a host forgotten and found again pairs from a clean slate", "[moonlight][lifecycle]") { + // The whole loop: paired, bound, forgotten, re-added, probed. The probe + // reports the host unpaired because the anchor went with the row, which is + // the client and the host agreeing again rather than disagreeing. + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/0)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(hostAt(fixture)); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(manager + .bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + manager.forget(uuid); + + // Found again at the same address, and remembered under the synthetic id a + // sweep would give it until serverinfo hands back the real one. + manager.addManualHost(QStringLiteral("127.0.0.1"), QStringLiteral("Den"), fixture.port(), + 47984); + const QString found = QStringLiteral("addr:127.0.0.1"); + REQUIRE(probeAndSettle(manager, found)); + + const auto inputs = manager.uiInputs(found, QString()); + CHECK(inputs.probeAnswered); + CHECK_FALSE(inputs.paired); + CHECK_FALSE(inputs.remembered); + // Not paired, not trust lost: nothing is remembered to have lost. + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::NotPaired); + // And a synthetic id never reads as a replaced machine, because there was + // never a real uuid to compare the answer against. + CHECK_FALSE(inputs.identityChanged); +} + +TEST_CASE("a host that trusts us while we hold no certificate is not paired", + "[moonlight][lifecycle]") { + // THE DISAGREEMENT THE LIVE REPORT LANDED IN, from the other side. A host + // reports PairStatus against the uniqueid on the request, and this install + // keeps its identity across a Forget, so a box we forgot still answers 1. + // That is the host's half of the trust and not ours: the certificate every + // paired-only call pins against went with the row. Reporting Paired here + // would hide the Pair button behind a chip that nothing can act on. + InfoHost fixture(serverInfo(QStringLiteral("host-uuid"), /*pairStatus=*/1)); + REQUIRE(fixture.listening()); + + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + auto forgotten = hostAt(fixture); + forgotten.serverCertPem.clear(); // remembered as a destination, never paired + repo.upsert(forgotten); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(probeAndSettle(manager, uuid)); + + const auto inputs = manager.uiInputs(uuid, QString()); + CHECK(inputs.probeAnswered); + CHECK(inputs.paired); // the host's half + CHECK_FALSE(inputs.remembered); // ours + CHECK(moonlight::hostTrust(inputs) == moonlight::HostTrust::NotPaired); + // Not TrustLost: nothing was lost, and the recovery is an ordinary pairing. + CHECK(moonlight::sessionUiState(inputs) == moonlight::SessionUiState::NotPaired); + CHECK(manager.row(uuid)->trust == moonlight::HostTrust::NotPaired); +} + +TEST_CASE("choosing a host as a destination writes it down", "[moonlight][lifecycle]") { + // INTEREST IS DURABLE. A host that exists only in a scan result cannot + // carry a binding: the next sweep owns that set, and the app pick and the + // controller type the binding flow writes have nowhere to live. Acting on + // a host promotes it, unpaired, to a record. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + repository::MoonlightHostRepository repo(settings); + manager.bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()); + + const auto stored = repo.get(uuid); + REQUIRE(stored.has_value()); + CHECK(stored->address == kNowhere); + // Written as INTEREST, never as trust: the anchor is still only ever + // produced by a pairing handshake that verified it. + CHECK(stored->serverCertPem.isEmpty()); + CHECK(manager.row(uuid)->trust == moonlight::HostTrust::NotPaired); + + // And now the pick has somewhere to go, which it did not before. + manager.setLastApp(uuid, QStringLiteral("1093255277"), QStringLiteral("Steam Big Picture")); + CHECK(repo.get(uuid)->lastAppId == QStringLiteral("1093255277")); +} + +TEST_CASE("remembering a destination never overwrites the pairing on file", + "[moonlight][lifecycle]") { + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(manager + .bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) + .has_value()); + + CHECK(repo.get(uuid)->serverCertPem == kAnchor); +} + +TEST_CASE("re-binding a slot that already holds a number is a restart, not a second pad", + "[moonlight][lifecycle]") { + // What Reconnect after a drop does. A second attach for the same slot would + // be skipped by the host anyway, and losing the binding would be worse. + auto settings = test::makeSharedSettings(); + repository::MoonlightHostRepository repo(settings); + repo.upsert(pairedHost()); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("host-uuid"); + + REQUIRE(manager.bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 0); + CHECK(manager.bindController(QStringLiteral("pad-a"), uuid, moonproto::kControllerTypeAuto, + plainPad()) == 0); + CHECK(manager.controllerCount(uuid) == 1); +} + +TEST_CASE("an app pick with no host to keep it is refused, not swallowed", + "[moonlight][lifecycle]") { + // Only a REMEMBERED host has somewhere to keep a pick. Dropping it quietly + // is how a choice silently fails to stick, which is unarguable from the + // user's side and invisible from ours. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + const QString uuid = QStringLiteral("addr:198.51.100.9"); + + manager.setLastApp(uuid, QStringLiteral("1093255277"), QStringLiteral("Steam")); + manager.setControllerType(uuid, moonproto::kControllerTypePs); + + repository::MoonlightHostRepository repo(settings); + CHECK_FALSE(repo.get(uuid).has_value()); + CHECK_FALSE(manager.row(uuid).has_value()); +} + +TEST_CASE("quitting the app on a host nobody paired reports the refusal", + "[moonlight][lifecycle]") { + // /cancel is HTTPS and paired-only, so there is nothing to send. The caller + // still has to hear an answer. + auto settings = test::makeSharedSettings(); + MoonlightManager manager(settings); + manager.addManualHost(kNowhere, QStringLiteral("Den"), 47989, 47984); + const QString uuid = QStringLiteral("addr:%1").arg(kNowhere); + + bool sawResult = false; + bool ok = true; + QObject::connect(&manager, &MoonlightManager::hostAppCancelled, + [&](const QString& id, bool result) { + sawResult = id == uuid; + ok = result; + }); + manager.quitHostApp(uuid); + + CHECK(sawResult); + CHECK_FALSE(ok); +} diff --git a/tests/test_moonlight_host_repository.cpp b/tests/test_moonlight_host_repository.cpp new file mode 100644 index 0000000..fab187c --- /dev/null +++ b/tests/test_moonlight_host_repository.cpp @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The remembered-Moonlight-host store: JSON round-trip, upsert-preserves- +// anchor semantics, and namespace isolation from the satellite family in the +// co-tenant settings file. + +#include "repository/MoonlightHostRepository.h" + +#include "QSettingsFixture.h" +#include "RepositoryContract.h" +#include "repository/SettingsKeys.h" + +#include + +using dish::repository::kMoonlightControllerTypeAuto; +using dish::repository::MoonlightHost; +using dish::repository::MoonlightHostRepository; +using dish::test::makeSharedSettings; + +namespace { + +MoonlightHost sampleHost(const QString& uuid) { + MoonlightHost host; + host.uuid = uuid; + host.name = QStringLiteral("Living Room PC"); + host.address = QStringLiteral("192.168.1.42"); + host.serverCertPem = + QStringLiteral("-----BEGIN CERTIFICATE-----\nABC\n-----END CERTIFICATE-----\n"); + host.lastAppId = QStringLiteral("881448767"); + host.lastAppName = QStringLiteral("Desktop"); + host.controllerType = 2; + return host; +} + +} // namespace + +TEST_CASE("MoonlightHostRepository satisfies the repository contract", "[repository][moonlight]") { + dish::test::runRepositoryContract( + [] { return std::make_unique(makeSharedSettings()); }, + [](int i) { return QStringLiteral("uuid-%1").arg(i); }, + [](const QString& k) { + MoonlightHost h; + h.uuid = k; + h.name = QStringLiteral("host-") + k; + h.address = QStringLiteral("10.0.0.") + k.right(1); + return h; + }); +} + +TEST_CASE("a host round-trips through JSON", "[moonlight][repository]") { + MoonlightHostRepository repo(makeSharedSettings()); + const auto host = sampleHost(QStringLiteral("uuid-a")); + repo.upsert(host); + const auto loaded = repo.get(QStringLiteral("uuid-a")); + REQUIRE(loaded.has_value()); + CHECK(*loaded == host); + CHECK(loaded->paired()); +} + +TEST_CASE("upsert preserves the pairing anchor and app pick on re-discovery", + "[moonlight][repository]") { + auto store = makeSharedSettings(); + MoonlightHostRepository repo(store); + repo.upsert(sampleHost(QStringLiteral("uuid-a"))); + + // A bare re-discovery: same uuid, new address, no cert or app. + MoonlightHost rediscovered; + rediscovered.uuid = QStringLiteral("uuid-a"); + rediscovered.address = QStringLiteral("192.168.1.99"); + repo.upsert(rediscovered); + + const auto loaded = repo.get(QStringLiteral("uuid-a")); + REQUIRE(loaded.has_value()); + CHECK(loaded->address == QStringLiteral("192.168.1.99")); // address updated + CHECK(loaded->paired()); // cert preserved + CHECK(loaded->lastAppId == QStringLiteral("881448767")); // pick preserved + CHECK(loaded->name == QStringLiteral("Living Room PC")); // name preserved +} + +TEST_CASE("an empty uuid is not stored", "[moonlight][repository]") { + MoonlightHostRepository repo(makeSharedSettings()); + MoonlightHost host; + host.address = QStringLiteral("1.2.3.4"); + repo.upsert(host); + CHECK(repo.all().empty()); +} + +TEST_CASE("controllerType defaults to Auto when absent", "[moonlight][repository]") { + MoonlightHostRepository repo(makeSharedSettings()); + MoonlightHost host; + host.uuid = QStringLiteral("uuid-x"); + host.address = QStringLiteral("1.2.3.4"); + repo.put(QStringLiteral("uuid-x"), host); + const auto loaded = repo.get(QStringLiteral("uuid-x")); + REQUIRE(loaded.has_value()); + CHECK(loaded->controllerType == kMoonlightControllerTypeAuto); +} + +TEST_CASE("fromJson rejects rows without a uuid or address", "[moonlight][repository]") { + QJsonObject noUuid; + noUuid.insert(QStringLiteral("address"), QStringLiteral("1.2.3.4")); + CHECK_FALSE(MoonlightHost::fromJson(noUuid).has_value()); + + QJsonObject noAddr; + noAddr.insert(QStringLiteral("uuid"), QStringLiteral("u")); + CHECK_FALSE(MoonlightHost::fromJson(noAddr).has_value()); +} + +TEST_CASE("a controllerType of 0 migrates to the Auto sentinel", "[moonlight][repository]") { + // 0 is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to pick. + // That is a different promise from "match the pad", so a record written + // before the three clients converged on 0xFF is migrated on read rather + // than sent as it stands. + auto settings = makeSharedSettings(); + settings->setValue( + QLatin1String(dish::repository::keys::kMoonlightHostListKey), + QByteArray(R"({"legacy":{"uuid":"legacy","address":"10.0.0.9","controllerType":0}})")); + + MoonlightHostRepository repo(settings); + const auto loaded = repo.get(QStringLiteral("legacy")); + REQUIRE(loaded.has_value()); + CHECK(loaded->controllerType == kMoonlightControllerTypeAuto); + CHECK(loaded->controllerType == 0xFF); +} + +TEST_CASE("a controllerType outside the picker's range migrates too", "[moonlight][repository]") { + auto settings = makeSharedSettings(); + settings->setValue( + QLatin1String(dish::repository::keys::kMoonlightHostListKey), + QByteArray(R"({"odd":{"uuid":"odd","address":"10.0.0.8","controllerType":42}})")); + + MoonlightHostRepository repo(settings); + const auto loaded = repo.get(QStringLiteral("odd")); + REQUIRE(loaded.has_value()); + CHECK(loaded->controllerType == kMoonlightControllerTypeAuto); +} + +TEST_CASE("the three real picks survive a round trip untouched", "[moonlight][repository]") { + auto settings = makeSharedSettings(); + MoonlightHostRepository repo(settings); + for (const int pick : {1, 2, 3}) { + MoonlightHost host; + host.uuid = QStringLiteral("pick-%1").arg(pick); + host.address = QStringLiteral("10.0.0.1"); + host.controllerType = pick; + repo.upsert(host); + const auto loaded = repo.get(host.uuid); + REQUIRE(loaded.has_value()); + CHECK(loaded->controllerType == pick); + } +} diff --git a/tests/test_moonlight_pad_slots.cpp b/tests/test_moonlight_pad_slots.cpp new file mode 100644 index 0000000..0705665 --- /dev/null +++ b/tests/test_moonlight_pad_slots.cpp @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Controller-number allocation, the CONTROLLER_MULTI active mask, and the +// hard-coded capability table every CONTROLLER_ARRIVAL is built from. These are +// the decisions a host cannot help us with: no Moonlight host reports what its +// emulated devices carry, so the table below IS the client's knowledge and a +// regression in it is invisible on the wire until a pad silently stops sending +// motion the host asked for. + +#include "core/moonlight/MoonlightPadSlots.h" +#include "core/moonlight/MoonlightSessionMachine.h" + +#include + +#include + +using namespace dish::moonlight; +namespace proto = dish::moonproto; + +namespace { + +SourceCapabilities everything() { + SourceCapabilities source; + source.rumble = true; + source.motion = true; + source.touchpad = true; + source.battery = true; + source.lightbar = true; + return source; +} + +SourceCapabilities plainPad() { + SourceCapabilities source; + source.rumble = true; + return source; +} + +} // namespace + +TEST_CASE("controller numbers are the lowest free index, never reused live", "[moonlight][pads]") { + PadSlots slots; + REQUIRE(slots.assign("a") == 0); + REQUIRE(slots.assign("b") == 1); + REQUIRE(slots.assign("c") == 2); + REQUIRE(slots.assign("d") == 3); + CHECK(slots.full()); + // The fifth pad has no number, which is the whole of the four-pad limit. + CHECK_FALSE(slots.assign("e").has_value()); + + // A slot that already holds one is not handed a second. + CHECK_FALSE(slots.assign("b").has_value()); + CHECK(slots.numberFor("b") == 1); + + // The freed index is the next one handed out, and only once it is free. + REQUIRE(slots.release("b") == 1); + CHECK_FALSE(slots.numberFor("b").has_value()); + CHECK(slots.assign("e") == 1); +} + +TEST_CASE("the active mask has one bit per attached pad", "[moonlight][pads]") { + PadSlots slots; + CHECK(slots.activeMask() == 0x0000); + slots.assign("a"); + CHECK(slots.activeMask() == 0x0001); + slots.assign("b"); + slots.assign("c"); + CHECK(slots.activeMask() == 0x0007); + // Clearing a bit while still naming the controller IS the unplug, so the + // mask has to drop it before the final packet goes out. + slots.release("b"); + CHECK(slots.activeMask() == 0x0005); + slots.release("a"); + slots.release("c"); + CHECK(slots.activeMask() == 0x0000); + CHECK(slots.empty()); +} + +TEST_CASE("an inbound event resolves to the pad holding its number", "[moonlight][pads]") { + PadSlots slots; + slots.assign("first"); + slots.assign("second"); + REQUIRE(slots.slotFor(0) == std::string("first")); + REQUIRE(slots.slotFor(1) == std::string("second")); + CHECK_FALSE(slots.slotFor(2).has_value()); + slots.release("first"); + CHECK_FALSE(slots.slotFor(0).has_value()); +} + +TEST_CASE("the capability ceiling per emulated type", "[moonlight][pads]") { + // Exactly the table the reference host builds: an Xbox and a Nintendo + // device are sticks, buttons, analog triggers and body rumble; only the + // PlayStation device carries trigger rumble, touch, motion, battery and an + // LED. + CHECK(typeCapabilityCeiling(proto::kControllerTypeXbox) == 0x03); + CHECK(typeCapabilityCeiling(proto::kControllerTypeNintendo) == 0x03); + CHECK(typeCapabilityCeiling(proto::kControllerTypePs) == 0xFF); + // An unknown byte is never given more than the conservative pair. + CHECK(typeCapabilityCeiling(proto::kControllerTypeUnknown) == 0x03); +} + +TEST_CASE("Nintendo carries no motion over Moonlight", "[moonlight][pads]") { + // Unlike the satellite `switchpro` type, which is a DIFFERENT type system + // that happens to share the name: the reference host asks for accelerometer + // and gyro only for a PlayStation device and routes motion only into one. + const std::uint8_t nintendo = typeCapabilityCeiling(proto::kControllerTypeNintendo); + CHECK((nintendo & proto::kCapGyro) == 0); + CHECK((nintendo & proto::kCapAccelerometer) == 0); + CHECK((nintendo & proto::kCapTouchpad) == 0); + CHECK((nintendo & proto::kCapRumble) != 0); + + const std::uint8_t ps = typeCapabilityCeiling(proto::kControllerTypePs); + CHECK((ps & proto::kCapGyro) != 0); + CHECK((ps & proto::kCapAccelerometer) != 0); + CHECK((ps & proto::kCapTouchpad) != 0); +} + +TEST_CASE("Auto resolves on the client, before the wire", "[moonlight][pads]") { + // Motion present becomes PlayStation, everything else Xbox. The sentinel + // itself never travels: it is not a wire value. + CHECK(resolveAutoType(true) == proto::kControllerTypePs); + CHECK(resolveAutoType(false) == proto::kControllerTypeXbox); + CHECK(resolveControllerType(proto::kControllerTypeAuto, true) == proto::kControllerTypePs); + CHECK(resolveControllerType(proto::kControllerTypeAuto, false) == proto::kControllerTypeXbox); + + // An explicit pick is honoured whatever the pad reports. + CHECK(resolveControllerType(proto::kControllerTypeXbox, true) == proto::kControllerTypeXbox); + CHECK(resolveControllerType(proto::kControllerTypeNintendo, true) == + proto::kControllerTypeNintendo); + CHECK(resolveControllerType(proto::kControllerTypePs, false) == proto::kControllerTypePs); +} + +TEST_CASE("the Auto sentinel is 0xFF, and a stored 0 migrates to it", "[moonlight][pads]") { + // 0 is CONTROLLER_TYPE_UNKNOWN on the wire, which asks the HOST to pick. + // That is a different promise from "match the pad", so a record written + // before the sentinel converged is migrated on read rather than sent. + CHECK(proto::kControllerTypeAuto == 0xFF); + CHECK(migrateControllerType(0) == proto::kControllerTypeAuto); + CHECK(migrateControllerType(proto::kControllerTypeAuto) == proto::kControllerTypeAuto); + // Anything outside the picker's own range is Auto too, never a wire value. + CHECK(migrateControllerType(-1) == proto::kControllerTypeAuto); + CHECK(migrateControllerType(9) == proto::kControllerTypeAuto); + CHECK(migrateControllerType(255) == proto::kControllerTypeAuto); + // The three real picks survive untouched. + CHECK(migrateControllerType(1) == 1); + CHECK(migrateControllerType(2) == 2); + CHECK(migrateControllerType(3) == 3); + // And a migrated 0 resolves the way Auto does, not the way Unknown would. + CHECK(resolveControllerType(0, true) == proto::kControllerTypePs); + CHECK(resolveControllerType(0, false) == proto::kControllerTypeXbox); +} + +TEST_CASE("the declared bitfield is the type ceiling AND the source", "[moonlight][pads]") { + // Declaring a capability the source cannot deliver makes the host request + // reports that never arrive. + CHECK(declaredCapabilities(proto::kControllerTypePs, everything()) == 0xFF); + // A plain rumbling pad announced as a PlayStation device gets no touch, no + // motion, no battery and no LED, because it has none. + const std::uint8_t plainAsPs = declaredCapabilities(proto::kControllerTypePs, plainPad()); + CHECK((plainAsPs & proto::kCapTouchpad) == 0); + CHECK((plainAsPs & proto::kCapGyro) == 0); + CHECK((plainAsPs & proto::kCapRgbLed) == 0); + CHECK((plainAsPs & proto::kCapAnalogTriggers) != 0); + CHECK((plainAsPs & proto::kCapRumble) != 0); + // A fully featured pad announced as an Xbox device is cut to the ceiling. + CHECK(declaredCapabilities(proto::kControllerTypeXbox, everything()) == 0x03); + CHECK(declaredCapabilities(proto::kControllerTypeNintendo, everything()) == 0x03); + // A pad with no motors still declares its analog triggers. + CHECK(declaredCapabilities(proto::kControllerTypeXbox, SourceCapabilities{}) == + proto::kCapAnalogTriggers); +} + +TEST_CASE("the touchpad click rides only a live touchpad", "[moonlight][pads]") { + // supportedButtonFlags is the whole legacy word for every type; the + // touchpad click is the one bit that is conditional. + CHECK(declaredButtons(0x03) == 0x0000FFFFU); + const std::uint32_t withTouch = + declaredButtons(declaredCapabilities(proto::kControllerTypePs, everything())); + CHECK(withTouch == (0x0000FFFFU | proto::kBtnTouchpad)); + CHECK(declaredButtons(declaredCapabilities(proto::kControllerTypePs, plainPad())) == + 0x0000FFFFU); +} + +TEST_CASE("a second binding joins the session and never launches a second", "[moonlight][pads]") { + // The reference count is the whole point: the session belongs to the HOST. + CHECK(sessionNeedsStart(SessionPhase::Idle)); + CHECK(sessionNeedsStart(SessionPhase::Failed)); + for (const SessionPhase live : + {SessionPhase::CheckingInfo, SessionPhase::Launching, SessionPhase::Rtsp, + SessionPhase::ControlConnecting, SessionPhase::Streaming}) { + CHECK_FALSE(sessionNeedsStart(live)); + } +} + +TEST_CASE("only the last unbind hands a live app back", "[moonlight][pads]") { + // A session that never went live always cancels: the host is holding an app + // for us and every later attempt would be refused by our own leftovers. + CHECK(shouldHandBackApp(/*launched=*/true, /*wentLive=*/false, + /*lastControllerLeft=*/false)); + CHECK(shouldHandBackApp(true, false, true)); + // A live session with controllers still on it is left alone. + CHECK_FALSE(shouldHandBackApp(true, true, false)); + // The last one out closes the door. + CHECK(shouldHandBackApp(true, true, true)); + // Nothing was ever launched, so there is nothing to hand back. + CHECK_FALSE(shouldHandBackApp(false, false, true)); + CHECK_FALSE(shouldHandBackApp(false, true, true)); +} + +TEST_CASE("four controllers ride one session and each keeps its own type", "[moonlight][pads]") { + // The end-to-end shape: four bindings on one host, each with its own + // emulated type, one shared mask, and no index reused while live. + PadSlots slots; + std::set numbers; + const char* ids[] = {"pad0", "pad1", "pad2", "pad3"}; + for (const char* id : ids) { + const auto n = slots.assign(id); + REQUIRE(n.has_value()); + numbers.insert(*n); + } + CHECK(numbers.size() == kMaxPads); + CHECK(slots.activeMask() == 0x000F); + + // Type is PER BINDING, so one host can carry a PlayStation pad and an Xbox + // pad at the same time. + CHECK(declaredCapabilities(resolveControllerType(proto::kControllerTypeAuto, true), + everything()) == 0xFF); + CHECK(declaredCapabilities(resolveControllerType(proto::kControllerTypeAuto, false), + everything()) == 0x03); + + // Three leave; the session still has a rider, so the app stays up. + CHECK(slots.release("pad0").has_value()); + CHECK(slots.release("pad1").has_value()); + CHECK(slots.release("pad2").has_value()); + CHECK_FALSE(slots.empty()); + CHECK_FALSE(shouldHandBackApp(true, true, slots.empty())); + // The fourth leaves and the app is handed back. + CHECK(slots.release("pad3").has_value()); + CHECK(slots.empty()); + CHECK(shouldHandBackApp(true, true, slots.empty())); +} diff --git a/tests/test_moonlight_pairing.cpp b/tests/test_moonlight_pairing.cpp new file mode 100644 index 0000000..eff8d89 --- /dev/null +++ b/tests/test_moonlight_pairing.cpp @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Round-trips the whole PIN handshake in both directions: the client side is +// the production PairingSession, the host side is re-derived here from the +// same primitives, following the protocol's host algorithm (Wolf's +// moonlight.cpp pair functions). Every random input is fixed, so failures +// reproduce byte-for-byte. + +#include "core/moonlight/MoonlightPairing.h" +#include "core/moonlight/MoonlightPairingCrypto.h" + +#include "Util/Hex.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace dish; +using namespace dish::mooncrypto; +using dish::moonpair::PairingSession; + +namespace { + +std::array patternBytes(std::uint8_t seed) { + std::array out{}; + for (std::size_t i = 0; i < out.size(); ++i) { + out[i] = static_cast(seed + i * 7); + } + return out; +} + +std::string upperHex(const Bytes& bytes) { + std::string hex = util::toHex(bytes); + std::transform(hex.begin(), hex.end(), hex.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + return hex; +} + +Bytes mustHexDecode(const std::string& hex) { + const auto decoded = util::fromHex(hex); + REQUIRE(decoded.has_value()); + return *decoded; +} + +// The host side of the handshake, mirroring the protocol's server algorithm +// with deterministic secrets. +struct FakeHost { + explicit FakeHost(ClientIdentity id) : identity(std::move(id)) {} + + ClientIdentity identity; + std::array aesKey{}; + std::array serverSecret = patternBytes(0x21); + std::array serverChallenge = patternBytes(0x87); + + Bytes clientChallenge; // decrypted in phase 2 + Bytes clientHash; // decrypted in phase 3 + + // Phase 1: derive the key, hand back the cert. + std::string phase1(const std::string& saltHex, const std::string& pinTyped) { + const auto saltBytes = mustHexDecode(saltHex); + REQUIRE(saltBytes.size() == 16); + std::array salt{}; + std::copy(saltBytes.begin(), saltBytes.end(), salt.begin()); + aesKey = derivePairingKey(salt, pinTyped); + return upperHex(Bytes(identity.certPem.begin(), identity.certPem.end())); + } + + // Phase 2: decrypt the challenge, answer hash(challenge + own cert + // signature + server secret) + server challenge, encrypted. + std::string phase2(const std::string& clientChallengeHex) { + const auto encrypted = mustHexDecode(clientChallengeHex); + const auto decrypted = aesEcbDecrypt(aesKey, encrypted.data(), encrypted.size()); + REQUIRE(decrypted.has_value()); + clientChallenge = *decrypted; + + const auto certSig = certSignature(identity.certPem); + REQUIRE(certSig.has_value()); + Bytes material = clientChallenge; + material.insert(material.end(), certSig->begin(), certSig->end()); + material.insert(material.end(), serverSecret.begin(), serverSecret.end()); + const auto hash = sha256(material.data(), material.size()); + + Bytes response(hash.begin(), hash.end()); + response.insert(response.end(), serverChallenge.begin(), serverChallenge.end()); + const auto sealed = aesEcbEncrypt(aesKey, response.data(), response.size()); + REQUIRE(sealed.has_value()); + return upperHex(*sealed); + } + + // Phase 3: decrypt the client hash, answer secret + signature. + std::string phase3(const std::string& serverChallengeRespHex) { + const auto encrypted = mustHexDecode(serverChallengeRespHex); + const auto decrypted = aesEcbDecrypt(aesKey, encrypted.data(), encrypted.size()); + REQUIRE(decrypted.has_value()); + clientHash = *decrypted; + + const auto signature = + rsaSignSha256(identity.privateKeyPem, serverSecret.data(), serverSecret.size()); + REQUIRE(signature.has_value()); + Bytes payload(serverSecret.begin(), serverSecret.end()); + payload.insert(payload.end(), signature->begin(), signature->end()); + return upperHex(payload); + } + + // Phase 4: the host's verdict over the client's secret + signature. + bool phase4(const std::string& clientPairingSecretHex, const std::string& clientCertPem) { + const auto payload = mustHexDecode(clientPairingSecretHex); + if (payload.size() < 16 + kRsaSignatureSize) { return false; } + const std::uint8_t* clientSecret = payload.data(); + const std::uint8_t* signature = payload.data() + 16; + + const auto clientCertSig = certSignature(clientCertPem); + if (!clientCertSig) { return false; } + Bytes material(serverChallenge.begin(), serverChallenge.end()); + material.insert(material.end(), clientCertSig->begin(), clientCertSig->end()); + material.insert(material.end(), clientSecret, clientSecret + 16); + const auto expected = sha256(material.data(), material.size()); + if (clientHash.size() != expected.size() || + std::memcmp(clientHash.data(), expected.data(), expected.size()) != 0) { + return false; + } + return rsaVerifySha256(clientCertPem, clientSecret, 16, signature, kRsaSignatureSize); + } +}; + +const ClientIdentity& clientIdentity() { + static const ClientIdentity id = [] { + const auto generated = generateClientIdentity(); + REQUIRE(generated.has_value()); + return *generated; + }(); + return id; +} + +const ClientIdentity& hostIdentity() { + static const ClientIdentity id = [] { + const auto generated = generateClientIdentity(); + REQUIRE(generated.has_value()); + return *generated; + }(); + return id; +} + +PairingSession makeSession(const std::string& pin) { + return PairingSession(clientIdentity().certPem, clientIdentity().privateKeyPem, + patternBytes(0x01), pin, patternBytes(0x43), patternBytes(0x65)); +} + +} // namespace + +TEST_CASE("pinFromRandom keeps leading zeros", "[moonlight][pairing]") { + CHECK(dish::moonpair::pinFromRandom(0) == "0000"); + CHECK(dish::moonpair::pinFromRandom(42) == "0042"); + CHECK(dish::moonpair::pinFromRandom(19999) == "9999"); + CHECK(dish::moonpair::pinFromRandom(1234) == "1234"); +} + +TEST_CASE("full handshake succeeds on both ends with the right PIN", "[moonlight][pairing]") { + FakeHost host(hostIdentity()); + PairingSession session = makeSession("4989"); + + // Phase 1. + const std::string plaincert = host.phase1(session.saltHex(), "4989"); + REQUIRE(session.acceptServerCert(plaincert)); + CHECK(session.serverCertPem() == hostIdentity().certPem); + + // Phase 2. + const auto challenge = session.clientChallengeHex(); + REQUIRE(challenge.has_value()); + const std::string challengeResponse = host.phase2(*challenge); + const auto serverChallengeResp = session.acceptChallengeResponse(challengeResponse); + REQUIRE(serverChallengeResp.has_value()); + + // The host decrypted our real challenge bytes. + CHECK(util::toHex(host.clientChallenge) == util::toHex(patternBytes(0x43).data(), 16)); + + // Phase 3: the client accepts the host's secret + signature. + const std::string pairingSecret = host.phase3(*serverChallengeResp); + CHECK(session.acceptPairingSecret(pairingSecret)); + + // Phase 4: the host accepts the client's secret + signature. + const auto clientPairingSecret = session.clientPairingSecretHex(); + REQUIRE(clientPairingSecret.has_value()); + CHECK(host.phase4(*clientPairingSecret, clientIdentity().certPem)); +} + +TEST_CASE("a wrong PIN is detected by the client at phase 3", "[moonlight][pairing]") { + FakeHost host(hostIdentity()); + PairingSession session = makeSession("4989"); // client's PIN differs + + host.phase1(session.saltHex(), "1111"); + REQUIRE(session.acceptServerCert( + upperHex(Bytes(hostIdentity().certPem.begin(), hostIdentity().certPem.end())))); + const auto challenge = session.clientChallengeHex(); + REQUIRE(challenge.has_value()); + const std::string challengeResponse = host.phase2(*challenge); + const auto serverChallengeResp = session.acceptChallengeResponse(challengeResponse); + // The decrypt "succeeds" but yields noise on both sides… + REQUIRE(serverChallengeResp.has_value()); + // …so the hash check MUST fail when the host reveals its secret. + CHECK_FALSE(session.acceptPairingSecret(host.phase3(*serverChallengeResp))); +} + +TEST_CASE("a substituted server certificate is rejected", "[moonlight][pairing]") { + FakeHost host(hostIdentity()); + PairingSession session = makeSession("4989"); + + host.phase1(session.saltHex(), "4989"); + // A man in the middle presents its own cert but cannot sign with the real + // host key behind the phase-2 hash material. + REQUIRE(session.acceptServerCert( + upperHex(Bytes(clientIdentity().certPem.begin(), clientIdentity().certPem.end())))); + const auto challenge = session.clientChallengeHex(); + REQUIRE(challenge.has_value()); + const auto serverChallengeResp = session.acceptChallengeResponse(host.phase2(*challenge)); + REQUIRE(serverChallengeResp.has_value()); + CHECK_FALSE(session.acceptPairingSecret(host.phase3(*serverChallengeResp))); +} + +TEST_CASE("a tampered pairing secret signature is rejected", "[moonlight][pairing]") { + FakeHost host(hostIdentity()); + PairingSession session = makeSession("4989"); + + host.phase1(session.saltHex(), "4989"); + REQUIRE(session.acceptServerCert( + upperHex(Bytes(hostIdentity().certPem.begin(), hostIdentity().certPem.end())))); + const auto challenge = session.clientChallengeHex(); + REQUIRE(challenge.has_value()); + const auto serverChallengeResp = session.acceptChallengeResponse(host.phase2(*challenge)); + REQUIRE(serverChallengeResp.has_value()); + + std::string pairingSecret = host.phase3(*serverChallengeResp); + // Flip a nibble inside the signature half. + const std::size_t at = pairingSecret.size() - 3; + pairingSecret[at] = pairingSecret[at] == '0' ? '1' : '0'; + CHECK_FALSE(session.acceptPairingSecret(pairingSecret)); +} + +TEST_CASE("malformed responses are refused", "[moonlight][pairing]") { + PairingSession session = makeSession("4989"); + + SECTION("plaincert that is not hex") { CHECK_FALSE(session.acceptServerCert("zz-not-hex")); } + SECTION("plaincert that is hex but not a certificate") { + CHECK_FALSE(session.acceptServerCert("41414141")); + } + SECTION("clientChallengeHex needs the server cert first") { + CHECK_FALSE(session.clientChallengeHex().has_value()); + } + SECTION("short challengeresponse") { + REQUIRE(session.acceptServerCert( + upperHex(Bytes(hostIdentity().certPem.begin(), hostIdentity().certPem.end())))); + CHECK_FALSE(session.acceptChallengeResponse("00112233").has_value()); + } + SECTION("pairing secret before the challenge phases") { + CHECK_FALSE(session.acceptPairingSecret(std::string(544, 'A'))); + } +} diff --git a/tests/test_moonlight_rtsp.cpp b/tests/test_moonlight_rtsp.cpp new file mode 100644 index 0000000..2990ccf --- /dev/null +++ b/tests/test_moonlight_rtsp.cpp @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Request strings are pinned exactly — the host side parses them with a strict +// grammar — and responses are parsed in both the \r\n and bare \n forms hosts +// emit. + +#include "core/moonlight/MoonlightRtsp.h" + +#include + +#include +#include +#include +#include + +using namespace dish::moonrtsp; + +TEST_CASE("OPTIONS request formatting", "[moonlight][rtsp]") { + const std::string req = formatOptions(1, "rtsp://192.168.1.100:48010"); + CHECK(req == "OPTIONS rtsp://192.168.1.100:48010 RTSP/1.0\r\n" + "CSeq: 1\r\n" + "X-GS-ClientVersion: 14\r\n" + "\r\n"); +} + +TEST_CASE("DESCRIBE request formatting", "[moonlight][rtsp]") { + const std::string req = formatDescribe(2, "rtsp://10.0.0.7:48010"); + CHECK(req == "DESCRIBE rtsp://10.0.0.7:48010 RTSP/1.0\r\n" + "CSeq: 2\r\n" + "X-GS-ClientVersion: 14\r\n" + "Accept: application/sdp\r\n" + "\r\n"); +} + +TEST_CASE("SETUP request formatting, with and without a session", "[moonlight][rtsp]") { + CHECK(formatSetup(3, "audio", "") == "SETUP streamid=audio/0/0 RTSP/1.0\r\n" + "CSeq: 3\r\n" + "X-GS-ClientVersion: 14\r\n" + "Transport: unicast;X-GS-ClientPort=50000-50001\r\n" + "If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT\r\n" + "\r\n"); + const std::string withSession = formatSetup(4, "control", "DEADBEEFCAFE"); + CHECK(withSession.find("SETUP streamid=control/0/0 RTSP/1.0\r\n") == 0); + CHECK(withSession.find("Session: DEADBEEFCAFE\r\n") != std::string::npos); +} + +TEST_CASE("ANNOUNCE request carries the SDP payload", "[moonlight][rtsp]") { + const std::string payload = buildAnnouncePayload(StreamConfig{}); + const std::string req = formatAnnounce(6, "DEADBEEFCAFE", payload); + CHECK(req.find("ANNOUNCE streamid=control/13/0 RTSP/1.0\r\n") == 0); + CHECK(req.find("Content-type: application/sdp\r\n") != std::string::npos); + CHECK(req.find("Content-length: " + std::to_string(payload.size()) + "\r\n") != + std::string::npos); + // The payload rides after the blank line. + CHECK(req.find("\r\n\r\nv=0\r\n") != std::string::npos); +} + +TEST_CASE("ANNOUNCE payload carries the WHOLE attribute set", "[moonlight][rtsp]") { + // IT HAS TO BE THE WHOLE SET. A host builds its stream configuration by + // looking each attribute up by name and a lookup that misses is fatal: + // measured against a live Sunshine host, an ANNOUNCE carrying only the + // seven attributes this client itself cares about is answered + // 400 BAD REQUEST, while this set is answered 200 OK. Nothing here is + // decoration, and an attribute dropped as unused is a host that stops + // talking to us. + StreamConfig config; + config.width = 2560; + config.height = 1440; + config.fps = 120; + const std::string p = buildAnnouncePayload(config); + + const std::vector expected = { + "v=0", + "o=android 0 14 IN IPv4 0.0.0.0", + "s=NVIDIA Streaming Client", + "a=x-nv-video[0].clientViewportWd:2560", + "a=x-nv-video[0].clientViewportHt:1440", + "a=x-nv-video[0].maxFPS:120", + "a=x-nv-video[0].packetSize:1024", + "a=x-nv-video[0].rateControlMode:4", + "a=x-nv-video[0].timeoutLengthMs:7000", + "a=x-nv-video[0].framesWithInvalidRefThreshold:0", + "a=x-nv-video[0].refPicInvalidation:0", + "a=x-nv-video[0].encoderCscMode:0", + "a=x-nv-video[0].dynamicRangeMode:0", + "a=x-nv-video[0].maxNumReferenceFrames:1", + "a=x-nv-video[0].videoEncoderSlicesPerFrame:1", + "a=x-nv-video[0].clientRefreshRateX100:12000", + "a=x-nv-vqos[0].bitStreamFormat:0", + "a=x-nv-vqos[0].bw.minimumBitrateKbps:500", + "a=x-nv-vqos[0].bw.maximumBitrateKbps:500", + "a=x-nv-vqos[0].fec.enable:1", + "a=x-nv-vqos[0].fec.minRequiredFecPackets:2", + "a=x-nv-vqos[0].fec.repairPercent:20", + "a=x-nv-vqos[0].drc.enable:0", + "a=x-nv-vqos[0].videoQualityScoreUpdateTime:5000", + "a=x-nv-vqos[0].qosTrafficType:5", + "a=x-nv-aqos.qosTrafficType:4", + "a=x-nv-aqos.packetDuration:5", + "a=x-nv-audio.surround.numChannels:2", + "a=x-nv-audio.surround.channelMask:3", + "a=x-nv-audio.surround.enable:0", + "a=x-nv-audio.surround.AudioQuality:0", + "a=x-nv-general.useReliableUdp:13", + "a=x-nv-general.featureFlags:167", + "a=x-ml-general.featureFlags:3", + "a=x-ss-general.encryptionEnabled:0", + "t=0 0", + }; + + // Every line, in order, CRLF-terminated, and nothing else. + std::string rebuilt; + for (const auto& line : expected) { + CHECK(p.find(line + "\r\n") != std::string::npos); + rebuilt += line + "\r\n"; + } + CHECK(p == rebuilt); + + // Thirty-two attributes, plus the v/o/s/t framing lines. + std::size_t attributes = 0; + for (std::size_t at = 0; (at = p.find("\na=", at)) != std::string::npos; ++at) { ++attributes; } + CHECK(attributes == 32); + CHECK(expected.size() == 36); + CHECK(p.find("v=0\r\n") == 0); +} + +TEST_CASE("ANNOUNCE payload follows the negotiated display mode", "[moonlight][rtsp]") { + StreamConfig config; + CHECK(config.width == 1920); + CHECK(config.height == 1080); + CHECK(config.fps == 60); + const std::string p = buildAnnouncePayload(config); + CHECK(p.find("a=x-nv-video[0].clientViewportWd:1920\r\n") != std::string::npos); + CHECK(p.find("a=x-nv-video[0].clientViewportHt:1080\r\n") != std::string::npos); + CHECK(p.find("a=x-nv-video[0].maxFPS:60\r\n") != std::string::npos); + CHECK(p.find("a=x-nv-video[0].clientRefreshRateX100:6000\r\n") != std::string::npos); +} + +TEST_CASE("PLAY request formatting", "[moonlight][rtsp]") { + const std::string req = formatPlay(7, "rtsp://192.168.1.100:48010", "DEADBEEFCAFE"); + CHECK(req == "PLAY rtsp://192.168.1.100:48010 RTSP/1.0\r\n" + "CSeq: 7\r\n" + "X-GS-ClientVersion: 14\r\n" + "Session: DEADBEEFCAFE\r\n" + "\r\n"); +} + +TEST_CASE("parses a CRLF response with options", "[moonlight][rtsp]") { + const auto resp = parseResponse("RTSP/1.0 200 OK\r\n" + "CSeq: 5\r\n" + "Session: DEADBEEFCAFE;timeout = 90\r\n" + "Transport: server_port=47999\r\n" + "X-SS-Connect-Data: 3735928559\r\n" + "\r\n"); + REQUIRE(resp.has_value()); + CHECK(resp->ok()); + CHECK(resp->statusCode == 200); + CHECK(resp->statusMessage == "OK"); + CHECK(resp->cseq == 5); + CHECK(transportPort(*resp) == 47999); + CHECK(connectData(*resp) == 3735928559U); + CHECK(sessionId(*resp) == "DEADBEEFCAFE"); +} + +TEST_CASE("parses a bare-LF response with payload", "[moonlight][rtsp]") { + const auto resp = parseResponse("RTSP/1.0 200 OK\n" + "CSeq: 2\n" + "\n" + "sprop-parameter-sets=AAAAAU\n" + "a=fmtp:97 surround-params=21101\n"); + REQUIRE(resp.has_value()); + CHECK(resp->ok()); + CHECK(resp->cseq == 2); + CHECK(resp->payload.find("surround-params=21101") != std::string::npos); +} + +TEST_CASE("parses an error response", "[moonlight][rtsp]") { + const auto resp = parseResponse("RTSP/1.0 404 NOT FOUND\r\nCSeq: 9\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK_FALSE(resp->ok()); + CHECK(resp->statusCode == 404); + CHECK(resp->statusMessage == "NOT FOUND"); + CHECK(resp->cseq == 9); +} + +TEST_CASE("rejects non-response input", "[moonlight][rtsp]") { + CHECK_FALSE(parseResponse("").has_value()); + CHECK_FALSE( + parseResponse("OPTIONS rtsp://1.2.3.4:48010 RTSP/1.0\r\nCSeq: 1\r\n\r\n").has_value()); + CHECK_FALSE(parseResponse("RTSP/1.0").has_value()); + CHECK_FALSE(parseResponse("RTSP/1.0 abc OK").has_value()); +} + +TEST_CASE("transport parsing is robust", "[moonlight][rtsp]") { + SECTION("port with trailing parameters") { + const auto resp = parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\n" + "Transport: server_port=48000;mode=play\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK(transportPort(*resp) == 48000); + } + SECTION("missing Transport option") { + const auto resp = parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK_FALSE(transportPort(*resp).has_value()); + CHECK_FALSE(connectData(*resp).has_value()); + CHECK_FALSE(sessionId(*resp).has_value()); + CHECK_FALSE(pingPayload(*resp).has_value()); + } + SECTION("out-of-range port") { + const auto resp = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\nTransport: server_port=70000\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK_FALSE(transportPort(*resp).has_value()); + } + SECTION("connect data that is not a number") { + const auto resp = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\nX-SS-Connect-Data: 12ab34\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK_FALSE(connectData(*resp).has_value()); + } + SECTION("connect data wider than 64 bits") { + const auto resp = parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\n" + "X-SS-Connect-Data: 999999999999999999999999\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK_FALSE(connectData(*resp).has_value()); + } + SECTION("ping payload passes through verbatim") { + const auto resp = parseResponse( + "RTSP/1.0 200 OK\r\nCSeq: 3\r\nX-SS-Ping-Payload: AbCd0123EfGh4567\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK(pingPayload(*resp) == "AbCd0123EfGh4567"); + } +} + +TEST_CASE("X-SS-Connect-Data parses unsigned, above and below INT32_MAX", "[moonlight][rtsp]") { + // READ WIDE, THEN NARROW. The token is unsigned 32-bit and a real host's + // routinely sits above INT32_MAX: 4270471497 came off a live Sunshine host. + // A signed parse fails for exactly those values and, defaulted, hands the + // control stream a token of 0 — which connects, to the wrong session. + const auto token = [](const std::string& text) { + const auto resp = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 3\r\nX-SS-Connect-Data: " + text + "\r\n\r\n"); + REQUIRE(resp.has_value()); + return connectData(*resp); + }; + + // Below INT32_MAX. + CHECK(token("0") == 0U); + CHECK(token("1") == 1U); + CHECK(token("3735928559") == 3735928559U); + CHECK(token("2147483646") == 2147483646U); + CHECK(token("2147483647") == 2147483647U); // INT32_MAX itself + + // Above INT32_MAX — the half a signed parse loses. + CHECK(token("2147483648") == 2147483648U); + CHECK(token("4270471497") == 4270471497U); // the live host's own value + CHECK(token("4294967295") == 4294967295U); // UINT32_MAX + + // Wider than the wire field: narrowed to the 32 bits ENet carries, never + // silently dropped to zero. + CHECK(token("4294967296") == 0U); + CHECK(token("4294967297") == 1U); + CHECK(token("99999999999") == static_cast(99999999999ULL & 0xFFFFFFFFULL)); + + // Whitespace around the value is tolerated the way hosts emit it. + CHECK(token(" 4270471497 ") == 4270471497U); +} + +TEST_CASE("Content-length frames a reply that declares one", "[moonlight][rtsp]") { + const auto declared = parseResponse("RTSP/1.0 200 OK\r\nCSeq: 4\r\n" + "Content-length: 11\r\n\r\nhello world"); + REQUIRE(declared.has_value()); + CHECK(contentLength(*declared) == 11); + CHECK(declared->payload == "hello world"); + + // The DESCRIBE shape: no Content-length at all, framed by the close. + const auto framedByClose = parseResponse("RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n" + "a=fmtp:97 surround-params=21101\r\n"); + REQUIRE(framedByClose.has_value()); + CHECK_FALSE(contentLength(*framedByClose).has_value()); + CHECK(framedByClose->payload.find("surround-params") != std::string::npos); + + // Case-insensitive, and a non-numeric length is no length. + const auto lowercase = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 4\r\ncontent-length: 3\r\n\r\nabc"); + REQUIRE(lowercase.has_value()); + CHECK(contentLength(*lowercase) == 3); + const auto rubbish = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 4\r\nContent-length: many\r\n\r\nabc"); + REQUIRE(rubbish.has_value()); + CHECK_FALSE(contentLength(*rubbish).has_value()); +} + +TEST_CASE("option lookup is case-insensitive", "[moonlight][rtsp]") { + const auto resp = + parseResponse("RTSP/1.0 200 OK\r\nCSeq: 1\r\nsession: ABC;timeout=90\r\n\r\n"); + REQUIRE(resp.has_value()); + CHECK(sessionId(*resp) == "ABC"); +} diff --git a/tests/test_moonlight_rtsp_framing.cpp b/tests/test_moonlight_rtsp_framing.cpp new file mode 100644 index 0000000..1d7b4b5 --- /dev/null +++ b/tests/test_moonlight_rtsp_framing.cpp @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The RTSP transport against a loopback host that behaves the way a real one +// does: it answers exactly one message per TCP connection and then hangs up. +// Reusing the socket cost the whole stream setup once, failing at DESCRIBE with +// the host already gone, so the connection count is asserted and not merely the +// replies. The DESCRIBE reply's shape — no Content-length, framed by the close — +// is pinned here too, because nothing else in the suite reads a body to EOF. + +#include "source/moonlight/MoonlightRtspClient.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using dish::source::moon::MoonlightRtspClient; +namespace moonrtsp = dish::moonrtsp; + +namespace { + +// A loopback RTSP origin. Each connection is answered by the responder the case +// supplies, so a hang-up before answering and a body framed by the close are +// both reachable. +class FixtureHost { + public: + using Responder = std::function; + + explicit FixtureHost(Responder respond) : respond_(std::move(respond)) { + listening_ = server_.listen(QHostAddress::LocalHost, 0); + QObject::connect(&server_, &QTcpServer::newConnection, &server_, [this] { accept(); }); + } + + bool listening() const { return listening_; } + int port() const { return static_cast(server_.serverPort()); } + int connections() const { return connections_; } + const QList& requests() const { return requests_; } + + private: + void accept() { + QTcpSocket* sock = server_.nextPendingConnection(); + ++connections_; + auto request = std::make_shared(); + auto answered = std::make_shared(false); + QObject::connect(sock, &QTcpSocket::readyRead, sock, [this, sock, request, answered] { + request->append(sock->readAll()); + if (*answered || !request->contains("\r\n\r\n")) { return; } + *answered = true; + requests_.append(*request); + respond_(sock, *request); + }); + QObject::connect(sock, &QTcpSocket::disconnected, sock, &QObject::deleteLater); + } + + QTcpServer server_; + Responder respond_; + bool listening_ = false; + int connections_ = 0; + QList requests_; +}; + +// Catch2 owns no event loop; spin the suite's QCoreApplication until the client +// answers, with a ceiling so a stall fails the case instead of hanging. +bool spinUntil(const std::function& ready, int timeoutMs = 8000) { + QElapsedTimer clock; + clock.start(); + while (!ready() && clock.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 5); + } + return ready(); +} + +// One request, run to completion. `done` distinguishes "no reply yet" from +// "replied with nothing", which nullopt alone cannot. +struct Exchange { + bool done = false; + std::optional response; +}; + +Exchange ask(MoonlightRtspClient& client, const QString& text, int timeoutMs = 8000) { + auto exchange = std::make_shared(); + client.request(text, [exchange](const std::optional& response) { + exchange->response = response; + exchange->done = true; + }); + spinUntil([exchange] { return exchange->done; }, timeoutMs); + return *exchange; +} + +// A host that answers and then hangs up, exactly as Sunshine does. +FixtureHost::Responder answerAndHangUp(const QByteArray& reply) { + return [reply](QTcpSocket* sock, const QByteArray&) { + sock->write(reply); + sock->flush(); + sock->disconnectFromHost(); + }; +} + +QByteArray okWithLength(const QByteArray& body) { + return "RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: DEADBEEFCAFE;timeout = 90\r\n" + "Content-length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body; +} + +} // namespace + +TEST_CASE("every RTSP request gets its own connection", "[moonlight][rtspclient]") { + // A Moonlight host answers exactly one message per TCP connection and then + // hangs up on its own: a second message written into that socket is never + // seen at all. Three requests must therefore be three connections. + FixtureHost host(answerAndHangUp("RTSP/1.0 200 OK\r\nCSeq: 1\r\n" + "Transport: server_port=47999\r\n" + "X-SS-Connect-Data: 4270471497\r\n\r\n")); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + + for (int i = 0; i < 3; ++i) { + const auto exchange = ask(client, QStringLiteral("OPTIONS rtsp://127.0.0.1:1 RTSP/1.0\r\n" + "CSeq: %1\r\n\r\n") + .arg(i + 1)); + REQUIRE(exchange.done); + REQUIRE(exchange.response.has_value()); + CHECK(exchange.response->ok()); + CHECK(moonrtsp::transportPort(*exchange.response) == 47999); + // Above INT32_MAX, and it survives the whole transport path. + CHECK(moonrtsp::connectData(*exchange.response) == 4270471497U); + } + CHECK(host.connections() == 3); + CHECK(host.requests().size() == 3); +} + +TEST_CASE("open() reports readiness without dialling", "[moonlight][rtspclient]") { + FixtureHost host(answerAndHangUp("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n")); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + bool ready = false; + QObject::connect(&client, &MoonlightRtspClient::connected, &client, [&ready] { ready = true; }); + client.open(QStringLiteral("127.0.0.1"), host.port()); + CHECK(spinUntil([&ready] { return ready; })); + CHECK(client.isOpen()); + // Nothing has been dialled yet: the first socket is the first request's. + CHECK(host.connections() == 0); + + REQUIRE(ask(client, QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n")).done); + CHECK(host.connections() == 1); +} + +TEST_CASE("a reply with no Content-length is framed by the close", "[moonlight][rtspclient]") { + // The DESCRIBE shape: the host sends the SDP with no length header at all + // and simply closes, so the rest of the stream is the body. + const QByteArray sdp = "a=fmtp:97 surround-params=21101\r\n" + "a=rtpmap:96 H264/90000\r\n" + "sprop-parameter-sets=AAAAAU\r\n"; + FixtureHost host([sdp](QTcpSocket* sock, const QByteArray&) { + sock->write("RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n"); + sock->flush(); + // Written in two pieces so a reader that stops at the first chunk is + // caught rather than passing by luck. + sock->write(sdp.left(20)); + sock->flush(); + sock->write(sdp.mid(20)); + sock->flush(); + sock->disconnectFromHost(); + }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("DESCRIBE rtsp://127.0.0.1:1 RTSP/1.0\r\n" + "CSeq: 2\r\nAccept: application/sdp\r\n\r\n")); + REQUIRE(exchange.done); + REQUIRE(exchange.response.has_value()); + CHECK(exchange.response->ok()); + CHECK(exchange.response->cseq == 2); + CHECK_FALSE(moonrtsp::contentLength(*exchange.response).has_value()); + // The WHOLE body, not the part that had arrived when the head did. + CHECK(exchange.response->payload == sdp.toStdString()); +} + +TEST_CASE("a reply with Content-length does not wait for the close", "[moonlight][rtspclient]") { + const QByteArray body = "SETUP-BODY"; + FixtureHost host([body](QTcpSocket* sock, const QByteArray&) { + sock->write(okWithLength(body)); + sock->flush(); // and deliberately no hang-up + }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("SETUP streamid=audio/0/0 RTSP/1.0\r\n" + "CSeq: 3\r\n\r\n")); + REQUIRE(exchange.done); + REQUIRE(exchange.response.has_value()); + CHECK(exchange.response->ok()); + CHECK(moonrtsp::sessionId(*exchange.response) == "DEADBEEFCAFE"); + CHECK(exchange.response->payload == body.toStdString()); +} + +TEST_CASE("a body split across writes is not truncated at its length", "[moonlight][rtspclient]") { + const QByteArray body = "0123456789abcdefghij"; + FixtureHost host([body](QTcpSocket* sock, const QByteArray&) { + sock->write("RTSP/1.0 200 OK\r\nCSeq: 4\r\nContent-length: " + + QByteArray::number(body.size()) + "\r\n\r\n"); + sock->flush(); + sock->write(body.left(5)); + sock->flush(); + sock->write(body.mid(5)); + sock->flush(); + sock->disconnectFromHost(); + }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("ANNOUNCE x RTSP/1.0\r\nCSeq: 4\r\n\r\n")); + REQUIRE(exchange.done); + REQUIRE(exchange.response.has_value()); + CHECK(exchange.response->payload == body.toStdString()); +} + +TEST_CASE("a host that hangs up before answering fails that step", "[moonlight][rtspclient]") { + // The mid-handshake death this transport used to report as nothing at all. + FixtureHost host([](QTcpSocket* sock, const QByteArray&) { sock->abort(); }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("PLAY x RTSP/1.0\r\nCSeq: 5\r\n\r\n")); + REQUIRE(exchange.done); + CHECK_FALSE(exchange.response.has_value()); +} + +TEST_CASE("a half-sent head is not mistaken for a reply", "[moonlight][rtspclient]") { + FixtureHost host([](QTcpSocket* sock, const QByteArray&) { + sock->write("RTSP/1.0 200 OK\r\nCSeq: 6\r\nTransport: server_p"); + sock->flush(); + sock->disconnectFromHost(); + }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("SETUP streamid=video/0/0 RTSP/1.0\r\n" + "CSeq: 6\r\n\r\n")); + REQUIRE(exchange.done); + CHECK_FALSE(exchange.response.has_value()); +} + +TEST_CASE("a non-RTSP reply is refused rather than misparsed", "[moonlight][rtspclient]") { + FixtureHost host(answerAndHangUp("HTTP/1.1 200 OK\r\nContent-length: 0\r\n\r\n")); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 7\r\n\r\n")); + REQUIRE(exchange.done); + CHECK_FALSE(exchange.response.has_value()); +} + +TEST_CASE("a refusal reaches the caller with its status", "[moonlight][rtspclient]") { + // The answer a minimal ANNOUNCE SDP earns from a real host. + FixtureHost host(answerAndHangUp("RTSP/1.0 400 BAD REQUEST\r\nCSeq: 8\r\n\r\n")); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + const auto exchange = ask(client, QStringLiteral("ANNOUNCE x RTSP/1.0\r\nCSeq: 8\r\n\r\n")); + REQUIRE(exchange.done); + REQUIRE(exchange.response.has_value()); + CHECK_FALSE(exchange.response->ok()); + CHECK(exchange.response->statusCode == 400); + CHECK(exchange.response->statusMessage == "BAD REQUEST"); +} + +TEST_CASE("a request with no endpoint fails immediately", "[moonlight][rtspclient]") { + MoonlightRtspClient client; + CHECK_FALSE(client.isOpen()); + bool called = false; + std::optional got; + client.request(QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n"), + [&](const std::optional& response) { + called = true; + got = response; + }); + CHECK(called); + CHECK_FALSE(got.has_value()); +} + +TEST_CASE("close() cancels an in-flight request", "[moonlight][rtspclient]") { + // A host that accepts and then says nothing, so the request is still open + // when the session tears down. + FixtureHost host([](QTcpSocket*, const QByteArray&) {}); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + auto exchange = std::make_shared(); + client.request(QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n"), + [exchange](const std::optional& response) { + exchange->response = response; + exchange->done = true; + }); + spinUntil([&host] { return host.connections() > 0; }, 2000); + client.close(); + CHECK(exchange->done); + CHECK_FALSE(exchange->response.has_value()); + CHECK_FALSE(client.isOpen()); + + // And a request after close is refused rather than dialling again. + const int before = host.connections(); + bool called = false; + client.request(QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 2\r\n\r\n"), + [&called](const std::optional&) { called = true; }); + CHECK(called); + CHECK(host.connections() == before); +} + +TEST_CASE("a second request supersedes the one still in flight", "[moonlight][rtspclient]") { + FixtureHost host([](QTcpSocket* sock, const QByteArray& request) { + // Only the second request is ever answered. + if (request.contains("CSeq: 2")) { + sock->write("RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n"); + sock->flush(); + sock->disconnectFromHost(); + } + }); + REQUIRE(host.listening()); + + MoonlightRtspClient client; + client.open(QStringLiteral("127.0.0.1"), host.port()); + auto first = std::make_shared(); + client.request(QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n"), + [first](const std::optional& response) { + first->response = response; + first->done = true; + }); + spinUntil([&host] { return host.connections() > 0; }, 2000); + + const auto second = ask(client, QStringLiteral("OPTIONS * RTSP/1.0\r\nCSeq: 2\r\n\r\n")); + CHECK(first->done); + CHECK_FALSE(first->response.has_value()); + REQUIRE(second.done); + REQUIRE(second.response.has_value()); + CHECK(second.response->cseq == 2); + CHECK(host.connections() == 2); +} diff --git a/tests/test_moonlight_session_machine.cpp b/tests/test_moonlight_session_machine.cpp new file mode 100644 index 0000000..94d2b3e --- /dev/null +++ b/tests/test_moonlight_session_machine.cpp @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The full decision space of the Moonlight session reducer: the happy path, +// every failure edge, stop-from-anywhere, and the no-op guarantee for stale +// completions. + +#include "core/moonlight/MoonlightSessionMachine.h" + +#include + +#include +#include + +using namespace dish::moonlight; +using namespace dish::moonlight::moon_event; + +namespace { + +bool hasEffect(const Reduction& r, SessionEffect e) { + return std::find(r.effects.begin(), r.effects.end(), e) != r.effects.end(); +} + +SessionState at(SessionPhase phase, RtspStep step = RtspStep::Options) { + SessionState s; + s.phase = phase; + s.rtspStep = step; + return s; +} + +} // namespace + +TEST_CASE("happy path from Idle to Streaming", "[moonlight][machine]") { + SessionState s; + + auto r = reduce(s, StartRequested{}); + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::CheckingInfo); + CHECK(hasEffect(r, SessionEffect::FetchServerInfo)); + + r = reduce(s, ServerInfoOk{true, 0}); + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::Launching); + CHECK_FALSE(s.resuming); + CHECK(hasEffect(r, SessionEffect::SendLaunch)); + + r = reduce(s, LaunchOk{}); + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::Rtsp); + CHECK(s.rtspStep == RtspStep::Options); + CHECK(hasEffect(r, SessionEffect::OpenRtsp)); + + r = reduce(s, RtspReady{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Rtsp); + CHECK(hasEffect(r, SessionEffect::SendRtspOptions)); + + // Options → Describe → SetupAudio → SetupVideo → SetupControl → Announce → Play. + const SessionEffect order[] = { + SessionEffect::SendRtspDescribe, SessionEffect::SendRtspSetupAudio, + SessionEffect::SendRtspSetupVideo, SessionEffect::SendRtspSetupControl, + SessionEffect::SendRtspAnnounce, SessionEffect::SendRtspPlay, + }; + for (const SessionEffect expected : order) { + r = reduce(s, RtspStepOk{}); + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::Rtsp); + CHECK(hasEffect(r, expected)); + } + CHECK(s.rtspStep == RtspStep::Play); + + r = reduce(s, RtspStepOk{}); // PLAY answered + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::ControlConnecting); + CHECK(hasEffect(r, SessionEffect::ConnectControl)); + + r = reduce(s, ControlConnected{}); + REQUIRE(r.next.has_value()); + s = *r.next; + CHECK(s.phase == SessionPhase::Streaming); + CHECK(hasEffect(r, SessionEffect::StartStreaming)); +} + +TEST_CASE("a running app resumes instead of launching", "[moonlight][machine]") { + const auto r = reduce(at(SessionPhase::CheckingInfo), ServerInfoOk{true, 123456}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Launching); + CHECK(r.next->resuming); +} + +TEST_CASE("currentgame of -1 means nothing running", "[moonlight][machine]") { + const auto r = reduce(at(SessionPhase::CheckingInfo), ServerInfoOk{true, -1}); + REQUIRE(r.next.has_value()); + CHECK_FALSE(r.next->resuming); +} + +TEST_CASE("failure edges land in Failed with teardown + notify", "[moonlight][machine]") { + struct Case { + SessionState from; + SessionEvent event; + SessionFailure expected; + }; + const Case cases[] = { + {at(SessionPhase::CheckingInfo), ServerInfoFailed{}, SessionFailure::Unreachable}, + {at(SessionPhase::CheckingInfo), ServerInfoOk{false, 0}, SessionFailure::NotPaired}, + {at(SessionPhase::Launching), LaunchFailed{}, SessionFailure::LaunchRejected}, + {at(SessionPhase::Rtsp, RtspStep::SetupControl), RtspFailed{}, + SessionFailure::RtspRejected}, + {at(SessionPhase::ControlConnecting), ControlLost{}, SessionFailure::ControlLost}, + {at(SessionPhase::ControlConnecting), HostTerminated{}, SessionFailure::HostEnded}, + {at(SessionPhase::Streaming), ControlLost{}, SessionFailure::Dropped}, + {at(SessionPhase::Streaming), HostTerminated{}, SessionFailure::HostEnded}, + }; + for (const auto& c : cases) { + const auto r = reduce(c.from, c.event); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Failed); + REQUIRE(r.next->failure.has_value()); + CHECK(*r.next->failure == c.expected); + CHECK(hasEffect(r, SessionEffect::Teardown)); + CHECK(hasEffect(r, SessionEffect::NotifyFailure)); + } +} + +TEST_CASE("a resumable in-body refusal promotes the launch to a resume", "[moonlight][machine]") { + // /launch answered HTTP 200 with status_code="400", "An app is already + // running on this host" and 1: the host will hand that + // session over, so ask it to, rather than giving up. + const auto r = reduce(at(SessionPhase::Launching), LaunchBusy{true}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Launching); + CHECK(r.next->resuming); + CHECK_FALSE(r.next->failure.has_value()); + CHECK(hasEffect(r, SessionEffect::SendLaunch)); + CHECK_FALSE(hasEffect(r, SessionEffect::Teardown)); +} + +TEST_CASE("a refusal with no resume offer ends the attempt", "[moonlight][machine]") { + const auto r = reduce(at(SessionPhase::Launching), LaunchBusy{false}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Failed); + REQUIRE(r.next->failure.has_value()); + CHECK(*r.next->failure == SessionFailure::AppAlreadyRunning); + CHECK(hasEffect(r, SessionEffect::Teardown)); + CHECK(hasEffect(r, SessionEffect::NotifyFailure)); +} + +TEST_CASE("a resume that is refused again does not loop", "[moonlight][machine]") { + SessionState resuming = at(SessionPhase::Launching); + resuming.resuming = true; + const auto r = reduce(resuming, LaunchBusy{true}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Failed); + REQUIRE(r.next->failure.has_value()); + CHECK(*r.next->failure == SessionFailure::AppAlreadyRunning); + CHECK_FALSE(hasEffect(r, SessionEffect::SendLaunch)); +} + +TEST_CASE("a busy reply outside Launching is ignored", "[moonlight][machine]") { + const SessionPhase elsewhere[] = {SessionPhase::Idle, SessionPhase::CheckingInfo, + SessionPhase::Rtsp, SessionPhase::ControlConnecting, + SessionPhase::Streaming}; + for (const SessionPhase phase : elsewhere) { + CHECK_FALSE(reduce(at(phase), LaunchBusy{true}).next.has_value()); + CHECK_FALSE(reduce(at(phase), LaunchBusy{false}).next.has_value()); + } +} + +TEST_CASE("a serverinfo that already names a running app resumes straight away", + "[moonlight][machine]") { + const auto r = reduce(at(SessionPhase::CheckingInfo), ServerInfoOk{true, 881448767}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Launching); + CHECK(r.next->resuming); + CHECK(hasEffect(r, SessionEffect::SendLaunch)); + // Sunshine spells "nothing running" as either 0 or -1. + for (const int idle : {0, -1}) { + const auto fresh = reduce(at(SessionPhase::CheckingInfo), ServerInfoOk{true, idle}); + REQUIRE(fresh.next.has_value()); + CHECK_FALSE(fresh.next->resuming); + } +} + +TEST_CASE("stop wins from every phase", "[moonlight][machine]") { + const SessionPhase all[] = { + SessionPhase::Idle, SessionPhase::CheckingInfo, SessionPhase::Launching, + SessionPhase::Rtsp, SessionPhase::ControlConnecting, SessionPhase::Streaming, + SessionPhase::Failed}; + for (const SessionPhase phase : all) { + const auto r = reduce(at(phase), StopRequested{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::Idle); + CHECK_FALSE(r.next->failure.has_value()); + // TERMINATION only where the encrypted link exists. + const bool linkUp = + phase == SessionPhase::Streaming || phase == SessionPhase::ControlConnecting; + CHECK(hasEffect(r, SessionEffect::SendTermination) == linkUp); + CHECK(hasEffect(r, SessionEffect::Teardown) == (phase != SessionPhase::Idle)); + } +} + +TEST_CASE("Failed is restartable", "[moonlight][machine]") { + SessionState failed = at(SessionPhase::Failed); + failed.failure = SessionFailure::ControlLost; + const auto r = reduce(failed, StartRequested{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->phase == SessionPhase::CheckingInfo); + CHECK_FALSE(r.next->failure.has_value()); + CHECK(hasEffect(r, SessionEffect::FetchServerInfo)); +} + +TEST_CASE("stale completions are no-ops in every non-matching phase", "[moonlight][machine]") { + const SessionEvent completions[] = { + ServerInfoOk{true, 0}, ServerInfoFailed{}, LaunchOk{}, LaunchFailed{}, + RtspReady{}, RtspStepOk{}, RtspFailed{}, ControlConnected{}, + }; + // Idle must shrug off every completion event. + for (const auto& event : completions) { + const auto r = reduce(at(SessionPhase::Idle), event); + CHECK_FALSE(r.next.has_value()); + CHECK(r.effects.empty()); + } + // A late launch reply after RTSP started is ignored. + CHECK_FALSE(reduce(at(SessionPhase::Rtsp), LaunchOk{}).next.has_value()); + // A late RTSP reply after the control link opened is ignored. + CHECK_FALSE(reduce(at(SessionPhase::ControlConnecting), RtspStepOk{}).next.has_value()); + // RtspReady only applies at the Options step. + CHECK_FALSE(reduce(at(SessionPhase::Rtsp, RtspStep::Announce), RtspReady{}).next.has_value()); + // Streaming ignores connect-phase noise. + CHECK_FALSE(reduce(at(SessionPhase::Streaming), ControlConnected{}).next.has_value()); + // ControlLost before any control link exists is ignored. + CHECK_FALSE(reduce(at(SessionPhase::Launching), ControlLost{}).next.has_value()); + // A second StartRequested mid-flight is ignored. + CHECK_FALSE(reduce(at(SessionPhase::CheckingInfo), StartRequested{}).next.has_value()); + CHECK_FALSE(reduce(at(SessionPhase::Streaming), StartRequested{}).next.has_value()); +} + +TEST_CASE("RtspReady at Options re-sends OPTIONS without advancing", "[moonlight][machine]") { + const auto r = reduce(at(SessionPhase::Rtsp, RtspStep::Options), RtspReady{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->rtspStep == RtspStep::Options); + CHECK(hasEffect(r, SessionEffect::SendRtspOptions)); +} + +TEST_CASE("a host that answers unpaired names what is remembered", "[moonlight][machine]") { + // NOT PAIRED and TRUST LOST are the same wire fact and two different + // sentences: one asks for a first pairing, the other says the host deleted + // one we still hold a certificate for. + ServerInfoOk fresh; + fresh.paired = false; + fresh.remembered = false; + auto r = reduce(at(SessionPhase::CheckingInfo), fresh); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::NotPaired); + + ServerInfoOk forgotten; + forgotten.paired = false; + forgotten.remembered = true; + r = reduce(at(SessionPhase::CheckingInfo), forgotten); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::TrustLost); + CHECK(hasEffect(r, SessionEffect::NotifyFailure)); +} + +TEST_CASE("a host with a new identity is named before pairing is judged", "[moonlight][machine]") { + // The stored certificate anchors nothing on a machine that was reset, so + // "no longer recognises this device" would be the wrong reason. + ServerInfoOk replaced; + replaced.paired = true; + replaced.remembered = true; + replaced.identityChanged = true; + auto r = reduce(at(SessionPhase::CheckingInfo), replaced); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::HostReplaced); + + // Even when the host also reports us unpaired. + replaced.paired = false; + r = reduce(at(SessionPhase::CheckingInfo), replaced); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::HostReplaced); +} + +TEST_CASE("a failed resume is not a refused launch", "[moonlight][machine]") { + // The host HAS the session and would not hand it back, which the user fixes + // by closing the app rather than by trying the same thing again. + SessionState resuming = at(SessionPhase::Launching); + resuming.resuming = true; + auto r = reduce(resuming, LaunchFailed{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::ResumeFailed); + + // A first launch that fails is still a plain refusal. + r = reduce(at(SessionPhase::Launching), LaunchFailed{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::LaunchRejected); +} + +TEST_CASE("a link that dies after going live is a drop, not a setup failure", + "[moonlight][machine]") { + // The host keeps the app and will usually let us resume it. Merging the two + // would offer a Reconnect that cannot work, or a retry that closes a game. + auto r = reduce(at(SessionPhase::ControlConnecting), ControlLost{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::ControlLost); + + r = reduce(at(SessionPhase::Streaming), ControlLost{}); + REQUIRE(r.next.has_value()); + CHECK(r.next->failure == SessionFailure::Dropped); + + // A host that ends the session says so itself, from either phase. + for (const SessionPhase phase : {SessionPhase::ControlConnecting, SessionPhase::Streaming}) { + const auto ended = reduce(at(phase), HostTerminated{}); + REQUIRE(ended.next.has_value()); + CHECK(ended.next->failure == SessionFailure::HostEnded); + } +} + +TEST_CASE("a session starts only from a resting phase", "[moonlight][machine]") { + // The reference count: a second binding on a host that is already checking, + // launching or live joins that session and must not launch a second. + CHECK(sessionNeedsStart(SessionPhase::Idle)); + CHECK(sessionNeedsStart(SessionPhase::Failed)); + CHECK_FALSE(sessionNeedsStart(SessionPhase::CheckingInfo)); + CHECK_FALSE(sessionNeedsStart(SessionPhase::Launching)); + CHECK_FALSE(sessionNeedsStart(SessionPhase::Rtsp)); + CHECK_FALSE(sessionNeedsStart(SessionPhase::ControlConnecting)); + CHECK_FALSE(sessionNeedsStart(SessionPhase::Streaming)); +} diff --git a/tests/test_moonlight_session_ui.cpp b/tests/test_moonlight_session_ui.cpp new file mode 100644 index 0000000..ec960f6 --- /dev/null +++ b/tests/test_moonlight_session_ui.cpp @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The Moonlight binding flow's render contract: twenty-one states, one of which +// is drawn at a time, and the guarantee that only ONE of them may stop a user +// from saving a binding. A binding is a durable intent — pairing is remembered +// trust verified lazily, so a host that is unpaired, unreachable, refusing or +// dropped is a state to render and not a reason to refuse the user's answer. +// +// Every case below names the state it walks, so a reordering of the evaluation +// chain cannot quietly send one of them somewhere else. + +#include "core/moonlight/MoonlightSessionUi.h" + +#include + +#include + +using namespace dish::moonlight; + +namespace { + +// Paired, answered, nothing running: the resting shape every case narrows from. +SessionUiInputs paired() { + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = true; + in.remembered = true; + in.paired = true; + in.appsRead = true; + in.appCount = 2; + return in; +} + +std::string tokenOf(const SessionUiInputs& in) { return sessionUiToken(sessionUiState(in)); } + +} // namespace + +TEST_CASE("M1 checking: a probe in flight with nothing cached", "[moonlight][ui]") { + SessionUiInputs in; + in.probeInFlight = true; + CHECK(sessionUiState(in) == SessionUiState::Checking); + CHECK(tokenOf(in) == "checking"); +} + +TEST_CASE("M2 not paired: answered, PairStatus 0, nothing remembered", "[moonlight][ui]") { + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = true; + in.paired = false; + in.remembered = false; + CHECK(sessionUiState(in) == SessionUiState::NotPaired); + CHECK(tokenOf(in) == "notPaired"); +} + +TEST_CASE("M2 again: the host's word alone is not a pairing", "[moonlight][ui]") { + // The disagreement a Forget leaves behind. A host reports PairStatus + // against the uniqueid on the request, and the client identity outlives a + // Forget, so a box we forgot still answers 1. Trust is MUTUAL: every + // paired-only call is mutual TLS pinned against the certificate the + // handshake verified, and that certificate went with the row. Reporting + // Paired here would hide the Pair button behind a chip nothing can act on. + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = true; + in.paired = true; // the host's half + in.remembered = false; // ours + CHECK(sessionUiState(in) == SessionUiState::NotPaired); + CHECK(hostTrust(in) == HostTrust::NotPaired); + // Not TrustLost: nothing was lost, and an ordinary pairing recovers it. + CHECK(tokenOf(in) == "notPaired"); +} + +TEST_CASE("a rejection only takes away trust there was to lose", "[moonlight][ui]") { + // A 401 from a host nobody ever paired with refuses in exactly the way a + // host that dropped us does. Calling that "trust lost" tells a first-time + // user that a pairing they never made has been removed, which is false, and + // NotPaired carries the identical next step. + SessionUiInputs stranger; + stranger.probeAttempted = true; + stranger.trustRejected = true; + stranger.remembered = false; + CHECK(sessionUiState(stranger) == SessionUiState::NotPaired); + CHECK(hostTrust(stranger) == HostTrust::NotPaired); + + // With our certificate still on file the rejection really did take + // something away, and that is the state that says so. + SessionUiInputs known = stranger; + known.remembered = true; + CHECK(sessionUiState(known) == SessionUiState::TrustLost); + CHECK(hostTrust(known) == HostTrust::NotPaired); + + // And a rejection outranks the "nobody has answered yet" fallback: a host + // that just refused us must not promise a session when it comes back. + CHECK_FALSE(sessionUiState(known) == SessionUiState::Remembered); +} + +TEST_CASE("a trust problem outranks the app list it caused", "[moonlight][ui]") { + // The app list is HTTPS and paired-only, so a host we cannot open a channel + // to fails it BY CONSTRUCTION. Rendering "could not read the app list" over + // that puts a transport complaint where the answer is Pair, and it is the + // one sentence the user has no way to act on. + SessionUiInputs noCert; + noCert.probeAttempted = true; + noCert.probeAnswered = true; + noCert.paired = true; // the host's word + noCert.remembered = false; // and nothing of ours to pin against + noCert.appsFailed = true; + CHECK(sessionUiState(noCert) == SessionUiState::NotPaired); + + // Same once the mutual-TLS call comes back 401, which is what that failure + // actually was. + SessionUiInputs rejected = noCert; + rejected.trustRejected = true; + CHECK(sessionUiState(rejected) == SessionUiState::NotPaired); + + // And with our certificate on file the same 401 is the loss it looks like. + SessionUiInputs lost = rejected; + lost.remembered = true; + CHECK(sessionUiState(lost) == SessionUiState::TrustLost); +} + +TEST_CASE("the chip and the sentence under it read one rule", "[moonlight][ui]") { + // Two functions answering the same question differently is the shape of the + // live dead end: the row read the host's word alone, said Paired, and hid + // the Pair button, while the section below it could not open a channel at + // all. Walked as a cross product so an edit to one has to move the other. + for (const bool probeAnswered : {false, true}) { + for (const bool paired : {false, true}) { + for (const bool remembered : {false, true}) { + for (const bool trustRejected : {false, true}) { + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = probeAnswered; + in.paired = paired; + in.remembered = remembered; + in.trustRejected = trustRejected; + CAPTURE(probeAnswered, paired, remembered, trustRejected); + + const SessionUiState state = sessionUiState(in); + const bool sectionUnpaired = + state == SessionUiState::NotPaired || state == SessionUiState::TrustLost; + + // Where the section says the user is not paired, the row + // offers the way back rather than a memory it cannot use. + if (sectionUnpaired) { CHECK(hostTrust(in) == HostTrust::NotPaired); } + // And the converse, which is the half that stranded the + // user: Paired is the one chip that hides the Pair button, + // so it may never sit above a section that disagrees. + if (hostTrust(in) == HostTrust::Paired) { CHECK_FALSE(sectionUnpaired); } + // The two unpaired states split on ONE thing, in both. + if (trustRejected) { + CHECK(state == + (remembered ? SessionUiState::TrustLost : SessionUiState::NotPaired)); + CHECK(hostTrust(in) == HostTrust::NotPaired); + } + } + } + } + } +} + +TEST_CASE("M3 pairing: the PIN is on screen", "[moonlight][ui]") { + SessionUiInputs in; + in.pairingActive = true; + CHECK(sessionUiState(in) == SessionUiState::PairingPin); + // It wins over everything, because a live pairing attempt IS the state. + SessionUiInputs overlapping = paired(); + overlapping.pairingActive = true; + CHECK(sessionUiState(overlapping) == SessionUiState::PairingPin); +} + +TEST_CASE("M4 pairing refused", "[moonlight][ui]") { + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = true; + in.pairingRefused = true; + CHECK(sessionUiState(in) == SessionUiState::PairingRefused); + CHECK(tokenOf(in) == "pairingRefused"); +} + +TEST_CASE("M5 and M6 split on what is remembered, not on what happened", "[moonlight][ui]") { + // Both are "the host did not answer". The difference is whether there is a + // pairing to come back to, and the copy says something different for each. + SessionUiInputs never; + never.probeAttempted = true; + never.probeInFlight = false; + never.probeAnswered = false; + never.remembered = false; + CHECK(sessionUiState(never) == SessionUiState::Unreachable); + + SessionUiInputs known = never; + known.remembered = true; + CHECK(sessionUiState(known) == SessionUiState::Remembered); + CHECK(tokenOf(known) == "remembered"); + + // The same split when the failure came from a session attempt. + SessionUiInputs failed; + failed.probeAttempted = true; + failed.failure = SessionFailure::Unreachable; + CHECK(sessionUiState(failed) == SessionUiState::Unreachable); + failed.remembered = true; + CHECK(sessionUiState(failed) == SessionUiState::Remembered); +} + +TEST_CASE("a host nobody has asked yet is checking, never silent", "[moonlight][ui]") { + // Reporting "not answering" about a host no request has ever gone to would + // be an accusation the client cannot support. + SessionUiInputs untouched; + CHECK(sessionUiState(untouched) == SessionUiState::Checking); + untouched.remembered = true; + CHECK(sessionUiState(untouched) == SessionUiState::Checking); +} + +TEST_CASE("M7 trust lost: answered unpaired with a certificate stored", "[moonlight][ui]") { + SessionUiInputs in; + in.probeAttempted = true; + in.probeAnswered = true; + in.paired = false; + in.remembered = true; + CHECK(sessionUiState(in) == SessionUiState::TrustLost); + CHECK(tokenOf(in) == "trustLost"); + + // A 401 on a mutual-TLS call says the same thing, whatever the probe said. + SessionUiInputs rejected = paired(); + rejected.trustRejected = true; + CHECK(sessionUiState(rejected) == SessionUiState::TrustLost); + + // And the session reducer's own token maps here too. + SessionUiInputs fromSession; + fromSession.probeAttempted = true; + fromSession.probeAnswered = true; + fromSession.paired = true; + fromSession.remembered = true; + fromSession.failure = SessionFailure::TrustLost; + CHECK(sessionUiState(fromSession) == SessionUiState::TrustLost); +} + +TEST_CASE("M8 host replaced: a uniqueid we do not remember", "[moonlight][ui]") { + SessionUiInputs in = paired(); + in.identityChanged = true; + CHECK(sessionUiState(in) == SessionUiState::HostReplaced); + CHECK(tokenOf(in) == "hostReplaced"); + + // It is named before pairing is judged: "no longer recognises this device" + // would be the wrong reason for a machine that was reset. + SessionUiInputs unpaired; + unpaired.probeAttempted = true; + unpaired.probeAnswered = true; + unpaired.remembered = true; + unpaired.identityChanged = true; + CHECK(sessionUiState(unpaired) == SessionUiState::HostReplaced); +} + +TEST_CASE("M9 through M12: the app list is a state, not a list", "[moonlight][ui]") { + SessionUiInputs loading = paired(); + loading.appsRead = false; + loading.appCount = 0; + loading.appsInFlight = true; + CHECK(sessionUiState(loading) == SessionUiState::AppsLoading); + + SessionUiInputs ready = paired(); + CHECK(sessionUiState(ready) == SessionUiState::NewSession); + CHECK(tokenOf(ready) == "newSession"); + + SessionUiInputs empty = paired(); + empty.appCount = 0; + CHECK(sessionUiState(empty) == SessionUiState::NoApps); + + // FAILED IS NOT EMPTY. The list is HTTPS and paired-only, so a refusal read + // as an empty list would present a 404 as a fact about the host. + SessionUiInputs failed = paired(); + failed.appsRead = false; + failed.appCount = 0; + failed.appsFailed = true; + CHECK(sessionUiState(failed) == SessionUiState::AppsFailed); + CHECK(tokenOf(failed) != "noApps"); +} + +TEST_CASE("M13 joining: a session of ours is already up", "[moonlight][ui]") { + SessionUiInputs in = paired(); + in.sessionLive = true; + in.otherControllers = 1; + CHECK(sessionUiState(in) == SessionUiState::Joining); + CHECK(tokenOf(in) == "joining"); + // No app question survives here: whoever created the session settled it. + in.appsRead = false; + in.appsFailed = true; + CHECK(sessionUiState(in) == SessionUiState::Joining); +} + +TEST_CASE("M14 host full is the ONE state that blocks", "[moonlight][ui]") { + SessionUiInputs in = paired(); + in.otherControllers = 4; + CHECK(sessionUiState(in) == SessionUiState::HostFull); + CHECK(sessionUiBlocksApply(SessionUiState::HostFull)); + + // A live session on a full host is still full: "joining" would invite the + // user into a session that has no room for them. + SessionUiInputs live = in; + live.sessionLive = true; + CHECK(sessionUiState(live) == SessionUiState::HostFull); + + // And so is a host nobody has managed to reach. The ceiling is local + // bookkeeping, so no network answer can change it, and rendering a spinner + // or an unreachable host over it would enable an Apply the bind refuses. + SessionUiInputs unreachable; + unreachable.probeAttempted = true; + unreachable.otherControllers = 4; + CHECK(sessionUiState(unreachable) == SessionUiState::HostFull); + SessionUiInputs unasked; + unasked.otherControllers = 4; + CHECK(sessionUiState(unasked) == SessionUiState::HostFull); + + // Three others plus this binding is exactly the ceiling, and still fits. + SessionUiInputs room = paired(); + room.otherControllers = 3; + room.sessionLive = true; + CHECK(sessionUiState(room) == SessionUiState::Joining); +} + +TEST_CASE("M15 through M18: the refusals a host answers 200 with", "[moonlight][ui]") { + SessionUiInputs busy = paired(); + busy.failure = SessionFailure::AppAlreadyRunning; + CHECK(sessionUiState(busy) == SessionUiState::BusyOther); + + SessionUiInputs resume = paired(); + resume.failure = SessionFailure::ResumeFailed; + CHECK(sessionUiState(resume) == SessionUiState::ResumeFailed); + + SessionUiInputs refused = paired(); + refused.failure = SessionFailure::LaunchRejected; + CHECK(sessionUiState(refused) == SessionUiState::Refused); + + // The stream never came up. Both roads there read the same, because the + // user's move is the same and Dish has already cancelled the app. + for (const SessionFailure setup : {SessionFailure::RtspRejected, SessionFailure::ControlLost}) { + SessionUiInputs in = paired(); + in.failure = setup; + CHECK(sessionUiState(in) == SessionUiState::SetupFailed); + } +} + +TEST_CASE("M19 live is this binding's own place in the session", "[moonlight][ui]") { + SessionUiInputs in = paired(); + in.sessionLive = true; + in.bindingLive = true; + in.otherControllers = 2; + CHECK(sessionUiState(in) == SessionUiState::Live); + CHECK(tokenOf(in) == "live"); + // Live outranks a stale failure from an earlier attempt. + in.failure = SessionFailure::Dropped; + CHECK(sessionUiState(in) == SessionUiState::Live); +} + +TEST_CASE("M20 and M21 are never merged", "[moonlight][ui]") { + // A drop is recoverable and the host will usually let us resume; a session + // the host ended is not. + SessionUiInputs dropped = paired(); + dropped.failure = SessionFailure::Dropped; + CHECK(sessionUiState(dropped) == SessionUiState::Dropped); + + SessionUiInputs ended = paired(); + ended.failure = SessionFailure::HostEnded; + CHECK(sessionUiState(ended) == SessionUiState::EndedByHost); + + CHECK(std::string(sessionUiToken(SessionUiState::Dropped)) != + std::string(sessionUiToken(SessionUiState::EndedByHost))); +} + +TEST_CASE("every state has its own token and all twenty-one are reachable", "[moonlight][ui]") { + const SessionUiState all[] = { + SessionUiState::Checking, SessionUiState::NotPaired, SessionUiState::PairingPin, + SessionUiState::PairingRefused, SessionUiState::Unreachable, SessionUiState::Remembered, + SessionUiState::TrustLost, SessionUiState::HostReplaced, SessionUiState::AppsLoading, + SessionUiState::NewSession, SessionUiState::NoApps, SessionUiState::AppsFailed, + SessionUiState::Joining, SessionUiState::HostFull, SessionUiState::BusyOther, + SessionUiState::ResumeFailed, SessionUiState::Refused, SessionUiState::SetupFailed, + SessionUiState::Live, SessionUiState::Dropped, SessionUiState::EndedByHost}; + static_assert(sizeof(all) / sizeof(all[0]) == 21, "the render contract is twenty-one states"); + + std::string seen; + for (const SessionUiState state : all) { + const std::string token = sessionUiToken(state); + CHECK_FALSE(token.empty()); + // A duplicate token would make two states render as one. + CHECK(seen.find("|" + token + "|") == std::string::npos); + seen += "|" + token + "|"; + } +} + +TEST_CASE("apply is blocked by exactly one state", "[moonlight][ui]") { + const SessionUiState all[] = { + SessionUiState::Checking, SessionUiState::NotPaired, SessionUiState::PairingPin, + SessionUiState::PairingRefused, SessionUiState::Unreachable, SessionUiState::Remembered, + SessionUiState::TrustLost, SessionUiState::HostReplaced, SessionUiState::AppsLoading, + SessionUiState::NewSession, SessionUiState::NoApps, SessionUiState::AppsFailed, + SessionUiState::Joining, SessionUiState::BusyOther, SessionUiState::ResumeFailed, + SessionUiState::Refused, SessionUiState::SetupFailed, SessionUiState::Live, + SessionUiState::Dropped, SessionUiState::EndedByHost}; + for (const SessionUiState state : all) { CHECK_FALSE(sessionUiBlocksApply(state)); } + CHECK(sessionUiBlocksApply(SessionUiState::HostFull)); +} + +TEST_CASE("the host row says trust, and never liveness", "[moonlight][ui]") { + SessionUiInputs verified = paired(); + CHECK(hostTrust(verified) == HostTrust::Paired); + CHECK(std::string(hostTrustToken(HostTrust::Paired)) == "paired"); + + // Did not answer this visit, but the pairing is stored: remembered, and + // neutral rather than amber. It is not a problem, only unconfirmed. + SessionUiInputs offline; + offline.remembered = true; + CHECK(hostTrust(offline) == HostTrust::Remembered); + + // Answered and unpaired is a fact about now, whatever is remembered. + SessionUiInputs unpaired; + unpaired.probeAttempted = true; + unpaired.probeAnswered = true; + unpaired.remembered = true; + CHECK(hostTrust(unpaired) == HostTrust::NotPaired); + + // A 401 and a changed identity both drop trust outright. + SessionUiInputs rejected = paired(); + rejected.trustRejected = true; + CHECK(hostTrust(rejected) == HostTrust::NotPaired); + SessionUiInputs replaced = paired(); + replaced.identityChanged = true; + CHECK(hostTrust(replaced) == HostTrust::NotPaired); + + // Never asked at all. + CHECK(hostTrust(SessionUiInputs{}) == HostTrust::NotPaired); +} + +TEST_CASE("the host phase distinguishes what four tokens cannot", "[moonlight][ui]") { + SessionState session; + // Nothing has ever run: a paired host rests at paired, an unpaired one idle. + CHECK(hostPhaseFor(session, /*paired=*/true, /*everStarted=*/false) == HostPhase::Paired); + CHECK(hostPhaseFor(session, false, false) == HostPhase::Idle); + // Something ran and stopped, which is not the same as never having run. + CHECK(hostPhaseFor(session, true, true) == HostPhase::Closed); + + session.phase = SessionPhase::CheckingInfo; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Launching); + session.phase = SessionPhase::Launching; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Launching); + session.phase = SessionPhase::Rtsp; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Connecting); + session.phase = SessionPhase::ControlConnecting; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Connecting); + session.phase = SessionPhase::Streaming; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Streaming); + + session.phase = SessionPhase::Failed; + session.failure = SessionFailure::Dropped; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Faltering); + session.failure = SessionFailure::LaunchRejected; + CHECK(hostPhaseFor(session, true, true) == HostPhase::Failed); + + CHECK(std::string(hostPhaseToken(HostPhase::Streaming)) == "streaming"); + CHECK(std::string(hostPhaseToken(HostPhase::Faltering)) == "faltering"); + CHECK(std::string(hostPhaseToken(HostPhase::Closed)) == "closed"); +} diff --git a/tests/test_moonlight_tls_config.cpp b/tests/test_moonlight_tls_config.cpp new file mode 100644 index 0000000..8c0609a --- /dev/null +++ b/tests/test_moonlight_tls_config.cpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// The one guarantee the mutual-TLS calls make that nothing else can observe: no +// session is ever offered for resumption. A resumed TLS session carries the peer +// identity forward instead of asking for the certificate again, so a Moonlight +// host's verify callback never runs, and Sunshine answers that with a fatal +// internal_error alert (RFC 8446 alert 80) and no log line at all, at TLS 1.2 as +// well as 1.3. Qt shares and persists sessions across the connections one +// QNetworkAccessManager makes, which is exactly the shape that triggers it, so +// every switch is pinned here rather than left to a future refactor. + +#include "source/moonlight/MoonlightHttp.h" + +#include + +#include +#include +#include +#include +#include +#include + +using dish::source::moon::MoonlightHttp; + +TEST_CASE("the mutual-TLS configuration never offers a session to resume", "[moonlight][tls]") { + const QSslConfiguration ssl = MoonlightHttp::tlsConfiguration(QString(), QString()); + + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionTickets)); + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionSharing)); + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionPersistence)); + // Not vacuous: the configuration this starts from would still offer a + // session by at least one route, so the switches above are this code's + // doing and not the framework's. Written as "at least one" rather than + // switch by switch, because which of them Qt leaves on is Qt's business and + // changing it must not turn this red. + const QSslConfiguration byDefault = QSslConfiguration::defaultConfiguration(); + const bool defaultWouldResume = + !byDefault.testSslOption(QSsl::SslOptionDisableSessionTickets) || + !byDefault.testSslOption(QSsl::SslOptionDisableSessionSharing) || + !byDefault.testSslOption(QSsl::SslOptionDisableSessionPersistence); + CHECK(defaultWouldResume); +} + +TEST_CASE("peer verification is off because trust is the pairing pin", "[moonlight][tls]") { + // Both ends are self-signed, so chain verification can only fail. The reply + // handler compares the presented certificate against the one the pairing + // handshake verified and reports a mismatch as unreachable, which is the + // trust decision this replaces. + const QSslConfiguration ssl = MoonlightHttp::tlsConfiguration(QString(), QString()); + CHECK(ssl.peerVerifyMode() == QSslSocket::VerifyNone); +} + +TEST_CASE("a usable identity is presented, and an unusable one is left out", "[moonlight][tls]") { + SECTION("empty PEMs carry no client credential") { + const QSslConfiguration ssl = MoonlightHttp::tlsConfiguration(QString(), QString()); + CHECK(ssl.localCertificate().isNull()); + CHECK(ssl.privateKey().isNull()); + } + SECTION("unparsable PEMs are dropped rather than half-applied") { + const QSslConfiguration ssl = MoonlightHttp::tlsConfiguration( + QStringLiteral("-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"), + QStringLiteral("-----BEGIN PRIVATE KEY-----\nnot base64\n-----END PRIVATE KEY-----\n")); + CHECK(ssl.localCertificate().isNull()); + CHECK(ssl.privateKey().isNull()); + // The resumption switches hold whatever the identity turned out to be. + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionTickets)); + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionSharing)); + CHECK(ssl.testSslOption(QSsl::SslOptionDisableSessionPersistence)); + } +} diff --git a/tests/test_moonlight_wire.cpp b/tests/test_moonlight_wire.cpp new file mode 100644 index 0000000..28c9836 --- /dev/null +++ b/tests/test_moonlight_wire.cpp @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Byte-exact encoder fixtures come from the Moonlight protocol documentation's +// "network" rows (Wolf docs, input-data.adoc) and Wolf's captured-session test +// payloads; the decoders are exercised over every host->client event this +// client handles, including short and malformed buffers. + +#include "core/moonlight/MoonlightProtocol.h" +#include "core/moonlight/MoonlightWire.h" + +#include "Util/Hex.h" + +#include + +#include +#include +#include +#include +#include + +using namespace dish; +using namespace dish::moonwire; + +namespace { + +std::string hexOf(const std::uint8_t* data, std::size_t len) { return util::toHex(data, len); } + +std::vector bytesOf(const std::string& hex) { + const auto decoded = util::fromHex(hex); + REQUIRE(decoded.has_value()); + return *decoded; +} + +} // namespace + +TEST_CASE("MOUSE_MOVE_REL matches the documented network fixture", "[moonlight][wire]") { + // input-data.adoc, network row: 06 02 0C 00 00 00 00 08 07 00 00 00 FF FF 00 00 + // = delta (-1, 0), deltas big-endian. + std::array buf{}; + const std::size_t len = encodeMouseMoveRel(buf.data(), -1, 0); + CHECK(len == kMouseMoveRelSize); + CHECK(hexOf(buf.data(), len) == "06020c000000000807000000ffff0000"); +} + +TEST_CASE("CONTROLLER_MULTI matches the captured-session fixture", "[moonlight][wire]") { + // Wolf testControl.cpp "control joypad input packets": controller 0, active + // mask 1, A pressed, everything else neutral. + std::array buf{}; + const std::size_t len = + encodeControllerMulti(buf.data(), 0, 0x0001, moonproto::kBtnA, 0, 0, 0, 0, 0, 0); + CHECK(len == kControllerMultiSize); + CHECK(hexOf(buf.data(), len) == + "060222000000001e0c0000001a000000010014000010000000000000000000009c0000005500"); +} + +TEST_CASE("CONTROLLER_MULTI splits the extended button word", "[moonlight][wire]") { + std::array buf{}; + const std::uint32_t buttons = moonproto::kBtnA | moonproto::kBtnTouchpad; // hi and lo halves + const std::size_t len = + encodeControllerMulti(buf.data(), 1, 0x0003, buttons, 0xFF, 0x80, 100, -100, 32767, -32768); + REQUIRE(len == kControllerMultiSize); + // btnflags (offset 20) = 0x1000 LE; buttonFlags2 (offset 34) = 0x0010 LE. + CHECK(buf[20] == 0x00); + CHECK(buf[21] == 0x10); + CHECK(buf[34] == 0x10); + CHECK(buf[35] == 0x00); + // ctrl# 1, active mask 3. + CHECK(buf[14] == 0x01); + CHECK(buf[16] == 0x03); + // Triggers and stick extremes land at their fixed offsets. + CHECK(buf[22] == 0xFF); + CHECK(buf[23] == 0x80); + CHECK(buf[28] == 0xFF); // 32767 = FF 7F + CHECK(buf[29] == 0x7F); + CHECK(buf[30] == 0x00); // -32768 = 00 80 + CHECK(buf[31] == 0x80); +} + +TEST_CASE("CONTROLLER_ARRIVAL layout", "[moonlight][wire]") { + std::array buf{}; + const std::uint8_t caps = moonproto::kCapAnalogTriggers | moonproto::kCapRumble; + const std::size_t len = encodeControllerArrival(buf.data(), 2, moonproto::kControllerTypePs, + caps, moonproto::kStandardButtons); + CHECK(len == kControllerArrivalSize); + // [06 02][10 00][00 00 00 0C][04 00 00 55][ctrl][type][cap][pad][buttons u32 LE] + // The advertised word is the whole 16-bit legacy half: a live Sunshine host + // logs it back as supportedButtonFlags [0000FFFF], and all three Dish + // clients advertise the same value so one host cannot see three pads. + CHECK(hexOf(buf.data(), len) == "060210000000000c0400005502020300ffff0000"); + CHECK(moonproto::kStandardButtons == 0x0000FFFFU); +} + +TEST_CASE("CONTROLLER_ARRIVAL carries the struct's alignment pad", "[moonlight][wire]") { + // THE BODY IS EIGHT BYTES, NOT SEVEN. The fields add up to seven, but the + // host reads them out of a naturally aligned struct, so the u32 button mask + // starts at offset 4 and offset 3 is reserved. Sending seven shifted every + // field after the type by one and a live Sunshine host logged our + // capabilities 0x03 as `capabilities [FF03]` and our 0xFFFF button mask as + // `supportedButtonFlags [000000FF]`. + std::array buf{}; + const std::uint8_t caps = moonproto::kCapAnalogTriggers | moonproto::kCapRumble; + const std::size_t len = + encodeControllerArrival(buf.data(), 0, moonproto::kControllerTypeXbox, caps, 0xFFFF); + REQUIRE(len == 20); + CHECK(hexOf(buf.data(), len) == "060210000000000c0400005500010300ffff0000"); + + // Read back the way the host does: fixed offsets into the aligned struct. + const std::uint8_t* body = buf.data() + 12; + CHECK(body[0] == 0x00); // controller number + CHECK(body[1] == moonproto::kControllerTypeXbox); // type + CHECK(body[2] == caps); // capabilities + CHECK(body[3] == 0x00); // reserved / alignment pad + const std::uint32_t buttons = + static_cast(body[4]) | (static_cast(body[5]) << 8) | + (static_cast(body[6]) << 16) | (static_cast(body[7]) << 24); + CHECK(buttons == 0x0000FFFFU); + // The two words the host's own log prints, in its own spelling. + CHECK(static_cast(body[2]) == 0x0003U); + + // The wrapper counts the eight-byte body: packet_len 16, data_size 12. + CHECK(hexOf(buf.data() + 2, 2) == "1000"); + CHECK(hexOf(buf.data() + 4, 4) == "0000000c"); +} + +TEST_CASE("CONTROLLER_ARRIVAL spans the whole emulated-type and capability range", + "[moonlight][wire]") { + std::array buf{}; + for (const std::uint8_t type : + {moonproto::kControllerTypeUnknown, moonproto::kControllerTypeXbox, + moonproto::kControllerTypePs, moonproto::kControllerTypeNintendo}) { + const std::uint8_t caps = static_cast( + moonproto::kCapAnalogTriggers | moonproto::kCapRumble | moonproto::kCapTriggerRumble | + moonproto::kCapTouchpad | moonproto::kCapAccelerometer | moonproto::kCapGyro | + moonproto::kCapBattery | moonproto::kCapRgbLed); + REQUIRE(encodeControllerArrival(buf.data(), 3, type, caps, 0xFFFFFFFFU) == + kControllerArrivalSize); + CHECK(buf[13] == type); + CHECK(buf[14] == 0xFF); + CHECK(buf[15] == 0x00); + CHECK(hexOf(buf.data() + 16, 4) == "ffffffff"); + } +} + +TEST_CASE("CONTROLLER_BATTERY layout", "[moonlight][wire]") { + std::array buf{}; + const std::size_t len = encodeControllerBattery(buf.data(), 0, moonproto::kBatteryCharging, 42); + CHECK(len == kControllerBatterySize); + CHECK(hexOf(buf.data(), len) == "06020c00000000080700005500032a00"); +} + +TEST_CASE("CONTROLLER_MOTION layout carries little-endian floats", "[moonlight][wire]") { + std::array buf{}; + const std::size_t len = + encodeControllerMotion(buf.data(), 3, moonproto::kMotionGyroscope, 1.0F, -2.5F, 0.0F); + CHECK(len == kControllerMotionSize); + // Header: [06 02][18 00][00 00 00 14][06 00 00 55], body ctrl=3 type=2. + CHECK(hexOf(buf.data(), 12) == "060218000000001406000055"); + CHECK(buf[12] == 3); + CHECK(buf[13] == moonproto::kMotionGyroscope); + // 1.0f = 0x3F800000 little-endian. + CHECK(hexOf(buf.data() + 16, 4) == "0000803f"); + // -2.5f = 0xC0200000. + CHECK(hexOf(buf.data() + 20, 4) == "000020c0"); + CHECK(hexOf(buf.data() + 24, 4) == "00000000"); +} + +TEST_CASE("PERIODIC_PING is byte-for-byte the captured plaintext", "[moonlight][wire]") { + std::array buf{}; + const std::size_t len = encodePeriodicPing(buf.data()); + CHECK(len == kPeriodicPingSize); + CHECK(hexOf(buf.data(), len) == "000208000400000000000000"); +} + +TEST_CASE("TERMINATION carries the graceful reason big-endian", "[moonlight][wire]") { + std::array buf{}; + const std::size_t len = encodeTermination(buf.data()); + CHECK(len == kTerminationSize); + CHECK(hexOf(buf.data(), len) == "090104008003" + std::string("0023")); +} + +TEST_CASE("RTP ping falls back to the legacy 4-byte PING without a payload", "[moonlight][wire]") { + std::array buf{}; + CHECK(encodeRtpPing(buf.data(), nullptr, 0, 7) == kRtpPingLegacySize); + CHECK(hexOf(buf.data(), kRtpPingLegacySize) == "50494e47"); // "PING" + + const char empty[] = ""; + CHECK(encodeRtpPing(buf.data(), empty, 0, 7) == kRtpPingLegacySize); + CHECK(hexOf(buf.data(), kRtpPingLegacySize) == "50494e47"); +} + +TEST_CASE("RTP ping echoes the SETUP payload as SS_PING", "[moonlight][wire]") { + // The 16-char X-SS-Ping-Payload verbatim, then the sequence little-endian. + std::array buf{}; + const std::string payload = "AbCd0123EfGh4567"; + const std::size_t len = encodeRtpPing(buf.data(), payload.data(), payload.size(), 0x01020304); + CHECK(len == kRtpPingSize); + CHECK(std::string(reinterpret_cast(buf.data()), 16) == payload); + CHECK(hexOf(buf.data() + 16, 4) == "04030201"); +} + +TEST_CASE("SS_PING is the live host's payload verbatim, not hex-decoded", "[moonlight][wire]") { + // A live Sunshine host sent X-SS-Ping-Payload: 68A75BBEEEA86826. It LOOKS + // like hex and is not: the host mints 16 printable ASCII characters and + // matches the session by those same 16 bytes. Hex-decoding it produces an + // 8-byte datagram, which lands in the 5..19 dead zone Wolf's udp-ping.cpp + // discards without a word. + std::array buf{}; + const std::string payload = "68A75BBEEEA86826"; + REQUIRE(payload.size() == 16); + const std::size_t len = encodeRtpPing(buf.data(), payload.data(), payload.size(), 0); + REQUIRE(len == 20); + CHECK(hexOf(buf.data(), len) == "3638413735424245454541383638323600000000"); + // Byte for byte the header text; the hex decoding of it is 8 bytes long. + CHECK(std::string(reinterpret_cast(buf.data()), 16) == payload); + const auto decoded = util::fromHex(payload); + REQUIRE(decoded.has_value()); + CHECK(decoded->size() == 8); + CHECK(len != decoded->size()); +} + +TEST_CASE("the RTP ping encoder never emits a 5..19 byte datagram", "[moonlight][wire]") { + // LENGTH IS THE PROTOCOL. Wolf's rtp/udp-ping.cpp dispatches on the byte + // count alone: exactly 4 is the legacy PING, 20 or more is an SS_PING, and + // everything between is dropped silently, after which the host reports + // "Initial Ping Timeout" and ends the session ten seconds in. + std::array buf{}; + const std::string filler(40, 'x'); + for (std::size_t payloadLen = 0; payloadLen <= filler.size(); ++payloadLen) { + const std::size_t len = encodeRtpPing(buf.data(), filler.data(), payloadLen, 1); + CHECK((len == kRtpPingLegacySize || len == kRtpPingSize)); + CHECK_FALSE((len > kRtpPingLegacySize && len < kRtpPingSize)); + } + CHECK(encodeRtpPing(buf.data(), nullptr, 0, 1) == kRtpPingLegacySize); + CHECK(encodeRtpPing(buf.data(), nullptr, 16, 1) == kRtpPingLegacySize); +} + +TEST_CASE("RTP ping pads a short payload and truncates a long one", "[moonlight][wire]") { + std::array buf{}; + + const std::string shortPayload = "abc"; + REQUIRE(encodeRtpPing(buf.data(), shortPayload.data(), shortPayload.size(), 1) == kRtpPingSize); + CHECK(std::string(reinterpret_cast(buf.data()), 3) == "abc"); + // Zero-padded through the fixed 16-byte field. + CHECK(hexOf(buf.data() + 3, 13) == "00000000000000000000000000"); + CHECK(hexOf(buf.data() + 16, 4) == "01000000"); + + const std::string longPayload = "0123456789abcdefEXTRA"; + REQUIRE(encodeRtpPing(buf.data(), longPayload.data(), longPayload.size(), 2) == kRtpPingSize); + CHECK(std::string(reinterpret_cast(buf.data()), 16) == "0123456789abcdef"); + CHECK(hexOf(buf.data() + 16, 4) == "02000000"); +} + +// ── Host -> client decoding ────────────────────────────────────────────────── + +TEST_CASE("decodes RUMBLE_DATA", "[moonlight][wire]") { + // [0b 01][0a 00] [unused u32][ctrl=1][low=0x1234][high=0xff00] + const auto pkt = bytesOf("0b010a000000000001003412" + std::string("00ff")); + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::Rumble); + CHECK(ev->controllerNumber == 1); + CHECK(ev->rumbleLow == 0x1234); + CHECK(ev->rumbleHigh == 0xFF00); +} + +TEST_CASE("decodes RUMBLE_TRIGGERS", "[moonlight][wire]") { + const auto pkt = bytesOf("0055060002000a00" + std::string("1400")); + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::RumbleTriggers); + CHECK(ev->controllerNumber == 2); + CHECK(ev->rumbleLow == 10); + CHECK(ev->rumbleHigh == 20); +} + +TEST_CASE("decodes MOTION_EVENT", "[moonlight][wire]") { + // ctrl=0, rate=100 Hz, type=gyro. + const auto pkt = bytesOf("015505000000640002"); + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::MotionRequest); + CHECK(ev->controllerNumber == 0); + CHECK(ev->motionRateHz == 100); + CHECK(ev->motionType == moonproto::kMotionGyroscope); +} + +TEST_CASE("decodes RGB_LED", "[moonlight][wire]") { + const auto pkt = bytesOf("0255050001001020" + std::string("30")); + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::RgbLed); + CHECK(ev->controllerNumber == 1); + CHECK(ev->red == 0x10); + CHECK(ev->green == 0x20); + CHECK(ev->blue == 0x30); +} + +TEST_CASE("decodes TERMINATION from the host", "[moonlight][wire]") { + const auto pkt = bytesOf("0901040080030023"); + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::Termination); +} + +TEST_CASE("unknown types decode as Unknown, not an error", "[moonlight][wire]") { + const auto pkt = bytesOf("0e01020000ff"); // HDR_MODE, unhandled + const auto ev = decodeHostEvent(pkt.data(), pkt.size()); + REQUIRE(ev.has_value()); + CHECK(ev->type == HostEventType::Unknown); +} + +TEST_CASE("short and malformed buffers are rejected", "[moonlight][wire]") { + CHECK_FALSE(decodeHostEvent(nullptr, 100).has_value()); + + const auto tooShortHeader = bytesOf("0b01"); + CHECK_FALSE(decodeHostEvent(tooShortHeader.data(), tooShortHeader.size()).has_value()); + + // RUMBLE_DATA with a truncated body (9 of 10 bytes). + const auto shortRumble = bytesOf("0b010a00000000000100341200"); + CHECK_FALSE(decodeHostEvent(shortRumble.data(), shortRumble.size() - 4).has_value()); + + // RUMBLE_TRIGGERS truncated. + const auto shortTriggers = bytesOf("00550600020070"); + CHECK_FALSE(decodeHostEvent(shortTriggers.data(), shortTriggers.size()).has_value()); + + // MOTION_EVENT truncated (missing the type byte). + const auto shortMotion = bytesOf("01550500000064"); + CHECK_FALSE(decodeHostEvent(shortMotion.data(), shortMotion.size() - 1).has_value()); + + // RGB_LED truncated. + const auto shortLed = bytesOf("02550500010010"); + CHECK_FALSE(decodeHostEvent(shortLed.data(), shortLed.size() - 1).has_value()); + + // Empty buffer. + const std::uint8_t byte = 0; + CHECK_FALSE(decodeHostEvent(&byte, 0).has_value()); +} diff --git a/tests/test_moonlight_xml.cpp b/tests/test_moonlight_xml.cpp new file mode 100644 index 0000000..34162dd --- /dev/null +++ b/tests/test_moonlight_xml.cpp @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright (C) 2026 Dish contributors. +// +// Documents shaped exactly like the host response builders emit them (Wolf's +// moonlight.cpp; Sunshine and Apollo produce the same fields). + +#include "core/moonlight/MoonlightXml.h" + +#include + +#include + +using namespace dish::moonxml; + +namespace { + +const std::string kServerInfo = + "" + "" + "wolfpad" + "7.1.431.-1" + "3.23.0.74" + "0f83dfaa-a462-4f22-bbb1-8a6a3fd1b6ba" + "1869449984" + "257" + "47984" + "47989" + "00:00:00:00:00:00" + "192.168.1.100" + "19201080" + "60" + "1" + "0" + "SUNSHINE_SERVER_FREE" + ""; + +} // namespace + +TEST_CASE("parseServerInfo reads the fields the client needs", "[moonlight][xml]") { + const auto info = parseServerInfo(kServerInfo); + REQUIRE(info.has_value()); + CHECK(info->hostname == "wolfpad"); + CHECK(info->uuid == "0f83dfaa-a462-4f22-bbb1-8a6a3fd1b6ba"); + CHECK(info->appVersion == "7.1.431.-1"); + CHECK(info->httpsPort == 47984); + CHECK(info->externalPort == 47989); + CHECK(info->pairStatus == 1); + CHECK(info->currentGame == 0); + CHECK_FALSE(info->busy()); +} + +TEST_CASE("parseServerInfo flags a busy host", "[moonlight][xml]") { + std::string busy = kServerInfo; + const auto at = busy.find("SUNSHINE_SERVER_FREE"); + busy.replace(at, std::string("SUNSHINE_SERVER_FREE").size(), "SUNSHINE_SERVER_BUSY"); + const auto info = parseServerInfo(busy); + REQUIRE(info.has_value()); + CHECK(info->busy()); +} + +TEST_CASE("parseServerInfo rejects failure and garbage", "[moonlight][xml]") { + CHECK_FALSE(parseServerInfo("").has_value()); + CHECK_FALSE( + parseServerInfo("x").has_value()); + CHECK_FALSE(parseServerInfo("").has_value()); + CHECK_FALSE(parseServerInfo("not xml at all").has_value()); +} + +TEST_CASE("parseAppList returns every App row", "[moonlight][xml]") { + const std::string xml = + "" + "0Desktop1" + "1Steam Big Picture" + "2" + "Fish & Chips3" + ""; + const auto apps = parseAppList(xml); + REQUIRE(apps.size() == 3); + CHECK(apps[0].title == "Desktop"); + CHECK(apps[0].id == "1"); + CHECK(apps[1].title == "Steam Big Picture"); + CHECK(apps[1].id == "2"); + CHECK(apps[2].title == "Fish & Chips"); // entity decoded + CHECK(apps[2].id == "3"); +} + +TEST_CASE("parseAppList tolerates malformed documents", "[moonlight][xml]") { + CHECK(parseAppList("").empty()); + CHECK(parseAppList("").empty()); + CHECK(parseAppList("x9" + "") + .empty()); + // A truncated App block is skipped rather than crashing the parse. + CHECK(parseAppList("x").empty()); +} + +TEST_CASE("parseLaunch extracts the RTSP endpoint", "[moonlight][xml]") { + const auto launch = parseLaunch("" + "rtsp://170.55.71.212:48010" + "1"); + REQUIRE(launch.has_value()); + CHECK(launch->rtspHost == "170.55.71.212"); + CHECK(launch->rtspPort == 48010); + CHECK(launch->launched); +} + +TEST_CASE("parseLaunch accepts a resume response", "[moonlight][xml]") { + const auto launch = parseLaunch("" + "rtsp://10.0.0.2:31000" + "1"); + REQUIRE(launch.has_value()); + CHECK(launch->rtspPort == 31000); + CHECK(launch->launched); +} + +TEST_CASE("parseLaunch rejects a missing or malformed session url", "[moonlight][xml]") { + CHECK_FALSE( + parseLaunch("1").has_value()); + CHECK_FALSE(parseLaunch("" + "rtsp://1.2.3.4:48010") + .has_value()); + CHECK_FALSE(parseLaunch("" + "rtsp://1.2.3.4:notaport") + .has_value()); +} + +TEST_CASE("a host refuses in the BODY, not in the status line", "[moonlight][xml]") { + // Measured against a live Sunshine host: asking /launch to start a second + // app answers HTTP 200 with status_code="400" and "An app is already + // running on this host". Code that reads only the HTTP status treats that + // refusal as a success and then fails downstream on the missing + // sessionUrl0, naming the wrong thing. + const std::string busy = + "" + "" + "0"; + const auto status = parseStatus(busy); + REQUIRE(status.has_value()); + CHECK(status->code == 400); + CHECK(status->message == "An app is already running on this host"); + CHECK_FALSE(status->ok()); + CHECK_FALSE(status->resume); + CHECK(status->appAlreadyRunning()); + // And the launch parse refuses it rather than reporting a session. + CHECK_FALSE(parseLaunch(busy).has_value()); +} + +TEST_CASE("a resumable refusal carries the resume flag", "[moonlight][xml]") { + const auto status = + parseStatus("1"); + REQUIRE(status.has_value()); + CHECK(status->appAlreadyRunning()); + CHECK(status->resume); +} + +TEST_CASE("parseStatus reads every endpoint's root element", "[moonlight][xml]") { + SECTION("a plain success names no status_code at all") { + const auto status = parseStatus("1"); + REQUIRE(status.has_value()); + CHECK(status->code == 200); + CHECK(status->ok()); + CHECK(status->message.empty()); + CHECK_FALSE(status->appAlreadyRunning()); + } + SECTION("2xx is success, not just 200") { + const auto status = parseStatus(""); + REQUIRE(status.has_value()); + CHECK(status->ok()); + } + SECTION("a refusal that is not the busy one") { + const auto status = + parseStatus(""); + REQUIRE(status.has_value()); + CHECK_FALSE(status->ok()); + CHECK(status->message == "Not paired"); + CHECK_FALSE(status->appAlreadyRunning()); + } + SECTION("the message match is case-insensitive and entity-decoded") { + const auto status = parseStatus( + ""); + REQUIRE(status.has_value()); + CHECK(status->message == "An App Is ALREADY RUNNING & busy"); + CHECK(status->appAlreadyRunning()); + } + SECTION("a 2xx that mentions the phrase is still not a refusal") { + const auto status = + parseStatus(""); + REQUIRE(status.has_value()); + CHECK_FALSE(status->appAlreadyRunning()); + } + SECTION("no root element at all") { CHECK_FALSE(parseStatus("not xml at all").has_value()); } +} + +TEST_CASE("statusMessage reads the root attribute", "[moonlight][xml]") { + CHECK(statusMessage("") == "nope"); + CHECK_FALSE(statusMessage("").has_value()); +} + +TEST_CASE("parseServerInfo reads the advertised display modes", "[moonlight][xml]") { + const auto info = parseServerInfo(kServerInfo); + REQUIRE(info.has_value()); + REQUIRE(info->displayModes.size() == 1); + CHECK(info->displayModes[0].width == 1920); + CHECK(info->displayModes[0].height == 1080); + CHECK(info->displayModes[0].refreshRate == 60); +} + +TEST_CASE("preferredDisplayMode picks the host's own display", "[moonlight][xml]") { + // Ask for a mode that matches what the host is already showing: an + // Apollo/Vibepollo virtual display follows the client's request, so asking + // for something small resizes the user's desktop under them. + const std::string xml = "h" + "" + "1280720" + "60" + "25601440" + "60" + "25601440" + "144" + "19201080" + "240" + ""; + const auto info = parseServerInfo(xml); + REQUIRE(info.has_value()); + CHECK(info->displayModes.size() == 4); + const auto best = preferredDisplayMode(info->displayModes); + REQUIRE(best.has_value()); + CHECK(best->width == 2560); + CHECK(best->height == 1440); + CHECK(best->refreshRate == 144); // largest area first, then the fastest at it + + // A host that advertises none leaves the caller on its own default. + CHECK_FALSE(preferredDisplayMode({}).has_value()); + const auto noModes = parseServerInfo("h"); + REQUIRE(noModes.has_value()); + CHECK(noModes->displayModes.empty()); + CHECK_FALSE(preferredDisplayMode(noModes->displayModes).has_value()); +} + +TEST_CASE("display-mode rows without a usable size are skipped", "[moonlight][xml]") { + const auto info = parseServerInfo("h" + "" + "00" + "60" + "768" + "1024768" + "" + ""); + REQUIRE(info.has_value()); + REQUIRE(info->displayModes.size() == 1); + CHECK(info->displayModes[0].width == 1024); + CHECK(info->displayModes[0].refreshRate == 0); + const auto best = preferredDisplayMode(info->displayModes); + REQUIRE(best.has_value()); + CHECK(best->height == 768); +} + +TEST_CASE("an endpoint that names no status_code is still read", "[moonlight][xml]") { + // Wolf's /applist answers plainly, with no status_code attribute at all. + const auto apps = parseAppList("" + "Desktop881448767" + ""); + REQUIRE(apps.size() == 1); + CHECK(apps[0].id == "881448767"); + const auto info = parseServerInfo("wolf" + "1"); + REQUIRE(info.has_value()); + CHECK(info->pairStatus == 1); + const auto launch = parseLaunch("rtsp://10.0.0.5:48010" + "1"); + REQUIRE(launch.has_value()); + CHECK(launch->rtspPort == 48010); +} + +TEST_CASE("pairedFlag and tag helpers", "[moonlight][xml]") { + CHECK(pairedFlag("1")); + CHECK_FALSE(pairedFlag("0")); + CHECK_FALSE(pairedFlag("")); + + CHECK(tagValue("4142", "plaincert") == "4142"); + CHECK_FALSE(tagValue("", "plaincert").has_value()); + CHECK(tagInt("-1", "currentgame") == -1); + CHECK_FALSE(tagInt("abc", "currentgame").has_value()); + CHECK(statusCode("") == 200); + CHECK_FALSE(statusCode("").has_value()); +} + +TEST_CASE("tagValue does not match tags sharing a prefix", "[moonlight][xml]") { + const std::string xml = "titlereal"; + CHECK(tagValue(xml, "App") == "real"); +} diff --git a/third_party/enet/CMakeLists.txt b/third_party/enet/CMakeLists.txt new file mode 100644 index 0000000..8fa1dfc --- /dev/null +++ b/third_party/enet/CMakeLists.txt @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MIT +# Vendored build of the cgutman/enet fork (see THIRD_PARTY.md). Kept as its own +# static library so the upstream C compiles with its own, relaxed warning set +# rather than Dish's -Werror lint wall, and so its include directory is exposed +# to the one Dish translation unit that speaks ENet. + +add_library(dish_enet STATIC + callbacks.c + compress.c + host.c + list.c + packet.c + peer.c + protocol.c + unix.c) + +target_include_directories(dish_enet PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) + +# ENet's platform probes: HAS_* flags for the socket features it needs. These +# hold on every glibc/musl Linux, which is the only target here. +target_compile_definitions(dish_enet PRIVATE + HAS_FCNTL=1 + HAS_IOCTL=1 + HAS_POLL=1 + HAS_GETADDRINFO=1 + HAS_GETNAMEINFO=1 + HAS_INET_PTON=1 + HAS_INET_NTOP=1 + HAS_MSGHDR_FLAGS=1 + HAS_SOCKLEN_T=1) + +# Upstream C, not our code: keep it quiet rather than holding a third party to +# our conversion/shadow wall. +if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(dish_enet PRIVATE -w) +endif() + +set_target_properties(dish_enet PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/third_party/enet/ChangeLog b/third_party/enet/ChangeLog new file mode 100644 index 0000000..e182076 --- /dev/null +++ b/third_party/enet/ChangeLog @@ -0,0 +1,200 @@ +ENet 1.3.17 (November 15, 2020): + +* fixes for sender getting too far ahead of receiver that can cause instability with reliable packets + +ENet 1.3.16 (September 8, 2020): + +* fix bug in unreliable fragment queuing +* use single output queue for reliable and unreliable packets for saner ordering +* revert experimental throttle changes that were less stable than prior algorithm + +ENet 1.3.15 (April 20, 2020): + +* quicker RTT initialization +* use fractional precision for RTT calculations +* fixes for packet throttle with low RTT variance +* miscellaneous socket bug fixes + +ENet 1.3.14 (January 27, 2019): + +* bug fix for enet_peer_disconnect_later() +* use getaddrinfo and getnameinfo where available +* miscellaneous cleanups + +ENet 1.3.13 (April 30, 2015): + +* miscellaneous bug fixes +* added premake and cmake support +* miscellaneous documentation cleanups + +ENet 1.3.12 (April 24, 2014): + +* added maximumPacketSize and maximumWaitingData fields to ENetHost to limit the amount of +data waiting to be delivered on a peer (beware that the default maximumPacketSize is +32MB and should be set higher if desired as should maximumWaitingData) + +ENet 1.3.11 (December 26, 2013): + +* allow an ENetHost to connect to itself +* fixed possible bug with disconnect notifications during connect attempts +* fixed some preprocessor definition bugs + +ENet 1.3.10 (October 23, 2013); + +* doubled maximum reliable window size +* fixed RCVTIMEO/SNDTIMEO socket options and also added NODELAY + +ENet 1.3.9 (August 19, 2013): + +* added duplicatePeers option to ENetHost which can limit the number of peers from duplicate IPs +* added enet_socket_get_option() and ENET_SOCKOPT_ERROR +* added enet_host_random_seed() platform stub + +ENet 1.3.8 (June 2, 2013): + +* added enet_linked_version() for checking the linked version +* added enet_socket_get_address() for querying the local address of a socket +* silenced some debugging prints unless ENET_DEBUG is defined during compilation +* handle EINTR in enet_socket_wait() so that enet_host_service() doesn't propagate errors from signals +* optimized enet_host_bandwidth_throttle() to be less expensive for large numbers of peers + +ENet 1.3.7 (March 6, 2013): + +* added ENET_PACKET_FLAG_SENT to indicate that a packet is being freed because it has been sent +* added userData field to ENetPacket +* changed how random seed is generated on Windows to avoid import warnings +* fixed case where disconnects could be generated with no preceding connect event + +ENet 1.3.6 (December 11, 2012): + +* added support for intercept callback in ENetHost that can be used to process raw packets before ENet +* added enet_socket_shutdown() for issuing shutdown on a socket +* fixed enet_socket_connect() to not error on non-blocking connects +* fixed bug in MTU negotiation during connections + +ENet 1.3.5 (July 31, 2012): + +* fixed bug in unreliable packet fragment queuing + +ENet 1.3.4 (May 29, 2012): + +* added enet_peer_ping_interval() for configuring per-peer ping intervals +* added enet_peer_timeout() for configuring per-peer timeouts +* added protocol packet size limits + +ENet 1.3.3 (June 28, 2011): + +* fixed bug with simultaneous disconnects not dispatching events + +ENet 1.3.2 (May 31, 2011): + +* added support for unreliable packet fragmenting via the packet flag +ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT +* fixed regression in unreliable packet queuing +* added check against received port to limit some forms of IP-spoofing + +ENet 1.3.1 (February 10, 2011): + +* fixed bug in tracking of reliable data in transit +* reliable data window size now scales with the throttle +* fixed bug in fragment length calculation when checksums are used + +ENet 1.3.0 (June 5, 2010): + +* enet_host_create() now requires the channel limit to be specified as +a parameter +* enet_host_connect() now accepts a data parameter which is supplied +to the receiving receiving host in the event data field for a connect event +* added an adaptive order-2 PPM range coder as a built-in compressor option +which can be set with enet_host_compress_with_range_coder() +* added support for packet compression configurable with a callback +* improved session number handling to not rely on the packet checksum +field, saving 4 bytes per packet unless the checksum option is used +* removed the dependence on the rand callback for session number handling + +Caveats: This version is not protocol compatible with the 1.2 series or +earlier. The enet_host_connect and enet_host_create API functions require +supplying additional parameters. + +ENet 1.2.5 (June 28, 2011): + +* fixed bug with simultaneous disconnects not dispatching events + +ENet 1.2.4 (May 31, 2011): + +* fixed regression in unreliable packet queuing +* added check against received port to limit some forms of IP-spoofing + +ENet 1.2.3 (February 10, 2011): + +* fixed bug in tracking reliable data in transit + +ENet 1.2.2 (June 5, 2010): + +* checksum functionality is now enabled by setting a checksum callback +inside ENetHost instead of being a configure script option +* added totalSentData, totalSentPackets, totalReceivedData, and +totalReceivedPackets counters inside ENetHost for getting usage +statistics +* added enet_host_channel_limit() for limiting the maximum number of +channels allowed by connected peers +* now uses dispatch queues for event dispatch rather than potentially +unscalable array walking +* added no_memory callback that is called when a malloc attempt fails, +such that if no_memory returns rather than aborts (the default behavior), +then the error is propagated to the return value of the API calls +* now uses packed attribute for protocol structures on platforms with +strange alignment rules +* improved autoconf build system contributed by Nathan Brink allowing +for easier building as a shared library + +Caveats: If you were using the compile-time option that enabled checksums, +make sure to set the checksum callback inside ENetHost to enet_crc32 to +regain the old behavior. The ENetCallbacks structure has added new fields, +so make sure to clear the structure to zero before use if +using enet_initialize_with_callbacks(). + +ENet 1.2.1 (November 12, 2009): + +* fixed bug that could cause disconnect events to be dropped +* added thin wrapper around select() for portable usage +* added ENET_SOCKOPT_REUSEADDR socket option +* factored enet_socket_bind()/enet_socket_listen() out of enet_socket_create() +* added contributed Code::Blocks build file + +ENet 1.2 (February 12, 2008): + +* fixed bug in VERIFY_CONNECT acknowledgement that could cause connect +attempts to occasionally timeout +* fixed acknowledgements to check both the outgoing and sent queues +when removing acknowledged packets +* fixed accidental bit rot in the MSVC project file +* revised sequence number overflow handling to address some possible +disconnect bugs +* added enet_host_check_events() for getting only local queued events +* factored out socket option setting into enet_socket_set_option() so +that socket options are now set separately from enet_socket_create() + +Caveats: While this release is superficially protocol compatible with 1.1, +differences in the sequence number overflow handling can potentially cause +random disconnects. + +ENet 1.1 (June 6, 2007): + +* optional CRC32 just in case someone needs a stronger checksum than UDP +provides (--enable-crc32 configure option) +* the size of packet headers are half the size they used to be (so less +overhead when sending small packets) +* enet_peer_disconnect_later() that waits till all queued outgoing +packets get sent before issuing an actual disconnect +* freeCallback field in individual packets for notification of when a +packet is about to be freed +* ENET_PACKET_FLAG_NO_ALLOCATE for supplying pre-allocated data to a +packet (can be used in concert with freeCallback to support some custom +allocation schemes that the normal memory allocation callbacks would +normally not allow) +* enet_address_get_host_ip() for printing address numbers +* promoted the enet_socket_*() functions to be part of the API now +* a few stability/crash fixes + + diff --git a/third_party/enet/LICENSE b/third_party/enet/LICENSE new file mode 100644 index 0000000..6906f8e --- /dev/null +++ b/third_party/enet/LICENSE @@ -0,0 +1,7 @@ +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. diff --git a/third_party/enet/README b/third_party/enet/README new file mode 100644 index 0000000..54b2d21 --- /dev/null +++ b/third_party/enet/README @@ -0,0 +1,15 @@ +Please visit the ENet homepage at http://enet.bespin.org for installation +and usage instructions. + +If you obtained this package from github, the quick description on how to build +is: + +# Generate the build system. + +autoreconf -vfi + +# Compile and install the library. + +./configure && make && make install + + diff --git a/third_party/enet/callbacks.c b/third_party/enet/callbacks.c new file mode 100644 index 0000000..b3990af --- /dev/null +++ b/third_party/enet/callbacks.c @@ -0,0 +1,53 @@ +/** + @file callbacks.c + @brief ENet callback functions +*/ +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" + +static ENetCallbacks callbacks = { malloc, free, abort }; + +int +enet_initialize_with_callbacks (ENetVersion version, const ENetCallbacks * inits) +{ + if (version < ENET_VERSION_CREATE (1, 3, 0)) + return -1; + + if (inits -> malloc != NULL || inits -> free != NULL) + { + if (inits -> malloc == NULL || inits -> free == NULL) + return -1; + + callbacks.malloc = inits -> malloc; + callbacks.free = inits -> free; + } + + if (inits -> no_memory != NULL) + callbacks.no_memory = inits -> no_memory; + + return enet_initialize (); +} + +ENetVersion +enet_linked_version (void) +{ + return ENET_VERSION; +} + +void * +enet_malloc (size_t size) +{ + void * memory = callbacks.malloc (size); + + if (memory == NULL) + callbacks.no_memory (); + + return memory; +} + +void +enet_free (void * memory) +{ + callbacks.free (memory); +} + diff --git a/third_party/enet/compress.c b/third_party/enet/compress.c new file mode 100644 index 0000000..784489a --- /dev/null +++ b/third_party/enet/compress.c @@ -0,0 +1,654 @@ +/** + @file compress.c + @brief An adaptive order-2 PPM range coder +*/ +#define ENET_BUILDING_LIB 1 +#include +#include "enet/enet.h" + +typedef struct _ENetSymbol +{ + /* binary indexed tree of symbols */ + enet_uint8 value; + enet_uint8 count; + enet_uint16 under; + enet_uint16 left, right; + + /* context defined by this symbol */ + enet_uint16 symbols; + enet_uint16 escapes; + enet_uint16 total; + enet_uint16 parent; +} ENetSymbol; + +/* adaptation constants tuned aggressively for small packet sizes rather than large file compression */ +enum +{ + ENET_RANGE_CODER_TOP = 1<<24, + ENET_RANGE_CODER_BOTTOM = 1<<16, + + ENET_CONTEXT_SYMBOL_DELTA = 3, + ENET_CONTEXT_SYMBOL_MINIMUM = 1, + ENET_CONTEXT_ESCAPE_MINIMUM = 1, + + ENET_SUBCONTEXT_ORDER = 2, + ENET_SUBCONTEXT_SYMBOL_DELTA = 2, + ENET_SUBCONTEXT_ESCAPE_DELTA = 5 +}; + +/* context exclusion roughly halves compression speed, so disable for now */ +#undef ENET_CONTEXT_EXCLUSION + +typedef struct _ENetRangeCoder +{ + /* only allocate enough symbols for reasonable MTUs, would need to be larger for large file compression */ + ENetSymbol symbols[4096]; +} ENetRangeCoder; + +void * +enet_range_coder_create (void) +{ + ENetRangeCoder * rangeCoder = (ENetRangeCoder *) enet_malloc (sizeof (ENetRangeCoder)); + if (rangeCoder == NULL) + return NULL; + + return rangeCoder; +} + +void +enet_range_coder_destroy (void * context) +{ + ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; + if (rangeCoder == NULL) + return; + + enet_free (rangeCoder); +} + +#define ENET_SYMBOL_CREATE(symbol, value_, count_) \ +{ \ + symbol = & rangeCoder -> symbols [nextSymbol ++]; \ + symbol -> value = value_; \ + symbol -> count = count_; \ + symbol -> under = count_; \ + symbol -> left = 0; \ + symbol -> right = 0; \ + symbol -> symbols = 0; \ + symbol -> escapes = 0; \ + symbol -> total = 0; \ + symbol -> parent = 0; \ +} + +#define ENET_CONTEXT_CREATE(context, escapes_, minimum) \ +{ \ + ENET_SYMBOL_CREATE (context, 0, 0); \ + (context) -> escapes = escapes_; \ + (context) -> total = escapes_ + 256*minimum; \ + (context) -> symbols = 0; \ +} + +static enet_uint16 +enet_symbol_rescale (ENetSymbol * symbol) +{ + enet_uint16 total = 0; + for (;;) + { + symbol -> count -= symbol->count >> 1; + symbol -> under = symbol -> count; + if (symbol -> left) + symbol -> under += enet_symbol_rescale (symbol + symbol -> left); + total += symbol -> under; + if (! symbol -> right) break; + symbol += symbol -> right; + } + return total; +} + +#define ENET_CONTEXT_RESCALE(context, minimum) \ +{ \ + (context) -> total = (context) -> symbols ? enet_symbol_rescale ((context) + (context) -> symbols) : 0; \ + (context) -> escapes -= (context) -> escapes >> 1; \ + (context) -> total += (context) -> escapes + 256*minimum; \ +} + +#define ENET_RANGE_CODER_OUTPUT(value) \ +{ \ + if (outData >= outEnd) \ + return 0; \ + * outData ++ = value; \ +} + +#define ENET_RANGE_CODER_ENCODE(under, count, total) \ +{ \ + encodeRange /= (total); \ + encodeLow += (under) * encodeRange; \ + encodeRange *= (count); \ + for (;;) \ + { \ + if((encodeLow ^ (encodeLow + encodeRange)) >= ENET_RANGE_CODER_TOP) \ + { \ + if(encodeRange >= ENET_RANGE_CODER_BOTTOM) break; \ + encodeRange = -encodeLow & (ENET_RANGE_CODER_BOTTOM - 1); \ + } \ + ENET_RANGE_CODER_OUTPUT (encodeLow >> 24); \ + encodeRange <<= 8; \ + encodeLow <<= 8; \ + } \ +} + +#define ENET_RANGE_CODER_FLUSH \ +{ \ + while (encodeLow) \ + { \ + ENET_RANGE_CODER_OUTPUT (encodeLow >> 24); \ + encodeLow <<= 8; \ + } \ +} + +#define ENET_RANGE_CODER_FREE_SYMBOLS \ +{ \ + if (nextSymbol >= sizeof (rangeCoder -> symbols) / sizeof (ENetSymbol) - ENET_SUBCONTEXT_ORDER ) \ + { \ + nextSymbol = 0; \ + ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); \ + predicted = 0; \ + order = 0; \ + } \ +} + +#define ENET_CONTEXT_ENCODE(context, symbol_, value_, under_, count_, update, minimum) \ +{ \ + under_ = value*minimum; \ + count_ = minimum; \ + if (! (context) -> symbols) \ + { \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + (context) -> symbols = symbol_ - (context); \ + } \ + else \ + { \ + ENetSymbol * node = (context) + (context) -> symbols; \ + for (;;) \ + { \ + if (value_ < node -> value) \ + { \ + node -> under += update; \ + if (node -> left) { node += node -> left; continue; } \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + node -> left = symbol_ - node; \ + } \ + else \ + if (value_ > node -> value) \ + { \ + under_ += node -> under; \ + if (node -> right) { node += node -> right; continue; } \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + node -> right = symbol_ - node; \ + } \ + else \ + { \ + count_ += node -> count; \ + under_ += node -> under - node -> count; \ + node -> under += update; \ + node -> count += update; \ + symbol_ = node; \ + } \ + break; \ + } \ + } \ +} + +#ifdef ENET_CONTEXT_EXCLUSION +static const ENetSymbol emptyContext = { 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + +#define ENET_CONTEXT_WALK(context, body) \ +{ \ + const ENetSymbol * node = (context) + (context) -> symbols; \ + const ENetSymbol * stack [256]; \ + size_t stackSize = 0; \ + while (node -> left) \ + { \ + stack [stackSize ++] = node; \ + node += node -> left; \ + } \ + for (;;) \ + { \ + body; \ + if (node -> right) \ + { \ + node += node -> right; \ + while (node -> left) \ + { \ + stack [stackSize ++] = node; \ + node += node -> left; \ + } \ + } \ + else \ + if (stackSize <= 0) \ + break; \ + else \ + node = stack [-- stackSize]; \ + } \ +} + +#define ENET_CONTEXT_ENCODE_EXCLUDE(context, value_, under, total, minimum) \ +ENET_CONTEXT_WALK(context, { \ + if (node -> value != value_) \ + { \ + enet_uint16 parentCount = rangeCoder -> symbols [node -> parent].count + minimum; \ + if (node -> value < value_) \ + under -= parentCount; \ + total -= parentCount; \ + } \ +}) +#endif + +size_t +enet_range_coder_compress (void * context, const ENetBuffer * inBuffers, size_t inBufferCount, size_t inLimit, enet_uint8 * outData, size_t outLimit) +{ + ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; + enet_uint8 * outStart = outData, * outEnd = & outData [outLimit]; + const enet_uint8 * inData, * inEnd; + enet_uint32 encodeLow = 0, encodeRange = ~0; + ENetSymbol * root; + enet_uint16 predicted = 0; + size_t order = 0, nextSymbol = 0; + + if (rangeCoder == NULL || inBufferCount <= 0 || inLimit <= 0) + return 0; + + inData = (const enet_uint8 *) inBuffers -> data; + inEnd = & inData [inBuffers -> dataLength]; + inBuffers ++; + inBufferCount --; + + ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); + + for (;;) + { + ENetSymbol * subcontext, * symbol; +#ifdef ENET_CONTEXT_EXCLUSION + const ENetSymbol * childContext = & emptyContext; +#endif + enet_uint8 value; + enet_uint16 count, under, * parent = & predicted, total; + if (inData >= inEnd) + { + if (inBufferCount <= 0) + break; + inData = (const enet_uint8 *) inBuffers -> data; + inEnd = & inData [inBuffers -> dataLength]; + inBuffers ++; + inBufferCount --; + } + value = * inData ++; + + for (subcontext = & rangeCoder -> symbols [predicted]; + subcontext != root; +#ifdef ENET_CONTEXT_EXCLUSION + childContext = subcontext, +#endif + subcontext = & rangeCoder -> symbols [subcontext -> parent]) + { + ENET_CONTEXT_ENCODE (subcontext, symbol, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0); + * parent = symbol - rangeCoder -> symbols; + parent = & symbol -> parent; + total = subcontext -> total; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > ENET_SUBCONTEXT_SYMBOL_DELTA + ENET_SUBCONTEXT_ESCAPE_DELTA) + ENET_CONTEXT_ENCODE_EXCLUDE (childContext, value, under, total, 0); +#endif + if (count > 0) + { + ENET_RANGE_CODER_ENCODE (subcontext -> escapes + under, count, total); + } + else + { + if (subcontext -> escapes > 0 && subcontext -> escapes < total) + ENET_RANGE_CODER_ENCODE (0, subcontext -> escapes, total); + subcontext -> escapes += ENET_SUBCONTEXT_ESCAPE_DELTA; + subcontext -> total += ENET_SUBCONTEXT_ESCAPE_DELTA; + } + subcontext -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; + if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || subcontext -> total > ENET_RANGE_CODER_BOTTOM - 0x100) + ENET_CONTEXT_RESCALE (subcontext, 0); + if (count > 0) goto nextInput; + } + + ENET_CONTEXT_ENCODE (root, symbol, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM); + * parent = symbol - rangeCoder -> symbols; + parent = & symbol -> parent; + total = root -> total; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > ENET_SUBCONTEXT_SYMBOL_DELTA + ENET_SUBCONTEXT_ESCAPE_DELTA) + ENET_CONTEXT_ENCODE_EXCLUDE (childContext, value, under, total, ENET_CONTEXT_SYMBOL_MINIMUM); +#endif + ENET_RANGE_CODER_ENCODE (root -> escapes + under, count, total); + root -> total += ENET_CONTEXT_SYMBOL_DELTA; + if (count > 0xFF - 2*ENET_CONTEXT_SYMBOL_DELTA + ENET_CONTEXT_SYMBOL_MINIMUM || root -> total > ENET_RANGE_CODER_BOTTOM - 0x100) + ENET_CONTEXT_RESCALE (root, ENET_CONTEXT_SYMBOL_MINIMUM); + + nextInput: + if (order >= ENET_SUBCONTEXT_ORDER) + predicted = rangeCoder -> symbols [predicted].parent; + else + order ++; + ENET_RANGE_CODER_FREE_SYMBOLS; + } + + ENET_RANGE_CODER_FLUSH; + + return (size_t) (outData - outStart); +} + +#define ENET_RANGE_CODER_SEED \ +{ \ + if (inData < inEnd) decodeCode |= * inData ++ << 24; \ + if (inData < inEnd) decodeCode |= * inData ++ << 16; \ + if (inData < inEnd) decodeCode |= * inData ++ << 8; \ + if (inData < inEnd) decodeCode |= * inData ++; \ +} + +#define ENET_RANGE_CODER_READ(total) ((decodeCode - decodeLow) / (decodeRange /= (total))) + +#define ENET_RANGE_CODER_DECODE(under, count, total) \ +{ \ + decodeLow += (under) * decodeRange; \ + decodeRange *= (count); \ + for (;;) \ + { \ + if((decodeLow ^ (decodeLow + decodeRange)) >= ENET_RANGE_CODER_TOP) \ + { \ + if(decodeRange >= ENET_RANGE_CODER_BOTTOM) break; \ + decodeRange = -decodeLow & (ENET_RANGE_CODER_BOTTOM - 1); \ + } \ + decodeCode <<= 8; \ + if (inData < inEnd) \ + decodeCode |= * inData ++; \ + decodeRange <<= 8; \ + decodeLow <<= 8; \ + } \ +} + +#define ENET_CONTEXT_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, createRoot, visitNode, createRight, createLeft) \ +{ \ + under_ = 0; \ + count_ = minimum; \ + if (! (context) -> symbols) \ + { \ + createRoot; \ + } \ + else \ + { \ + ENetSymbol * node = (context) + (context) -> symbols; \ + for (;;) \ + { \ + enet_uint16 after = under_ + node -> under + (node -> value + 1)*minimum, before = node -> count + minimum; \ + visitNode; \ + if (code >= after) \ + { \ + under_ += node -> under; \ + if (node -> right) { node += node -> right; continue; } \ + createRight; \ + } \ + else \ + if (code < after - before) \ + { \ + node -> under += update; \ + if (node -> left) { node += node -> left; continue; } \ + createLeft; \ + } \ + else \ + { \ + value_ = node -> value; \ + count_ += node -> count; \ + under_ = after - before; \ + node -> under += update; \ + node -> count += update; \ + symbol_ = node; \ + } \ + break; \ + } \ + } \ +} + +#define ENET_CONTEXT_TRY_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, exclude) \ +ENET_CONTEXT_DECODE (context, symbol_, code, value_, under_, count_, update, minimum, return 0, exclude (node -> value, after, before), return 0, return 0) + +#define ENET_CONTEXT_ROOT_DECODE(context, symbol_, code, value_, under_, count_, update, minimum, exclude) \ +ENET_CONTEXT_DECODE (context, symbol_, code, value_, under_, count_, update, minimum, \ + { \ + value_ = code / minimum; \ + under_ = code - code%minimum; \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + (context) -> symbols = symbol_ - (context); \ + }, \ + exclude (node -> value, after, before), \ + { \ + value_ = node->value + 1 + (code - after)/minimum; \ + under_ = code - (code - after)%minimum; \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + node -> right = symbol_ - node; \ + }, \ + { \ + value_ = node->value - 1 - (after - before - code - 1)/minimum; \ + under_ = code - (after - before - code - 1)%minimum; \ + ENET_SYMBOL_CREATE (symbol_, value_, update); \ + node -> left = symbol_ - node; \ + }) \ + +#ifdef ENET_CONTEXT_EXCLUSION +typedef struct _ENetExclude +{ + enet_uint8 value; + enet_uint16 under; +} ENetExclude; + +#define ENET_CONTEXT_DECODE_EXCLUDE(context, total, minimum) \ +{ \ + enet_uint16 under = 0; \ + nextExclude = excludes; \ + ENET_CONTEXT_WALK (context, { \ + under += rangeCoder -> symbols [node -> parent].count + minimum; \ + nextExclude -> value = node -> value; \ + nextExclude -> under = under; \ + nextExclude ++; \ + }); \ + total -= under; \ +} + +#define ENET_CONTEXT_EXCLUDED(value_, after, before) \ +{ \ + size_t low = 0, high = nextExclude - excludes; \ + for(;;) \ + { \ + size_t mid = (low + high) >> 1; \ + const ENetExclude * exclude = & excludes [mid]; \ + if (value_ < exclude -> value) \ + { \ + if (low + 1 < high) \ + { \ + high = mid; \ + continue; \ + } \ + if (exclude > excludes) \ + after -= exclude [-1].under; \ + } \ + else \ + { \ + if (value_ > exclude -> value) \ + { \ + if (low + 1 < high) \ + { \ + low = mid; \ + continue; \ + } \ + } \ + else \ + before = 0; \ + after -= exclude -> under; \ + } \ + break; \ + } \ +} +#endif + +#define ENET_CONTEXT_NOT_EXCLUDED(value_, after, before) + +size_t +enet_range_coder_decompress (void * context, const enet_uint8 * inData, size_t inLimit, enet_uint8 * outData, size_t outLimit) +{ + ENetRangeCoder * rangeCoder = (ENetRangeCoder *) context; + enet_uint8 * outStart = outData, * outEnd = & outData [outLimit]; + const enet_uint8 * inEnd = & inData [inLimit]; + enet_uint32 decodeLow = 0, decodeCode = 0, decodeRange = ~0; + ENetSymbol * root; + enet_uint16 predicted = 0; + size_t order = 0, nextSymbol = 0; +#ifdef ENET_CONTEXT_EXCLUSION + ENetExclude excludes [256]; + ENetExclude * nextExclude = excludes; +#endif + + if (rangeCoder == NULL || inLimit <= 0) + return 0; + + ENET_CONTEXT_CREATE (root, ENET_CONTEXT_ESCAPE_MINIMUM, ENET_CONTEXT_SYMBOL_MINIMUM); + + ENET_RANGE_CODER_SEED; + + for (;;) + { + ENetSymbol * subcontext, * symbol, * patch; +#ifdef ENET_CONTEXT_EXCLUSION + const ENetSymbol * childContext = & emptyContext; +#endif + enet_uint8 value = 0; + enet_uint16 code, under, count, bottom, * parent = & predicted, total; + + for (subcontext = & rangeCoder -> symbols [predicted]; + subcontext != root; +#ifdef ENET_CONTEXT_EXCLUSION + childContext = subcontext, +#endif + subcontext = & rangeCoder -> symbols [subcontext -> parent]) + { + if (subcontext -> escapes <= 0) + continue; + total = subcontext -> total; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > 0) + ENET_CONTEXT_DECODE_EXCLUDE (childContext, total, 0); +#endif + if (subcontext -> escapes >= total) + continue; + code = ENET_RANGE_CODER_READ (total); + if (code < subcontext -> escapes) + { + ENET_RANGE_CODER_DECODE (0, subcontext -> escapes, total); + continue; + } + code -= subcontext -> escapes; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > 0) + { + ENET_CONTEXT_TRY_DECODE (subcontext, symbol, code, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0, ENET_CONTEXT_EXCLUDED); + } + else +#endif + { + ENET_CONTEXT_TRY_DECODE (subcontext, symbol, code, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0, ENET_CONTEXT_NOT_EXCLUDED); + } + bottom = symbol - rangeCoder -> symbols; + ENET_RANGE_CODER_DECODE (subcontext -> escapes + under, count, total); + subcontext -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; + if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || subcontext -> total > ENET_RANGE_CODER_BOTTOM - 0x100) + ENET_CONTEXT_RESCALE (subcontext, 0); + goto patchContexts; + } + + total = root -> total; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > 0) + ENET_CONTEXT_DECODE_EXCLUDE (childContext, total, ENET_CONTEXT_SYMBOL_MINIMUM); +#endif + code = ENET_RANGE_CODER_READ (total); + if (code < root -> escapes) + { + ENET_RANGE_CODER_DECODE (0, root -> escapes, total); + break; + } + code -= root -> escapes; +#ifdef ENET_CONTEXT_EXCLUSION + if (childContext -> total > 0) + { + ENET_CONTEXT_ROOT_DECODE (root, symbol, code, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM, ENET_CONTEXT_EXCLUDED); + } + else +#endif + { + ENET_CONTEXT_ROOT_DECODE (root, symbol, code, value, under, count, ENET_CONTEXT_SYMBOL_DELTA, ENET_CONTEXT_SYMBOL_MINIMUM, ENET_CONTEXT_NOT_EXCLUDED); + } + bottom = symbol - rangeCoder -> symbols; + ENET_RANGE_CODER_DECODE (root -> escapes + under, count, total); + root -> total += ENET_CONTEXT_SYMBOL_DELTA; + if (count > 0xFF - 2*ENET_CONTEXT_SYMBOL_DELTA + ENET_CONTEXT_SYMBOL_MINIMUM || root -> total > ENET_RANGE_CODER_BOTTOM - 0x100) + ENET_CONTEXT_RESCALE (root, ENET_CONTEXT_SYMBOL_MINIMUM); + + patchContexts: + for (patch = & rangeCoder -> symbols [predicted]; + patch != subcontext; + patch = & rangeCoder -> symbols [patch -> parent]) + { + ENET_CONTEXT_ENCODE (patch, symbol, value, under, count, ENET_SUBCONTEXT_SYMBOL_DELTA, 0); + * parent = symbol - rangeCoder -> symbols; + parent = & symbol -> parent; + if (count <= 0) + { + patch -> escapes += ENET_SUBCONTEXT_ESCAPE_DELTA; + patch -> total += ENET_SUBCONTEXT_ESCAPE_DELTA; + } + patch -> total += ENET_SUBCONTEXT_SYMBOL_DELTA; + if (count > 0xFF - 2*ENET_SUBCONTEXT_SYMBOL_DELTA || patch -> total > ENET_RANGE_CODER_BOTTOM - 0x100) + ENET_CONTEXT_RESCALE (patch, 0); + } + * parent = bottom; + + ENET_RANGE_CODER_OUTPUT (value); + + if (order >= ENET_SUBCONTEXT_ORDER) + predicted = rangeCoder -> symbols [predicted].parent; + else + order ++; + ENET_RANGE_CODER_FREE_SYMBOLS; + } + + return (size_t) (outData - outStart); +} + +/** @defgroup host ENet host functions + @{ +*/ + +/** Sets the packet compressor the host should use to the default range coder. + @param host host to enable the range coder for + @returns 0 on success, < 0 on failure +*/ +int +enet_host_compress_with_range_coder (ENetHost * host) +{ + ENetCompressor compressor; + memset (& compressor, 0, sizeof (compressor)); + compressor.context = enet_range_coder_create(); + if (compressor.context == NULL) + return -1; + compressor.compress = enet_range_coder_compress; + compressor.decompress = enet_range_coder_decompress; + compressor.destroy = enet_range_coder_destroy; + enet_host_compress (host, & compressor); + return 0; +} + +/** @} */ + + diff --git a/third_party/enet/host.c b/third_party/enet/host.c new file mode 100644 index 0000000..6b6827a --- /dev/null +++ b/third_party/enet/host.c @@ -0,0 +1,477 @@ +/** + @file host.c + @brief ENet host management functions +*/ +#define ENET_BUILDING_LIB 1 +#include +#include "enet/enet.h" + +/** @defgroup host ENet host functions + @{ +*/ + +/** Creates a host for communicating to peers. + + @param addressFamily the address family of the socket that should be created (ex: PF_INET/PF_INET6) + @param address the address at which other peers may connect to this host. If NULL, then no peers may connect to the host. + @param peerCount the maximum number of peers that should be allocated for the host. + @param channelLimit the maximum number of channels allowed; if 0, then this is equivalent to ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT + @param incomingBandwidth downstream bandwidth of the host in bytes/second; if 0, ENet will assume unlimited bandwidth. + @param outgoingBandwidth upstream bandwidth of the host in bytes/second; if 0, ENet will assume unlimited bandwidth. + + @returns the host on success and NULL on failure + + @remarks ENet will strategically drop packets on specific sides of a connection between hosts + to ensure the host's bandwidth is not overwhelmed. The bandwidth parameters also determine + the window size of a connection which limits the amount of reliable packets that may be in transit + at any given time. +*/ +ENetHost * +enet_host_create (int addressFamily, const ENetAddress * address, size_t peerCount, size_t channelLimit, enet_uint32 incomingBandwidth, enet_uint32 outgoingBandwidth) +{ + ENetHost * host; + ENetPeer * currentPeer; + + if (peerCount > ENET_PROTOCOL_MAXIMUM_PEER_ID) + return NULL; + + host = (ENetHost *) enet_malloc (sizeof (ENetHost)); + if (host == NULL) + return NULL; + memset (host, 0, sizeof (ENetHost)); + + host -> peers = (ENetPeer *) enet_malloc (peerCount * sizeof (ENetPeer)); + if (host -> peers == NULL) + { + enet_free (host); + + return NULL; + } + memset (host -> peers, 0, peerCount * sizeof (ENetPeer)); + + host -> socket = enet_socket_create (addressFamily, ENET_SOCKET_TYPE_DATAGRAM); + if (host -> socket == ENET_SOCKET_NULL || (address != NULL && enet_socket_bind (host -> socket, address) < 0)) + { + if (host -> socket != ENET_SOCKET_NULL) + enet_socket_destroy (host -> socket); + + enet_free (host -> peers); + enet_free (host); + + return NULL; + } + + enet_socket_set_option (host -> socket, ENET_SOCKOPT_NONBLOCK, 1); + enet_socket_set_option (host -> socket, ENET_SOCKOPT_RCVBUF, ENET_HOST_RECEIVE_BUFFER_SIZE); + enet_socket_set_option (host -> socket, ENET_SOCKOPT_SNDBUF, ENET_HOST_SEND_BUFFER_SIZE); + enet_socket_set_option (host -> socket, ENET_SOCKOPT_QOS, 1); + + if (address != NULL && enet_socket_get_address (host -> socket, & host -> address) < 0) + host -> address = * address; + + if (! channelLimit || channelLimit > ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT) + channelLimit = ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT; + else + if (channelLimit < ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT) + channelLimit = ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT; + + host -> randomSeed = (enet_uint32) (size_t) host; + host -> randomSeed += enet_host_random_seed (); + host -> randomSeed = (host -> randomSeed << 16) | (host -> randomSeed >> 16); + host -> channelLimit = channelLimit; + host -> incomingBandwidth = incomingBandwidth; + host -> outgoingBandwidth = outgoingBandwidth; + host -> bandwidthThrottleEpoch = 0; + host -> recalculateBandwidthLimits = 0; + host -> mtu = ENET_HOST_DEFAULT_MTU; + host -> peerCount = peerCount; + host -> commandCount = 0; + host -> bufferCount = 0; + host -> checksum = NULL; + memset(& host -> receivedAddress, 0, sizeof (host -> receivedAddress)); + host -> receivedData = NULL; + host -> receivedDataLength = 0; + + host -> totalSentData = 0; + host -> totalSentPackets = 0; + host -> totalReceivedData = 0; + host -> totalReceivedPackets = 0; + + host -> connectedPeers = 0; + host -> bandwidthLimitedPeers = 0; + host -> duplicatePeers = ENET_PROTOCOL_MAXIMUM_PEER_ID; + host -> maximumPacketSize = ENET_HOST_DEFAULT_MAXIMUM_PACKET_SIZE; + host -> maximumWaitingData = ENET_HOST_DEFAULT_MAXIMUM_WAITING_DATA; + + host -> compressor.context = NULL; + host -> compressor.compress = NULL; + host -> compressor.decompress = NULL; + host -> compressor.destroy = NULL; + + host -> intercept = NULL; + + enet_list_clear (& host -> dispatchQueue); + + for (currentPeer = host -> peers; + currentPeer < & host -> peers [host -> peerCount]; + ++ currentPeer) + { + currentPeer -> host = host; + currentPeer -> incomingPeerID = currentPeer - host -> peers; + currentPeer -> outgoingSessionID = currentPeer -> incomingSessionID = 0xFF; + currentPeer -> data = NULL; + + enet_list_clear (& currentPeer -> acknowledgements); + enet_list_clear (& currentPeer -> sentReliableCommands); + enet_list_clear (& currentPeer -> sentUnreliableCommands); + enet_list_clear (& currentPeer -> outgoingCommands); + enet_list_clear (& currentPeer -> dispatchedCommands); + + enet_peer_reset (currentPeer); + } + + return host; +} + +/** Destroys the host and all resources associated with it. + @param host pointer to the host to destroy +*/ +void +enet_host_destroy (ENetHost * host) +{ + ENetPeer * currentPeer; + + if (host == NULL) + return; + + enet_socket_destroy (host -> socket); + + for (currentPeer = host -> peers; + currentPeer < & host -> peers [host -> peerCount]; + ++ currentPeer) + { + enet_peer_reset (currentPeer); + } + + if (host -> compressor.context != NULL && host -> compressor.destroy) + (* host -> compressor.destroy) (host -> compressor.context); + + enet_free (host -> peers); + enet_free (host); +} + +enet_uint32 +enet_host_random (ENetHost * host) +{ + /* Mulberry32 by Tommy Ettinger */ + enet_uint32 n = (host -> randomSeed += 0x6D2B79F5U); + n = (n ^ (n >> 15)) * (n | 1U); + n ^= n + (n ^ (n >> 7)) * (n | 61U); + return n ^ (n >> 14); +} + +/** Initiates a connection to a foreign host. + @param host host seeking the connection + @param address destination for the connection + @param channelCount number of channels to allocate + @param data user data supplied to the receiving host + @returns a peer representing the foreign host on success, NULL on failure + @remarks The peer returned will have not completed the connection until enet_host_service() + notifies of an ENET_EVENT_TYPE_CONNECT event for the peer. +*/ +ENetPeer * +enet_host_connect (ENetHost * host, const ENetAddress * address, size_t channelCount, enet_uint32 data) +{ + ENetPeer * currentPeer; + ENetChannel * channel; + ENetProtocol command; + + if (channelCount < ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT) + channelCount = ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT; + else + if (channelCount > ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT) + channelCount = ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT; + + for (currentPeer = host -> peers; + currentPeer < & host -> peers [host -> peerCount]; + ++ currentPeer) + { + if (currentPeer -> state == ENET_PEER_STATE_DISCONNECTED) + break; + } + + if (currentPeer >= & host -> peers [host -> peerCount]) + return NULL; + + currentPeer -> channels = (ENetChannel *) enet_malloc (channelCount * sizeof (ENetChannel)); + if (currentPeer -> channels == NULL) + return NULL; + currentPeer -> channelCount = channelCount; + currentPeer -> state = ENET_PEER_STATE_CONNECTING; + currentPeer -> address = * address; + currentPeer -> connectID = enet_host_random (host); + + if (host -> outgoingBandwidth == 0) + currentPeer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + else + currentPeer -> windowSize = (host -> outgoingBandwidth / + ENET_PEER_WINDOW_SIZE_SCALE) * + ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + + if (currentPeer -> windowSize < ENET_PROTOCOL_MINIMUM_WINDOW_SIZE) + currentPeer -> windowSize = ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + if (currentPeer -> windowSize > ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE) + currentPeer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + + for (channel = currentPeer -> channels; + channel < & currentPeer -> channels [channelCount]; + ++ channel) + { + channel -> outgoingReliableSequenceNumber = 0; + channel -> outgoingUnreliableSequenceNumber = 0; + channel -> incomingReliableSequenceNumber = 0; + channel -> incomingUnreliableSequenceNumber = 0; + + enet_list_clear (& channel -> incomingReliableCommands); + enet_list_clear (& channel -> incomingUnreliableCommands); + + channel -> usedReliableWindows = 0; + memset (channel -> reliableWindows, 0, sizeof (channel -> reliableWindows)); + } + + command.header.command = ENET_PROTOCOL_COMMAND_CONNECT | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + command.header.channelID = 0xFF; + command.connect.outgoingPeerID = ENET_HOST_TO_NET_16 (currentPeer -> incomingPeerID); + command.connect.incomingSessionID = currentPeer -> incomingSessionID; + command.connect.outgoingSessionID = currentPeer -> outgoingSessionID; + command.connect.mtu = ENET_HOST_TO_NET_32 (currentPeer -> mtu); + command.connect.windowSize = ENET_HOST_TO_NET_32 (currentPeer -> windowSize); + command.connect.channelCount = ENET_HOST_TO_NET_32 (channelCount); + command.connect.incomingBandwidth = ENET_HOST_TO_NET_32 (host -> incomingBandwidth); + command.connect.outgoingBandwidth = ENET_HOST_TO_NET_32 (host -> outgoingBandwidth); + command.connect.packetThrottleInterval = ENET_HOST_TO_NET_32 (currentPeer -> packetThrottleInterval); + command.connect.packetThrottleAcceleration = ENET_HOST_TO_NET_32 (currentPeer -> packetThrottleAcceleration); + command.connect.packetThrottleDeceleration = ENET_HOST_TO_NET_32 (currentPeer -> packetThrottleDeceleration); + command.connect.connectID = currentPeer -> connectID; + command.connect.data = ENET_HOST_TO_NET_32 (data); + + enet_peer_queue_outgoing_command (currentPeer, & command, NULL, 0, 0); + + return currentPeer; +} + +/** Sets the packet compressor the host should use to compress and decompress packets. + @param host host to enable or disable compression for + @param compressor callbacks for for the packet compressor; if NULL, then compression is disabled +*/ +void +enet_host_compress (ENetHost * host, const ENetCompressor * compressor) +{ + if (host -> compressor.context != NULL && host -> compressor.destroy) + (* host -> compressor.destroy) (host -> compressor.context); + + if (compressor) + host -> compressor = * compressor; + else + host -> compressor.context = NULL; +} + +/** Limits the maximum allowed channels of future incoming connections. + @param host host to limit + @param channelLimit the maximum number of channels allowed; if 0, then this is equivalent to ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT +*/ +void +enet_host_channel_limit (ENetHost * host, size_t channelLimit) +{ + if (! channelLimit || channelLimit > ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT) + channelLimit = ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT; + else + if (channelLimit < ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT) + channelLimit = ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT; + + host -> channelLimit = channelLimit; +} + + +/** Adjusts the bandwidth limits of a host. + @param host host to adjust + @param incomingBandwidth new incoming bandwidth + @param outgoingBandwidth new outgoing bandwidth + @remarks the incoming and outgoing bandwidth parameters are identical in function to those + specified in enet_host_create(). +*/ +void +enet_host_bandwidth_limit (ENetHost * host, enet_uint32 incomingBandwidth, enet_uint32 outgoingBandwidth) +{ + host -> incomingBandwidth = incomingBandwidth; + host -> outgoingBandwidth = outgoingBandwidth; + host -> recalculateBandwidthLimits = 1; +} + +void +enet_host_bandwidth_throttle (ENetHost * host) +{ + enet_uint32 timeCurrent = enet_time_get (), + elapsedTime = timeCurrent - host -> bandwidthThrottleEpoch, + peersRemaining = (enet_uint32) host -> connectedPeers, + dataTotal = ~0, + bandwidth = ~0, + throttle = 0, + bandwidthLimit = 0; + int needsAdjustment = host -> bandwidthLimitedPeers > 0 ? 1 : 0; + ENetPeer * peer; + ENetProtocol command; + + if (elapsedTime < ENET_HOST_BANDWIDTH_THROTTLE_INTERVAL) + return; + + host -> bandwidthThrottleEpoch = timeCurrent; + + if (peersRemaining == 0) + return; + + if (host -> outgoingBandwidth != 0) + { + dataTotal = 0; + bandwidth = (host -> outgoingBandwidth * elapsedTime) / 1000; + + for (peer = host -> peers; + peer < & host -> peers [host -> peerCount]; + ++ peer) + { + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + continue; + + dataTotal += peer -> outgoingDataTotal; + } + } + + while (peersRemaining > 0 && needsAdjustment != 0) + { + needsAdjustment = 0; + + if (dataTotal <= bandwidth) + throttle = ENET_PEER_PACKET_THROTTLE_SCALE; + else + throttle = (bandwidth * ENET_PEER_PACKET_THROTTLE_SCALE) / dataTotal; + + for (peer = host -> peers; + peer < & host -> peers [host -> peerCount]; + ++ peer) + { + enet_uint32 peerBandwidth; + + if ((peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) || + peer -> incomingBandwidth == 0 || + peer -> outgoingBandwidthThrottleEpoch == timeCurrent) + continue; + + peerBandwidth = (peer -> incomingBandwidth * elapsedTime) / 1000; + if ((throttle * peer -> outgoingDataTotal) / ENET_PEER_PACKET_THROTTLE_SCALE <= peerBandwidth) + continue; + + peer -> packetThrottleLimit = (peerBandwidth * + ENET_PEER_PACKET_THROTTLE_SCALE) / peer -> outgoingDataTotal; + + if (peer -> packetThrottleLimit == 0) + peer -> packetThrottleLimit = 1; + + if (peer -> packetThrottle > peer -> packetThrottleLimit) + peer -> packetThrottle = peer -> packetThrottleLimit; + + peer -> outgoingBandwidthThrottleEpoch = timeCurrent; + + peer -> incomingDataTotal = 0; + peer -> outgoingDataTotal = 0; + + needsAdjustment = 1; + -- peersRemaining; + bandwidth -= peerBandwidth; + dataTotal -= peerBandwidth; + } + } + + if (peersRemaining > 0) + { + if (dataTotal <= bandwidth) + throttle = ENET_PEER_PACKET_THROTTLE_SCALE; + else + throttle = (bandwidth * ENET_PEER_PACKET_THROTTLE_SCALE) / dataTotal; + + for (peer = host -> peers; + peer < & host -> peers [host -> peerCount]; + ++ peer) + { + if ((peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) || + peer -> outgoingBandwidthThrottleEpoch == timeCurrent) + continue; + + peer -> packetThrottleLimit = throttle; + + if (peer -> packetThrottle > peer -> packetThrottleLimit) + peer -> packetThrottle = peer -> packetThrottleLimit; + + peer -> incomingDataTotal = 0; + peer -> outgoingDataTotal = 0; + } + } + + if (host -> recalculateBandwidthLimits) + { + host -> recalculateBandwidthLimits = 0; + + peersRemaining = (enet_uint32) host -> connectedPeers; + bandwidth = host -> incomingBandwidth; + needsAdjustment = 1; + + if (bandwidth == 0) + bandwidthLimit = 0; + else + while (peersRemaining > 0 && needsAdjustment != 0) + { + needsAdjustment = 0; + bandwidthLimit = bandwidth / peersRemaining; + + for (peer = host -> peers; + peer < & host -> peers [host -> peerCount]; + ++ peer) + { + if ((peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) || + peer -> incomingBandwidthThrottleEpoch == timeCurrent) + continue; + + if (peer -> outgoingBandwidth > 0 && + peer -> outgoingBandwidth >= bandwidthLimit) + continue; + + peer -> incomingBandwidthThrottleEpoch = timeCurrent; + + needsAdjustment = 1; + -- peersRemaining; + bandwidth -= peer -> outgoingBandwidth; + } + } + + for (peer = host -> peers; + peer < & host -> peers [host -> peerCount]; + ++ peer) + { + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + continue; + + command.header.command = ENET_PROTOCOL_COMMAND_BANDWIDTH_LIMIT | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + command.header.channelID = 0xFF; + command.bandwidthLimit.outgoingBandwidth = ENET_HOST_TO_NET_32 (host -> outgoingBandwidth); + + if (peer -> incomingBandwidthThrottleEpoch == timeCurrent) + command.bandwidthLimit.incomingBandwidth = ENET_HOST_TO_NET_32 (peer -> outgoingBandwidth); + else + command.bandwidthLimit.incomingBandwidth = ENET_HOST_TO_NET_32 (bandwidthLimit); + + enet_peer_queue_outgoing_command (peer, & command, NULL, 0, 0); + } + } +} + +/** @} */ diff --git a/third_party/enet/include/enet/callbacks.h b/third_party/enet/include/enet/callbacks.h new file mode 100644 index 0000000..340a4a9 --- /dev/null +++ b/third_party/enet/include/enet/callbacks.h @@ -0,0 +1,27 @@ +/** + @file callbacks.h + @brief ENet callbacks +*/ +#ifndef __ENET_CALLBACKS_H__ +#define __ENET_CALLBACKS_H__ + +#include + +typedef struct _ENetCallbacks +{ + void * (ENET_CALLBACK * malloc) (size_t size); + void (ENET_CALLBACK * free) (void * memory); + void (ENET_CALLBACK * no_memory) (void); +} ENetCallbacks; + +/** @defgroup callbacks ENet internal callbacks + @{ + @ingroup private +*/ +extern void * enet_malloc (size_t); +extern void enet_free (void *); + +/** @} */ + +#endif /* __ENET_CALLBACKS_H__ */ + diff --git a/third_party/enet/include/enet/enet.h b/third_party/enet/include/enet/enet.h new file mode 100644 index 0000000..dfe5475 --- /dev/null +++ b/third_party/enet/include/enet/enet.h @@ -0,0 +1,572 @@ +/** + @file enet.h + @brief ENet public header file +*/ +#ifndef __ENET_ENET_H__ +#define __ENET_ENET_H__ + +#ifdef __cplusplus +extern "C" +{ +#endif + +#include + +#ifdef _WIN32 +#include "enet/win32.h" +#else +#include "enet/unix.h" +#endif + +#include "enet/types.h" +#include "enet/protocol.h" +#include "enet/list.h" +#include "enet/callbacks.h" + +#define ENET_VERSION_MAJOR 1 +#define ENET_VERSION_MINOR 3 +#define ENET_VERSION_PATCH 17 +#define ENET_VERSION_CREATE(major, minor, patch) (((major)<<16) | ((minor)<<8) | (patch)) +#define ENET_VERSION_GET_MAJOR(version) (((version)>>16)&0xFF) +#define ENET_VERSION_GET_MINOR(version) (((version)>>8)&0xFF) +#define ENET_VERSION_GET_PATCH(version) ((version)&0xFF) +#define ENET_VERSION ENET_VERSION_CREATE(ENET_VERSION_MAJOR, ENET_VERSION_MINOR, ENET_VERSION_PATCH) + +typedef enet_uint32 ENetVersion; + +struct _ENetHost; +struct _ENetEvent; +struct _ENetPacket; + +typedef enum _ENetSocketType +{ + ENET_SOCKET_TYPE_STREAM = 1, + ENET_SOCKET_TYPE_DATAGRAM = 2 +} ENetSocketType; + +typedef enum _ENetSocketWait +{ + ENET_SOCKET_WAIT_NONE = 0, + ENET_SOCKET_WAIT_SEND = (1 << 0), + ENET_SOCKET_WAIT_RECEIVE = (1 << 1), + ENET_SOCKET_WAIT_INTERRUPT = (1 << 2) +} ENetSocketWait; + +typedef enum _ENetSocketOption +{ + ENET_SOCKOPT_NONBLOCK = 1, + ENET_SOCKOPT_RCVBUF, + ENET_SOCKOPT_SNDBUF, + ENET_SOCKOPT_REUSEADDR, + ENET_SOCKOPT_RCVTIMEO, + ENET_SOCKOPT_SNDTIMEO, + ENET_SOCKOPT_ERROR, + ENET_SOCKOPT_NODELAY, + ENET_SOCKOPT_QOS, +} ENetSocketOption; + +typedef enum _ENetSocketShutdown +{ + ENET_SOCKET_SHUTDOWN_READ = 0, + ENET_SOCKET_SHUTDOWN_WRITE = 1, + ENET_SOCKET_SHUTDOWN_READ_WRITE = 2 +} ENetSocketShutdown; + +/** + * Portable internet address structure. + */ +typedef struct _ENetAddress +{ + socklen_t addressLength; + struct sockaddr_storage address; +} ENetAddress; + +/** + * Packet flag bit constants. + * + * The host must be specified in network byte-order, and the port must be in + * host byte-order. The constant ENET_HOST_ANY may be used to specify the + * default server host. + + @sa ENetPacket +*/ +typedef enum _ENetPacketFlag +{ + /** packet must be received by the target peer and resend attempts should be + * made until the packet is delivered */ + ENET_PACKET_FLAG_RELIABLE = (1 << 0), + /** packet will not be sequenced with other packets + * not supported for reliable packets + */ + ENET_PACKET_FLAG_UNSEQUENCED = (1 << 1), + /** packet will not allocate data, and user must supply it instead */ + ENET_PACKET_FLAG_NO_ALLOCATE = (1 << 2), + /** packet will be fragmented using unreliable (instead of reliable) sends + * if it exceeds the MTU */ + ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT = (1 << 3), + + /** whether the packet has been sent from all queues it has been entered into */ + ENET_PACKET_FLAG_SENT = (1<<8) +} ENetPacketFlag; + +typedef void (ENET_CALLBACK * ENetPacketFreeCallback) (struct _ENetPacket *); + +/** + * ENet packet structure. + * + * An ENet data packet that may be sent to or received from a peer. The shown + * fields should only be read and never modified. The data field contains the + * allocated data for the packet. The dataLength fields specifies the length + * of the allocated data. The flags field is either 0 (specifying no flags), + * or a bitwise-or of any combination of the following flags: + * + * ENET_PACKET_FLAG_RELIABLE - packet must be received by the target peer + * and resend attempts should be made until the packet is delivered + * + * ENET_PACKET_FLAG_UNSEQUENCED - packet will not be sequenced with other packets + * (not supported for reliable packets) + * + * ENET_PACKET_FLAG_NO_ALLOCATE - packet will not allocate data, and user must supply it instead + * + * ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT - packet will be fragmented using unreliable + * (instead of reliable) sends if it exceeds the MTU + * + * ENET_PACKET_FLAG_SENT - whether the packet has been sent from all queues it has been entered into + @sa ENetPacketFlag + */ +typedef struct _ENetPacket +{ + size_t referenceCount; /**< internal use only */ + enet_uint32 flags; /**< bitwise-or of ENetPacketFlag constants */ + enet_uint8 * data; /**< allocated data for packet */ + size_t dataLength; /**< length of data */ + ENetPacketFreeCallback freeCallback; /**< function to be called when the packet is no longer in use */ + void * userData; /**< application private data, may be freely modified */ +} ENetPacket; + +typedef struct _ENetAcknowledgement +{ + ENetListNode acknowledgementList; + enet_uint32 sentTime; + ENetProtocol command; +} ENetAcknowledgement; + +typedef struct _ENetOutgoingCommand +{ + ENetListNode outgoingCommandList; + enet_uint16 reliableSequenceNumber; + enet_uint16 unreliableSequenceNumber; + enet_uint32 sentTime; + enet_uint32 roundTripTimeout; + enet_uint32 roundTripTimeoutLimit; + enet_uint32 fragmentOffset; + enet_uint16 fragmentLength; + enet_uint16 sendAttempts; + ENetProtocol command; + ENetPacket * packet; +} ENetOutgoingCommand; + +typedef struct _ENetIncomingCommand +{ + ENetListNode incomingCommandList; + enet_uint16 reliableSequenceNumber; + enet_uint16 unreliableSequenceNumber; + ENetProtocol command; + enet_uint32 fragmentCount; + enet_uint32 fragmentsRemaining; + enet_uint32 * fragments; + ENetPacket * packet; +} ENetIncomingCommand; + +typedef enum _ENetPeerState +{ + ENET_PEER_STATE_DISCONNECTED = 0, + ENET_PEER_STATE_CONNECTING = 1, + ENET_PEER_STATE_ACKNOWLEDGING_CONNECT = 2, + ENET_PEER_STATE_CONNECTION_PENDING = 3, + ENET_PEER_STATE_CONNECTION_SUCCEEDED = 4, + ENET_PEER_STATE_CONNECTED = 5, + ENET_PEER_STATE_DISCONNECT_LATER = 6, + ENET_PEER_STATE_DISCONNECTING = 7, + ENET_PEER_STATE_ACKNOWLEDGING_DISCONNECT = 8, + ENET_PEER_STATE_ZOMBIE = 9 +} ENetPeerState; + +#ifndef ENET_BUFFER_MAXIMUM +#define ENET_BUFFER_MAXIMUM (1 + 2 * ENET_PROTOCOL_MAXIMUM_PACKET_COMMANDS) +#endif + +enum +{ + ENET_HOST_RECEIVE_BUFFER_SIZE = 256 * 1024, + ENET_HOST_SEND_BUFFER_SIZE = 256 * 1024, + ENET_HOST_BANDWIDTH_THROTTLE_INTERVAL = 1000, + ENET_HOST_DEFAULT_MTU = 900, + ENET_HOST_DEFAULT_MAXIMUM_PACKET_SIZE = 32 * 1024 * 1024, + ENET_HOST_DEFAULT_MAXIMUM_WAITING_DATA = 32 * 1024 * 1024, + + ENET_PEER_DEFAULT_ROUND_TRIP_TIME = 500, + ENET_PEER_DEFAULT_PACKET_THROTTLE = 32, + ENET_PEER_PACKET_THROTTLE_SCALE = 32, + ENET_PEER_PACKET_THROTTLE_COUNTER = 7, + ENET_PEER_PACKET_THROTTLE_ACCELERATION = 2, + ENET_PEER_PACKET_THROTTLE_DECELERATION = 2, + ENET_PEER_PACKET_THROTTLE_INTERVAL = 5000, + ENET_PEER_PACKET_LOSS_SCALE = (1 << 16), + ENET_PEER_PACKET_LOSS_INTERVAL = 10000, + ENET_PEER_WINDOW_SIZE_SCALE = 64 * 1024, + ENET_PEER_TIMEOUT_LIMIT = 32, + ENET_PEER_TIMEOUT_MINIMUM = 5000, + ENET_PEER_TIMEOUT_MAXIMUM = 30000, + ENET_PEER_PING_INTERVAL = 500, + ENET_PEER_UNSEQUENCED_WINDOWS = 64, + ENET_PEER_UNSEQUENCED_WINDOW_SIZE = 1024, + ENET_PEER_FREE_UNSEQUENCED_WINDOWS = 32, + ENET_PEER_RELIABLE_WINDOWS = 16, + ENET_PEER_RELIABLE_WINDOW_SIZE = 0x1000, + ENET_PEER_FREE_RELIABLE_WINDOWS = 8 +}; + +typedef struct _ENetChannel +{ + enet_uint16 outgoingReliableSequenceNumber; + enet_uint16 outgoingUnreliableSequenceNumber; + enet_uint16 usedReliableWindows; + enet_uint16 reliableWindows [ENET_PEER_RELIABLE_WINDOWS]; + enet_uint16 incomingReliableSequenceNumber; + enet_uint16 incomingUnreliableSequenceNumber; + ENetList incomingReliableCommands; + ENetList incomingUnreliableCommands; +} ENetChannel; + +typedef enum _ENetPeerFlag +{ + ENET_PEER_FLAG_NEEDS_DISPATCH = (1 << 0) +} ENetPeerFlag; + +/** + * An ENet peer which data packets may be sent or received from. + * + * No fields should be modified unless otherwise specified. + */ +typedef struct _ENetPeer +{ + ENetListNode dispatchList; + struct _ENetHost * host; + enet_uint16 outgoingPeerID; + enet_uint16 incomingPeerID; + enet_uint32 connectID; + enet_uint8 outgoingSessionID; + enet_uint8 incomingSessionID; + ENetAddress address; /**< Internet address of the peer */ + void * data; /**< Application private data, may be freely modified */ + ENetPeerState state; + ENetChannel * channels; + size_t channelCount; /**< Number of channels allocated for communication with peer */ + enet_uint32 incomingBandwidth; /**< Downstream bandwidth of the client in bytes/second */ + enet_uint32 outgoingBandwidth; /**< Upstream bandwidth of the client in bytes/second */ + enet_uint32 incomingBandwidthThrottleEpoch; + enet_uint32 outgoingBandwidthThrottleEpoch; + enet_uint32 incomingDataTotal; + enet_uint32 outgoingDataTotal; + enet_uint32 lastSendTime; + enet_uint32 lastReceiveTime; + enet_uint32 nextTimeout; + enet_uint32 earliestTimeout; + enet_uint32 packetLossEpoch; + enet_uint32 packetsSent; + enet_uint32 packetsLost; + enet_uint32 packetLoss; /**< mean packet loss of reliable packets as a ratio with respect to the constant ENET_PEER_PACKET_LOSS_SCALE */ + enet_uint32 packetLossVariance; + enet_uint32 packetThrottle; + enet_uint32 packetThrottleLimit; + enet_uint32 packetThrottleCounter; + enet_uint32 packetThrottleEpoch; + enet_uint32 packetThrottleAcceleration; + enet_uint32 packetThrottleDeceleration; + enet_uint32 packetThrottleInterval; + enet_uint32 pingInterval; + enet_uint32 timeoutLimit; + enet_uint32 timeoutMinimum; + enet_uint32 timeoutMaximum; + enet_uint32 lastRoundTripTime; + enet_uint32 lowestRoundTripTime; + enet_uint32 lastRoundTripTimeVariance; + enet_uint32 highestRoundTripTimeVariance; + enet_uint32 roundTripTime; /**< mean round trip time (RTT), in milliseconds, between sending a reliable packet and receiving its acknowledgement */ + enet_uint32 roundTripTimeVariance; + enet_uint32 mtu; + enet_uint32 windowSize; + enet_uint32 reliableDataInTransit; + enet_uint16 outgoingReliableSequenceNumber; + ENetList acknowledgements; + ENetList sentReliableCommands; + ENetList sentUnreliableCommands; + ENetList outgoingCommands; + ENetList dispatchedCommands; + enet_uint16 flags; + enet_uint16 reserved; + enet_uint16 incomingUnsequencedGroup; + enet_uint16 outgoingUnsequencedGroup; + enet_uint32 unsequencedWindow [ENET_PEER_UNSEQUENCED_WINDOW_SIZE / 32]; + enet_uint32 eventData; + size_t totalWaitingData; +} ENetPeer; + +/** An ENet packet compressor for compressing UDP packets before socket sends or receives. + */ +typedef struct _ENetCompressor +{ + /** Context data for the compressor. Must be non-NULL. */ + void * context; + /** Compresses from inBuffers[0:inBufferCount-1], containing inLimit bytes, to outData, outputting at most outLimit bytes. Should return 0 on failure. */ + size_t (ENET_CALLBACK * compress) (void * context, const ENetBuffer * inBuffers, size_t inBufferCount, size_t inLimit, enet_uint8 * outData, size_t outLimit); + /** Decompresses from inData, containing inLimit bytes, to outData, outputting at most outLimit bytes. Should return 0 on failure. */ + size_t (ENET_CALLBACK * decompress) (void * context, const enet_uint8 * inData, size_t inLimit, enet_uint8 * outData, size_t outLimit); + /** Destroys the context when compression is disabled or the host is destroyed. May be NULL. */ + void (ENET_CALLBACK * destroy) (void * context); +} ENetCompressor; + +/** Callback that computes the checksum of the data held in buffers[0:bufferCount-1] */ +typedef enet_uint32 (ENET_CALLBACK * ENetChecksumCallback) (const ENetBuffer * buffers, size_t bufferCount); + +/** Callback for intercepting received raw UDP packets. Should return 1 to intercept, 0 to ignore, or -1 to propagate an error. */ +typedef int (ENET_CALLBACK * ENetInterceptCallback) (struct _ENetHost * host, struct _ENetEvent * event); + +/** An ENet host for communicating with peers. + * + * No fields should be modified unless otherwise stated. + + @sa enet_host_create() + @sa enet_host_destroy() + @sa enet_host_connect() + @sa enet_host_service() + @sa enet_host_flush() + @sa enet_host_compress() + @sa enet_host_compress_with_range_coder() + @sa enet_host_channel_limit() + @sa enet_host_bandwidth_limit() + @sa enet_host_bandwidth_throttle() + */ +typedef struct _ENetHost +{ + ENetSocket socket; + ENetAddress address; /**< Internet address of the host */ + enet_uint32 incomingBandwidth; /**< downstream bandwidth of the host */ + enet_uint32 outgoingBandwidth; /**< upstream bandwidth of the host */ + enet_uint32 bandwidthThrottleEpoch; + enet_uint32 mtu; + enet_uint32 randomSeed; + int recalculateBandwidthLimits; + ENetPeer * peers; /**< array of peers allocated for this host */ + size_t peerCount; /**< number of peers allocated for this host */ + size_t channelLimit; /**< maximum number of channels allowed for connected peers */ + enet_uint32 serviceTime; + ENetList dispatchQueue; + int continueSending; + size_t packetSize; + enet_uint16 headerFlags; + ENetProtocol commands [ENET_PROTOCOL_MAXIMUM_PACKET_COMMANDS]; + size_t commandCount; + ENetBuffer buffers [ENET_BUFFER_MAXIMUM]; + size_t bufferCount; + ENetChecksumCallback checksum; /**< callback the user can set to enable packet checksums for this host */ + ENetCompressor compressor; + enet_uint8 packetData [2][ENET_PROTOCOL_MAXIMUM_MTU]; + ENetAddress receivedAddress; + enet_uint8 * receivedData; + size_t receivedDataLength; + enet_uint32 totalSentData; /**< total data sent, user should reset to 0 as needed to prevent overflow */ + enet_uint32 totalSentPackets; /**< total UDP packets sent, user should reset to 0 as needed to prevent overflow */ + enet_uint32 totalReceivedData; /**< total data received, user should reset to 0 as needed to prevent overflow */ + enet_uint32 totalReceivedPackets; /**< total UDP packets received, user should reset to 0 as needed to prevent overflow */ + ENetInterceptCallback intercept; /**< callback the user can set to intercept received raw UDP packets */ + size_t connectedPeers; + size_t bandwidthLimitedPeers; + size_t duplicatePeers; /**< optional number of allowed peers from duplicate IPs, defaults to ENET_PROTOCOL_MAXIMUM_PEER_ID */ + size_t maximumPacketSize; /**< the maximum allowable packet size that may be sent or received on a peer */ + size_t maximumWaitingData; /**< the maximum aggregate amount of buffer space a peer may use waiting for packets to be delivered */ +} ENetHost; + +/** + * An ENet event type, as specified in @ref ENetEvent. + */ +typedef enum _ENetEventType +{ + /** no event occurred within the specified time limit */ + ENET_EVENT_TYPE_NONE = 0, + + /** a connection request initiated by enet_host_connect has completed. + * The peer field contains the peer which successfully connected. + */ + ENET_EVENT_TYPE_CONNECT = 1, + + /** a peer has disconnected. This event is generated on a successful + * completion of a disconnect initiated by enet_peer_disconnect, if + * a peer has timed out, or if a connection request intialized by + * enet_host_connect has timed out. The peer field contains the peer + * which disconnected. The data field contains user supplied data + * describing the disconnection, or 0, if none is available. + */ + ENET_EVENT_TYPE_DISCONNECT = 2, + + /** a packet has been received from a peer. The peer field specifies the + * peer which sent the packet. The channelID field specifies the channel + * number upon which the packet was received. The packet field contains + * the packet that was received; this packet must be destroyed with + * enet_packet_destroy after use. + */ + ENET_EVENT_TYPE_RECEIVE = 3 +} ENetEventType; + +/** + * An ENet event as returned by enet_host_service(). + + @sa enet_host_service + */ +typedef struct _ENetEvent +{ + ENetEventType type; /**< type of the event */ + ENetPeer * peer; /**< peer that generated a connect, disconnect or receive event */ + enet_uint8 channelID; /**< channel on the peer that generated the event, if appropriate */ + enet_uint32 data; /**< data associated with the event, if appropriate */ + ENetPacket * packet; /**< packet associated with the event, if appropriate */ +} ENetEvent; + +/** @defgroup global ENet global functions + @{ +*/ + +/** + Initializes ENet globally. Must be called prior to using any functions in + ENet. + @returns 0 on success, < 0 on failure +*/ +ENET_API int enet_initialize (void); + +/** + Initializes ENet globally and supplies user-overridden callbacks. Must be called prior to using any functions in ENet. Do not use enet_initialize() if you use this variant. Make sure the ENetCallbacks structure is zeroed out so that any additional callbacks added in future versions will be properly ignored. + + @param version the constant ENET_VERSION should be supplied so ENet knows which version of ENetCallbacks struct to use + @param inits user-overridden callbacks where any NULL callbacks will use ENet's defaults + @returns 0 on success, < 0 on failure +*/ +ENET_API int enet_initialize_with_callbacks (ENetVersion version, const ENetCallbacks * inits); + +/** + Shuts down ENet globally. Should be called when a program that has + initialized ENet exits. +*/ +ENET_API void enet_deinitialize (void); + +/** + Gives the linked version of the ENet library. + @returns the version number +*/ +ENET_API ENetVersion enet_linked_version (void); + +/** @} */ + +/** @defgroup private ENet private implementation functions */ + +/** + Returns the wall-time in milliseconds. Its initial value is unspecified + unless otherwise set. + */ +ENET_API enet_uint32 enet_time_get (void); +/** + Sets the current wall-time in milliseconds. + */ +ENET_API void enet_time_set (enet_uint32); + +/** @defgroup socket ENet socket functions + @{ +*/ +ENET_API ENetSocket enet_socket_create (int, ENetSocketType); +ENET_API int enet_socket_bind (ENetSocket, const ENetAddress *); +ENET_API int enet_socket_get_address (ENetSocket, ENetAddress *); +ENET_API int enet_socket_listen (ENetSocket, int); +ENET_API ENetSocket enet_socket_accept (ENetSocket, ENetAddress *); +ENET_API int enet_socket_connect (ENetSocket, const ENetAddress *); +ENET_API int enet_socket_send (ENetSocket, const ENetAddress *, const ENetBuffer *, size_t); +ENET_API int enet_socket_receive (ENetSocket, ENetAddress *, ENetBuffer *, size_t); +ENET_API int enet_socket_wait (ENetSocket, enet_uint32 *, enet_uint32); +ENET_API int enet_socket_set_option (ENetSocket, ENetSocketOption, int); +ENET_API int enet_socket_get_option (ENetSocket, ENetSocketOption, int *); +ENET_API int enet_socket_shutdown (ENetSocket, ENetSocketShutdown); +ENET_API void enet_socket_destroy (ENetSocket); +ENET_API int enet_socketset_select (ENetSocket, ENetSocketSet *, ENetSocketSet *, enet_uint32); + +/** @} */ + +/** @defgroup Address ENet address functions + @{ +*/ +/** Attempts to resolve the host named by the parameter hostName and sets + the host field in the address parameter if successful. + @param address destination to store resolved address + @param hostName host name to lookup + @retval 0 on success + @retval < 0 on failure + @returns the address of the given hostName in address on success +*/ +ENET_API int enet_address_set_host (ENetAddress * address, const char * hostName); +ENET_API int enet_address_set_address (ENetAddress * address, struct sockaddr * addr, socklen_t addrlen); +ENET_API int enet_address_set_port (ENetAddress * address, enet_uint16 port); +ENET_API int enet_address_equal (ENetAddress * address1, ENetAddress * address2); + +/** @} */ + +ENET_API ENetPacket * enet_packet_create (const void *, size_t, enet_uint32); +ENET_API void enet_packet_destroy (ENetPacket *); +ENET_API int enet_packet_resize (ENetPacket *, size_t); +ENET_API enet_uint32 enet_crc32 (const ENetBuffer *, size_t); + +ENET_API ENetHost * enet_host_create (int, const ENetAddress *, size_t, size_t, enet_uint32, enet_uint32); +ENET_API void enet_host_destroy (ENetHost *); +ENET_API ENetPeer * enet_host_connect (ENetHost *, const ENetAddress *, size_t, enet_uint32); +ENET_API int enet_host_check_events (ENetHost *, ENetEvent *); +ENET_API int enet_host_service (ENetHost *, ENetEvent *, enet_uint32); +ENET_API void enet_host_flush (ENetHost *); +ENET_API void enet_host_compress (ENetHost *, const ENetCompressor *); +ENET_API int enet_host_compress_with_range_coder (ENetHost * host); +ENET_API void enet_host_channel_limit (ENetHost *, size_t); +ENET_API void enet_host_bandwidth_limit (ENetHost *, enet_uint32, enet_uint32); +extern void enet_host_bandwidth_throttle (ENetHost *); +extern enet_uint32 enet_host_random_seed (void); +extern enet_uint32 enet_host_random (ENetHost *); + +ENET_API int enet_peer_send (ENetPeer *, enet_uint8, ENetPacket *); +ENET_API ENetPacket * enet_peer_receive (ENetPeer *, enet_uint8 * channelID); +ENET_API void enet_peer_ping (ENetPeer *); +ENET_API void enet_peer_ping_interval (ENetPeer *, enet_uint32); +ENET_API void enet_peer_timeout (ENetPeer *, enet_uint32, enet_uint32, enet_uint32); +ENET_API void enet_peer_reset (ENetPeer *); +ENET_API void enet_peer_disconnect (ENetPeer *, enet_uint32); +ENET_API void enet_peer_disconnect_now (ENetPeer *, enet_uint32); +ENET_API void enet_peer_disconnect_later (ENetPeer *, enet_uint32); +ENET_API void enet_peer_throttle_configure (ENetPeer *, enet_uint32, enet_uint32, enet_uint32); +extern int enet_peer_throttle (ENetPeer *, enet_uint32); +extern void enet_peer_reset_queues (ENetPeer *); +extern void enet_peer_setup_outgoing_command (ENetPeer *, ENetOutgoingCommand *); +extern ENetOutgoingCommand * enet_peer_queue_outgoing_command (ENetPeer *, const ENetProtocol *, ENetPacket *, enet_uint32, enet_uint16); +extern ENetIncomingCommand * enet_peer_queue_incoming_command (ENetPeer *, const ENetProtocol *, const void *, size_t, enet_uint32, enet_uint32); +extern ENetAcknowledgement * enet_peer_queue_acknowledgement (ENetPeer *, const ENetProtocol *, enet_uint16); +extern void enet_peer_dispatch_incoming_unreliable_commands (ENetPeer *, ENetChannel *, ENetIncomingCommand *); +extern void enet_peer_dispatch_incoming_reliable_commands (ENetPeer *, ENetChannel *, ENetIncomingCommand *); +extern void enet_peer_on_connect (ENetPeer *); +extern void enet_peer_on_disconnect (ENetPeer *); + +ENET_API void * enet_range_coder_create (void); +ENET_API void enet_range_coder_destroy (void *); +ENET_API size_t enet_range_coder_compress (void *, const ENetBuffer *, size_t, size_t, enet_uint8 *, size_t); +ENET_API size_t enet_range_coder_decompress (void *, const enet_uint8 *, size_t, enet_uint8 *, size_t); + +extern size_t enet_protocol_command_size (enet_uint8); + +#ifdef __cplusplus +} +#endif + +#endif /* __ENET_ENET_H__ */ + diff --git a/third_party/enet/include/enet/list.h b/third_party/enet/include/enet/list.h new file mode 100644 index 0000000..d7b2600 --- /dev/null +++ b/third_party/enet/include/enet/list.h @@ -0,0 +1,43 @@ +/** + @file list.h + @brief ENet list management +*/ +#ifndef __ENET_LIST_H__ +#define __ENET_LIST_H__ + +#include + +typedef struct _ENetListNode +{ + struct _ENetListNode * next; + struct _ENetListNode * previous; +} ENetListNode; + +typedef ENetListNode * ENetListIterator; + +typedef struct _ENetList +{ + ENetListNode sentinel; +} ENetList; + +extern void enet_list_clear (ENetList *); + +extern ENetListIterator enet_list_insert (ENetListIterator, void *); +extern void * enet_list_remove (ENetListIterator); +extern ENetListIterator enet_list_move (ENetListIterator, void *, void *); + +extern size_t enet_list_size (ENetList *); + +#define enet_list_begin(list) ((list) -> sentinel.next) +#define enet_list_end(list) (& (list) -> sentinel) + +#define enet_list_empty(list) (enet_list_begin (list) == enet_list_end (list)) + +#define enet_list_next(iterator) ((iterator) -> next) +#define enet_list_previous(iterator) ((iterator) -> previous) + +#define enet_list_front(list) ((void *) (list) -> sentinel.next) +#define enet_list_back(list) ((void *) (list) -> sentinel.previous) + +#endif /* __ENET_LIST_H__ */ + diff --git a/third_party/enet/include/enet/protocol.h b/third_party/enet/include/enet/protocol.h new file mode 100644 index 0000000..b3c8b03 --- /dev/null +++ b/third_party/enet/include/enet/protocol.h @@ -0,0 +1,202 @@ +/** + @file protocol.h + @brief ENet protocol +*/ +#ifndef __ENET_PROTOCOL_H__ +#define __ENET_PROTOCOL_H__ + +#include "enet/types.h" + +enum +{ + ENET_PROTOCOL_MINIMUM_MTU = 576, +#ifdef __WIIU__ + ENET_PROTOCOL_MAXIMUM_MTU = 1400, +#else + ENET_PROTOCOL_MAXIMUM_MTU = 4096, +#endif + ENET_PROTOCOL_MAXIMUM_PACKET_COMMANDS = 32, + ENET_PROTOCOL_MINIMUM_WINDOW_SIZE = 4096, + ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE = 65536, + ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT = 1, + ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT = 255, + ENET_PROTOCOL_MAXIMUM_PEER_ID = 0xFFF, + ENET_PROTOCOL_MAXIMUM_FRAGMENT_COUNT = 1024 * 1024 +}; + +typedef enum _ENetProtocolCommand +{ + ENET_PROTOCOL_COMMAND_NONE = 0, + ENET_PROTOCOL_COMMAND_ACKNOWLEDGE = 1, + ENET_PROTOCOL_COMMAND_CONNECT = 2, + ENET_PROTOCOL_COMMAND_VERIFY_CONNECT = 3, + ENET_PROTOCOL_COMMAND_DISCONNECT = 4, + ENET_PROTOCOL_COMMAND_PING = 5, + ENET_PROTOCOL_COMMAND_SEND_RELIABLE = 6, + ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE = 7, + ENET_PROTOCOL_COMMAND_SEND_FRAGMENT = 8, + ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED = 9, + ENET_PROTOCOL_COMMAND_BANDWIDTH_LIMIT = 10, + ENET_PROTOCOL_COMMAND_THROTTLE_CONFIGURE = 11, + ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE_FRAGMENT = 12, + ENET_PROTOCOL_COMMAND_COUNT = 13, + + ENET_PROTOCOL_COMMAND_MASK = 0x0F +} ENetProtocolCommand; + +typedef enum _ENetProtocolFlag +{ + ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE = (1 << 7), + ENET_PROTOCOL_COMMAND_FLAG_UNSEQUENCED = (1 << 6), + + ENET_PROTOCOL_HEADER_FLAG_COMPRESSED = (1 << 14), + ENET_PROTOCOL_HEADER_FLAG_SENT_TIME = (1 << 15), + ENET_PROTOCOL_HEADER_FLAG_MASK = ENET_PROTOCOL_HEADER_FLAG_COMPRESSED | ENET_PROTOCOL_HEADER_FLAG_SENT_TIME, + + ENET_PROTOCOL_HEADER_SESSION_MASK = (3 << 12), + ENET_PROTOCOL_HEADER_SESSION_SHIFT = 12 +} ENetProtocolFlag; + +#ifdef _MSC_VER +#pragma pack(push, 1) +#define ENET_PACKED +#elif defined(__GNUC__) || defined(__clang__) +#define ENET_PACKED __attribute__ ((packed)) +#else +#define ENET_PACKED +#endif + +typedef struct _ENetProtocolHeader +{ + enet_uint16 peerID; + enet_uint16 sentTime; +} ENET_PACKED ENetProtocolHeader; + +typedef struct _ENetProtocolCommandHeader +{ + enet_uint8 command; + enet_uint8 channelID; + enet_uint16 reliableSequenceNumber; +} ENET_PACKED ENetProtocolCommandHeader; + +typedef struct _ENetProtocolAcknowledge +{ + ENetProtocolCommandHeader header; + enet_uint16 receivedReliableSequenceNumber; + enet_uint16 receivedSentTime; +} ENET_PACKED ENetProtocolAcknowledge; + +typedef struct _ENetProtocolConnect +{ + ENetProtocolCommandHeader header; + enet_uint16 outgoingPeerID; + enet_uint8 incomingSessionID; + enet_uint8 outgoingSessionID; + enet_uint32 mtu; + enet_uint32 windowSize; + enet_uint32 channelCount; + enet_uint32 incomingBandwidth; + enet_uint32 outgoingBandwidth; + enet_uint32 packetThrottleInterval; + enet_uint32 packetThrottleAcceleration; + enet_uint32 packetThrottleDeceleration; + enet_uint32 connectID; + enet_uint32 data; +} ENET_PACKED ENetProtocolConnect; + +typedef struct _ENetProtocolVerifyConnect +{ + ENetProtocolCommandHeader header; + enet_uint16 outgoingPeerID; + enet_uint8 incomingSessionID; + enet_uint8 outgoingSessionID; + enet_uint32 mtu; + enet_uint32 windowSize; + enet_uint32 channelCount; + enet_uint32 incomingBandwidth; + enet_uint32 outgoingBandwidth; + enet_uint32 packetThrottleInterval; + enet_uint32 packetThrottleAcceleration; + enet_uint32 packetThrottleDeceleration; + enet_uint32 connectID; +} ENET_PACKED ENetProtocolVerifyConnect; + +typedef struct _ENetProtocolBandwidthLimit +{ + ENetProtocolCommandHeader header; + enet_uint32 incomingBandwidth; + enet_uint32 outgoingBandwidth; +} ENET_PACKED ENetProtocolBandwidthLimit; + +typedef struct _ENetProtocolThrottleConfigure +{ + ENetProtocolCommandHeader header; + enet_uint32 packetThrottleInterval; + enet_uint32 packetThrottleAcceleration; + enet_uint32 packetThrottleDeceleration; +} ENET_PACKED ENetProtocolThrottleConfigure; + +typedef struct _ENetProtocolDisconnect +{ + ENetProtocolCommandHeader header; + enet_uint32 data; +} ENET_PACKED ENetProtocolDisconnect; + +typedef struct _ENetProtocolPing +{ + ENetProtocolCommandHeader header; +} ENET_PACKED ENetProtocolPing; + +typedef struct _ENetProtocolSendReliable +{ + ENetProtocolCommandHeader header; + enet_uint16 dataLength; +} ENET_PACKED ENetProtocolSendReliable; + +typedef struct _ENetProtocolSendUnreliable +{ + ENetProtocolCommandHeader header; + enet_uint16 unreliableSequenceNumber; + enet_uint16 dataLength; +} ENET_PACKED ENetProtocolSendUnreliable; + +typedef struct _ENetProtocolSendUnsequenced +{ + ENetProtocolCommandHeader header; + enet_uint16 unsequencedGroup; + enet_uint16 dataLength; +} ENET_PACKED ENetProtocolSendUnsequenced; + +typedef struct _ENetProtocolSendFragment +{ + ENetProtocolCommandHeader header; + enet_uint16 startSequenceNumber; + enet_uint16 dataLength; + enet_uint32 fragmentCount; + enet_uint32 fragmentNumber; + enet_uint32 totalLength; + enet_uint32 fragmentOffset; +} ENET_PACKED ENetProtocolSendFragment; + +typedef union _ENetProtocol +{ + ENetProtocolCommandHeader header; + ENetProtocolAcknowledge acknowledge; + ENetProtocolConnect connect; + ENetProtocolVerifyConnect verifyConnect; + ENetProtocolDisconnect disconnect; + ENetProtocolPing ping; + ENetProtocolSendReliable sendReliable; + ENetProtocolSendUnreliable sendUnreliable; + ENetProtocolSendUnsequenced sendUnsequenced; + ENetProtocolSendFragment sendFragment; + ENetProtocolBandwidthLimit bandwidthLimit; + ENetProtocolThrottleConfigure throttleConfigure; +} ENET_PACKED ENetProtocol; + +#ifdef _MSC_VER +#pragma pack(pop) +#endif + +#endif /* __ENET_PROTOCOL_H__ */ + diff --git a/third_party/enet/include/enet/time.h b/third_party/enet/include/enet/time.h new file mode 100644 index 0000000..c82a546 --- /dev/null +++ b/third_party/enet/include/enet/time.h @@ -0,0 +1,18 @@ +/** + @file time.h + @brief ENet time constants and macros +*/ +#ifndef __ENET_TIME_H__ +#define __ENET_TIME_H__ + +#define ENET_TIME_OVERFLOW 86400000 + +#define ENET_TIME_LESS(a, b) ((a) - (b) >= ENET_TIME_OVERFLOW) +#define ENET_TIME_GREATER(a, b) ((b) - (a) >= ENET_TIME_OVERFLOW) +#define ENET_TIME_LESS_EQUAL(a, b) (! ENET_TIME_GREATER (a, b)) +#define ENET_TIME_GREATER_EQUAL(a, b) (! ENET_TIME_LESS (a, b)) + +#define ENET_TIME_DIFFERENCE(a, b) ((a) - (b) >= ENET_TIME_OVERFLOW ? (b) - (a) : (a) - (b)) + +#endif /* __ENET_TIME_H__ */ + diff --git a/third_party/enet/include/enet/types.h b/third_party/enet/include/enet/types.h new file mode 100644 index 0000000..ab010a4 --- /dev/null +++ b/third_party/enet/include/enet/types.h @@ -0,0 +1,13 @@ +/** + @file types.h + @brief type definitions for ENet +*/ +#ifndef __ENET_TYPES_H__ +#define __ENET_TYPES_H__ + +typedef unsigned char enet_uint8; /**< unsigned 8-bit type */ +typedef unsigned short enet_uint16; /**< unsigned 16-bit type */ +typedef unsigned int enet_uint32; /**< unsigned 32-bit type */ + +#endif /* __ENET_TYPES_H__ */ + diff --git a/third_party/enet/include/enet/unix.h b/third_party/enet/include/enet/unix.h new file mode 100644 index 0000000..b55be33 --- /dev/null +++ b/third_party/enet/include/enet/unix.h @@ -0,0 +1,48 @@ +/** + @file unix.h + @brief ENet Unix header +*/ +#ifndef __ENET_UNIX_H__ +#define __ENET_UNIX_H__ + +#include +#include +#include +#include +#include +#include +#include + +#ifdef MSG_MAXIOVLEN +#define ENET_BUFFER_MAXIMUM MSG_MAXIOVLEN +#endif + +typedef int ENetSocket; + +#define ENET_SOCKET_NULL -1 + +#define ENET_HOST_TO_NET_16(value) (htons (value)) /**< macro that converts host to net byte-order of a 16-bit value */ +#define ENET_HOST_TO_NET_32(value) (htonl (value)) /**< macro that converts host to net byte-order of a 32-bit value */ + +#define ENET_NET_TO_HOST_16(value) (ntohs (value)) /**< macro that converts net to host byte-order of a 16-bit value */ +#define ENET_NET_TO_HOST_32(value) (ntohl (value)) /**< macro that converts net to host byte-order of a 32-bit value */ + +typedef struct +{ + void * data; + size_t dataLength; +} ENetBuffer; + +#define ENET_CALLBACK + +#define ENET_API extern + +typedef fd_set ENetSocketSet; + +#define ENET_SOCKETSET_EMPTY(sockset) FD_ZERO (& (sockset)) +#define ENET_SOCKETSET_ADD(sockset, socket) FD_SET (socket, & (sockset)) +#define ENET_SOCKETSET_REMOVE(sockset, socket) FD_CLR (socket, & (sockset)) +#define ENET_SOCKETSET_CHECK(sockset, socket) FD_ISSET (socket, & (sockset)) + +#endif /* __ENET_UNIX_H__ */ + diff --git a/third_party/enet/include/enet/utility.h b/third_party/enet/include/enet/utility.h new file mode 100644 index 0000000..b04bb7a --- /dev/null +++ b/third_party/enet/include/enet/utility.h @@ -0,0 +1,13 @@ +/** + @file utility.h + @brief ENet utility header +*/ +#ifndef __ENET_UTILITY_H__ +#define __ENET_UTILITY_H__ + +#define ENET_MAX(x, y) ((x) > (y) ? (x) : (y)) +#define ENET_MIN(x, y) ((x) < (y) ? (x) : (y)) +#define ENET_DIFFERENCE(x, y) ((x) < (y) ? (y) - (x) : (x) - (y)) + +#endif /* __ENET_UTILITY_H__ */ + diff --git a/third_party/enet/include/enet/win32.h b/third_party/enet/include/enet/win32.h new file mode 100644 index 0000000..5810a2e --- /dev/null +++ b/third_party/enet/include/enet/win32.h @@ -0,0 +1,60 @@ +/** + @file win32.h + @brief ENet Win32 header +*/ +#ifndef __ENET_WIN32_H__ +#define __ENET_WIN32_H__ + +#ifdef _MSC_VER +#ifdef ENET_BUILDING_LIB +#pragma warning (disable: 4267) // size_t to int conversion +#pragma warning (disable: 4244) // 64bit to 32bit int +#pragma warning (disable: 4018) // signed/unsigned mismatch +#pragma warning (disable: 4146) // unary minus operator applied to unsigned type +#define _CRT_SECURE_NO_DEPRECATE +#define _CRT_SECURE_NO_WARNINGS +#endif +#endif + +#include +#include +#include + +typedef SOCKET ENetSocket; + +#define ENET_SOCKET_NULL INVALID_SOCKET + +#define ENET_HOST_TO_NET_16(value) (htons (value)) +#define ENET_HOST_TO_NET_32(value) (htonl (value)) + +#define ENET_NET_TO_HOST_16(value) (ntohs (value)) +#define ENET_NET_TO_HOST_32(value) (ntohl (value)) + +typedef struct +{ + size_t dataLength; + void * data; +} ENetBuffer; + +#define ENET_CALLBACK __cdecl + +#ifdef ENET_DLL +#ifdef ENET_BUILDING_LIB +#define ENET_API __declspec( dllexport ) +#else +#define ENET_API __declspec( dllimport ) +#endif /* ENET_BUILDING_LIB */ +#else /* !ENET_DLL */ +#define ENET_API extern +#endif /* ENET_DLL */ + +typedef fd_set ENetSocketSet; + +#define ENET_SOCKETSET_EMPTY(sockset) FD_ZERO (& (sockset)) +#define ENET_SOCKETSET_ADD(sockset, socket) FD_SET (socket, & (sockset)) +#define ENET_SOCKETSET_REMOVE(sockset, socket) FD_CLR (socket, & (sockset)) +#define ENET_SOCKETSET_CHECK(sockset, socket) FD_ISSET (socket, & (sockset)) + +#endif /* __ENET_WIN32_H__ */ + + diff --git a/third_party/enet/list.c b/third_party/enet/list.c new file mode 100644 index 0000000..1c1a8df --- /dev/null +++ b/third_party/enet/list.c @@ -0,0 +1,75 @@ +/** + @file list.c + @brief ENet linked list functions +*/ +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" + +/** + @defgroup list ENet linked list utility functions + @ingroup private + @{ +*/ +void +enet_list_clear (ENetList * list) +{ + list -> sentinel.next = & list -> sentinel; + list -> sentinel.previous = & list -> sentinel; +} + +ENetListIterator +enet_list_insert (ENetListIterator position, void * data) +{ + ENetListIterator result = (ENetListIterator) data; + + result -> previous = position -> previous; + result -> next = position; + + result -> previous -> next = result; + position -> previous = result; + + return result; +} + +void * +enet_list_remove (ENetListIterator position) +{ + position -> previous -> next = position -> next; + position -> next -> previous = position -> previous; + + return position; +} + +ENetListIterator +enet_list_move (ENetListIterator position, void * dataFirst, void * dataLast) +{ + ENetListIterator first = (ENetListIterator) dataFirst, + last = (ENetListIterator) dataLast; + + first -> previous -> next = last -> next; + last -> next -> previous = first -> previous; + + first -> previous = position -> previous; + last -> next = position; + + first -> previous -> next = first; + position -> previous = last; + + return first; +} + +size_t +enet_list_size (ENetList * list) +{ + size_t size = 0; + ENetListIterator position; + + for (position = enet_list_begin (list); + position != enet_list_end (list); + position = enet_list_next (position)) + ++ size; + + return size; +} + +/** @} */ diff --git a/third_party/enet/packet.c b/third_party/enet/packet.c new file mode 100644 index 0000000..5fa78b2 --- /dev/null +++ b/third_party/enet/packet.c @@ -0,0 +1,165 @@ +/** + @file packet.c + @brief ENet packet management functions +*/ +#include +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" + +/** @defgroup Packet ENet packet functions + @{ +*/ + +/** Creates a packet that may be sent to a peer. + @param data initial contents of the packet's data; the packet's data will remain uninitialized if data is NULL. + @param dataLength size of the data allocated for this packet + @param flags flags for this packet as described for the ENetPacket structure. + @returns the packet on success, NULL on failure +*/ +ENetPacket * +enet_packet_create (const void * data, size_t dataLength, enet_uint32 flags) +{ + ENetPacket * packet = (ENetPacket *) enet_malloc (sizeof (ENetPacket)); + if (packet == NULL) + return NULL; + + if (flags & ENET_PACKET_FLAG_NO_ALLOCATE) + packet -> data = (enet_uint8 *) data; + else + if (dataLength <= 0) + packet -> data = NULL; + else + { + packet -> data = (enet_uint8 *) enet_malloc (dataLength); + if (packet -> data == NULL) + { + enet_free (packet); + return NULL; + } + + if (data != NULL) + memcpy (packet -> data, data, dataLength); + } + + packet -> referenceCount = 0; + packet -> flags = flags; + packet -> dataLength = dataLength; + packet -> freeCallback = NULL; + packet -> userData = NULL; + + return packet; +} + +/** Destroys the packet and deallocates its data. + @param packet packet to be destroyed +*/ +void +enet_packet_destroy (ENetPacket * packet) +{ + if (packet == NULL) + return; + + if (packet -> freeCallback != NULL) + (* packet -> freeCallback) (packet); + if (! (packet -> flags & ENET_PACKET_FLAG_NO_ALLOCATE) && + packet -> data != NULL) + enet_free (packet -> data); + enet_free (packet); +} + +/** Attempts to resize the data in the packet to length specified in the + dataLength parameter + @param packet packet to resize + @param dataLength new size for the packet data + @returns 0 on success, < 0 on failure +*/ +int +enet_packet_resize (ENetPacket * packet, size_t dataLength) +{ + enet_uint8 * newData; + + if (dataLength <= packet -> dataLength || (packet -> flags & ENET_PACKET_FLAG_NO_ALLOCATE)) + { + packet -> dataLength = dataLength; + + return 0; + } + + newData = (enet_uint8 *) enet_malloc (dataLength); + if (newData == NULL) + return -1; + + memcpy (newData, packet -> data, packet -> dataLength); + enet_free (packet -> data); + + packet -> data = newData; + packet -> dataLength = dataLength; + + return 0; +} + +static int initializedCRC32 = 0; +static enet_uint32 crcTable [256]; + +static enet_uint32 +reflect_crc (int val, int bits) +{ + int result = 0, bit; + + for (bit = 0; bit < bits; bit ++) + { + if(val & 1) result |= 1 << (bits - 1 - bit); + val >>= 1; + } + + return result; +} + +static void +initialize_crc32 (void) +{ + int byte; + + for (byte = 0; byte < 256; ++ byte) + { + enet_uint32 crc = reflect_crc (byte, 8) << 24; + int offset; + + for(offset = 0; offset < 8; ++ offset) + { + if (crc & 0x80000000) + crc = (crc << 1) ^ 0x04c11db7; + else + crc <<= 1; + } + + crcTable [byte] = reflect_crc (crc, 32); + } + + initializedCRC32 = 1; +} + +enet_uint32 +enet_crc32 (const ENetBuffer * buffers, size_t bufferCount) +{ + enet_uint32 crc = 0xFFFFFFFF; + + if (! initializedCRC32) initialize_crc32 (); + + while (bufferCount -- > 0) + { + const enet_uint8 * data = (const enet_uint8 *) buffers -> data, + * dataEnd = & data [buffers -> dataLength]; + + while (data < dataEnd) + { + crc = (crc >> 8) ^ crcTable [(crc & 0xFF) ^ *data++]; + } + + ++ buffers; + } + + return ENET_HOST_TO_NET_32 (~ crc); +} + +/** @} */ diff --git a/third_party/enet/peer.c b/third_party/enet/peer.c new file mode 100644 index 0000000..32f9809 --- /dev/null +++ b/third_party/enet/peer.c @@ -0,0 +1,1004 @@ +/** + @file peer.c + @brief ENet peer management functions +*/ +#include +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" + +/** @defgroup peer ENet peer functions + @{ +*/ + +/** Configures throttle parameter for a peer. + + Unreliable packets are dropped by ENet in response to the varying conditions + of the Internet connection to the peer. The throttle represents a probability + that an unreliable packet should not be dropped and thus sent by ENet to the peer. + The lowest mean round trip time from the sending of a reliable packet to the + receipt of its acknowledgement is measured over an amount of time specified by + the interval parameter in milliseconds. If a measured round trip time happens to + be significantly less than the mean round trip time measured over the interval, + then the throttle probability is increased to allow more traffic by an amount + specified in the acceleration parameter, which is a ratio to the ENET_PEER_PACKET_THROTTLE_SCALE + constant. If a measured round trip time happens to be significantly greater than + the mean round trip time measured over the interval, then the throttle probability + is decreased to limit traffic by an amount specified in the deceleration parameter, which + is a ratio to the ENET_PEER_PACKET_THROTTLE_SCALE constant. When the throttle has + a value of ENET_PEER_PACKET_THROTTLE_SCALE, no unreliable packets are dropped by + ENet, and so 100% of all unreliable packets will be sent. When the throttle has a + value of 0, all unreliable packets are dropped by ENet, and so 0% of all unreliable + packets will be sent. Intermediate values for the throttle represent intermediate + probabilities between 0% and 100% of unreliable packets being sent. The bandwidth + limits of the local and foreign hosts are taken into account to determine a + sensible limit for the throttle probability above which it should not raise even in + the best of conditions. + + @param peer peer to configure + @param interval interval, in milliseconds, over which to measure lowest mean RTT; the default value is ENET_PEER_PACKET_THROTTLE_INTERVAL. + @param acceleration rate at which to increase the throttle probability as mean RTT declines + @param deceleration rate at which to decrease the throttle probability as mean RTT increases +*/ +void +enet_peer_throttle_configure (ENetPeer * peer, enet_uint32 interval, enet_uint32 acceleration, enet_uint32 deceleration) +{ + ENetProtocol command; + + peer -> packetThrottleInterval = interval; + peer -> packetThrottleAcceleration = acceleration; + peer -> packetThrottleDeceleration = deceleration; + + command.header.command = ENET_PROTOCOL_COMMAND_THROTTLE_CONFIGURE | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + command.header.channelID = 0xFF; + + command.throttleConfigure.packetThrottleInterval = ENET_HOST_TO_NET_32 (interval); + command.throttleConfigure.packetThrottleAcceleration = ENET_HOST_TO_NET_32 (acceleration); + command.throttleConfigure.packetThrottleDeceleration = ENET_HOST_TO_NET_32 (deceleration); + + enet_peer_queue_outgoing_command (peer, & command, NULL, 0, 0); +} + +int +enet_peer_throttle (ENetPeer * peer, enet_uint32 rtt) +{ + if (peer -> lastRoundTripTime <= peer -> lastRoundTripTimeVariance) + { + peer -> packetThrottle = peer -> packetThrottleLimit; + } + else + if (rtt <= peer -> lastRoundTripTime) + { + peer -> packetThrottle += peer -> packetThrottleAcceleration; + + if (peer -> packetThrottle > peer -> packetThrottleLimit) + peer -> packetThrottle = peer -> packetThrottleLimit; + + return 1; + } + else + if (rtt > peer -> lastRoundTripTime + 2 * peer -> lastRoundTripTimeVariance) + { + if (peer -> packetThrottle > peer -> packetThrottleDeceleration) + peer -> packetThrottle -= peer -> packetThrottleDeceleration; + else + peer -> packetThrottle = 0; + + return -1; + } + + return 0; +} + +/** Queues a packet to be sent. + @param peer destination for the packet + @param channelID channel on which to send + @param packet packet to send + @retval 0 on success + @retval < 0 on failure +*/ +int +enet_peer_send (ENetPeer * peer, enet_uint8 channelID, ENetPacket * packet) +{ + ENetChannel * channel; + ENetProtocol command; + size_t fragmentLength; + + if (peer -> state != ENET_PEER_STATE_CONNECTED || + channelID >= peer -> channelCount || + packet -> dataLength > peer -> host -> maximumPacketSize) + return -1; + + channel = & peer -> channels [channelID]; + fragmentLength = peer -> mtu - sizeof (ENetProtocolHeader) - sizeof (ENetProtocolSendFragment); + if (peer -> host -> checksum != NULL) + fragmentLength -= sizeof(enet_uint32); + + if (packet -> dataLength > fragmentLength) + { + enet_uint32 fragmentCount = (packet -> dataLength + fragmentLength - 1) / fragmentLength, + fragmentNumber, + fragmentOffset; + enet_uint8 commandNumber; + enet_uint16 startSequenceNumber; + ENetList fragments; + ENetOutgoingCommand * fragment; + + if (fragmentCount > ENET_PROTOCOL_MAXIMUM_FRAGMENT_COUNT) + return -1; + + if ((packet -> flags & (ENET_PACKET_FLAG_RELIABLE | ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT)) == ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT && + channel -> outgoingUnreliableSequenceNumber < 0xFFFF) + { + commandNumber = ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE_FRAGMENT; + startSequenceNumber = ENET_HOST_TO_NET_16 (channel -> outgoingUnreliableSequenceNumber + 1); + } + else + { + commandNumber = ENET_PROTOCOL_COMMAND_SEND_FRAGMENT | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + startSequenceNumber = ENET_HOST_TO_NET_16 (channel -> outgoingReliableSequenceNumber + 1); + } + + enet_list_clear (& fragments); + + for (fragmentNumber = 0, + fragmentOffset = 0; + fragmentOffset < packet -> dataLength; + ++ fragmentNumber, + fragmentOffset += fragmentLength) + { + if (packet -> dataLength - fragmentOffset < fragmentLength) + fragmentLength = packet -> dataLength - fragmentOffset; + + fragment = (ENetOutgoingCommand *) enet_malloc (sizeof (ENetOutgoingCommand)); + if (fragment == NULL) + { + while (! enet_list_empty (& fragments)) + { + fragment = (ENetOutgoingCommand *) enet_list_remove (enet_list_begin (& fragments)); + + enet_free (fragment); + } + + return -1; + } + + fragment -> fragmentOffset = fragmentOffset; + fragment -> fragmentLength = fragmentLength; + fragment -> packet = packet; + fragment -> command.header.command = commandNumber; + fragment -> command.header.channelID = channelID; + fragment -> command.sendFragment.startSequenceNumber = startSequenceNumber; + fragment -> command.sendFragment.dataLength = ENET_HOST_TO_NET_16 (fragmentLength); + fragment -> command.sendFragment.fragmentCount = ENET_HOST_TO_NET_32 (fragmentCount); + fragment -> command.sendFragment.fragmentNumber = ENET_HOST_TO_NET_32 (fragmentNumber); + fragment -> command.sendFragment.totalLength = ENET_HOST_TO_NET_32 (packet -> dataLength); + fragment -> command.sendFragment.fragmentOffset = ENET_NET_TO_HOST_32 (fragmentOffset); + + enet_list_insert (enet_list_end (& fragments), fragment); + } + + packet -> referenceCount += fragmentNumber; + + while (! enet_list_empty (& fragments)) + { + fragment = (ENetOutgoingCommand *) enet_list_remove (enet_list_begin (& fragments)); + + enet_peer_setup_outgoing_command (peer, fragment); + } + + return 0; + } + + command.header.channelID = channelID; + + if ((packet -> flags & (ENET_PACKET_FLAG_RELIABLE | ENET_PACKET_FLAG_UNSEQUENCED)) == ENET_PACKET_FLAG_UNSEQUENCED) + { + command.header.command = ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED | ENET_PROTOCOL_COMMAND_FLAG_UNSEQUENCED; + command.sendUnsequenced.dataLength = ENET_HOST_TO_NET_16 (packet -> dataLength); + } + else + if (packet -> flags & ENET_PACKET_FLAG_RELIABLE || channel -> outgoingUnreliableSequenceNumber >= 0xFFFF) + { + command.header.command = ENET_PROTOCOL_COMMAND_SEND_RELIABLE | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + command.sendReliable.dataLength = ENET_HOST_TO_NET_16 (packet -> dataLength); + } + else + { + command.header.command = ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE; + command.sendUnreliable.dataLength = ENET_HOST_TO_NET_16 (packet -> dataLength); + } + + if (enet_peer_queue_outgoing_command (peer, & command, packet, 0, packet -> dataLength) == NULL) + return -1; + + return 0; +} + +/** Attempts to dequeue any incoming queued packet. + @param peer peer to dequeue packets from + @param channelID holds the channel ID of the channel the packet was received on success + @returns a pointer to the packet, or NULL if there are no available incoming queued packets +*/ +ENetPacket * +enet_peer_receive (ENetPeer * peer, enet_uint8 * channelID) +{ + ENetIncomingCommand * incomingCommand; + ENetPacket * packet; + + if (enet_list_empty (& peer -> dispatchedCommands)) + return NULL; + + incomingCommand = (ENetIncomingCommand *) enet_list_remove (enet_list_begin (& peer -> dispatchedCommands)); + + if (channelID != NULL) + * channelID = incomingCommand -> command.header.channelID; + + packet = incomingCommand -> packet; + + -- packet -> referenceCount; + + if (incomingCommand -> fragments != NULL) + enet_free (incomingCommand -> fragments); + + enet_free (incomingCommand); + + peer -> totalWaitingData -= packet -> dataLength; + + return packet; +} + +static void +enet_peer_reset_outgoing_commands (ENetList * queue) +{ + ENetOutgoingCommand * outgoingCommand; + + while (! enet_list_empty (queue)) + { + outgoingCommand = (ENetOutgoingCommand *) enet_list_remove (enet_list_begin (queue)); + + if (outgoingCommand -> packet != NULL) + { + -- outgoingCommand -> packet -> referenceCount; + + if (outgoingCommand -> packet -> referenceCount == 0) + enet_packet_destroy (outgoingCommand -> packet); + } + + enet_free (outgoingCommand); + } +} + +static void +enet_peer_remove_incoming_commands (ENetList * queue, ENetListIterator startCommand, ENetListIterator endCommand, ENetIncomingCommand * excludeCommand) +{ + ENetListIterator currentCommand; + + for (currentCommand = startCommand; currentCommand != endCommand; ) + { + ENetIncomingCommand * incomingCommand = (ENetIncomingCommand *) currentCommand; + + currentCommand = enet_list_next (currentCommand); + + if (incomingCommand == excludeCommand) + continue; + + enet_list_remove (& incomingCommand -> incomingCommandList); + + if (incomingCommand -> packet != NULL) + { + -- incomingCommand -> packet -> referenceCount; + + if (incomingCommand -> packet -> referenceCount == 0) + enet_packet_destroy (incomingCommand -> packet); + } + + if (incomingCommand -> fragments != NULL) + enet_free (incomingCommand -> fragments); + + enet_free (incomingCommand); + } +} + +static void +enet_peer_reset_incoming_commands (ENetList * queue) +{ + enet_peer_remove_incoming_commands(queue, enet_list_begin (queue), enet_list_end (queue), NULL); +} + +void +enet_peer_reset_queues (ENetPeer * peer) +{ + ENetChannel * channel; + + if (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH) + { + enet_list_remove (& peer -> dispatchList); + + peer -> flags &= ~ ENET_PEER_FLAG_NEEDS_DISPATCH; + } + + while (! enet_list_empty (& peer -> acknowledgements)) + enet_free (enet_list_remove (enet_list_begin (& peer -> acknowledgements))); + + enet_peer_reset_outgoing_commands (& peer -> sentReliableCommands); + enet_peer_reset_outgoing_commands (& peer -> sentUnreliableCommands); + enet_peer_reset_outgoing_commands (& peer -> outgoingCommands); + enet_peer_reset_incoming_commands (& peer -> dispatchedCommands); + + if (peer -> channels != NULL && peer -> channelCount > 0) + { + for (channel = peer -> channels; + channel < & peer -> channels [peer -> channelCount]; + ++ channel) + { + enet_peer_reset_incoming_commands (& channel -> incomingReliableCommands); + enet_peer_reset_incoming_commands (& channel -> incomingUnreliableCommands); + } + + enet_free (peer -> channels); + } + + peer -> channels = NULL; + peer -> channelCount = 0; +} + +void +enet_peer_on_connect (ENetPeer * peer) +{ + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + { + if (peer -> incomingBandwidth != 0) + ++ peer -> host -> bandwidthLimitedPeers; + + ++ peer -> host -> connectedPeers; + } +} + +void +enet_peer_on_disconnect (ENetPeer * peer) +{ + if (peer -> state == ENET_PEER_STATE_CONNECTED || peer -> state == ENET_PEER_STATE_DISCONNECT_LATER) + { + if (peer -> incomingBandwidth != 0) + -- peer -> host -> bandwidthLimitedPeers; + + -- peer -> host -> connectedPeers; + } +} + +/** Forcefully disconnects a peer. + @param peer peer to forcefully disconnect + @remarks The foreign host represented by the peer is not notified of the disconnection and will timeout + on its connection to the local host. +*/ +void +enet_peer_reset (ENetPeer * peer) +{ + enet_peer_on_disconnect (peer); + + peer -> outgoingPeerID = ENET_PROTOCOL_MAXIMUM_PEER_ID; + peer -> connectID = 0; + + peer -> state = ENET_PEER_STATE_DISCONNECTED; + + peer -> incomingBandwidth = 0; + peer -> outgoingBandwidth = 0; + peer -> incomingBandwidthThrottleEpoch = 0; + peer -> outgoingBandwidthThrottleEpoch = 0; + peer -> incomingDataTotal = 0; + peer -> outgoingDataTotal = 0; + peer -> lastSendTime = 0; + peer -> lastReceiveTime = 0; + peer -> nextTimeout = 0; + peer -> earliestTimeout = 0; + peer -> packetLossEpoch = 0; + peer -> packetsSent = 0; + peer -> packetsLost = 0; + peer -> packetLoss = 0; + peer -> packetLossVariance = 0; + peer -> packetThrottle = ENET_PEER_DEFAULT_PACKET_THROTTLE; + peer -> packetThrottleLimit = ENET_PEER_PACKET_THROTTLE_SCALE; + peer -> packetThrottleCounter = 0; + peer -> packetThrottleEpoch = 0; + peer -> packetThrottleAcceleration = ENET_PEER_PACKET_THROTTLE_ACCELERATION; + peer -> packetThrottleDeceleration = ENET_PEER_PACKET_THROTTLE_DECELERATION; + peer -> packetThrottleInterval = ENET_PEER_PACKET_THROTTLE_INTERVAL; + peer -> pingInterval = ENET_PEER_PING_INTERVAL; + peer -> timeoutLimit = ENET_PEER_TIMEOUT_LIMIT; + peer -> timeoutMinimum = ENET_PEER_TIMEOUT_MINIMUM; + peer -> timeoutMaximum = ENET_PEER_TIMEOUT_MAXIMUM; + peer -> lastRoundTripTime = ENET_PEER_DEFAULT_ROUND_TRIP_TIME; + peer -> lowestRoundTripTime = ENET_PEER_DEFAULT_ROUND_TRIP_TIME; + peer -> lastRoundTripTimeVariance = 0; + peer -> highestRoundTripTimeVariance = 0; + peer -> roundTripTime = ENET_PEER_DEFAULT_ROUND_TRIP_TIME; + peer -> roundTripTimeVariance = 0; + peer -> mtu = peer -> host -> mtu; + peer -> reliableDataInTransit = 0; + peer -> outgoingReliableSequenceNumber = 0; + peer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + peer -> incomingUnsequencedGroup = 0; + peer -> outgoingUnsequencedGroup = 0; + peer -> eventData = 0; + peer -> totalWaitingData = 0; + peer -> flags = 0; + + memset (peer -> unsequencedWindow, 0, sizeof (peer -> unsequencedWindow)); + + enet_peer_reset_queues (peer); +} + +/** Sends a ping request to a peer. + @param peer destination for the ping request + @remarks ping requests factor into the mean round trip time as designated by the + roundTripTime field in the ENetPeer structure. ENet automatically pings all connected + peers at regular intervals, however, this function may be called to ensure more + frequent ping requests. +*/ +void +enet_peer_ping (ENetPeer * peer) +{ + ENetProtocol command; + + if (peer -> state != ENET_PEER_STATE_CONNECTED) + return; + + command.header.command = ENET_PROTOCOL_COMMAND_PING | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + command.header.channelID = 0xFF; + + enet_peer_queue_outgoing_command (peer, & command, NULL, 0, 0); +} + +/** Sets the interval at which pings will be sent to a peer. + + Pings are used both to monitor the liveness of the connection and also to dynamically + adjust the throttle during periods of low traffic so that the throttle has reasonable + responsiveness during traffic spikes. + + @param peer the peer to adjust + @param pingInterval the interval at which to send pings; defaults to ENET_PEER_PING_INTERVAL if 0 +*/ +void +enet_peer_ping_interval (ENetPeer * peer, enet_uint32 pingInterval) +{ + peer -> pingInterval = pingInterval ? pingInterval : ENET_PEER_PING_INTERVAL; +} + +/** Sets the timeout parameters for a peer. + + The timeout parameter control how and when a peer will timeout from a failure to acknowledge + reliable traffic. Timeout values use an exponential backoff mechanism, where if a reliable + packet is not acknowledge within some multiple of the average RTT plus a variance tolerance, + the timeout will be doubled until it reaches a set limit. If the timeout is thus at this + limit and reliable packets have been sent but not acknowledged within a certain minimum time + period, the peer will be disconnected. Alternatively, if reliable packets have been sent + but not acknowledged for a certain maximum time period, the peer will be disconnected regardless + of the current timeout limit value. + + @param peer the peer to adjust + @param timeoutLimit the timeout limit; defaults to ENET_PEER_TIMEOUT_LIMIT if 0 + @param timeoutMinimum the timeout minimum; defaults to ENET_PEER_TIMEOUT_MINIMUM if 0 + @param timeoutMaximum the timeout maximum; defaults to ENET_PEER_TIMEOUT_MAXIMUM if 0 +*/ + +void +enet_peer_timeout (ENetPeer * peer, enet_uint32 timeoutLimit, enet_uint32 timeoutMinimum, enet_uint32 timeoutMaximum) +{ + peer -> timeoutLimit = timeoutLimit ? timeoutLimit : ENET_PEER_TIMEOUT_LIMIT; + peer -> timeoutMinimum = timeoutMinimum ? timeoutMinimum : ENET_PEER_TIMEOUT_MINIMUM; + peer -> timeoutMaximum = timeoutMaximum ? timeoutMaximum : ENET_PEER_TIMEOUT_MAXIMUM; +} + +/** Force an immediate disconnection from a peer. + @param peer peer to disconnect + @param data data describing the disconnection + @remarks No ENET_EVENT_DISCONNECT event will be generated. The foreign peer is not + guaranteed to receive the disconnect notification, and is reset immediately upon + return from this function. +*/ +void +enet_peer_disconnect_now (ENetPeer * peer, enet_uint32 data) +{ + ENetProtocol command; + + if (peer -> state == ENET_PEER_STATE_DISCONNECTED) + return; + + if (peer -> state != ENET_PEER_STATE_ZOMBIE && + peer -> state != ENET_PEER_STATE_DISCONNECTING) + { + enet_peer_reset_queues (peer); + + command.header.command = ENET_PROTOCOL_COMMAND_DISCONNECT | ENET_PROTOCOL_COMMAND_FLAG_UNSEQUENCED; + command.header.channelID = 0xFF; + command.disconnect.data = ENET_HOST_TO_NET_32 (data); + + enet_peer_queue_outgoing_command (peer, & command, NULL, 0, 0); + + enet_host_flush (peer -> host); + } + + enet_peer_reset (peer); +} + +/** Request a disconnection from a peer. + @param peer peer to request a disconnection + @param data data describing the disconnection + @remarks An ENET_EVENT_DISCONNECT event will be generated by enet_host_service() + once the disconnection is complete. +*/ +void +enet_peer_disconnect (ENetPeer * peer, enet_uint32 data) +{ + ENetProtocol command; + + if (peer -> state == ENET_PEER_STATE_DISCONNECTING || + peer -> state == ENET_PEER_STATE_DISCONNECTED || + peer -> state == ENET_PEER_STATE_ACKNOWLEDGING_DISCONNECT || + peer -> state == ENET_PEER_STATE_ZOMBIE) + return; + + enet_peer_reset_queues (peer); + + command.header.command = ENET_PROTOCOL_COMMAND_DISCONNECT; + command.header.channelID = 0xFF; + command.disconnect.data = ENET_HOST_TO_NET_32 (data); + + if (peer -> state == ENET_PEER_STATE_CONNECTED || peer -> state == ENET_PEER_STATE_DISCONNECT_LATER) + command.header.command |= ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + else + command.header.command |= ENET_PROTOCOL_COMMAND_FLAG_UNSEQUENCED; + + enet_peer_queue_outgoing_command (peer, & command, NULL, 0, 0); + + if (peer -> state == ENET_PEER_STATE_CONNECTED || peer -> state == ENET_PEER_STATE_DISCONNECT_LATER) + { + enet_peer_on_disconnect (peer); + + peer -> state = ENET_PEER_STATE_DISCONNECTING; + } + else + { + enet_host_flush (peer -> host); + enet_peer_reset (peer); + } +} + +/** Request a disconnection from a peer, but only after all queued outgoing packets are sent. + @param peer peer to request a disconnection + @param data data describing the disconnection + @remarks An ENET_EVENT_DISCONNECT event will be generated by enet_host_service() + once the disconnection is complete. +*/ +void +enet_peer_disconnect_later (ENetPeer * peer, enet_uint32 data) +{ + if ((peer -> state == ENET_PEER_STATE_CONNECTED || peer -> state == ENET_PEER_STATE_DISCONNECT_LATER) && + ! (enet_list_empty (& peer -> outgoingCommands) && + enet_list_empty (& peer -> sentReliableCommands))) + { + peer -> state = ENET_PEER_STATE_DISCONNECT_LATER; + peer -> eventData = data; + } + else + enet_peer_disconnect (peer, data); +} + +ENetAcknowledgement * +enet_peer_queue_acknowledgement (ENetPeer * peer, const ENetProtocol * command, enet_uint16 sentTime) +{ + ENetAcknowledgement * acknowledgement; + + if (command -> header.channelID < peer -> channelCount) + { + ENetChannel * channel = & peer -> channels [command -> header.channelID]; + enet_uint16 reliableWindow = command -> header.reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE, + currentWindow = channel -> incomingReliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + + if (command -> header.reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + reliableWindow += ENET_PEER_RELIABLE_WINDOWS; + + if (reliableWindow >= currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS - 1 && reliableWindow <= currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS) + return NULL; + } + + acknowledgement = (ENetAcknowledgement *) enet_malloc (sizeof (ENetAcknowledgement)); + if (acknowledgement == NULL) + return NULL; + + peer -> outgoingDataTotal += sizeof (ENetProtocolAcknowledge); + + acknowledgement -> sentTime = sentTime; + acknowledgement -> command = * command; + + enet_list_insert (enet_list_end (& peer -> acknowledgements), acknowledgement); + + return acknowledgement; +} + +void +enet_peer_setup_outgoing_command (ENetPeer * peer, ENetOutgoingCommand * outgoingCommand) +{ + ENetChannel * channel = & peer -> channels [outgoingCommand -> command.header.channelID]; + + peer -> outgoingDataTotal += enet_protocol_command_size (outgoingCommand -> command.header.command) + outgoingCommand -> fragmentLength; + + if (outgoingCommand -> command.header.channelID == 0xFF) + { + ++ peer -> outgoingReliableSequenceNumber; + + outgoingCommand -> reliableSequenceNumber = peer -> outgoingReliableSequenceNumber; + outgoingCommand -> unreliableSequenceNumber = 0; + } + else + if (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) + { + ++ channel -> outgoingReliableSequenceNumber; + channel -> outgoingUnreliableSequenceNumber = 0; + + outgoingCommand -> reliableSequenceNumber = channel -> outgoingReliableSequenceNumber; + outgoingCommand -> unreliableSequenceNumber = 0; + } + else + if (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_UNSEQUENCED) + { + ++ peer -> outgoingUnsequencedGroup; + + outgoingCommand -> reliableSequenceNumber = 0; + outgoingCommand -> unreliableSequenceNumber = 0; + } + else + { + if (outgoingCommand -> fragmentOffset == 0) + ++ channel -> outgoingUnreliableSequenceNumber; + + outgoingCommand -> reliableSequenceNumber = channel -> outgoingReliableSequenceNumber; + outgoingCommand -> unreliableSequenceNumber = channel -> outgoingUnreliableSequenceNumber; + } + + outgoingCommand -> sendAttempts = 0; + outgoingCommand -> sentTime = 0; + outgoingCommand -> roundTripTimeout = 0; + outgoingCommand -> roundTripTimeoutLimit = 0; + outgoingCommand -> command.header.reliableSequenceNumber = ENET_HOST_TO_NET_16 (outgoingCommand -> reliableSequenceNumber); + + switch (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK) + { + case ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE: + outgoingCommand -> command.sendUnreliable.unreliableSequenceNumber = ENET_HOST_TO_NET_16 (outgoingCommand -> unreliableSequenceNumber); + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED: + outgoingCommand -> command.sendUnsequenced.unsequencedGroup = ENET_HOST_TO_NET_16 (peer -> outgoingUnsequencedGroup); + break; + + default: + break; + } + + enet_list_insert (enet_list_end (& peer -> outgoingCommands), outgoingCommand); +} + +ENetOutgoingCommand * +enet_peer_queue_outgoing_command (ENetPeer * peer, const ENetProtocol * command, ENetPacket * packet, enet_uint32 offset, enet_uint16 length) +{ + ENetOutgoingCommand * outgoingCommand = (ENetOutgoingCommand *) enet_malloc (sizeof (ENetOutgoingCommand)); + if (outgoingCommand == NULL) + return NULL; + + outgoingCommand -> command = * command; + outgoingCommand -> fragmentOffset = offset; + outgoingCommand -> fragmentLength = length; + outgoingCommand -> packet = packet; + if (packet != NULL) + ++ packet -> referenceCount; + + enet_peer_setup_outgoing_command (peer, outgoingCommand); + + return outgoingCommand; +} + +void +enet_peer_dispatch_incoming_unreliable_commands (ENetPeer * peer, ENetChannel * channel, ENetIncomingCommand * queuedCommand) +{ + ENetListIterator droppedCommand, startCommand, currentCommand; + + for (droppedCommand = startCommand = currentCommand = enet_list_begin (& channel -> incomingUnreliableCommands); + currentCommand != enet_list_end (& channel -> incomingUnreliableCommands); + currentCommand = enet_list_next (currentCommand)) + { + ENetIncomingCommand * incomingCommand = (ENetIncomingCommand *) currentCommand; + + if ((incomingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK) == ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED) + continue; + + if (incomingCommand -> reliableSequenceNumber == channel -> incomingReliableSequenceNumber) + { + if (incomingCommand -> fragmentsRemaining <= 0) + { + channel -> incomingUnreliableSequenceNumber = incomingCommand -> unreliableSequenceNumber; + continue; + } + + if (startCommand != currentCommand) + { + enet_list_move (enet_list_end (& peer -> dispatchedCommands), startCommand, enet_list_previous (currentCommand)); + + if (! (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH)) + { + enet_list_insert (enet_list_end (& peer -> host -> dispatchQueue), & peer -> dispatchList); + + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + } + + droppedCommand = currentCommand; + } + else + if (droppedCommand != currentCommand) + droppedCommand = enet_list_previous (currentCommand); + } + else + { + enet_uint16 reliableWindow = incomingCommand -> reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE, + currentWindow = channel -> incomingReliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + if (incomingCommand -> reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + reliableWindow += ENET_PEER_RELIABLE_WINDOWS; + if (reliableWindow >= currentWindow && reliableWindow < currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS - 1) + break; + + droppedCommand = enet_list_next (currentCommand); + + if (startCommand != currentCommand) + { + enet_list_move (enet_list_end (& peer -> dispatchedCommands), startCommand, enet_list_previous (currentCommand)); + + if (! (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH)) + { + enet_list_insert (enet_list_end (& peer -> host -> dispatchQueue), & peer -> dispatchList); + + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + } + } + } + + startCommand = enet_list_next (currentCommand); + } + + if (startCommand != currentCommand) + { + enet_list_move (enet_list_end (& peer -> dispatchedCommands), startCommand, enet_list_previous (currentCommand)); + + if (! (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH)) + { + enet_list_insert (enet_list_end (& peer -> host -> dispatchQueue), & peer -> dispatchList); + + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + } + + droppedCommand = currentCommand; + } + + enet_peer_remove_incoming_commands (& channel -> incomingUnreliableCommands, enet_list_begin (& channel -> incomingUnreliableCommands), droppedCommand, queuedCommand); +} + +void +enet_peer_dispatch_incoming_reliable_commands (ENetPeer * peer, ENetChannel * channel, ENetIncomingCommand * queuedCommand) +{ + ENetListIterator currentCommand; + + for (currentCommand = enet_list_begin (& channel -> incomingReliableCommands); + currentCommand != enet_list_end (& channel -> incomingReliableCommands); + currentCommand = enet_list_next (currentCommand)) + { + ENetIncomingCommand * incomingCommand = (ENetIncomingCommand *) currentCommand; + + if (incomingCommand -> fragmentsRemaining > 0 || + incomingCommand -> reliableSequenceNumber != (enet_uint16) (channel -> incomingReliableSequenceNumber + 1)) + break; + + channel -> incomingReliableSequenceNumber = incomingCommand -> reliableSequenceNumber; + + if (incomingCommand -> fragmentCount > 0) + channel -> incomingReliableSequenceNumber += incomingCommand -> fragmentCount - 1; + } + + if (currentCommand == enet_list_begin (& channel -> incomingReliableCommands)) + return; + + channel -> incomingUnreliableSequenceNumber = 0; + + enet_list_move (enet_list_end (& peer -> dispatchedCommands), enet_list_begin (& channel -> incomingReliableCommands), enet_list_previous (currentCommand)); + + if (! (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH)) + { + enet_list_insert (enet_list_end (& peer -> host -> dispatchQueue), & peer -> dispatchList); + + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + } + + if (! enet_list_empty (& channel -> incomingUnreliableCommands)) + enet_peer_dispatch_incoming_unreliable_commands (peer, channel, queuedCommand); +} + +ENetIncomingCommand * +enet_peer_queue_incoming_command (ENetPeer * peer, const ENetProtocol * command, const void * data, size_t dataLength, enet_uint32 flags, enet_uint32 fragmentCount) +{ + static ENetIncomingCommand dummyCommand; + + ENetChannel * channel = & peer -> channels [command -> header.channelID]; + enet_uint32 unreliableSequenceNumber = 0, reliableSequenceNumber = 0; + enet_uint16 reliableWindow, currentWindow; + ENetIncomingCommand * incomingCommand; + ENetListIterator currentCommand; + ENetPacket * packet = NULL; + + if (peer -> state == ENET_PEER_STATE_DISCONNECT_LATER) + goto discardCommand; + + if ((command -> header.command & ENET_PROTOCOL_COMMAND_MASK) != ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED) + { + reliableSequenceNumber = command -> header.reliableSequenceNumber; + reliableWindow = reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + currentWindow = channel -> incomingReliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + + if (reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + reliableWindow += ENET_PEER_RELIABLE_WINDOWS; + + if (reliableWindow < currentWindow || reliableWindow >= currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS - 1) + goto discardCommand; + } + + switch (command -> header.command & ENET_PROTOCOL_COMMAND_MASK) + { + case ENET_PROTOCOL_COMMAND_SEND_FRAGMENT: + case ENET_PROTOCOL_COMMAND_SEND_RELIABLE: + if (reliableSequenceNumber == channel -> incomingReliableSequenceNumber) + goto discardCommand; + + for (currentCommand = enet_list_previous (enet_list_end (& channel -> incomingReliableCommands)); + currentCommand != enet_list_end (& channel -> incomingReliableCommands); + currentCommand = enet_list_previous (currentCommand)) + { + incomingCommand = (ENetIncomingCommand *) currentCommand; + + if (reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + continue; + } + else + if (incomingCommand -> reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber <= reliableSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < reliableSequenceNumber) + break; + + goto discardCommand; + } + } + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE: + case ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE_FRAGMENT: + unreliableSequenceNumber = ENET_NET_TO_HOST_16 (command -> sendUnreliable.unreliableSequenceNumber); + + if (reliableSequenceNumber == channel -> incomingReliableSequenceNumber && + unreliableSequenceNumber <= channel -> incomingUnreliableSequenceNumber) + goto discardCommand; + + for (currentCommand = enet_list_previous (enet_list_end (& channel -> incomingUnreliableCommands)); + currentCommand != enet_list_end (& channel -> incomingUnreliableCommands); + currentCommand = enet_list_previous (currentCommand)) + { + incomingCommand = (ENetIncomingCommand *) currentCommand; + + if ((command -> header.command & ENET_PROTOCOL_COMMAND_MASK) == ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED) + continue; + + if (reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + continue; + } + else + if (incomingCommand -> reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber < reliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber > reliableSequenceNumber) + continue; + + if (incomingCommand -> unreliableSequenceNumber <= unreliableSequenceNumber) + { + if (incomingCommand -> unreliableSequenceNumber < unreliableSequenceNumber) + break; + + goto discardCommand; + } + } + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED: + currentCommand = enet_list_end (& channel -> incomingUnreliableCommands); + break; + + default: + goto discardCommand; + } + + if (peer -> totalWaitingData >= peer -> host -> maximumWaitingData) + goto notifyError; + + packet = enet_packet_create (data, dataLength, flags); + if (packet == NULL) + goto notifyError; + + incomingCommand = (ENetIncomingCommand *) enet_malloc (sizeof (ENetIncomingCommand)); + if (incomingCommand == NULL) + goto notifyError; + + incomingCommand -> reliableSequenceNumber = command -> header.reliableSequenceNumber; + incomingCommand -> unreliableSequenceNumber = unreliableSequenceNumber & 0xFFFF; + incomingCommand -> command = * command; + incomingCommand -> fragmentCount = fragmentCount; + incomingCommand -> fragmentsRemaining = fragmentCount; + incomingCommand -> packet = packet; + incomingCommand -> fragments = NULL; + + if (fragmentCount > 0) + { + if (fragmentCount <= ENET_PROTOCOL_MAXIMUM_FRAGMENT_COUNT) + incomingCommand -> fragments = (enet_uint32 *) enet_malloc ((fragmentCount + 31) / 32 * sizeof (enet_uint32)); + if (incomingCommand -> fragments == NULL) + { + enet_free (incomingCommand); + + goto notifyError; + } + memset (incomingCommand -> fragments, 0, (fragmentCount + 31) / 32 * sizeof (enet_uint32)); + } + + if (packet != NULL) + { + ++ packet -> referenceCount; + + peer -> totalWaitingData += packet -> dataLength; + } + + enet_list_insert (enet_list_next (currentCommand), incomingCommand); + + switch (command -> header.command & ENET_PROTOCOL_COMMAND_MASK) + { + case ENET_PROTOCOL_COMMAND_SEND_FRAGMENT: + case ENET_PROTOCOL_COMMAND_SEND_RELIABLE: + enet_peer_dispatch_incoming_reliable_commands (peer, channel, incomingCommand); + break; + + default: + enet_peer_dispatch_incoming_unreliable_commands (peer, channel, incomingCommand); + break; + } + + return incomingCommand; + +discardCommand: + if (fragmentCount > 0) + goto notifyError; + + if (packet != NULL && packet -> referenceCount == 0) + enet_packet_destroy (packet); + + return & dummyCommand; + +notifyError: + if (packet != NULL && packet -> referenceCount == 0) + enet_packet_destroy (packet); + + return NULL; +} + +/** @} */ diff --git a/third_party/enet/protocol.c b/third_party/enet/protocol.c new file mode 100644 index 0000000..26ffc9d --- /dev/null +++ b/third_party/enet/protocol.c @@ -0,0 +1,1881 @@ +/** + @file protocol.c + @brief ENet protocol functions +*/ +#include +#include +#define ENET_BUILDING_LIB 1 +#include "enet/utility.h" +#include "enet/time.h" +#include "enet/enet.h" + +static size_t commandSizes [ENET_PROTOCOL_COMMAND_COUNT] = +{ + 0, + sizeof (ENetProtocolAcknowledge), + sizeof (ENetProtocolConnect), + sizeof (ENetProtocolVerifyConnect), + sizeof (ENetProtocolDisconnect), + sizeof (ENetProtocolPing), + sizeof (ENetProtocolSendReliable), + sizeof (ENetProtocolSendUnreliable), + sizeof (ENetProtocolSendFragment), + sizeof (ENetProtocolSendUnsequenced), + sizeof (ENetProtocolBandwidthLimit), + sizeof (ENetProtocolThrottleConfigure), + sizeof (ENetProtocolSendFragment) +}; + +size_t +enet_protocol_command_size (enet_uint8 commandNumber) +{ + return commandSizes [commandNumber & ENET_PROTOCOL_COMMAND_MASK]; +} + +static void +enet_protocol_change_state (ENetHost * host, ENetPeer * peer, ENetPeerState state) +{ + if (state == ENET_PEER_STATE_CONNECTED || state == ENET_PEER_STATE_DISCONNECT_LATER) + enet_peer_on_connect (peer); + else + enet_peer_on_disconnect (peer); + + peer -> state = state; +} + +static void +enet_protocol_dispatch_state (ENetHost * host, ENetPeer * peer, ENetPeerState state) +{ + enet_protocol_change_state (host, peer, state); + + if (! (peer -> flags & ENET_PEER_FLAG_NEEDS_DISPATCH)) + { + enet_list_insert (enet_list_end (& host -> dispatchQueue), & peer -> dispatchList); + + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + } +} + +static int +enet_protocol_dispatch_incoming_commands (ENetHost * host, ENetEvent * event) +{ + while (! enet_list_empty (& host -> dispatchQueue)) + { + ENetPeer * peer = (ENetPeer *) enet_list_remove (enet_list_begin (& host -> dispatchQueue)); + + peer -> flags &= ~ ENET_PEER_FLAG_NEEDS_DISPATCH; + + switch (peer -> state) + { + case ENET_PEER_STATE_CONNECTION_PENDING: + case ENET_PEER_STATE_CONNECTION_SUCCEEDED: + enet_protocol_change_state (host, peer, ENET_PEER_STATE_CONNECTED); + + event -> type = ENET_EVENT_TYPE_CONNECT; + event -> peer = peer; + event -> data = peer -> eventData; + + return 1; + + case ENET_PEER_STATE_ZOMBIE: + host -> recalculateBandwidthLimits = 1; + + event -> type = ENET_EVENT_TYPE_DISCONNECT; + event -> peer = peer; + event -> data = peer -> eventData; + + enet_peer_reset (peer); + + return 1; + + case ENET_PEER_STATE_CONNECTED: + if (enet_list_empty (& peer -> dispatchedCommands)) + continue; + + event -> packet = enet_peer_receive (peer, & event -> channelID); + if (event -> packet == NULL) + continue; + + event -> type = ENET_EVENT_TYPE_RECEIVE; + event -> peer = peer; + + if (! enet_list_empty (& peer -> dispatchedCommands)) + { + peer -> flags |= ENET_PEER_FLAG_NEEDS_DISPATCH; + + enet_list_insert (enet_list_end (& host -> dispatchQueue), & peer -> dispatchList); + } + + return 1; + + default: + break; + } + } + + return 0; +} + +static void +enet_protocol_notify_connect (ENetHost * host, ENetPeer * peer, ENetEvent * event) +{ + host -> recalculateBandwidthLimits = 1; + + if (event != NULL) + { + enet_protocol_change_state (host, peer, ENET_PEER_STATE_CONNECTED); + + event -> type = ENET_EVENT_TYPE_CONNECT; + event -> peer = peer; + event -> data = peer -> eventData; + } + else + enet_protocol_dispatch_state (host, peer, peer -> state == ENET_PEER_STATE_CONNECTING ? ENET_PEER_STATE_CONNECTION_SUCCEEDED : ENET_PEER_STATE_CONNECTION_PENDING); +} + +static void +enet_protocol_notify_disconnect (ENetHost * host, ENetPeer * peer, ENetEvent * event) +{ + if (peer -> state >= ENET_PEER_STATE_CONNECTION_PENDING) + host -> recalculateBandwidthLimits = 1; + + if (peer -> state != ENET_PEER_STATE_CONNECTING && peer -> state < ENET_PEER_STATE_CONNECTION_SUCCEEDED) + enet_peer_reset (peer); + else + if (event != NULL) + { + event -> type = ENET_EVENT_TYPE_DISCONNECT; + event -> peer = peer; + event -> data = 0; + + enet_peer_reset (peer); + } + else + { + peer -> eventData = 0; + + enet_protocol_dispatch_state (host, peer, ENET_PEER_STATE_ZOMBIE); + } +} + +static void +enet_protocol_remove_sent_unreliable_commands (ENetPeer * peer) +{ + ENetOutgoingCommand * outgoingCommand; + + if (enet_list_empty (& peer -> sentUnreliableCommands)) + return; + + do + { + outgoingCommand = (ENetOutgoingCommand *) enet_list_front (& peer -> sentUnreliableCommands); + + enet_list_remove (& outgoingCommand -> outgoingCommandList); + + if (outgoingCommand -> packet != NULL) + { + -- outgoingCommand -> packet -> referenceCount; + + if (outgoingCommand -> packet -> referenceCount == 0) + { + outgoingCommand -> packet -> flags |= ENET_PACKET_FLAG_SENT; + + enet_packet_destroy (outgoingCommand -> packet); + } + } + + enet_free (outgoingCommand); + } while (! enet_list_empty (& peer -> sentUnreliableCommands)); + + if (peer -> state == ENET_PEER_STATE_DISCONNECT_LATER && + enet_list_empty (& peer -> outgoingCommands) && + enet_list_empty (& peer -> sentReliableCommands)) + enet_peer_disconnect (peer, peer -> eventData); +} + +static ENetProtocolCommand +enet_protocol_remove_sent_reliable_command (ENetPeer * peer, enet_uint16 reliableSequenceNumber, enet_uint8 channelID) +{ + ENetOutgoingCommand * outgoingCommand = NULL; + ENetListIterator currentCommand; + ENetProtocolCommand commandNumber; + int wasSent = 1; + + for (currentCommand = enet_list_begin (& peer -> sentReliableCommands); + currentCommand != enet_list_end (& peer -> sentReliableCommands); + currentCommand = enet_list_next (currentCommand)) + { + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + + if (outgoingCommand -> reliableSequenceNumber == reliableSequenceNumber && + outgoingCommand -> command.header.channelID == channelID) + break; + } + + if (currentCommand == enet_list_end (& peer -> sentReliableCommands)) + { + for (currentCommand = enet_list_begin (& peer -> outgoingCommands); + currentCommand != enet_list_end (& peer -> outgoingCommands); + currentCommand = enet_list_next (currentCommand)) + { + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + + if (! (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE)) + continue; + + if (outgoingCommand -> sendAttempts < 1) return ENET_PROTOCOL_COMMAND_NONE; + + if (outgoingCommand -> reliableSequenceNumber == reliableSequenceNumber && + outgoingCommand -> command.header.channelID == channelID) + break; + } + + if (currentCommand == enet_list_end (& peer -> outgoingCommands)) + return ENET_PROTOCOL_COMMAND_NONE; + + wasSent = 0; + } + + if (outgoingCommand == NULL) + return ENET_PROTOCOL_COMMAND_NONE; + + if (channelID < peer -> channelCount) + { + ENetChannel * channel = & peer -> channels [channelID]; + enet_uint16 reliableWindow = reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + if (channel -> reliableWindows [reliableWindow] > 0) + { + -- channel -> reliableWindows [reliableWindow]; + if (! channel -> reliableWindows [reliableWindow]) + channel -> usedReliableWindows &= ~ (1 << reliableWindow); + } + } + + commandNumber = (ENetProtocolCommand) (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK); + + enet_list_remove (& outgoingCommand -> outgoingCommandList); + + if (outgoingCommand -> packet != NULL) + { + if (wasSent) + peer -> reliableDataInTransit -= outgoingCommand -> fragmentLength; + + -- outgoingCommand -> packet -> referenceCount; + + if (outgoingCommand -> packet -> referenceCount == 0) + { + outgoingCommand -> packet -> flags |= ENET_PACKET_FLAG_SENT; + + enet_packet_destroy (outgoingCommand -> packet); + } + } + + enet_free (outgoingCommand); + + if (enet_list_empty (& peer -> sentReliableCommands)) + return commandNumber; + + outgoingCommand = (ENetOutgoingCommand *) enet_list_front (& peer -> sentReliableCommands); + + peer -> nextTimeout = outgoingCommand -> sentTime + outgoingCommand -> roundTripTimeout; + + return commandNumber; +} + +static ENetPeer * +enet_protocol_handle_connect (ENetHost * host, ENetProtocolHeader * header, ENetProtocol * command) +{ + enet_uint8 incomingSessionID, outgoingSessionID; + enet_uint32 mtu, windowSize; + ENetChannel * channel; + size_t channelCount, duplicatePeers = 0; + ENetPeer * currentPeer, * peer = NULL; + ENetProtocol verifyCommand; + + channelCount = ENET_NET_TO_HOST_32 (command -> connect.channelCount); + + if (channelCount < ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT || + channelCount > ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT) + return NULL; + + for (currentPeer = host -> peers; + currentPeer < & host -> peers [host -> peerCount]; + ++ currentPeer) + { + if (currentPeer -> state == ENET_PEER_STATE_DISCONNECTED) + { + if (peer == NULL) + peer = currentPeer; + } + else + if (currentPeer -> state != ENET_PEER_STATE_CONNECTING && + enet_address_equal (& currentPeer -> address, & host -> receivedAddress)) + { + if (currentPeer -> connectID == command -> connect.connectID) + return NULL; + + ++ duplicatePeers; + } + } + + if (peer == NULL || duplicatePeers >= host -> duplicatePeers) + return NULL; + + if (channelCount > host -> channelLimit) + channelCount = host -> channelLimit; + peer -> channels = (ENetChannel *) enet_malloc (channelCount * sizeof (ENetChannel)); + if (peer -> channels == NULL) + return NULL; + peer -> channelCount = channelCount; + peer -> state = ENET_PEER_STATE_ACKNOWLEDGING_CONNECT; + peer -> connectID = command -> connect.connectID; + peer -> address = host -> receivedAddress; + peer -> outgoingPeerID = ENET_NET_TO_HOST_16 (command -> connect.outgoingPeerID); + peer -> incomingBandwidth = ENET_NET_TO_HOST_32 (command -> connect.incomingBandwidth); + peer -> outgoingBandwidth = ENET_NET_TO_HOST_32 (command -> connect.outgoingBandwidth); + peer -> packetThrottleInterval = ENET_NET_TO_HOST_32 (command -> connect.packetThrottleInterval); + peer -> packetThrottleAcceleration = ENET_NET_TO_HOST_32 (command -> connect.packetThrottleAcceleration); + peer -> packetThrottleDeceleration = ENET_NET_TO_HOST_32 (command -> connect.packetThrottleDeceleration); + peer -> eventData = ENET_NET_TO_HOST_32 (command -> connect.data); + + incomingSessionID = command -> connect.incomingSessionID == 0xFF ? peer -> outgoingSessionID : command -> connect.incomingSessionID; + incomingSessionID = (incomingSessionID + 1) & (ENET_PROTOCOL_HEADER_SESSION_MASK >> ENET_PROTOCOL_HEADER_SESSION_SHIFT); + if (incomingSessionID == peer -> outgoingSessionID) + incomingSessionID = (incomingSessionID + 1) & (ENET_PROTOCOL_HEADER_SESSION_MASK >> ENET_PROTOCOL_HEADER_SESSION_SHIFT); + peer -> outgoingSessionID = incomingSessionID; + + outgoingSessionID = command -> connect.outgoingSessionID == 0xFF ? peer -> incomingSessionID : command -> connect.outgoingSessionID; + outgoingSessionID = (outgoingSessionID + 1) & (ENET_PROTOCOL_HEADER_SESSION_MASK >> ENET_PROTOCOL_HEADER_SESSION_SHIFT); + if (outgoingSessionID == peer -> incomingSessionID) + outgoingSessionID = (outgoingSessionID + 1) & (ENET_PROTOCOL_HEADER_SESSION_MASK >> ENET_PROTOCOL_HEADER_SESSION_SHIFT); + peer -> incomingSessionID = outgoingSessionID; + + for (channel = peer -> channels; + channel < & peer -> channels [channelCount]; + ++ channel) + { + channel -> outgoingReliableSequenceNumber = 0; + channel -> outgoingUnreliableSequenceNumber = 0; + channel -> incomingReliableSequenceNumber = 0; + channel -> incomingUnreliableSequenceNumber = 0; + + enet_list_clear (& channel -> incomingReliableCommands); + enet_list_clear (& channel -> incomingUnreliableCommands); + + channel -> usedReliableWindows = 0; + memset (channel -> reliableWindows, 0, sizeof (channel -> reliableWindows)); + } + + mtu = ENET_NET_TO_HOST_32 (command -> connect.mtu); + + if (mtu < ENET_PROTOCOL_MINIMUM_MTU) + mtu = ENET_PROTOCOL_MINIMUM_MTU; + else + if (mtu > ENET_PROTOCOL_MAXIMUM_MTU) + mtu = ENET_PROTOCOL_MAXIMUM_MTU; + + peer -> mtu = mtu; + + if (host -> outgoingBandwidth == 0 && + peer -> incomingBandwidth == 0) + peer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + else + if (host -> outgoingBandwidth == 0 || + peer -> incomingBandwidth == 0) + peer -> windowSize = (ENET_MAX (host -> outgoingBandwidth, peer -> incomingBandwidth) / + ENET_PEER_WINDOW_SIZE_SCALE) * + ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + peer -> windowSize = (ENET_MIN (host -> outgoingBandwidth, peer -> incomingBandwidth) / + ENET_PEER_WINDOW_SIZE_SCALE) * + ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + + if (peer -> windowSize < ENET_PROTOCOL_MINIMUM_WINDOW_SIZE) + peer -> windowSize = ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + if (peer -> windowSize > ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE) + peer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + + if (host -> incomingBandwidth == 0) + windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + else + windowSize = (host -> incomingBandwidth / ENET_PEER_WINDOW_SIZE_SCALE) * + ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + + if (windowSize > ENET_NET_TO_HOST_32 (command -> connect.windowSize)) + windowSize = ENET_NET_TO_HOST_32 (command -> connect.windowSize); + + if (windowSize < ENET_PROTOCOL_MINIMUM_WINDOW_SIZE) + windowSize = ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + if (windowSize > ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE) + windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + + verifyCommand.header.command = ENET_PROTOCOL_COMMAND_VERIFY_CONNECT | ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE; + verifyCommand.header.channelID = 0xFF; + verifyCommand.verifyConnect.outgoingPeerID = ENET_HOST_TO_NET_16 (peer -> incomingPeerID); + verifyCommand.verifyConnect.incomingSessionID = incomingSessionID; + verifyCommand.verifyConnect.outgoingSessionID = outgoingSessionID; + verifyCommand.verifyConnect.mtu = ENET_HOST_TO_NET_32 (peer -> mtu); + verifyCommand.verifyConnect.windowSize = ENET_HOST_TO_NET_32 (windowSize); + verifyCommand.verifyConnect.channelCount = ENET_HOST_TO_NET_32 (channelCount); + verifyCommand.verifyConnect.incomingBandwidth = ENET_HOST_TO_NET_32 (host -> incomingBandwidth); + verifyCommand.verifyConnect.outgoingBandwidth = ENET_HOST_TO_NET_32 (host -> outgoingBandwidth); + verifyCommand.verifyConnect.packetThrottleInterval = ENET_HOST_TO_NET_32 (peer -> packetThrottleInterval); + verifyCommand.verifyConnect.packetThrottleAcceleration = ENET_HOST_TO_NET_32 (peer -> packetThrottleAcceleration); + verifyCommand.verifyConnect.packetThrottleDeceleration = ENET_HOST_TO_NET_32 (peer -> packetThrottleDeceleration); + verifyCommand.verifyConnect.connectID = peer -> connectID; + + enet_peer_queue_outgoing_command (peer, & verifyCommand, NULL, 0, 0); + + return peer; +} + +static int +enet_protocol_handle_send_reliable (ENetHost * host, ENetPeer * peer, const ENetProtocol * command, enet_uint8 ** currentData) +{ + size_t dataLength; + + if (command -> header.channelID >= peer -> channelCount || + (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER)) + return -1; + + dataLength = ENET_NET_TO_HOST_16 (command -> sendReliable.dataLength); + * currentData += dataLength; + if (dataLength > host -> maximumPacketSize || + * currentData < host -> receivedData || + * currentData > & host -> receivedData [host -> receivedDataLength]) + return -1; + + if (enet_peer_queue_incoming_command (peer, command, (const enet_uint8 *) command + sizeof (ENetProtocolSendReliable), dataLength, ENET_PACKET_FLAG_RELIABLE, 0) == NULL) + return -1; + + return 0; +} + +static int +enet_protocol_handle_send_unsequenced (ENetHost * host, ENetPeer * peer, const ENetProtocol * command, enet_uint8 ** currentData) +{ + enet_uint32 unsequencedGroup, index; + size_t dataLength; + + if (command -> header.channelID >= peer -> channelCount || + (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER)) + return -1; + + dataLength = ENET_NET_TO_HOST_16 (command -> sendUnsequenced.dataLength); + * currentData += dataLength; + if (dataLength > host -> maximumPacketSize || + * currentData < host -> receivedData || + * currentData > & host -> receivedData [host -> receivedDataLength]) + return -1; + + unsequencedGroup = ENET_NET_TO_HOST_16 (command -> sendUnsequenced.unsequencedGroup); + index = unsequencedGroup % ENET_PEER_UNSEQUENCED_WINDOW_SIZE; + + if (unsequencedGroup < peer -> incomingUnsequencedGroup) + unsequencedGroup += 0x10000; + + if (unsequencedGroup >= (enet_uint32) peer -> incomingUnsequencedGroup + ENET_PEER_FREE_UNSEQUENCED_WINDOWS * ENET_PEER_UNSEQUENCED_WINDOW_SIZE) + return 0; + + unsequencedGroup &= 0xFFFF; + + if (unsequencedGroup - index != peer -> incomingUnsequencedGroup) + { + peer -> incomingUnsequencedGroup = unsequencedGroup - index; + + memset (peer -> unsequencedWindow, 0, sizeof (peer -> unsequencedWindow)); + } + else + if (peer -> unsequencedWindow [index / 32] & (1 << (index % 32))) + return 0; + + if (enet_peer_queue_incoming_command (peer, command, (const enet_uint8 *) command + sizeof (ENetProtocolSendUnsequenced), dataLength, ENET_PACKET_FLAG_UNSEQUENCED, 0) == NULL) + return -1; + + peer -> unsequencedWindow [index / 32] |= 1 << (index % 32); + + return 0; +} + +static int +enet_protocol_handle_send_unreliable (ENetHost * host, ENetPeer * peer, const ENetProtocol * command, enet_uint8 ** currentData) +{ + size_t dataLength; + + if (command -> header.channelID >= peer -> channelCount || + (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER)) + return -1; + + dataLength = ENET_NET_TO_HOST_16 (command -> sendUnreliable.dataLength); + * currentData += dataLength; + if (dataLength > host -> maximumPacketSize || + * currentData < host -> receivedData || + * currentData > & host -> receivedData [host -> receivedDataLength]) + return -1; + + if (enet_peer_queue_incoming_command (peer, command, (const enet_uint8 *) command + sizeof (ENetProtocolSendUnreliable), dataLength, 0, 0) == NULL) + return -1; + + return 0; +} + +static int +enet_protocol_handle_send_fragment (ENetHost * host, ENetPeer * peer, const ENetProtocol * command, enet_uint8 ** currentData) +{ + enet_uint32 fragmentNumber, + fragmentCount, + fragmentOffset, + fragmentLength, + startSequenceNumber, + totalLength; + ENetChannel * channel; + enet_uint16 startWindow, currentWindow; + ENetListIterator currentCommand; + ENetIncomingCommand * startCommand = NULL; + + if (command -> header.channelID >= peer -> channelCount || + (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER)) + return -1; + + fragmentLength = ENET_NET_TO_HOST_16 (command -> sendFragment.dataLength); + * currentData += fragmentLength; + if (fragmentLength > host -> maximumPacketSize || + * currentData < host -> receivedData || + * currentData > & host -> receivedData [host -> receivedDataLength]) + return -1; + + channel = & peer -> channels [command -> header.channelID]; + startSequenceNumber = ENET_NET_TO_HOST_16 (command -> sendFragment.startSequenceNumber); + startWindow = startSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + currentWindow = channel -> incomingReliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + + if (startSequenceNumber < channel -> incomingReliableSequenceNumber) + startWindow += ENET_PEER_RELIABLE_WINDOWS; + + if (startWindow < currentWindow || startWindow >= currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS - 1) + return 0; + + fragmentNumber = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentNumber); + fragmentCount = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentCount); + fragmentOffset = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentOffset); + totalLength = ENET_NET_TO_HOST_32 (command -> sendFragment.totalLength); + + if (fragmentCount > ENET_PROTOCOL_MAXIMUM_FRAGMENT_COUNT || + fragmentNumber >= fragmentCount || + totalLength > host -> maximumPacketSize || + fragmentOffset >= totalLength || + fragmentLength > totalLength - fragmentOffset) + return -1; + + for (currentCommand = enet_list_previous (enet_list_end (& channel -> incomingReliableCommands)); + currentCommand != enet_list_end (& channel -> incomingReliableCommands); + currentCommand = enet_list_previous (currentCommand)) + { + ENetIncomingCommand * incomingCommand = (ENetIncomingCommand *) currentCommand; + + if (startSequenceNumber >= channel -> incomingReliableSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + continue; + } + else + if (incomingCommand -> reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber <= startSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < startSequenceNumber) + break; + + if ((incomingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK) != ENET_PROTOCOL_COMMAND_SEND_FRAGMENT || + totalLength != incomingCommand -> packet -> dataLength || + fragmentCount != incomingCommand -> fragmentCount) + return -1; + + startCommand = incomingCommand; + break; + } + } + + if (startCommand == NULL) + { + ENetProtocol hostCommand = * command; + + hostCommand.header.reliableSequenceNumber = startSequenceNumber; + + startCommand = enet_peer_queue_incoming_command (peer, & hostCommand, NULL, totalLength, ENET_PACKET_FLAG_RELIABLE, fragmentCount); + if (startCommand == NULL) + return -1; + } + + if ((startCommand -> fragments [fragmentNumber / 32] & (1 << (fragmentNumber % 32))) == 0) + { + -- startCommand -> fragmentsRemaining; + + startCommand -> fragments [fragmentNumber / 32] |= (1 << (fragmentNumber % 32)); + + if (fragmentOffset + fragmentLength > startCommand -> packet -> dataLength) + fragmentLength = startCommand -> packet -> dataLength - fragmentOffset; + + memcpy (startCommand -> packet -> data + fragmentOffset, + (enet_uint8 *) command + sizeof (ENetProtocolSendFragment), + fragmentLength); + + if (startCommand -> fragmentsRemaining <= 0) + enet_peer_dispatch_incoming_reliable_commands (peer, channel, NULL); + } + + return 0; +} + +static int +enet_protocol_handle_send_unreliable_fragment (ENetHost * host, ENetPeer * peer, const ENetProtocol * command, enet_uint8 ** currentData) +{ + enet_uint32 fragmentNumber, + fragmentCount, + fragmentOffset, + fragmentLength, + reliableSequenceNumber, + startSequenceNumber, + totalLength; + enet_uint16 reliableWindow, currentWindow; + ENetChannel * channel; + ENetListIterator currentCommand; + ENetIncomingCommand * startCommand = NULL; + + if (command -> header.channelID >= peer -> channelCount || + (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER)) + return -1; + + fragmentLength = ENET_NET_TO_HOST_16 (command -> sendFragment.dataLength); + * currentData += fragmentLength; + if (fragmentLength > host -> maximumPacketSize || + * currentData < host -> receivedData || + * currentData > & host -> receivedData [host -> receivedDataLength]) + return -1; + + channel = & peer -> channels [command -> header.channelID]; + reliableSequenceNumber = command -> header.reliableSequenceNumber; + startSequenceNumber = ENET_NET_TO_HOST_16 (command -> sendFragment.startSequenceNumber); + + reliableWindow = reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + currentWindow = channel -> incomingReliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + + if (reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + reliableWindow += ENET_PEER_RELIABLE_WINDOWS; + + if (reliableWindow < currentWindow || reliableWindow >= currentWindow + ENET_PEER_FREE_RELIABLE_WINDOWS - 1) + return 0; + + if (reliableSequenceNumber == channel -> incomingReliableSequenceNumber && + startSequenceNumber <= channel -> incomingUnreliableSequenceNumber) + return 0; + + fragmentNumber = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentNumber); + fragmentCount = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentCount); + fragmentOffset = ENET_NET_TO_HOST_32 (command -> sendFragment.fragmentOffset); + totalLength = ENET_NET_TO_HOST_32 (command -> sendFragment.totalLength); + + if (fragmentCount > ENET_PROTOCOL_MAXIMUM_FRAGMENT_COUNT || + fragmentNumber >= fragmentCount || + totalLength > host -> maximumPacketSize || + fragmentOffset >= totalLength || + fragmentLength > totalLength - fragmentOffset) + return -1; + + for (currentCommand = enet_list_previous (enet_list_end (& channel -> incomingUnreliableCommands)); + currentCommand != enet_list_end (& channel -> incomingUnreliableCommands); + currentCommand = enet_list_previous (currentCommand)) + { + ENetIncomingCommand * incomingCommand = (ENetIncomingCommand *) currentCommand; + + if (reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + { + if (incomingCommand -> reliableSequenceNumber < channel -> incomingReliableSequenceNumber) + continue; + } + else + if (incomingCommand -> reliableSequenceNumber >= channel -> incomingReliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber < reliableSequenceNumber) + break; + + if (incomingCommand -> reliableSequenceNumber > reliableSequenceNumber) + continue; + + if (incomingCommand -> unreliableSequenceNumber <= startSequenceNumber) + { + if (incomingCommand -> unreliableSequenceNumber < startSequenceNumber) + break; + + if ((incomingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK) != ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE_FRAGMENT || + totalLength != incomingCommand -> packet -> dataLength || + fragmentCount != incomingCommand -> fragmentCount) + return -1; + + startCommand = incomingCommand; + break; + } + } + + if (startCommand == NULL) + { + startCommand = enet_peer_queue_incoming_command (peer, command, NULL, totalLength, ENET_PACKET_FLAG_UNRELIABLE_FRAGMENT, fragmentCount); + if (startCommand == NULL) + return -1; + } + + if ((startCommand -> fragments [fragmentNumber / 32] & (1 << (fragmentNumber % 32))) == 0) + { + -- startCommand -> fragmentsRemaining; + + startCommand -> fragments [fragmentNumber / 32] |= (1 << (fragmentNumber % 32)); + + if (fragmentOffset + fragmentLength > startCommand -> packet -> dataLength) + fragmentLength = startCommand -> packet -> dataLength - fragmentOffset; + + memcpy (startCommand -> packet -> data + fragmentOffset, + (enet_uint8 *) command + sizeof (ENetProtocolSendFragment), + fragmentLength); + + if (startCommand -> fragmentsRemaining <= 0) + enet_peer_dispatch_incoming_unreliable_commands (peer, channel, NULL); + } + + return 0; +} + +static int +enet_protocol_handle_ping (ENetHost * host, ENetPeer * peer, const ENetProtocol * command) +{ + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + return -1; + + return 0; +} + +static int +enet_protocol_handle_bandwidth_limit (ENetHost * host, ENetPeer * peer, const ENetProtocol * command) +{ + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + return -1; + + if (peer -> incomingBandwidth != 0) + -- host -> bandwidthLimitedPeers; + + peer -> incomingBandwidth = ENET_NET_TO_HOST_32 (command -> bandwidthLimit.incomingBandwidth); + peer -> outgoingBandwidth = ENET_NET_TO_HOST_32 (command -> bandwidthLimit.outgoingBandwidth); + + if (peer -> incomingBandwidth != 0) + ++ host -> bandwidthLimitedPeers; + + if (peer -> incomingBandwidth == 0 && host -> outgoingBandwidth == 0) + peer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + else + if (peer -> incomingBandwidth == 0 || host -> outgoingBandwidth == 0) + peer -> windowSize = (ENET_MAX (peer -> incomingBandwidth, host -> outgoingBandwidth) / + ENET_PEER_WINDOW_SIZE_SCALE) * ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + peer -> windowSize = (ENET_MIN (peer -> incomingBandwidth, host -> outgoingBandwidth) / + ENET_PEER_WINDOW_SIZE_SCALE) * ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + + if (peer -> windowSize < ENET_PROTOCOL_MINIMUM_WINDOW_SIZE) + peer -> windowSize = ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + else + if (peer -> windowSize > ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE) + peer -> windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + + return 0; +} + +static int +enet_protocol_handle_throttle_configure (ENetHost * host, ENetPeer * peer, const ENetProtocol * command) +{ + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + return -1; + + peer -> packetThrottleInterval = ENET_NET_TO_HOST_32 (command -> throttleConfigure.packetThrottleInterval); + peer -> packetThrottleAcceleration = ENET_NET_TO_HOST_32 (command -> throttleConfigure.packetThrottleAcceleration); + peer -> packetThrottleDeceleration = ENET_NET_TO_HOST_32 (command -> throttleConfigure.packetThrottleDeceleration); + + return 0; +} + +static int +enet_protocol_handle_disconnect (ENetHost * host, ENetPeer * peer, const ENetProtocol * command) +{ + if (peer -> state == ENET_PEER_STATE_DISCONNECTED || peer -> state == ENET_PEER_STATE_ZOMBIE || peer -> state == ENET_PEER_STATE_ACKNOWLEDGING_DISCONNECT) + return 0; + + enet_peer_reset_queues (peer); + + if (peer -> state == ENET_PEER_STATE_CONNECTION_SUCCEEDED || peer -> state == ENET_PEER_STATE_DISCONNECTING || peer -> state == ENET_PEER_STATE_CONNECTING) + enet_protocol_dispatch_state (host, peer, ENET_PEER_STATE_ZOMBIE); + else + if (peer -> state != ENET_PEER_STATE_CONNECTED && peer -> state != ENET_PEER_STATE_DISCONNECT_LATER) + { + if (peer -> state == ENET_PEER_STATE_CONNECTION_PENDING) host -> recalculateBandwidthLimits = 1; + + enet_peer_reset (peer); + } + else + if (command -> header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) + enet_protocol_change_state (host, peer, ENET_PEER_STATE_ACKNOWLEDGING_DISCONNECT); + else + enet_protocol_dispatch_state (host, peer, ENET_PEER_STATE_ZOMBIE); + + if (peer -> state != ENET_PEER_STATE_DISCONNECTED) + peer -> eventData = ENET_NET_TO_HOST_32 (command -> disconnect.data); + + return 0; +} + +static int +enet_protocol_handle_acknowledge (ENetHost * host, ENetEvent * event, ENetPeer * peer, const ENetProtocol * command) +{ + enet_uint32 roundTripTime, + receivedSentTime, + receivedReliableSequenceNumber; + ENetProtocolCommand commandNumber; + + if (peer -> state == ENET_PEER_STATE_DISCONNECTED || peer -> state == ENET_PEER_STATE_ZOMBIE) + return 0; + + receivedSentTime = ENET_NET_TO_HOST_16 (command -> acknowledge.receivedSentTime); + receivedSentTime |= host -> serviceTime & 0xFFFF0000; + if ((receivedSentTime & 0x8000) > (host -> serviceTime & 0x8000)) + receivedSentTime -= 0x10000; + + if (ENET_TIME_LESS (host -> serviceTime, receivedSentTime)) + return 0; + + roundTripTime = ENET_TIME_DIFFERENCE (host -> serviceTime, receivedSentTime); + roundTripTime = ENET_MAX (roundTripTime, 1); + + if (peer -> lastReceiveTime > 0) + { + enet_peer_throttle (peer, roundTripTime); + + peer -> roundTripTimeVariance -= (peer -> roundTripTimeVariance + 3) / 4; + + if (roundTripTime >= peer -> roundTripTime) + { + enet_uint32 diff = roundTripTime - peer -> roundTripTime; + peer -> roundTripTimeVariance += (diff + 3) / 4; + peer -> roundTripTime += (diff + 7) / 8; + } + else + { + enet_uint32 diff = peer -> roundTripTime - roundTripTime; + peer -> roundTripTimeVariance += (diff + 3) / 4; + peer -> roundTripTime -= (diff + 7) / 8; + } + } + else + { + peer -> roundTripTime = roundTripTime; + peer -> roundTripTimeVariance = (roundTripTime + 1) / 2; + } + + if (peer -> roundTripTime < peer -> lowestRoundTripTime) + peer -> lowestRoundTripTime = peer -> roundTripTime; + + if (peer -> roundTripTimeVariance > peer -> highestRoundTripTimeVariance) + peer -> highestRoundTripTimeVariance = peer -> roundTripTimeVariance; + + if (peer -> packetThrottleEpoch == 0 || + ENET_TIME_DIFFERENCE (host -> serviceTime, peer -> packetThrottleEpoch) >= peer -> packetThrottleInterval) + { + peer -> lastRoundTripTime = peer -> lowestRoundTripTime; + peer -> lastRoundTripTimeVariance = ENET_MAX (peer -> highestRoundTripTimeVariance, 1); + peer -> lowestRoundTripTime = peer -> roundTripTime; + peer -> highestRoundTripTimeVariance = peer -> roundTripTimeVariance; + peer -> packetThrottleEpoch = host -> serviceTime; + } + + peer -> lastReceiveTime = ENET_MAX (host -> serviceTime, 1); + peer -> earliestTimeout = 0; + + receivedReliableSequenceNumber = ENET_NET_TO_HOST_16 (command -> acknowledge.receivedReliableSequenceNumber); + + commandNumber = enet_protocol_remove_sent_reliable_command (peer, receivedReliableSequenceNumber, command -> header.channelID); + + switch (peer -> state) + { + case ENET_PEER_STATE_ACKNOWLEDGING_CONNECT: + if (commandNumber != ENET_PROTOCOL_COMMAND_VERIFY_CONNECT) + return -1; + + enet_protocol_notify_connect (host, peer, event); + break; + + case ENET_PEER_STATE_DISCONNECTING: + if (commandNumber != ENET_PROTOCOL_COMMAND_DISCONNECT) + return -1; + + enet_protocol_notify_disconnect (host, peer, event); + break; + + case ENET_PEER_STATE_DISCONNECT_LATER: + if (enet_list_empty (& peer -> outgoingCommands) && + enet_list_empty (& peer -> sentReliableCommands)) + enet_peer_disconnect (peer, peer -> eventData); + break; + + default: + break; + } + + return 0; +} + +static int +enet_protocol_handle_verify_connect (ENetHost * host, ENetEvent * event, ENetPeer * peer, const ENetProtocol * command) +{ + enet_uint32 mtu, windowSize; + size_t channelCount; + + if (peer -> state != ENET_PEER_STATE_CONNECTING) + return 0; + + channelCount = ENET_NET_TO_HOST_32 (command -> verifyConnect.channelCount); + + if (channelCount < ENET_PROTOCOL_MINIMUM_CHANNEL_COUNT || channelCount > ENET_PROTOCOL_MAXIMUM_CHANNEL_COUNT || + ENET_NET_TO_HOST_32 (command -> verifyConnect.packetThrottleInterval) != peer -> packetThrottleInterval || + ENET_NET_TO_HOST_32 (command -> verifyConnect.packetThrottleAcceleration) != peer -> packetThrottleAcceleration || + ENET_NET_TO_HOST_32 (command -> verifyConnect.packetThrottleDeceleration) != peer -> packetThrottleDeceleration || + command -> verifyConnect.connectID != peer -> connectID) + { + peer -> eventData = 0; + + enet_protocol_dispatch_state (host, peer, ENET_PEER_STATE_ZOMBIE); + + return -1; + } + + enet_protocol_remove_sent_reliable_command (peer, 1, 0xFF); + + if (channelCount < peer -> channelCount) + peer -> channelCount = channelCount; + + peer -> outgoingPeerID = ENET_NET_TO_HOST_16 (command -> verifyConnect.outgoingPeerID); + peer -> incomingSessionID = command -> verifyConnect.incomingSessionID; + peer -> outgoingSessionID = command -> verifyConnect.outgoingSessionID; + + mtu = ENET_NET_TO_HOST_32 (command -> verifyConnect.mtu); + + if (mtu < ENET_PROTOCOL_MINIMUM_MTU) + mtu = ENET_PROTOCOL_MINIMUM_MTU; + else + if (mtu > ENET_PROTOCOL_MAXIMUM_MTU) + mtu = ENET_PROTOCOL_MAXIMUM_MTU; + + if (mtu < peer -> mtu) + peer -> mtu = mtu; + + windowSize = ENET_NET_TO_HOST_32 (command -> verifyConnect.windowSize); + + if (windowSize < ENET_PROTOCOL_MINIMUM_WINDOW_SIZE) + windowSize = ENET_PROTOCOL_MINIMUM_WINDOW_SIZE; + + if (windowSize > ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE) + windowSize = ENET_PROTOCOL_MAXIMUM_WINDOW_SIZE; + + if (windowSize < peer -> windowSize) + peer -> windowSize = windowSize; + + peer -> incomingBandwidth = ENET_NET_TO_HOST_32 (command -> verifyConnect.incomingBandwidth); + peer -> outgoingBandwidth = ENET_NET_TO_HOST_32 (command -> verifyConnect.outgoingBandwidth); + + enet_protocol_notify_connect (host, peer, event); + return 0; +} + +static int +enet_protocol_handle_incoming_commands (ENetHost * host, ENetEvent * event) +{ + ENetProtocolHeader * header; + ENetProtocol * command; + ENetPeer * peer; + enet_uint8 * currentData; + size_t headerSize; + enet_uint16 peerID, flags; + enet_uint8 sessionID; + + if (host -> receivedDataLength < (size_t) & ((ENetProtocolHeader *) 0) -> sentTime) + return 0; + + header = (ENetProtocolHeader *) host -> receivedData; + + peerID = ENET_NET_TO_HOST_16 (header -> peerID); + sessionID = (peerID & ENET_PROTOCOL_HEADER_SESSION_MASK) >> ENET_PROTOCOL_HEADER_SESSION_SHIFT; + flags = peerID & ENET_PROTOCOL_HEADER_FLAG_MASK; + peerID &= ~ (ENET_PROTOCOL_HEADER_FLAG_MASK | ENET_PROTOCOL_HEADER_SESSION_MASK); + + headerSize = (flags & ENET_PROTOCOL_HEADER_FLAG_SENT_TIME ? sizeof (ENetProtocolHeader) : (size_t) & ((ENetProtocolHeader *) 0) -> sentTime); + if (host -> checksum != NULL) + headerSize += sizeof (enet_uint32); + + if (peerID == ENET_PROTOCOL_MAXIMUM_PEER_ID) + peer = NULL; + else + if (peerID >= host -> peerCount) + return 0; + else + { + peer = & host -> peers [peerID]; + + if (peer -> state == ENET_PEER_STATE_DISCONNECTED || + peer -> state == ENET_PEER_STATE_ZOMBIE || + /* ! enet_address_equal(& host -> receivedAddress, & peer -> address) || */ + (peer -> outgoingPeerID < ENET_PROTOCOL_MAXIMUM_PEER_ID && + sessionID != peer -> incomingSessionID)) + return 0; + } + + if (flags & ENET_PROTOCOL_HEADER_FLAG_COMPRESSED) + { + size_t originalSize; + if (host -> compressor.context == NULL || host -> compressor.decompress == NULL) + return 0; + + originalSize = host -> compressor.decompress (host -> compressor.context, + host -> receivedData + headerSize, + host -> receivedDataLength - headerSize, + host -> packetData [1] + headerSize, + sizeof (host -> packetData [1]) - headerSize); + if (originalSize <= 0 || originalSize > sizeof (host -> packetData [1]) - headerSize) + return 0; + + memcpy (host -> packetData [1], header, headerSize); + host -> receivedData = host -> packetData [1]; + host -> receivedDataLength = headerSize + originalSize; + } + + if (host -> checksum != NULL) + { + enet_uint32 * checksum = (enet_uint32 *) & host -> receivedData [headerSize - sizeof (enet_uint32)], + desiredChecksum = * checksum; + ENetBuffer buffer; + + * checksum = peer != NULL ? peer -> connectID : 0; + + buffer.data = host -> receivedData; + buffer.dataLength = host -> receivedDataLength; + + if (host -> checksum (& buffer, 1) != desiredChecksum) + return 0; + } + + if (peer != NULL) + { + memcpy(& peer -> address, & host -> receivedAddress, sizeof (host -> receivedAddress)); + peer -> incomingDataTotal += host -> receivedDataLength; + } + + currentData = host -> receivedData + headerSize; + + while (currentData < & host -> receivedData [host -> receivedDataLength]) + { + enet_uint8 commandNumber; + size_t commandSize; + + command = (ENetProtocol *) currentData; + + if (currentData + sizeof (ENetProtocolCommandHeader) > & host -> receivedData [host -> receivedDataLength]) + break; + + commandNumber = command -> header.command & ENET_PROTOCOL_COMMAND_MASK; + if (commandNumber >= ENET_PROTOCOL_COMMAND_COUNT) + break; + + commandSize = commandSizes [commandNumber]; + if (commandSize == 0 || currentData + commandSize > & host -> receivedData [host -> receivedDataLength]) + break; + + currentData += commandSize; + + if (peer == NULL && commandNumber != ENET_PROTOCOL_COMMAND_CONNECT) + break; + + command -> header.reliableSequenceNumber = ENET_NET_TO_HOST_16 (command -> header.reliableSequenceNumber); + + switch (commandNumber) + { + case ENET_PROTOCOL_COMMAND_ACKNOWLEDGE: + if (enet_protocol_handle_acknowledge (host, event, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_CONNECT: + if (peer != NULL) + goto commandError; + peer = enet_protocol_handle_connect (host, header, command); + if (peer == NULL) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_VERIFY_CONNECT: + if (enet_protocol_handle_verify_connect (host, event, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_DISCONNECT: + if (enet_protocol_handle_disconnect (host, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_PING: + if (enet_protocol_handle_ping (host, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_SEND_RELIABLE: + if (enet_protocol_handle_send_reliable (host, peer, command, & currentData)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE: + if (enet_protocol_handle_send_unreliable (host, peer, command, & currentData)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNSEQUENCED: + if (enet_protocol_handle_send_unsequenced (host, peer, command, & currentData)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_SEND_FRAGMENT: + if (enet_protocol_handle_send_fragment (host, peer, command, & currentData)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_BANDWIDTH_LIMIT: + if (enet_protocol_handle_bandwidth_limit (host, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_THROTTLE_CONFIGURE: + if (enet_protocol_handle_throttle_configure (host, peer, command)) + goto commandError; + break; + + case ENET_PROTOCOL_COMMAND_SEND_UNRELIABLE_FRAGMENT: + if (enet_protocol_handle_send_unreliable_fragment (host, peer, command, & currentData)) + goto commandError; + break; + + default: + goto commandError; + } + + if (peer != NULL && + (command -> header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) != 0) + { + enet_uint16 sentTime; + + if (! (flags & ENET_PROTOCOL_HEADER_FLAG_SENT_TIME)) + break; + + sentTime = ENET_NET_TO_HOST_16 (header -> sentTime); + + switch (peer -> state) + { + case ENET_PEER_STATE_DISCONNECTING: + case ENET_PEER_STATE_ACKNOWLEDGING_CONNECT: + case ENET_PEER_STATE_DISCONNECTED: + case ENET_PEER_STATE_ZOMBIE: + break; + + case ENET_PEER_STATE_ACKNOWLEDGING_DISCONNECT: + if ((command -> header.command & ENET_PROTOCOL_COMMAND_MASK) == ENET_PROTOCOL_COMMAND_DISCONNECT) + enet_peer_queue_acknowledgement (peer, command, sentTime); + break; + + default: + enet_peer_queue_acknowledgement (peer, command, sentTime); + break; + } + } + } + +commandError: + if (event != NULL && event -> type != ENET_EVENT_TYPE_NONE) + return 1; + + return 0; +} + +static int +enet_protocol_receive_incoming_commands (ENetHost * host, ENetEvent * event) +{ + int packets; + + for (packets = 0; packets < 256; ++ packets) + { + int receivedLength; + ENetBuffer buffer; + + buffer.data = host -> packetData [0]; + buffer.dataLength = sizeof (host -> packetData [0]); + + receivedLength = enet_socket_receive (host -> socket, + & host -> receivedAddress, + & buffer, + 1); + + if (receivedLength < 0) + return -1; + + if (receivedLength == 0) + return 0; + + host -> receivedData = host -> packetData [0]; + host -> receivedDataLength = receivedLength; + + host -> totalReceivedData += receivedLength; + host -> totalReceivedPackets ++; + + if (host -> intercept != NULL) + { + switch (host -> intercept (host, event)) + { + case 1: + if (event != NULL && event -> type != ENET_EVENT_TYPE_NONE) + return 1; + + continue; + + case -1: + return -1; + + default: + break; + } + } + + switch (enet_protocol_handle_incoming_commands (host, event)) + { + case 1: + return 1; + + case -1: + return -1; + + default: + break; + } + } + + return 0; +} + +static void +enet_protocol_send_acknowledgements (ENetHost * host, ENetPeer * peer) +{ + ENetProtocol * command = & host -> commands [host -> commandCount]; + ENetBuffer * buffer = & host -> buffers [host -> bufferCount]; + ENetAcknowledgement * acknowledgement; + ENetListIterator currentAcknowledgement; + enet_uint16 reliableSequenceNumber; + + currentAcknowledgement = enet_list_begin (& peer -> acknowledgements); + + while (currentAcknowledgement != enet_list_end (& peer -> acknowledgements)) + { + if (command >= & host -> commands [sizeof (host -> commands) / sizeof (ENetProtocol)] || + buffer >= & host -> buffers [sizeof (host -> buffers) / sizeof (ENetBuffer)] || + peer -> mtu - host -> packetSize < sizeof (ENetProtocolAcknowledge)) + { + host -> continueSending = 1; + + break; + } + + acknowledgement = (ENetAcknowledgement *) currentAcknowledgement; + + currentAcknowledgement = enet_list_next (currentAcknowledgement); + + buffer -> data = command; + buffer -> dataLength = sizeof (ENetProtocolAcknowledge); + + host -> packetSize += buffer -> dataLength; + + reliableSequenceNumber = ENET_HOST_TO_NET_16 (acknowledgement -> command.header.reliableSequenceNumber); + + command -> header.command = ENET_PROTOCOL_COMMAND_ACKNOWLEDGE; + command -> header.channelID = acknowledgement -> command.header.channelID; + command -> header.reliableSequenceNumber = reliableSequenceNumber; + command -> acknowledge.receivedReliableSequenceNumber = reliableSequenceNumber; + command -> acknowledge.receivedSentTime = ENET_HOST_TO_NET_16 (acknowledgement -> sentTime); + + if ((acknowledgement -> command.header.command & ENET_PROTOCOL_COMMAND_MASK) == ENET_PROTOCOL_COMMAND_DISCONNECT) + enet_protocol_dispatch_state (host, peer, ENET_PEER_STATE_ZOMBIE); + + enet_list_remove (& acknowledgement -> acknowledgementList); + enet_free (acknowledgement); + + ++ command; + ++ buffer; + } + + host -> commandCount = command - host -> commands; + host -> bufferCount = buffer - host -> buffers; +} + +static int +enet_protocol_check_timeouts (ENetHost * host, ENetPeer * peer, ENetEvent * event) +{ + ENetOutgoingCommand * outgoingCommand; + ENetListIterator currentCommand, insertPosition; + + currentCommand = enet_list_begin (& peer -> sentReliableCommands); + insertPosition = enet_list_begin (& peer -> outgoingCommands); + + while (currentCommand != enet_list_end (& peer -> sentReliableCommands)) + { + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + + currentCommand = enet_list_next (currentCommand); + + if (ENET_TIME_DIFFERENCE (host -> serviceTime, outgoingCommand -> sentTime) < outgoingCommand -> roundTripTimeout) + continue; + + if (peer -> earliestTimeout == 0 || + ENET_TIME_LESS (outgoingCommand -> sentTime, peer -> earliestTimeout)) + peer -> earliestTimeout = outgoingCommand -> sentTime; + + if (peer -> earliestTimeout != 0 && + (ENET_TIME_DIFFERENCE (host -> serviceTime, peer -> earliestTimeout) >= peer -> timeoutMaximum || + (outgoingCommand -> roundTripTimeout >= outgoingCommand -> roundTripTimeoutLimit && + ENET_TIME_DIFFERENCE (host -> serviceTime, peer -> earliestTimeout) >= peer -> timeoutMinimum))) + { + enet_protocol_notify_disconnect (host, peer, event); + + return 1; + } + + if (outgoingCommand -> packet != NULL) + peer -> reliableDataInTransit -= outgoingCommand -> fragmentLength; + + ++ peer -> packetsLost; + + outgoingCommand -> roundTripTimeout *= 2; + if (outgoingCommand -> roundTripTimeout > outgoingCommand -> roundTripTimeoutLimit) + outgoingCommand -> roundTripTimeout = outgoingCommand -> roundTripTimeoutLimit; + + enet_list_insert (insertPosition, enet_list_remove (& outgoingCommand -> outgoingCommandList)); + + if (currentCommand == enet_list_begin (& peer -> sentReliableCommands) && + ! enet_list_empty (& peer -> sentReliableCommands)) + { + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + + peer -> nextTimeout = outgoingCommand -> sentTime + outgoingCommand -> roundTripTimeout; + } + } + + return 0; +} + +static int +enet_protocol_check_outgoing_commands (ENetHost * host, ENetPeer * peer) +{ + ENetProtocol * command = & host -> commands [host -> commandCount]; + ENetBuffer * buffer = & host -> buffers [host -> bufferCount]; + ENetOutgoingCommand * outgoingCommand; + ENetListIterator currentCommand; + ENetChannel *channel = NULL; + enet_uint16 reliableWindow = 0; + size_t commandSize; + int windowExceeded = 0, windowWrap = 0, canPing = 1; + + currentCommand = enet_list_begin (& peer -> outgoingCommands); + + while (currentCommand != enet_list_end (& peer -> outgoingCommands)) + { + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + + if (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) + { + channel = outgoingCommand -> command.header.channelID < peer -> channelCount ? & peer -> channels [outgoingCommand -> command.header.channelID] : NULL; + reliableWindow = outgoingCommand -> reliableSequenceNumber / ENET_PEER_RELIABLE_WINDOW_SIZE; + if (channel != NULL) + { + if (! windowWrap && + outgoingCommand -> sendAttempts < 1 && + ! (outgoingCommand -> reliableSequenceNumber % ENET_PEER_RELIABLE_WINDOW_SIZE) && + (channel -> reliableWindows [(reliableWindow + ENET_PEER_RELIABLE_WINDOWS - 1) % ENET_PEER_RELIABLE_WINDOWS] >= ENET_PEER_RELIABLE_WINDOW_SIZE || + channel -> usedReliableWindows & ((((1 << (ENET_PEER_FREE_RELIABLE_WINDOWS + 2)) - 1) << reliableWindow) | + (((1 << (ENET_PEER_FREE_RELIABLE_WINDOWS + 2)) - 1) >> (ENET_PEER_RELIABLE_WINDOWS - reliableWindow))))) + windowWrap = 1; + if (windowWrap) + { + currentCommand = enet_list_next (currentCommand); + + continue; + } + } + + if (outgoingCommand -> packet != NULL) + { + if (! windowExceeded) + { + enet_uint32 windowSize = (peer -> packetThrottle * peer -> windowSize) / ENET_PEER_PACKET_THROTTLE_SCALE; + + if (peer -> reliableDataInTransit + outgoingCommand -> fragmentLength > ENET_MAX (windowSize, peer -> mtu)) + windowExceeded = 1; + } + if (windowExceeded) + { + currentCommand = enet_list_next (currentCommand); + + continue; + } + } + + canPing = 0; + } + + commandSize = commandSizes [outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_MASK]; + if (command >= & host -> commands [sizeof (host -> commands) / sizeof (ENetProtocol)] || + buffer + 1 >= & host -> buffers [sizeof (host -> buffers) / sizeof (ENetBuffer)] || + peer -> mtu - host -> packetSize < commandSize || + (outgoingCommand -> packet != NULL && + (enet_uint16) (peer -> mtu - host -> packetSize) < (enet_uint16) (commandSize + outgoingCommand -> fragmentLength))) + { + host -> continueSending = 1; + + break; + } + + currentCommand = enet_list_next (currentCommand); + + if (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE) + { + if (channel != NULL && outgoingCommand -> sendAttempts < 1) + { + channel -> usedReliableWindows |= 1 << reliableWindow; + ++ channel -> reliableWindows [reliableWindow]; + } + + ++ outgoingCommand -> sendAttempts; + + if (outgoingCommand -> roundTripTimeout == 0) + { + outgoingCommand -> roundTripTimeout = peer -> roundTripTime + 4 * ENET_MAX (1, peer -> roundTripTimeVariance); + outgoingCommand -> roundTripTimeoutLimit = peer -> timeoutLimit * outgoingCommand -> roundTripTimeout; + } + + if (enet_list_empty (& peer -> sentReliableCommands)) + peer -> nextTimeout = host -> serviceTime + outgoingCommand -> roundTripTimeout; + + enet_list_insert (enet_list_end (& peer -> sentReliableCommands), + enet_list_remove (& outgoingCommand -> outgoingCommandList)); + + outgoingCommand -> sentTime = host -> serviceTime; + + host -> headerFlags |= ENET_PROTOCOL_HEADER_FLAG_SENT_TIME; + + peer -> reliableDataInTransit += outgoingCommand -> fragmentLength; + } + else + { + if (outgoingCommand -> packet != NULL && outgoingCommand -> fragmentOffset == 0) + { + peer -> packetThrottleCounter += ENET_PEER_PACKET_THROTTLE_COUNTER; + peer -> packetThrottleCounter %= ENET_PEER_PACKET_THROTTLE_SCALE; + + if (peer -> packetThrottleCounter > peer -> packetThrottle) + { + enet_uint16 reliableSequenceNumber = outgoingCommand -> reliableSequenceNumber, + unreliableSequenceNumber = outgoingCommand -> unreliableSequenceNumber; + for (;;) + { + -- outgoingCommand -> packet -> referenceCount; + + if (outgoingCommand -> packet -> referenceCount == 0) + enet_packet_destroy (outgoingCommand -> packet); + + enet_list_remove (& outgoingCommand -> outgoingCommandList); + enet_free (outgoingCommand); + + if (currentCommand == enet_list_end (& peer -> outgoingCommands)) + break; + + outgoingCommand = (ENetOutgoingCommand *) currentCommand; + if (outgoingCommand -> reliableSequenceNumber != reliableSequenceNumber || + outgoingCommand -> unreliableSequenceNumber != unreliableSequenceNumber) + break; + + currentCommand = enet_list_next (currentCommand); + } + + continue; + } + } + + enet_list_remove (& outgoingCommand -> outgoingCommandList); + + if (outgoingCommand -> packet != NULL) + enet_list_insert (enet_list_end (& peer -> sentUnreliableCommands), outgoingCommand); + } + + buffer -> data = command; + buffer -> dataLength = commandSize; + + host -> packetSize += buffer -> dataLength; + + * command = outgoingCommand -> command; + + if (outgoingCommand -> packet != NULL) + { + ++ buffer; + + buffer -> data = outgoingCommand -> packet -> data + outgoingCommand -> fragmentOffset; + buffer -> dataLength = outgoingCommand -> fragmentLength; + + host -> packetSize += outgoingCommand -> fragmentLength; + } + else + if (! (outgoingCommand -> command.header.command & ENET_PROTOCOL_COMMAND_FLAG_ACKNOWLEDGE)) + enet_free (outgoingCommand); + + ++ peer -> packetsSent; + + ++ command; + ++ buffer; + } + + host -> commandCount = command - host -> commands; + host -> bufferCount = buffer - host -> buffers; + + if (peer -> state == ENET_PEER_STATE_DISCONNECT_LATER && + enet_list_empty (& peer -> outgoingCommands) && + enet_list_empty (& peer -> sentReliableCommands) && + enet_list_empty (& peer -> sentUnreliableCommands)) + enet_peer_disconnect (peer, peer -> eventData); + + return canPing; +} + +static int +enet_protocol_send_outgoing_commands (ENetHost * host, ENetEvent * event, int checkForTimeouts) +{ + enet_uint8 headerData [sizeof (ENetProtocolHeader) + sizeof (enet_uint32)]; + ENetProtocolHeader * header = (ENetProtocolHeader *) headerData; + ENetPeer * currentPeer; + int sentLength; + size_t shouldCompress = 0; + + host -> continueSending = 1; + + while (host -> continueSending) + for (host -> continueSending = 0, + currentPeer = host -> peers; + currentPeer < & host -> peers [host -> peerCount]; + ++ currentPeer) + { + if (currentPeer -> state == ENET_PEER_STATE_DISCONNECTED || + currentPeer -> state == ENET_PEER_STATE_ZOMBIE) + continue; + + host -> headerFlags = 0; + host -> commandCount = 0; + host -> bufferCount = 1; + host -> packetSize = sizeof (ENetProtocolHeader); + + if (! enet_list_empty (& currentPeer -> acknowledgements)) + enet_protocol_send_acknowledgements (host, currentPeer); + + if (checkForTimeouts != 0 && + ! enet_list_empty (& currentPeer -> sentReliableCommands) && + ENET_TIME_GREATER_EQUAL (host -> serviceTime, currentPeer -> nextTimeout) && + enet_protocol_check_timeouts (host, currentPeer, event) == 1) + { + if (event != NULL && event -> type != ENET_EVENT_TYPE_NONE) + return 1; + else + continue; + } + + if ((enet_list_empty (& currentPeer -> outgoingCommands) || + enet_protocol_check_outgoing_commands (host, currentPeer)) && + enet_list_empty (& currentPeer -> sentReliableCommands) && + ENET_TIME_DIFFERENCE (host -> serviceTime, currentPeer -> lastReceiveTime) >= currentPeer -> pingInterval && + currentPeer -> mtu - host -> packetSize >= sizeof (ENetProtocolPing)) + { + enet_peer_ping (currentPeer); + enet_protocol_check_outgoing_commands (host, currentPeer); + } + + if (host -> commandCount == 0) + continue; + + if (currentPeer -> packetLossEpoch == 0) + currentPeer -> packetLossEpoch = host -> serviceTime; + else + if (ENET_TIME_DIFFERENCE (host -> serviceTime, currentPeer -> packetLossEpoch) >= ENET_PEER_PACKET_LOSS_INTERVAL && + currentPeer -> packetsSent > 0) + { + enet_uint32 packetLoss = currentPeer -> packetsLost * ENET_PEER_PACKET_LOSS_SCALE / currentPeer -> packetsSent; + +#ifdef ENET_DEBUG + printf ("peer %u: %f%%+-%f%% packet loss, %u+-%u ms round trip time, %f%% throttle, %u outgoing, %u/%u incoming\n", currentPeer -> incomingPeerID, currentPeer -> packetLoss / (float) ENET_PEER_PACKET_LOSS_SCALE, currentPeer -> packetLossVariance / (float) ENET_PEER_PACKET_LOSS_SCALE, currentPeer -> roundTripTime, currentPeer -> roundTripTimeVariance, currentPeer -> packetThrottle / (float) ENET_PEER_PACKET_THROTTLE_SCALE, enet_list_size (& currentPeer -> outgoingCommands), currentPeer -> channels != NULL ? enet_list_size (& currentPeer -> channels -> incomingReliableCommands) : 0, currentPeer -> channels != NULL ? enet_list_size (& currentPeer -> channels -> incomingUnreliableCommands) : 0); +#endif + + currentPeer -> packetLossVariance = (currentPeer -> packetLossVariance * 3 + ENET_DIFFERENCE (packetLoss, currentPeer -> packetLoss)) / 4; + currentPeer -> packetLoss = (currentPeer -> packetLoss * 7 + packetLoss) / 8; + + currentPeer -> packetLossEpoch = host -> serviceTime; + currentPeer -> packetsSent = 0; + currentPeer -> packetsLost = 0; + } + + host -> buffers -> data = headerData; + if (host -> headerFlags & ENET_PROTOCOL_HEADER_FLAG_SENT_TIME) + { + header -> sentTime = ENET_HOST_TO_NET_16 (host -> serviceTime & 0xFFFF); + + host -> buffers -> dataLength = sizeof (ENetProtocolHeader); + } + else + host -> buffers -> dataLength = (size_t) & ((ENetProtocolHeader *) 0) -> sentTime; + + shouldCompress = 0; + if (host -> compressor.context != NULL && host -> compressor.compress != NULL) + { + size_t originalSize = host -> packetSize - sizeof(ENetProtocolHeader), + compressedSize = host -> compressor.compress (host -> compressor.context, + & host -> buffers [1], host -> bufferCount - 1, + originalSize, + host -> packetData [1], + originalSize); + if (compressedSize > 0 && compressedSize < originalSize) + { + host -> headerFlags |= ENET_PROTOCOL_HEADER_FLAG_COMPRESSED; + shouldCompress = compressedSize; +#ifdef ENET_DEBUG_COMPRESS + printf ("peer %u: compressed %u -> %u (%u%%)\n", currentPeer -> incomingPeerID, originalSize, compressedSize, (compressedSize * 100) / originalSize); +#endif + } + } + + if (currentPeer -> outgoingPeerID < ENET_PROTOCOL_MAXIMUM_PEER_ID) + host -> headerFlags |= currentPeer -> outgoingSessionID << ENET_PROTOCOL_HEADER_SESSION_SHIFT; + header -> peerID = ENET_HOST_TO_NET_16 (currentPeer -> outgoingPeerID | host -> headerFlags); + if (host -> checksum != NULL) + { + enet_uint32 * checksum = (enet_uint32 *) & headerData [host -> buffers -> dataLength]; + * checksum = currentPeer -> outgoingPeerID < ENET_PROTOCOL_MAXIMUM_PEER_ID ? currentPeer -> connectID : 0; + host -> buffers -> dataLength += sizeof (enet_uint32); + * checksum = host -> checksum (host -> buffers, host -> bufferCount); + } + + if (shouldCompress > 0) + { + host -> buffers [1].data = host -> packetData [1]; + host -> buffers [1].dataLength = shouldCompress; + host -> bufferCount = 2; + } + + currentPeer -> lastSendTime = host -> serviceTime; + + if (currentPeer -> state == ENET_PEER_STATE_CONNECTING && currentPeer -> packetsLost == 2) { + // Disable QoS tagging if we don't get a response to 2 connection requests in a row. + // Some networks drop QoS tagged packets, so let's try without it. + enet_socket_set_option (host -> socket, ENET_SOCKOPT_QOS, 0); + } + + sentLength = enet_socket_send (host -> socket, & currentPeer -> address, host -> buffers, host -> bufferCount); + + enet_protocol_remove_sent_unreliable_commands (currentPeer); + + if (sentLength < 0) + return -1; + + host -> totalSentData += sentLength; + host -> totalSentPackets ++; + } + + return 0; +} + +/** Sends any queued packets on the host specified to its designated peers. + + @param host host to flush + @remarks this function need only be used in circumstances where one wishes to send queued packets earlier than in a call to enet_host_service(). + @ingroup host +*/ +void +enet_host_flush (ENetHost * host) +{ + host -> serviceTime = enet_time_get (); + + enet_protocol_send_outgoing_commands (host, NULL, 0); +} + +/** Checks for any queued events on the host and dispatches one if available. + + @param host host to check for events + @param event an event structure where event details will be placed if available + @retval > 0 if an event was dispatched + @retval 0 if no events are available + @retval < 0 on failure + @ingroup host +*/ +int +enet_host_check_events (ENetHost * host, ENetEvent * event) +{ + if (event == NULL) return -1; + + event -> type = ENET_EVENT_TYPE_NONE; + event -> peer = NULL; + event -> packet = NULL; + + return enet_protocol_dispatch_incoming_commands (host, event); +} + +/** Waits for events on the host specified and shuttles packets between + the host and its peers. + + @param host host to service + @param event an event structure where event details will be placed if one occurs + if event == NULL then no events will be delivered + @param timeout number of milliseconds that ENet should wait for events + @retval > 0 if an event occurred within the specified time limit + @retval 0 if no event occurred + @retval < 0 on failure + @remarks enet_host_service should be called fairly regularly for adequate performance + @ingroup host +*/ +int +enet_host_service (ENetHost * host, ENetEvent * event, enet_uint32 timeout) +{ + enet_uint32 waitCondition; + + if (event != NULL) + { + event -> type = ENET_EVENT_TYPE_NONE; + event -> peer = NULL; + event -> packet = NULL; + + switch (enet_protocol_dispatch_incoming_commands (host, event)) + { + case 1: + return 1; + + case -1: +#ifdef ENET_DEBUG + perror ("Error dispatching incoming packets"); +#endif + + return -1; + + default: + break; + } + } + + host -> serviceTime = enet_time_get (); + + timeout += host -> serviceTime; + + for (;;) + { + if (ENET_TIME_DIFFERENCE (host -> serviceTime, host -> bandwidthThrottleEpoch) >= ENET_HOST_BANDWIDTH_THROTTLE_INTERVAL) + enet_host_bandwidth_throttle (host); + + switch (enet_protocol_send_outgoing_commands (host, event, 1)) + { + case 1: + return 1; + + case -1: +#ifdef ENET_DEBUG + perror ("Error sending outgoing packets"); +#endif + + return -1; + + default: + break; + } + + switch (enet_protocol_receive_incoming_commands (host, event)) + { + case 1: + return 1; + + case -1: +#ifdef ENET_DEBUG + perror ("Error receiving incoming packets"); +#endif + + return -1; + + default: + break; + } + + switch (enet_protocol_send_outgoing_commands (host, event, 1)) + { + case 1: + return 1; + + case -1: +#ifdef ENET_DEBUG + perror ("Error sending outgoing packets"); +#endif + + return -1; + + default: + break; + } + + if (event != NULL) + { + switch (enet_protocol_dispatch_incoming_commands (host, event)) + { + case 1: + return 1; + + case -1: +#ifdef ENET_DEBUG + perror ("Error dispatching incoming packets"); +#endif + + return -1; + + default: + break; + } + } + + if (ENET_TIME_GREATER_EQUAL (host -> serviceTime, timeout)) + return 0; + + do + { + host -> serviceTime = enet_time_get (); + + if (ENET_TIME_GREATER_EQUAL (host -> serviceTime, timeout)) + return 0; + + waitCondition = ENET_SOCKET_WAIT_RECEIVE | ENET_SOCKET_WAIT_INTERRUPT; + + if (enet_socket_wait (host -> socket, & waitCondition, ENET_TIME_DIFFERENCE (timeout, host -> serviceTime) / 10) != 0) + return -1; + } + while (waitCondition & ENET_SOCKET_WAIT_INTERRUPT); + + host -> serviceTime = enet_time_get (); + } + + return 0; +} + diff --git a/third_party/enet/unix.c b/third_party/enet/unix.c new file mode 100644 index 0000000..73f8ad9 --- /dev/null +++ b/third_party/enet/unix.c @@ -0,0 +1,681 @@ +/** + @file unix.c + @brief ENet Unix system specific functions +*/ +#ifndef _WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" + +#if defined(__APPLE__) +#ifndef HAS_POLL +#define HAS_POLL 1 +#endif +#ifndef HAS_FCNTL +#define HAS_FCNTL 1 +#endif +#ifndef HAS_INET_PTON +#define HAS_INET_PTON 1 +#endif +#ifndef HAS_INET_NTOP +#define HAS_INET_NTOP 1 +#endif +#ifndef HAS_MSGHDR_FLAGS +#define HAS_MSGHDR_FLAGS 1 +#endif +#ifndef HAS_SOCKLEN_T +#define HAS_SOCKLEN_T 1 +#endif +#ifndef HAS_GETADDRINFO +#define HAS_GETADDRINFO 1 +#endif +#ifndef HAS_GETNAMEINFO +#define HAS_GETNAMEINFO 1 +#endif +#elif defined(__vita__) +#ifdef HAS_POLL +#undef HAS_POLL +#endif +#ifdef HAS_FCNTL +#undef HAS_FCNTL +#endif +#ifdef HAS_IOCTL +#undef HAS_IOCTL +#endif +#ifndef HAS_INET_PTON +#define HAS_INET_PTON 1 +#endif +#ifndef HAS_INET_NTOP +#define HAS_INET_NTOP 1 +#endif +#ifdef HAS_MSGHDR_FLAGS +#undef HAS_MSGHDR_FLAGS +#endif +#ifndef HAS_SOCKLEN_T +#define HAS_SOCKLEN_T 1 +#endif +#ifndef HAS_GETADDRINFO +#define HAS_GETADDRINFO 1 +#endif +#ifndef HAS_GETNAMEINFO +#define HAS_GETNAMEINFO 1 +#endif +#elif defined(__WIIU__) +#ifndef HAS_POLL +#define HAS_POLL 1 +#endif +#ifndef HAS_FCNTL +#define HAS_FCNTL 1 +#endif +#ifndef HAS_IOCTL +#define HAS_IOCTL 1 +#endif +#ifndef HAS_INET_PTON +#define HAS_INET_PTON 1 +#endif +#ifndef HAS_INET_NTOP +#define HAS_INET_NTOP 1 +#endif +#ifndef HAS_SOCKLEN_T +#define HAS_SOCKLEN_T 1 +#endif +#ifndef HAS_GETADDRINFO +#define HAS_GETADDRINFO 1 +#endif +#ifndef HAS_GETNAMEINFO +#define HAS_GETNAMEINFO 1 +#endif +#ifndef NO_MSGAPI +#define NO_MSGAPI 1 +#endif +#else +#ifndef HAS_IOCTL +#define HAS_IOCTL 1 +#endif +#ifndef HAS_POLL +#define HAS_POLL 1 +#endif +#endif + +#ifdef HAS_FCNTL +#include +#endif + +#ifdef HAS_IOCTL +#include +#endif + +#ifdef HAS_POLL +#include +#endif + +#if !defined(HAS_SOCKLEN_T) && !defined(__socklen_t_defined) +typedef int socklen_t; +#endif + +#ifndef SOMAXCONN +#define SOMAXCONN 128 +#endif + +#ifndef MSG_NOSIGNAL +#define MSG_NOSIGNAL 0 +#endif + +static enet_uint32 timeBase = 0; + +int +enet_initialize (void) +{ + return 0; +} + +void +enet_deinitialize (void) +{ +} + +enet_uint32 +enet_host_random_seed (void) +{ + struct timeval timeVal; + + gettimeofday (& timeVal, NULL); + + return (timeVal.tv_sec * 1000) ^ (timeVal.tv_usec / 1000); +} + +enet_uint32 +enet_time_get (void) +{ + struct timeval timeVal; + + gettimeofday (& timeVal, NULL); + + return timeVal.tv_sec * 1000 + timeVal.tv_usec / 1000 - timeBase; +} + +void +enet_time_set (enet_uint32 newTimeBase) +{ + struct timeval timeVal; + + gettimeofday (& timeVal, NULL); + + timeBase = timeVal.tv_sec * 1000 + timeVal.tv_usec / 1000 - newTimeBase; +} + +int +enet_address_equal (ENetAddress * address1, ENetAddress * address2) +{ + if (address1 -> address.ss_family != address2 -> address.ss_family) + return 0; + + switch (address1 -> address.ss_family) + { + case AF_INET: + { + struct sockaddr_in *sin1, *sin2; + sin1 = (struct sockaddr_in *) & address1 -> address; + sin2 = (struct sockaddr_in *) & address2 -> address; + return sin1 -> sin_port == sin2 -> sin_port && + sin1 -> sin_addr.s_addr == sin2 -> sin_addr.s_addr; + } +#ifdef AF_INET6 + case AF_INET6: + { + struct sockaddr_in6 *sin6a, *sin6b; + sin6a = (struct sockaddr_in6 *) & address1 -> address; + sin6b = (struct sockaddr_in6 *) & address2 -> address; + return sin6a -> sin6_port == sin6b -> sin6_port && + ! memcmp (& sin6a -> sin6_addr, & sin6b -> sin6_addr, sizeof (sin6a -> sin6_addr)); + } +#endif + default: + { + return 0; + } + } +} + +int +enet_address_set_port (ENetAddress * address, enet_uint16 port) +{ + if (address -> address.ss_family == AF_INET) + { + struct sockaddr_in *sin = (struct sockaddr_in *) &address -> address; + sin -> sin_port = ENET_HOST_TO_NET_16 (port); + return 0; + } +#ifdef AF_INET6 + else if (address -> address.ss_family == AF_INET6) + { + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) &address -> address; + sin6 -> sin6_port = ENET_HOST_TO_NET_16 (port); + return 0; + } +#endif + else + { + return -1; + } +} + +int +enet_address_set_address (ENetAddress * address, struct sockaddr * addr, socklen_t addrlen) +{ + if (addrlen > sizeof(struct sockaddr_storage)) + return -1; + + memcpy (&address->address, addr, addrlen); + address->addressLength = addrlen; + return 0; +} + +int +enet_address_set_host (ENetAddress * address, const char * name) +{ + struct addrinfo hints, * resultList = NULL, * result = NULL; + + memset (& hints, 0, sizeof (hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_flags = AI_ADDRCONFIG; + + if (getaddrinfo (name, NULL, & hints, & resultList) != 0) + return -1; + + for (result = resultList; result != NULL; result = result -> ai_next) + { + memcpy (& address -> address, result -> ai_addr, result -> ai_addrlen); + address -> addressLength = result -> ai_addrlen; + + freeaddrinfo (resultList); + + return 0; + } + + if (resultList != NULL) + freeaddrinfo (resultList); + + return -1; +} + +int +enet_socket_bind (ENetSocket socket, const ENetAddress * address) +{ + return bind (socket, + (struct sockaddr *) & address -> address, + address -> addressLength); +} + +int +enet_socket_get_address (ENetSocket socket, ENetAddress * address) +{ + address -> addressLength = sizeof (address -> address); + + if (getsockname (socket, (struct sockaddr *) & address -> address, & address -> addressLength) == -1) + return -1; + + return 0; +} + +int +enet_socket_listen (ENetSocket socket, int backlog) +{ + return listen (socket, backlog < 0 ? SOMAXCONN : backlog); +} + +ENetSocket +enet_socket_create (int af, ENetSocketType type) +{ + return socket (af, type == ENET_SOCKET_TYPE_DATAGRAM ? SOCK_DGRAM : SOCK_STREAM, 0); +} + +int +enet_socket_set_option (ENetSocket socket, ENetSocketOption option, int value) +{ + int result = -1; + switch (option) + { + case ENET_SOCKOPT_NONBLOCK: +#ifdef HAS_FCNTL + result = fcntl (socket, F_SETFL, (value ? O_NONBLOCK : 0) | (fcntl (socket, F_GETFL) & ~O_NONBLOCK)); +#else +#ifdef HAS_IOCTL + result = ioctl (socket, FIONBIO, & value); +#else + result = setsockopt (socket, SOL_SOCKET, SO_NONBLOCK, (char *) & value, sizeof(int)); +#endif +#endif + break; + + case ENET_SOCKOPT_REUSEADDR: + result = setsockopt (socket, SOL_SOCKET, SO_REUSEADDR, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_RCVBUF: + result = setsockopt (socket, SOL_SOCKET, SO_RCVBUF, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_SNDBUF: + result = setsockopt (socket, SOL_SOCKET, SO_SNDBUF, (char *) & value, sizeof (int)); + break; + +#ifndef __WIIU__ + case ENET_SOCKOPT_RCVTIMEO: + { + struct timeval timeVal; + timeVal.tv_sec = value / 1000; + timeVal.tv_usec = (value % 1000) * 1000; + result = setsockopt (socket, SOL_SOCKET, SO_RCVTIMEO, (char *) & timeVal, sizeof (struct timeval)); + break; + } + + case ENET_SOCKOPT_SNDTIMEO: + { + struct timeval timeVal; + timeVal.tv_sec = value / 1000; + timeVal.tv_usec = (value % 1000) * 1000; + result = setsockopt (socket, SOL_SOCKET, SO_SNDTIMEO, (char *) & timeVal, sizeof (struct timeval)); + break; + } +#endif + + case ENET_SOCKOPT_NODELAY: + result = setsockopt (socket, IPPROTO_TCP, TCP_NODELAY, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_QOS: +#ifdef SO_NET_SERVICE_TYPE + // iOS/macOS + value = value ? NET_SERVICE_TYPE_VO : NET_SERVICE_TYPE_BE; + result = setsockopt (socket, SOL_SOCKET, SO_NET_SERVICE_TYPE, (char *) & value, sizeof (int)); +#else +#ifdef IP_TOS + // UNIX - IPv4 + value = value ? 46 << 2 : 0; // DSCP: Expedited Forwarding + result = setsockopt (socket, IPPROTO_IP, IP_TOS, (char *) & value, sizeof (int)); +#endif +#ifdef IPV6_TCLASS + // UNIX - IPv6 + value = value ? 46 << 2: 0; // DSCP: Expedited Forwarding + result = setsockopt (socket, IPPROTO_IPV6, IPV6_TCLASS, (char *) & value, sizeof (int)); +#endif +#ifdef SO_PRIORITY + // Linux + value = value ? 6 : 0; // Max priority without NET_CAP_ADMIN + result = setsockopt (socket, SOL_SOCKET, SO_PRIORITY, (char *) & value, sizeof (int)); +#endif +#endif /* SO_NET_SERVICE_TYPE */ + break; + + default: + break; + } + return result == -1 ? -1 : 0; +} + +int +enet_socket_get_option (ENetSocket socket, ENetSocketOption option, int * value) +{ + int result = -1; + socklen_t len; + switch (option) + { + case ENET_SOCKOPT_ERROR: + len = sizeof (int); + result = getsockopt (socket, SOL_SOCKET, SO_ERROR, value, & len); + break; + + default: + break; + } + return result == -1 ? -1 : 0; +} + +int +enet_socket_connect (ENetSocket socket, const ENetAddress * address) +{ + int result; + + result = connect (socket, (struct sockaddr *) & address -> address, address -> addressLength); + if (result == -1 && errno == EINPROGRESS) + return 0; + + return result; +} + +ENetSocket +enet_socket_accept (ENetSocket socket, ENetAddress * address) +{ + int result; + + if (address != NULL) + address -> addressLength = sizeof (address -> address); + + result = accept (socket, + address != NULL ? (struct sockaddr *) & address -> address : NULL, + address != NULL ? & address -> addressLength : NULL); + + if (result == -1) + return ENET_SOCKET_NULL; + + return result; +} + +int +enet_socket_shutdown (ENetSocket socket, ENetSocketShutdown how) +{ + return shutdown (socket, (int) how); +} + +void +enet_socket_destroy (ENetSocket socket) +{ + if (socket != -1) + close (socket); +} + +int +enet_socket_send (ENetSocket socket, + const ENetAddress * address, + const ENetBuffer * buffers, + size_t bufferCount) +{ + int sentLength; + +#ifdef NO_MSGAPI + void* sendBuffer; + size_t sendLength; + + if (bufferCount > 1) + { + size_t i; + + sendLength = 0; + for (i = 0; i < bufferCount; i++) + { + sendLength += buffers[i].dataLength; + } + + sendBuffer = malloc (sendLength); + if (sendBuffer == NULL) + return -1; + + sendLength = 0; + for (i = 0; i < bufferCount; i++) + { + memcpy (& ((unsigned char *)sendBuffer)[sendLength], buffers[i].data, buffers[i].dataLength); + sendLength += buffers[i].dataLength; + } + } + else + { + sendBuffer = buffers[0].data; + sendLength = buffers[0].dataLength; + } + + sentLength = sendto (socket, sendBuffer, sendLength, MSG_NOSIGNAL, + (struct sockaddr *) & address -> address, address -> addressLength); + + if (bufferCount > 1) + free(sendBuffer); +#else + struct msghdr msgHdr; + + memset (& msgHdr, 0, sizeof (struct msghdr)); + + if (address != NULL) + { + msgHdr.msg_name = (void*) & address -> address; + msgHdr.msg_namelen = address -> addressLength; + } + + msgHdr.msg_iov = (struct iovec *) buffers; + msgHdr.msg_iovlen = bufferCount; + + sentLength = sendmsg (socket, & msgHdr, MSG_NOSIGNAL); +#endif + + if (sentLength == -1) + { + if (errno == EWOULDBLOCK) + return 0; + + return -1; + } + + return sentLength; +} + +int +enet_socket_receive (ENetSocket socket, + ENetAddress * address, + ENetBuffer * buffers, + size_t bufferCount) +{ + int recvLength; + +#ifdef NO_MSGAPI + // This will ONLY work with a single buffer! + + address -> addressLength = sizeof (address -> address); + recvLength = recvfrom (socket, buffers[0].data, buffers[0].dataLength, MSG_NOSIGNAL, + (struct sockaddr *) & address -> address, & address -> addressLength); + + if (recvLength == -1) + { + if (errno == EWOULDBLOCK) + return 0; + + return -1; + } + + return recvLength; +#else + struct msghdr msgHdr; + + memset (& msgHdr, 0, sizeof (struct msghdr)); + + if (address != NULL) + { + msgHdr.msg_name = & address -> address; + msgHdr.msg_namelen = sizeof (address -> address); + } + + msgHdr.msg_iov = (struct iovec *) buffers; + msgHdr.msg_iovlen = bufferCount; + + recvLength = recvmsg (socket, & msgHdr, MSG_NOSIGNAL); + + if (recvLength == -1) + { + if (errno == EWOULDBLOCK) + return 0; + + return -1; + } + + if (address != NULL) + address -> addressLength = msgHdr.msg_namelen; + +#ifdef HAS_MSGHDR_FLAGS + if (msgHdr.msg_flags & MSG_TRUNC) + return -1; +#endif + + return recvLength; +#endif +} + +int +enet_socketset_select (ENetSocket maxSocket, ENetSocketSet * readSet, ENetSocketSet * writeSet, enet_uint32 timeout) +{ + struct timeval timeVal; + + timeVal.tv_sec = timeout / 1000; + timeVal.tv_usec = (timeout % 1000) * 1000; + + return select (maxSocket + 1, readSet, writeSet, NULL, & timeVal); +} + +int +enet_socket_wait (ENetSocket socket, enet_uint32 * condition, enet_uint32 timeout) +{ +#ifdef HAS_POLL + struct pollfd pollSocket; + int pollCount; + + pollSocket.fd = socket; + pollSocket.events = 0; + + if (* condition & ENET_SOCKET_WAIT_SEND) + pollSocket.events |= POLLOUT; + + if (* condition & ENET_SOCKET_WAIT_RECEIVE) + pollSocket.events |= POLLIN; + + pollCount = poll (& pollSocket, 1, timeout); + + if (pollCount < 0) + { + if (errno == EINTR && * condition & ENET_SOCKET_WAIT_INTERRUPT) + { + * condition = ENET_SOCKET_WAIT_INTERRUPT; + + return 0; + } + + return -1; + } + + * condition = ENET_SOCKET_WAIT_NONE; + + if (pollCount == 0) + return 0; + + if (pollSocket.revents & POLLOUT) + * condition |= ENET_SOCKET_WAIT_SEND; + + if (pollSocket.revents & POLLIN) + * condition |= ENET_SOCKET_WAIT_RECEIVE; + + return 0; +#else + fd_set readSet, writeSet; + struct timeval timeVal; + int selectCount; + + timeVal.tv_sec = timeout / 1000; + timeVal.tv_usec = (timeout % 1000) * 1000; + + FD_ZERO (& readSet); + FD_ZERO (& writeSet); + + if (* condition & ENET_SOCKET_WAIT_SEND) + FD_SET (socket, & writeSet); + + if (* condition & ENET_SOCKET_WAIT_RECEIVE) + FD_SET (socket, & readSet); + + selectCount = select (socket + 1, & readSet, & writeSet, NULL, & timeVal); + + if (selectCount < 0) + { + if (errno == EINTR && * condition & ENET_SOCKET_WAIT_INTERRUPT) + { + * condition = ENET_SOCKET_WAIT_INTERRUPT; + + return 0; + } + + return -1; + } + + * condition = ENET_SOCKET_WAIT_NONE; + + if (selectCount == 0) + return 0; + + if (FD_ISSET (socket, & writeSet)) + * condition |= ENET_SOCKET_WAIT_SEND; + + if (FD_ISSET (socket, & readSet)) + * condition |= ENET_SOCKET_WAIT_RECEIVE; + + return 0; +#endif +} + +#endif + diff --git a/third_party/enet/win32.c b/third_party/enet/win32.c new file mode 100644 index 0000000..0793454 --- /dev/null +++ b/third_party/enet/win32.c @@ -0,0 +1,495 @@ +/** + @file win32.c + @brief ENet Win32 system specific functions +*/ +#ifdef _WIN32 + +#define ENET_BUILDING_LIB 1 +#include "enet/enet.h" +#include +#ifndef HAS_QOS_FLOWID +typedef UINT32 QOS_FLOWID; +#endif +#ifndef HAS_PQOS_FLOWID +typedef UINT32 *PQOS_FLOWID; +#endif +#include +#include +#ifndef QOS_NON_ADAPTIVE_FLOW +#define QOS_NON_ADAPTIVE_FLOW 0x00000002 +#endif + +static enet_uint32 timeBase = 0; +static HANDLE qosHandle = INVALID_HANDLE_VALUE; +static QOS_FLOWID qosFlowId; +static BOOL qosAddedFlow; + +static HMODULE QwaveLibraryHandle; + +BOOL (WINAPI *pfnQOSCreateHandle)(PQOS_VERSION Version, PHANDLE QOSHandle); +BOOL (WINAPI *pfnQOSCloseHandle)(HANDLE QOSHandle); +BOOL (WINAPI *pfnQOSAddSocketToFlow)(HANDLE QOSHandle, SOCKET Socket, PSOCKADDR DestAddr, QOS_TRAFFIC_TYPE TrafficType, DWORD Flags, PQOS_FLOWID FlowId); + +int +enet_initialize (void) +{ + WORD versionRequested = MAKEWORD (2, 0); + WSADATA wsaData; + + if (WSAStartup (versionRequested, & wsaData)) + return -1; + + if (LOBYTE (wsaData.wVersion) != 2|| + HIBYTE (wsaData.wVersion) != 0) + { + WSACleanup (); + + return -1; + } + + timeBeginPeriod (1); + + QwaveLibraryHandle = LoadLibraryA("qwave.dll"); + if (QwaveLibraryHandle != NULL) { + pfnQOSCreateHandle = (void*)GetProcAddress(QwaveLibraryHandle, "QOSCreateHandle"); + pfnQOSCloseHandle = (void*)GetProcAddress(QwaveLibraryHandle, "QOSCloseHandle"); + pfnQOSAddSocketToFlow = (void*)GetProcAddress(QwaveLibraryHandle, "QOSAddSocketToFlow"); + + if (pfnQOSCreateHandle == NULL || pfnQOSCloseHandle == NULL || pfnQOSAddSocketToFlow == NULL) { + pfnQOSCreateHandle = NULL; + pfnQOSCloseHandle = NULL; + pfnQOSAddSocketToFlow = NULL; + + FreeLibrary(QwaveLibraryHandle); + QwaveLibraryHandle = NULL; + } + } + + return 0; +} + +void +enet_deinitialize (void) +{ + qosAddedFlow = FALSE; + qosFlowId = 0; + + if (qosHandle != INVALID_HANDLE_VALUE) + { + pfnQOSCloseHandle(qosHandle); + qosHandle = INVALID_HANDLE_VALUE; + } + + if (QwaveLibraryHandle != NULL) { + pfnQOSCreateHandle = NULL; + pfnQOSCloseHandle = NULL; + pfnQOSAddSocketToFlow = NULL; + + FreeLibrary(QwaveLibraryHandle); + QwaveLibraryHandle = NULL; + } + + timeEndPeriod (1); + + WSACleanup (); +} + +enet_uint32 +enet_host_random_seed (void) +{ + return (enet_uint32) timeGetTime (); +} + +enet_uint32 +enet_time_get (void) +{ + return (enet_uint32) timeGetTime () - timeBase; +} + +void +enet_time_set (enet_uint32 newTimeBase) +{ + timeBase = (enet_uint32) timeGetTime () - newTimeBase; +} + +int +enet_address_set_port (ENetAddress * address, enet_uint16 port) +{ + if (address -> address.ss_family == AF_INET) + { + struct sockaddr_in *sin = (struct sockaddr_in *) &address -> address; + sin -> sin_port = ENET_HOST_TO_NET_16 (port); + return 0; + } + else if (address -> address.ss_family == AF_INET6) + { + struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *) &address -> address; + sin6 -> sin6_port = ENET_HOST_TO_NET_16 (port); + return 0; + } + else + { + return -1; + } +} + +int +enet_address_set_address (ENetAddress * address, struct sockaddr * addr, socklen_t addrlen) +{ + if (addrlen > sizeof(struct sockaddr_storage)) + return -1; + + memcpy (&address->address, addr, addrlen); + address->addressLength = addrlen; + return 0; +} + +int +enet_address_equal (ENetAddress * address1, ENetAddress * address2) +{ + if (address1 -> address.ss_family != address2 -> address.ss_family) + return 0; + + switch (address1 -> address.ss_family) + { + case AF_INET: + { + struct sockaddr_in *sin1, *sin2; + sin1 = (struct sockaddr_in *) & address1 -> address; + sin2 = (struct sockaddr_in *) & address2 -> address; + return sin1 -> sin_port == sin2 -> sin_port && + sin1 -> sin_addr.S_un.S_addr == sin2 -> sin_addr.S_un.S_addr; + } + case AF_INET6: + { + struct sockaddr_in6 *sin6a, *sin6b; + sin6a = (struct sockaddr_in6 *) & address1 -> address; + sin6b = (struct sockaddr_in6 *) & address2 -> address; + return sin6a -> sin6_port == sin6b -> sin6_port && + ! memcmp (& sin6a -> sin6_addr, & sin6b -> sin6_addr, sizeof (sin6a -> sin6_addr)); + } + default: + { + return 0; + } + } +} + +int +enet_address_set_host (ENetAddress * address, const char * name) +{ + struct addrinfo hints, * resultList = NULL, * result = NULL; + + memset (& hints, 0, sizeof (hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_flags = AI_ADDRCONFIG; + + if (getaddrinfo (name, NULL, & hints, & resultList) != 0) + return -1; + + for (result = resultList; result != NULL; result = result -> ai_next) + { + memcpy (& address -> address, result -> ai_addr, result -> ai_addrlen); + address -> addressLength = result -> ai_addrlen; + + freeaddrinfo (resultList); + + return 0; + } + + if (resultList != NULL) + freeaddrinfo (resultList); + + return -1; +} + +int +enet_socket_bind (ENetSocket socket, const ENetAddress * address) +{ + return bind (socket, + (struct sockaddr *) & address -> address, + address -> addressLength); +} + +int +enet_socket_get_address (ENetSocket socket, ENetAddress * address) +{ + address -> addressLength = sizeof (address -> address); + + if (getsockname (socket, (struct sockaddr *) & address -> address, & address -> addressLength) == -1) + return -1; + + return 0; +} + +int +enet_socket_listen (ENetSocket socket, int backlog) +{ + return listen (socket, backlog < 0 ? SOMAXCONN : backlog) == SOCKET_ERROR ? -1 : 0; +} + +ENetSocket +enet_socket_create (int af, ENetSocketType type) +{ + return socket (af, type == ENET_SOCKET_TYPE_DATAGRAM ? SOCK_DGRAM : SOCK_STREAM, 0); +} + +int +enet_socket_set_option (ENetSocket socket, ENetSocketOption option, int value) +{ + int result = SOCKET_ERROR; + switch (option) + { + case ENET_SOCKOPT_NONBLOCK: + { + u_long nonBlocking = (u_long) value; + result = ioctlsocket (socket, FIONBIO, & nonBlocking); + break; + } + + case ENET_SOCKOPT_REUSEADDR: + result = setsockopt (socket, SOL_SOCKET, SO_REUSEADDR, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_RCVBUF: + result = setsockopt (socket, SOL_SOCKET, SO_RCVBUF, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_SNDBUF: + result = setsockopt (socket, SOL_SOCKET, SO_SNDBUF, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_RCVTIMEO: + result = setsockopt (socket, SOL_SOCKET, SO_RCVTIMEO, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_SNDTIMEO: + result = setsockopt (socket, SOL_SOCKET, SO_SNDTIMEO, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_NODELAY: + result = setsockopt (socket, IPPROTO_TCP, TCP_NODELAY, (char *) & value, sizeof (int)); + break; + + case ENET_SOCKOPT_QOS: + { + if (value) + { + QOS_VERSION qosVersion; + + qosVersion.MajorVersion = 1; + qosVersion.MinorVersion = 0; + if (pfnQOSCreateHandle == NULL || !pfnQOSCreateHandle(&qosVersion, &qosHandle)) + { + qosHandle = INVALID_HANDLE_VALUE; + } + } + else if (qosHandle != INVALID_HANDLE_VALUE) + { + pfnQOSCloseHandle(qosHandle); + qosHandle = INVALID_HANDLE_VALUE; + } + + qosAddedFlow = FALSE; + qosFlowId = 0; + + result = 0; + break; + } + + default: + break; + } + return result == SOCKET_ERROR ? -1 : 0; +} + +int +enet_socket_get_option (ENetSocket socket, ENetSocketOption option, int * value) +{ + int result = SOCKET_ERROR, len; + switch (option) + { + case ENET_SOCKOPT_ERROR: + len = sizeof(int); + result = getsockopt (socket, SOL_SOCKET, SO_ERROR, (char *) value, & len); + break; + + default: + break; + } + return result == SOCKET_ERROR ? -1 : 0; +} + +int +enet_socket_connect (ENetSocket socket, const ENetAddress * address) +{ + int result; + + result = connect (socket, (struct sockaddr *) & address -> address, address -> addressLength); + if (result == SOCKET_ERROR && WSAGetLastError () != WSAEWOULDBLOCK) + return -1; + + return 0; +} + +ENetSocket +enet_socket_accept (ENetSocket socket, ENetAddress * address) +{ + int result; + + if (address != NULL) + address -> addressLength = sizeof (address -> address); + + result = accept (socket, + address != NULL ? (struct sockaddr *) & address -> address : NULL, + address != NULL ? & address -> addressLength : NULL); + + if (result == -1) + return ENET_SOCKET_NULL; + + return result; +} + +int +enet_socket_shutdown (ENetSocket socket, ENetSocketShutdown how) +{ + return shutdown (socket, (int) how) == SOCKET_ERROR ? -1 : 0; +} + +void +enet_socket_destroy (ENetSocket socket) +{ + if (socket != INVALID_SOCKET) + closesocket (socket); +} + +int +enet_socket_send (ENetSocket socket, + const ENetAddress * address, + const ENetBuffer * buffers, + size_t bufferCount) +{ + DWORD sentLength; + + if (!qosAddedFlow && qosHandle != INVALID_HANDLE_VALUE) + { + qosFlowId = 0; // Must be initialized to 0 + pfnQOSAddSocketToFlow(qosHandle, + socket, + (struct sockaddr *)&address->address, + QOSTrafficTypeControl, + QOS_NON_ADAPTIVE_FLOW, + &qosFlowId); + + // Even if we failed, don't try again + qosAddedFlow = TRUE; + } + + if (WSASendTo (socket, + (LPWSABUF) buffers, + (DWORD) bufferCount, + & sentLength, + 0, + address != NULL ? (struct sockaddr *) & address -> address : NULL, + address != NULL ? address -> addressLength : 0, + NULL, + NULL) == SOCKET_ERROR) + { + if (WSAGetLastError () == WSAEWOULDBLOCK) + return 0; + + return -1; + } + + return (int) sentLength; +} + +int +enet_socket_receive (ENetSocket socket, + ENetAddress * address, + ENetBuffer * buffers, + size_t bufferCount) +{ + DWORD flags = 0, + recvLength; + + if (address != NULL) + address -> addressLength = sizeof (address -> address); + + if (WSARecvFrom (socket, + (LPWSABUF) buffers, + (DWORD) bufferCount, + & recvLength, + & flags, + address != NULL ? (struct sockaddr *) & address -> address : NULL, + address != NULL ? & address -> addressLength : NULL, + NULL, + NULL) == SOCKET_ERROR) + { + switch (WSAGetLastError ()) + { + case WSAEWOULDBLOCK: + case WSAECONNRESET: + return 0; + } + + return -1; + } + + if (flags & MSG_PARTIAL) + return -1; + + return (int) recvLength; +} + +int +enet_socketset_select (ENetSocket maxSocket, ENetSocketSet * readSet, ENetSocketSet * writeSet, enet_uint32 timeout) +{ + struct timeval timeVal; + + timeVal.tv_sec = timeout / 1000; + timeVal.tv_usec = (timeout % 1000) * 1000; + + return select (maxSocket + 1, readSet, writeSet, NULL, & timeVal); +} + +int +enet_socket_wait (ENetSocket socket, enet_uint32 * condition, enet_uint32 timeout) +{ + fd_set readSet, writeSet; + struct timeval timeVal; + int selectCount; + + timeVal.tv_sec = timeout / 1000; + timeVal.tv_usec = (timeout % 1000) * 1000; + + FD_ZERO (& readSet); + FD_ZERO (& writeSet); + + if (* condition & ENET_SOCKET_WAIT_SEND) + FD_SET (socket, & writeSet); + + if (* condition & ENET_SOCKET_WAIT_RECEIVE) + FD_SET (socket, & readSet); + + selectCount = select (socket + 1, & readSet, & writeSet, NULL, & timeVal); + + if (selectCount < 0) + return -1; + + * condition = ENET_SOCKET_WAIT_NONE; + + if (selectCount == 0) + return 0; + + if (FD_ISSET (socket, & writeSet)) + * condition |= ENET_SOCKET_WAIT_SEND; + + if (FD_ISSET (socket, & readSet)) + * condition |= ENET_SOCKET_WAIT_RECEIVE; + + return 0; +} + +#endif + diff --git a/translations/dish_bs.ts b/translations/dish_bs.ts index 245d21a..7d1b284 100644 --- a/translations/dish_bs.ts +++ b/translations/dish_bs.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 nema podršku za %2. + + A %1 controller does not carry %2 over Moonlight. + Kontroler %1 ne prenosi %2 preko Moonlighta. + %1 doesn’t carry %2. %1 nema kanal za %2. @@ -381,6 +385,22 @@ Claiming controller… Preuzimanje kontrolera… + + Auto + Automatski + + + Paired + Uparen + + + Remembered + Zapamćen + + + Not paired + Nije uparen + slot %1 utor %1 @@ -457,6 +477,10 @@ Pick a destination to continue. Odaberite odredište za nastavak. + + Unbind a controller on %1 to make room. + Odvežite jedan kontroler na %1 da napravite mjesta. + Waiting on the controller catalog. Čeka se katalog kontrolera. @@ -597,10 +621,22 @@ Manage destinations › Upravljaj odredištima › + + Moonlight hosts + Moonlight hostovi + + + Moonlight host · %1 + Moonlight host · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. Ovaj računar se uparuje kao Bluetooth kontroler. Žiro, dodirna ploča i miš trebaju Satelit host. + + Auto sends %1 for this controller. + Automatski šalje %1 za ovaj kontroler. + Handing the device over can take a few seconds. Predaja uređaja može potrajati nekoliko sekundi. @@ -780,6 +816,14 @@ Disconnect Prekini vezu + + Moonlight hosts + Moonlight hostovi + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Strimujte na PC s pokrenutim Sunshineom, Apollom ili Wolfom umjesto na satelit. + Forget Zaboravi @@ -2586,6 +2630,249 @@ Otkaži + + MoonlightHostsPage + + Moonlight hosts + Moonlight hostovi + + + %n found + + %n pronađen + %n pronađena + %n pronađenih + + + + %n paired + + %n uparen + %n uparena + %n uparenih + + + + Found + Pronađen + + + scanning… + skeniranje… + + + Add by address… + Dodaj po adresi… + + + Scanning… + Skeniranje… + + + Scan + Skeniraj + + + Looking for Moonlight hosts + Traženje Moonlight hostova + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Skeniranje vaše mreže za hostove koji oglašavaju GameStream. Pojavljuju se ovdje čim odgovore. + + + No Moonlight hosts found + Nema pronađenih Moonlight hostova + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + PC se pojavi ovdje čim na njemu radi Sunshine, Apollo ili Wolf i obje mašine su na istoj mreži. Možete ga dodati i po adresi. + + + Get Sunshine ↗ + Preuzmi Sunshine ↗ + + + %1, Moonlight host, %2 + %1, Moonlight host, %2 + + + Moonlight host (Sunshine/Apollo) + Moonlight host (Sunshine/Apollo) + + + In use by %1 + Koristi ga %1 + + + Session + Sesija + + + Pair again + Upari ponovo + + + Pair… + Upari… + + + More actions for %1 + Više radnji za %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + Uparivanje je jednokratno povjerenje, a ne veza. Dish ga ponovo provjeri kad upotrijebite kontroler. + + + Quit session + Prekini sesiju + + + Forget + Zaboravi + + + Forget %1? + Zaboraviti %1? + + + Cancel + Otkaži + + + Moonlight host + Moonlight host + + + Add a host by address + Dodaj host po adresi + + + Add + Dodaj + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Unesite IP adresu ili ime hosta. Dish koristi standardne Moonlight portove. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Ime (opcionalno) + + + Pairing + Uparivanje + + + Pair with %1 + Upari sa %1 + + + Done + Gotovo + + + Type %1 into the Moonlight or Sunshine page on %2. + Upišite %1 na Moonlight ili Sunshine stranicu na %2. + + + Check that the code went into the right host, then try again. + Provjerite je li kod unesen na pravi host, pa pokušajte ponovo. + + + Waiting for the host to accept the PIN… + Čeka se da host prihvati PIN… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + Dish briše svoju polovinu uparivanja i ponovo će tražiti PIN. %1 zadržava vlastiti zapis o ovom uređaju dok ga tamo neko ne ukloni. + + + New code + Novi kod + + + %n bindings ride on it and will be dropped: + + %n povezivanje se oslanja na njega i bit će uklonjeno: + %n povezivanja se oslanjaju na njega i bit će uklonjena: + %n povezivanja se oslanja na njega i bit će uklonjeno: + + + + Its session ends for the %n controllers on it. + + Sesija se završava za %n kontroler na njemu. + Sesija se završava za %n kontrolera na njemu. + Sesija se završava za %n kontrolera na njemu. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 nije odgovorio. Provjerite je li uključen i na ovoj mreži. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 je odbio zahtjev. Provjerite je li uparivanje dozvoljeno na hostu. + + + Dish could not prepare its own identity for pairing. Try again. + Dish nije mogao pripremiti vlastiti identitet za uparivanje. Pokušajte ponovo. + + + Paired + Uparen + + + Remembered + Zapamćen + + + Not paired + Nije uparen + + + %n controllers + + %n kontroler + %n kontrolera + %n kontrolera + + + + Pairing… + Uparivanje… + + + Starting… + Pokretanje… + + + Connecting… + Povezivanje… + + + Streaming + Striming + + + Unsteady + Nestabilan + + + Failed + Neuspjelo + + + Disconnected + Prekinuto + + PairingDialog @@ -2962,6 +3249,10 @@ Step 3 of 3 · Type Korak 3 od 3 · Tip + + Step 3 of 3 · Session + Korak 3 od 3 · Sesija + Step 3 of 3 · Feel Korak 3 od 3 · Osjećaj @@ -2987,15 +3278,23 @@ Standardni - satellite · 0 slots free - satelit · 0 slobodnih slotova + moonlight + moonlight + + + satellite + satelit + + + %1 · 0 slots free + %1 · 0 slobodnih mjesta - satellite · %n slots free + %n slots free - satelit · %n slobodan slot - satelit · %n slobodna slota - satelit · %n slobodnih slotova + %n slobodan utor + %n slobodna utora + %n slobodnih utora @@ -3290,8 +3589,8 @@ Korak %1, %2 - Sub-step %1 of 3 - Podkorak %1 od 3 + Sub-step %1 of %2 + Podkorak %1 od %2 @@ -3352,6 +3651,22 @@ %n slobodnih utora + + full + pun + + + Paired + Uparen + + + Remembered + Zapamćen + + + Not paired + Nije uparen + Which PC? Koji PC? @@ -3380,13 +3695,25 @@ Needs pairing, PIN Potrebno uparivanje, PIN + + Moonlight hosts + Moonlight hostovi + + + Moonlight host · %1 + Moonlight host · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + PC se pojavi ovdje čim na njemu radi Sunshine, Apollo ili Wolf i obje mašine su na istoj mreži. Možete ga dodati i po adresi. + No PCs found yet Još nema pronađenih PC-a - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - PC se pojavljuje ovdje kada na njemu radi besplatna aplikacija Satellite i kada su oba PC-a na istoj mreži. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + PC se pojavi ovdje čim na njemu radi besplatna Satellite aplikacija, ili Sunshine, Apollo ili Wolf, i obje mašine su na istoj mreži. Don’t see your PC? Install the free Satellite app on it. @@ -3710,16 +4037,279 @@ Na %1 se još ništa nije promijenilo. Dugme Poveži je prvi i jedini upis. + + WizardSessionPage + + Continue › + Nastavi › + + + Unbind a controller on %1 to make room. + Odvežite jedan kontroler na %1 da napravite mjesta. + + + Session + Sesija + + + Checking %1… + Provjera %1… + + + Reading the app list from %1… + Čitanje liste aplikacija s %1… + + + Waiting for the host to accept the PIN… + Čeka se da host prihvati PIN… + + + Streaming + Striming + + + Could not read the app list from %1 + Nije moguće pročitati listu aplikacija s %1 + + + Could not finish the session on %1 + Nije moguće dovršiti sesiju na %1 + + + %1 refused the session: %2 + %1 je odbio sesiju: %2 + + + %1 refused the session + %1 je odbio sesiju + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + Dish će pokrenuti ono što host navede prvo. Pokušajte ponovo kad %1 bude dostupan. + + + The app started but the stream did not come up, so Dish closed it again. + Aplikacija se pokrenula, ali strim nije uspostavljen, pa ju je Dish opet zatvorio. + + + Add the controller anyway and Dish will try again the next time you use it. + Svejedno dodajte kontroler i Dish će pokušati ponovo kad ga sljedeći put upotrijebite. + + + No apps on this host + Nema aplikacija na ovom hostu + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 još nema postavljenih aplikacija. Dodajte jednu na hostu, ili dodajte kontroler pa će Dish pokrenuti ono što host navede prvo. + + + Retry + Pokušaj ponovo + + + Pair now + Upari sada + + + Pair again + Upari ponovo + + + Try again + Pokušaj ponovo + + + New code + Novi kod + + + Cancel + Otkaži + + + Close the app on %1 + Zatvori aplikaciju na %1 + + + Reconnect + Ponovo poveži + + + Start a session + Pokreni sesiju + + + See controllers on %1 + Pogledaj kontrolere na %1 + + + Without a pick, Dish starts whatever %1 lists first. + Bez odabira, Dish pokreće ono što %1 navede prvo. + + + Not paired yet + Još nije upareno + + + Pair with %1 + Upari sa %1 + + + %1 did not accept the PIN + %1 nije prihvatio PIN + + + %1 is not answering + %1 se ne javlja + + + %1 no longer recognises this device + %1 više ne prepoznaje ovaj uređaj + + + %1 was reset + %1 je resetovan + + + New session + Nova sesija + + + Joining %1 + Pridruživanje %1 + + + Joining the session on %1 + Pridruživanje sesiji na %1 + + + %1 is full + %1 je pun + + + Another device is using %1 + Drugi uređaj koristi %1 + + + Could not rejoin the session on %1 + Nije moguće ponovo se pridružiti sesiji na %1 + + + Streaming to %1 + Striming na %1 + + + Session on %1 ended + Sesija na %1 je završena + + + %1 ended the session + %1 je završio sesiju + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 treba jednokratni PIN prije nego što Dish može pokrenuti sesiju. Uparite sada, ili dodajte kontroler pa uparite kasnije. + + + Type %1 into the Moonlight or Sunshine page on %2. + Upišite %1 na Moonlight ili Sunshine stranicu na %2. + + + %1 did not answer. Check that it is switched on and on this network. + %1 nije odgovorio. Provjerite je li uključen i na ovoj mreži. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 je odbio zahtjev. Provjerite je li uparivanje dozvoljeno na hostu. + + + Dish could not prepare its own identity for pairing. Try again. + Dish nije mogao pripremiti vlastiti identitet za uparivanje. Pokušajte ponovo. + + + Check that the code went into the right host, then try again. + Provjerite je li kod unesen na pravi host, pa pokušajte ponovo. + + + Check that the host is switched on and on this network, then try again. + Provjerite je li host uključen i na ovoj mreži, pa pokušajte ponovo. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + Dish pamti uparivanje s %1 i pokrenut će sesiju kad se host vrati. + + + The host removed the pairing. Pair again to start a session. + Host je uklonio uparivanje. Uparite ponovo da pokrenete sesiju. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + Ovaj host ima novi identitet, pa staro uparivanje više ne radi. Uparite ponovo da pokrenete sesiju. + + + This is the first controller on %1, so it picks what the host runs. + Ovo je prvi kontroler na %1, pa on bira šta host pokreće. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 već ima pokrenutu sesiju za ovaj uređaj. Ovaj kontroler joj se pridružuje kao kontroler %2. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + Sesija nosi najviše četiri kontrolera, a %1 ih već ima četiri. Odvežite jedan da napravite mjesta. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 pokreće aplikaciju za drugi uređaj i neće predati tu sesiju. Zatvorite je da pokrenete novu, ili dodajte kontroler i pokušajte kasnije. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + Host ima sesiju, ali je nije vratio. Zatvorite aplikaciju na %1 i pokrenite novu. + + + The link dropped. Dish will rejoin the next time you use this controller. + Veza je pala. Dish će se ponovo pridružiti kad sljedeći put upotrijebite ovaj kontroler. + + + The app closed on the host. Start a new session to keep using this controller. + Aplikacija se zatvorila na hostu. Pokrenite novu sesiju da nastavite koristiti ovaj kontroler. + + + %1 · controller %2 of 4 + %1 · kontroler %2 od 4 + + + Unbinding the last controller ends this session. + Odvezivanje posljednjeg kontrolera završava ovu sesiju. + + + This is the only Moonlight state that stops you adding the controller. + Ovo je jedino Moonlight stanje koje vas sprječava da dodate kontroler. + + + You can add the controller now and settle this later. + Možete dodati kontroler sada i ovo riješiti kasnije. + + WizardTypePage Continue › Nastavi › + + Some hosts override the choice. + Neki hostovi nadjačaju izbor. + Types offered by %1’s catalog. Tipovi koje nudi katalog hosta %1. + + Auto + Automatski + Rumble Vibracija @@ -3732,10 +4322,18 @@ Touchpad Dodirna ploča + + How should the host see it? + Kako host treba da ga vidi? + How should the PC see it? Kako PC treba da ga vidi? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + Dish traži od %1 da priključi ovaj kontroler. Neki hostovi nadjačaju izbor. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Odaberite kontroler koji PC treba prijaviti. Svaki otključava različite dodatke — ovaj pad ograničava sva tri na isti način. @@ -3756,6 +4354,14 @@ Best fit Najbolji izbor + + Picked for you + Odabrano za vas + + + Auto sends %1 for this controller. + Automatski šalje %1 za ovaj kontroler. + dish::AppModel @@ -3775,6 +4381,14 @@ The satellite wouldn’t accept that controller — binding undone. Satelit nije prihvatio taj kontroler — povezivanje poništeno. + + That host is already running an app. Stop it on the host, then try again. + Taj host već pokreće aplikaciju. Zaustavi je na hostu, pa pokušaj ponovo. + + + The Moonlight session ended. + Moonlight sesija je završena. + Couldn’t switch %1 to Direct mode — keeping it on Standard. Nije moguće prebaciti %1 na Brzi način — ostaje na Standardnom. diff --git a/translations/dish_de.ts b/translations/dish_de.ts index be08545..c4442fc 100644 --- a/translations/dish_de.ts +++ b/translations/dish_de.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 verfügt nicht über %2. + + A %1 controller does not carry %2 over Moonlight. + Ein %1-Controller überträgt %2 nicht über Moonlight. + %1 doesn’t carry %2. %1 überträgt %2 nicht. @@ -380,6 +384,22 @@ Claiming controller… Controller wird übernommen… + + Auto + Automatisch + + + Paired + Gekoppelt + + + Remembered + Gemerkt + + + Not paired + Nicht gekoppelt + slot %1 Slot %1 @@ -455,6 +475,10 @@ Pick a destination to continue. Wähle ein Ziel, um fortzufahren. + + Unbind a controller on %1 to make room. + Löse einen Controller auf %1, um Platz zu schaffen. + Waiting on the controller catalog. Warte auf den Controller-Katalog. @@ -595,10 +619,22 @@ Manage destinations › Ziele verwalten › + + Moonlight hosts + Moonlight-Hosts + + + Moonlight host · %1 + Moonlight-Host · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. Dieser Rechner wird als Bluetooth-Gamepad gekoppelt. Gyro, Touchpad und Maus brauchen einen Satellit-Host. + + Auto sends %1 for this controller. + Automatisch sendet %1 für diesen Controller. + Handing the device over can take a few seconds. Die Übergabe des Geräts kann ein paar Sekunden dauern. @@ -778,6 +814,14 @@ Disconnect Trennen + + Moonlight hosts + Moonlight-Hosts + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Streame auf einen PC mit Sunshine, Apollo oder Wolf statt auf einen Satelliten. + Forget Entfernen @@ -2574,6 +2618,244 @@ Abbrechen + + MoonlightHostsPage + + Moonlight hosts + Moonlight-Hosts + + + %n found + + %n gefunden + %n gefunden + + + + %n paired + + %n gekoppelt + %n gekoppelt + + + + Found + Gefunden + + + scanning… + suche läuft… + + + Add by address… + Per Adresse hinzufügen… + + + Scanning… + Suche läuft… + + + Scan + Suchen + + + Looking for Moonlight hosts + Suche nach Moonlight-Hosts + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Das Netzwerk wird nach Hosts durchsucht, die GameStream anbieten. Sie erscheinen hier, sobald sie antworten. + + + No Moonlight hosts found + Keine Moonlight-Hosts gefunden + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Ein PC erscheint hier, sobald Sunshine, Apollo oder Wolf darauf läuft und beide Rechner im selben Netzwerk sind. Du kannst ihn auch per Adresse hinzufügen. + + + Get Sunshine ↗ + Sunshine holen ↗ + + + %1, Moonlight host, %2 + %1, Moonlight-Host, %2 + + + Moonlight host (Sunshine/Apollo) + Moonlight-Host (Sunshine/Apollo) + + + In use by %1 + Genutzt von %1 + + + Session + Sitzung + + + Pair again + Erneut koppeln + + + Pair… + Koppeln… + + + More actions for %1 + Weitere Aktionen für %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + Die Kopplung ist einmaliges Vertrauen, keine Verbindung. Dish prüft sie erneut, wenn du einen Controller benutzt. + + + Quit session + Sitzung beenden + + + Forget + Entfernen + + + Forget %1? + %1 entfernen? + + + Cancel + Abbrechen + + + Moonlight host + Moonlight-Host + + + Add a host by address + Host per Adresse hinzufügen + + + Add + Hinzufügen + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Gib die IP-Adresse oder den Hostnamen ein. Dish verwendet die Standard-Moonlight-Ports. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Name (optional) + + + Pairing + Kopplung + + + Pair with %1 + Mit %1 koppeln + + + Done + Fertig + + + Type %1 into the Moonlight or Sunshine page on %2. + Gib %1 auf der Moonlight- oder Sunshine-Seite von %2 ein. + + + Check that the code went into the right host, then try again. + Prüfe, ob der Code beim richtigen Host gelandet ist, und versuche es erneut. + + + Waiting for the host to accept the PIN… + Warte darauf, dass der Host die PIN annimmt… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + Dish löscht seine Hälfte der Kopplung und braucht die PIN erneut. %1 behält seinen eigenen Eintrag zu diesem Gerät, bis ihn dort jemand entfernt. + + + New code + Neuer Code + + + %n bindings ride on it and will be dropped: + + %n Zuordnung hängt daran und wird entfernt: + %n Zuordnungen hängen daran und werden entfernt: + + + + Its session ends for the %n controllers on it. + + Die Sitzung endet für den %n Controller darauf. + Die Sitzung endet für die %n Controller darauf. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 hat nicht geantwortet. Prüfe, ob der Host eingeschaltet und in diesem Netzwerk ist. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 hat die Anfrage abgelehnt. Prüfe, ob Kopplung auf dem Host erlaubt ist. + + + Dish could not prepare its own identity for pairing. Try again. + Dish konnte seine eigene Identität für die Kopplung nicht vorbereiten. Versuche es erneut. + + + Paired + Gekoppelt + + + Remembered + Gemerkt + + + Not paired + Nicht gekoppelt + + + %n controllers + + %n Controller + %n Controller + + + + Pairing… + Kopplung läuft… + + + Starting… + Startet… + + + Connecting… + Verbindet… + + + Streaming + Streaming + + + Unsteady + Instabil + + + Failed + Fehlgeschlagen + + + Disconnected + Getrennt + + PairingDialog @@ -2950,6 +3232,10 @@ Step 3 of 3 · Type Schritt 3 von 3 · Typ + + Step 3 of 3 · Session + Schritt 3 von 3 · Sitzung + Step 3 of 3 · Feel Schritt 3 von 3 · Spielgefühl @@ -2975,14 +3261,22 @@ Standard - satellite · 0 slots free - Satellit · 0 Slots frei + moonlight + moonlight + + + satellite + Satellit + + + %1 · 0 slots free + %1 · 0 Plätze frei - satellite · %n slots free + %n slots free - Satellit · %n Slot frei - Satellit · %n Slots frei + %n Slot frei + %n Slots frei @@ -3276,8 +3570,8 @@ Schritt %1, %2 - Sub-step %1 of 3 - Teilschritt %1 von 3 + Sub-step %1 of %2 + Teilschritt %1 von %2 @@ -3337,6 +3631,22 @@ %n Slots frei + + full + voll + + + Paired + Gekoppelt + + + Remembered + Gemerkt + + + Not paired + Nicht gekoppelt + Which PC? Welcher PC? @@ -3364,13 +3674,25 @@ Needs pairing, PIN Kopplung nötig, PIN + + Moonlight hosts + Moonlight-Hosts + + + Moonlight host · %1 + Moonlight-Host · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Ein PC erscheint hier, sobald Sunshine, Apollo oder Wolf darauf läuft und beide Rechner im selben Netzwerk sind. Du kannst ihn auch per Adresse hinzufügen. + No PCs found yet Noch keine PCs gefunden - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - Ein PC erscheint hier, sobald die kostenlose Satellite-App darauf läuft und beide Rechner im selben Netzwerk sind. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + Ein PC erscheint hier, sobald die kostenlose Satellite-App darauf läuft, oder Sunshine, Apollo oder Wolf, und beide Rechner im selben Netzwerk sind. Don’t see your PC? Install the free Satellite app on it. @@ -3694,16 +4016,279 @@ Auf %1 hat sich noch nichts geändert. „Zuordnen“ ist der erste und einzige Schreibvorgang. + + WizardSessionPage + + Continue › + Weiter › + + + Unbind a controller on %1 to make room. + Löse einen Controller auf %1, um Platz zu schaffen. + + + Session + Sitzung + + + Checking %1… + %1 wird geprüft… + + + Reading the app list from %1… + Die App-Liste von %1 wird gelesen… + + + Waiting for the host to accept the PIN… + Warte darauf, dass der Host die PIN annimmt… + + + Streaming + Streaming + + + Could not read the app list from %1 + Die App-Liste von %1 konnte nicht gelesen werden + + + Could not finish the session on %1 + Die Sitzung auf %1 konnte nicht abgeschlossen werden + + + %1 refused the session: %2 + %1 hat die Sitzung abgelehnt: %2 + + + %1 refused the session + %1 hat die Sitzung abgelehnt + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + Dish startet, was der Host zuerst auflistet. Versuche es erneut, sobald %1 erreichbar ist. + + + The app started but the stream did not come up, so Dish closed it again. + Die App startete, aber der Stream kam nicht zustande, also hat Dish sie wieder geschlossen. + + + Add the controller anyway and Dish will try again the next time you use it. + Füge den Controller trotzdem hinzu, Dish versucht es beim nächsten Mal erneut. + + + No apps on this host + Keine Apps auf diesem Host + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 hat noch keine Apps eingerichtet. Lege eine auf dem Host an, oder füge den Controller hinzu und Dish startet, was der Host zuerst auflistet. + + + Retry + Wiederholen + + + Pair now + Jetzt koppeln + + + Pair again + Erneut koppeln + + + Try again + Erneut versuchen + + + New code + Neuer Code + + + Cancel + Abbrechen + + + Close the app on %1 + App auf %1 schließen + + + Reconnect + Erneut verbinden + + + Start a session + Sitzung starten + + + See controllers on %1 + Controller auf %1 ansehen + + + Without a pick, Dish starts whatever %1 lists first. + Ohne Auswahl startet Dish das, was %1 zuerst auflistet. + + + Not paired yet + Noch nicht gekoppelt + + + Pair with %1 + Mit %1 koppeln + + + %1 did not accept the PIN + %1 hat die PIN nicht angenommen + + + %1 is not answering + %1 antwortet nicht + + + %1 no longer recognises this device + %1 erkennt dieses Gerät nicht mehr + + + %1 was reset + %1 wurde zurückgesetzt + + + New session + Neue Sitzung + + + Joining %1 + Tritt %1 bei + + + Joining the session on %1 + Tritt der Sitzung auf %1 bei + + + %1 is full + %1 ist voll + + + Another device is using %1 + Ein anderes Gerät benutzt %1 + + + Could not rejoin the session on %1 + Der Sitzung auf %1 konnte nicht wieder beigetreten werden + + + Streaming to %1 + Streaming auf %1 + + + Session on %1 ended + Sitzung auf %1 beendet + + + %1 ended the session + %1 hat die Sitzung beendet + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 braucht eine einmalige PIN, bevor Dish eine Sitzung starten kann. Koppele jetzt, oder füge den Controller hinzu und koppele später. + + + Type %1 into the Moonlight or Sunshine page on %2. + Gib %1 auf der Moonlight- oder Sunshine-Seite von %2 ein. + + + %1 did not answer. Check that it is switched on and on this network. + %1 hat nicht geantwortet. Prüfe, ob der Host eingeschaltet und in diesem Netzwerk ist. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 hat die Anfrage abgelehnt. Prüfe, ob Kopplung auf dem Host erlaubt ist. + + + Dish could not prepare its own identity for pairing. Try again. + Dish konnte seine eigene Identität für die Kopplung nicht vorbereiten. Versuche es erneut. + + + Check that the code went into the right host, then try again. + Prüfe, ob der Code beim richtigen Host gelandet ist, und versuche es erneut. + + + Check that the host is switched on and on this network, then try again. + Prüfe, ob der Host eingeschaltet und in diesem Netzwerk ist, und versuche es erneut. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + Dish merkt sich die Kopplung mit %1 und startet eine Sitzung, sobald der Host wieder da ist. + + + The host removed the pairing. Pair again to start a session. + Der Host hat die Kopplung entfernt. Koppele erneut, um eine Sitzung zu starten. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + Dieser Host hat eine neue Identität, die alte Kopplung funktioniert daher nicht mehr. Koppele erneut, um eine Sitzung zu starten. + + + This is the first controller on %1, so it picks what the host runs. + Das ist der erste Controller auf %1, also wählt er, was der Host ausführt. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 führt bereits eine Sitzung für dieses Gerät aus. Dieser Controller tritt ihr als Controller %2 bei. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + Eine Sitzung trägt höchstens vier Controller, und %1 hat bereits vier. Löse einen, um Platz zu schaffen. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 führt eine App für ein anderes Gerät aus und gibt diese Sitzung nicht ab. Schließe sie, um eine neue zu starten, oder füge den Controller hinzu und versuche es später erneut. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + Der Host hat eine Sitzung, gab sie aber nicht zurück. Schließe die App auf %1 und starte eine neue. + + + The link dropped. Dish will rejoin the next time you use this controller. + Die Verbindung ist abgebrochen. Dish tritt beim nächsten Einsatz dieses Controllers wieder bei. + + + The app closed on the host. Start a new session to keep using this controller. + Die App wurde auf dem Host geschlossen. Starte eine neue Sitzung, um diesen Controller weiter zu nutzen. + + + %1 · controller %2 of 4 + %1 · Controller %2 von 4 + + + Unbinding the last controller ends this session. + Das Lösen des letzten Controllers beendet diese Sitzung. + + + This is the only Moonlight state that stops you adding the controller. + Das ist der einzige Moonlight-Zustand, der dich am Hinzufügen des Controllers hindert. + + + You can add the controller now and settle this later. + Du kannst den Controller jetzt hinzufügen und das später klären. + + WizardTypePage Continue › Weiter › + + Some hosts override the choice. + Manche Hosts überschreiben die Wahl. + Types offered by %1’s catalog. Typen aus dem Katalog von %1. + + Auto + Automatisch + Rumble Vibration @@ -3716,10 +4301,18 @@ Touchpad Touchpad + + How should the host see it? + Wie soll der Host ihn sehen? + How should the PC see it? Wie soll der PC ihn sehen? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + Dish bittet %1, diesen Controller einzustecken. Manche Hosts überschreiben die Wahl. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Wähle den Controller, den der PC melden soll. Jeder schaltet andere Extras frei — dieses Pad begrenzt alle drei gleich. @@ -3740,6 +4333,14 @@ Best fit Beste Wahl + + Picked for you + Für dich gewählt + + + Auto sends %1 for this controller. + Automatisch sendet %1 für diesen Controller. + dish::AppModel @@ -3759,6 +4360,14 @@ The satellite wouldn’t accept that controller — binding undone. Der Satellit hat diesen Controller nicht akzeptiert — Zuordnung rückgängig gemacht. + + That host is already running an app. Stop it on the host, then try again. + Auf diesem Host läuft bereits eine App. Beende sie auf dem Host und versuche es dann erneut. + + + The Moonlight session ended. + Die Moonlight-Sitzung wurde beendet. + Couldn’t switch %1 to Direct mode — keeping it on Standard. %1 konnte nicht in den Direktmodus wechseln — bleibt im Standardmodus. diff --git a/translations/dish_en.ts b/translations/dish_en.ts index b77fe7a..9e2f4cf 100644 --- a/translations/dish_en.ts +++ b/translations/dish_en.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 has no %2. + + A %1 controller does not carry %2 over Moonlight. + A %1 controller does not carry %2 over Moonlight. + %1 doesn’t carry %2. %1 doesn’t carry %2. @@ -380,6 +384,22 @@ Claiming controller… Claiming controller… + + Auto + Auto + + + Paired + Paired + + + Remembered + Remembered + + + Not paired + Not paired + slot %1 slot %1 @@ -455,6 +475,10 @@ Pick a destination to continue. Pick a destination to continue. + + Unbind a controller on %1 to make room. + Unbind a controller on %1 to make room. + Waiting on the controller catalog. Waiting on the controller catalog. @@ -595,10 +619,22 @@ Manage destinations › Manage destinations › + + Moonlight hosts + Moonlight hosts + + + Moonlight host · %1 + Moonlight host · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. + + Auto sends %1 for this controller. + Auto sends %1 for this controller. + Handing the device over can take a few seconds. Handing the device over can take a few seconds. @@ -854,6 +890,14 @@ More actions for %1 More actions for %1 + + Moonlight hosts + Moonlight hosts + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Forget Forget @@ -2574,6 +2618,244 @@ Cancel + + MoonlightHostsPage + + Moonlight hosts + Moonlight hosts + + + %n found + + %n found + %n found + + + + %n paired + + %n paired + %n paired + + + + Found + Found + + + scanning… + scanning… + + + Add by address… + Add by address… + + + Scanning… + Scanning… + + + Scan + Scan + + + Looking for Moonlight hosts + Looking for Moonlight hosts + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Scanning your network for hosts advertising GameStream. They appear here as they answer. + + + No Moonlight hosts found + No Moonlight hosts found + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + + + Get Sunshine ↗ + Get Sunshine ↗ + + + %1, Moonlight host, %2 + %1, Moonlight host, %2 + + + Moonlight host (Sunshine/Apollo) + Moonlight host (Sunshine/Apollo) + + + In use by %1 + In use by %1 + + + Session + Session + + + Pair again + Pair again + + + Pair… + Pair… + + + More actions for %1 + More actions for %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + + + Quit session + Quit session + + + Forget + Forget + + + Forget %1? + Forget %1? + + + Cancel + Cancel + + + Moonlight host + Moonlight host + + + Add a host by address + Add a host by address + + + Add + Add + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Name (optional) + + + Pairing + Pairing + + + Pair with %1 + Pair with %1 + + + Done + Done + + + Type %1 into the Moonlight or Sunshine page on %2. + Type %1 into the Moonlight or Sunshine page on %2. + + + Check that the code went into the right host, then try again. + Check that the code went into the right host, then try again. + + + Waiting for the host to accept the PIN… + Waiting for the host to accept the PIN… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + + + New code + New code + + + %n bindings ride on it and will be dropped: + + %n binding rides on it and will be dropped: + %n bindings ride on it and will be dropped: + + + + Its session ends for the %n controllers on it. + + Its session ends for the %n controller on it. + Its session ends for the %n controllers on it. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 did not answer. Check that it is switched on and on this network. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 turned the request down. Check that pairing is allowed on the host. + + + Dish could not prepare its own identity for pairing. Try again. + Dish could not prepare its own identity for pairing. Try again. + + + Paired + Paired + + + Remembered + Remembered + + + Not paired + Not paired + + + %n controllers + + %n controller + %n controllers + + + + Pairing… + Pairing… + + + Starting… + Starting… + + + Connecting… + Connecting… + + + Streaming + Streaming + + + Unsteady + Unsteady + + + Failed + Failed + + + Disconnected + Disconnected + + PairingDialog @@ -2950,6 +3232,10 @@ Step 3 of 3 · Type Step 3 of 3 · Type + + Step 3 of 3 · Session + Step 3 of 3 · Session + Step 3 of 3 · Feel Step 3 of 3 · Feel @@ -2975,14 +3261,22 @@ Standard - satellite · 0 slots free - satellite · 0 slots free + moonlight + moonlight + + + satellite + satellite + + + %1 · 0 slots free + %1 · 0 slots free - satellite · %n slots free + %n slots free - satellite · %n slot free - satellite · %n slots free + %n slot free + %n slots free @@ -3276,8 +3570,8 @@ Step %1, %2 - Sub-step %1 of 3 - Sub-step %1 of 3 + Sub-step %1 of %2 + Sub-step %1 of %2 @@ -3337,6 +3631,22 @@ %n slots free + + full + full + + + Paired + Paired + + + Remembered + Remembered + + + Not paired + Not paired + Which PC? Which PC? @@ -3364,13 +3674,25 @@ Needs pairing, PIN Needs pairing, PIN + + Moonlight hosts + Moonlight hosts + + + Moonlight host · %1 + Moonlight host · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + No PCs found yet No PCs found yet - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. Don’t see your PC? Install the free Satellite app on it. @@ -3694,16 +4016,279 @@ Nothing on %1 has changed yet. Bind is the first and only write. + + WizardSessionPage + + Continue › + Continue › + + + Unbind a controller on %1 to make room. + Unbind a controller on %1 to make room. + + + Session + Session + + + Checking %1… + Checking %1… + + + Reading the app list from %1… + Reading the app list from %1… + + + Waiting for the host to accept the PIN… + Waiting for the host to accept the PIN… + + + Streaming + Streaming + + + Could not read the app list from %1 + Could not read the app list from %1 + + + Could not finish the session on %1 + Could not finish the session on %1 + + + %1 refused the session: %2 + %1 refused the session: %2 + + + %1 refused the session + %1 refused the session + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + Dish will start whatever the host lists first. Retry once %1 is reachable. + + + The app started but the stream did not come up, so Dish closed it again. + The app started but the stream did not come up, so Dish closed it again. + + + Add the controller anyway and Dish will try again the next time you use it. + Add the controller anyway and Dish will try again the next time you use it. + + + No apps on this host + No apps on this host + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + + + Retry + Retry + + + Pair now + Pair now + + + Pair again + Pair again + + + Try again + Try again + + + New code + New code + + + Cancel + Cancel + + + Close the app on %1 + Close the app on %1 + + + Reconnect + Reconnect + + + Start a session + Start a session + + + See controllers on %1 + See controllers on %1 + + + Without a pick, Dish starts whatever %1 lists first. + Without a pick, Dish starts whatever %1 lists first. + + + Not paired yet + Not paired yet + + + Pair with %1 + Pair with %1 + + + %1 did not accept the PIN + %1 did not accept the PIN + + + %1 is not answering + %1 is not answering + + + %1 no longer recognises this device + %1 no longer recognises this device + + + %1 was reset + %1 was reset + + + New session + New session + + + Joining %1 + Joining %1 + + + Joining the session on %1 + Joining the session on %1 + + + %1 is full + %1 is full + + + Another device is using %1 + Another device is using %1 + + + Could not rejoin the session on %1 + Could not rejoin the session on %1 + + + Streaming to %1 + Streaming to %1 + + + Session on %1 ended + Session on %1 ended + + + %1 ended the session + %1 ended the session + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + + + Type %1 into the Moonlight or Sunshine page on %2. + Type %1 into the Moonlight or Sunshine page on %2. + + + %1 did not answer. Check that it is switched on and on this network. + %1 did not answer. Check that it is switched on and on this network. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 turned the request down. Check that pairing is allowed on the host. + + + Dish could not prepare its own identity for pairing. Try again. + Dish could not prepare its own identity for pairing. Try again. + + + Check that the code went into the right host, then try again. + Check that the code went into the right host, then try again. + + + Check that the host is switched on and on this network, then try again. + Check that the host is switched on and on this network, then try again. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + Dish remembers the pairing with %1 and will start a session when the host is back. + + + The host removed the pairing. Pair again to start a session. + The host removed the pairing. Pair again to start a session. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + + + This is the first controller on %1, so it picks what the host runs. + This is the first controller on %1, so it picks what the host runs. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 is already running a session for this device. This controller joins it as controller %2. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + The host has a session but would not hand it back. Close the app on %1 and start a new one. + + + The link dropped. Dish will rejoin the next time you use this controller. + The link dropped. Dish will rejoin the next time you use this controller. + + + The app closed on the host. Start a new session to keep using this controller. + The app closed on the host. Start a new session to keep using this controller. + + + %1 · controller %2 of 4 + %1 · controller %2 of 4 + + + Unbinding the last controller ends this session. + Unbinding the last controller ends this session. + + + This is the only Moonlight state that stops you adding the controller. + This is the only Moonlight state that stops you adding the controller. + + + You can add the controller now and settle this later. + You can add the controller now and settle this later. + + WizardTypePage Continue › Continue › + + Some hosts override the choice. + Some hosts override the choice. + Types offered by %1’s catalog. Types offered by %1’s catalog. + + Auto + Auto + Rumble Rumble @@ -3716,10 +4301,18 @@ Touchpad Touchpad + + How should the host see it? + How should the host see it? + How should the PC see it? How should the PC see it? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + Dish asks %1 to plug in this controller. Some hosts override the choice. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. @@ -3740,6 +4333,14 @@ Best fit Best fit + + Picked for you + Picked for you + + + Auto sends %1 for this controller. + Auto sends %1 for this controller. + dish::AppModel @@ -3747,6 +4348,14 @@ The satellite wouldn’t accept that controller — binding undone. The satellite wouldn’t accept that controller — binding undone. + + That host is already running an app. Stop it on the host, then try again. + That host is already running an app. Stop it on the host, then try again. + + + The Moonlight session ended. + The Moonlight session ended. + Controller Controller diff --git a/translations/dish_es.ts b/translations/dish_es.ts index f93e417..a3f7351 100644 --- a/translations/dish_es.ts +++ b/translations/dish_es.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 no tiene %2. + + A %1 controller does not carry %2 over Moonlight. + Un mando %1 no lleva %2 por Moonlight. + %1 doesn’t carry %2. %1 no transmite %2. @@ -380,6 +384,22 @@ Claiming controller… Tomando el control del mando… + + Auto + Automático + + + Paired + Vinculado + + + Remembered + Recordado + + + Not paired + Sin vincular + slot %1 ranura %1 @@ -455,6 +475,10 @@ Pick a destination to continue. Elige un destino para continuar. + + Unbind a controller on %1 to make room. + Desvincula un mando en %1 para hacer sitio. + Waiting on the controller catalog. Esperando el catálogo de mandos. @@ -595,10 +619,22 @@ Manage destinations › Gestionar destinos › + + Moonlight hosts + Hosts de Moonlight + + + Moonlight host · %1 + Host de Moonlight · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. Este equipo se vincula como mando Bluetooth. El giro, el panel táctil y el ratón necesitan un host Satélite. + + Auto sends %1 for this controller. + Automático envía %1 para este mando. + Handing the device over can take a few seconds. Ceder el dispositivo puede tardar unos segundos. @@ -778,6 +814,14 @@ Disconnect Desconectar + + Moonlight hosts + Hosts de Moonlight + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Transmite a un PC con Sunshine, Apollo o Wolf en lugar de a un satélite. + Forget Olvidar @@ -2574,6 +2618,244 @@ Cancelar + + MoonlightHostsPage + + Moonlight hosts + Hosts de Moonlight + + + %n found + + %n encontrado + %n encontrados + + + + %n paired + + %n emparejada + %n emparejadas + + + + Found + Encontrado + + + scanning… + buscando… + + + Add by address… + Añadir por dirección… + + + Scanning… + Buscando… + + + Scan + Buscar + + + Looking for Moonlight hosts + Buscando hosts de Moonlight + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Buscando en tu red hosts que anuncian GameStream. Aparecen aquí en cuanto responden. + + + No Moonlight hosts found + No se encontró ningún host de Moonlight + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Un PC aparece aquí en cuanto ejecuta Sunshine, Apollo o Wolf y ambas máquinas están en la misma red. También puedes añadirlo por dirección. + + + Get Sunshine ↗ + Obtener Sunshine ↗ + + + %1, Moonlight host, %2 + %1, host de Moonlight, %2 + + + Moonlight host (Sunshine/Apollo) + Host de Moonlight (Sunshine/Apollo) + + + In use by %1 + En uso por %1 + + + Session + Sesión + + + Pair again + Emparejar de nuevo + + + Pair… + Emparejar… + + + More actions for %1 + Más acciones para %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + El emparejamiento es una confianza de una sola vez, no una conexión. Dish la vuelve a comprobar cuando usas un mando. + + + Quit session + Terminar sesión + + + Forget + Olvidar + + + Forget %1? + ¿Olvidar %1? + + + Cancel + Cancelar + + + Moonlight host + Host de Moonlight + + + Add a host by address + Añadir un host por dirección + + + Add + Añadir + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Introduce la dirección IP o el nombre del host. Dish usa los puertos estándar de Moonlight. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Nombre (opcional) + + + Pairing + Emparejamiento + + + Pair with %1 + Emparejar con %1 + + + Done + Listo + + + Type %1 into the Moonlight or Sunshine page on %2. + Escribe %1 en la página de Moonlight o Sunshine de %2. + + + Check that the code went into the right host, then try again. + Comprueba que el código se introdujo en el host correcto y vuelve a intentarlo. + + + Waiting for the host to accept the PIN… + Esperando a que el host acepte el PIN… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + Dish elimina su mitad del emparejamiento y volverá a pedir el PIN. %1 conserva su propio registro de este dispositivo hasta que alguien lo elimine allí. + + + New code + Nuevo código + + + %n bindings ride on it and will be dropped: + + %n vínculo depende de este host y se perderá: + %n vínculos dependen de este host y se perderán: + + + + Its session ends for the %n controllers on it. + + Su sesión termina para el %n mando que la usa. + Su sesión termina para los %n mandos que la usan. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 no respondió. Comprueba que esté encendido y en esta red. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 rechazó la solicitud. Comprueba que el emparejamiento esté permitido en el host. + + + Dish could not prepare its own identity for pairing. Try again. + Dish no pudo preparar su propia identidad para el emparejamiento. Vuelve a intentarlo. + + + Paired + Vinculado + + + Remembered + Recordado + + + Not paired + Sin vincular + + + %n controllers + + %n mando + %n mandos + + + + Pairing… + Emparejando… + + + Starting… + Iniciando… + + + Connecting… + Conectando… + + + Streaming + Transmitiendo + + + Unsteady + Inestable + + + Failed + Falló + + + Disconnected + Desconectado + + PairingDialog @@ -2950,6 +3232,10 @@ Step 3 of 3 · Type Paso 3 de 3 · Tipo + + Step 3 of 3 · Session + Paso 3 de 3 · Sesión + Step 3 of 3 · Feel Paso 3 de 3 · Sensación @@ -2975,14 +3261,22 @@ Estándar - satellite · 0 slots free - satélite · 0 ranuras libres + moonlight + moonlight + + + satellite + satélite + + + %1 · 0 slots free + %1 · 0 espacios libres - satellite · %n slots free + %n slots free - satélite · %n ranura libre - satélite · %n ranuras libres + %n ranura libre + %n ranuras libres @@ -3276,8 +3570,8 @@ Paso %1, %2 - Sub-step %1 of 3 - Subpaso %1 de 3 + Sub-step %1 of %2 + Subpaso %1 de %2 @@ -3337,6 +3631,22 @@ %n ranuras libres + + full + lleno + + + Paired + Vinculado + + + Remembered + Recordado + + + Not paired + Sin vincular + Which PC? ¿Qué PC? @@ -3364,13 +3674,25 @@ Needs pairing, PIN Emparejar, PIN + + Moonlight hosts + Hosts de Moonlight + + + Moonlight host · %1 + Host de Moonlight · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Un PC aparece aquí en cuanto ejecuta Sunshine, Apollo o Wolf y ambas máquinas están en la misma red. También puedes añadirlo por dirección. + No PCs found yet Aún no se encontró ningún PC - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - Un PC aparece aquí en cuanto la app gratuita Satellite se ejecuta en él y ambas máquinas están en la misma red. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + Un PC aparece aquí en cuanto ejecuta la app gratuita Satellite, o Sunshine, Apollo o Wolf, y ambas máquinas están en la misma red. Don’t see your PC? Install the free Satellite app on it. @@ -3694,16 +4016,279 @@ Todavía no ha cambiado nada en %1. Vincular es la primera y única escritura. + + WizardSessionPage + + Continue › + Continuar › + + + Unbind a controller on %1 to make room. + Desvincula un mando en %1 para hacer sitio. + + + Session + Sesión + + + Checking %1… + Comprobando %1… + + + Reading the app list from %1… + Leyendo la lista de apps de %1… + + + Waiting for the host to accept the PIN… + Esperando a que el host acepte el PIN… + + + Streaming + Transmitiendo + + + Could not read the app list from %1 + No se pudo leer la lista de apps de %1 + + + Could not finish the session on %1 + No se pudo terminar la sesión en %1 + + + %1 refused the session: %2 + %1 rechazó la sesión: %2 + + + %1 refused the session + %1 rechazó la sesión + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + Dish iniciará lo que el host liste primero. Reinténtalo cuando %1 esté accesible. + + + The app started but the stream did not come up, so Dish closed it again. + La app se inició pero la transmisión no llegó a establecerse, así que Dish la cerró de nuevo. + + + Add the controller anyway and Dish will try again the next time you use it. + Añade el mando igualmente y Dish lo intentará de nuevo la próxima vez que lo uses. + + + No apps on this host + No hay apps en este host + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 aún no tiene apps configuradas. Añade una en el host, o añade el mando y Dish iniciará lo que el host liste primero. + + + Retry + Reintentar + + + Pair now + Emparejar ahora + + + Pair again + Emparejar de nuevo + + + Try again + Intentar de nuevo + + + New code + Nuevo código + + + Cancel + Cancelar + + + Close the app on %1 + Cerrar la app en %1 + + + Reconnect + Reconectar + + + Start a session + Iniciar una sesión + + + See controllers on %1 + Ver mandos en %1 + + + Without a pick, Dish starts whatever %1 lists first. + Sin una elección, Dish inicia lo que %1 liste primero. + + + Not paired yet + Aún sin emparejar + + + Pair with %1 + Emparejar con %1 + + + %1 did not accept the PIN + %1 no aceptó el PIN + + + %1 is not answering + %1 no responde + + + %1 no longer recognises this device + %1 ya no reconoce este dispositivo + + + %1 was reset + %1 se ha restablecido + + + New session + Nueva sesión + + + Joining %1 + Uniéndose a %1 + + + Joining the session on %1 + Uniéndose a la sesión en %1 + + + %1 is full + %1 está lleno + + + Another device is using %1 + Otro dispositivo está usando %1 + + + Could not rejoin the session on %1 + No se pudo volver a la sesión en %1 + + + Streaming to %1 + Transmitiendo a %1 + + + Session on %1 ended + La sesión en %1 terminó + + + %1 ended the session + %1 terminó la sesión + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 necesita un PIN de un solo uso antes de que Dish pueda iniciar una sesión. Empareja ahora, o añade el mando y empareja más tarde. + + + Type %1 into the Moonlight or Sunshine page on %2. + Escribe %1 en la página de Moonlight o Sunshine de %2. + + + %1 did not answer. Check that it is switched on and on this network. + %1 no respondió. Comprueba que esté encendido y en esta red. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 rechazó la solicitud. Comprueba que el emparejamiento esté permitido en el host. + + + Dish could not prepare its own identity for pairing. Try again. + Dish no pudo preparar su propia identidad para el emparejamiento. Vuelve a intentarlo. + + + Check that the code went into the right host, then try again. + Comprueba que el código se introdujo en el host correcto y vuelve a intentarlo. + + + Check that the host is switched on and on this network, then try again. + Comprueba que el host esté encendido y en esta red, y vuelve a intentarlo. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + Dish recuerda el emparejamiento con %1 e iniciará una sesión cuando el host vuelva. + + + The host removed the pairing. Pair again to start a session. + El host eliminó el emparejamiento. Empareja de nuevo para iniciar una sesión. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + Este host tiene una identidad nueva, así que el emparejamiento anterior ya no sirve. Empareja de nuevo para iniciar una sesión. + + + This is the first controller on %1, so it picks what the host runs. + Este es el primer mando en %1, así que elige lo que ejecuta el host. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 ya está ejecutando una sesión para este dispositivo. Este mando se une a ella como mando %2. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + Una sesión lleva cuatro mandos como máximo, y %1 ya tiene cuatro. Desvincula uno para hacer sitio. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 está ejecutando una app para otro dispositivo y no cederá esa sesión. Ciérrala para iniciar una nueva, o añade el mando y vuelve a intentarlo más tarde. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + El host tiene una sesión pero no la devolvió. Cierra la app en %1 e inicia una nueva. + + + The link dropped. Dish will rejoin the next time you use this controller. + El enlace se cayó. Dish volverá a unirse la próxima vez que uses este mando. + + + The app closed on the host. Start a new session to keep using this controller. + La app se cerró en el host. Inicia una sesión nueva para seguir usando este mando. + + + %1 · controller %2 of 4 + %1 · mando %2 de 4 + + + Unbinding the last controller ends this session. + Desvincular el último mando termina esta sesión. + + + This is the only Moonlight state that stops you adding the controller. + Este es el único estado de Moonlight que te impide añadir el mando. + + + You can add the controller now and settle this later. + Puedes añadir el mando ahora y resolver esto más tarde. + + WizardTypePage Continue › Continuar › + + Some hosts override the choice. + Algunos hosts anulan la elección. + Types offered by %1’s catalog. Tipos que ofrece el catálogo de %1. + + Auto + Automático + Rumble Vibración @@ -3716,10 +4301,18 @@ Touchpad Panel táctil + + How should the host see it? + ¿Cómo debe verlo el host? + How should the PC see it? ¿Cómo debe verlo el PC? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + Dish le pide a %1 que conecte este mando. Algunos hosts anulan la elección. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Elige el mando que el PC debe detectar. Cada uno desbloquea extras distintos — este mando limita los tres por igual. @@ -3740,6 +4333,14 @@ Best fit Mejor opción + + Picked for you + Elegido por ti + + + Auto sends %1 for this controller. + Automático envía %1 para este mando. + dish::AppModel @@ -3759,6 +4360,14 @@ The satellite wouldn’t accept that controller — binding undone. El satélite no aceptó ese mando — vinculación deshecha. + + That host is already running an app. Stop it on the host, then try again. + Ese host ya está ejecutando una aplicación. Ciérrala en el host y vuelve a intentarlo. + + + The Moonlight session ended. + La sesión de Moonlight ha terminado. + Couldn’t switch %1 to Direct mode — keeping it on Standard. No se pudo cambiar %1 a Modo directo — se mantiene en Estándar. diff --git a/translations/dish_fr.ts b/translations/dish_fr.ts index 97f67dc..7f8779e 100644 --- a/translations/dish_fr.ts +++ b/translations/dish_fr.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 n'a pas de %2. + + A %1 controller does not carry %2 over Moonlight. + Une manette %1 ne transporte pas %2 via Moonlight. + %1 doesn’t carry %2. %1 ne transmet pas de %2. @@ -380,6 +384,22 @@ Claiming controller… Acquisition de la manette… + + Auto + Automatique + + + Paired + Associé + + + Remembered + Mémorisé + + + Not paired + Non associé + slot %1 emplacement %1 @@ -455,6 +475,10 @@ Pick a destination to continue. Choisissez une destination pour continuer. + + Unbind a controller on %1 to make room. + Détachez une manette sur %1 pour faire de la place. + Waiting on the controller catalog. En attente du catalogue de manettes. @@ -595,10 +619,22 @@ Manage destinations › Gérer les destinations › + + Moonlight hosts + Hôtes Moonlight + + + Moonlight host · %1 + Hôte Moonlight · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. Cette machine s'appaire comme manette Bluetooth. Le gyro, le pavé tactile et la souris nécessitent un hôte Satellite. + + Auto sends %1 for this controller. + Automatique envoie %1 pour cette manette. + Handing the device over can take a few seconds. Céder le périphérique peut prendre quelques secondes. @@ -778,6 +814,14 @@ Disconnect Déconnecter + + Moonlight hosts + Hôtes Moonlight + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Diffusez vers un PC équipé de Sunshine, Apollo ou Wolf plutôt que vers un satellite. + Forget Oublier @@ -2574,6 +2618,244 @@ Annuler + + MoonlightHostsPage + + Moonlight hosts + Hôtes Moonlight + + + %n found + + %n trouvé + %n trouvés + + + + %n paired + + %n appairée + %n appairées + + + + Found + Trouvée + + + scanning… + recherche… + + + Add by address… + Ajouter par adresse… + + + Scanning… + Recherche… + + + Scan + Scanner + + + Looking for Moonlight hosts + Recherche d'hôtes Moonlight + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Recherche sur votre réseau des hôtes annonçant GameStream. Ils apparaissent ici dès qu'ils répondent. + + + No Moonlight hosts found + Aucun hôte Moonlight trouvé + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Un PC apparaît ici dès que Sunshine, Apollo ou Wolf y tourne et que les deux machines sont sur le même réseau. Vous pouvez aussi l'ajouter par adresse. + + + Get Sunshine ↗ + Obtenir Sunshine ↗ + + + %1, Moonlight host, %2 + %1, hôte Moonlight, %2 + + + Moonlight host (Sunshine/Apollo) + Hôte Moonlight (Sunshine/Apollo) + + + In use by %1 + Utilisé par %1 + + + Session + Session + + + Pair again + Appairer à nouveau + + + Pair… + Appairer… + + + More actions for %1 + Plus d'actions pour %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + L'appairage est une confiance unique, pas une connexion. Dish la revérifie quand vous utilisez une manette. + + + Quit session + Quitter la session + + + Forget + Oublier + + + Forget %1? + Oublier %1 ? + + + Cancel + Annuler + + + Moonlight host + Hôte Moonlight + + + Add a host by address + Ajouter un hôte par adresse + + + Add + Ajouter + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Saisissez l'adresse IP ou le nom de l'hôte. Dish utilise les ports Moonlight standard. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Nom (facultatif) + + + Pairing + Appairage + + + Pair with %1 + Appairer avec %1 + + + Done + Terminé + + + Type %1 into the Moonlight or Sunshine page on %2. + Saisissez %1 dans la page Moonlight ou Sunshine de %2. + + + Check that the code went into the right host, then try again. + Vérifiez que le code a bien été saisi sur le bon hôte, puis réessayez. + + + Waiting for the host to accept the PIN… + En attente de l'acceptation du PIN par l'hôte… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + Dish supprime sa moitié de l'appairage et redemandera le code PIN. %1 conserve sa propre fiche de cet appareil jusqu'à ce que quelqu'un l'y supprime. + + + New code + Nouveau code + + + %n bindings ride on it and will be dropped: + + %n liaison en dépend et sera supprimée : + %n liaisons en dépendent et seront supprimées : + + + + Its session ends for the %n controllers on it. + + Sa session prend fin pour la %n manette qui l'utilise. + Sa session prend fin pour les %n manettes qui l'utilisent. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 n'a pas répondu. Vérifiez qu'il est allumé et sur ce réseau. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 a refusé la demande. Vérifiez que l'appairage est autorisé sur l'hôte. + + + Dish could not prepare its own identity for pairing. Try again. + Dish n'a pas pu préparer sa propre identité pour l'appairage. Réessayez. + + + Paired + Associé + + + Remembered + Mémorisé + + + Not paired + Non associé + + + %n controllers + + %n manette + %n manettes + + + + Pairing… + Appairage… + + + Starting… + Démarrage… + + + Connecting… + Connexion… + + + Streaming + Diffusion + + + Unsteady + Instable + + + Failed + Échec + + + Disconnected + Déconnecté + + PairingDialog @@ -2950,6 +3232,10 @@ Step 3 of 3 · Type Étape 3 sur 3 · Type + + Step 3 of 3 · Session + Étape 3 sur 3 · Session + Step 3 of 3 · Feel Étape 3 sur 3 · Ressenti @@ -2975,14 +3261,22 @@ Standard - satellite · 0 slots free - satellite · 0 emplacement libre + moonlight + moonlight + + + satellite + satellite + + + %1 · 0 slots free + %1 · 0 emplacement libre - satellite · %n slots free + %n slots free - satellite · %n emplacement libre - satellite · %n emplacements libres + %n emplacement libre + %n emplacements libres @@ -3276,8 +3570,8 @@ Étape %1, %2 - Sub-step %1 of 3 - Sous-étape %1 sur 3 + Sub-step %1 of %2 + Sous-étape %1 sur %2 @@ -3337,6 +3631,22 @@ %n emplacements libres + + full + plein + + + Paired + Associé + + + Remembered + Mémorisé + + + Not paired + Non associé + Which PC? Quel PC ? @@ -3364,13 +3674,25 @@ Needs pairing, PIN Appairage requis, PIN + + Moonlight hosts + Hôtes Moonlight + + + Moonlight host · %1 + Hôte Moonlight · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Un PC apparaît ici dès que Sunshine, Apollo ou Wolf y tourne et que les deux machines sont sur le même réseau. Vous pouvez aussi l'ajouter par adresse. + No PCs found yet Aucun PC trouvé pour l'instant - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - Un PC apparaît ici dès que l'app gratuite Satellite y est lancée et que les deux machines sont sur le même réseau. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + Un PC apparaît ici dès que l'application gratuite Satellite y tourne, ou Sunshine, Apollo ou Wolf, et que les deux machines sont sur le même réseau. Don’t see your PC? Install the free Satellite app on it. @@ -3694,16 +4016,279 @@ Rien n'a encore changé sur %1. Lier est la première et unique écriture. + + WizardSessionPage + + Continue › + Continuer › + + + Unbind a controller on %1 to make room. + Détachez une manette sur %1 pour faire de la place. + + + Session + Session + + + Checking %1… + Vérification de %1… + + + Reading the app list from %1… + Lecture de la liste des applications de %1… + + + Waiting for the host to accept the PIN… + En attente de l'acceptation du PIN par l'hôte… + + + Streaming + Diffusion + + + Could not read the app list from %1 + Impossible de lire la liste des applications de %1 + + + Could not finish the session on %1 + Impossible de terminer la session sur %1 + + + %1 refused the session: %2 + %1 a refusé la session : %2 + + + %1 refused the session + %1 a refusé la session + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + Dish lancera ce que l'hôte liste en premier. Réessayez quand %1 sera joignable. + + + The app started but the stream did not come up, so Dish closed it again. + L'application a démarré mais le flux ne s'est pas établi, alors Dish l'a refermée. + + + Add the controller anyway and Dish will try again the next time you use it. + Ajoutez quand même la manette et Dish réessaiera la prochaine fois que vous l'utiliserez. + + + No apps on this host + Aucune application sur cet hôte + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 n'a encore aucune application configurée. Ajoutez-en une sur l'hôte, ou ajoutez la manette et Dish lancera ce que l'hôte liste en premier. + + + Retry + Réessayer + + + Pair now + Appairer maintenant + + + Pair again + Appairer à nouveau + + + Try again + Réessayer + + + New code + Nouveau code + + + Cancel + Annuler + + + Close the app on %1 + Fermer l'application sur %1 + + + Reconnect + Reconnecter + + + Start a session + Démarrer une session + + + See controllers on %1 + Voir les manettes sur %1 + + + Without a pick, Dish starts whatever %1 lists first. + Sans choix, Dish lance ce que %1 liste en premier. + + + Not paired yet + Pas encore appairé + + + Pair with %1 + Appairer avec %1 + + + %1 did not accept the PIN + %1 n'a pas accepté le PIN + + + %1 is not answering + %1 ne répond pas + + + %1 no longer recognises this device + %1 ne reconnaît plus cet appareil + + + %1 was reset + %1 a été réinitialisé + + + New session + Nouvelle session + + + Joining %1 + Connexion à %1 + + + Joining the session on %1 + Connexion à la session sur %1 + + + %1 is full + %1 est plein + + + Another device is using %1 + Un autre appareil utilise %1 + + + Could not rejoin the session on %1 + Impossible de rejoindre la session sur %1 + + + Streaming to %1 + Diffusion vers %1 + + + Session on %1 ended + La session sur %1 est terminée + + + %1 ended the session + %1 a mis fin à la session + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 a besoin d'un PIN à usage unique avant que Dish puisse démarrer une session. Appairez maintenant, ou ajoutez la manette et appairez plus tard. + + + Type %1 into the Moonlight or Sunshine page on %2. + Saisissez %1 dans la page Moonlight ou Sunshine de %2. + + + %1 did not answer. Check that it is switched on and on this network. + %1 n'a pas répondu. Vérifiez qu'il est allumé et sur ce réseau. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 a refusé la demande. Vérifiez que l'appairage est autorisé sur l'hôte. + + + Dish could not prepare its own identity for pairing. Try again. + Dish n'a pas pu préparer sa propre identité pour l'appairage. Réessayez. + + + Check that the code went into the right host, then try again. + Vérifiez que le code a bien été saisi sur le bon hôte, puis réessayez. + + + Check that the host is switched on and on this network, then try again. + Vérifiez que l'hôte est allumé et sur ce réseau, puis réessayez. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + Dish mémorise l'appairage avec %1 et démarrera une session au retour de l'hôte. + + + The host removed the pairing. Pair again to start a session. + L'hôte a supprimé l'appairage. Appairez à nouveau pour démarrer une session. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + Cet hôte a une nouvelle identité, l'ancien appairage ne fonctionne donc plus. Appairez à nouveau pour démarrer une session. + + + This is the first controller on %1, so it picks what the host runs. + C'est la première manette sur %1, elle choisit donc ce que l'hôte lance. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 exécute déjà une session pour cet appareil. Cette manette la rejoint en tant que manette %2. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + Une session porte quatre manettes au maximum, et %1 en a déjà quatre. Détachez-en une pour faire de la place. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 exécute une application pour un autre appareil et ne cédera pas cette session. Fermez-la pour en démarrer une nouvelle, ou ajoutez la manette et réessayez plus tard. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + L'hôte a une session mais ne l'a pas rendue. Fermez l'application sur %1 et démarrez-en une nouvelle. + + + The link dropped. Dish will rejoin the next time you use this controller. + La liaison est tombée. Dish rejoindra la session la prochaine fois que vous utiliserez cette manette. + + + The app closed on the host. Start a new session to keep using this controller. + L'application s'est fermée sur l'hôte. Démarrez une nouvelle session pour continuer à utiliser cette manette. + + + %1 · controller %2 of 4 + %1 · manette %2 sur 4 + + + Unbinding the last controller ends this session. + Détacher la dernière manette met fin à cette session. + + + This is the only Moonlight state that stops you adding the controller. + C'est le seul état Moonlight qui vous empêche d'ajouter la manette. + + + You can add the controller now and settle this later. + Vous pouvez ajouter la manette maintenant et régler cela plus tard. + + WizardTypePage Continue › Continuer › + + Some hosts override the choice. + Certains hôtes remplacent ce choix. + Types offered by %1’s catalog. Types proposés par le catalogue de %1. + + Auto + Automatique + Rumble Vibration @@ -3716,10 +4301,18 @@ Touchpad Pavé tactile + + How should the host see it? + Comment l'hôte doit-il la voir ? + How should the PC see it? Comment le PC doit-il la voir ? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + Dish demande à %1 de brancher cette manette. Certains hôtes remplacent ce choix. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Choisissez la manette que le PC doit signaler. Chacune débloque des options différentes — cette manette les limite toutes les trois de la même façon. @@ -3740,6 +4333,14 @@ Best fit Meilleur choix + + Picked for you + Choisi pour vous + + + Auto sends %1 for this controller. + Automatique envoie %1 pour cette manette. + dish::AppModel @@ -3759,6 +4360,14 @@ The satellite wouldn’t accept that controller — binding undone. Le satellite n'a pas accepté cette manette — liaison annulée. + + That host is already running an app. Stop it on the host, then try again. + Cet hôte exécute déjà une application. Arrêtez-la sur l’hôte, puis réessayez. + + + The Moonlight session ended. + La session Moonlight s’est terminée. + Couldn’t switch %1 to Direct mode — keeping it on Standard. Impossible de passer %1 en Mode direct — maintenue en Standard. diff --git a/translations/dish_pt_BR.ts b/translations/dish_pt_BR.ts index d4345be..09c7b5a 100644 --- a/translations/dish_pt_BR.ts +++ b/translations/dish_pt_BR.ts @@ -185,6 +185,10 @@ %1 has no %2. %1 não tem %2. + + A %1 controller does not carry %2 over Moonlight. + Um controle %1 não leva %2 pelo Moonlight. + %1 doesn’t carry %2. %1 não transmite %2. @@ -380,6 +384,22 @@ Claiming controller… Adquirindo o controle… + + Auto + Automático + + + Paired + Pareado + + + Remembered + Lembrado + + + Not paired + Não pareado + slot %1 slot %1 @@ -455,6 +475,10 @@ Pick a destination to continue. Escolha um destino para continuar. + + Unbind a controller on %1 to make room. + Desvincule um controle em %1 para abrir espaço. + Waiting on the controller catalog. Aguardando o catálogo de controles. @@ -595,10 +619,22 @@ Manage destinations › Gerenciar destinos › + + Moonlight hosts + Hosts Moonlight + + + Moonlight host · %1 + Host Moonlight · %1 + This machine pairs as a Bluetooth gamepad. Gyro, touchpad and mouse need a Satellite host. Esta máquina pareia como controle Bluetooth. Giroscópio, touchpad e mouse precisam de um host Satélite. + + Auto sends %1 for this controller. + Automático envia %1 para este controle. + Handing the device over can take a few seconds. Liberar o dispositivo pode levar alguns segundos. @@ -778,6 +814,14 @@ Disconnect Desconectar + + Moonlight hosts + Hosts Moonlight + + + Stream to a PC running Sunshine, Apollo or Wolf instead of a satellite. + Transmita para um PC com Sunshine, Apollo ou Wolf em vez de um satélite. + Forget Esquecer @@ -2574,6 +2618,244 @@ Cancelar + + MoonlightHostsPage + + Moonlight hosts + Hosts Moonlight + + + %n found + + %n encontrado + %n encontrados + + + + %n paired + + %n pareado + %n pareados + + + + Found + Encontrado + + + scanning… + buscando… + + + Add by address… + Adicionar por endereço… + + + Scanning… + Buscando… + + + Scan + Buscar + + + Looking for Moonlight hosts + Procurando hosts Moonlight + + + Scanning your network for hosts advertising GameStream. They appear here as they answer. + Buscando na sua rede hosts que anunciam GameStream. Eles aparecem aqui assim que respondem. + + + No Moonlight hosts found + Nenhum host Moonlight encontrado + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Um PC aparece aqui assim que o Sunshine, o Apollo ou o Wolf estiver rodando nele e as duas máquinas estiverem na mesma rede. Você também pode adicioná-lo por endereço. + + + Get Sunshine ↗ + Obter o Sunshine ↗ + + + %1, Moonlight host, %2 + %1, host Moonlight, %2 + + + Moonlight host (Sunshine/Apollo) + Host Moonlight (Sunshine/Apollo) + + + In use by %1 + Em uso por %1 + + + Session + Sessão + + + Pair again + Parear novamente + + + Pair… + Parear… + + + More actions for %1 + Mais ações para %1 + + + Pairing is one-time trust, not a connection. Dish re-checks it when you use a controller. + O pareamento é uma confiança única, não uma conexão. O Dish confere de novo quando você usa um controle. + + + Quit session + Encerrar sessão + + + Forget + Esquecer + + + Forget %1? + Esquecer %1? + + + Cancel + Cancelar + + + Moonlight host + Host Moonlight + + + Add a host by address + Adicionar um host por endereço + + + Add + Adicionar + + + Enter the host IP address or hostname. Dish uses the standard Moonlight ports. + Digite o endereço IP ou o nome do host. O Dish usa as portas padrão do Moonlight. + + + 192.168.1.20 + 192.168.1.20 + + + Name (optional) + Nome (opcional) + + + Pairing + Pareamento + + + Pair with %1 + Parear com %1 + + + Done + Concluído + + + Type %1 into the Moonlight or Sunshine page on %2. + Digite %1 na página do Moonlight ou do Sunshine em %2. + + + Check that the code went into the right host, then try again. + Confira se o código foi digitado no host certo e tente de novo. + + + Waiting for the host to accept the PIN… + Aguardando o host aceitar o PIN… + + + Dish deletes its half of the pairing and will need the PIN again. %1 keeps its own record of this device until somebody removes it there. + O Dish exclui a metade dele do pareamento e vai pedir o PIN de novo. %1 mantém o próprio registro deste dispositivo até alguém removê-lo lá. + + + New code + Novo código + + + %n bindings ride on it and will be dropped: + + %n vínculo depende dele e será removido: + %n vínculos dependem dele e serão removidos: + + + + Its session ends for the %n controllers on it. + + A sessão dele termina para o %n controle nela. + A sessão dele termina para os %n controles nela. + + + + %1 did not answer. Check that it is switched on and on this network. + %1 não respondeu. Confira se ele está ligado e nesta rede. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 recusou a solicitação. Confira se o pareamento está permitido no host. + + + Dish could not prepare its own identity for pairing. Try again. + O Dish não conseguiu preparar a própria identidade para o pareamento. Tente de novo. + + + Paired + Pareado + + + Remembered + Lembrado + + + Not paired + Não pareado + + + %n controllers + + %n controle + %n controles + + + + Pairing… + Pareando… + + + Starting… + Iniciando… + + + Connecting… + Conectando… + + + Streaming + Transmitindo + + + Unsteady + Instável + + + Failed + Falhou + + + Disconnected + Desconectado + + PairingDialog @@ -2950,6 +3232,10 @@ Step 3 of 3 · Type Etapa 3 de 3 · Tipo + + Step 3 of 3 · Session + Passo 3 de 3 · Sessão + Step 3 of 3 · Feel Etapa 3 de 3 · Sensação @@ -2975,14 +3261,22 @@ Padrão - satellite · 0 slots free - satélite · 0 slots livres + moonlight + moonlight + + + satellite + satélite + + + %1 · 0 slots free + %1 · 0 vagas livres - satellite · %n slots free + %n slots free - satélite · %n slot livre - satélite · %n slots livres + %n slot livre + %n slots livres @@ -3276,8 +3570,8 @@ Etapa %1, %2 - Sub-step %1 of 3 - Subetapa %1 de 3 + Sub-step %1 of %2 + Subpasso %1 de %2 @@ -3337,6 +3631,22 @@ %n slots livres + + full + cheio + + + Paired + Pareado + + + Remembered + Lembrado + + + Not paired + Não pareado + Which PC? Qual PC? @@ -3364,13 +3674,25 @@ Needs pairing, PIN Parear novamente, PIN + + Moonlight hosts + Hosts Moonlight + + + Moonlight host · %1 + Host Moonlight · %1 + + + A PC appears here once Sunshine, Apollo or Wolf is running on it and both machines are on the same network. You can also add one by address. + Um PC aparece aqui assim que o Sunshine, o Apollo ou o Wolf estiver rodando nele e as duas máquinas estiverem na mesma rede. Você também pode adicioná-lo por endereço. + No PCs found yet Nenhum PC encontrado ainda - A PC shows up here once the free Satellite app is running on it and both machines are on the same network. - Um PC aparece aqui assim que o app Satellite gratuito estiver rodando nele e as duas máquinas estiverem na mesma rede. + A PC shows up here once the free Satellite app is running on it, or once Sunshine, Apollo or Wolf is, and both machines are on the same network. + Um PC aparece aqui assim que o app gratuito Satellite estiver rodando nele, ou o Sunshine, o Apollo ou o Wolf, e as duas máquinas estiverem na mesma rede. Don’t see your PC? Install the free Satellite app on it. @@ -3694,16 +4016,279 @@ Nada em %1 mudou ainda. Vincular é a primeira e única escrita. + + WizardSessionPage + + Continue › + Continuar › + + + Unbind a controller on %1 to make room. + Desvincule um controle em %1 para abrir espaço. + + + Session + Sessão + + + Checking %1… + Verificando %1… + + + Reading the app list from %1… + Lendo a lista de apps de %1… + + + Waiting for the host to accept the PIN… + Aguardando o host aceitar o PIN… + + + Streaming + Transmitindo + + + Could not read the app list from %1 + Não foi possível ler a lista de apps de %1 + + + Could not finish the session on %1 + Não foi possível concluir a sessão em %1 + + + %1 refused the session: %2 + %1 recusou a sessão: %2 + + + %1 refused the session + %1 recusou a sessão + + + Dish will start whatever the host lists first. Retry once %1 is reachable. + O Dish vai iniciar o que o host listar primeiro. Tente de novo quando %1 estiver acessível. + + + The app started but the stream did not come up, so Dish closed it again. + O app iniciou, mas a transmissão não subiu, então o Dish fechou ele de novo. + + + Add the controller anyway and Dish will try again the next time you use it. + Adicione o controle mesmo assim e o Dish tentará de novo na próxima vez que você usar. + + + No apps on this host + Nenhum app neste host + + + %1 has no apps set up yet. Add one on the host, or add the controller and Dish will start whatever the host lists first. + %1 ainda não tem apps configurados. Adicione um no host, ou adicione o controle e o Dish vai iniciar o que o host listar primeiro. + + + Retry + Tentar novamente + + + Pair now + Parear agora + + + Pair again + Parear novamente + + + Try again + Tentar de novo + + + New code + Novo código + + + Cancel + Cancelar + + + Close the app on %1 + Fechar o app em %1 + + + Reconnect + Reconectar + + + Start a session + Iniciar uma sessão + + + See controllers on %1 + Ver controles em %1 + + + Without a pick, Dish starts whatever %1 lists first. + Sem uma escolha, o Dish inicia o que %1 listar primeiro. + + + Not paired yet + Ainda não pareado + + + Pair with %1 + Parear com %1 + + + %1 did not accept the PIN + %1 não aceitou o PIN + + + %1 is not answering + %1 não está respondendo + + + %1 no longer recognises this device + %1 não reconhece mais este dispositivo + + + %1 was reset + %1 foi redefinido + + + New session + Nova sessão + + + Joining %1 + Entrando em %1 + + + Joining the session on %1 + Entrando na sessão em %1 + + + %1 is full + %1 está cheio + + + Another device is using %1 + Outro dispositivo está usando %1 + + + Could not rejoin the session on %1 + Não foi possível voltar à sessão em %1 + + + Streaming to %1 + Transmitindo para %1 + + + Session on %1 ended + A sessão em %1 terminou + + + %1 ended the session + %1 encerrou a sessão + + + %1 needs a one time PIN before Dish can start a session. Pair now, or add the controller and pair later. + %1 precisa de um PIN de uso único antes que o Dish possa iniciar uma sessão. Pareie agora, ou adicione o controle e pareie depois. + + + Type %1 into the Moonlight or Sunshine page on %2. + Digite %1 na página do Moonlight ou do Sunshine em %2. + + + %1 did not answer. Check that it is switched on and on this network. + %1 não respondeu. Confira se ele está ligado e nesta rede. + + + %1 turned the request down. Check that pairing is allowed on the host. + %1 recusou a solicitação. Confira se o pareamento está permitido no host. + + + Dish could not prepare its own identity for pairing. Try again. + O Dish não conseguiu preparar a própria identidade para o pareamento. Tente de novo. + + + Check that the code went into the right host, then try again. + Confira se o código foi digitado no host certo e tente de novo. + + + Check that the host is switched on and on this network, then try again. + Confira se o host está ligado e nesta rede e tente de novo. + + + Dish remembers the pairing with %1 and will start a session when the host is back. + O Dish lembra do pareamento com %1 e vai iniciar uma sessão quando o host voltar. + + + The host removed the pairing. Pair again to start a session. + O host removeu o pareamento. Pareie novamente para iniciar uma sessão. + + + This host has a new identity, so the old pairing no longer works. Pair again to start a session. + Este host tem uma identidade nova, então o pareamento antigo não funciona mais. Pareie novamente para iniciar uma sessão. + + + This is the first controller on %1, so it picks what the host runs. + Este é o primeiro controle em %1, então ele escolhe o que o host executa. + + + %1 is already running a session for this device. This controller joins it as controller %2. + %1 já está executando uma sessão para este dispositivo. Este controle entra nela como controle %2. + + + A session carries four controllers at most, and %1 already has four. Unbind one to make room. + Uma sessão leva no máximo quatro controles, e %1 já tem quatro. Desvincule um para abrir espaço. + + + %1 is running an app for a different device and will not hand that session over. Close it to start a new one, or add the controller and try again later. + %1 está executando um app para outro dispositivo e não vai passar essa sessão. Feche ela para iniciar uma nova, ou adicione o controle e tente de novo mais tarde. + + + The host has a session but would not hand it back. Close the app on %1 and start a new one. + O host tem uma sessão mas não devolveu ela. Feche o app em %1 e inicie uma nova. + + + The link dropped. Dish will rejoin the next time you use this controller. + A conexão caiu. O Dish vai entrar de novo na próxima vez que você usar este controle. + + + The app closed on the host. Start a new session to keep using this controller. + O app fechou no host. Inicie uma nova sessão para continuar usando este controle. + + + %1 · controller %2 of 4 + %1 · controle %2 de 4 + + + Unbinding the last controller ends this session. + Desvincular o último controle encerra esta sessão. + + + This is the only Moonlight state that stops you adding the controller. + Este é o único estado do Moonlight que impede você de adicionar o controle. + + + You can add the controller now and settle this later. + Você pode adicionar o controle agora e resolver isso depois. + + WizardTypePage Continue › Continuar › + + Some hosts override the choice. + Alguns hosts ignoram a escolha. + Types offered by %1’s catalog. Tipos oferecidos pelo catálogo de %1. + + Auto + Automático + Rumble Vibração @@ -3716,10 +4301,18 @@ Touchpad Touchpad + + How should the host see it? + Como o host deve vê-lo? + How should the PC see it? Como o PC deve vê-lo? + + Dish asks %1 to plug in this controller. Some hosts override the choice. + O Dish pede que %1 conecte este controle. Alguns hosts ignoram a escolha. + Pick the controller the PC should report. Each unlocks different extras — this pad limits all three the same way. Escolha o controle que o PC deve informar. Cada um libera extras diferentes — este controle limita os três da mesma forma. @@ -3740,6 +4333,14 @@ Best fit Melhor opção + + Picked for you + Escolhido para você + + + Auto sends %1 for this controller. + Automático envia %1 para este controle. + dish::AppModel @@ -3759,6 +4360,14 @@ The satellite wouldn’t accept that controller — binding undone. O satélite não aceitou esse controle — vínculo desfeito. + + That host is already running an app. Stop it on the host, then try again. + Esse host já está executando um aplicativo. Feche-o no host e tente novamente. + + + The Moonlight session ended. + A sessão do Moonlight terminou. + Couldn’t switch %1 to Direct mode — keeping it on Standard. Não foi possível mudar %1 para o Modo direto — mantendo no Padrão.