Browse Source

FEATURE: qBittorrent can update dynamic DNS services (DynDNS, no-ip)

adaptive-webui-19844
Christophe Dumez 14 years ago
parent
commit
c85cb8799e
  1. 1
      Changelog
  2. 287
      src/dnsupdater.cpp
  3. 81
      src/dnsupdater.h
  4. 98
      src/preferences/options.ui
  5. 23
      src/preferences/options_imp.cpp
  6. 1
      src/preferences/options_imp.h
  7. 43
      src/preferences/preferences.h
  8. 24
      src/qtlibtorrent/qbtsession.cpp
  9. 3
      src/qtlibtorrent/qbtsession.h
  10. 6
      src/src.pro

1
Changelog

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
- FEATURE: Added support for secure SMTP connection (SSL)
- FEATURE: Added support for SMTP authentication
- FEATURE: Added UPnP/NAT-PMP port forward for the Web UI port
- FEATURE: qBittorrent can update dynamic DNS services (DynDNS, no-ip)
- BUGFIX: Change systray icon on the fly (no restart needed)
- COSMETIC: Added monochrome icon for light themes

287
src/dnsupdater.cpp

@ -0,0 +1,287 @@ @@ -0,0 +1,287 @@
/*
* Bittorrent Client using Qt4 and libtorrent.
* Copyright (C) 2011 Christophe Dumez
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*
* Contact : chris@qbittorrent.org
*/
#include <QNetworkAccessManager>
#include <QDebug>
#include <QRegExp>
#include "dnsupdater.h"
#include "qbtsession.h"
DNSUpdater::DNSUpdater(QObject *parent) :
QObject(parent), m_state(OK)
{
updateCredentials();
// Load saved settings from previous session
QIniSettings settings("qBittorrent", "qBittorrent");
m_lastIPCheckTime = settings.value("DNSUpdater/lastUpdateTime").toDateTime();
m_lastIP = QHostAddress(settings.value("DNSUpdater/lastIP").toString());
// Start IP checking timer
m_ipCheckTimer.setInterval(IP_CHECK_INTERVAL_MS);
connect(&m_ipCheckTimer, SIGNAL(timeout()), SLOT(checkPublicIP()));
m_ipCheckTimer.start();
// Check lastUpdate to avoid flooding
if(!m_lastIPCheckTime.isValid() ||
m_lastIPCheckTime.secsTo(QDateTime::currentDateTime())*1000 > IP_CHECK_INTERVAL_MS) {
checkPublicIP();
}
}
DNSUpdater::~DNSUpdater() {
// Save lastupdate time and last ip
QIniSettings settings("qBittorrent", "qBittorrent");
settings.setValue("DNSUpdater/lastUpdateTime", m_lastIPCheckTime);
settings.setValue("DNSUpdater/lastIP", m_lastIP.toString());
}
void DNSUpdater::checkPublicIP()
{
Q_ASSERT(m_state == OK);
QNetworkAccessManager *manager = new QNetworkAccessManager(this);
connect(manager, SIGNAL(finished(QNetworkReply*)),
SLOT(ipRequestFinished(QNetworkReply*)));
m_lastIPCheckTime = QDateTime::currentDateTime();
QNetworkRequest request;
request.setUrl(QUrl("http://checkip.dyndns.org"));
request.setRawHeader("User-Agent", "qBittorrent/"VERSION" chris@qbittorrent.org");
manager->get(request);
}
void DNSUpdater::ipRequestFinished(QNetworkReply *reply)
{
qDebug() << Q_FUNC_INFO;
if(reply->error()) {
// Error
qWarning() << Q_FUNC_INFO << "Error:" << reply->errorString();
} else {
// Parse response
QRegExp ipregex("Current IP Address:\\s+([^<]+)</body>");
QString ret = reply->readAll();
if(ipregex.indexIn(ret) >= 0) {
QString ip_str = ipregex.cap(1);
qDebug() << Q_FUNC_INFO << "Regular expression captured the following IP:" << ip_str;
QHostAddress new_ip(ip_str);
if(!new_ip.isNull()) {
if(m_lastIP != new_ip) {
qDebug() << Q_FUNC_INFO << "The IP address changed, report the change to DynDNS...";
m_lastIP = new_ip;
updateDNSService();
}
} else {
qWarning() << Q_FUNC_INFO << "Failed to construct a QHostAddress from the IP string";
}
} else {
qWarning() << Q_FUNC_INFO << "Regular expression failed ot capture the IP address";
}
}
// Clean up
reply->deleteLater();
sender()->deleteLater();
}
void DNSUpdater::updateDNSService()
{
qDebug() << Q_FUNC_INFO;
// Prepare request
QNetworkAccessManager *manager = new QNetworkAccessManager(this);
connect(manager, SIGNAL(finished(QNetworkReply*)),
SLOT(ipUpdateFinished(QNetworkReply*)));
m_lastIPCheckTime = QDateTime::currentDateTime();
QNetworkRequest request;
request.setUrl(getUpdateUrl());
request.setRawHeader("User-Agent", "qBittorrent/"VERSION" chris@qbittorrent.org");
manager->get(request);
}
QUrl DNSUpdater::getUpdateUrl() const
{
QUrl url;
#ifdef QT_NO_OPENSSL
url.setScheme("http");
#else
url.setScheme("https");
#endif
url.setUserName(m_username);
url.setPassword(m_password);
Q_ASSERT(!m_lastIP.isNull());
// Service specific
switch(m_service) {
case DNS::DYNDNS:
url.setHost("members.dyndns.org");
break;
case DNS::NOIP:
url.setHost("dynupdate.no-ip.com");
break;
default:
qWarning() << "Unrecognized Dynamic DNS service!";
Q_ASSERT(0);
}
url.setPath("/nic/update");
url.addQueryItem("hostname", m_domain);
url.addQueryItem("myip", m_lastIP.toString());
Q_ASSERT(url.isValid());
qDebug() << Q_FUNC_INFO << url.toString();
return url;
}
void DNSUpdater::ipUpdateFinished(QNetworkReply *reply)
{
if(reply->error()) {
// Error
qWarning() << Q_FUNC_INFO << "Error:" << reply->errorString();
} else {
// Pase reply
processIPUpdateReply(reply->readAll());
}
// Clean up
reply->deleteLater();
sender()->deleteLater();
}
void DNSUpdater::processIPUpdateReply(const QString &reply)
{
qDebug() << Q_FUNC_INFO << reply;
QString code = reply.split(" ").first();
qDebug() << Q_FUNC_INFO << "Code:" << code;
if(code == "good" || code == "nochg") {
QBtSession::instance()->addConsoleMessage(tr("Your dynamic DNS was successfuly updated."), "green");
return;
}
if(code == "911" || code == "dnserr") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: The service is temporarily unavailable, it will be retried in 30 minutes."),
"red");
m_lastIP.clear();
// It will retry in 30 minutes because the timer was not stopped
return;
}
// Everything bellow is an error, stop updating until the user updates something
m_ipCheckTimer.stop();
m_lastIP.clear();
if(code == "nohost") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: hostname supplied does not exist under specified account."),
"red");
m_state = INVALID_CREDS;
return;
}
if(code == "badauth") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: Invalid username/password."), "red");
m_state = INVALID_CREDS;
return;
}
if(code == "badagent") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: qBittorrent was blacklisted by the service, please report a bug at http://bugs.qbittorrent.org."),
"red");
m_state = FATAL;
return;
}
if(code == "!donator") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: %1 was returned by the service, please report a bug at http://bugs.qbittorrent.org.").arg("!donator"),
"red");
m_state = FATAL;
return;
}
if(code == "abuse") {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: Your username was blocked due to abuse."),
"red");
m_state = FATAL;
return;
}
}
void DNSUpdater::updateCredentials()
{
if(m_state == FATAL) return;
Preferences pref;
bool change = false;
// Get DNS service information
if(m_service != pref.getDynDNSService()) {
m_service = pref.getDynDNSService();
change = true;
}
if(m_domain != pref.getDynDomainName()) {
m_domain = pref.getDynDomainName();
QRegExp domain_regex("^(?:(?!\\d|-)[a-zA-Z0-9\\-]{1,63}\\.)+[a-zA-Z]{2,}$");
if(domain_regex.indexIn(m_domain) < 0) {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: supplied domain name is invalid."),
"red");
m_lastIP.clear();
m_ipCheckTimer.stop();
m_state = INVALID_CREDS;
return;
}
change = true;
}
if(m_username != pref.getDynDNSUsername()) {
m_username = pref.getDynDNSUsername();
if(m_username.length() < 4) {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: supplied username is too short."),
"red");
m_lastIP.clear();
m_ipCheckTimer.stop();
m_state = INVALID_CREDS;
return;
}
change = true;
}
if(m_password != pref.getDynDNSPassword()) {
m_password = pref.getDynDNSPassword();
if(m_password.length() < 4) {
QBtSession::instance()->addConsoleMessage(tr("Dynamic DNS error: supplied password is too short."),
"red");
m_lastIP.clear();
m_ipCheckTimer.stop();
m_state = INVALID_CREDS;
return;
}
change = true;
}
if(m_state == INVALID_CREDS && change) {
m_state = OK; // Try again
m_ipCheckTimer.start();
checkPublicIP();
}
}
QUrl DNSUpdater::getRegistrationUrl(int service)
{
switch(service) {
case DNS::DYNDNS:
return QUrl("https://www.dyndns.com/account/services/hosts/add.html");
case DNS::NOIP:
return QUrl("http://www.no-ip.com/services/managed_dns/free_dynamic_dns.html");
default:
Q_ASSERT(0);
}
return QUrl();
}

