improve sending and receiving UI and fix issue sending to new client

This commit is contained in:
2026-08-24 19:06:26 +08:00
parent 7bcb02babe
commit da06f1fd51
5 changed files with 600 additions and 43 deletions
+156 -9
View File
@@ -32,6 +32,11 @@ void AppController::initialize()
{
m_security->initialize();
m_server->setSslConfiguration(m_security->sslConfiguration());
// The LocalSend protocol requires mutual TLS: sending our files to another
// device means our HTTP client must present this device's certificate during
// the handshake, or the receiver rejects the connection with
// "tlsv13 alert certificate required".
m_httpClient->setSslConfiguration(m_security->sslConfiguration());
LocalSend::InfoDto info = buildInfoDto();
m_server->setLocalInfo(info, m_security->fingerprint());
@@ -255,10 +260,21 @@ void AppController::acceptReceive(const QString& sessionId)
m_currentReceiveSenderAlias = session.sender.alias;
m_totalReceiveSize = 0;
m_receiveFileNames.clear();
m_receiveFileStates.clear();
m_receiveFinishedCount = 0;
for (auto it = session.files.constBegin(); it != session.files.constEnd(); ++it) {
m_totalReceiveSize += it->file.size;
m_receiveFileNames.insert(it.key(), it->file.fileName);
QVariantMap file;
file[QStringLiteral("id")] = it.key();
file[QStringLiteral("fileName")] = it.value().file.fileName;
file[QStringLiteral("size")] = it.value().file.size;
file[QStringLiteral("status")] = QStringLiteral("queue");
m_receiveFileStates.append(file);
}
m_receiveStartedAt = QDateTime::currentDateTime();
m_receiveElapsed.start();
emit receiveFileStatesChanged();
emit receivingChanged();
emit receiveProgressChanged();
@@ -433,17 +449,32 @@ void AppController::onUploadRequest(const QString& sessionId, const QString& fil
m_sessions->updateReceiveProgress(sessionId, fileId, written);
if (sessionId == m_currentReceiveSessionId) {
m_currentReceiveFileName = m_receiveFileNames.value(fileId);
m_currentReceiveFileIndex++;
m_receivedSize += written;
if (m_totalReceiveSize > 0) {
m_receiveProgressValue = (static_cast<double>(m_receivedSize) / m_totalReceiveSize) * 100.0;
}
emit receiveProgressChanged();
// Accumulate session-level stats. No m_currentReceiveSessionId guard here:
// the server only accepts one active receive session, so this upload always
// belongs to the currently shown dialog.
m_currentReceiveFileName = m_receiveFileNames.value(fileId);
m_currentReceiveFileIndex++;
m_receivedSize += written;
if (m_totalReceiveSize > 0) {
m_receiveProgressValue = (static_cast<double>(m_receivedSize) / m_totalReceiveSize) * 100.0;
}
emit receiveProgressChanged();
if (written >= transfer.file.size) {
// Mark this file finished in the UI state *before* completing the session:
// completing the last file synchronously emits receiveSessionCompleted, which
// resets m_currentReceiveSessionId so we can no longer match the id afterwards.
for (int i = 0; i < m_receiveFileStates.size(); ++i) {
QVariantMap f = m_receiveFileStates[i].toMap();
if (f.value(QStringLiteral("id")) == fileId) {
f[QStringLiteral("status")] = QStringLiteral("finished");
m_receiveFileStates[i] = f;
m_receiveFinishedCount++;
break;
}
}
emit receiveFileStatesChanged();
m_sessions->completeReceiveFile(sessionId, fileId);
}
}
@@ -544,6 +575,83 @@ QString AppController::currentReceiveSenderAlias() const
return m_currentReceiveSenderAlias;
}
QVariantList AppController::receiveFileStates() const
{
return m_receiveFileStates;
}
int AppController::receiveFinishedCount() const
{
return m_receiveFinishedCount;
}
QVariantList AppController::sendFileStates() const
{
return m_sendFileStates;
}
int AppController::sendFinishedCountFiles() const
{
return m_sendFinishedCount;
}
double AppController::receiveThroughput() const
{
// Bytes/second for the current receive session. Falls back to 0 before any data arrives.
if (!m_receiveElapsed.isValid() || m_receiveElapsed.nsecsElapsed() == 0 || m_receivedSize <= 0) {
return 0.0;
}
double seconds = m_receiveElapsed.nsecsElapsed() / 1e9;
if (seconds <= 0) {
return 0.0;
}
return m_receivedSize / seconds;
}
double AppController::sendThroughput() const
{
if (!m_sendElapsed.isValid() || m_sendElapsed.nsecsElapsed() == 0) {
return 0.0;
}
qint64 total = 0;
for (const auto& f : m_sendFileStates) {
total += f.toMap().value(QStringLiteral("size")).toLongLong();
}
if (total <= 0) {
return 0.0;
}
double seconds = m_sendElapsed.nsecsElapsed() / 1e9;
if (seconds <= 0) {
return 0.0;
}
double transferred = (m_sendProgress / 100.0) * total;
return transferred / seconds;
}
qint64 AppController::receiveTotalSize() const
{
return m_totalReceiveSize;
}
qint64 AppController::receivedSizeBytes() const
{
return m_receivedSize;
}
qint64 AppController::sendTotalBytes() const
{
qint64 total = 0;
for (const auto& f : m_sendFileStates) {
total += f.toMap().value(QStringLiteral("size")).toLongLong();
}
return total;
}
qint64 AppController::sendTransferredBytes() const
{
return static_cast<qint64>((m_sendProgress / 100.0) * sendTotalBytes());
}
QString AppController::currentSendFileName() const
{
if (m_currentFileIndex >= 0 && m_currentFileIndex < m_pendingSendPaths.size()) {
@@ -689,6 +797,8 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis
m_currentFileIndex = 0;
m_sendProgress = 0.0;
m_pinFirstAttempt = true;
m_sendFileStates.clear();
m_sendFinishedCount = 0;
qDebug() << "[AppController] sendFiles: device=" << deviceFingerprint
<< "files=" << filePaths.size();
@@ -708,6 +818,13 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis
return;
}
QVariantMap fstate;
fstate[QStringLiteral("id")] = QString::number(i);
fstate[QStringLiteral("fileName")] = info.fileName();
fstate[QStringLiteral("size")] = info.size();
fstate[QStringLiteral("status")] = QStringLiteral("queue");
m_sendFileStates.append(fstate);
LocalSend::FileDto fileDto;
fileDto.id = QString::number(i);
fileDto.fileName = info.fileName();
@@ -728,6 +845,9 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis
emit sendingChanged();
emit sendProgressChanged();
m_sendStartedAt = QDateTime::currentDateTime();
m_sendElapsed.start();
emit sendFileStatesChanged();
LocalSend::PrepareUploadRequestDto request;
request.info = buildRegisterDto();
@@ -802,6 +922,16 @@ void AppController::onPrepareUploadResponse(const LocalSend::PrepareUploadRespon
sendNextFile();
}
void AppController::markSendFileSending(int index)
{
if (index >= 0 && index < m_sendFileStates.size()) {
QVariantMap f = m_sendFileStates[index].toMap();
f[QStringLiteral("status")] = QStringLiteral("sending");
m_sendFileStates[index] = f;
emit sendFileStatesChanged();
}
}
void AppController::onPrepareUploadError(const QString& error)
{
qWarning() << "[AppController] onPrepareUploadError:" << error;
@@ -877,6 +1007,19 @@ void AppController::onUploadCompleted()
m_sessions->completeSendFile(m_currentSendSessionId, m_currentSendFileId);
// Mark this file finished in the live state list.
QString finishedId = m_currentSendFileId;
for (int i = 0; i < m_sendFileStates.size(); ++i) {
QVariantMap f = m_sendFileStates[i].toMap();
if (f.value(QStringLiteral("id")) == finishedId) {
f[QStringLiteral("status")] = QStringLiteral("finished");
m_sendFinishedCount++;
m_sendFileStates[i] = f;
break;
}
}
emit sendFileStatesChanged();
m_currentFileIndex++;
if (m_currentFileIndex < m_pendingSendPaths.size()) {
@@ -919,6 +1062,8 @@ void AppController::sendNextFile()
return;
}
markSendFileSending(m_currentFileIndex);
LocalSend::SendSession session = m_sessions->sendSession(m_currentSendSessionId);
if (session.sessionId.isEmpty()) {
qWarning() << "[AppController] Session not found:" << m_currentSendSessionId;
@@ -991,6 +1136,7 @@ void AppController::resetSendState()
m_currentSendDeviceFingerprint.clear();
m_currentFileIndex = 0;
m_sendProgress = 0.0;
m_sendElapsed.invalidate();
emit sendingChanged();
emit sendProgressChanged();
}
@@ -1004,8 +1150,9 @@ void AppController::resetReceiveState()
m_totalReceiveFiles = 0;
m_totalReceiveSize = 0;
m_receivedSize = 0;
m_currentReceiveSenderAlias.clear();
// Keep m_currentReceiveSenderAlias so the completed dialog still shows "From".
m_receiveFileNames.clear();
m_receiveElapsed.invalidate();
emit receivingChanged();
emit receiveProgressChanged();
}
+33
View File
@@ -3,6 +3,8 @@
#include <QObject>
#include <QVariantList>
#include <QVariantMap>
#include <QDateTime>
#include <QElapsedTimer>
#include "LocalSendCore/DiscoveryManager.h"
#include "LocalSendCore/HttpServer.h"
#include "LocalSendCore/HttpClient.h"
@@ -34,6 +36,16 @@ class AppController : public QObject
Q_PROPERTY(int currentReceiveFileIndex READ currentReceiveFileIndex NOTIFY receiveProgressChanged)
Q_PROPERTY(int totalReceiveFiles READ totalReceiveFiles NOTIFY receivingChanged)
Q_PROPERTY(QString currentReceiveSenderAlias READ currentReceiveSenderAlias NOTIFY receivingChanged)
Q_PROPERTY(QVariantList receiveFileStates READ receiveFileStates NOTIFY receiveFileStatesChanged)
Q_PROPERTY(int receiveFinishedCount READ receiveFinishedCount NOTIFY receiveFileStatesChanged)
Q_PROPERTY(QVariantList sendFileStates READ sendFileStates NOTIFY sendFileStatesChanged)
Q_PROPERTY(int sendFinishedCount READ sendFinishedCountFiles NOTIFY sendFileStatesChanged)
Q_PROPERTY(double receiveThroughput READ receiveThroughput NOTIFY receiveProgressChanged)
Q_PROPERTY(qint64 receiveTotalSize READ receiveTotalSize NOTIFY receiveProgressChanged)
Q_PROPERTY(qint64 receivedSize READ receivedSizeBytes NOTIFY receiveProgressChanged)
Q_PROPERTY(double sendThroughput READ sendThroughput NOTIFY sendProgressChanged)
Q_PROPERTY(qint64 sendTotalBytes READ sendTotalBytes NOTIFY sendProgressChanged)
Q_PROPERTY(qint64 sendTransferredBytes READ sendTransferredBytes NOTIFY sendProgressChanged)
Q_PROPERTY(QString deviceType READ deviceType WRITE setDeviceType NOTIFY deviceTypeChanged)
Q_PROPERTY(bool https READ https WRITE setHttps NOTIFY httpsChanged)
@@ -77,6 +89,16 @@ public:
int currentReceiveFileIndex() const;
int totalReceiveFiles() const;
QString currentReceiveSenderAlias() const;
QVariantList receiveFileStates() const;
int receiveFinishedCount() const;
QVariantList sendFileStates() const;
int sendFinishedCountFiles() const;
double receiveThroughput() const;
qint64 receiveTotalSize() const;
qint64 receivedSizeBytes() const;
double sendThroughput() const;
qint64 sendTotalBytes() const;
qint64 sendTransferredBytes() const;
QString deviceType() const;
void setDeviceType(const QString& type);
@@ -111,6 +133,8 @@ signals:
void devicesChanged();
void serverRunningChanged();
void autoFinishChanged();
void sendFileStatesChanged();
void receiveFileStatesChanged();
void sendingChanged();
void sendProgressChanged();
void pendingFilesChanged();
@@ -173,6 +197,10 @@ private:
int m_currentFileIndex = 0;
double m_sendProgress = 0.0;
bool m_pinFirstAttempt = true;
QVariantList m_sendFileStates;
int m_sendFinishedCount = 0;
QDateTime m_sendStartedAt;
QElapsedTimer m_sendElapsed;
QString m_currentReceiveSessionId;
double m_receiveProgressValue = 0.0;
@@ -183,10 +211,15 @@ private:
qint64 m_receivedSize = 0;
QString m_currentReceiveSenderAlias;
QMap<QString, QString> m_receiveFileNames;
QVariantList m_receiveFileStates;
int m_receiveFinishedCount = 0;
QDateTime m_receiveStartedAt;
QElapsedTimer m_receiveElapsed;
LocalSend::InfoDto buildInfoDto() const;
QString generateUniqueFilePath(const QString& baseDir, const QString& fileName) const;
void sendNextFile();
void markSendFileSending(int index);
LocalSend::RegisterDto buildRegisterDto() const;
void resetSendState();
void resetReceiveState();
+240 -34
View File
@@ -15,6 +15,10 @@ ApplicationWindow {
property string currentSenderAlias: ""
property string currentSenderIp: ""
property string currentMessage: ""
property double displayReceiveThroughput: 0
property string displayReceiveEta: "-"
property double displaySendThroughput: 0
property string displaySendEta: "-"
function getDeviceTypeIcon(deviceType) {
switch (deviceType) {
@@ -175,42 +179,118 @@ ApplicationWindow {
modal: true
closePolicy: Popup.NoAutoClose
title: succeeded ? qsTr("Receive Complete") : qsTr("Receiving Files")
width: 520
implicitWidth: 520
property bool succeeded: false
ColumnLayout {
spacing: 12
width: parent.width
Label {
text: qsTr("From: %1").arg(appController.currentReceiveSenderAlias)
font.bold: true
}
Label {
visible: !receiveProgressDialog.succeeded
text: appController.currentReceiveFileName
? qsTr("%1 (%2/%3)").arg(appController.currentReceiveFileName)
.arg(appController.currentReceiveFileIndex)
.arg(appController.totalReceiveFiles)
: qsTr("Waiting for data...")
elide: Text.ElideMiddle
Layout.maximumWidth: 400
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: 100
value: receiveProgressDialog.succeeded ? 100 : appController.receiveProgress
indeterminate: !receiveProgressDialog.succeeded && appController.receiveProgress === 0
}
RowLayout {
Layout.fillWidth: true
Label {
text: receiveProgressDialog.succeeded
? qsTr("Files received successfully.")
: qsTr("%1% complete").arg(Math.round(appController.receiveProgress))
Layout.fillWidth: true
}
Label {
text: qsTr("%1 / %2 files")
.arg(appController.receiveFinishedCount)
.arg(appController.receiveFileStates.length)
color: palette.mid
}
}
RowLayout {
Layout.fillWidth: true
visible: !receiveProgressDialog.succeeded
Label {
text: qsTr("Speed: %1").arg(formatThroughput(displayReceiveThroughput))
color: palette.mid
}
Item { Layout.fillWidth: true }
Label {
text: qsTr("Remaining: %1").arg(displayReceiveEta)
color: palette.mid
}
}
Label {
text: receiveProgressDialog.succeeded
? qsTr("Files received successfully.")
: qsTr("%1% complete").arg(Math.round(appController.receiveProgress))
color: receiveProgressDialog.succeeded ? palette.mid : palette.mid
visible: !receiveProgressDialog.succeeded && appController.currentReceiveFileName.length > 0
text: qsTr("Current: %1").arg(appController.currentReceiveFileName)
color: palette.mid
elide: Text.ElideMiddle
Layout.fillWidth: true
font.pixelSize: 12
}
Rectangle {
visible: receiveProgressDialog.succeeded || appController.receiveFileStates.length > 0
Layout.fillWidth: true
Layout.preferredHeight: Math.min(260, appController.receiveFileStates.length * 26 + 8)
color: palette.base
border.color: palette.mid
radius: 6
ListView {
anchors.fill: parent
anchors.margins: 4
model: appController.receiveFileStates
spacing: 2
clip: true
delegate: RowLayout {
width: ListView.view.width
height: 22
spacing: 8
Label {
text: modelData.fileName
Layout.fillWidth: true
color: palette.text
elide: Text.ElideRight
font.pixelSize: 12
horizontalAlignment: Text.AlignLeft
}
Label {
text: formatSize(modelData.size)
Layout.preferredWidth: 80
color: palette.mid
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
}
Label {
text: modelData.status === "finished" ? qsTr("Done")
: modelData.status === "sending" ? qsTr("Active")
: qsTr("Waiting")
Layout.preferredWidth: 60
color: modelData.status === "finished" ? "green"
: modelData.status === "sending" ? palette.text
: palette.mid
font.pixelSize: 12
}
}
}
}
Label {
visible: receiveProgressDialog.succeeded
text: qsTr("Saved to: %1").arg(appController.downloadPath)
@@ -319,35 +399,108 @@ ApplicationWindow {
modal: true
closePolicy: Popup.NoAutoClose
title: succeeded ? qsTr("Send Complete") : qsTr("Sending Files")
width: 520
implicitWidth: 520
property bool succeeded: false
ColumnLayout {
spacing: 12
width: parent.width
Label {
visible: !sendProgressDialog.succeeded
text: appController.currentSendFileName
? qsTr("%1 (%2/%3)").arg(appController.currentSendFileName)
.arg(appController.currentSendFileIndex)
.arg(appController.totalSendFiles)
: qsTr("Preparing...")
visible: !sendProgressDialog.succeeded && appController.currentSendFileName.length > 0
text: qsTr("Current: %1").arg(appController.currentSendFileName)
elide: Text.ElideMiddle
Layout.maximumWidth: 400
Layout.fillWidth: true
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: 100
value: sendProgressDialog.succeeded ? 100 : appController.sendProgress
}
Label {
text: sendProgressDialog.succeeded
? qsTr("Files sent successfully.")
: qsTr("%1% complete").arg(Math.round(appController.sendProgress))
color: palette.mid
RowLayout {
Layout.fillWidth: true
Label {
text: sendProgressDialog.succeeded
? qsTr("Files sent successfully.")
: qsTr("%1% complete").arg(Math.round(appController.sendProgress))
Layout.fillWidth: true
}
Label {
text: qsTr("%1 / %2 files")
.arg(appController.sendFinishedCount)
.arg(appController.sendFileStates.length)
color: palette.mid
}
}
RowLayout {
Layout.fillWidth: true
visible: !sendProgressDialog.succeeded
Label {
text: qsTr("Speed: %1").arg(formatThroughput(displaySendThroughput))
color: palette.mid
}
Item { Layout.fillWidth: true }
Label {
text: qsTr("Remaining: %1").arg(displaySendEta)
color: palette.mid
}
}
Rectangle {
visible: sendProgressDialog.succeeded || appController.sendFileStates.length > 0
Layout.fillWidth: true
Layout.preferredHeight: Math.min(260, appController.sendFileStates.length * 26 + 8)
color: palette.base
border.color: palette.mid
radius: 6
ListView {
anchors.fill: parent
anchors.margins: 4
model: appController.sendFileStates
spacing: 2
clip: true
delegate: RowLayout {
width: ListView.view.width
height: 22
spacing: 8
Label {
text: modelData.fileName
Layout.fillWidth: true
color: palette.text
elide: Text.ElideRight
font.pixelSize: 12
horizontalAlignment: Text.AlignLeft
}
Label {
text: formatSize(modelData.size)
Layout.preferredWidth: 80
color: palette.mid
font.pixelSize: 12
horizontalAlignment: Text.AlignRight
}
Label {
text: modelData.status === "finished" ? qsTr("Done")
: modelData.status === "sending" ? qsTr("Active")
: qsTr("Waiting")
Layout.preferredWidth: 60
color: modelData.status === "finished" ? "green"
: modelData.status === "sending" ? palette.text
: palette.mid
font.pixelSize: 12
}
}
}
}
}
@@ -634,6 +787,59 @@ ApplicationWindow {
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)
}
function formatThroughput(bytesPerSec) {
if (bytesPerSec <= 0) return "-"
var sec = bytesPerSec
if (sec < 1024) return Math.round(sec) + " B/s"
if (sec < 1024 * 1024) return (sec / 1024).toFixed(1) + " KB/s"
return (sec / (1024 * 1024)).toFixed(1) + " MB/s"
}
function formatEta(remainingSec) {
if (!isFinite(remainingSec) || remainingSec <= 0) return "-"
var total = Math.ceil(remainingSec)
var h = Math.floor(total / 3600)
var m = Math.floor((total % 3600) / 60)
var s = total % 60
if (h > 0) return h + "h " + m + "m"
if (m > 0) return m + "m " + s + "s"
return s + "s"
}
Timer {
id: receiveRateTimer
interval: 1000
repeat: true
running: receiveProgressDialog.visible
onTriggered: {
displayReceiveThroughput = appController.receiveThroughput
var total = appController.totalReceiveSize
var received = appController.receivedSize
if (displayReceiveThroughput > 0 && total > received) {
displayReceiveEta = formatEta((total - received) / displayReceiveThroughput)
} else {
displayReceiveEta = "-"
}
}
}
Timer {
id: sendRateTimer
interval: 1000
repeat: true
running: sendProgressDialog.visible
onTriggered: {
displaySendThroughput = appController.sendThroughput
var total = appController.sendTotalBytes
var sent = appController.sendTransferredBytes
if (displaySendThroughput > 0 && total > sent) {
displaySendEta = formatEta((total - sent) / displaySendThroughput)
} else {
displaySendEta = "-"
}
}
}
Component {
id: homePageComponent
Page {
+4
View File
@@ -21,3 +21,7 @@ add_test(NAME TestSessionManager COMMAND TestSessionManager)
add_executable(TestDtoTypesExtended core/TestDtoTypesExtended.cpp)
target_link_libraries(TestDtoTypesExtended PRIVATE LocalSendCore Qt6::Test)
add_test(NAME TestDtoTypesExtended COMMAND TestDtoTypesExtended)
add_executable(TestHttpClientMtls core/TestHttpClientMtls.cpp)
target_link_libraries(TestHttpClientMtls PRIVATE LocalSendCore Qt6::Test)
add_test(NAME TestHttpClientMtls COMMAND TestHttpClientMtls)
+167
View File
@@ -0,0 +1,167 @@
#include <QtTest>
#include <QHttpServer>
#include <QHttpServerResponse>
#include <QJsonObject>
#include <QJsonDocument>
#include <QHostAddress>
#include <QSslServer>
#include <QSslConfiguration>
#include <QDir>
#include <LocalSendCore/HttpClient.h>
#include <LocalSendCore/SecurityContext.h>
#include <LocalSendCore/Device.h>
#include <LocalSendCore/Constants.h>
// The modern LocalSend protocol server (the Rust implementation) requires
// mutual TLS: it only accepts a sender that presents a valid client
// certificate during the TLS handshake. These tests spin up a QSslServer with
// mandatory client-certificate verification and assert that LocalSend's
// HttpClient can complete the handshake *only when* its SSL configuration
// carries the device certificate (regression test for
// "tlsv13 alert certificate required" when sending files).
class TestHttpClientMtls : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void testSslConfigurationCarriesDeviceIdentity();
void testGetInfoSucceedsWithClientCertificate();
void testGetInfoFailsWithoutClientCertificate();
private:
// Starts a QHttpServer over TLS that REQUIRES a client certificate.
// The trust store is seeded with our device certificate, so only a client
// presenting that certificate passes. Returns the listening port.
quint16 startMtlsServer(QHttpServer& http, QSslServer& ssl,
const LocalSend::SecurityContext& sec);
QString m_configDir;
LocalSend::SecurityContext* m_sec = nullptr;
};
void TestHttpClientMtls::initTestCase()
{
// Isolated storage in the (writable) build directory so the test never
// touches the real app certificate or depends on the user's HOME.
m_configDir = QDir::currentPath() + QStringLiteral("/.mtls-test-cfg");
QDir().mkpath(m_configDir);
qputenv("XDG_CONFIG_HOME", m_configDir.toUtf8());
m_sec = new LocalSend::SecurityContext(this);
m_sec->initialize();
}
void TestHttpClientMtls::cleanupTestCase()
{
delete m_sec;
m_sec = nullptr;
QDir(m_configDir).removeRecursively();
}
void TestHttpClientMtls::testSslConfigurationCarriesDeviceIdentity()
{
QVERIFY(m_sec);
QSslConfiguration config = m_sec->sslConfiguration();
QVERIFY(!config.isNull());
QVERIFY(!config.localCertificate().isNull());
QVERIFY(!m_sec->privateKey().isNull());
// The config doubles as the client identity: it must carry the device
// certificate that the handshake presents to the receiver.
QCOMPARE(config.localCertificate().digest(QCryptographicHash::Sha256),
m_sec->certificate().digest(QCryptographicHash::Sha256));
}
void TestHttpClientMtls::testGetInfoSucceedsWithClientCertificate()
{
QVERIFY(m_sec);
QVERIFY(!m_sec->sslConfiguration().isNull());
QHttpServer http;
QSslServer ssl;
quint16 port = startMtlsServer(http, ssl, *m_sec);
QVERIFY(port != 0);
LocalSend::HttpClient client;
// The app wires the same security context into the client (AppController).
client.setSslConfiguration(m_sec->sslConfiguration());
LocalSend::Device device(QStringLiteral("127.0.0.1"), port);
device.protocol = LocalSend::ProtocolType::Https;
bool gotInfo = false;
bool gotError = false;
QObject::connect(&client, &LocalSend::HttpClient::infoReceived,
[&](const LocalSend::InfoDto&) { gotInfo = true; });
QObject::connect(&client, &LocalSend::HttpClient::infoError,
[&](const QString&) { gotError = true; });
client.getInfo(device);
QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000);
QVERIFY(!gotError);
QVERIFY(gotInfo);
}
void TestHttpClientMtls::testGetInfoFailsWithoutClientCertificate()
{
QVERIFY(m_sec);
QHttpServer http;
QSslServer ssl;
quint16 port = startMtlsServer(http, ssl, *m_sec);
QVERIFY(port != 0);
// NO client SSL configuration: like the app before the fix, the client
// does not present a certificate and the handshake must be rejected.
LocalSend::HttpClient client;
LocalSend::Device device(QStringLiteral("127.0.0.1"), port);
device.protocol = LocalSend::ProtocolType::Https;
bool gotInfo = false;
bool gotError = false;
QObject::connect(&client, &LocalSend::HttpClient::infoReceived,
[&](const LocalSend::InfoDto&) { gotInfo = true; });
QObject::connect(&client, &LocalSend::HttpClient::infoError,
[&](const QString&) { gotError = true; });
client.getInfo(device);
QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000);
QVERIFY(!gotInfo);
QVERIFY(gotError);
}
quint16 TestHttpClientMtls::startMtlsServer(QHttpServer& http, QSslServer& ssl,
const LocalSend::SecurityContext& sec)
{
http.route(QString::fromLatin1(LocalSend::ApiRoute::INFO), QHttpServerRequest::Method::Get,
[](const QHttpServerRequest&) {
QJsonObject o;
o[QStringLiteral("alias")] = QStringLiteral("test-receiver");
o[QStringLiteral("version")] = QStringLiteral("1.0");
o[QStringLiteral("fingerprint")] = QStringLiteral("AABB");
return QHttpServerResponse(QJsonDocument(o).toJson(QJsonDocument::Compact),
QHttpServerResponse::StatusCode::Ok);
});
QSslConfiguration serverSsl;
serverSsl.setLocalCertificate(sec.certificate());
serverSsl.setPrivateKey(sec.privateKey());
serverSsl.setPeerVerifyMode(QSslSocket::VerifyPeer); // mandatory client cert
serverSsl.setCaCertificates({ sec.certificate() }); // trust our device cert
ssl.setSslConfiguration(serverSsl);
if (!ssl.listen(QHostAddress::LocalHost, 0)) {
qWarning() << "Failed to listen for MTLS test server";
return 0;
}
http.bind(&ssl);
return ssl.serverPort();
}
QTEST_MAIN(TestHttpClientMtls)
#include "TestHttpClientMtls.moc"