Compare commits

...

10 Commits

Author SHA1 Message Date
3a0529edf8 https option in settings page, and make settings page scrollable 2026-05-26 17:30:10 +08:00
d9751a82ff handle https and use it by default 2026-05-26 17:16:04 +08:00
5922ff4ca5 manual send (to ip) 2026-05-26 16:33:57 +08:00
6de6664aa0 set device type 2026-05-26 15:38:52 +08:00
acade5c35b cpack deb 2026-04-28 16:43:43 +08:00
537f0ddb1e more tests 2026-04-28 15:30:48 +08:00
b1a81cd90b send progress dialog 2026-04-28 15:21:30 +08:00
7c884a2185 pin support 2026-04-28 13:51:58 +08:00
2556c2db83 file queue 2026-04-28 13:51:58 +08:00
bfe271550e able to send file 2026-04-27 16:08:40 +08:00
25 changed files with 2621 additions and 136 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
build/
*.user
*.autosave
*.qmlc
*.jsc
CMakeLists.txt.user
cmake-build-*/
.idea/
.vscode/
.cache/

View File

@@ -22,6 +22,8 @@ if(WITH_HTTP_SERVER)
find_package(Qt6 6.8 COMPONENTS HttpServer) find_package(Qt6 6.8 COMPONENTS HttpServer)
endif() endif()
include(GNUInstallDirs)
add_subdirectory(src/core) add_subdirectory(src/core)
add_subdirectory(src/app) add_subdirectory(src/app)
@@ -30,3 +32,31 @@ if(BUILD_TESTS)
find_package(Qt6 REQUIRED COMPONENTS Test) find_package(Qt6 REQUIRED COMPONENTS Test)
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
install(DIRECTORY data/icons/
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor
)
install(FILES data/org.localsend.localsend-qt.desktop
DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/applications
)
set(CPACK_PACKAGE_NAME "localsend-qt")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Share files between devices on the local network")
set(CPACK_PACKAGE_DESCRIPTION "LocalSend Qt is a native Qt6 client for the LocalSend protocol, \
allowing fast and secure file sharing between devices on the same local network.")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://localsend.org")
set(CPACK_PACKAGE_CONTACT "LocalSend Qt Developers")
set(CPACK_PACKAGE_LICENSE "MIT")
set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}_${PROJECT_VERSION}_${CMAKE_SYSTEM_PROCESSOR}")
set(CPACK_GENERATOR "DEB")
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6core6 (>= 6.8), libqt6network6 (>= 6.8), libqt6quick6 (>= 6.8), libqt6quickcontrols2-6 (>= 6.8), libqt6httpserver6 (>= 6.8), libc6")
set(CPACK_DEBIAN_PACKAGE_SECTION "net")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_DESCRIPTION "${CPACK_PACKAGE_DESCRIPTION}")
set(CPACK_DEBIAN_COMPRESSION_TYPE "zstd")
include(CPack)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,12 @@
[Desktop Entry]
Type=Application
Name=LocalSend Qt
GenericName=File Transfer
Comment=Share files between devices on the local network
Exec=localsend-qt
Icon=localsend-qt
Terminal=false
Categories=Network;FileTransfer;Qt;
Keywords=transfer;share;files;local;network;
StartupNotify=true
StartupWMClass=LocalSend

View File

