#include "nvcomputer.h" #include #include #include #include #include #include #include #include #include #include #include #define FAST_FAIL_TIMEOUT_MS 2000 #define REQUEST_TIMEOUT_MS 5000 #define LAUNCH_TIMEOUT_MS 120000 #define RESUME_TIMEOUT_MS 30000 #define QUIT_TIMEOUT_MS 30000 NvHTTP::NvHTTP(NvAddress address, uint16_t httpsPort, QSslCertificate serverCert) : m_ServerCert(serverCert) { m_BaseUrlHttp.setScheme("http"); m_BaseUrlHttps.setScheme("https"); setAddress(address); setHttpsPort(httpsPort); // Never use a proxy server QNetworkProxy noProxy(QNetworkProxy::NoProxy); m_Nam.setProxy(noProxy); connect(&m_Nam, &QNetworkAccessManager::sslErrors, this, &NvHTTP::handleSslErrors); } NvHTTP::NvHTTP(NvComputer* computer) : NvHTTP(computer->activeAddress, computer->activeHttpsPort, computer->serverCert) { } void NvHTTP::setServerCert(QSslCertificate serverCert) { m_ServerCert = serverCert; } void NvHTTP::setAddress(NvAddress address) { Q_ASSERT(!address.isNull()); m_Address = address; m_BaseUrlHttp.setHost(address.address()); m_BaseUrlHttps.setHost(address.address()); m_BaseUrlHttp.setPort(address.port()); } void NvHTTP::setHttpsPort(uint16_t port) { m_BaseUrlHttps.setPort(port); } NvAddress NvHTTP::address() { return m_Address; } QSslCertificate NvHTTP::serverCert() { return m_ServerCert; } uint16_t NvHTTP::httpPort() { return m_BaseUrlHttp.port(); } uint16_t NvHTTP::httpsPort() { return m_BaseUrlHttps.port(); } QVector NvHTTP::parseQuad(QString quad) { QVector ret; // Return an empty vector for old GFE versions // that were missing GfeVersion. if (quad.isEmpty()) { return ret; } QStringList parts = quad.split("."); ret.reserve(parts.length()); for (int i = 0; i < parts.length(); i++) { ret.append(parts.at(i).toInt()); } return ret; } int NvHTTP::getCurrentGame(QString serverInfo) { // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer // has the semantics that its name would indicate. To contain the effects of this change as much // as possible, we'll force the current game to zero if the server isn't in a streaming session. QString serverState = getXmlString(serverInfo, "state"); if (serverState != nullptr && serverState.endsWith("_SERVER_BUSY")) { return getXmlString(serverInfo, "currentgame").toInt(); } else { return 0; } } QString NvHTTP::getServerInfo(NvLogLevel logLevel, bool fastFail) { QString serverInfo; // Check if we have a pinned cert and HTTPS port for this host yet if (!m_ServerCert.isNull() && httpsPort() != 0) { try { // Always try HTTPS first, since it properly reports // pairing status (and a few other attributes). serverInfo = openConnectionToString(m_BaseUrlHttps, "serverinfo", nullptr, fastFail ? FAST_FAIL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, logLevel); // Throws if the request failed verifyResponseStatus(serverInfo); } catch (const GfeHttpResponseException& e) { if (e.getStatusCode() == 401) { // Certificate validation error, fallback to HTTP serverInfo = openConnectionToString(m_BaseUrlHttp, "serverinfo", nullptr, fastFail ? FAST_FAIL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, logLevel); verifyResponseStatus(serverInfo); } else { // Rethrow real errors throw e; } } } else { // Only use HTTP prior to pairing or fetching HTTPS port serverInfo = openConnectionToString(m_BaseUrlHttp, "serverinfo", nullptr, fastFail ? FAST_FAIL_TIMEOUT_MS : REQUEST_TIMEOUT_MS, logLevel); verifyResponseStatus(serverInfo); // Populate the HTTPS port uint16_t httpsPort = getXmlString(serverInfo, "HttpsPort").toUShort(); if (httpsPort == 0) { httpsPort = DEFAULT_HTTPS_PORT; } setHttpsPort(httpsPort); // If we just needed to determine the HTTPS port, we'll try again over // HTTPS now that we have the port number if (!m_ServerCert.isNull()) { return getServerInfo(logLevel, fastFail); } } return serverInfo; } void NvHTTP::launchApp(int appId, PSTREAM_CONFIGURATION streamConfig, bool sops, bool localAudio, int gamepadMask, QString& rtspSessionUrl) { int riKeyId; memcpy(&riKeyId, streamConfig->remoteInputAesIv, sizeof(riKeyId)); riKeyId = qFromBigEndian(riKeyId); QString response = openConnectionToString(m_BaseUrlHttps, "launch", "appid="+QString::number(appId)+ "&mode="+QString::number(streamConfig->width)+"x"+ QString::number(streamConfig->height)+"x"+ // Using an FPS value over 60 causes SOPS to default to 720p60, // so force it to 0 to ensure the correct resolution is set. We // used to use 60 here but that locked the frame rate to 60 FPS // on GFE 3.20.3. QString::number(streamConfig->fps > 60 ? 0 : streamConfig->fps)+ "&additionalStates=1&sops="+QString::number(sops ? 1 : 0)+ "&rikey="+QByteArray(streamConfig->remoteInputAesKey, sizeof(streamConfig->remoteInputAesKey)).toHex()+ "&rikeyid="+QString::number(riKeyId)+ (streamConfig->enableHdr ? "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0" : "")+ "&localAudioPlayMode="+QString::number(localAudio ? 1 : 0)+ "&surroundAudioInfo="+QString::number(SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(streamConfig->audioConfiguration))+ "&remoteControllersBitmap="+QString::number(gamepadMask)+ "&gcmap="+QString::number(gamepadMask), LAUNCH_TIMEOUT_MS); qInfo() << "Launch response:" << response; // Throws if the request failed verifyResponseStatus(response); rtspSessionUrl = getXmlString(response, "sessionUrl0"); } void NvHTTP::resumeApp(PSTREAM_CONFIGURATION streamConfig, QString& rtspSessionUrl) { int riKeyId; memcpy(&riKeyId, streamConfig->remoteInputAesIv, sizeof(riKeyId)); riKeyId = qFromBigEndian(riKeyId); QString response = openConnectionToString(m_BaseUrlHttps, "resume", "rikey="+QString(QByteArray(streamConfig->remoteInputAesKey, sizeof(streamConfig->remoteInputAesKey)).toHex())+ "&rikeyid="+QString::number(riKeyId)+ "&surroundAudioInfo="+QString::number(SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(streamConfig->audioConfiguration)), RESUME_TIMEOUT_MS); qInfo() << "Resume response:" << response; // Throws if the request failed verifyResponseStatus(response); rtspSessionUrl = getXmlString(response, "sessionUrl0"); } void NvHTTP::quitApp() { QString response = openConnectionToString(m_BaseUrlHttps, "cancel", nullptr, QUIT_TIMEOUT_MS); qInfo() << "Quit response:" << response; // Throws if the request failed verifyResponseStatus(response); // Newer GFE versions will just return success even if quitting fails // if we're not the original requestor. if (getCurrentGame(getServerInfo(NvHTTP::NVLL_ERROR)) != 0) { // Generate a synthetic GfeResponseException letting the caller know // that they can't kill someone else's stream. throw GfeHttpResponseException(599, ""); } } QVector NvHTTP::getDisplayModeList(QString serverInfo) { QXmlStreamReader xmlReader(serverInfo); QVector modes; while (!xmlReader.atEnd()) { while (xmlReader.readNextStartElement()) { auto name = xmlReader.name(); if (name == QString("DisplayMode")) { modes.append(NvDisplayMode()); } else if (name == QString("Width")) { modes.last().width = xmlReader.readElementText().toInt(); } else if (name == QString("Height")) { modes.last().height = xmlReader.readElementText().toInt(); } else if (name == QString("RefreshRate")) { modes.last().refreshRate = xmlReader.readElementText().toInt(); } } } return modes; } QVector NvHTTP::getAppList() { QString appxml = openConnectionToString(m_BaseUrlHttps, "applist", nullptr, REQUEST_TIMEOUT_MS, NvLogLevel::NVLL_ERROR); verifyResponseStatus(appxml); QXmlStreamReader xmlReader(appxml); QVector apps; while (!xmlReader.atEnd()) { while (xmlReader.readNextStartElement()) { auto name = xmlReader.name(); if (name == QString("App")) { // We must have a valid app before advancing to the next one if (!apps.isEmpty() && !apps.last().isInitialized()) { qWarning() << "Invalid applist XML"; Q_ASSERT(false); return QVector(); } apps.append(NvApp()); } else if (name == QString("AppTitle")) { apps.last().name = xmlReader.readElementText(); } else if (name == QString("ID")) { apps.last().id = xmlReader.readElementText().toInt(); } else if (name == QString("IsHdrSupported")) { apps.last().hdrSupported = xmlReader.readElementText() == "1"; } else if (name == QString("IsAppCollectorGame")) { apps.last().isAppCollectorGame = xmlReader.readElementText() == "1"; } } } return apps; } void NvHTTP::verifyResponseStatus(QString xml) { QXmlStreamReader xmlReader(xml); while (xmlReader.readNextStartElement()) { if (xmlReader.name() == QString("root")) { // Status code can be 0xFFFFFFFF in some rare cases on GFE 3.20.3, and // QString::toInt() will fail in that case, so use QString::toUInt() // and cast the result to an int instead. int statusCode = (int)xmlReader.attributes().value("status_code").toUInt(); if (statusCode == 200) { // Successful return; } else { QString statusMessage = xmlReader.attributes().value("status_message").toString(); if (statusCode != 401) { // 401 is expected for unpaired PCs when we fetch serverinfo over HTTPS qWarning() << "Request failed:" << statusCode << statusMessage; } if (statusCode == -1 && statusMessage == "Invalid") { // Special case handling an audio capture error which GFE doesn't // provide any useful status message for. statusCode = 418; statusMessage = tr("Missing audio capture device. Reinstalling GeForce Experience should resolve this error."); } throw GfeHttpResponseException(statusCode, statusMessage); } } } throw GfeHttpResponseException(-1, "Malformed GFE XML (missing root element)"); } QImage NvHTTP::getBoxArt(int appId) { QNetworkReply* reply = openConnection(m_BaseUrlHttps, "appasset", "appid="+QString::number(appId)+ "&AssetType=2&AssetIdx=0", REQUEST_TIMEOUT_MS, NvLogLevel::NVLL_VERBOSE); QImage image = QImageReader(reply).read(); delete reply; return image; } QByteArray NvHTTP::getXmlStringFromHex(QString xml, QString tagName) { QString str = getXmlString(xml, tagName); if (str == nullptr) { return nullptr; } return QByteArray::fromHex(str.toLatin1()); } QString NvHTTP::getXmlString(QString xml, QString tagName) { QXmlStreamReader xmlReader(xml); while (!xmlReader.atEnd()) { if (xmlReader.readNext() != QXmlStreamReader::StartElement) { continue; } if (xmlReader.name() == tagName) { return xmlReader.readElementText(); } } return nullptr; } void NvHTTP::handleSslErrors(QNetworkReply* reply, const QList& errors) { bool ignoreErrors = true; if (m_ServerCert.isNull()) { // We should never make an HTTPS request without a cert Q_ASSERT(!m_ServerCert.isNull()); return; } for (const QSslError& error : errors) { if (m_ServerCert != error.certificate()) { ignoreErrors = false; break; } } if (ignoreErrors) { reply->ignoreSslErrors(errors); } } QString NvHTTP::openConnectionToString(QUrl baseUrl, QString command, QString arguments, int timeoutMs, NvLogLevel logLevel) { QNetworkReply* reply = openConnection(baseUrl, command, arguments, timeoutMs, logLevel); QString ret; QTextStream stream(reply); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) stream.setEncoding(QStringConverter::Utf8); #else stream.setCodec("UTF-8"); #endif ret = stream.readAll(); delete reply; return ret; } QNetworkReply* NvHTTP::openConnection(QUrl baseUrl, QString command, QString arguments, int timeoutMs, NvLogLevel logLevel) { // Port must be set Q_ASSERT(baseUrl.port(0) != 0); // Build a URL for the request QUrl url(baseUrl); url.setPath("/" + command); // Use a common UID for Moonlight clients to allow them to quit // games for each other (otherwise GFE gets screwed up and it requires // manual intervention to solve). url.setQuery("uniqueid=0123456789ABCDEF&uuid=" + QUuid::createUuid().toRfc4122().toHex() + ((arguments != nullptr) ? ("&" + arguments) : "")); QNetworkRequest request(url); // Add our client certificate request.setSslConfiguration(IdentityManager::get()->getSslConfig()); #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) // Disable HTTP/2 (GFE 3.22 doesn't like it) and Qt 6 enables it by default request.setAttribute(QNetworkRequest::Http2AllowedAttribute, false); #endif #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) && QT_VERSION < QT_VERSION_CHECK(5, 15, 1) && !defined(QT_NO_BEARERMANAGEMENT) // HACK: Set network accessibility to work around QTBUG-80947 (introduced in Qt 5.14.0 and fixed in Qt 5.15.1) QT_WARNING_PUSH QT_WARNING_DISABLE_DEPRECATED m_Nam.setNetworkAccessible(QNetworkAccessManager::Accessible); QT_WARNING_POP #endif QNetworkReply* reply = m_Nam.get(request); // Run the request with a timeout if requested QEventLoop loop; connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, &loop, &QEventLoop::quit); if (timeoutMs) { QTimer::singleShot(timeoutMs, &loop, &QEventLoop::quit); } if (logLevel >= NvLogLevel::NVLL_VERBOSE) { qInfo() << "Executing request:" << url.toString(); } loop.exec(QEventLoop::ExcludeUserInputEvents); // Abort the request if it timed out if (!reply->isFinished()) { if (logLevel >= NvLogLevel::NVLL_ERROR) { qWarning() << "Aborting timed out request for" << url.toString(); } reply->abort(); } // We must clear out cached authentication and connections or // GFE will puke next time m_Nam.clearAccessCache(); // Handle error if (reply->error() != QNetworkReply::NoError) { if (logLevel >= NvLogLevel::NVLL_ERROR) { qWarning() << command << " request failed with error " << reply->error(); } if (reply->error() == QNetworkReply::SslHandshakeFailedError) { // This will trigger falling back to HTTP for the serverinfo query // then pairing again to get the updated certificate. GfeHttpResponseException exception(401, "Server certificate mismatch"); delete reply; throw exception; } else if (reply->error() == QNetworkReply::OperationCanceledError) { QtNetworkReplyException exception(QNetworkReply::TimeoutError, "Request timed out"); delete reply; throw exception; } else { QtNetworkReplyException exception(reply->error(), reply->errorString()); delete reply; throw exception; } } return reply; }