#include "computermanager.h" #include "boxartmanager.h" #include "nvhttp.h" #include "settings/streamingpreferences.h" #include #include #include #include #include #define SER_HOSTS "hosts" class PcMonitorThread : public QThread { Q_OBJECT #define TRIES_BEFORE_OFFLINING 2 #define POLLS_PER_APPLIST_FETCH 10 public: PcMonitorThread(NvComputer* computer) : m_Computer(computer) { setObjectName("Polling thread for " + computer->name); } private: bool tryPollComputer(NvAddress address, bool& changed) { NvHTTP http(address, 0, m_Computer->serverCert); QString serverInfo; try { serverInfo = http.getServerInfo(NvHTTP::NvLogLevel::NVLL_NONE, true); } catch (...) { return false; } NvComputer newState(http, serverInfo); // Ensure the machine that responded is the one we intended to contact if (m_Computer->uuid != newState.uuid) { qInfo() << "Found unexpected PC " << newState.name << " looking for " << m_Computer->name; return false; } changed = m_Computer->update(newState); return true; } bool updateAppList(bool& changed) { NvHTTP http(m_Computer); QVector appList; try { appList = http.getAppList(); if (appList.isEmpty()) { return false; } } catch (...) { return false; } QWriteLocker lock(&m_Computer->lock); changed = m_Computer->updateAppList(appList); return true; } void run() override { // Always fetch the applist the first time int pollsSinceLastAppListFetch = POLLS_PER_APPLIST_FETCH; while (!isInterruptionRequested()) { bool stateChanged = false; bool online = false; bool wasOnline = m_Computer->state == NvComputer::CS_ONLINE; for (int i = 0; i < (wasOnline ? TRIES_BEFORE_OFFLINING : 1) && !online; i++) { for (auto& address : m_Computer->uniqueAddresses()) { if (isInterruptionRequested()) { return; } if (tryPollComputer(address, stateChanged)) { if (!wasOnline) { qInfo() << m_Computer->name << "is now online at" << m_Computer->activeAddress.toString(); } online = true; break; } } } // Check if we failed after all retry attempts // Note: we don't need to acquire the read lock here, // because we're on the writing thread. if (!online && m_Computer->state != NvComputer::CS_OFFLINE) { qInfo() << m_Computer->name << "is now offline"; m_Computer->state = NvComputer::CS_OFFLINE; stateChanged = true; } // Grab the applist if it's empty or it's been long enough that we need to refresh pollsSinceLastAppListFetch++; if (m_Computer->state == NvComputer::CS_ONLINE && m_Computer->pairState == NvComputer::PS_PAIRED && (m_Computer->appList.isEmpty() || pollsSinceLastAppListFetch >= POLLS_PER_APPLIST_FETCH)) { // Notify prior to the app list poll since it may take a while, and we don't // want to delay onlining of a machine, especially if we already have a cached list. if (stateChanged) { emit computerStateChanged(m_Computer); stateChanged = false; } if (updateAppList(stateChanged)) { pollsSinceLastAppListFetch = 0; } } if (stateChanged) { // Tell anyone listening that we've changed state emit computerStateChanged(m_Computer); } // Wait a bit to poll again, but do it in 100 ms chunks // so we can be interrupted reasonably quickly. // FIXME: QWaitCondition would be better. for (int i = 0; i < 30 && !isInterruptionRequested(); i++) { QThread::msleep(100); } } } signals: void computerStateChanged(NvComputer* computer); private: NvComputer* m_Computer; }; ComputerManager::ComputerManager(QObject *parent) : QObject(parent), m_PollingRef(0), m_MdnsBrowser(nullptr), m_CompatFetcher(nullptr) { QSettings settings; // Inflate our hosts from QSettings int hosts = settings.beginReadArray(SER_HOSTS); for (int i = 0; i < hosts; i++) { settings.setArrayIndex(i); NvComputer* computer = new NvComputer(settings); m_KnownHosts[computer->uuid] = computer; } settings.endArray(); // Fetch latest compatibility data asynchronously m_CompatFetcher.start(); // To quit in a timely manner, we must block additional requests // after we receive the aboutToQuit() signal. This is neccessary // because NvHTTP uses aboutToQuit() to abort requests in progres // while quitting, however this is a one time signal - additional // requests would not be aborted and block termination. connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &ComputerManager::handleAboutToQuit); } ComputerManager::~ComputerManager() { QWriteLocker lock(&m_Lock); // Delete machines that haven't been resolved yet while (!m_PendingResolution.isEmpty()) { MdnsPendingComputer* computer = m_PendingResolution.first(); delete computer; m_PendingResolution.removeFirst(); } // Delete the browser to stop discovery delete m_MdnsBrowser; m_MdnsBrowser = nullptr; // Interrupt polling for (ComputerPollingEntry* entry : m_PollEntries) { entry->interrupt(); } // Delete all polling entries (and associated threads) for (ComputerPollingEntry* entry : m_PollEntries) { delete entry; } // Destroy all NvComputer objects now that polling is halted for (NvComputer* computer : m_KnownHosts) { delete computer; } } void ComputerManager::saveHosts() { QSettings settings; QReadLocker lock(&m_Lock); settings.remove(SER_HOSTS); settings.beginWriteArray(SER_HOSTS); int i = 0; for (const NvComputer* computer : m_KnownHosts) { settings.setArrayIndex(i++); computer->serialize(settings); } settings.endArray(); } QHostAddress ComputerManager::getBestGlobalAddressV6(QVector &addresses) { for (const QHostAddress& address : addresses) { if (address.protocol() == QAbstractSocket::IPv6Protocol) { if (address.isInSubnet(QHostAddress("fe80::"), 10)) { // Link-local continue; } if (address.isInSubnet(QHostAddress("fec0::"), 10)) { qInfo() << "Ignoring site-local address:" << address; continue; } if (address.isInSubnet(QHostAddress("fc00::"), 7)) { qInfo() << "Ignoring ULA:" << address; continue; } if (address.isInSubnet(QHostAddress("2002::"), 16)) { qInfo() << "Ignoring 6to4 address:" << address; continue; } if (address.isInSubnet(QHostAddress("2001::"), 32)) { qInfo() << "Ignoring Teredo address:" << address; continue; } return address; } } return QHostAddress(); } void ComputerManager::startPolling() { QWriteLocker lock(&m_Lock); if (++m_PollingRef > 1) { return; } StreamingPreferences prefs; if (prefs.enableMdns) { // Start an MDNS query for GameStream hosts m_MdnsBrowser = new QMdnsEngine::Browser(&m_MdnsServer, "_nvstream._tcp.local.", &m_MdnsCache); connect(m_MdnsBrowser, &QMdnsEngine::Browser::serviceAdded, this, [this](const QMdnsEngine::Service& service) { qInfo() << "Discovered mDNS host:" << service.hostname(); MdnsPendingComputer* pendingComputer = new MdnsPendingComputer(&m_MdnsServer, service); connect(pendingComputer, &MdnsPendingComputer::resolvedHost, this, &ComputerManager::handleMdnsServiceResolved); m_PendingResolution.append(pendingComputer); }); } else { qWarning() << "mDNS is disabled by user preference"; } // Start polling threads for each known host QMapIterator i(m_KnownHosts); while (i.hasNext()) { i.next(); startPollingComputer(i.value()); } } // Must hold m_Lock for write void ComputerManager::startPollingComputer(NvComputer* computer) { if (m_PollingRef == 0) { return; } ComputerPollingEntry* pollingEntry; if (!m_PollEntries.contains(computer->uuid)) { pollingEntry = m_PollEntries[computer->uuid] = new ComputerPollingEntry(); } else { pollingEntry = m_PollEntries[computer->uuid]; } if (!pollingEntry->isActive()) { PcMonitorThread* thread = new PcMonitorThread(computer); connect(thread, &PcMonitorThread::computerStateChanged, this, &ComputerManager::handleComputerStateChanged); pollingEntry->setActiveThread(thread); thread->start(); } } void ComputerManager::handleMdnsServiceResolved(MdnsPendingComputer* computer, QVector& addresses) { QHostAddress v6Global = getBestGlobalAddressV6(addresses); bool added = false; // Add the host using the IPv4 address for (const QHostAddress& address : addresses) { if (address.protocol() == QAbstractSocket::IPv4Protocol) { // NB: We don't just call addNewHost() here with v6Global because the IPv6 // address may not be reachable (if the user hasn't installed the IPv6 helper yet // or if this host lacks outbound IPv6 capability). We want to add IPv6 even if // it's not currently reachable. addNewHost(NvAddress(address, computer->port()), true, NvAddress(v6Global, computer->port())); added = true; break; } } if (!added) { // If we get here, there wasn't an IPv4 address so we'll do it v6-only for (const QHostAddress& address : addresses) { if (address.protocol() == QAbstractSocket::IPv6Protocol) { // Use a link-local or site-local address for the "local address" if (address.isInSubnet(QHostAddress("fe80::"), 10) || address.isInSubnet(QHostAddress("fec0::"), 10) || address.isInSubnet(QHostAddress("fc00::"), 7)) { addNewHost(NvAddress(address, computer->port()), true, NvAddress(v6Global, computer->port())); break; } } } } m_PendingResolution.removeOne(computer); computer->deleteLater(); } void ComputerManager::handleComputerStateChanged(NvComputer* computer) { emit computerStateChanged(computer); if (computer->pendingQuit && computer->currentGameId == 0) { computer->pendingQuit = false; emit quitAppCompleted(QVariant()); } // Save updated hosts to QSettings saveHosts(); } QVector ComputerManager::getComputers() { QReadLocker lock(&m_Lock); return QVector::fromList(m_KnownHosts.values()); } class DeferredHostDeletionTask : public QRunnable { public: DeferredHostDeletionTask(ComputerManager* cm, NvComputer* computer) : m_Computer(computer), m_ComputerManager(cm) {} void run() { ComputerPollingEntry* pollingEntry; // Only do the minimum amount of work while holding the writer lock. // We must release it before calling saveHosts(). { QWriteLocker lock(&m_ComputerManager->m_Lock); pollingEntry = m_ComputerManager->m_PollEntries.take(m_Computer->uuid); m_ComputerManager->m_KnownHosts.remove(m_Computer->uuid); } // Persist the new host list m_ComputerManager->saveHosts(); // Delete the polling entry first. This will stop all polling threads too. delete pollingEntry; // Delete cached box art BoxArtManager::deleteBoxArt(m_Computer); // Finally, delete the computer itself. This must be done // last because the polling thread might be using it. delete m_Computer; } private: NvComputer* m_Computer; ComputerManager* m_ComputerManager; }; void ComputerManager::deleteHost(NvComputer* computer) { // Punt to a worker thread to avoid stalling the // UI while waiting for the polling thread to die QThreadPool::globalInstance()->start(new DeferredHostDeletionTask(this, computer)); } void ComputerManager::renameHost(NvComputer* computer, QString name) { { QWriteLocker lock(&computer->lock); computer->name = name; computer->hasCustomName = true; } // Notify the UI of the state change handleComputerStateChanged(computer); } void ComputerManager::clientSideAttributeUpdated(NvComputer* computer) { // Persist the change saveHosts(); // Notify the UI of the state change handleComputerStateChanged(computer); } void ComputerManager::handleAboutToQuit() { QWriteLocker lock(&m_Lock); // Interrupt polling threads immediately, so they // avoid making additional requests while quitting for (ComputerPollingEntry* entry : m_PollEntries) { entry->interrupt(); } } class PendingPairingTask : public QObject, public QRunnable { Q_OBJECT public: PendingPairingTask(ComputerManager* computerManager, NvComputer* computer, QString pin) : m_Computer(computer), m_Pin(pin) { connect(this, &PendingPairingTask::pairingCompleted, computerManager, &ComputerManager::pairingCompleted); } signals: void pairingCompleted(NvComputer* computer, QString error); private: void run() { NvPairingManager pairingManager(m_Computer); try { NvPairingManager::PairState result = pairingManager.pair(m_Computer->appVersion, m_Pin, m_Computer->serverCert); switch (result) { case NvPairingManager::PairState::PIN_WRONG: emit pairingCompleted(m_Computer, "The PIN from the PC didn't match. Please try again."); break; case NvPairingManager::PairState::FAILED: emit pairingCompleted(m_Computer, "Pairing failed. Please try again."); break; case NvPairingManager::PairState::ALREADY_IN_PROGRESS: emit pairingCompleted(m_Computer, "Another pairing attempt is already in progress."); break; case NvPairingManager::PairState::PAIRED: emit pairingCompleted(m_Computer, nullptr); break; } } catch (const GfeHttpResponseException& e) { emit pairingCompleted(m_Computer, "GeForce Experience returned error: " + e.toQString()); } catch (const QtNetworkReplyException& e) { emit pairingCompleted(m_Computer, e.toQString()); } } NvComputer* m_Computer; QString m_Pin; }; void ComputerManager::pairHost(NvComputer* computer, QString pin) { // Punt to a worker thread to avoid stalling the // UI while waiting for pairing to complete PendingPairingTask* pairing = new PendingPairingTask(this, computer, pin); QThreadPool::globalInstance()->start(pairing); } class PendingQuitTask : public QObject, public QRunnable { Q_OBJECT public: PendingQuitTask(ComputerManager* computerManager, NvComputer* computer) : m_Computer(computer) { connect(this, &PendingQuitTask::quitAppFailed, computerManager, &ComputerManager::quitAppCompleted); } signals: void quitAppFailed(QString error); private: void run() { NvHTTP http(m_Computer); try { if (m_Computer->currentGameId != 0) { http.quitApp(); } } catch (const GfeHttpResponseException& e) { { QWriteLocker lock(&m_Computer->lock); m_Computer->pendingQuit = false; } if (e.getStatusCode() == 599) { // 599 is a special code we make a custom message for emit quitAppFailed(tr("The running game wasn't started by this PC. " "You must quit the game on the host PC manually or use the device that originally started the game.")); } else { emit quitAppFailed(e.toQString()); } } catch (const QtNetworkReplyException& e) { { QWriteLocker lock(&m_Computer->lock); m_Computer->pendingQuit = false; } emit quitAppFailed(e.toQString()); } } NvComputer* m_Computer; }; void ComputerManager::quitRunningApp(NvComputer* computer) { QWriteLocker lock(&computer->lock); computer->pendingQuit = true; PendingQuitTask* quit = new PendingQuitTask(this, computer); QThreadPool::globalInstance()->start(quit); } void ComputerManager::stopPollingAsync() { QWriteLocker lock(&m_Lock); Q_ASSERT(m_PollingRef > 0); if (--m_PollingRef > 0) { return; } // Delete machines that haven't been resolved yet while (!m_PendingResolution.isEmpty()) { MdnsPendingComputer* computer = m_PendingResolution.first(); computer->deleteLater(); m_PendingResolution.removeFirst(); } // Delete the browser to stop discovery delete m_MdnsBrowser; m_MdnsBrowser = nullptr; // Interrupt all threads, but don't wait for them to terminate for (ComputerPollingEntry* entry : m_PollEntries) { entry->interrupt(); } } void ComputerManager::addNewHostManually(QString address) { QUrl url = QUrl::fromUserInput(address); if (url.isValid() && !url.host().isEmpty()) { // If there wasn't a port specified, use the default addNewHost(NvAddress(url.host(), url.port(DEFAULT_HTTP_PORT)), false); } else { emit computerAddCompleted(false, false); } } class PendingAddTask : public QObject, public QRunnable { Q_OBJECT public: PendingAddTask(ComputerManager* computerManager, NvAddress address, NvAddress mdnsIpv6Address, bool mdns) : m_ComputerManager(computerManager), m_Address(address), m_MdnsIpv6Address(mdnsIpv6Address), m_Mdns(mdns) { connect(this, &PendingAddTask::computerAddCompleted, computerManager, &ComputerManager::computerAddCompleted); connect(this, &PendingAddTask::computerStateChanged, computerManager, &ComputerManager::handleComputerStateChanged); } signals: void computerAddCompleted(QVariant success, QVariant detectedPortBlocking); void computerStateChanged(NvComputer* computer); private: QString fetchServerInfo(NvHTTP& http) { QString serverInfo; try { // There's a race condition between GameStream servers reporting presence over // mDNS and the HTTPS server being ready to respond to our queries. To work // around this issue, we will issue the request again after a few seconds if // we see a ServiceUnavailableError error. try { serverInfo = http.getServerInfo(NvHTTP::NVLL_VERBOSE); } catch (const QtNetworkReplyException& e) { if (e.getError() == QNetworkReply::ServiceUnavailableError) { qWarning() << "Retrying request in 5 seconds after ServiceUnavailableError"; QThread::sleep(5); serverInfo = http.getServerInfo(NvHTTP::NVLL_VERBOSE); qInfo() << "Retry successful"; } else { // Rethrow other errors throw e; } } return serverInfo; } catch (...) { if (!m_Mdns) { StreamingPreferences prefs; int portTestResult; if (prefs.detectNetworkBlocking) { // We failed to connect to the specified PC. Let's test to make sure this network // isn't blocking Moonlight, so we can tell the user about it. portTestResult = LiTestClientConnectivity("qt.conntest.moonlight-stream.org", 443, ML_PORT_FLAG_TCP_47984 | ML_PORT_FLAG_TCP_47989); } else { portTestResult = 0; } emit computerAddCompleted(false, portTestResult != 0 && portTestResult != ML_TEST_RESULT_INCONCLUSIVE); } return QString(); } } void run() { NvHTTP http(m_Address, 0, QSslCertificate()); qInfo() << "Processing new PC at" << m_Address.toString() << "from" << (m_Mdns ? "mDNS" : "user") << "with IPv6 address" << m_MdnsIpv6Address.toString(); // Perform initial serverinfo fetch over HTTP since we don't know which cert to use QString serverInfo = fetchServerInfo(http); if (serverInfo.isEmpty() && !m_MdnsIpv6Address.isNull()) { // Retry using the global IPv6 address if the IPv4 or link-local IPv6 address fails http.setAddress(m_MdnsIpv6Address); serverInfo = fetchServerInfo(http); } if (serverInfo.isEmpty()) { return; } // Create initial newComputer using HTTP serverinfo with no pinned cert NvComputer* newComputer = new NvComputer(http, serverInfo); // Check if we have a record of this host UUID to pull the pinned cert NvComputer* existingComputer; { QReadLocker lock(&m_ComputerManager->m_Lock); existingComputer = m_ComputerManager->m_KnownHosts.value(newComputer->uuid); if (existingComputer != nullptr) { http.setServerCert(existingComputer->serverCert); } } // Fetch serverinfo again over HTTPS with the pinned cert if (existingComputer != nullptr) { Q_ASSERT(http.httpsPort() != 0); serverInfo = fetchServerInfo(http); if (serverInfo.isEmpty()) { return; } // Update the polled computer with the HTTPS information NvComputer httpsComputer(http, serverInfo); newComputer->update(httpsComputer); } // Update addresses depending on the context if (m_Mdns) { // Only update local address if we actually reached the PC via this address. // If we reached it via the IPv6 address after the local address failed, // don't store the non-working local address. if (http.address() == m_Address) { newComputer->localAddress = m_Address; } // Get the WAN IP address using STUN if we're on mDNS over IPv4 if (QHostAddress(newComputer->localAddress.address()).protocol() == QAbstractSocket::IPv4Protocol) { quint32 addr; int err = LiFindExternalAddressIP4("stun.moonlight-stream.org", 3478, &addr); if (err == 0) { newComputer->setRemoteAddress(QHostAddress(qFromBigEndian(addr))); } else { qWarning() << "STUN failed to get WAN address:" << err; } } if (!m_MdnsIpv6Address.isNull()) { Q_ASSERT(QHostAddress(m_MdnsIpv6Address.address()).protocol() == QAbstractSocket::IPv6Protocol); newComputer->ipv6Address = m_MdnsIpv6Address; } } else { newComputer->manualAddress = m_Address; } QHostAddress hostAddress(m_Address.address()); bool addressIsSiteLocalV4 = hostAddress.isInSubnet(QHostAddress("10.0.0.0"), 8) || hostAddress.isInSubnet(QHostAddress("172.16.0.0"), 12) || hostAddress.isInSubnet(QHostAddress("192.168.0.0"), 16); { // Check if this PC already exists QWriteLocker lock(&m_ComputerManager->m_Lock); NvComputer* existingComputer = m_ComputerManager->m_KnownHosts.value(newComputer->uuid); if (existingComputer != nullptr) { // Fold it into the existing PC bool changed = existingComputer->update(*newComputer); delete newComputer; // Drop the lock before notifying lock.unlock(); // For non-mDNS clients, let them know it succeeded if (!m_Mdns) { emit computerAddCompleted(true, false); } // Tell our client if something changed if (changed) { qInfo() << existingComputer->name << "is now at" << existingComputer->activeAddress.toString(); emit computerStateChanged(existingComputer); } } else { // Store this in our active sets m_ComputerManager->m_KnownHosts[newComputer->uuid] = newComputer; // Start polling if enabled (write lock required) m_ComputerManager->startPollingComputer(newComputer); // Drop the lock before notifying lock.unlock(); // If this wasn't added via mDNS but it is a RFC 1918 IPv4 address and not a VPN, // go ahead and do the STUN request now to populate an external address. if (!m_Mdns && addressIsSiteLocalV4 && !newComputer->isReachableOverVpn()) { quint32 addr; int err = LiFindExternalAddressIP4("stun.moonlight-stream.org", 3478, &addr); if (err == 0) { lock.relock(); newComputer->setRemoteAddress(QHostAddress(qFromBigEndian(addr))); lock.unlock(); } else { qWarning() << "STUN failed to get WAN address:" << err; } } // For non-mDNS clients, let them know it succeeded if (!m_Mdns) { emit computerAddCompleted(true, false); } // Tell our client about this new PC emit computerStateChanged(newComputer); } } } ComputerManager* m_ComputerManager; NvAddress m_Address; NvAddress m_MdnsIpv6Address; bool m_Mdns; }; void ComputerManager::addNewHost(NvAddress address, bool mdns, NvAddress mdnsIpv6Address) { // Punt to a worker thread to avoid stalling the // UI while waiting for serverinfo query to complete PendingAddTask* addTask = new PendingAddTask(this, address, mdnsIpv6Address, mdns); QThreadPool::globalInstance()->start(addTask); } #include "computermanager.moc"