@@ -6,6 +6,7 @@
#include <QStandardPaths> #include <QStandardPaths>
#include <QUrl> #include <QUrl>
#include <QDebug> #include <QDebug>
#include <QMimeDatabase>
AppController::AppController(QObject* parent) AppController::AppController(QObject* parent)
: QObject(parent) : QObject(parent)
@@ -14,6 +15,7 @@ AppController::AppController(QObject* parent)
, m_discovery(new LocalSend::DiscoveryManager(this)) , m_discovery(new LocalSend::DiscoveryManager(this))
, m_server(new LocalSend::HttpServer(this)) , m_server(new LocalSend::HttpServer(this))
, m_sessions(new LocalSend::SessionManager(this)) , m_sessions(new LocalSend::SessionManager(this))
, m_httpClient(new LocalSend::HttpClient(this))
{ {
} }
@@ -25,6 +27,7 @@ AppController::~AppController()
void AppController::initialize() void AppController::initialize()
{ {
m_security->initialize(); m_security->initialize();
m_server->setSslConfiguration(m_security->sslConfiguration());
LocalSend::InfoDto info = buildInfoDto(); LocalSend::InfoDto info = buildInfoDto();
m_server->setLocalInfo(info, m_security->fingerprint()); m_server->setLocalInfo(info, m_security->fingerprint());
@@ -35,6 +38,8 @@ void AppController::initialize()
emit serverRunningChanged(); emit serverRunningChanged();
} }
m_server->setReceivePin(m_settings->receivePin());
connect(m_discovery, &LocalSend::DiscoveryManager::deviceDiscovered, connect(m_discovery, &LocalSend::DiscoveryManager::deviceDiscovered,
this, &AppController::onDeviceDiscovered); this, &AppController::onDeviceDiscovered);
connect(m_discovery, &LocalSend::DiscoveryManager::deviceLost, connect(m_discovery, &LocalSend::DiscoveryManager::deviceLost,
@@ -45,6 +50,8 @@ void AppController::initialize()
this, &AppController::onPrepareUploadRequest); this, &AppController::onPrepareUploadRequest);
connect(m_server, &LocalSend::HttpServer::uploadRequest, connect(m_server, &LocalSend::HttpServer::uploadRequest,
this, &AppController::onUploadRequest); this, &AppController::onUploadRequest);
connect(m_server, &LocalSend::HttpServer::cancelRequest,
this, &AppController::onCancelRequest);
connect(m_sessions, &LocalSend::SessionManager::receiveSessionAccepted, connect(m_sessions, &LocalSend::SessionManager::receiveSessionAccepted,
this, &AppController::onSessionAccepted); this, &AppController::onSessionAccepted);
connect(m_sessions, &LocalSend::SessionManager::receiveSessionDeclined, connect(m_sessions, &LocalSend::SessionManager::receiveSessionDeclined,
@@ -54,6 +61,25 @@ void AppController::initialize()
connect(m_sessions, &LocalSend::SessionManager::receiveSessionCompleted, connect(m_sessions, &LocalSend::SessionManager::receiveSessionCompleted,
this, &AppController::onReceiveCompleted); this, &AppController::onReceiveCompleted);
connect(m_httpClient, &LocalSend::HttpClient::prepareUploadResponse,
this, &AppController::onPrepareUploadResponse);
connect(m_httpClient, &LocalSend::HttpClient::prepareUploadError,
this, &AppController::onPrepareUploadError);
connect(m_httpClient, &LocalSend::HttpClient::prepareUploadPinRequired,
this, &AppController::onPrepareUploadPinRequired);
connect(m_httpClient, &LocalSend::HttpClient::prepareUploadTooManyAttempts,
this, &AppController::onPrepareUploadTooManyAttempts);
connect(m_httpClient, &LocalSend::HttpClient::uploadProgress,
this, &AppController::onUploadProgress);
connect(m_httpClient, &LocalSend::HttpClient::uploadCompleted,
this, &AppController::onUploadCompleted);
connect(m_httpClient, &LocalSend::HttpClient::uploadError,
this, &AppController::onUploadError);
connect(m_httpClient, &LocalSend::HttpClient::registerCompleted,
this, &AppController::onManualRegisterCompleted);
connect(m_httpClient, &LocalSend::HttpClient::registerError,
this, &AppController::onManualRegisterError);
startDiscovery(); startDiscovery();
} }
@@ -120,6 +146,43 @@ void AppController::setQuickSave(bool enabled)
} }
} }
QString AppController::deviceType() const
{
return LocalSend::deviceTypeToString(m_settings->deviceType());
}
void AppController::setDeviceType(const QString& type)
{
LocalSend::DeviceType newType = LocalSend::deviceTypeFromString(type);
if (m_settings->deviceType() != newType) {
m_settings->setDeviceType(newType);
emit deviceTypeChanged();
LocalSend::InfoDto info = buildInfoDto();
m_server->setLocalInfo(info, m_security->fingerprint());
m_discovery->setLocalInfo(info, m_security->fingerprint(), m_settings->port(),
m_settings->https() ? LocalSend::ProtocolType::Https : LocalSend::ProtocolType::Http);
}
}
bool AppController::https() const
{
return m_settings->https();
}
void AppController::setHttps(bool enabled)
{
if (m_settings->https() != enabled) {
m_settings->setHttps(enabled);
emit httpsChanged();
LocalSend::InfoDto info = buildInfoDto();
m_server->setLocalInfo(info, m_security->fingerprint());
m_discovery->setLocalInfo(info, m_security->fingerprint(), m_settings->port(),
enabled ? LocalSend::ProtocolType::Https : LocalSend::ProtocolType::Http);
}
}
QVariantList AppController::devices() const QVariantList AppController::devices() const
{ {
QVariantList result; QVariantList result;
@@ -166,6 +229,22 @@ void AppController::acceptReceive(const QString& sessionId)
return; return;
} }
m_currentReceiveSessionId = sessionId;
m_receiveProgressValue = 0.0;
m_receivedSize = 0;
m_currentReceiveFileIndex = 0;
m_currentReceiveFileName.clear();
m_totalReceiveFiles = session.files.size();
m_currentReceiveSenderAlias = session.sender.alias;
m_totalReceiveSize = 0;
m_receiveFileNames.clear();
for (auto it = session.files.constBegin(); it != session.files.constEnd(); ++it) {
m_totalReceiveSize += it->file.size;
m_receiveFileNames.insert(it.key(), it->file.fileName);
}
emit receivingChanged();
emit receiveProgressChanged();
QString baseDir = downloadPath(); QString baseDir = downloadPath();
QDir dir(baseDir); QDir dir(baseDir);
if (!dir.exists()) { if (!dir.exists()) {
@@ -179,6 +258,7 @@ void AppController::acceptReceive(const QString& sessionId)
qDebug() << "[AppController] File" << it.key() << "->" << filePath; qDebug() << "[AppController] File" << it.key() << "->" << filePath;
} }
m_server->addActiveSession(sessionId);
m_sessions->acceptReceiveSession(sessionId, destinationPaths); m_sessions->acceptReceiveSession(sessionId, destinationPaths);
} }
@@ -255,6 +335,8 @@ void AppController::onPrepareUploadRequest(const QString& httpSessionId,
LocalSend::Device device; LocalSend::Device device;
device.ip = sender.toString(); device.ip = sender.toString();
device.port = dto.info.port;
device.protocol = dto.info.protocol;
device.alias = dto.info.alias; device.alias = dto.info.alias;
device.fingerprint = dto.info.fingerprint; device.fingerprint = dto.info.fingerprint;
device.deviceModel = dto.info.deviceModel; device.deviceModel = dto.info.deviceModel;
@@ -318,6 +400,16 @@ void AppController::onUploadRequest(const QString& sessionId, const QString& fil
m_sessions->updateReceiveProgress(sessionId, fileId, written); 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();
}
if (written >= transfer.file.size) { if (written >= transfer.file.size) {
m_sessions->completeReceiveFile(sessionId, fileId); m_sessions->completeReceiveFile(sessionId, fileId);
} }
@@ -343,5 +435,568 @@ void AppController::onReceiveProgress(const QString& sessionId, const QString& f
void AppController::onReceiveCompleted(const QString& sessionId) void AppController::onReceiveCompleted(const QString& sessionId)
{ {
if (sessionId == m_currentReceiveSessionId) {
m_receiveProgressValue = 100.0;
m_currentReceiveFileIndex = m_totalReceiveFiles;
emit receiveProgressChanged();
}
emit receiveCompleted(sessionId); emit receiveCompleted(sessionId);
if (sessionId == m_currentReceiveSessionId) {
m_server->removeActiveSession(sessionId);
resetReceiveState();
}
}
bool AppController::sending() const
{
return !m_currentSendSessionId.isEmpty();
}
double AppController::sendProgress() const
{
return m_sendProgress;
}
QVariantList AppController::pendingFiles() const
{
return m_pendingFilesList;
}
bool AppController::hasPendingFiles() const
{
return !m_pendingFilesList.isEmpty();
}
QString AppController::receivePin() const
{
return m_settings->receivePin();
}
void AppController::setReceivePin(const QString& pin)
{
if (m_settings->receivePin() != pin) {
m_settings->setReceivePin(pin);
m_server->setReceivePin(pin);
emit receivePinChanged();
}
}
bool AppController::receiving() const
{
return !m_currentReceiveSessionId.isEmpty();
}
double AppController::receiveProgress() const
{
return m_receiveProgressValue;
}
QString AppController::currentReceiveFileName() const
{
return m_currentReceiveFileName;
}
int AppController::currentReceiveFileIndex() const
{
return m_currentReceiveFileIndex;
}
int AppController::totalReceiveFiles() const
{
return m_totalReceiveFiles;
}
QString AppController::currentReceiveSenderAlias() const
{
return m_currentReceiveSenderAlias;
}
QString AppController::currentSendFileName() const
{
if (m_currentFileIndex >= 0 && m_currentFileIndex < m_pendingSendPaths.size()) {
QFileInfo info(m_pendingSendPaths[m_currentFileIndex]);
return info.fileName();
}
return QString();
}
int AppController::currentSendFileIndex() const
{
return m_currentFileIndex + 1;
}
int AppController::totalSendFiles() const
{
return m_pendingSendPaths.size();
}
void AppController::addFiles(const QStringList& filePaths)
{
QMimeDatabase mimeDb;
for (const QString& rawPath : filePaths) {
QString filePath = rawPath;
if (filePath.startsWith(QStringLiteral("file://"))) {
filePath = filePath.mid(7);
}
QFileInfo info(filePath);
if (!info.exists() || info.isDir()) {
continue;
}
QVariantMap file;
file[QStringLiteral("path")] = info.absoluteFilePath();
file[QStringLiteral("fileName")] = info.fileName();
file[QStringLiteral("size")] = info.size();
file[QStringLiteral("fileType")] = mimeDb.mimeTypeForFile(info).name();
m_pendingFilesList.append(file);
m_pendingSendPaths.append(info.absoluteFilePath());
}
emit pendingFilesChanged();
}
void AppController::removePendingFile(int index)
{
if (index < 0 || index >= m_pendingFilesList.size()) {
return;
}
m_pendingFilesList.removeAt(index);
m_pendingSendPaths.removeAt(index);
emit pendingFilesChanged();
}
void AppController::clearPendingFiles()
{
m_pendingFilesList.clear();
m_pendingSendPaths.clear();
emit pendingFilesChanged();
}
void AppController::sendTo(const QString& deviceFingerprint)
{
if (m_pendingSendPaths.isEmpty()) {
emit sendError(QStringLiteral("No files selected"));
return;
}
sendFiles(deviceFingerprint, m_pendingSendPaths);
}
void AppController::sendToAddress(const QString& address)
{
if (m_pendingSendPaths.isEmpty()) {
emit sendError(QStringLiteral("No files selected"));
return;
}
if (sending()) {
emit sendError(QStringLiteral("Already sending files"));
return;
}
QString ip = address;
quint16 port = m_settings->port();
if (address.contains(':')) {
int colonPos = address.lastIndexOf(':');
ip = address.left(colonPos);
bool ok = false;
int parsedPort = address.mid(colonPos + 1).toInt(&ok);
if (ok && parsedPort > 0 && parsedPort <= 65535) {
port = static_cast<quint16>(parsedPort);
}
}
QHostAddress hostAddr(ip);
if (hostAddr.isNull()) {
emit sendError(QStringLiteral("Invalid IP address: %1").arg(ip));
return;
}
qDebug() << "[AppController] sendToAddress:" << ip << "port:" << port;
m_manualSendDevice = LocalSend::Device(ip, port);
m_manualSendDevice.discoveryMethod = LocalSend::DiscoveryMethod::HttpTarget;
m_manualSendPending = true;
m_httpClient->registerDevice(m_manualSendDevice, buildRegisterDto());
}
void AppController::sendFiles(const QString& deviceFingerprint, const QStringList& filePaths)
{
if (!m_devices.contains(deviceFingerprint)) {
emit sendError(QStringLiteral("Device not found"));
return;
}
if (filePaths.isEmpty()) {
emit sendError(QStringLiteral("No files selected"));
return;
}
if (sending()) {
emit sendError(QStringLiteral("Already sending files"));
return;
}
m_currentSendDeviceFingerprint = deviceFingerprint;
m_pendingSendPaths = filePaths;
m_currentFileIndex = 0;
m_sendProgress = 0.0;
m_pinFirstAttempt = true;
qDebug() << "[AppController] sendFiles: device=" << deviceFingerprint
<< "files=" << filePaths.size();
LocalSend::Device target = m_devices[deviceFingerprint];
QMap<QString, LocalSend::FileDto> files;
QMimeDatabase mimeDb;
for (int i = 0; i < filePaths.size(); ++i) {
QString filePath = filePaths[i];
QFileInfo info(filePath);
if (!info.exists()) {
emit sendError(QStringLiteral("File not found: ") + filePath);
resetSendState();
return;
}
LocalSend::FileDto fileDto;
fileDto.id = QString::number(i);
fileDto.fileName = info.fileName();
fileDto.size = info.size();
fileDto.fileType = mimeDb.mimeTypeForFile(filePath).name();
files.insert(fileDto.id, fileDto);
}
m_currentSendSessionId = m_sessions->createSendSession(target, files,
[filePaths]() {
QMap<QString, QString> paths;
for (int i = 0; i < filePaths.size(); ++i) {
paths.insert(QString::number(i), filePaths[i]);
}
return paths;
}()
);
emit sendingChanged();
emit sendProgressChanged();
LocalSend::PrepareUploadRequestDto request;
request.info = buildRegisterDto();
request.files = files;
qDebug() << "[AppController] Sending prepare-upload request to" << target.ip;
m_httpClient->prepareUpload(target, request);
}
void AppController::cancelSend()
{
if (m_currentSendSessionId.isEmpty()) {
return;
}
LocalSend::Device target = m_devices.value(m_currentSendDeviceFingerprint);
m_httpClient->abortCurrentUpload();
m_httpClient->cancel(target, m_currentSendSessionId);
m_sessions->cancelSendSession(m_currentSendSessionId);
resetSendState();
m_pendingSendPaths.clear();
m_pendingFilesList.clear();
emit pendingFilesChanged();
}
void AppController::cancelReceive()
{
if (m_currentReceiveSessionId.isEmpty()) {
return;
}
qDebug() << "[AppController] cancelReceive, sessionId:" << m_currentReceiveSessionId;
LocalSend::ReceiveSession session = m_sessions->receiveSession(m_currentReceiveSessionId);
LocalSend::Device senderDevice = session.sender;
m_sessions->cancelReceiveSession(m_currentReceiveSessionId);
m_server->removeActiveSession(m_currentReceiveSessionId);
if (!senderDevice.ip.isEmpty() && senderDevice.port > 0) {
m_httpClient->cancel(senderDevice, m_currentReceiveSessionId);
}
resetReceiveState();
}
void AppController::onPrepareUploadResponse(const LocalSend::PrepareUploadResponseDto& response)
{
qDebug() << "[AppController] onPrepareUploadResponse: sessionId=" << response.sessionId
<< "files=" << response.files.size();
LocalSend::SendSession session = m_sessions->sendSession(m_currentSendSessionId);
if (session.sessionId.isEmpty()) {
emit sendError(QStringLiteral("Session not found"));
resetSendState();
return;
}
if (response.files.isEmpty()) {
emit sendError(QStringLiteral("Receiver declined the transfer"));
resetSendState();
return;
}
m_sessions->setSendSessionTokens(m_currentSendSessionId, response.sessionId, response.files);
m_currentSendSessionId = response.sessionId;
m_sessions->startSendSession(m_currentSendSessionId);
m_currentFileIndex = 0;
sendNextFile();
}
void AppController::onPrepareUploadError(const QString& error)
{
qWarning() << "[AppController] onPrepareUploadError:" << error;
resetSendState();
emit sendError(error);
}
void AppController::onPrepareUploadPinRequired()
{
qDebug() << "[AppController] onPrepareUploadPinRequired, firstAttempt:" << m_pinFirstAttempt;
emit pinRequired(m_pinFirstAttempt);
m_pinFirstAttempt = false;
}
void AppController::onPrepareUploadTooManyAttempts()
{
qWarning() << "[AppController] onPrepareUploadTooManyAttempts";
resetSendState();
emit sendError(QStringLiteral("Too many PIN attempts"));
}
void AppController::retryWithPin(const QString& pin)
{
if (m_currentSendDeviceFingerprint.isEmpty() || !m_devices.contains(m_currentSendDeviceFingerprint)) {
resetSendState();
return;
}
LocalSend::Device target = m_devices[m_currentSendDeviceFingerprint];
QMap<QString, LocalSend::FileDto> files;
QMimeDatabase mimeDb;
for (int i = 0; i < m_pendingSendPaths.size(); ++i) {
QFileInfo info(m_pendingSendPaths[i]);
if (!info.exists()) {
continue;
}
LocalSend::FileDto fileDto;
fileDto.id = QString::number(i);
fileDto.fileName = info.fileName();
fileDto.size = info.size();
fileDto.fileType = mimeDb.mimeTypeForFile(info).name();
files.insert(fileDto.id, fileDto);
}
LocalSend::PrepareUploadRequestDto request;
request.info = buildRegisterDto();
request.files = files;
qDebug() << "[AppController] Retrying prepare-upload with PIN to" << target.ip;
m_httpClient->prepareUpload(target, request, pin);
}
void AppController::onUploadProgress(qint64 sent, qint64 total)
{
if (total > 0) {
double fileProgress = static_cast<double>(sent) / total;
int totalFiles = m_pendingSendPaths.size();
double overallProgress = (m_currentFileIndex + fileProgress) / totalFiles;
m_sendProgress = overallProgress * 100.0;
emit sendProgressChanged();
}
}
void AppController::onUploadCompleted()
{
qDebug() << "[AppController] onUploadCompleted, file index:" << m_currentFileIndex;
if (m_currentSendSessionId.isEmpty()) {
return;
}
m_sessions->completeSendFile(m_currentSendSessionId, m_currentSendFileId);
m_currentFileIndex++;
if (m_currentFileIndex < m_pendingSendPaths.size()) {
sendNextFile();
} else {
qDebug() << "[AppController] All files sent successfully";
m_sendProgress = 100.0;
emit sendProgressChanged();
QString sessionId = m_currentSendSessionId;
resetSendState();
m_pendingSendPaths.clear();
m_pendingFilesList.clear();
emit pendingFilesChanged();
emit sendCompleted(sessionId);
}
}
void AppController::onUploadError(const QString& error)
{
if (m_currentSendSessionId.isEmpty()) {
return;
}
qWarning() << "[AppController] onUploadError:" << error;
if (error == QStringLiteral("SESSION_CANCELLED")) {
m_sessions->cancelSendSession(m_currentSendSessionId);
resetSendState();
emit sendCanceled();
} else {
m_sessions->failSendFile(m_currentSendSessionId, m_currentSendFileId);
resetSendState();
emit sendError(error);
}
}
void AppController::sendNextFile()
{
if (m_currentFileIndex >= m_pendingSendPaths.size()) {
return;
}
LocalSend::SendSession session = m_sessions->sendSession(m_currentSendSessionId);
if (session.sessionId.isEmpty()) {
qWarning() << "[AppController] Session not found:" << m_currentSendSessionId;
return;
}
QString fileId = QString::number(m_currentFileIndex);
if (!session.files.contains(fileId)) {
qWarning() << "[AppController] File not found in session:" << fileId;
return;
}
m_currentSendFileId = fileId;
QString filePath = m_pendingSendPaths[m_currentFileIndex];
QString token = session.files[fileId].token;
LocalSend::Device target = m_devices.value(m_currentSendDeviceFingerprint);
qDebug() << "[AppController] Uploading file" << fileId << ":" << filePath
<< "token:" << token << "to" << target.ip;
if (token.isEmpty()) {
emit sendError(QStringLiteral("No token for file: ") + fileId);
resetSendState();
return;
}
m_httpClient->uploadFile(target, m_currentSendSessionId, fileId, token, filePath);
}
LocalSend::RegisterDto AppController::buildRegisterDto() const
{
LocalSend::RegisterDto dto;
dto.alias = m_settings->alias();
dto.version = m_settings->version();
dto.deviceModel = m_settings->deviceModel();
dto.deviceType = m_settings->deviceType();
dto.fingerprint = m_security->fingerprint();
dto.port = m_settings->port();
dto.protocol = m_settings->https() ? LocalSend::ProtocolType::Https : LocalSend::ProtocolType::Http;
dto.download = false;
return dto;
}
void AppController::onCancelRequest(const QString& sessionId)
{
qDebug() << "[AppController] onCancelRequest, sessionId:" << sessionId;
if (sessionId == m_currentSendSessionId) {
m_httpClient->abortCurrentUpload();
m_sessions->cancelSendSession(m_currentSendSessionId);
resetSendState();
emit sendCanceled();
return;
}
if (sessionId == m_currentReceiveSessionId) {
m_sessions->cancelReceiveSession(m_currentReceiveSessionId);
m_server->removeActiveSession(m_currentReceiveSessionId);
resetReceiveState();
emit receiveError(sessionId, QStringLiteral("Sender canceled the transfer"));
return;
}
}
void AppController::resetSendState()
{
m_currentSendSessionId.clear();
m_currentSendFileId.clear();
m_currentSendDeviceFingerprint.clear();
m_currentFileIndex = 0;
m_sendProgress = 0.0;
emit sendingChanged();
emit sendProgressChanged();
}
void AppController::resetReceiveState()
{
m_currentReceiveSessionId.clear();
m_receiveProgressValue = 0.0;
m_currentReceiveFileName.clear();
m_currentReceiveFileIndex = 0;
m_totalReceiveFiles = 0;
m_totalReceiveSize = 0;
m_receivedSize = 0;
m_currentReceiveSenderAlias.clear();
m_receiveFileNames.clear();
emit receivingChanged();
emit receiveProgressChanged();
}
void AppController::onManualRegisterCompleted(const LocalSend::InfoDto& peerInfo)
{
if (!m_manualSendPending) return;
m_manualSendPending = false;
m_manualSendDevice.alias = peerInfo.alias;
m_manualSendDevice.deviceModel = peerInfo.deviceModel;
m_manualSendDevice.deviceType = peerInfo.deviceType;
m_manualSendDevice.version = peerInfo.version;
m_manualSendDevice.fingerprint = peerInfo.fingerprint;
qDebug() << "[AppController] Manual register completed:" << m_manualSendDevice.alias
<< "fingerprint:" << m_manualSendDevice.fingerprint;
if (!m_devices.contains(m_manualSendDevice.fingerprint)) {
m_devices.insert(m_manualSendDevice.fingerprint, m_manualSendDevice);
emit devicesChanged();
}
sendFiles(m_manualSendDevice.fingerprint, m_pendingSendPaths);
}
void AppController::onManualRegisterError(const QString& error)
{
if (!m_manualSendPending) return;
if (m_manualSendDevice.protocol == LocalSend::ProtocolType::Http) {
qWarning() << "[AppController] Manual register failed with HTTP, retrying with HTTPS:" << error;
m_manualSendDevice.protocol = LocalSend::ProtocolType::Https;
m_httpClient->registerDevice(m_manualSendDevice, buildRegisterDto());
return;
}
m_manualSendPending = false;
qWarning() << "[AppController] Manual register error:" << error;
emit sendError(QStringLiteral("Cannot reach device at %1: %2").arg(m_manualSendDevice.ip).arg(error));
} }

View File

@@ -19,6 +19,22 @@ class AppController : public QObject
Q_PROPERTY(bool serverRunning READ serverRunning NOTIFY serverRunningChanged) Q_PROPERTY(bool serverRunning READ serverRunning NOTIFY serverRunningChanged)
Q_PROPERTY(QString downloadPath READ downloadPath WRITE setDownloadPath NOTIFY downloadPathChanged) Q_PROPERTY(QString downloadPath READ downloadPath WRITE setDownloadPath NOTIFY downloadPathChanged)
Q_PROPERTY(bool quickSave READ quickSave WRITE setQuickSave NOTIFY quickSaveChanged) Q_PROPERTY(bool quickSave READ quickSave WRITE setQuickSave NOTIFY quickSaveChanged)
Q_PROPERTY(bool sending READ sending NOTIFY sendingChanged)
Q_PROPERTY(double sendProgress READ sendProgress NOTIFY sendProgressChanged)
Q_PROPERTY(QString currentSendFileName READ currentSendFileName NOTIFY sendProgressChanged)
Q_PROPERTY(int currentSendFileIndex READ currentSendFileIndex NOTIFY sendProgressChanged)
Q_PROPERTY(int totalSendFiles READ totalSendFiles NOTIFY sendingChanged)
Q_PROPERTY(QVariantList pendingFiles READ pendingFiles NOTIFY pendingFilesChanged)
Q_PROPERTY(bool hasPendingFiles READ hasPendingFiles NOTIFY pendingFilesChanged)
Q_PROPERTY(QString receivePin READ receivePin WRITE setReceivePin NOTIFY receivePinChanged)
Q_PROPERTY(bool receiving READ receiving NOTIFY receivingChanged)
Q_PROPERTY(double receiveProgress READ receiveProgress NOTIFY receiveProgressChanged)
Q_PROPERTY(QString currentReceiveFileName READ currentReceiveFileName NOTIFY receiveProgressChanged)
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(QString deviceType READ deviceType WRITE setDeviceType NOTIFY deviceTypeChanged)
Q_PROPERTY(bool https READ https WRITE setHttps NOTIFY httpsChanged)
public: public:
explicit AppController(QObject* parent = nullptr); explicit AppController(QObject* parent = nullptr);
@@ -41,12 +57,45 @@ public:
QVariantList devices() const; QVariantList devices() const;
bool serverRunning() const; bool serverRunning() const;
bool sending() const;
double sendProgress() const;
QString currentSendFileName() const;
int currentSendFileIndex() const;
int totalSendFiles() const;
QVariantList pendingFiles() const;
bool hasPendingFiles() const;
QString receivePin() const;
void setReceivePin(const QString& pin);
bool receiving() const;
double receiveProgress() const;
QString currentReceiveFileName() const;
int currentReceiveFileIndex() const;
int totalReceiveFiles() const;
QString currentReceiveSenderAlias() const;
QString deviceType() const;
void setDeviceType(const QString& type);
bool https() const;
void setHttps(bool enabled);
Q_INVOKABLE void startDiscovery(); Q_INVOKABLE void startDiscovery();
Q_INVOKABLE void stopDiscovery(); Q_INVOKABLE void stopDiscovery();
Q_INVOKABLE void refreshDevices(); Q_INVOKABLE void refreshDevices();
Q_INVOKABLE void acceptReceive(const QString& sessionId); Q_INVOKABLE void acceptReceive(const QString& sessionId);
Q_INVOKABLE void declineReceive(const QString& sessionId); Q_INVOKABLE void declineReceive(const QString& sessionId);
Q_INVOKABLE void cancelReceive();
Q_INVOKABLE void sendFiles(const QString& deviceFingerprint, const QStringList& filePaths);
Q_INVOKABLE void sendTo(const QString& deviceFingerprint);
Q_INVOKABLE void sendToAddress(const QString& address);
Q_INVOKABLE void cancelSend();
Q_INVOKABLE void addFiles(const QStringList& filePaths);
Q_INVOKABLE void removePendingFile(int index);
Q_INVOKABLE void clearPendingFiles();
Q_INVOKABLE void retryWithPin(const QString& pin);
signals: signals:
void aliasChanged(); void aliasChanged();
@@ -55,13 +104,23 @@ signals:
void quickSaveChanged(); void quickSaveChanged();
void devicesChanged(); void devicesChanged();
void serverRunningChanged(); void serverRunningChanged();
void sendingChanged();
void sendProgressChanged();
void pendingFilesChanged();
void receiveRequest(const QString& sessionId, const QString& senderAlias, void receiveRequest(const QString& sessionId, const QString& senderAlias,
const QString& senderIp, const QVariantList& files); const QString& senderIp, const QVariantList& files);
void receiveProgress(const QString& sessionId, const QString& fileId, double progress); void receiveProgress(const QString& sessionId, const QString& fileId, double progress);
void receiveCompleted(const QString& sessionId); void receiveCompleted(const QString& sessionId);
void receiveError(const QString& sessionId, const QString& error); void receiveError(const QString& sessionId, const QString& error);
void sendProgress(const QString& sessionId, double progress);
void sendCompleted(const QString& sessionId); void sendCompleted(const QString& sessionId);
void sendError(const QString& error);
void sendCanceled();
void pinRequired(bool firstAttempt);
void receivePinChanged();
void receivingChanged();
void receiveProgressChanged();
void deviceTypeChanged();
void httpsChanged();
private slots: private slots:
void onDeviceDiscovered(const LocalSend::Device& device); void onDeviceDiscovered(const LocalSend::Device& device);
@@ -77,15 +136,54 @@ private slots:
void onReceiveProgress(const QString& sessionId, const QString& fileId, double progress); void onReceiveProgress(const QString& sessionId, const QString& fileId, double progress);
void onReceiveCompleted(const QString& sessionId); void onReceiveCompleted(const QString& sessionId);
void onPrepareUploadResponse(const LocalSend::PrepareUploadResponseDto& response);
void onPrepareUploadError(const QString& error);
void onPrepareUploadPinRequired();
void onPrepareUploadTooManyAttempts();
void onUploadProgress(qint64 sent, qint64 total);
void onUploadCompleted();
void onUploadError(const QString& error);
void onManualRegisterCompleted(const LocalSend::InfoDto& peerInfo);
void onManualRegisterError(const QString& error);
void onCancelRequest(const QString& sessionId);
private: private:
LocalSend::Settings* m_settings = nullptr; LocalSend::Settings* m_settings = nullptr;
LocalSend::SecurityContext* m_security = nullptr; LocalSend::SecurityContext* m_security = nullptr;
LocalSend::DiscoveryManager* m_discovery = nullptr; LocalSend::DiscoveryManager* m_discovery = nullptr;
LocalSend::HttpServer* m_server = nullptr; LocalSend::HttpServer* m_server = nullptr;
LocalSend::SessionManager* m_sessions = nullptr; LocalSend::SessionManager* m_sessions = nullptr;
LocalSend::HttpClient* m_httpClient = nullptr;
QMap<QString, LocalSend::Device> m_devices; QMap<QString, LocalSend::Device> m_devices;
QString m_currentSendSessionId;
QString m_currentSendFileId;
QString m_currentSendDeviceFingerprint;
QStringList m_pendingSendPaths;
QVariantList m_pendingFilesList;
int m_currentFileIndex = 0;
double m_sendProgress = 0.0;
bool m_pinFirstAttempt = true;
QString m_currentReceiveSessionId;
double m_receiveProgressValue = 0.0;
QString m_currentReceiveFileName;
int m_currentReceiveFileIndex = 0;
int m_totalReceiveFiles = 0;
qint64 m_totalReceiveSize = 0;
qint64 m_receivedSize = 0;
QString m_currentReceiveSenderAlias;
QMap<QString, QString> m_receiveFileNames;
LocalSend::InfoDto buildInfoDto() const; LocalSend::InfoDto buildInfoDto() const;
QString generateUniqueFilePath(const QString& baseDir, const QString& fileName) const; QString generateUniqueFilePath(const QString& baseDir, const QString& fileName) const;
void sendNextFile();
LocalSend::RegisterDto buildRegisterDto() const;
void resetSendState();
void resetReceiveState();
bool m_manualSendPending = false;
LocalSend::Device m_manualSendDevice;
}; };