81
src/dnsupdater.h

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
/*
* Bittorrent Client using Qt4 and libtorrent.
* Copyright (C) 2011 Christophe Dumez
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*
* Contact : chris@qbittorrent.org
*/
#ifndef DNSUPDATER_H
#define DNSUPDATER_H
#include <QObject>
#include <QHostAddress>
#include <QNetworkReply>
#include <QDateTime>
#include <QTimer>
#include "preferences.h"
/*!
* Based on http://www.dyndns.com/developers/specs/
*/
class DNSUpdater : public QObject
{
Q_OBJECT
public:
explicit DNSUpdater(QObject *parent = 0);
~DNSUpdater();
static QUrl getRegistrationUrl(int service);
public slots:
void updateCredentials();
private slots:
void checkPublicIP();
void ipRequestFinished(QNetworkReply* reply);
void updateDNSService();
void ipUpdateFinished(QNetworkReply* reply);
private:
QUrl getUpdateUrl() const;
void processIPUpdateReply(const QString &reply);
private:
QHostAddress m_lastIP;
QDateTime m_lastIPCheckTime;
QTimer m_ipCheckTimer;
int m_state;
// Service creds
DNS::Service m_service;
QString m_domain;
QString m_username;
QString m_password;
private:
static const int IP_CHECK_INTERVAL_MS = 1800000; // 30 min
enum State { OK, INVALID_CREDS, FATAL };
};
#endif // DNSUPDATER_H

