syncthingtray/syncthingconnector/syncthingconnection.cpp
Martchus ecece11df3 Introduce basic test setup for models
This test setup utilizes the "mocked" configuration so far only used for
manual testing. This allows adding unit tests for models without having to
run Syncthing itself. The test is pretty basic itself at this point.
2024-04-26 23:04:47 +02:00

1310 lines
47 KiB
C++

#include "./syncthingconnection.h"
#include "./syncthingconfig.h"
#include "./syncthingconnectionsettings.h"
#include "./utils.h"
#ifdef LIB_SYNCTHING_CONNECTOR_CONNECTION_MOCKED
#include "./syncthingconnectionmockhelpers.h"
#endif
#include "resources/config.h"
#include <c++utilities/conversion/stringconversion.h>
#include <c++utilities/io/ansiescapecodes.h>
#include <QAuthenticator>
#include <QDir>
#include <QFileInfo>
#include <QHostAddress>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonValue>
#include <QNetworkAccessManager>
#include <QNetworkInterface>
#include <QNetworkReply>
#include <QStringBuilder>
#include <QTimer>
#if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0))
#include <QNetworkInformation>
#define SYNCTHINGCONNECTION_SUPPORT_METERED
#endif
#include <iostream>
#include <utility>
using namespace std;
using namespace CppUtilities;
using namespace CppUtilities::EscapeCodes;
namespace Data {
#ifndef LIB_SYNCTHING_CONNECTOR_MOCKED
/*!
* \brief Returns the QNetworkAccessManager instance used by SyncthingConnection instances.
*/
QNetworkAccessManager &networkAccessManager()
{
static auto networkAccessManager = new QNetworkAccessManager;
return *networkAccessManager;
}
#endif
/*!
* \class SyncthingConnection
* \brief The SyncthingConnection class allows Qt applications to access Syncthing via its REST API.
* \remarks All requests are performed asynchronously.
*
* The first thing to do when working with that class is setting the URL to connect to and the API key
* via the constructor or setSyncthingUrl() and setApiKey(). Credentials for the HTTP authentication
* can be set via setCredentials() if not included in the URL.
*
* Requests can then be done via the request...() methods, eg. requestConfig(). This would emit the
* newConfig() signal on success and the error() signal when an error occurred. The other request...()
* methods work in a similar way.
*
* However, usually it is best to simply call the connect() method. It will do all required requests
* to populate dirInfo(), devInfo(), myId(), totalIncomingTraffic(), totalOutgoingTraffic() and all
* the other variables. It will also use Syncthing's event API to listen for changes. The signals
* newDirs() and newDevs() are can be used to know when dirInfo() and devInfo() become available.
* Note that in this case the previous dirInfo()/devInfo() is invalidated.
*/
/*!
* \brief Constructs a new instance ready to connect. To establish the connection, call connect().
*/
SyncthingConnection::SyncthingConnection(
const QString &syncthingUrl, const QByteArray &apiKey, SyncthingConnectionLoggingFlags loggingFlags, QObject *parent)
: QObject(parent)
, m_syncthingUrl(syncthingUrl)
, m_apiKey(apiKey)
, m_status(SyncthingStatus::Disconnected)
, m_statusComputionFlags(SyncthingStatusComputionFlags::Default)
, m_loggingFlags(SyncthingConnectionLoggingFlags::None)
, m_loggingFlagsHandler(SyncthingConnectionLoggingFlags::None)
, m_keepPolling(false)
, m_abortingAllRequests(false)
, m_connectionAborted(false)
, m_abortingToReconnect(false)
, m_requestCompletion(true)
, m_lastEventId(0)
, m_lastDiskEventId(0)
, m_autoReconnectTries(0)
, m_requestTimeout(SyncthingConnectionSettings::defaultRequestTimeout)
, m_longPollingTimeout(SyncthingConnectionSettings::defaultLongPollingTimeout)
, m_totalIncomingTraffic(unknownTraffic)
, m_totalOutgoingTraffic(unknownTraffic)
, m_totalIncomingRate(0.0)
, m_totalOutgoingRate(0.0)
, m_configReply(nullptr)
, m_statusReply(nullptr)
, m_connectionsReply(nullptr)
, m_errorsReply(nullptr)
, m_dirStatsReply(nullptr)
, m_devStatsReply(nullptr)
, m_eventsReply(nullptr)
, m_versionReply(nullptr)
, m_diskEventsReply(nullptr)
, m_logReply(nullptr)
, m_unreadNotifications(false)
, m_hasConfig(false)
, m_hasStatus(false)
, m_hasEvents(false)
, m_hasDiskEvents(false)
, m_lastFileDeleted(false)
, m_dirStatsAltered(false)
, m_recordFileChanges(false)
, m_useDeprecatedRoutes(true)
, m_pausingOnMeteredConnection(false)
#ifdef SYNCTHINGCONNECTION_SUPPORT_METERED
, m_handlingMeteredConnectionInitialized(false)
#endif
{
m_trafficPollTimer.setInterval(SyncthingConnectionSettings::defaultTrafficPollInterval);
m_trafficPollTimer.setTimerType(Qt::VeryCoarseTimer);
m_trafficPollTimer.setSingleShot(true);
QObject::connect(&m_trafficPollTimer, &QTimer::timeout, this, &SyncthingConnection::requestConnections);
m_devStatsPollTimer.setInterval(SyncthingConnectionSettings::defaultDevStatusPollInterval);
m_devStatsPollTimer.setTimerType(Qt::VeryCoarseTimer);
m_devStatsPollTimer.setSingleShot(true);
QObject::connect(&m_devStatsPollTimer, &QTimer::timeout, this, &SyncthingConnection::requestDeviceStatistics);
m_errorsPollTimer.setInterval(SyncthingConnectionSettings::defaultErrorsPollInterval);
m_errorsPollTimer.setTimerType(Qt::VeryCoarseTimer);
m_errorsPollTimer.setSingleShot(true);
QObject::connect(&m_errorsPollTimer, &QTimer::timeout, this, &SyncthingConnection::requestErrors);
m_autoReconnectTimer.setTimerType(Qt::VeryCoarseTimer);
m_autoReconnectTimer.setInterval(SyncthingConnectionSettings::defaultReconnectInterval);
QObject::connect(&m_autoReconnectTimer, &QTimer::timeout, this, &SyncthingConnection::autoReconnect);
#ifdef LIB_SYNCTHING_CONNECTOR_CONNECTION_MOCKED
setupTestData();
#endif
setLoggingFlags(loggingFlags);
// allow initializing the default value for m_useDeprecatedRoutes via environment variable
auto useDeprecatedRoutesIsInt = false;
auto useDeprecatedRoutesInt = qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_USE_DEPRECATED_ROUTES", &useDeprecatedRoutesIsInt);
if (useDeprecatedRoutesIsInt) {
m_useDeprecatedRoutes = useDeprecatedRoutesInt;
}
}
/*!
* \brief Destroys the instance. Ongoing requests are aborted.
*/
SyncthingConnection::~SyncthingConnection()
{
m_status = SyncthingStatus::BeingDestroyed;
disconnect();
}
/*!
* \brief Returns whether the currently assigned syncthingUrl() refers to the Syncthing instance on the local machine.
*/
bool SyncthingConnection::isLocal() const
{
return ::Data::isLocal(QUrl(m_syncthingUrl));
}
/*!
* \brief Returns the string representation of the specified \a status.
*/
QString SyncthingConnection::statusText(SyncthingStatus status)
{
switch (status) {
case SyncthingStatus::Disconnected:
return tr("disconnected");
case SyncthingStatus::Reconnecting:
return tr("reconnecting");
case SyncthingStatus::Idle:
return tr("connected");
case SyncthingStatus::Scanning:
return tr("connected, scanning");
case SyncthingStatus::Paused:
return tr("connected, paused");
case SyncthingStatus::Synchronizing:
return tr("connected, synchronizing");
case SyncthingStatus::RemoteNotInSync:
return tr("connected, remote not in sync");
default:
return tr("unknown");
}
}
/*!
* \brief Sets the specified logging \a flags.
*/
void SyncthingConnection::setLoggingFlags(SyncthingConnectionLoggingFlags flags)
{
m_loggingFlags = flags;
if (flags & SyncthingConnectionLoggingFlags::FromEnvironment) {
if (!(flags & SyncthingConnectionLoggingFlags::All) && qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_LOG_ALL")) {
m_loggingFlags |= SyncthingConnectionLoggingFlags::All;
} else {
if (!(flags & SyncthingConnectionLoggingFlags::ApiCalls) && qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_LOG_API_CALLS")) {
m_loggingFlags |= SyncthingConnectionLoggingFlags::ApiCalls;
}
if (!(flags & SyncthingConnectionLoggingFlags::ApiCalls) && qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_LOG_API_REPLIES")) {
m_loggingFlags |= SyncthingConnectionLoggingFlags::ApiCalls;
}
if (!(flags & SyncthingConnectionLoggingFlags::Events) && qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_LOG_EVENTS")) {
m_loggingFlags |= SyncthingConnectionLoggingFlags::Events;
}
if (!(flags & SyncthingConnectionLoggingFlags::DirsOrDevsResetted)
&& qEnvironmentVariableIntValue(PROJECT_VARNAME_UPPER "_LOG_DIRS_OR_DEVS_RESETTED")) {
m_loggingFlags |= SyncthingConnectionLoggingFlags::Events;
}
}
}
if ((m_loggingFlags & SyncthingConnectionLoggingFlags::DirsOrDevsResetted)
&& !(m_loggingFlagsHandler & SyncthingConnectionLoggingFlags::DirsOrDevsResetted)) {
QObject::connect(this, &SyncthingConnection::newDirs, [this](const auto &dirs) {
if (m_loggingFlags & SyncthingConnectionLoggingFlags::DirsOrDevsResetted) {
std::cerr << Phrases::Info << "Folder list renewed:" << Phrases::End;
std::cerr << displayNames(dirs).join(QStringLiteral(", ")).toStdString() << endl;
}
});
QObject::connect(this, &SyncthingConnection::newDevices, [this](const auto &devs) {
if (m_loggingFlags & SyncthingConnectionLoggingFlags::DirsOrDevsResetted) {
std::cerr << Phrases::Info << "Device list renewed:" << Phrases::End;
std::cerr << displayNames(devs).join(QStringLiteral(", ")).toStdString() << endl;
}
});
m_loggingFlagsHandler |= SyncthingConnectionLoggingFlags::DirsOrDevsResetted;
}
}
/*!
* \brief Returns whether there is at least one directory out-of-sync.
*/
bool SyncthingConnection::hasOutOfSyncDirs() const
{
for (const SyncthingDir &dir : m_dirs) {
if (dir.status == SyncthingDirStatus::OutOfSync) {
return true;
}
}
return false;
}
/*!
* \brief Sets all polling intervals (traffic, device statistics, errors) to 0 so polling is disabled.
* \remarks Does not affect the auto-reconnect and does not affect the *long* polling for the event API.
*/
void SyncthingConnection::disablePolling()
{
setTrafficPollInterval(0);
setDevStatsPollInterval(0);
setErrorsPollInterval(0);
}
/*!
* \brief Sets whether to pause all devices on metered connections.
*/
void SyncthingConnection::setPausingOnMeteredConnection(bool pausingOnMeteredConnection)
{
if (m_pausingOnMeteredConnection != pausingOnMeteredConnection) {
if ((m_pausingOnMeteredConnection = pausingOnMeteredConnection)) {
// initialize handling of metered connections
#ifdef SYNCTHINGCONNECTION_SUPPORT_METERED
if (!m_handlingMeteredConnectionInitialized) {
if (const auto *const networkInformation = loadNetworkInformationBackendForMetered()) {
QObject::connect(networkInformation, &QNetworkInformation::isMeteredChanged, this, &SyncthingConnection::handleMeteredConnection);
}
}
#endif
}
if (m_hasConfig) {
handleMeteredConnection();
}
}
}
/*!
* \brief Ensures that devices are paused/resumed depending on whether the network connection is metered.
*/
void SyncthingConnection::handleMeteredConnection()
{
#ifdef SYNCTHINGCONNECTION_SUPPORT_METERED
const auto *const networkInformation = QNetworkInformation::instance();
if (!networkInformation || !networkInformation->supports(QNetworkInformation::Feature::Metered)) {
return;
}
if (networkInformation->isMetered() && m_pausingOnMeteredConnection) {
auto hasDevicesToPause = false;
m_devsPausedDueToMeteredConnection.reserve(static_cast<qsizetype>(m_devs.size()));
for (const auto &device : m_devs) {
if (!device.paused && device.status != SyncthingDevStatus::ThisDevice) {
if (!m_devsPausedDueToMeteredConnection.contains(device.id)) {
m_devsPausedDueToMeteredConnection << device.id;
hasDevicesToPause = true;
}
}
}
if (hasDevicesToPause) {
pauseResumeDevice(m_devsPausedDueToMeteredConnection, true, true);
}
} else {
pauseResumeDevice(m_devsPausedDueToMeteredConnection, false, true);
m_devsPausedDueToMeteredConnection.clear();
}
#endif
}
/*!
* \brief Connects asynchronously to Syncthing. Does nothing if already connected.
*
* Use this to connect the first time or to connect to the same Syncthing instance again or to ensure
* the connection to the currently configured instance is established. Use reconnect() to connect to
* a different instance.
*
* \remarks Does not clear data from a previous connection (except error items). Use reconnect() if that
* is required.
*/
void SyncthingConnection::connect()
{
// reset auto-reconnect
m_autoReconnectTimer.stop();
m_autoReconnectTries = 0;
// skip if already connected (see reconnect() to force reconnecting)
if (isConnected()) {
return;
}
// reset status
m_connectionAborted = m_abortingToReconnect = m_hasConfig = m_hasStatus = m_hasEvents = m_hasDiskEvents = false;
// check configuration
if (m_apiKey.isEmpty() || m_syncthingUrl.isEmpty()) {
emit error(tr("Connection configuration is insufficient."), SyncthingErrorCategory::OverallConnection, QNetworkReply::NoError);
setStatus(SyncthingStatus::Disconnected);
return;
}
// start by requesting config and status; if both are available request further info and events
requestConfig();
requestStatus();
m_keepPolling = true;
}
/*!
* \brief Applies the specified configuration and tries to reconnect via reconnect() if properties requiring reconnect
* to take effect have changed.
* \remarks The expected SSL errors of the specified configuration are updated accordingly.
*/
void SyncthingConnection::connect(SyncthingConnectionSettings &connectionSettings)
{
if (applySettings(connectionSettings) || !isConnected()) {
reconnect();
}
}
/*!
* \brief Connects in \a milliSeconds. Useful to "schedule" another attempt in case of a failure.
* \remarks Does nothing if the connection attempt would happen anyways though auto-reconnect.
*/
void SyncthingConnection::connectLater(int milliSeconds)
{
// skip if conneting via auto-reconnect anyways
if (m_autoReconnectTimer.isActive() && milliSeconds > m_autoReconnectTimer.interval()) {
return;
}
QTimer::singleShot(milliSeconds, this, static_cast<void (SyncthingConnection::*)(void)>(&SyncthingConnection::connect));
}
/*!
* \brief Disconnects. That means all (long) polling is stopped and ongoing requests are aborted via abortAllRequests().
*/
void SyncthingConnection::disconnect()
{
m_abortingToReconnect = m_keepPolling = false;
m_trafficPollTimer.stop();
m_devStatsPollTimer.stop();
m_errorsPollTimer.stop();
m_autoReconnectTimer.stop();
m_autoReconnectTries = 0;
abortAllRequests();
}
/*!
* \brief Aborts the specified \a reply if it is not nullptr.
*/
static inline void abortMaybe(QNetworkReply *reply)
{
if (reply) {
reply->abort();
}
}
/*!
* \brief Aborts status-relevant, pending requests.
* \remarks Status-relevant means that requests for triggering actions like rescan() or restart() are excluded. requestQrCode() does not
* contribute to the status as well and is excluded as well.
*/
void SyncthingConnection::abortAllRequests()
{
m_connectionAborted = m_abortingAllRequests = true;
abortMaybe(m_configReply);
abortMaybe(m_statusReply);
abortMaybe(m_connectionsReply);
abortMaybe(m_errorsReply);
abortMaybe(m_dirStatsReply);
abortMaybe(m_devStatsReply);
abortMaybe(m_eventsReply);
abortMaybe(m_versionReply);
abortMaybe(m_diskEventsReply);
abortMaybe(m_logReply);
abortMaybe(m_configReply);
abortMaybe(m_configReply);
for (auto *const reply : std::as_const(m_otherReplies)) {
reply->abort();
}
m_abortingAllRequests = false;
handleAdditionalRequestCanceled();
}
/*!
* \brief Disconnects if connected, then (re-)connects asynchronously.
* \remarks
* - Clears the currently cached configuration.
* - This explicit request to reconnect will reset the autoReconnectTries().
*/
void SyncthingConnection::reconnect()
{
// reset reconnect timer
m_autoReconnectTimer.stop();
m_autoReconnectTries = 0;
// stop other timers
m_trafficPollTimer.stop();
m_devStatsPollTimer.stop();
m_errorsPollTimer.stop();
// reset variables to track connection progress
// note: especially resetting events is important as it influences the subsequent hasPendingRequests() call
m_hasConfig = m_hasStatus = m_hasEvents = m_hasDiskEvents = false;
// reconnect right now if no pending requests to be aborted
if (!hasPendingRequests()) {
continueReconnecting();
return;
}
// abort pending requests before connecting again
m_keepPolling = m_abortingToReconnect = true;
abortAllRequests();
}
/*!
* \brief Applies the specified configuration and tries to reconnect via reconnect().
* \remarks The expected SSL errors of the specified configuration are updated accordingly.
*/
void SyncthingConnection::reconnect(SyncthingConnectionSettings &connectionSettings)
{
applySettings(connectionSettings);
reconnect();
}
/*!
* \brief Reconnects after the specified number of \a milliSeconds.
*/
void SyncthingConnection::reconnectLater(int milliSeconds)
{
QTimer::singleShot(milliSeconds, this, static_cast<void (SyncthingConnection::*)(void)>(&SyncthingConnection::reconnect));
}
/*!
* \brief Internally called to reconnect; ensures currently cached config is cleared.
*/
void SyncthingConnection::continueReconnecting()
{
// notify that we're about to invalidate the configuration if not already invalidated anyways
const auto isConfigInvalidated = m_rawConfig.isEmpty();
if (!isConfigInvalidated) {
emit newConfig(m_rawConfig = QJsonObject());
}
// cleanup information from previous connection
m_keepPolling = true;
m_connectionAborted = false;
m_abortingToReconnect = false;
m_lastEventId = 0;
m_lastDiskEventId = 0;
m_configDir.clear();
m_myId.clear();
m_tilde.clear();
m_pathSeparator.clear();
m_totalIncomingTraffic = unknownTraffic;
m_totalOutgoingTraffic = unknownTraffic;
m_totalIncomingRate = 0.0;
m_totalOutgoingRate = 0.0;
emit trafficChanged(unknownTraffic, unknownTraffic);
m_unreadNotifications = false;
m_hasConfig = false;
m_hasStatus = false;
m_hasEvents = false;
m_hasDiskEvents = false;
m_dirs.clear();
m_devs.clear();
m_devsPausedDueToMeteredConnection.clear();
m_lastConnectionsUpdateEvent = 0;
m_lastConnectionsUpdateTime = DateTime();
m_lastFileEvent = 0;
m_lastFileTime = DateTime();
m_lastErrorTime = DateTime();
m_startTime = DateTime();
m_lastFileName.clear();
m_lastFileDeleted = false;
m_syncthingVersion.clear();
m_dirStatsAltered = false;
emit dirStatisticsChanged();
// notify that the configuration has been invalidated
if (!isConfigInvalidated) {
emit newConfigApplied();
}
if (m_apiKey.isEmpty() || m_syncthingUrl.isEmpty()) {
emit error(tr("Connection configuration is insufficient."), SyncthingErrorCategory::OverallConnection, QNetworkReply::NoError);
setStatus(SyncthingStatus::Disconnected);
return;
}
requestConfig();
requestStatus();
setStatus(SyncthingStatus::Reconnecting);
}
/*!
* \brief Reads results of requestConfig() and requestStatus().
* \remarks Called in readConfig() or readStatus() to conclude reading parts requiring config *and* status
* being available. Does nothing if this is not the case (yet).
*/
void SyncthingConnection::concludeReadingConfigAndStatus()
{
if (!m_hasConfig || !m_hasStatus) {
return;
}
readDevs(m_rawConfig.value(QLatin1String("devices")).toArray());
readDirs(m_rawConfig.value(QLatin1String("folders")).toArray());
emit newConfigApplied();
continueConnecting();
}
/*!
* \brief Sets the state from (re)connecting to Syncthing's actual state if polling but there are no more pending requests.
* \remarks Called by read...() handlers for requests started in continueConnecting().
* \sa hasPendingRequests()
*/
void SyncthingConnection::concludeConnection()
{
if (!m_keepPolling || hasPendingRequests()) {
return;
}
setStatus(SyncthingStatus::Idle);
emitDirStatisticsChanged();
}
/*!
* \brief Connects increasing the auto-reconnect tries.
* \remarks Called via m_autoReconnectTimer.
*/
void SyncthingConnection::autoReconnect()
{
const auto tmp = m_autoReconnectTries;
connect();
m_autoReconnectTries = tmp + 1;
}
/*!
* \brief Returns the directory info object for the directory with the specified ID.
* \returns Returns a pointer to the object or nullptr if not found.
* \remarks The returned object becomes invalid when the newDirs() signal is emitted or the connection is destroyed.
*/
SyncthingDir *SyncthingConnection::findDirInfo(const QString &dirId, int &row)
{
row = 0;
for (SyncthingDir &d : m_dirs) {
if (d.id == dirId) {
return &d;
}
++row;
}
return nullptr;
}
/*!
* \brief Returns the directory info object for the directory with the specified ID or label.
* \returns Returns a pointer to the object or nullptr if not found.
* \remarks
* - IDs have precedence, labels are checked as fallback.
* - The returned object becomes invalid when the newDirs() signal is emitted or the connection is destroyed.
*/
SyncthingDir *SyncthingConnection::findDirInfoConsideringLabels(const QString &dirIdOrLabel, int &row)
{
if (auto *const dir = findDirInfo(dirIdOrLabel, row)) {
return dir;
}
row = 0;
for (SyncthingDir &d : m_dirs) {
if (d.label == dirIdOrLabel) {
return &d;
}
++row;
}
return nullptr;
}
/*!
* \brief Returns the directory info object for the directory with the ID stored in the specified \a object with the specified \a key.
*/
SyncthingDir *SyncthingConnection::findDirInfo(QLatin1String key, const QJsonObject &object, int *row)
{
const auto dirId(object.value(key).toString());
if (dirId.isEmpty()) {
return nullptr;
}
int dummyRow;
auto &rowRef(row ? *row : dummyRow);
return findDirInfo(dirId, rowRef);
}
/*!
* \brief Returns the directory info object for the directory with the specified \a path.
*
* If a corresponding Syncthing directory could be found, \a relativePath is set to the path of the item relative
* to the location of the corresponding Syncthing directory.
*
* \returns Returns a pointer to the object or nullptr if not found.
* \remarks The returned object becomes invalid when the newDirs() signal is emitted or the connection is destroyed.
*/
SyncthingDir *SyncthingConnection::findDirInfoByPath(const QString &path, QString &relativePath, int &row)
{
row = 0;
const auto cleanPath = QDir::cleanPath(path);
for (SyncthingDir &dir : m_dirs) {
const auto dirCleanPath = QDir::cleanPath(dir.path);
if (cleanPath == dirCleanPath) {
relativePath.clear();
return &dir;
} else if (cleanPath.startsWith(dirCleanPath)) {
relativePath = cleanPath.mid(dirCleanPath.size());
return &dir;
}
++row;
}
return nullptr;
}
/*!
* \brief Appends a directory info object with the specified \a dirId to \a dirs.
*
* If such an object already exists, it is recycled by moving it to \a dirs.
* Otherwise a new, empty object is created.
*
* \returns Returns the directory info object or nullptr if \a dirId is invalid.
*/
SyncthingDir *SyncthingConnection::addDirInfo(std::vector<SyncthingDir> &dirs, const QString &dirId)
{
if (dirId.isEmpty()) {
return nullptr;
}
int row;
if (auto *const existingDirInfo = findDirInfo(dirId, row)) {
return &dirs.emplace_back(std::move(*existingDirInfo));
} else {
return &dirs.emplace_back(dirId);
}
}
/*!
* \brief Returns the device info object for the device with the specified ID.
* \returns Returns a pointer to the object or nullptr if not found.
* \remarks The returned object becomes invalid when the newConfig() signal is emitted or the connection is destroyed.
*/
SyncthingDev *SyncthingConnection::findDevInfo(const QString &devId, int &row)
{
row = 0;
for (SyncthingDev &d : m_devs) {
if (d.id == devId) {
return &d;
}
++row;
}
return nullptr;
}
/*!
* \brief Returns the device info object for the first device with the specified name.
* \returns Returns a pointer to the object or nullptr if not found.
* \remarks The returned object becomes invalid when the newConfig() signal is emitted or the connection is destroyed.
*/
SyncthingDev *SyncthingConnection::findDevInfoByName(const QString &devName, int &row)
{
row = 0;
for (SyncthingDev &d : m_devs) {
if (d.name == devName) {
return &d;
}
++row;
}
return nullptr;
}
/*!
* \brief Returns all directory IDs for the current configuration.
* \remarks Computed by looping dirInfo().
*/
QStringList SyncthingConnection::directoryIds() const
{
return ids(m_dirs);
}
/*!
* \brief Returns all device IDs for the current configuration.
* \remarks Computed by looping devInfo().
*/
QStringList SyncthingConnection::deviceIds() const
{
return ids(m_devs);
}
/*!
* \brief Returns the device name for the specified \a deviceId if known; otherwise returns just the \a deviceId itself.
*/
QString SyncthingConnection::deviceNameOrId(const QString &deviceId) const
{
for (const auto &dev : devInfo()) {
if (dev.id == deviceId) {
return dev.name;
}
}
return deviceId;
}
/*!
* \brief Returns the number of devices Syncthing is currently connected to.
* \remarks Computed by looping devInfo().
*/
std::vector<const SyncthingDev *> SyncthingConnection::connectedDevices() const
{
std::vector<const SyncthingDev *> connectedDevs;
connectedDevs.reserve(devInfo().size());
for (const SyncthingDev &dev : devInfo()) {
if (dev.isConnected()) {
connectedDevs.emplace_back(&dev);
}
}
return connectedDevs;
}
/*!
* \brief Appends a device info object with the specified \a devId to \a devs.
*
* If such an object already exists, it is recycled by moving it to \a devs.
* Otherwise a new, empty object is created.
*
* \returns Returns the device info object or nullptr if \a devId is invalid.
*/
SyncthingDev *SyncthingConnection::addDevInfo(std::vector<SyncthingDev> &devs, const QString &devId)
{
if (devId.isEmpty()) {
return nullptr;
}
int row;
if (SyncthingDev *const existingDevInfo = findDevInfo(devId, row)) {
return &devs.emplace_back(std::move(*existingDevInfo));
} else {
return &devs.emplace_back(devId);
}
}
/*!
* \brief Internally called to parse a time stamp.
*/
DateTime SyncthingConnection::parseTimeStamp(const QJsonValue &jsonValue, const QString &context, DateTime defaultValue, bool greaterThanEpoch)
{
const auto utf16 = jsonValue.toString();
const auto utf8 = utf16.toUtf8();
try {
const auto [localTime, utcOffset] = DateTime::fromIsoString(utf8.data());
return !greaterThanEpoch || (localTime - utcOffset) > DateTime::unixEpochStart() ? localTime : defaultValue;
} catch (const ConversionException &e) {
emit error(tr("Unable to parse timestamp \"%1\" (%2): %3").arg(utf16, context, QString::fromUtf8(e.what())), SyncthingErrorCategory::Parsing,
QNetworkReply::NoError);
}
return defaultValue;
}
/*!
* \brief Continues connecting if both - config and status - have been parsed yet and continuous polling is enabled.
*/
void SyncthingConnection::continueConnecting()
{
// skip if config and status are missing or we're not supposed to actually connect
if (!m_keepPolling || !m_hasConfig || !m_hasStatus) {
return;
}
// read additional information (beside config and status)
// FIXME: make those requests configurable (eg. flag enum)
requestConnections();
requestDirStatistics();
requestDeviceStatistics();
requestErrors();
requestVersion();
for (const SyncthingDir &dir : m_dirs) {
requestDirStatus(dir.id);
if (!m_requestCompletion || dir.paused) {
continue;
}
for (const QString &devId : dir.deviceIds) {
requestCompletion(devId, dir.id);
}
}
// poll for events
requestEvents();
requestDiskEvents();
}
/*!
* \brief Locates and loads the (self-signed) certificate used by the Syncthing GUI.
* \remarks
* - Ensures any previous certificates are cleared in any case.
* - Emits error() when an error occurs.
* - Loading the certificate is only possible if the connection object is configured
* to connect to the locally running Syncthing instance. Otherwise this method will
* only do the cleanup of previous certificates but not emit any errors.
* - This function uses m_certificatePath which is set by applySettings() if the user
* specified a certificate path manually. Otherwise the path is detected automatically
* and stored in m_dynamicallyDeterminedCertificatePath so the certificate path is
* known in handleSslErrors().
* \returns Returns whether a certificate could be loaded.
*/
bool SyncthingConnection::loadSelfSignedCertificate(const QUrl &url)
{
// ensure current exceptions for self-signed certificates are cleared
m_expectedSslErrors.clear();
// not required when not using secure connection
const auto syncthingUrl = url.isEmpty() ? m_syncthingUrl : url;
if (!syncthingUrl.scheme().endsWith(QChar('s'))) {
return false;
}
// only possible if the Syncthing instance is running on the local machine
if (!::Data::isLocal(syncthingUrl)) {
return false;
}
// find cert
const auto certPath = !m_certificatePath.isEmpty()
? m_certificatePath
: (!m_configDir.isEmpty() ? (m_configDir + QStringLiteral("/https-cert.pem")) : SyncthingConfig::locateHttpsCertificate());
if (certPath.isEmpty()) {
emit error(tr("Unable to locate certificate used by Syncthing."), SyncthingErrorCategory::OverallConnection, QNetworkReply::NoError);
return false;
}
// add exception
const auto certs = QSslCertificate::fromPath(certPath);
if (certs.isEmpty() || certs.at(0).isNull()) {
emit error(tr("Unable to load certificate used by Syncthing."), SyncthingErrorCategory::OverallConnection, QNetworkReply::NoError);
return false;
}
m_expectedSslErrors = SyncthingConnectionSettings::compileSslErrors(certs.at(0));
// keep track of the dynamically determined certificate path for handleSslErrors()
if (m_certificatePath.isEmpty()) {
m_dynamicallyDeterminedCertificatePath = certPath;
m_certificateLastModified = QFileInfo(certPath).lastModified();
}
return true;
}
/*!
* \brief Clears the self-signed certificate that might be loaded via loadSelfSignedCertificate().
* \remarks This function mainly exists to ease testing; one normally doesn't need to invoke it explicitly.
*/
void SyncthingConnection::clearSelfSignedCertificate()
{
m_expectedSslErrors.clear();
m_certificatePath.clear();
m_dynamicallyDeterminedCertificatePath.clear();
m_certificateLastModified = QDateTime();
}
/*!
* \brief Applies the specified configuration.
* \remarks
* - The expected SSL errors are taken from the specified \a connectionSettings. If empty, this
* function attempts to load expected SSL errors automatically as needed/possible via
* loadSelfSignedCertificate(). It then writes back those SSL errors to \a connectionSettings.
* This way \a connectionSettings can act as a cache for SSL exceptions.
* - The configuration is not used instantly. It will be used on the next reconnect.
* \returns Returns whether at least one property requiring a reconnect to take effect has changed.
* \sa reconnect()
*/
bool SyncthingConnection::applySettings(SyncthingConnectionSettings &connectionSettings)
{
bool reconnectRequired = false;
if (syncthingUrl() != connectionSettings.syncthingUrl) {
setSyncthingUrl(connectionSettings.syncthingUrl);
reconnectRequired = true;
}
if (apiKey() != connectionSettings.apiKey) {
setApiKey(connectionSettings.apiKey);
reconnectRequired = true;
}
if ((connectionSettings.authEnabled && (user() != connectionSettings.userName || password() != connectionSettings.password))
|| (!connectionSettings.authEnabled && (!user().isEmpty() || !password().isEmpty()))) {
if (connectionSettings.authEnabled) {
setCredentials(connectionSettings.userName, connectionSettings.password);
} else {
setCredentials(QString(), QString());
}
reconnectRequired = true;
}
m_certificatePath = connectionSettings.httpsCertPath;
m_certificateLastModified = connectionSettings.httpCertLastModified;
if (connectionSettings.expectedSslErrors.isEmpty()) {
const bool previouslyHadExpectedSslErrors = !expectedSslErrors().isEmpty();
const bool ok = loadSelfSignedCertificate();
connectionSettings.expectedSslErrors = expectedSslErrors();
if (ok || (previouslyHadExpectedSslErrors && !ok)) {
reconnectRequired = true;
}
} else if (expectedSslErrors() != connectionSettings.expectedSslErrors) {
m_expectedSslErrors = connectionSettings.expectedSslErrors;
reconnectRequired = true;
}
setTrafficPollInterval(connectionSettings.trafficPollInterval);
setDevStatsPollInterval(connectionSettings.devStatsPollInterval);
setErrorsPollInterval(connectionSettings.errorsPollInterval);
setAutoReconnectInterval(connectionSettings.reconnectInterval);
setRequestTimeout(connectionSettings.requestTimeout);
setLongPollingTimeout(connectionSettings.longPollingTimeout);
setStatusComputionFlags(connectionSettings.statusComputionFlags);
setPausingOnMeteredConnection(connectionSettings.pauseOnMeteredConnection);
return reconnectRequired;
}
/*!
* \brief Sets the connection status. Ensures statusChanged() is emitted if the status has actually changed.
* \param status Specifies the status; should be either SyncthingStatus::Disconnected, SyncthingStatus::Reconnecting, or
* SyncthingStatus::Idle. There is no use in specifying other values such as SyncthingStatus::Synchronizing as
* these are determined automatically within the method according to SyncthingConnection::statusComputionFlags().
*
* The precedence of the "connected" states from highest to lowest is:
* 1. SyncthingStatus::Synchronizing
* 2. SyncthingStatus::RemoteSynchronizing
* 3. SyncthingStatus::Scanning
* 4. SyncthingStatus::Paused
* 5. SyncthingStatus::Idle
*
* \remarks
* - The "out-of-sync" status is (currently) *not* handled by this function. One needs to query this via
* the SyncthingConnection::hasOutOfSyncDirs() function.
* - Whether notifications are available is *not* handled by this function. One needs to query this via
* SyncthingConnection::hasUnreadNotifications().
*/
void SyncthingConnection::setStatus(SyncthingStatus status)
{
if (m_status == SyncthingStatus::BeingDestroyed) {
return;
}
switch (status) {
case SyncthingStatus::Disconnected:
// disable (long) polling
m_keepPolling = false;
m_connectionAborted = true;
[[fallthrough]];
case SyncthingStatus::Reconnecting:
m_devStatsPollTimer.stop();
m_trafficPollTimer.stop();
m_errorsPollTimer.stop();
break;
default:
// reset reconnect tries
m_autoReconnectTries = 0;
// skip if no further status information should be gathered
status = SyncthingStatus::Idle;
if (m_statusComputionFlags == SyncthingStatusComputionFlags::None) {
break;
}
// check whether at least one directory is scanning, preparing to synchronize or synchronizing
// note: We don't distinguish between "preparing to sync" and "synchronizing" for computing the overall
// status at the moment.
auto scanning = false, synchronizing = false, remoteSynchronizing = false;
if (m_statusComputionFlags & SyncthingStatusComputionFlags::Synchronizing
|| m_statusComputionFlags & SyncthingStatusComputionFlags::Scanning) {
for (const SyncthingDir &dir : m_dirs) {
switch (dir.status) {
case SyncthingDirStatus::WaitingToSync:
case SyncthingDirStatus::PreparingToSync:
case SyncthingDirStatus::Synchronizing:
synchronizing = m_statusComputionFlags & SyncthingStatusComputionFlags::Synchronizing;
break;
case SyncthingDirStatus::WaitingToScan:
case SyncthingDirStatus::Scanning:
scanning = m_statusComputionFlags & SyncthingStatusComputionFlags::Scanning;
break;
default:;
}
if (synchronizing) {
break; // skip remaining dirs, "synchronizing" overrides "scanning" anyways
}
}
}
// set the status to "remote synchronizing" if at least one remote device is still in progress
if (!synchronizing && (m_statusComputionFlags & SyncthingStatusComputionFlags::RemoteSynchronizing)) {
for (const SyncthingDev &dev : m_devs) {
if (dev.status == SyncthingDevStatus::Synchronizing) {
remoteSynchronizing = true;
break;
}
}
}
if (synchronizing) {
status = SyncthingStatus::Synchronizing;
} else if (remoteSynchronizing) {
status = SyncthingStatus::RemoteNotInSync;
} else if (scanning) {
status = SyncthingStatus::Scanning;
} else if (m_statusComputionFlags & SyncthingStatusComputionFlags::DevicePaused) {
// check whether at least one device is paused
for (const SyncthingDev &dev : m_devs) {
if (dev.paused) {
status = SyncthingStatus::Paused;
break;
}
}
}
}
if (m_status != status || status == SyncthingStatus::Disconnected) {
// emit event if status changed always for disconnects so isConnecting() is re-evaluated
emit statusChanged(m_status = status);
}
}
/*!
* \brief Internally called to emit a JSON parsing error.
* \remarks Since in this case the reply has already been read, its response must be passed as extra argument.
*/
void SyncthingConnection::emitError(const QString &message, const QJsonParseError &jsonError, QNetworkReply *reply, const QByteArray &response)
{
if (loggingFlags() & SyncthingConnectionLoggingFlags::ApiReplies) {
std::cerr << Phrases::Error << "JSON parsing error: " << message.toLocal8Bit().data() << jsonError.errorString().toLocal8Bit().data()
<< " (at offset " << jsonError.offset << ')' << Phrases::End;
}
emit error(message % jsonError.errorString() % QChar(' ') % QChar('(') % tr("at offset %1").arg(jsonError.offset) % QChar(')'),
SyncthingErrorCategory::Parsing, QNetworkReply::NoError, reply->request(), response);
}
/*!
* \brief Internally called to emit a network error (server replied error code are connection or server could not be reached at all).
*/
void SyncthingConnection::emitError(const QString &message, SyncthingErrorCategory category, QNetworkReply *reply)
{
if (loggingFlags() & SyncthingConnectionLoggingFlags::ApiReplies) {
cerr << Phrases::Error << "Syncthing connection error: " << message.toLocal8Bit().data() << reply->errorString().toLocal8Bit().data() << endl;
}
emit error(message + reply->errorString(), category, reply->error(), reply->request(), reply->bytesAvailable() ? reply->readAll() : QByteArray());
}
/*!
* \brief Internally called to emit myIdChanged() signal.
*/
void SyncthingConnection::emitMyIdChanged(const QString &newId)
{
if (newId.isEmpty() || m_myId == newId) {
return;
}
// adjust device status
int row = 0;
for (SyncthingDev &dev : m_devs) {
if (dev.id == newId) {
if (dev.status != SyncthingDevStatus::ThisDevice) {
dev.status = SyncthingDevStatus::ThisDevice;
dev.paused = false;
emit devStatusChanged(dev, row);
}
} else if (dev.status == SyncthingDevStatus::ThisDevice) {
dev.status = SyncthingDevStatus::Unknown;
emit devStatusChanged(dev, row);
}
++row;
}
emit myIdChanged(m_myId = newId);
}
/*!
* \brief Internally called to emit tildeChanged() signal.
*/
void SyncthingConnection::emitTildeChanged(const QString &newTilde, const QString &newPathSeparator)
{
if ((newTilde.isEmpty() || m_tilde == newTilde) && (newPathSeparator.isEmpty() && m_pathSeparator == newPathSeparator)) {
return;
}
m_tilde = newTilde;
m_pathSeparator = newPathSeparator;
emit tildeChanged(m_tilde);
}
/*!
* \brief Internally called to emit dirStatisticsChanged() event.
*/
void SyncthingConnection::emitDirStatisticsChanged()
{
if (m_dirStatsAltered) {
m_dirStatsAltered = false;
emit dirStatisticsChanged();
}
}
/*!
* \brief Internally called to handle a fatal error when reading config (dirs/devs), status and events.
*/
void SyncthingConnection::handleFatalConnectionError()
{
setStatus(SyncthingStatus::Disconnected);
abortAllRequests();
if (m_autoReconnectTimer.interval()) {
m_autoReconnectTimer.start();
}
}
/*!
* \brief Handles cancellation of additional requests done via continueConnecting() method.
*/
void SyncthingConnection::handleAdditionalRequestCanceled()
{
// postpone handling if there are still other requests pending
if (hasPendingRequests()) {
return;
}
if (m_abortingToReconnect) {
// if reconnection flag is set, instantly etstablish a new connection ...
continueReconnecting();
} else {
// ... otherwise declare we're disconnected if that was the last pending request
setStatus(SyncthingStatus::Disconnected);
}
}
/*!
* \brief Internally called to recalculate the overall connection status, e.g. after the status of a directory
* changed.
* \remarks
* - This is achieved by simply setting the status to idle. setStatus() will calculate the specific status.
* - If not connected, this method does nothing. This is important, because when this method is called when
* establishing a connection (and the status is hence still disconnected) timers for polling would be killed.
*/
void SyncthingConnection::recalculateStatus()
{
if (isConnected()) {
setStatus(SyncthingStatus::Idle);
}
}
/*!
* \fn SyncthingConnection::newConfig()
* \brief Indicates new configuration (dirs, devs, ...) is available.
* \remarks
* - Configuration is requested automatically when connecting.
* - Previous directories (and directory info objects!) are invalidated.
* - Previous devices (and device info objects!) are invalidated.
*/
/*!
* \fn SyncthingConnection::newDirs()
* \brief Indicates new directories are available.
* \remarks Always emitted after newConfig() as soon as new directory info objects become available.
*/
/*!
* \fn SyncthingConnection::newDevices()
* \brief Indicates new devices are available.
* \remarks Always emitted after newConfig() as soon as new device info objects become available.
*/
/*!
* \fn SyncthingConnection::newEvents()
* \brief Indicates new events (dir status changed, ...) are available.
* \remarks New events are automatically polled when connected.
*/
/*!
* \fn SyncthingConnection::allEventsProcesses()
* \brief Indicates all new events have been processed.
* \remarks
* This event is emitted after newEvents() and dirStatusChanged(), devStatusChanged() and other specific events.
* If you would go through the list of all directories on every dirStatusChanged() event using instead might
* be a more efficient alternative allEventsProcesses(). Just set a flag on dirStatusChanged() and go though the
* list of directories only once on the allEventsProcesses() event when the flag has been set.
*/
/*!
* \fn SyncthingConnection::dirStatusChanged()
* \brief Indicates the status of the specified \a dir changed.
*/
/*!
* \fn SyncthingConnection::devStatusChanged()
* \brief Indicates the status of the specified \a dev changed.
*/
/*!
* \fn SyncthingConnection::downloadProgressChanged()
* \brief Indicates the download progress changed.
*/
/*!
* \fn SyncthingConnection::newNotification()
* \brief Indicates a new Syncthing notification is available.
*/
/*!
* \fn SyncthingConnection::newDevAvailable()
* \brief Indicates another device wants to talk to us.
*/
/*!
* \fn SyncthingConnection::newDirAvailable()
* \brief Indicates a device wants to share an so far unknown directory with us.
* \remarks \a dev might be nullptr. In this case the device which wants to share the directory is unknown as well.
*/
/*!
* \fn SyncthingConnection::error()
* \brief Indicates a request (for configuration, events, ...) failed.
*/
/*!
* \fn SyncthingConnection::statusChanged()
* \brief Indicates the status of the connection changed (status(), hasOutOfSyncDirs()).
*/
/*!
* \fn SyncthingConnection::configDirChanged()
* \brief Indicates the Syncthing home/configuration directory changed.
*/
/*!
* \fn SyncthingConnection::myIdChanged()
* \brief Indicates ID of the own Syncthing device changed.
*/
/*!
* \fn SyncthingConnection::tildeChanged()
* \brief Indicates the tilde or path separator of the connected Syncthing instance changed.
*/
/*!
* \fn SyncthingConnection::trafficChanged()
* \brief Indicates totalIncomingTraffic() or totalOutgoingTraffic() has changed.
*/
/*!
* \fn SyncthingConnection::newConfigTriggered()
* \brief Indicates a new configuration has posted successfully via postConfig().
* \remarks In contrast to newConfig(), this signal is only emitted for configuration changes internally posted via postConfig().
*/
/*!
* \fn SyncthingConnection::rescanTriggered()
* \brief Indicates a rescan has been triggered successfully.
* \remarks Only emitted for rescans triggered internally via rescan() or rescanAll().
*/
/*!
* \fn SyncthingConnection::pauseTriggered()
* \brief Indicates a device has been paused successfully.
* \remarks Only emitted for pausing triggered internally via pause() or pauseAll().
*/
/*!
* \fn SyncthingConnection::resumeTriggered()
* \brief Indicates a device has been resumed successfully.
* \remarks Only emitted for resuming triggered internally via resume() or resumeAll().
*/
/*!
* \fn SyncthingConnection::restartTriggered()
* \brief Indicates a restart has been successfully triggered via restart().
*/
} // namespace Data