View File

@@ -4,6 +4,10 @@ qt_add_executable(LocalSendQt
AppController.cpp AppController.cpp
) )
set_target_properties(LocalSendQt PROPERTIES
OUTPUT_NAME localsend-qt
)
qt_add_qml_module(LocalSendQt qt_add_qml_module(LocalSendQt
URI LocalSend URI LocalSend
VERSION 1.0 VERSION 1.0
@@ -18,6 +22,7 @@ target_link_libraries(LocalSendQt PRIVATE
) )
install(TARGETS LocalSendQt install(TARGETS LocalSendQt
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
BUNDLE DESTINATION . BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
) )

View File

@@ -14,7 +14,74 @@ ApplicationWindow {
property var currentFiles: [] property var currentFiles: []
property string currentSenderAlias: "" property string currentSenderAlias: ""
property string currentSenderIp: "" property string currentSenderIp: ""
property var receiveProgress: ({})
function getDeviceTypeIcon(deviceType) {
switch (deviceType) {
case "mobile": return "\u{1F4F1}"
case "desktop": return "\u{1F4BB}"
case "web": return "\u{1F310}"
case "headless": return "\u{1F5A5}"
case "server": return "\u{1F4E1}"
default: return "\u{1F4BB}"
}
}
DropArea {
id: dropArea
anchors.fill: parent
z: 9999
onEntered: function(drag) {
if (drag.hasUrls) {
drag.accepted = true
dropOverlay.visible = true
} else {
drag.accepted = false
}
}
onDropped: function(drop) {
dropOverlay.visible = false
if (drop.hasUrls) {
var paths = []
for (var i = 0; i < drop.urls.length; i++) {
paths.push(drop.urls[i].toString())
}
if (paths.length > 0) {
appController.addFiles(paths)
}
}
}
onExited: {
dropOverlay.visible = false
}
Rectangle {
id: dropOverlay
visible: false
anchors.fill: parent
color: "#200080FF"
z: 10000
Label {
anchors.centerIn: parent
text: qsTr("Drop files here to add them")
font.pixelSize: 24
font.bold: true
color: "#0080FF"
}
Rectangle {
anchors.fill: parent
anchors.margins: 8
color: "transparent"
radius: 12
border.color: "#0080FF"
border.width: 3
}
}
}
StackView { StackView {
id: stackView id: stackView
@@ -25,6 +92,21 @@ ApplicationWindow {
} }
} }
FileDialog {
id: fileDialog
title: qsTr("Select Files")
fileMode: FileDialog.OpenFiles
onAccepted: {
var paths = []
for (var i = 0; i < selectedFiles.length; i++) {
paths.push(selectedFiles[i].toString())
}
if (paths.length > 0) {
appController.addFiles(paths)
}
}
}
Dialog { Dialog {
id: receiveDialog id: receiveDialog
anchors.centerIn: parent anchors.centerIn: parent
@@ -35,7 +117,7 @@ ApplicationWindow {
onAccepted: { onAccepted: {
appController.acceptReceive(currentSessionId) appController.acceptReceive(currentSessionId)
currentSessionId = "" receiveProgressDialog.open()
} }
onRejected: { onRejected: {
@@ -73,7 +155,7 @@ ApplicationWindow {
} }
Label { Label {
text: formatSize(modelData.size) text: formatSize(modelData.size)
color: "gray" color: palette.mid
} }
} }
} }
@@ -87,7 +169,7 @@ ApplicationWindow {
} }
Dialog { Dialog {
id: progressDialog id: receiveProgressDialog
anchors.centerIn: parent anchors.centerIn: parent
modal: true modal: true
closePolicy: Popup.NoAutoClose closePolicy: Popup.NoAutoClose
@@ -97,25 +179,88 @@ ApplicationWindow {
spacing: 12 spacing: 12
Label { Label {
id: progressLabel text: qsTr("From: %1").arg(appController.currentReceiveSenderAlias)
text: qsTr("Receiving from %1...").arg(currentSenderAlias) font.bold: true
}
ProgressBar {
id: totalProgressBar
Layout.fillWidth: true
from: 0
to: 100
value: calculateTotalProgress()
} }
Label { Label {
text: qsTr("%1% complete").arg(Math.round(totalProgressBar.value)) text: appController.currentReceiveFileName
color: "gray" ? 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: appController.receiveProgress
}
Label {
text: qsTr("%1% complete").arg(Math.round(appController.receiveProgress))
color: palette.mid
} }
} }
property var progressData: ({}) footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: {
appController.cancelReceive()
receiveProgressDialog.close()
}
}
}
}
Dialog {
id: sendProgressDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
title: qsTr("Sending Files")
ColumnLayout {
spacing: 12
Label {
text: appController.currentSendFileName
? qsTr("%1 (%2/%3)").arg(appController.currentSendFileName)
.arg(appController.currentSendFileIndex)
.arg(appController.totalSendFiles)
: qsTr("Preparing...")
elide: Text.ElideMiddle
Layout.maximumWidth: 400
}
ProgressBar {
Layout.fillWidth: true
from: 0
to: 100
value: appController.sendProgress
}
Label {
text: qsTr("%1% complete").arg(Math.round(appController.sendProgress))
color: palette.mid
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: {
appController.cancelSend()
sendProgressDialog.close()
}
}
}
} }
Connections { Connections {
@@ -129,34 +274,51 @@ ApplicationWindow {
if (appController.quickSave) { if (appController.quickSave) {
appController.acceptReceive(sessionId) appController.acceptReceive(sessionId)
receiveProgressDialog.open()
} else { } else {
receiveDialog.open() receiveDialog.open()
} }
} }
function onReceiveProgress(sessionId, fileId, progress) {
if (sessionId === currentSessionId) {
receiveProgress[fileId] = progress
receiveProgress = Object.assign({}, receiveProgress)
progressDialog.progressData = receiveProgress
}
}
function onReceiveCompleted(sessionId) { function onReceiveCompleted(sessionId) {
if (sessionId === currentSessionId) { receiveProgressDialog.close()
progressDialog.close()
receiveProgress = {}
currentSessionId = ""
}
} }
function onReceiveError(sessionId, error) { function onReceiveError(sessionId, error) {
if (sessionId === currentSessionId) { receiveProgressDialog.close()
progressDialog.close() errorDialog.text = error
errorDialog.text = error errorDialog.open()
errorDialog.open() }
function onSendingChanged() {
if (appController.sending && !sendProgressDialog.visible) {
sendProgressDialog.open()
} }
} }
function onSendCompleted(sessionId) {
sendProgressDialog.close()
successDialog.text = qsTr("Files sent successfully!")
successDialog.open()
}
function onSendError(error) {
sendProgressDialog.close()
pinDialog.close()
errorDialog.text = error
errorDialog.open()
}
function onSendCanceled() {
sendProgressDialog.close()
errorDialog.text = qsTr("Transfer canceled by receiver")
errorDialog.open()
}
function onPinRequired(firstAttempt) {
pinDialog.isFirstAttempt = firstAttempt
pinDialog.open()
}
} }
Dialog { Dialog {
@@ -172,6 +334,171 @@ ApplicationWindow {
} }
} }
Dialog {
id: successDialog
anchors.centerIn: parent
modal: true
standardButtons: Dialog.Ok
title: qsTr("Success")
property alias text: successLabel.text
Label {
id: successLabel
}
}
Dialog {
id: pinDialog
anchors.centerIn: parent
modal: true
closePolicy: Popup.NoAutoClose
title: qsTr("PIN Required")
property bool isFirstAttempt: true
ColumnLayout {
spacing: 12
Label {
text: pinDialog.isFirstAttempt
? qsTr("The receiver requires a PIN to accept files.")
: qsTr("Invalid PIN. Please try again.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
color: pinDialog.isFirstAttempt ? palette.text : "red"
}
TextField {
id: pinInput
Layout.fillWidth: true
placeholderText: qsTr("Enter PIN")
echoMode: TextInput.Password
focus: true
onAccepted: pinDialog.submitPin()
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
}
Button {
text: qsTr("Submit")
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
enabled: pinInput.text.length > 0
}
}
onAccepted: submitPin()
onRejected: {
appController.cancelSend()
sendProgressDialog.close()
close()
}
function submitPin() {
if (pinInput.text.length > 0) {
appController.retryWithPin(pinInput.text)
pinInput.text = ""
}
}
onOpened: {
pinInput.text = ""
pinInput.forceActiveFocus()
}
}
Dialog {
id: manualSendDialog
anchors.centerIn: parent
modal: true
title: qsTr("Manual Sending")
ColumnLayout {
spacing: 12
Label {
text: qsTr("Enter the IP address of the target device.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: manualAddressInput
Layout.fillWidth: true
placeholderText: qsTr("IP address (e.g., 192.168.1.100:53317)")
onAccepted: manualSendDialog.accepted()
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
}
Button {
text: qsTr("Send")
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
enabled: manualAddressInput.text.trim().length > 0
}
}
onAccepted: {
appController.sendToAddress(manualAddressInput.text.trim())
}
onOpened: {
manualAddressInput.text = ""
manualAddressInput.forceActiveFocus()
}
}
Dialog {
id: setPinDialog
anchors.centerIn: parent
modal: true
title: qsTr("Set Receive PIN")
ColumnLayout {
spacing: 12
Label {
text: qsTr("Enter a PIN that senders must provide to transfer files to this device. Leave empty to disable.")
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
TextField {
id: setPinInput
Layout.fillWidth: true
placeholderText: qsTr("Enter PIN")
onAccepted: setPinDialog.accepted()
}
}
footer: DialogButtonBox {
Button {
text: qsTr("Cancel")
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
}
Button {
text: qsTr("OK")
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
}
}
onAccepted: {
appController.receivePin = setPinInput.text
}
onOpened: {
setPinInput.text = appController.receivePin
setPinInput.forceActiveFocus()
}
}
function formatSize(bytes) { function formatSize(bytes) {
if (bytes < 1024) return bytes + " B" if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
@@ -179,20 +506,6 @@ ApplicationWindow {
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB" return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"
} }
function calculateTotalProgress() {
if (!currentFiles || currentFiles.length === 0) return 0
var total = 0
var count = 0
for (var i = 0; i < currentFiles.length; i++) {
var fileId = currentFiles[i].id
if (receiveProgress[fileId] !== undefined) {
total += receiveProgress[fileId] * 100
count++
}
}
return count > 0 ? total / currentFiles.length : 0
}
Component { Component {
id: homePageComponent id: homePageComponent
Page { Page {
@@ -222,6 +535,86 @@ ApplicationWindow {
anchors.margins: 16 anchors.margins: 16
spacing: 16 spacing: 16
ColumnLayout {
Layout.fillWidth: true
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: qsTr("Selected Files")
font.bold: true
font.pixelSize: 16
}
Item { Layout.fillWidth: true }
Button {
text: qsTr("Add Files")
onClicked: fileDialog.open()
}
Button {
text: qsTr("Clear All")
visible: appController.hasPendingFiles
onClicked: appController.clearPendingFiles()
}
}
ListView {
id: pendingFilesList
Layout.fillWidth: true
Layout.preferredHeight: Math.min(180, contentHeight)
model: appController.pendingFiles
spacing: 4
visible: appController.hasPendingFiles
clip: true
delegate: Pane {
width: ListView.view.width
padding: 8
background: Rectangle {
color: "transparent"
radius: 6
border.color: palette.mid
border.width: 1
}
RowLayout {
anchors.fill: parent
spacing: 8
Label {
text: modelData.fileName
Layout.fillWidth: true
elide: Text.ElideMiddle
}
Label {
text: formatSize(modelData.size)
color: palette.mid
font.pixelSize: 12
}
ToolButton {
text: "\u2715"
font.pixelSize: 14
onClicked: appController.removePendingFile(index)
}
}
}
}
Label {
visible: !appController.hasPendingFiles
text: qsTr("No files selected. Click \"Add Files\" or drag and drop files here.")
color: palette.mid
Layout.fillWidth: true
wrapMode: Text.WordWrap
horizontalAlignment: Text.AlignHCenter
}
}
Label { Label {
text: qsTr("Nearby Devices") text: qsTr("Nearby Devices")
font.bold: true font.bold: true
@@ -242,34 +635,54 @@ ApplicationWindow {
padding: 12 padding: 12
background: Rectangle { background: Rectangle {
color: Qt.lighter("gray", 1.8) color: "transparent"
radius: 8 radius: 8
border.color: palette.mid
border.width: 1
} }
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
spacing: 12
Label {
text: getDeviceTypeIcon(modelData.deviceType)
font.pixelSize: 32
Layout.alignment: Qt.AlignVCenter
}
Column { Column {
Layout.fillWidth: true Layout.fillWidth: true
Label { Label {
text: modelData.alias || modelData.ip text: modelData.alias || modelData.ip
font.bold: true font.bold: true
color: palette.text
} }
Label { Label {
text: "%1:%2".arg(modelData.ip).arg(modelData.port) text: "%1:%2".arg(modelData.ip).arg(modelData.port)
color: "gray" color: palette.mid
font.pixelSize: 12 font.pixelSize: 12
} }
} }
Button { Button {
visible: appController.hasPendingFiles && !appController.sending
text: qsTr("Send") text: qsTr("Send")
enabled: !appController.sending
onClicked: { onClicked: {
// TODO: send to this device if (appController.hasPendingFiles && !appController.sending) {
appController.sendTo(modelData.fingerprint)
}
} }
} }
} }
} }
Label {
anchors.centerIn: parent
text: qsTr("No devices found")
color: palette.mid
visible: deviceListView.count === 0
}
} }
RowLayout { RowLayout {
@@ -278,6 +691,11 @@ ApplicationWindow {
text: qsTr("Refresh") text: qsTr("Refresh")
onClicked: appController.refreshDevices() onClicked: appController.refreshDevices()
} }
Button {
text: qsTr("Manual Send")
enabled: appController.hasPendingFiles && !appController.sending
onClicked: manualSendDialog.open()
}
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Label { Label {
text: qsTr("Alias: %1").arg(appController.alias) text: qsTr("Alias: %1").arg(appController.alias)
@@ -317,47 +735,125 @@ ApplicationWindow {
anchors.margins: 16 anchors.margins: 16
spacing: 16 spacing: 16
GridLayout { ScrollView {
columns: 2
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true
contentWidth: availableWidth
clip: true
Label { text: qsTr("Device Alias:") } ColumnLayout {
TextField { width: parent.width
id: aliasField spacing: 16
text: appController.alias
onEditingFinished: appController.alias = text
Layout.fillWidth: true
}
Label { text: qsTr("Port:") } GridLayout {
SpinBox { columns: 2
id: portField
value: appController.port
from: 1
to: 65535
onValueChanged: appController.port = value
}
Label { text: qsTr("Download Path:") }
RowLayout {
Layout.fillWidth: true
TextField {
id: downloadPathField
text: appController.downloadPath
onEditingFinished: appController.downloadPath = text
Layout.fillWidth: true Layout.fillWidth: true
}
Button {
text: qsTr("Browse")
onClicked: folderDialog.open()
}
}
Label { text: qsTr("Quick Save:") } Label { text: qsTr("Device Alias:") }
CheckBox { TextField {
id: quickSaveCheck id: aliasField
checked: appController.quickSave text: appController.alias
onCheckedChanged: appController.quickSave = checked onEditingFinished: appController.alias = text
Layout.fillWidth: true
}
Label { text: qsTr("Port:") }
SpinBox {
id: portField
value: appController.port
from: 1
to: 65535
onValueChanged: appController.port = value
}
Label { text: qsTr("Download Path:") }
RowLayout {
Layout.fillWidth: true
TextField {
id: downloadPathField
text: appController.downloadPath
onEditingFinished: appController.downloadPath = text
Layout.fillWidth: true
}
Button {
text: qsTr("Browse")
onClicked: folderDialog.open()
}
}
Label { text: qsTr("Quick Save:") }
CheckBox {
id: quickSaveCheck
checked: appController.quickSave
onCheckedChanged: appController.quickSave = checked
}
Label { text: qsTr("Receive PIN:") }
RowLayout {
Layout.fillWidth: true
Label {
text: appController.receivePin.length > 0 ? qsTr("Enabled") : qsTr("Disabled")
color: appController.receivePin.length > 0 ? "green" : palette.mid
}
Item { Layout.fillWidth: true }
Button {
text: appController.receivePin.length > 0 ? qsTr("Change") : qsTr("Set PIN")
onClicked: setPinDialog.open()
}
Button {
text: qsTr("Remove")
visible: appController.receivePin.length > 0
onClicked: appController.receivePin = ""
}
}
Label { text: qsTr("Device Type:") }
ComboBox {
id: deviceTypeCombo
Layout.fillWidth: true
model: [
{ text: qsTr("Mobile"), value: "mobile" },
{ text: qsTr("Desktop"), value: "desktop" },
{ text: qsTr("Web"), value: "web" },
{ text: qsTr("Headless"), value: "headless" },
{ text: qsTr("Server"), value: "server" }
]
textRole: "text"
Component.onCompleted: {
for (var i = 0; i < model.length; i++) {
if (model[i].value === appController.deviceType) {
currentIndex = i
break
}
}
}
onCurrentIndexChanged: {
if (currentIndex >= 0 && currentIndex < model.length) {
appController.deviceType = model[currentIndex].value
}
}
}
}
CheckBox {
id: advancedCheck
Layout.fillWidth: true
text: qsTr("Advanced Options")
checked: false
}
GridLayout {
columns: 2
Layout.fillWidth: true
visible: advancedCheck.checked
Label { text: qsTr("HTTPS:") }
CheckBox {
id: httpsCheck
checked: appController.https
onCheckedChanged: appController.https = checked
}
}
} }
} }
@@ -369,9 +865,8 @@ ApplicationWindow {
} }
} }
Item { Layout.fillHeight: true }
Label { Label {
Layout.fillWidth: true
text: qsTr("Server Status: %1").arg(appController.serverRunning ? qsTr("Running") : qsTr("Stopped")) text: qsTr("Server Status: %1").arg(appController.serverRunning ? qsTr("Running") : qsTr("Stopped"))
color: appController.serverRunning ? "green" : "red" color: appController.serverRunning ? "green" : "red"
} }

View File

@@ -53,3 +53,13 @@ set_target_properties(LocalSendCore PROPERTIES
SOVERSION 1 SOVERSION 1
OUTPUT_NAME localsend-core OUTPUT_NAME localsend-core
) )
install(TARGETS LocalSendCore
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
if(INSTALL_DEVEL_HEADERS)
install(DIRECTORY include/LocalSendCore
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
endif()

View File

@@ -22,6 +22,12 @@ namespace ApiRoute {
constexpr const char* UPLOAD = "/api/localsend/v2/upload"; constexpr const char* UPLOAD = "/api/localsend/v2/upload";
constexpr const char* CANCEL = "/api/localsend/v2/cancel"; constexpr const char* CANCEL = "/api/localsend/v2/cancel";
constexpr const char* SHOW = "/api/localsend/v2/show"; constexpr const char* SHOW = "/api/localsend/v2/show";
constexpr const char* V1_INFO = "/api/localsend/v1/info";
constexpr const char* V1_REGISTER = "/api/localsend/v1/register";
constexpr const char* V1_PREPARE_UPLOAD = "/api/localsend/v1/send-request";
constexpr const char* V1_UPLOAD = "/api/localsend/v1/send";
constexpr const char* V1_CANCEL = "/api/localsend/v1/cancel";
} }
} }

View File

@@ -22,18 +22,21 @@ public:
void getInfo(const Device& device); void getInfo(const Device& device);
void registerDevice(const Device& device, const RegisterDto& dto); void registerDevice(const Device& device, const RegisterDto& dto);
void prepareUpload(const Device& device, const PrepareUploadRequestDto& dto); void prepareUpload(const Device& device, const PrepareUploadRequestDto& dto, const QString& pin = QString());
void uploadFile(const Device& device, const QString& sessionId, const QString& fileId, void uploadFile(const Device& device, const QString& sessionId, const QString& fileId,
const QString& token, const QString& filePath); const QString& token, const QString& filePath);
void cancel(const Device& device, const QString& sessionId); void cancel(const Device& device, const QString& sessionId);
void abortCurrentUpload();
signals: signals:
void infoReceived(const InfoDto& info); void infoReceived(const InfoDto& info);
void infoError(const QString& error); void infoError(const QString& error);
void registerCompleted(); void registerCompleted(const InfoDto& peerInfo);
void registerError(const QString& error); void registerError(const QString& error);
void prepareUploadResponse(const PrepareUploadResponseDto& response); void prepareUploadResponse(const PrepareUploadResponseDto& response);
void prepareUploadError(const QString& error); void prepareUploadError(const QString& error);
void prepareUploadPinRequired();
void prepareUploadTooManyAttempts();
void uploadProgress(qint64 sent, qint64 total); void uploadProgress(qint64 sent, qint64 total);
void uploadCompleted(); void uploadCompleted();
void uploadError(const QString& error); void uploadError(const QString& error);
@@ -41,6 +44,7 @@ signals:
private: private:
QNetworkAccessManager* m_manager = nullptr; QNetworkAccessManager* m_manager = nullptr;
QSslConfiguration m_sslConfig; QSslConfiguration m_sslConfig;
QNetworkReply* m_currentUploadReply = nullptr;
QNetworkReply* sendGet(const QUrl& url); QNetworkReply* sendGet(const QUrl& url);
QNetworkReply* sendPost(const QUrl& url, const QByteArray& data); QNetworkReply* sendPost(const QUrl& url, const QByteArray& data);

View File

@@ -29,6 +29,7 @@ public:
void stop(); void stop();
void setLocalInfo(const InfoDto& info, const QString& fingerprint); void setLocalInfo(const InfoDto& info, const QString& fingerprint);
void setReceivePin(const QString& pin);
#ifdef HAS_QTHTTPSERVER #ifdef HAS_QTHTTPSERVER
void setSslConfiguration(const QSslConfiguration& config); void setSslConfiguration(const QSslConfiguration& config);
#endif #endif
@@ -39,6 +40,9 @@ public:
void respondToPrepareUpload(const QString& sessionId, bool accepted, void respondToPrepareUpload(const QString& sessionId, bool accepted,
const QMap<QString, QString>& tokens = {}); const QMap<QString, QString>& tokens = {});
void addActiveSession(const QString& sessionId);
void removeActiveSession(const QString& sessionId);
signals: signals:
void registerRequest(const RegisterDto& dto, const QHostAddress& sender); void registerRequest(const RegisterDto& dto, const QHostAddress& sender);
void prepareUploadRequest(const QString& sessionId, const PrepareUploadRequestDto& dto, void prepareUploadRequest(const QString& sessionId, const PrepareUploadRequestDto& dto,
@@ -58,10 +62,13 @@ private:
std::shared_ptr<QPromise<QHttpServerResponse>> promise; std::shared_ptr<QPromise<QHttpServerResponse>> promise;
}; };
QMap<QString, PendingPrepareUpload> m_pendingPrepareUploads; QMap<QString, PendingPrepareUpload> m_pendingPrepareUploads;
QSet<QString> m_activeSessions;
#endif #endif
InfoDto m_localInfo; InfoDto m_localInfo;
QString m_localFingerprint; QString m_localFingerprint;
QString m_receivePin;
QMap<QString, int> m_pinAttempts;
quint16 m_port = 0; quint16 m_port = 0;
#ifdef HAS_QTHTTPSERVER #ifdef HAS_QTHTTPSERVER
@@ -71,6 +78,7 @@ private:
QFuture<QHttpServerResponse> handlePrepareUploadRequest(const QHttpServerRequest& request); QFuture<QHttpServerResponse> handlePrepareUploadRequest(const QHttpServerRequest& request);
QHttpServerResponse handleUploadRequest(const QHttpServerRequest& request); QHttpServerResponse handleUploadRequest(const QHttpServerRequest& request);
QHttpServerResponse handleCancelRequest(const QHttpServerRequest& request); QHttpServerResponse handleCancelRequest(const QHttpServerRequest& request);
bool checkPin(const QHttpServerRequest& request, const QHostAddress& peer);
#endif #endif
}; };

