Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion app/backend/computermanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<QString, NvComputer*> 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<QHostAddress>& addresses)
{
Expand Down
4 changes: 4 additions & 0 deletions app/backend/computermanager.h
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ private slots:

void handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector<QHostAddress>& 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();

Expand Down
104 changes: 61 additions & 43 deletions app/backend/multiseatdiscovery.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@

#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QUrl>
#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);
Expand All @@ -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<uint16_t>(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<uint16_t>(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<uint16_t>(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);
}
60 changes: 54 additions & 6 deletions app/backend/multiseatdiscovery.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,83 @@

#include <QObject>
#include <QNetworkAccessManager>
#include <QSet>
#include <QStringList>
#include <QTimer>

// 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();

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);

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<QString> m_InFlight;
};
Loading