diff --git a/app/backend/computermanager.cpp b/app/backend/computermanager.cpp index 5e07591c6..7e82e0de9 100644 --- a/app/backend/computermanager.cpp +++ b/app/backend/computermanager.cpp @@ -385,12 +385,18 @@ void ComputerManager::startPolling() qWarning() << "mDNS is disabled by user preference"; } - // Start MultiSeat seat auto-discovery + // Start MultiSeat seat auto-discovery. + // + // Seats are found by probing the seat port block on hosts the user already has, not by asking + // the MultiSeat service — that only worked when Moonlight ran on the host itself. So discovery + // needs the known-host addresses, refreshed each time it polls. m_MultiSeatDiscovery = new MultiSeatDiscovery(this); connect(m_MultiSeatDiscovery, &MultiSeatDiscovery::seatFound, this, [this](QString host, uint16_t port, QString name) { addNewHost(NvAddress(host, port), false, name); }); + connect(m_MultiSeatDiscovery, &MultiSeatDiscovery::aboutToPoll, + this, &ComputerManager::updateMultiSeatProbeTargets); m_MultiSeatDiscovery->start(); // Start polling threads for each known host @@ -426,6 +432,38 @@ void ComputerManager::startPollingComputer(NvComputer* computer) } } +// Hand seat discovery the addresses of hosts the user already has. +// +// Deduplicated by address rather than by host, because a seat and the console Apollo it lives +// beside share one address — probing it twice would just double the requests for nothing. Local +// addresses only: seat ports are not port-forwarded, so probing a remote address would be four +// guaranteed failures per tick against someone else's network. +void ComputerManager::updateMultiSeatProbeTargets() +{ + if (m_MultiSeatDiscovery == nullptr) { + return; + } + + QStringList addresses; + + QReadLocker lock(&m_Lock); + QMapIterator i(m_KnownHosts); + while (i.hasNext()) { + i.next(); + NvComputer* computer = i.value(); + + QReadLocker computerLock(&computer->lock); + for (const NvAddress& candidate : { computer->localAddress, computer->manualAddress }) { + if (!candidate.isNull() && !addresses.contains(candidate.address())) { + addresses.append(candidate.address()); + } + } + } + lock.unlock(); + + m_MultiSeatDiscovery->setHostsToProbe(addresses); +} + void ComputerManager::handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector& addresses) { diff --git a/app/backend/computermanager.h b/app/backend/computermanager.h index 6e43c801b..f3df8a6ce 100644 --- a/app/backend/computermanager.h +++ b/app/backend/computermanager.h @@ -261,6 +261,10 @@ private slots: void handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector& addresses); + // Refresh the addresses MultiSeatDiscovery probes for seats. Driven by its aboutToPoll signal + // rather than by the host add/remove paths, which run under the write lock on a thread pool. + void updateMultiSeatProbeTargets(); + private: void saveHosts(); diff --git a/app/backend/multiseatdiscovery.cpp b/app/backend/multiseatdiscovery.cpp index b79862538..af1eb6c6e 100644 --- a/app/backend/multiseatdiscovery.cpp +++ b/app/backend/multiseatdiscovery.cpp @@ -2,16 +2,13 @@ #include #include -#include -#include -#include #include +#include "nvhttp.h" MultiSeatDiscovery::MultiSeatDiscovery(QObject* parent) : QObject(parent), m_Nam(new QNetworkAccessManager(this)), - m_Timer(new QTimer(this)), - m_RequestPending(false) + m_Timer(new QTimer(this)) { connect(m_Nam, &QNetworkAccessManager::finished, this, &MultiSeatDiscovery::handleReply); @@ -38,59 +35,80 @@ void MultiSeatDiscovery::stop() m_Timer->stop(); } +void MultiSeatDiscovery::setHostsToProbe(const QStringList& addresses) +{ + m_Hosts = addresses; +} + void MultiSeatDiscovery::poll() { - if (m_RequestPending) { - return; + // Refresh the address list first — hosts may have been added or removed since the last tick. + emit aboutToPoll(); + + // Nothing to probe until the user has a host. That is the right dependency: a seat lives on + // the same machine as the Apollo they already added, so its address is already known. + for (const QString& host : std::as_const(m_Hosts)) { + for (int seat = 0; seat < MAX_SEATS_PROBED; seat++) { + uint16_t port = static_cast(SEAT_PORT_BASE + seat * SEAT_PORT_STRIDE); + + QString key = QStringLiteral("%1:%2").arg(host).arg(port); + if (m_InFlight.contains(key)) { + // Still waiting on the previous probe of this endpoint. Skipping avoids stacking + // one request per tick against something slow or firewalled. + continue; + } + + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(host); + url.setPort(port); + url.setPath(QStringLiteral("/serverinfo")); + + QNetworkRequest request(url); + request.setAttribute(QNetworkRequest::ConnectionEncryptedAttribute, false); + // Do not let a probe hold a connection open; most ports probed will be closed. + request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + + m_InFlight.insert(key); + m_Nam->get(request); + } } - - QUrl url; - url.setScheme("http"); - url.setHost("127.0.0.1"); - url.setPort(MULTISEAT_API_PORT); - url.setPath("/api/seats"); - - QNetworkRequest request(url); - request.setAttribute(QNetworkRequest::ConnectionEncryptedAttribute, false); - - m_RequestPending = true; - m_Nam->get(request); } void MultiSeatDiscovery::handleReply(QNetworkReply* reply) { - m_RequestPending = false; reply->deleteLater(); + const QUrl url = reply->url(); + const QString host = url.host(); + const uint16_t port = static_cast(url.port()); + m_InFlight.remove(QStringLiteral("%1:%2").arg(host).arg(port)); + if (reply->error() != QNetworkReply::NoError) { - // MultiSeat service not running — silent failure, will retry + // Expected for every port with no seat on it, which is most of them. Staying quiet here + // is correct — unlike the old code, silence now means "no seat on this port", not "the + // whole mechanism could never work". return; } - QJsonParseError parseError; - QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &parseError); - if (doc.isNull() || !doc.isArray()) { - qWarning() << "MultiSeat: invalid seats response:" << parseError.errorString(); + // Apollo answers /serverinfo with XML. Read the hostname; it is what tells a seat apart from + // an unrelated Apollo that happens to sit on one of these ports. + // + // Reuses NvHTTP's parser rather than opening a second QXmlStreamReader on the same shape of + // document — one place to be wrong about Apollo's XML is enough. + const QString hostname = NvHTTP::getXmlString(QString::fromUtf8(reply->readAll()), + QStringLiteral("hostname")); + + if (hostname.isEmpty()) { return; } - QJsonArray seats = doc.array(); - for (const QJsonValue& val : std::as_const(seats)) { - if (!val.isObject()) continue; - QJsonObject seat = val.toObject(); - - QString status = seat["status"].toString(); - // Only expose seats that have Apollo running and ready for streaming - if (status != "Ready" && status != "Streaming") continue; - - int portBase = seat["portBase"].toInt(0); - if (portBase <= 0) continue; - - QString accountName = seat["accountName"].toString(); - QString displayName = accountName.isEmpty() - ? QStringLiteral("MultiSeat Seat") - : QStringLiteral("MultiSeat - %1").arg(accountName); - - emit seatFound("127.0.0.1", static_cast(portBase), displayName); + if (!hostname.startsWith(QLatin1String(SEAT_NAME_PREFIX))) { + // Something is serving here, but it is not a MultiSeat seat. Leave it alone rather than + // adding a host the user did not ask for. + return; } + + emit seatFound(host, port, hostname); } diff --git a/app/backend/multiseatdiscovery.h b/app/backend/multiseatdiscovery.h index 31a9979e1..662ac6ee3 100644 --- a/app/backend/multiseatdiscovery.h +++ b/app/backend/multiseatdiscovery.h @@ -2,17 +2,49 @@ #include #include +#include +#include #include -// Polls the local MultiSeat service API (http://localhost:9550/api/seats) -// and emits seatFound() for each active seat so ComputerManager can add it. +// Finds MultiSeat seats by probing the seat port block on hosts the user already has, and emits +// seatFound() for each one so ComputerManager can add it. +// +// ⭐ It does NOT ask the MultiSeat service. The previous implementation polled +// http://127.0.0.1:9550/api/seats, which only ever worked when Moonlight ran on the host itself — +// on any other machine that address is the CLIENT, which runs no MultiSeat service. The request +// failed and was swallowed deliberately ("silent failure, will retry"), so seats simply never +// appeared and nothing said why. See MoonlightVibe#1. +// +// Asking the service would also mean exposing MultiSeat's dashboard API to the LAN and putting its +// API key on every client. Probing needs neither: a seat's Apollo already answers /serverinfo on +// its own port, which is exactly what adding it by hand does. +// +// ⛔ mDNS is NOT an alternative here. A seat's Apollo logs "Registered Apollo mDNS service", but +// the registration never reaches the network: Apollo registers through Windows' responder rather +// than binding 5353 itself, and a registration made inside an RDP session does not escape it. +// Measured 2026-09-10 — browsing _nvstream._tcp with a seat running returns the console Apollo and +// nothing else, every time. class MultiSeatDiscovery : public QObject { Q_OBJECT public: static constexpr int POLL_INTERVAL_MS = 15000; - static constexpr int MULTISEAT_API_PORT = 9550; + + // Mirrors MultiSeat's Constants.PortBase / Constants.PortsPerSeat. Seat N answers on + // PortBase + N * PortsPerSeat, so the defaults give 48100, 48130, 48160, 48190. + // + // ⚠️ Both are configurable on the host (MultiSeat:PortBase, MultiSeat:MaxSeats) and nothing + // advertises them, so a host that has changed them needs its seats added by hand. Probing the + // default block covers the normal case without any host-side cooperation at all. + static constexpr uint16_t SEAT_PORT_BASE = 48100; + static constexpr uint16_t SEAT_PORT_STRIDE = 30; + static constexpr int MAX_SEATS_PROBED = 4; + + // A seat's Apollo is named MultiSeat-{Account}-{N} by ApolloConfigBuilder, so its /serverinfo + // hostname identifies it. This is what keeps the probe from mistaking an unrelated Apollo on a + // nearby port for a seat. + static constexpr const char* SEAT_NAME_PREFIX = "MultiSeat-"; explicit MultiSeatDiscovery(QObject* parent = nullptr); ~MultiSeatDiscovery(); @@ -20,11 +52,22 @@ class MultiSeatDiscovery : public QObject void start(); void stop(); + // Addresses to probe — the hosts the user already knows about. ComputerManager keeps this + // current; discovery has no opinion about where hosts come from. + void setHostsToProbe(const QStringList& addresses); + signals: - // Emitted for each Ready/Streaming seat found in the MultiSeat API. - // port is the Apollo HTTP discovery port (seat.portBase). + // Emitted for each seat that answers on a seat port with a MultiSeat- hostname. + // port is the seat's Apollo HTTP port (its portBase). void seatFound(QString host, uint16_t port, QString displayName); + // Raised immediately before each poll so the owner can refresh setHostsToProbe(). + // + // Pulling the addresses here rather than pushing them from every place a host is added or + // removed keeps this off ComputerManager's locking paths — the add path runs on a thread pool + // while holding the write lock, and reaching back into it from there invites a deadlock. + void aboutToPoll(); + private slots: void poll(); void handleReply(QNetworkReply* reply); @@ -32,5 +75,10 @@ private slots: private: QNetworkAccessManager* m_Nam; QTimer* m_Timer; - bool m_RequestPending; + QStringList m_Hosts; + + // Keyed by "host:port". Stops a slow or unreachable endpoint from accumulating one request + // per tick, which the old single m_RequestPending flag could not express once probes run in + // parallel. + QSet m_InFlight; };