View File

@@ -53,6 +53,8 @@ public:
QString createSendSession(const Device& target, const QMap<QString, FileDto>& files, QString createSendSession(const Device& target, const QMap<QString, FileDto>& files,
const QMap<QString, QString>& localPaths); const QMap<QString, QString>& localPaths);
void setSendSessionTokens(const QString& sessionId, const QString& responseSessionId,
const QMap<QString, QString>& tokens);
void startSendSession(const QString& sessionId); void startSendSession(const QString& sessionId);
void updateSendProgress(const QString& sessionId, const QString& fileId, qint64 bytes); void updateSendProgress(const QString& sessionId, const QString& fileId, qint64 bytes);
void completeSendFile(const QString& sessionId, const QString& fileId); void completeSendFile(const QString& sessionId, const QString& fileId);

View File

@@ -29,6 +29,9 @@ public:
bool quickSave() const; bool quickSave() const;
void setQuickSave(bool enabled); void setQuickSave(bool enabled);
QString receivePin() const;
void setReceivePin(const QString& pin);
QString deviceModel() const; QString deviceModel() const;
void setDeviceModel(const QString& model); void setDeviceModel(const QString& model);

View File

@@ -3,6 +3,7 @@
#include <QFile> #include <QFile>
#include <QUrlQuery> #include <QUrlQuery>
#include <QJsonDocument> #include <QJsonDocument>
#include <QDebug>
namespace LocalSend { namespace LocalSend {
@@ -10,6 +11,10 @@ HttpClient::HttpClient(QObject* parent)
: QObject(parent) : QObject(parent)
, m_manager(new QNetworkAccessManager(this)) , m_manager(new QNetworkAccessManager(this))
{ {
connect(m_manager, &QNetworkAccessManager::sslErrors,
this, [](QNetworkReply* reply, const QList<QSslError>&) {
reply->ignoreSslErrors();
});
} }
HttpClient::~HttpClient() HttpClient::~HttpClient()
@@ -90,28 +95,66 @@ void HttpClient::registerDevice(const Device& device, const RegisterDto& dto)
return; return;
} }
emit registerCompleted(); QByteArray responseData = reply->readAll();
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(responseData, &error);
if (error.error != QJsonParseError::NoError) {
emit registerError(QStringLiteral("Invalid JSON response"));
return;
}
InfoDto info = InfoDto::fromJson(doc.object());
emit registerCompleted(info);
}); });
} }
void HttpClient::prepareUpload(const Device& device, const PrepareUploadRequestDto& dto) void HttpClient::prepareUpload(const Device& device, const PrepareUploadRequestDto& dto, const QString& pin)
{ {
QUrl url = buildUrl(device, ApiRoute::PREPARE_UPLOAD); QUrl url = buildUrl(device, ApiRoute::PREPARE_UPLOAD);
if (!pin.isEmpty()) {
QUrlQuery query;
query.addQueryItem(QStringLiteral("pin"), pin);
url.setQuery(query);
}
QByteArray data = QJsonDocument(dto.toJson()).toJson(QJsonDocument::Compact); QByteArray data = QJsonDocument(dto.toJson()).toJson(QJsonDocument::Compact);
qDebug() << "[HttpClient] prepareUpload to" << url.toString();
qDebug() << "[HttpClient] request:" << data;
QNetworkReply* reply = sendPost(url, data); QNetworkReply* reply = sendPost(url, data);
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater(); reply->deleteLater();
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode == 401) {
qDebug() << "[HttpClient] prepareUpload: PIN required (401)";
emit prepareUploadPinRequired();
return;
}
if (statusCode == 429) {
qWarning() << "[HttpClient] prepareUpload: Too many attempts (429)";
emit prepareUploadTooManyAttempts();
return;
}
if (reply->error() != QNetworkReply::NoError) { if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[HttpClient] prepareUpload error:" << reply->errorString();
emit prepareUploadError(reply->errorString()); emit prepareUploadError(reply->errorString());
return; return;
} }
QByteArray responseData = reply->readAll();
qDebug() << "[HttpClient] prepareUpload response:" << responseData;
QJsonParseError error; QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &error); QJsonDocument doc = QJsonDocument::fromJson(responseData, &error);
if (error.error != QJsonParseError::NoError) { if (error.error != QJsonParseError::NoError) {
qWarning() << "[HttpClient] Invalid JSON response";
emit prepareUploadError(QStringLiteral("Invalid JSON response")); emit prepareUploadError(QStringLiteral("Invalid JSON response"));
return; return;
} }
@@ -131,14 +174,18 @@ void HttpClient::uploadFile(const Device& device, const QString& sessionId,
query.addQueryItem(QStringLiteral("token"), token); query.addQueryItem(QStringLiteral("token"), token);
url.setQuery(query); url.setQuery(query);
qDebug() << "[HttpClient] uploadFile to" << url.toString();
QFile* file = new QFile(filePath); QFile* file = new QFile(filePath);
if (!file->open(QIODevice::ReadOnly)) { if (!file->open(QIODevice::ReadOnly)) {
qWarning() << "[HttpClient] Cannot open file:" << filePath << file->errorString();
emit uploadError(QStringLiteral("Cannot open file: ") + file->errorString()); emit uploadError(QStringLiteral("Cannot open file: ") + file->errorString());
delete file; delete file;
return; return;
} }
qint64 fileSize = file->size(); qint64 fileSize = file->size();
qDebug() << "[HttpClient] File size:" << fileSize;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/octet-stream")); request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/octet-stream"));
@@ -148,20 +195,30 @@ void HttpClient::uploadFile(const Device& device, const QString& sessionId,
} }
QNetworkReply* reply = m_manager->post(request, file); QNetworkReply* reply = m_manager->post(request, file);
m_currentUploadReply = reply;
file->setParent(reply); file->setParent(reply);
connect(reply, &QNetworkReply::uploadProgress, this, [this, fileSize](qint64 sent, qint64) { connect(reply, &QNetworkReply::uploadProgress, this, [this, fileSize](qint64 sent, qint64) {
emit uploadProgress(sent, fileSize); emit uploadProgress(sent, fileSize);
}); });
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply, filePath]() {
m_currentUploadReply = nullptr;
reply->deleteLater(); reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) { if (reply->error() != QNetworkReply::NoError) {
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode == 410) {
qWarning() << "[HttpClient] uploadFile: session cancelled (410)";
emit uploadError(QStringLiteral("SESSION_CANCELLED"));
return;
}
qWarning() << "[HttpClient] uploadFile error:" << reply->errorString();
emit uploadError(reply->errorString()); emit uploadError(reply->errorString());
return; return;
} }
qDebug() << "[HttpClient] uploadFile completed:" << filePath;
emit uploadCompleted(); emit uploadCompleted();
}); });
} }
@@ -178,5 +235,13 @@ void HttpClient::cancel(const Device& device, const QString& sessionId)
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater); connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
} }
void HttpClient::abortCurrentUpload()
{
if (m_currentUploadReply) {
m_currentUploadReply->abort();
m_currentUploadReply = nullptr;
}
}
} }

