Skip to content
6 changes: 5 additions & 1 deletion app/backend/computerseeker.cpp
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
#include "computerseeker.h"
#include "computermanager.h"
#include <QTimer>
#include <QThreadPool>

ComputerSeeker::ComputerSeeker(ComputerManager *manager, QString computerName, QObject *parent)
: QObject(parent), m_ComputerManager(manager), m_ComputerName(computerName),
m_TimeoutTimer(new QTimer(this))
{
// If we know this computer, send a WOL packet to wake it up in case it is asleep.
// Run on thread pool since HTTP wake may block for up to 10 seconds.
const auto computers = m_ComputerManager->getComputers();
for (NvComputer* computer : computers) {
if (this->matchComputer(computer)) {
computer->wake();
QThreadPool::globalInstance()->start(QRunnable::create([computer]() {
computer->wake();
}));
}
}

Expand Down
88 changes: 88 additions & 0 deletions app/backend/nvcomputer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
#include <QHostInfo>
#include <QNetworkInterface>
#include <QNetworkProxy>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QEventLoop>
#include <QTimer>
#include <QCoreApplication>

#define SER_NAME "hostname"
#define SER_UUID "uuid"
Expand All @@ -22,6 +28,8 @@
#define SER_SRVCERT "srvcert"
#define SER_CUSTOMNAME "customname"
#define SER_NVIDIASOFTWARE "nvidiasw"
#define SER_WAKEMETHOD "wakemethod"
#define SER_HTTPWAKEURL "httpwakeurl"

NvComputer::NvComputer(QSettings& settings)
{
Expand All @@ -39,6 +47,8 @@ NvComputer::NvComputer(QSettings& settings)
settings.value(SER_MANUALPORT, QVariant(DEFAULT_HTTP_PORT)).toUInt());
this->serverCert = QSslCertificate(settings.value(SER_SRVCERT).toByteArray());
this->isNvidiaServerSoftware = settings.value(SER_NVIDIASOFTWARE).toBool();
this->wakeMethod = static_cast<WakeMethod>(settings.value(SER_WAKEMETHOD, WM_WOL).toInt());
this->httpWakeUrl = settings.value(SER_HTTPWAKEURL).toString();

int appCount = settings.beginReadArray(SER_APPLIST);
this->appList.reserve(appCount);
Expand Down Expand Up @@ -92,6 +102,8 @@ void NvComputer::serialize(QSettings& settings, bool serializeApps) const
settings.setValue(SER_MANUALPORT, manualAddress.port());
settings.setValue(SER_SRVCERT, serverCert.toPem());
settings.setValue(SER_NVIDIASOFTWARE, isNvidiaServerSoftware);
settings.setValue(SER_WAKEMETHOD, static_cast<int>(wakeMethod));
settings.setValue(SER_HTTPWAKEURL, httpWakeUrl);

// Avoid deleting an existing applist if we couldn't get one
if (!appList.isEmpty() && serializeApps) {
Expand All @@ -117,6 +129,8 @@ bool NvComputer::isEqualSerialized(const NvComputer &that) const
this->manualAddress == that.manualAddress &&
this->serverCert == that.serverCert &&
this->isNvidiaServerSoftware == that.isNvidiaServerSoftware &&
this->wakeMethod == that.wakeMethod &&
this->httpWakeUrl == that.httpWakeUrl &&
this->appList == that.appList;
}

Expand Down Expand Up @@ -211,6 +225,67 @@ NvComputer::NvComputer(NvHTTP& http, QString serverInfo)
this->state = NvComputer::CS_ONLINE;
this->pendingQuit = false;
this->isSupportedServerVersion = CompatFetcher::isGfeVersionSupported(this->gfeVersion);
this->wakeMethod = WM_WOL;
this->httpWakeUrl = QString();
}

// NOTE: This method performs synchronous network I/O and may block for up to
// 10 seconds. It should only be called from a worker thread, not the UI thread.
bool NvComputer::performHttpWake(const QString& url, const QString& computerName) const
{
QUrl qurl(url);
if (!qurl.isValid()) {
qWarning() << computerName << "has invalid HTTP wake URL";
return false;
}

// Log URL without query params or credentials to avoid exposing sensitive data
QUrl redactedUrl = qurl;
if (redactedUrl.hasQuery()) {
redactedUrl.setQuery("***");
}
if (!redactedUrl.userInfo().isEmpty()) {
redactedUrl.setUserInfo("***");
}
qInfo() << "Sending HTTP wake request for" << computerName << "to" << redactedUrl.toString();

QNetworkAccessManager nam;
nam.setProxy(QNetworkProxy::NoProxy);

QNetworkRequest request(qurl);
QNetworkReply* reply = nam.get(request);

// Sync-over-async with 10 second timeout
bool timedOut = false;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
timedOut = true;
reply->abort();
});
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(10000);
loop.exec(QEventLoop::ExcludeUserInputEvents);
timer.stop();