98
src/preferences/options.ui

@ -2281,11 +2281,103 @@ QGroupBox { @@ -2281,11 +2281,103 @@ QGroupBox {
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="checkDynDNS">
<property name="title">
<string>Update my dynamic domain name</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>false</bool>
</property>
<layout class="QFormLayout" name="formLayout_5">
<item row="0" column="0">
<widget class="QLabel" name="label_19">
<property name="text">
<string>Service:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QComboBox" name="comboDNSService">
<item>
<property name="text">
<string>DynDNS</string>
</property>
</item>
<item>
<property name="text">
<string>No-IP</string>
</property>
</item>
</widget>
</item>
<item>
<widget class="QPushButton" name="registerDNSBtn">
<property name="text">
<string>Register</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_20">
<property name="text">
<string>Domain name:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="domainNameTxt">
<property name="text">
<string>changeme.dyndns.org</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_21">
<property name="text">
<string>Username:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="DNSUsernameTxt">
<property name="maxLength">
<number>50</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_22">
<property name="text">
<string>Password:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="DNSPasswordTxt">
<property name="maxLength">
<number>50</number>
</property>
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_9">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
@ -2315,8 +2407,8 @@ QGroupBox { @@ -2315,8 +2407,8 @@ QGroupBox {
<rect>
<x>0</x>
<y>0</y>
<width>86</width>
<height>16</height>
<width>504</width>
<height>384</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_36"/>

23
src/preferences/options_imp.cpp

@ -37,6 +37,7 @@ @@ -37,6 +37,7 @@
#include <QCloseEvent>
#include <QDesktopWidget>
#include <QTranslator>
#include <QDesktopServices>
#include <libtorrent/version.hpp>
#include <time.h>
@ -50,6 +51,7 @@ @@ -50,6 +51,7 @@
#include "qinisettings.h"
#include "qbtsession.h"
#include "iconprovider.h"
#include "dnsupdater.h"
using namespace libtorrent;
@ -203,6 +205,11 @@ options_imp::options_imp(QWidget *parent): @@ -203,6 +205,11 @@ options_imp::options_imp(QWidget *parent):
connect(textWebUiUsername, SIGNAL(textChanged(QString)), this, SLOT(enableApplyButton()));
connect(textWebUiPassword, SIGNAL(textChanged(QString)), this, SLOT(enableApplyButton()));
connect(checkBypassLocalAuth, SIGNAL(toggled(bool)), this, SLOT(enableApplyButton()));
connect(checkDynDNS, SIGNAL(toggled(bool)), SLOT(enableApplyButton()));
connect(comboDNSService, SIGNAL(currentIndexChanged(int)), SLOT(enableApplyButton()));
connect(domainNameTxt, SIGNAL(textChanged(QString)), SLOT(enableApplyButton()));
connect(DNSUsernameTxt, SIGNAL(textChanged(QString)), SLOT(enableApplyButton()));
connect(DNSPasswordTxt, SIGNAL(textChanged(QString)), SLOT(enableApplyButton()));
// Disable apply Button
applyButton->setEnabled(false);
// Tab selection mecanism
@ -428,6 +435,12 @@ void options_imp::saveOptions(){ @@ -428,6 +435,12 @@ void options_imp::saveOptions(){
// FIXME: Check that the password is valid (not empty at least)
pref.setWebUiPassword(webUiPassword());
pref.setWebUiLocalAuthEnabled(!checkBypassLocalAuth->isChecked());
// DynDNS
pref.setDynDNSEnabled(checkDynDNS->isChecked());
pref.setDynDNSService(comboDNSService->currentIndex());
pref.setDynDomainName(domainNameTxt->text());
pref.setDynDNSUsername(DNSUsernameTxt->text());
pref.setDynDNSPassword(DNSPasswordTxt->text());
}
// End Web UI
// End preferences
@ -666,6 +679,12 @@ void options_imp::loadOptions(){ @@ -666,6 +679,12 @@ void options_imp::loadOptions(){
textWebUiUsername->setText(pref.getWebUiUsername());
textWebUiPassword->setText(pref.getWebUiPassword());
checkBypassLocalAuth->setChecked(!pref.isWebUiLocalAuthEnabled());
// Dynamic DNS
checkDynDNS->setChecked(pref.isDynDNSEnabled());
comboDNSService->setCurrentIndex((int)pref.getDynDNSService());
domainNameTxt->setText(pref.getDynDomainName());
DNSUsernameTxt->setText(pref.getDynDNSUsername());
DNSPasswordTxt->setText(pref.getDynDNSPassword());
// End Web UI
// Random stuff
srand(time(0));
@ -1090,6 +1109,10 @@ void options_imp::showConnectionTab() @@ -1090,6 +1109,10 @@ void options_imp::showConnectionTab()
tabSelection->setCurrentRow(2);
}
void options_imp::on_registerDNSBtn_clicked() {
QDesktopServices::openUrl(DNSUpdater::getRegistrationUrl(comboDNSService->currentIndex()));
}
void options_imp::on_IpFilterRefreshBtn_clicked() {
if(m_refreshingIpFilter) return;
m_refreshingIpFilter = true;

1
src/preferences/options_imp.h

@ -80,6 +80,7 @@ private slots: @@ -80,6 +80,7 @@ private slots:
void on_randomButton_clicked();
void on_addScanFolderButton_clicked();
void on_removeScanFolderButton_clicked();
void on_registerDNSBtn_clicked();
void setLocale(const QString &locale);
private:

43
src/preferences/preferences.h

@ -61,6 +61,9 @@ enum ProxyType {HTTP=1, SOCKS5=2, HTTP_PW=3, SOCKS5_PW=4, SOCKS4=5}; @@ -61,6 +61,9 @@ enum ProxyType {HTTP=1, SOCKS5=2, HTTP_PW=3, SOCKS5_PW=4, SOCKS4=5};
namespace TrayIcon {
enum Style { NORMAL = 0, MONO_DARK, MONO_LIGHT };
}
namespace DNS {
enum Service { DYNDNS, NOIP };
}
class Preferences : public QIniSettings {
Q_DISABLE_COPY(Preferences);
@ -746,6 +749,46 @@ public: @@ -746,6 +749,46 @@ public:
return pass_ha1;
}
bool isDynDNSEnabled() const {
return value("Preferences/DynDNS/Enabled", false).toBool();
}
void setDynDNSEnabled(bool enabled) {
setValue("Preferences/DynDNS/Enabled", enabled);
}
DNS::Service getDynDNSService() const {
return DNS::Service(value("Preferences/DynDNS/Service", DNS::DYNDNS).toInt());
}
void setDynDNSService(int service) {
setValue("Preferences/DynDNS/Service", service);
}
QString getDynDomainName() const {
return value("Preferences/DynDNS/DomainName", "changeme.dyndns.org").toString();
}
void setDynDomainName(const QString name) {
setValue("Preferences/DynDNS/DomainName", name);
}
QString getDynDNSUsername() const {
return value("Preferences/DynDNS/Username").toString();
}
void setDynDNSUsername(const QString username) {
setValue("Preferences/DynDNS/Username", username);
}
QString getDynDNSPassword() const {
return value("Preferences/DynDNS/Password").toString();
}
void setDynDNSPassword(const QString password) {
setValue("Preferences/DynDNS/Password", password);
}
// Advanced settings
void setUILockPassword(const QString &clear_password) {

24
src/qtlibtorrent/qbtsession.cpp

@ -78,6 +78,7 @@ @@ -78,6 +78,7 @@
#endif
#include <queue>
#include <string.h>
#include "dnsupdater.h"
using namespace libtorrent;
@ -98,7 +99,7 @@ QBtSession::QBtSession() @@ -98,7 +99,7 @@ QBtSession::QBtSession()
, geoipDBLoaded(false), resolve_countries(false)
#endif
, m_tracker(0), m_shutdownAct(NO_SHUTDOWN),
m_upnp(0), m_natpmp(0)
m_upnp(0), m_natpmp(0), m_dynDNSUpdater(0)
{
BigRatioTimer = new QTimer(this);
BigRatioTimer->setInterval(10000);
@ -612,8 +613,25 @@ void QBtSession::initWebUi() { @@ -612,8 +613,25 @@ void QBtSession::initWebUi() {
else
addConsoleMessage(tr("Web User Interface Error - Unable to bind Web UI to port %1").arg(port), "red");
}
} else if(httpServer) {
delete httpServer;
// DynDNS
if(pref.isDynDNSEnabled()) {
if(!m_dynDNSUpdater)
m_dynDNSUpdater = new DNSUpdater(this);
else
m_dynDNSUpdater->updateCredentials();
} else {
if(m_dynDNSUpdater) {
delete m_dynDNSUpdater;
m_dynDNSUpdater = 0;
}
}
} else {
if(httpServer)
delete httpServer;
if(m_dynDNSUpdater) {
delete m_dynDNSUpdater;
m_dynDNSUpdater = 0;
}
}
}

3
src/qtlibtorrent/qbtsession.h

@ -58,6 +58,7 @@ class HttpServer; @@ -58,6 +58,7 @@ class HttpServer;
class BandwidthScheduler;
class ScanFoldersModel;
class TorrentSpeedMonitor;
class DNSUpdater;
class QBtSession : public QObject {
Q_OBJECT
@ -272,6 +273,8 @@ private: @@ -272,6 +273,8 @@ private:
// Port forwarding
libtorrent::upnp *m_upnp;
libtorrent::natpmp *m_natpmp;
// DynDNS
DNSUpdater *m_dynDNSUpdater;
};
#endif

6
src/src.pro

@ -101,13 +101,15 @@ HEADERS += misc.h \ @@ -101,13 +101,15 @@ HEADERS += misc.h \
filesystemwatcher.h \
scannedfoldersmodel.h \
qinisettings.h \
smtp.h
smtp.h \
dnsupdater.h
SOURCES += main.cpp \
downloadthread.cpp \
scannedfoldersmodel.cpp \
misc.cpp \
smtp.cpp
smtp.cpp \
dnsupdater.cpp
nox {
HEADERS += headlessloader.h

Loading…
Cancel
Save