View File

@@ -4,6 +4,7 @@
#ifdef HAS_QTHTTPSERVER #ifdef HAS_QTHTTPSERVER
#include <QTcpServer> #include <QTcpServer>
#include <QSslServer>
#include <QJsonDocument> #include <QJsonDocument>
#include <QEventLoop> #include <QEventLoop>
#include <QUuid> #include <QUuid>
@@ -33,23 +34,76 @@ void HttpServer::setSslConfiguration(const QSslConfiguration& config)
m_sslConfig = config; m_sslConfig = config;
} }
void HttpServer::setReceivePin(const QString& pin)
{
m_receivePin = pin;
m_pinAttempts.clear();
}
void HttpServer::addActiveSession(const QString& sessionId)
{
m_activeSessions.insert(sessionId);
}
void HttpServer::removeActiveSession(const QString& sessionId)
{
m_activeSessions.remove(sessionId);
}
bool HttpServer::checkPin(const QHttpServerRequest& request, const QHostAddress& peer)
{
if (m_receivePin.isEmpty()) {
return true;
}
QString peerIp = peer.toString();
int attempts = m_pinAttempts.value(peerIp, 0);
if (attempts >= 3) {
return false;
}
QUrlQuery query(request.url().query());
QString requestPin = query.queryItemValue(QStringLiteral("pin"));
if (requestPin != m_receivePin) {
if (!requestPin.isEmpty()) {
m_pinAttempts[peerIp] = attempts + 1;
}
return false;
}
return true;
}
bool HttpServer::start(quint16 port, bool https) bool HttpServer::start(quint16 port, bool https)
{ {
Q_UNUSED(https)
if (m_tcpServer && m_tcpServer->isListening()) { if (m_tcpServer && m_tcpServer->isListening()) {
stop(); stop();
} }
m_port = port; m_port = port;
m_tcpServer = new QTcpServer(this); if (https && !m_sslConfig.isNull()) {
if (!m_tcpServer->listen(QHostAddress::Any, port)) { auto* sslServer = new QSslServer(this);
delete m_tcpServer; sslServer->setSslConfiguration(m_sslConfig);
m_tcpServer = nullptr; if (!sslServer->listen(QHostAddress::Any, port)) {
return false; delete sslServer;
return false;
}
m_tcpServer = sslServer;
m_server->bind(m_tcpServer);
qDebug() << "[HttpServer] Listening on HTTPS port:" << port;
} else {
m_tcpServer = new QTcpServer(this);
if (!m_tcpServer->listen(QHostAddress::Any, port)) {
delete m_tcpServer;
m_tcpServer = nullptr;
return false;
}
m_server->bind(m_tcpServer);
qDebug() << "[HttpServer] Listening on HTTP port:" << port;
} }
m_server->bind(m_tcpServer);
return true; return true;
} }
@@ -123,30 +177,33 @@ void HttpServer::respondToPrepareUpload(const QString& sessionId, bool accepted,
void HttpServer::setupRoutes() void HttpServer::setupRoutes()
{ {
m_server->route(ApiRoute::INFO, QHttpServerRequest::Method::Get, auto infoHandler = [this](const QHttpServerRequest&) {
[this](const QHttpServerRequest&) { return handleInfoRequest();
return handleInfoRequest(); };
}); auto registerHandler = [this](const QHttpServerRequest& request) {
return handleRegisterRequest(request, request.remoteAddress());
};
auto prepareUploadHandler = [this](const QHttpServerRequest& request) {
return handlePrepareUploadRequest(request);
};
auto uploadHandler = [this](const QHttpServerRequest& request) {
return handleUploadRequest(request);
};
auto cancelHandler = [this](const QHttpServerRequest& request) {
return handleCancelRequest(request);
};
m_server->route(ApiRoute::REGISTER, QHttpServerRequest::Method::Post, m_server->route(ApiRoute::INFO, QHttpServerRequest::Method::Get, infoHandler);
[this](const QHttpServerRequest& request) { m_server->route(ApiRoute::REGISTER, QHttpServerRequest::Method::Post, registerHandler);
return handleRegisterRequest(request, request.remoteAddress()); m_server->route(ApiRoute::PREPARE_UPLOAD, QHttpServerRequest::Method::Post, prepareUploadHandler);
}); m_server->route(ApiRoute::UPLOAD, QHttpServerRequest::Method::Post, uploadHandler);
m_server->route(ApiRoute::CANCEL, QHttpServerRequest::Method::Post, cancelHandler);
m_server->route(ApiRoute::PREPARE_UPLOAD, QHttpServerRequest::Method::Post, m_server->route(ApiRoute::V1_INFO, QHttpServerRequest::Method::Get, infoHandler);
[this](const QHttpServerRequest& request) { m_server->route(ApiRoute::V1_REGISTER, QHttpServerRequest::Method::Post, registerHandler);
return handlePrepareUploadRequest(request); m_server->route(ApiRoute::V1_PREPARE_UPLOAD, QHttpServerRequest::Method::Post, prepareUploadHandler);
}); m_server->route(ApiRoute::V1_UPLOAD, QHttpServerRequest::Method::Post, uploadHandler);
m_server->route(ApiRoute::V1_CANCEL, QHttpServerRequest::Method::Post, cancelHandler);
m_server->route(ApiRoute::UPLOAD, QHttpServerRequest::Method::Post,
[this](const QHttpServerRequest& request) {
return handleUploadRequest(request);
});
m_server->route(ApiRoute::CANCEL, QHttpServerRequest::Method::Post,
[this](const QHttpServerRequest& request) {
return handleCancelRequest(request);
});
} }
QHttpServerResponse HttpServer::handleInfoRequest() QHttpServerResponse HttpServer::handleInfoRequest()
@@ -180,6 +237,41 @@ QHttpServerResponse HttpServer::handleRegisterRequest(const QHttpServerRequest&
QFuture<QHttpServerResponse> HttpServer::handlePrepareUploadRequest(const QHttpServerRequest& request) QFuture<QHttpServerResponse> HttpServer::handlePrepareUploadRequest(const QHttpServerRequest& request)
{ {
QHostAddress peer = request.remoteAddress();
if (!m_receivePin.isEmpty()) {
QString peerIp = peer.toString();
int attempts = m_pinAttempts.value(peerIp, 0);
if (attempts >= 3) {
auto promise = std::make_shared<QPromise<QHttpServerResponse>>();
promise->start();
QJsonObject errorObj;
errorObj[QStringLiteral("message")] = QStringLiteral("Too many attempts.");
promise->addResult(QHttpServerResponse(QJsonDocument(errorObj).toJson(QJsonDocument::Compact),
QHttpServerResponse::StatusCode::Forbidden));
promise->finish();
return promise->future();
}
QUrlQuery query(request.url().query());
QString requestPin = query.queryItemValue(QStringLiteral("pin"));
if (requestPin != m_receivePin) {
if (!requestPin.isEmpty()) {
m_pinAttempts[peerIp] = attempts + 1;
}
auto promise = std::make_shared<QPromise<QHttpServerResponse>>();
promise->start();
QJsonObject errorObj;
errorObj[QStringLiteral("message")] = QStringLiteral("Invalid pin.");
promise->addResult(QHttpServerResponse(QJsonDocument(errorObj).toJson(QJsonDocument::Compact),
QHttpServerResponse::StatusCode::Unauthorized));
promise->finish();
return promise->future();
}
}
QJsonParseError error; QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(request.body(), &error); QJsonDocument doc = QJsonDocument::fromJson(request.body(), &error);
@@ -222,6 +314,14 @@ QHttpServerResponse HttpServer::handleUploadRequest(const QHttpServerRequest& re
return QHttpServerResponse(QHttpServerResponse::StatusCode::BadRequest); return QHttpServerResponse(QHttpServerResponse::StatusCode::BadRequest);
} }
if (!m_activeSessions.contains(sessionId)) {
QJsonObject errorObj;
errorObj[QStringLiteral("error")] = QStringLiteral("session_not_found");
errorObj[QStringLiteral("message")] = QStringLiteral("Session not found or has been cancelled");
return QHttpServerResponse(QJsonDocument(errorObj).toJson(QJsonDocument::Compact),
QHttpServerResponse::StatusCode::Gone);
}
emit uploadRequest(sessionId, fileId, token, request.body()); emit uploadRequest(sessionId, fileId, token, request.body());
return QHttpServerResponse(QHttpServerResponse::StatusCode::Ok); return QHttpServerResponse(QHttpServerResponse::StatusCode::Ok);
@@ -293,6 +393,16 @@ bool HttpServer::isRunning() const
return false; return false;
} }
void HttpServer::addActiveSession(const QString& sessionId)
{
Q_UNUSED(sessionId)
}
void HttpServer::removeActiveSession(const QString& sessionId)
{
Q_UNUSED(sessionId)
}
} }