bool success = false;
if (timedOut) {
qWarning() << "HTTP wake request timed out for" << computerName;
} else if (reply->error() != QNetworkReply::NoError) {
qWarning() << "HTTP wake request failed for" << computerName << ":" << reply->errorString();
} else {
int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (status >= 200 && status < 300) {
qInfo() << "HTTP wake request succeeded for" << computerName << "(status" << status << ")";
success = true;
} else {
qWarning() << "HTTP wake request failed for" << computerName << "(status" << status << ")";
}
}

delete reply;
return success;
}

bool NvComputer::wake() const
Expand All @@ -225,6 +300,19 @@ bool NvComputer::wake() const
return true;
}

// Check for HTTP wake method
if (wakeMethod == WM_HTTP) {
if (httpWakeUrl.isEmpty()) {
qWarning() << name << "has HTTP wake configured but no URL set";
return false;
}
QString url = httpWakeUrl;
QString computerName = name;
readLocker.unlock();
return performHttpWake(url, computerName);
}

// Standard WOL path
if (macAddress.isEmpty()) {
qWarning() << name << "has no MAC address stored";
return false;
Expand Down
9 changes: 9 additions & 0 deletions app/backend/nvcomputer.h
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ class NvComputer
CS_OFFLINE
};

enum WakeMethod
{
WM_WOL, // Standard Wake-on-LAN (default)
WM_HTTP // HTTP GET request to configured URL
};

// Ephemeral traits
ComputerState state;
PairState pairState;
Expand All @@ -113,11 +119,14 @@ class NvComputer
QSslCertificate serverCert;
QVector<NvApp> appList;
bool isNvidiaServerSoftware;
WakeMethod wakeMethod;
QString httpWakeUrl;
// Remember to update isEqualSerialized() when adding fields here!

// Synchronization
mutable CopySafeReadWriteLock lock;