View File

@@ -156,6 +156,25 @@ QString SessionManager::createSendSession(const Device& target, const QMap<QStri
return sessionId; return sessionId;
} }
void SessionManager::setSendSessionTokens(const QString& sessionId, const QString& responseSessionId,
const QMap<QString, QString>& tokens)
{
if (!m_sendSessions.contains(sessionId)) {
return;
}
SendSession session = m_sendSessions.take(sessionId);
session.sessionId = responseSessionId;
for (auto it = tokens.constBegin(); it != tokens.constEnd(); ++it) {
if (session.files.contains(it.key())) {
session.files[it.key()].token = it.value();
}
}
m_sendSessions.insert(responseSessionId, session);
}
void SessionManager::startSendSession(const QString& sessionId) void SessionManager::startSendSession(const QString& sessionId)
{ {
if (!m_sendSessions.contains(sessionId)) { if (!m_sendSessions.contains(sessionId)) {

View File

@@ -1,6 +1,7 @@
#include "LocalSendCore/Settings.h" #include "LocalSendCore/Settings.h"
#include "LocalSendCore/Constants.h" #include "LocalSendCore/Constants.h"
#include <QStandardPaths> #include <QStandardPaths>
#include <QSysInfo>
namespace LocalSend { namespace LocalSend {
@@ -11,6 +12,18 @@ Settings::Settings(QObject* parent)
{ {
} }
namespace {
DeviceType detectDeviceType() {
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
return DeviceType::Mobile;
#elif defined(Q_OS_LINUX) || defined(Q_OS_MACOS) || defined(Q_OS_WINDOWS)
return DeviceType::Desktop;
#else
return DeviceType::Headless;
#endif
}
}
QString Settings::alias() const QString Settings::alias() const
{ {
return m_settings.value(QStringLiteral("alias"), return m_settings.value(QStringLiteral("alias"),
@@ -40,7 +53,7 @@ void Settings::setPort(quint16 port)
bool Settings::https() const bool Settings::https() const
{ {
return m_settings.value(QStringLiteral("https"), false).toBool(); return m_settings.value(QStringLiteral("https"), true).toBool();
} }
void Settings::setHttps(bool enabled) void Settings::setHttps(bool enabled)
@@ -78,6 +91,23 @@ void Settings::setQuickSave(bool enabled)
} }
} }
QString Settings::receivePin() const
{
return m_settings.value(QStringLiteral("receivePin")).toString();
}
void Settings::setReceivePin(const QString& pin)
{
if (receivePin() != pin) {
if (pin.isEmpty()) {
m_settings.remove(QStringLiteral("receivePin"));
} else {
m_settings.setValue(QStringLiteral("receivePin"), pin);
}
m_settings.sync();
}
}
QString Settings::deviceModel() const QString Settings::deviceModel() const
{ {
return m_settings.value(QStringLiteral("deviceModel"), return m_settings.value(QStringLiteral("deviceModel"),
@@ -91,8 +121,11 @@ void Settings::setDeviceModel(const QString& model)
DeviceType Settings::deviceType() const DeviceType Settings::deviceType() const
{ {
return deviceTypeFromString(m_settings.value(QStringLiteral("deviceType"), QVariant stored = m_settings.value(QStringLiteral("deviceType"));
QStringLiteral("desktop")).toString()); if (stored.isValid()) {
return deviceTypeFromString(stored.toString());
}
return detectDeviceType();
} }
void Settings::setDeviceType(DeviceType type) void Settings::setDeviceType(DeviceType type)

View File

@@ -5,3 +5,19 @@ add_test(NAME TestDtoTypes COMMAND TestDtoTypes)
add_executable(TestMulticastDiscovery core/TestMulticastDiscovery.cpp) add_executable(TestMulticastDiscovery core/TestMulticastDiscovery.cpp)
target_link_libraries(TestMulticastDiscovery PRIVATE LocalSendCore Qt6::Test) target_link_libraries(TestMulticastDiscovery PRIVATE LocalSendCore Qt6::Test)
add_test(NAME TestMulticastDiscovery COMMAND TestMulticastDiscovery) add_test(NAME TestMulticastDiscovery COMMAND TestMulticastDiscovery)
add_executable(TestTypes core/TestTypes.cpp)
target_link_libraries(TestTypes PRIVATE LocalSendCore Qt6::Test)
add_test(NAME TestTypes COMMAND TestTypes)
add_executable(TestDevice core/TestDevice.cpp)
target_link_libraries(TestDevice PRIVATE LocalSendCore Qt6::Test)
add_test(NAME TestDevice COMMAND TestDevice)
add_executable(TestSessionManager core/TestSessionManager.cpp)
target_link_libraries(TestSessionManager PRIVATE LocalSendCore Qt6::Test)
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)

89
tests/core/TestDevice.cpp Normal file
View File

@@ -0,0 +1,89 @@
#include <QtTest>
#include <LocalSendCore/Device.h>
class TestDevice : public QObject
{
Q_OBJECT
private slots:
void testDefaultConstructor();
void testParameterizedConstructor();
void testDisplayNameWithAlias();
void testDisplayNameWithoutAlias();
void testIsHttps();
void testEqualityByFingerprint();
void testInequality();
};
void TestDevice::testDefaultConstructor()
{
LocalSend::Device device;
QVERIFY(device.ip.isEmpty());
QCOMPARE(device.port, quint16(53317));
QCOMPARE(device.protocol, LocalSend::ProtocolType::Http);
QVERIFY(device.alias.isEmpty());
QVERIFY(device.fingerprint.isEmpty());
QCOMPARE(device.deviceType, LocalSend::DeviceType::Desktop);
QVERIFY(!device.download);
QCOMPARE(device.discoveryMethod, LocalSend::DiscoveryMethod::Multicast);
QVERIFY(!device.lastSeen.isValid());
}
void TestDevice::testParameterizedConstructor()
{
LocalSend::Device device(QStringLiteral("192.168.1.100"), 8080);
QCOMPARE(device.ip, QStringLiteral("192.168.1.100"));
QCOMPARE(device.port, quint16(8080));
}
void TestDevice::testDisplayNameWithAlias()
{
LocalSend::Device device;
device.alias = QStringLiteral("My Laptop");
QCOMPARE(device.displayName(), QStringLiteral("My Laptop"));
}
void TestDevice::testDisplayNameWithoutAlias()
{
LocalSend::Device device;
device.ip = QStringLiteral("192.168.1.50");
QCOMPARE(device.displayName(), QStringLiteral("192.168.1.50"));
}
void TestDevice::testIsHttps()
{
LocalSend::Device httpDevice;
httpDevice.protocol = LocalSend::ProtocolType::Http;
QVERIFY(!httpDevice.isHttps());
LocalSend::Device httpsDevice;
httpsDevice.protocol = LocalSend::ProtocolType::Https;
QVERIFY(httpsDevice.isHttps());
}
void TestDevice::testEqualityByFingerprint()
{
LocalSend::Device a;
a.fingerprint = QStringLiteral("fp123");
a.ip = QStringLiteral("10.0.0.1");
LocalSend::Device b;
b.fingerprint = QStringLiteral("fp123");
b.ip = QStringLiteral("10.0.0.2");
QVERIFY(a == b);
}
void TestDevice::testInequality()
{
LocalSend::Device a;
a.fingerprint = QStringLiteral("fp1");
LocalSend::Device b;
b.fingerprint = QStringLiteral("fp2");
QVERIFY(a != b);
}
QTEST_MAIN(TestDevice)
#include "TestDevice.moc"

View File

@@ -0,0 +1,181 @@
#include <QtTest>
#include <LocalSendCore/DtoTypes.h>
#include <LocalSendCore/Constants.h>
#include <QDateTime>
class TestDtoTypesExtended : public QObject
{
Q_OBJECT
private slots:
void testFileDtoMetadata();
void testFileDtoRoundTripWithMetadata();
void testFileDtoWithoutOptionalFields();
void testPrepareUploadResponseDto();
void testReceiveRequestResponseDto();
void testMulticastDtoVersionDefault();
void testFileDtoSizeLarge();
void testPrepareUploadRequestDtoRoundTrip();
};
void TestDtoTypesExtended::testFileDtoMetadata()
{
LocalSend::FileDto dto;
dto.id = QStringLiteral("0");
dto.fileName = QStringLiteral("test.txt");
dto.size = 1024;
dto.fileType = QStringLiteral("text/plain");
dto.metadata = LocalSend::FileDto::Metadata{
QDateTime::fromMSecsSinceEpoch(1700000000000),
QDateTime::fromMSecsSinceEpoch(1700000001000)
};
QJsonObject json = dto.toJson();
QVERIFY(json.contains(QStringLiteral("metadata")));
QJsonObject metaObj = json[QStringLiteral("metadata")].toObject();
QCOMPARE(metaObj[QStringLiteral("lastModified")].toVariant().toLongLong(), qint64(1700000000000));
QCOMPARE(metaObj[QStringLiteral("lastAccessed")].toVariant().toLongLong(), qint64(1700000001000));
}
void TestDtoTypesExtended::testFileDtoRoundTripWithMetadata()
{
LocalSend::FileDto dto;
dto.id = QStringLiteral("1");
dto.fileName = QStringLiteral("doc.pdf");
dto.size = 2048;
dto.fileType = QStringLiteral("application/pdf");
dto.hash = QStringLiteral("abc123");
dto.preview = QStringLiteral("base64data");
dto.metadata = LocalSend::FileDto::Metadata{
QDateTime::fromMSecsSinceEpoch(1700000000000),
QDateTime()
};
QJsonObject json = dto.toJson();
LocalSend::FileDto parsed = LocalSend::FileDto::fromJson(json);
QCOMPARE(parsed.id, dto.id);
QCOMPARE(parsed.fileName, dto.fileName);
QCOMPARE(parsed.size, dto.size);
QCOMPARE(parsed.fileType, dto.fileType);
QCOMPARE(parsed.hash, dto.hash);
QCOMPARE(parsed.preview, dto.preview);
QVERIFY(parsed.metadata.has_value());
QCOMPARE(parsed.metadata->lastModified.toMSecsSinceEpoch(), qint64(1700000000000));
QVERIFY(!parsed.metadata->lastAccessed.isValid());
}
void TestDtoTypesExtended::testFileDtoWithoutOptionalFields()
{
LocalSend::FileDto dto;
dto.id = QStringLiteral("2");
dto.fileName = QStringLiteral("simple.txt");
dto.size = 50;
dto.fileType = QStringLiteral("text/plain");
QJsonObject json = dto.toJson();
QVERIFY(!json.contains(QStringLiteral("hash")));
QVERIFY(!json.contains(QStringLiteral("preview")));
QVERIFY(!json.contains(QStringLiteral("metadata")));
LocalSend::FileDto parsed = LocalSend::FileDto::fromJson(json);
QVERIFY(!parsed.metadata.has_value());
QVERIFY(parsed.hash.isEmpty());
QVERIFY(parsed.preview.isEmpty());
}
void TestDtoTypesExtended::testPrepareUploadResponseDto()
{
QJsonObject json;
json[QStringLiteral("sessionId")] = QStringLiteral("sess-123");
QJsonObject filesObj;
filesObj[QStringLiteral("0")] = QStringLiteral("token-0");
filesObj[QStringLiteral("1")] = QStringLiteral("token-1");
json[QStringLiteral("files")] = filesObj;
LocalSend::PrepareUploadResponseDto dto = LocalSend::PrepareUploadResponseDto::fromJson(json);
QCOMPARE(dto.sessionId, QStringLiteral("sess-123"));
QCOMPARE(dto.files.size(), 2);
QCOMPARE(dto.files[QStringLiteral("0")], QStringLiteral("token-0"));
QCOMPARE(dto.files[QStringLiteral("1")], QStringLiteral("token-1"));
}
void TestDtoTypesExtended::testReceiveRequestResponseDto()
{
LocalSend::ReceiveRequestResponseDto dto;
dto.sessionId = QStringLiteral("sess-456");
dto.cancel = false;
dto.destinationPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/file0.txt"));
dto.destinationPaths.insert(QStringLiteral("1"), QStringLiteral("/tmp/file1.txt"));
QJsonObject json = dto.toJson();
QCOMPARE(json[QStringLiteral("sessionId")].toString(), QStringLiteral("sess-456"));
QCOMPARE(json[QStringLiteral("cancel")].toBool(), false);
QJsonObject pathsObj = json[QStringLiteral("destinationPaths")].toObject();
QCOMPARE(pathsObj.size(), 2);
QCOMPARE(pathsObj[QStringLiteral("0")].toString(), QStringLiteral("/tmp/file0.txt"));
}
void TestDtoTypesExtended::testMulticastDtoVersionDefault()
{
LocalSend::MulticastDto dto;
dto.alias = QStringLiteral("Test");
dto.fingerprint = QStringLiteral("fp1");
QJsonObject json = dto.toJson();
QCOMPARE(json[QStringLiteral("version")].toString(), QString(LocalSend::PROTOCOL_VERSION));
}
void TestDtoTypesExtended::testFileDtoSizeLarge()
{
LocalSend::FileDto dto;
dto.id = QStringLiteral("big");
dto.fileName = QStringLiteral("big.iso");
dto.size = Q_INT64_C(4700000000);
dto.fileType = QStringLiteral("application/x-iso9660-image");
QJsonObject json = dto.toJson();
LocalSend::FileDto parsed = LocalSend::FileDto::fromJson(json);
QCOMPARE(parsed.size, Q_INT64_C(4700000000));
}
void TestDtoTypesExtended::testPrepareUploadRequestDtoRoundTrip()
{
LocalSend::PrepareUploadRequestDto dto;
dto.info.alias = QStringLiteral("Alice");
dto.info.fingerprint = QStringLiteral("fp-alice");
dto.info.port = 53317;
dto.info.protocol = LocalSend::ProtocolType::Https;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("photo.jpg");
f1.size = 3000000;
f1.fileType = QStringLiteral("image/jpeg");
dto.files.insert(QStringLiteral("0"), f1);
LocalSend::FileDto f2;
f2.id = QStringLiteral("1");
f2.fileName = QStringLiteral("notes.txt");
f2.size = 500;
f2.fileType = QStringLiteral("text/plain");
f2.hash = QStringLiteral("sha256abc");
dto.files.insert(QStringLiteral("1"), f2);
QJsonObject json = dto.toJson();
LocalSend::PrepareUploadRequestDto parsed = LocalSend::PrepareUploadRequestDto::fromJson(json);
QCOMPARE(parsed.info.alias, QStringLiteral("Alice"));
QCOMPARE(parsed.info.fingerprint, QStringLiteral("fp-alice"));
QCOMPARE(parsed.info.port, quint16(53317));
QCOMPARE(parsed.info.protocol, LocalSend::ProtocolType::Https);
QCOMPARE(parsed.files.size(), 2);
QCOMPARE(parsed.files[QStringLiteral("0")].fileName, QStringLiteral("photo.jpg"));
QCOMPARE(parsed.files[QStringLiteral("0")].size, qint64(3000000));
QCOMPARE(parsed.files[QStringLiteral("1")].hash, QStringLiteral("sha256abc"));
}
QTEST_MAIN(TestDtoTypesExtended)
#include "TestDtoTypesExtended.moc"

View File

@@ -0,0 +1,552 @@
#include <QtTest>
#include <LocalSendCore/SessionManager.h>
#include <LocalSendCore/Device.h>
class TestSessionManager : public QObject
{
Q_OBJECT
private slots:
void testCreateReceiveSession();
void testAcceptReceiveSession();
void testDeclineReceiveSession();
void testCancelReceiveSession();
void testReceiveProgress();
void testCompleteReceiveFile();
void testCompleteAllReceiveFiles();
void testFailReceiveFile();
void testCreateSendSession();
void testSetSendSessionTokens();
void testStartSendSession();
void testSendProgress();
void testCompleteSendFile();
void testCompleteAllSendFiles();
void testCancelSendSession();
void testHasSessionQueries();
void testGenerateToken();
void testDeclineRemovesSession();
void testSetSendSessionTokensRekeys();
};
void TestSessionManager::testCreateReceiveSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
sender.ip = QStringLiteral("10.0.0.1");
sender.alias = QStringLiteral("Sender");
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
LocalSend::FileDto f2;
f2.id = QStringLiteral("1");
f2.fileName = QStringLiteral("b.txt");
f2.size = 200;
files.insert(QStringLiteral("1"), f2);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionCreated);
QString sessionId = mgr.createReceiveSession(sender, files);
QVERIFY(!sessionId.isEmpty());
QCOMPARE(spy.count(), 1);
LocalSend::ReceiveSession session = mgr.receiveSession(sessionId);
QCOMPARE(session.sessionId, sessionId);
QCOMPARE(session.sender.alias, QStringLiteral("Sender"));
QCOMPARE(session.files.size(), 2);
QCOMPARE(session.status, LocalSend::SessionStatus::Waiting);
QVERIFY(!session.files[QStringLiteral("0")].token.isEmpty());
QVERIFY(!session.files[QStringLiteral("1")].token.isEmpty());
}
void TestSessionManager::testAcceptReceiveSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionAccepted);
QMap<QString, QString> destPaths;
destPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
mgr.acceptReceiveSession(sessionId, destPaths);
QCOMPARE(spy.count(), 1);
QList<QVariant> args = spy.takeFirst();
QCOMPARE(args.at(0).toString(), sessionId);
LocalSend::ReceiveSession session = mgr.receiveSession(sessionId);
QCOMPARE(session.status, LocalSend::SessionStatus::Sending);
QCOMPARE(session.files[QStringLiteral("0")].destinationPath, QStringLiteral("/tmp/a.txt"));
auto tokens = args.at(1).value<QMap<QString, QString>>();
QVERIFY(!tokens.value(QStringLiteral("0")).isEmpty());
}
void TestSessionManager::testDeclineReceiveSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionDeclined);
mgr.declineReceiveSession(sessionId);
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasReceiveSession(sessionId));
}
void TestSessionManager::testCancelReceiveSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QMap<QString, QString> destPaths;
destPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
mgr.acceptReceiveSession(sessionId, destPaths);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionCanceled);
mgr.cancelReceiveSession(sessionId);
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasReceiveSession(sessionId));
}
void TestSessionManager::testReceiveProgress()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 1000;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QMap<QString, QString> destPaths;
destPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
mgr.acceptReceiveSession(sessionId, destPaths);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveProgress);
mgr.updateReceiveProgress(sessionId, QStringLiteral("0"), 500);
QCOMPARE(spy.count(), 1);
double progress = spy.takeFirst().at(2).toDouble();
QVERIFY(qFuzzyCompare(progress, 0.5));
}
void TestSessionManager::testCompleteReceiveFile()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 1000;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QMap<QString, QString> destPaths;
destPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
mgr.acceptReceiveSession(sessionId, destPaths);
mgr.updateReceiveProgress(sessionId, QStringLiteral("0"), 1000);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionCompleted);
mgr.completeReceiveFile(sessionId, QStringLiteral("0"));
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasReceiveSession(sessionId));
}
void TestSessionManager::testCompleteAllReceiveFiles()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
LocalSend::FileDto f2;
f2.id = QStringLiteral("1");
f2.fileName = QStringLiteral("b.txt");
f2.size = 200;
files.insert(QStringLiteral("1"), f2);
QString sessionId = mgr.createReceiveSession(sender, files);
QMap<QString, QString> destPaths;
destPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
destPaths.insert(QStringLiteral("1"), QStringLiteral("/tmp/b.txt"));
mgr.acceptReceiveSession(sessionId, destPaths);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::receiveSessionCompleted);
mgr.completeReceiveFile(sessionId, QStringLiteral("0"));
QCOMPARE(spy.count(), 0);
QVERIFY(mgr.hasReceiveSession(sessionId));
mgr.completeReceiveFile(sessionId, QStringLiteral("1"));
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasReceiveSession(sessionId));
}
void TestSessionManager::testFailReceiveFile()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 1000;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
mgr.failReceiveFile(sessionId, QStringLiteral("0"));
LocalSend::ReceiveSession session = mgr.receiveSession(sessionId);
QCOMPARE(session.files[QStringLiteral("0")].status, LocalSend::FileStatus::Failed);
}
void TestSessionManager::testCreateSendSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
target.ip = QStringLiteral("10.0.0.2");
target.alias = QStringLiteral("Receiver");
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("c.txt");
f1.size = 500;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/home/user/c.txt"));
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendSessionCreated);
QString sessionId = mgr.createSendSession(target, files, localPaths);
QVERIFY(!sessionId.isEmpty());
QCOMPARE(spy.count(), 1);
LocalSend::SendSession session = mgr.sendSession(sessionId);
QCOMPARE(session.sessionId, sessionId);
QCOMPARE(session.target.alias, QStringLiteral("Receiver"));
QCOMPARE(session.files.size(), 1);
QCOMPARE(session.files[QStringLiteral("0")].localPath, QStringLiteral("/home/user/c.txt"));
QCOMPARE(session.status, LocalSend::SessionStatus::Waiting);
}
void TestSessionManager::testSetSendSessionTokens()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString origSessionId = mgr.createSendSession(target, files, localPaths);
QString responseSessionId = QStringLiteral("server-session-123");
QMap<QString, QString> tokens;
tokens.insert(QStringLiteral("0"), QStringLiteral("token-abc"));
mgr.setSendSessionTokens(origSessionId, responseSessionId, tokens);
QVERIFY(!mgr.hasSendSession(origSessionId));
QVERIFY(mgr.hasSendSession(responseSessionId));
LocalSend::SendSession session = mgr.sendSession(responseSessionId);
QCOMPARE(session.sessionId, responseSessionId);
QCOMPARE(session.files[QStringLiteral("0")].token, QStringLiteral("token-abc"));
}
void TestSessionManager::testStartSendSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString sessionId = mgr.createSendSession(target, files, localPaths);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendSessionStarted);
mgr.startSendSession(sessionId);
QCOMPARE(spy.count(), 1);
LocalSend::SendSession session = mgr.sendSession(sessionId);
QCOMPARE(session.status, LocalSend::SessionStatus::Sending);
}
void TestSessionManager::testSendProgress()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 2000;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString sessionId = mgr.createSendSession(target, files, localPaths);
mgr.startSendSession(sessionId);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendProgress);
mgr.updateSendProgress(sessionId, QStringLiteral("0"), 1000);
QCOMPARE(spy.count(), 1);
double progress = spy.takeFirst().at(2).toDouble();
QVERIFY(qFuzzyCompare(progress, 0.5));
}
void TestSessionManager::testCompleteSendFile()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 1000;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString sessionId = mgr.createSendSession(target, files, localPaths);
mgr.startSendSession(sessionId);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendSessionCompleted);
mgr.completeSendFile(sessionId, QStringLiteral("0"));
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasSendSession(sessionId));
}
void TestSessionManager::testCompleteAllSendFiles()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
LocalSend::FileDto f2;
f2.id = QStringLiteral("1");
f2.fileName = QStringLiteral("b.txt");
f2.size = 200;
files.insert(QStringLiteral("1"), f2);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
localPaths.insert(QStringLiteral("1"), QStringLiteral("/tmp/b.txt"));
QString sessionId = mgr.createSendSession(target, files, localPaths);
mgr.startSendSession(sessionId);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendSessionCompleted);
mgr.completeSendFile(sessionId, QStringLiteral("0"));
QCOMPARE(spy.count(), 0);
mgr.completeSendFile(sessionId, QStringLiteral("1"));
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasSendSession(sessionId));
}
void TestSessionManager::testCancelSendSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString sessionId = mgr.createSendSession(target, files, localPaths);
mgr.startSendSession(sessionId);
QSignalSpy spy(&mgr, &LocalSend::SessionManager::sendSessionCanceled);
mgr.cancelSendSession(sessionId);
QCOMPARE(spy.count(), 1);
QVERIFY(!mgr.hasSendSession(sessionId));
}
void TestSessionManager::testHasSessionQueries()
{
LocalSend::SessionManager mgr;
QVERIFY(!mgr.hasReceiveSession(QStringLiteral("nonexistent")));
QVERIFY(!mgr.hasSendSession(QStringLiteral("nonexistent")));
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QString recvSessionId = mgr.createReceiveSession(sender, files);
QVERIFY(mgr.hasReceiveSession(recvSessionId));
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
QString sendSessionId = mgr.createSendSession(sender, files, localPaths);
QVERIFY(mgr.hasSendSession(sendSessionId));
LocalSend::ReceiveSession emptyRecv = mgr.receiveSession(QStringLiteral("nope"));
QVERIFY(emptyRecv.sessionId.isEmpty());
LocalSend::SendSession emptySend = mgr.sendSession(QStringLiteral("nope"));
QVERIFY(emptySend.sessionId.isEmpty());
}
void TestSessionManager::testGenerateToken()
{
LocalSend::SessionManager mgr;
QString token1 = mgr.generateToken();
QString token2 = mgr.generateToken();
QVERIFY(!token1.isEmpty());
QVERIFY(!token2.isEmpty());
QVERIFY(token1 != token2);
}
void TestSessionManager::testDeclineRemovesSession()
{
LocalSend::SessionManager mgr;
LocalSend::Device sender;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
QString sessionId = mgr.createReceiveSession(sender, files);
QVERIFY(mgr.hasReceiveSession(sessionId));
mgr.declineReceiveSession(sessionId);
QVERIFY(!mgr.hasReceiveSession(sessionId));
}
void TestSessionManager::testSetSendSessionTokensRekeys()
{
LocalSend::SessionManager mgr;
LocalSend::Device target;
QMap<QString, LocalSend::FileDto> files;
LocalSend::FileDto f1;
f1.id = QStringLiteral("0");
f1.fileName = QStringLiteral("a.txt");
f1.size = 100;
files.insert(QStringLiteral("0"), f1);
LocalSend::FileDto f2;
f2.id = QStringLiteral("1");
f2.fileName = QStringLiteral("b.txt");
f2.size = 200;
files.insert(QStringLiteral("1"), f2);
QMap<QString, QString> localPaths;
localPaths.insert(QStringLiteral("0"), QStringLiteral("/tmp/a.txt"));
localPaths.insert(QStringLiteral("1"), QStringLiteral("/tmp/b.txt"));
QString origId = mgr.createSendSession(target, files, localPaths);
QString newId = QStringLiteral("response-session-id");
QMap<QString, QString> tokens;
tokens.insert(QStringLiteral("0"), QStringLiteral("tok0"));
tokens.insert(QStringLiteral("1"), QStringLiteral("tok1"));
mgr.setSendSessionTokens(origId, newId, tokens);
QVERIFY(!mgr.hasSendSession(origId));
QVERIFY(mgr.hasSendSession(newId));
LocalSend::SendSession session = mgr.sendSession(newId);
QCOMPARE(session.sessionId, newId);
QCOMPARE(session.files[QStringLiteral("0")].token, QStringLiteral("tok0"));
QCOMPARE(session.files[QStringLiteral("1")].token, QStringLiteral("tok1"));
QCOMPARE(session.files[QStringLiteral("0")].localPath, QStringLiteral("/tmp/a.txt"));
QCOMPARE(session.files[QStringLiteral("1")].localPath, QStringLiteral("/tmp/b.txt"));
}
QTEST_MAIN(TestSessionManager)
#include "TestSessionManager.moc"