private:
uint16_t externalPort;
bool performHttpWake(const QString& url, const QString& computerName) const;
};
111 changes: 111 additions & 0 deletions app/gui/PcView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ CenteredGridView {
renamePcDialog.open()
}
}
NavigableMenuItem {
text: qsTr("Configure Wake")
onTriggered: {
configureWakeDialog.pcIndex = index
configureWakeDialog.pcName = model.name
configureWakeDialog.wakeMethod = computerModel.getWakeMethod(index)
configureWakeDialog.httpWakeUrl = computerModel.getHttpWakeUrl(index)
configureWakeDialog.open()
}
}
NavigableMenuItem {
text: qsTr("Delete PC")
onTriggered: {
Expand Down Expand Up @@ -400,5 +410,106 @@ CenteredGridView {
standardButtons: Dialog.Ok
}

NavigableDialog {
id: configureWakeDialog
property int pcIndex: -1
property string pcName: ""
property int wakeMethod: 0
property string httpWakeUrl: ""
property bool urlValid: wolRadio.checked || computerModel.isValidWakeUrl(httpWakeUrlField.text.trim())

title: qsTr("Configure Wake: %1").arg(pcName)
standardButtons: Dialog.Ok | Dialog.Cancel

onOpened: {
wolRadio.forceActiveFocus()
wolRadio.checked = (wakeMethod === 0)
httpRadio.checked = (wakeMethod === 1)
httpWakeUrlField.text = httpWakeUrl
standardButton(Dialog.Ok).enabled = urlValid
}

onClosed: {
httpWakeUrlField.clear()
}

onAccepted: {
var method = wolRadio.checked ? 0 : 1
computerModel.configureWake(pcIndex, method, httpWakeUrlField.text.trim())
}

// Disable OK button when HTTP wake is selected but URL is invalid
onUrlValidChanged: {
if (visible) {
standardButton(Dialog.Ok).enabled = urlValid
}
}

ColumnLayout {
spacing: 10

Label {
text: qsTr("Wake Method:")
font.bold: true
}

ButtonGroup {
id: wakeMethodGroup
}

RadioButton {
id: wolRadio
text: qsTr("Standard Wake-on-LAN (magic packet)")
ButtonGroup.group: wakeMethodGroup
}

RadioButton {
id: httpRadio
text: qsTr("HTTP Wake (for VPN/Tailscale)")
ButtonGroup.group: wakeMethodGroup
}

Label {
text: qsTr("HTTP Wake URL:")
font.bold: true
visible: httpRadio.checked
}

TextField {
id: httpWakeUrlField
Layout.fillWidth: true
Layout.minimumWidth: 400
placeholderText: "https://wakeonlan.your-tailnet.ts.net/wake?mac=aa:bb:cc:dd:ee:ff"
visible: httpRadio.checked

Keys.onReturnPressed: {
if (configureWakeDialog.urlValid) {
configureWakeDialog.accept()
}
}
Keys.onEnterPressed: {
if (configureWakeDialog.urlValid) {
configureWakeDialog.accept()
}
}
}

Label {
text: qsTr("Invalid URL. Please enter a valid HTTP or HTTPS URL.")
font.pointSize: 9
color: "red"
visible: httpRadio.checked && httpWakeUrlField.text.trim() !== "" && !computerModel.isValidWakeUrl(httpWakeUrlField.text.trim())
}

Label {
text: qsTr("A simple HTTP GET request with a 10-second timeout will be sent to this URL.")
font.pointSize: 9
font.italic: true
visible: httpRadio.checked
Layout.topMargin: 5
}
}
}

ScrollBar.vertical: ScrollBar {}
}
48 changes: 47 additions & 1 deletion app/gui/computermodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ QVariant ComputerModel::data(const QModelIndex& index, int role) const
case BusyRole:
return computer->currentGameId != 0;
case WakeableRole:
return !computer->macAddress.isEmpty();
// Wakeable depends on the configured wake method
if (computer->wakeMethod == NvComputer::WM_HTTP) {
return !computer->httpWakeUrl.isEmpty();
} else {
return !computer->macAddress.isEmpty();
}
case StatusUnknownRole:
return computer->state == NvComputer::CS_UNKNOWN;
case ServerSupportedRole:
Expand Down Expand Up @@ -179,6 +184,47 @@ void ComputerModel::renameComputer(int computerIndex, QString name)
m_ComputerManager->renameHost(m_Computers[computerIndex], name);
}

void ComputerModel::configureWake(int computerIndex, int wakeMethod, QString httpWakeUrl)
{
Q_ASSERT(computerIndex < m_Computers.count());
NvComputer* computer = m_Computers[computerIndex];

{
QWriteLocker lock(&computer->lock);
computer->wakeMethod = static_cast<NvComputer::WakeMethod>(wakeMethod);
computer->httpWakeUrl = httpWakeUrl;
}

m_ComputerManager->clientSideAttributeUpdated(computer);
}

int ComputerModel::getWakeMethod(int computerIndex)
{
Q_ASSERT(computerIndex < m_Computers.count());
NvComputer* computer = m_Computers[computerIndex];
QReadLocker lock(&computer->lock);
return static_cast<int>(computer->wakeMethod);
}

QString ComputerModel::getHttpWakeUrl(int computerIndex)
{
Q_ASSERT(computerIndex < m_Computers.count());
NvComputer* computer = m_Computers[computerIndex];
QReadLocker lock(&computer->lock);
return computer->httpWakeUrl;
}

bool ComputerModel::isValidWakeUrl(QString url)
{
if (url.isEmpty()) {
return false;
}
QUrl qurl(url);
return qurl.isValid() &&
(qurl.scheme() == "http" || qurl.scheme() == "https") &&
!qurl.host().isEmpty();
}

QString ComputerModel::generatePinString()
{
return m_ComputerManager->generatePinString();
Expand Down
5 changes: 5 additions & 0 deletions app/gui/computermodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class ComputerModel : public QAbstractListModel

Q_INVOKABLE void renameComputer(int computerIndex, QString name);

Q_INVOKABLE void configureWake(int computerIndex, int wakeMethod, QString httpWakeUrl);
Q_INVOKABLE int getWakeMethod(int computerIndex);
Q_INVOKABLE QString getHttpWakeUrl(int computerIndex);
Q_INVOKABLE bool isValidWakeUrl(QString url);

Q_INVOKABLE Session* createSessionForCurrentGame(int computerIndex);

signals:
Expand Down