82
tests/core/TestTypes.cpp Normal file
View File

@@ -0,0 +1,82 @@
#include <QtTest>
#include <LocalSendCore/Types.h>
class TestTypes : public QObject
{
Q_OBJECT
private slots:
void testDeviceTypeToString();
void testDeviceTypeFromString();
void testDeviceTypeRoundTrip();
void testDeviceTypeFromStringUnknown();
void testProtocolTypeToString();
void testProtocolTypeFromString();
void testProtocolTypeRoundTrip();
void testProtocolTypeFromStringUnknown();
};
void TestTypes::testDeviceTypeToString()
{
QCOMPARE(LocalSend::deviceTypeToString(LocalSend::DeviceType::Mobile), QStringLiteral("mobile"));
QCOMPARE(LocalSend::deviceTypeToString(LocalSend::DeviceType::Desktop), QStringLiteral("desktop"));
QCOMPARE(LocalSend::deviceTypeToString(LocalSend::DeviceType::Web), QStringLiteral("web"));
QCOMPARE(LocalSend::deviceTypeToString(LocalSend::DeviceType::Headless), QStringLiteral("headless"));
QCOMPARE(LocalSend::deviceTypeToString(LocalSend::DeviceType::Server), QStringLiteral("server"));
}
void TestTypes::testDeviceTypeFromString()
{
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("mobile")), LocalSend::DeviceType::Mobile);
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("desktop")), LocalSend::DeviceType::Desktop);
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("web")), LocalSend::DeviceType::Web);
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("headless")), LocalSend::DeviceType::Headless);
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("server")), LocalSend::DeviceType::Server);
}
void TestTypes::testDeviceTypeRoundTrip()
{
const QList<LocalSend::DeviceType> types = {
LocalSend::DeviceType::Mobile,
LocalSend::DeviceType::Desktop,
LocalSend::DeviceType::Web,
LocalSend::DeviceType::Headless,
LocalSend::DeviceType::Server,
};
for (const auto& type : types) {
QCOMPARE(LocalSend::deviceTypeFromString(LocalSend::deviceTypeToString(type)), type);
}
}
void TestTypes::testDeviceTypeFromStringUnknown()
{
QCOMPARE(LocalSend::deviceTypeFromString(QStringLiteral("unknown")), LocalSend::DeviceType::Desktop);
QCOMPARE(LocalSend::deviceTypeFromString(QString()), LocalSend::DeviceType::Desktop);
}
void TestTypes::testProtocolTypeToString()
{
QCOMPARE(LocalSend::protocolTypeToString(LocalSend::ProtocolType::Http), QStringLiteral("http"));
QCOMPARE(LocalSend::protocolTypeToString(LocalSend::ProtocolType::Https), QStringLiteral("https"));
}
void TestTypes::testProtocolTypeFromString()
{
QCOMPARE(LocalSend::protocolTypeFromString(QStringLiteral("http")), LocalSend::ProtocolType::Http);
QCOMPARE(LocalSend::protocolTypeFromString(QStringLiteral("https")), LocalSend::ProtocolType::Https);
}
void TestTypes::testProtocolTypeRoundTrip()
{
QCOMPARE(LocalSend::protocolTypeFromString(LocalSend::protocolTypeToString(LocalSend::ProtocolType::Http)), LocalSend::ProtocolType::Http);
QCOMPARE(LocalSend::protocolTypeFromString(LocalSend::protocolTypeToString(LocalSend::ProtocolType::Https)), LocalSend::ProtocolType::Https);
}
void TestTypes::testProtocolTypeFromStringUnknown()
{
QCOMPARE(LocalSend::protocolTypeFromString(QStringLiteral("ftp")), LocalSend::ProtocolType::Http);
QCOMPARE(LocalSend::protocolTypeFromString(QString()), LocalSend::ProtocolType::Http);
}
QTEST_MAIN(TestTypes)
#include "TestTypes.moc"