diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5a5f3f0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + push: + branches: + - "**" + pull_request: + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Build and test (Ubuntu) + runs-on: ubuntu-22.04 + timeout-minutes: 30 + + env: + QT_QPA_PLATFORM: offscreen + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install build and Qt dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake ninja-build pkg-config qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools qt6-multimedia-dev libqt6sql6-sqlite libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev libsqlite3-dev libssl-dev + + - name: Configure tests + run: | + cmake -S . -B build-tests -G Ninja -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTS=ON -DENABLE_VTK=OFF + + - name: Build tests + run: cmake --build build-tests --parallel 2 + + - name: Run tests + run: ctest --test-dir build-tests --output-on-failure --timeout 60 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8deab7a..0ecde0f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,6 +190,8 @@ set(MOC_HEADERS include/multipack/config/SettingsManager.h include/multipack/system/UsbKeyCheck.h include/multipack/system/UpdateChecker.h + include/multipack/system/SystemUpdaterClient.h + include/multipack/system/WifiSsidProbe.h include/multipack/system/AutoUpdater.h include/multipack/system/UsbMonitor.h ) @@ -266,6 +268,8 @@ set(SYSTEM_SOURCES src/system/UpdateChecker.cpp src/system/FileOperations.cpp src/system/RobFileParser.cpp + src/system/SystemUpdaterClient.cpp + src/system/WifiSsidProbe.cpp src/system/AutoUpdater.cpp src/system/UsbMonitor.cpp ) @@ -274,7 +278,9 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64" AND MULTIPACK_SAFE_ARM64_EMULA # GCC 11 under Docker/QEMU can ICE/segfault on these translation units at -O2. # Keep the workaround tightly scoped so the ARM64 release build stays fast. set_source_files_properties( - src/system/AutoUpdater.cpp + src/system/SystemUpdaterClient.cpp + src/system/WifiSsidProbe.cpp + src/system/AutoUpdater.cpp src/ui/UIHelpers.cpp PROPERTIES COMPILE_OPTIONS "-O1") endif() diff --git a/ReadMe.md b/ReadMe.md index 67defb0..70c24f5 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -198,3 +198,28 @@ available before readiness. ## License Proprietary - Szaidel Cosmetic GmbH + +### Immutable system updates and network status + +With `MULTIPACK_IMMUTABLE=1`, the existing update button reads system status and +asks for a published system release VERSION (for example `rpi-hmi-v0.1.0`). +The asynchronous client sends only `status` or `install` plus that validated +identifier to `/run/hmi-updater/control.sock`. The image grants `hmi-update` group +access. The daemon verifies signatures, writes the inactive slot, and reboots; +once accepted, installation cannot be cancelled by closing the UI. Status includes +the active version, boot phase and download/install progress. Network errors can +be retried. No legacy application/USB installation runs in immutable mode. +Without the environment flag the signed legacy updater is retained. + +SSID probing uses asynchronous `nmcli` with a deadline and no Wi-Fi rescan. +HMI provisioning installs `network-manager`. Portable bundles require a host +NetworkManager installation for SSID display; when it is absent, the UI explicitly +shows SSID unavailable. The probe uses the C locale so translated nmcli output +does not affect SSID detection. Interface IP status remains usable. Probe processes belong +to the window and are terminated when it closes. + +A saved installation failure remains readable system status and allows another +VERSION to be submitted. If a status request is still pending when an update is +confirmed, the UI reports that no request was sent and asks the operator to retry. +Background idle/error observations do not replace operator messages; active +update progress and new installation failures remain visible. diff --git a/include/multipack/config/ConfigDefaults.h b/include/multipack/config/ConfigDefaults.h index 649e11c..70965e8 100644 --- a/include/multipack/config/ConfigDefaults.h +++ b/include/multipack/config/ConfigDefaults.h @@ -9,6 +9,8 @@ #define MULTIPACK_CONFIG_CONFIGDEFAULTS_H #include +#include +#include #ifndef MULTIPACK_APP_VERSION #define MULTIPACK_APP_VERSION "2.0.0" @@ -93,9 +95,15 @@ inline QString defaultDatabasePath() * @brief Get default settings file path * @return Settings file path */ +inline QString dataDirectory() +{ + const QString configured = qEnvironmentVariable("MULTIPACK_DATA_DIR").trimmed(); + return configured.isEmpty() ? QDir::currentPath() : QDir(configured).absolutePath(); +} + inline QString defaultSettingsPath() { - return "settings.json"; + return QDir(dataDirectory()).filePath("settings.json"); } /** @@ -104,7 +112,7 @@ inline QString defaultSettingsPath() */ inline QString defaultLogDir() { - return LOG_DIR; + return QDir(dataDirectory()).filePath(LOG_DIR); } } // namespace Defaults diff --git a/include/multipack/system/AutoUpdater.h b/include/multipack/system/AutoUpdater.h index dc9248a..587c22e 100644 --- a/include/multipack/system/AutoUpdater.h +++ b/include/multipack/system/AutoUpdater.h @@ -2,6 +2,7 @@ #define MULTIPACK_SYSTEM_AUTOUPDATER_H #include +#include "multipack/system/SystemUpdaterClient.h" #include #include #include @@ -77,7 +78,8 @@ class AutoUpdater : public QObject * @brief Constructor * @param parent Parent QObject */ - explicit AutoUpdater(QObject* parent = nullptr); + explicit AutoUpdater(QObject* parent = nullptr, + const QString& systemSocketPath = QStringLiteral("/run/hmi-updater/control.sock")); /** * @brief Destructor @@ -236,6 +238,11 @@ private slots: void onSslErrors(const QList& errors); private: + bool m_immutable = false; + SystemUpdaterClient* m_systemUpdater = nullptr; + QTimer* m_systemPoll = nullptr; + bool m_systemChecking = false; + bool m_systemInstalling = false; // Core components QNetworkAccessManager* m_networkManager; QNetworkReply* m_checkReply = nullptr; diff --git a/include/multipack/system/SystemUpdaterClient.h b/include/multipack/system/SystemUpdaterClient.h new file mode 100644 index 0000000..87f4696 --- /dev/null +++ b/include/multipack/system/SystemUpdaterClient.h @@ -0,0 +1,35 @@ +#ifndef MULTIPACK_SYSTEM_SYSTEMUPDATERCLIENT_H +#define MULTIPACK_SYSTEM_SYSTEMUPDATERCLIENT_H +#include +#include +#include +#include +namespace multipack::system { +// One bounded asynchronous request at a time. Production always uses the fixed socket. +class SystemUpdaterClient : public QObject { + Q_OBJECT +public: + explicit SystemUpdaterClient(QObject* parent = nullptr, + const QString& socketPath = QStringLiteral("/run/hmi-updater/control.sock"), + int timeoutMs = 4000); + bool status(); + bool install(const QString& version); + bool busy() const { return m_busy; } + void cancel(); // Cancels observation only; never cancels a privileged installation. + static bool validVersion(const QString& version); +signals: + void response(const QJsonObject& value); + void error(const QString& message); +private: + bool request(const QJsonObject& value); + void fail(const QString& message); + QLocalSocket* m_socket; + QTimer m_timeout; + QString m_path; + QByteArray m_request, m_buffer; + int m_timeoutMs; + bool m_busy = false; + bool m_statusRequest = false; +}; +} +#endif diff --git a/include/multipack/system/WifiSsidProbe.h b/include/multipack/system/WifiSsidProbe.h new file mode 100644 index 0000000..eff6e42 --- /dev/null +++ b/include/multipack/system/WifiSsidProbe.h @@ -0,0 +1,24 @@ +#ifndef MULTIPACK_SYSTEM_WIFISSIDPROBE_H +#define MULTIPACK_SYSTEM_WIFISSIDPROBE_H +#include +#include +#include +namespace multipack::system { +class WifiSsidProbe : public QObject { + Q_OBJECT +public: + explicit WifiSsidProbe(QObject* parent = nullptr, const QString& program = "nmcli", int timeoutMs = 1500); + ~WifiSsidProbe() override; + void refresh(); + static QString parse(const QByteArray& output); +signals: + void result(const QString& ssid); +private: + QProcess* m_process; + QTimer m_timer; + QString m_program; + int m_timeoutMs; + bool m_expired = false; +}; +} +#endif diff --git a/include/multipack/ui/MainWindow.h b/include/multipack/ui/MainWindow.h index 101dafd..5506d7a 100644 --- a/include/multipack/ui/MainWindow.h +++ b/include/multipack/ui/MainWindow.h @@ -1,3 +1,4 @@ +#include "multipack/system/WifiSsidProbe.h" /** * @file MainWindow.h * @brief Main application window using Qt Designer UI @@ -198,6 +199,7 @@ private slots: void maybeStartRobotStatusMonitor(); void stopRobotStatusMonitor(); void setupStatusTab(); + void refreshNetworkStatus(); void updateStatusTab(const robot::RobotStatus& status); void onStatusDetailsUpdated(const QString& polyscopeVersion, const QString& serialNumber, @@ -264,10 +266,18 @@ private slots: QWidget* m_centralWidget = nullptr; QTimer* m_zwischenlageTimer = nullptr; QTimer* m_paletteClearTimer = nullptr; + system::WifiSsidProbe* m_wifiSsidProbe = nullptr; + QTimer* m_networkStatusTimer = nullptr; NotificationPopup* m_zwischenlagePopup = nullptr; QPushButton* m_palette1ClearIndicator = nullptr; QPushButton* m_palette2ClearIndicator = nullptr; VisualizationWidget* m_visualizationWidget = nullptr; + QLabel* m_statusHostname = nullptr; + QLabel* m_statusEth0Ip = nullptr; + QLabel* m_statusWlan0Ip = nullptr; + QLabel* m_statusWifiSsid = nullptr; + QLabel* m_statusTailscaleState = nullptr; + QLabel* m_statusTailscaleIp = nullptr; QLabel* m_statusRobotIp = nullptr; QLabel* m_statusConnection = nullptr; QLabel* m_statusRobotMode = nullptr; diff --git a/src/config/SettingsManager.cpp b/src/config/SettingsManager.cpp index 800e41c..c098e63 100644 --- a/src/config/SettingsManager.cpp +++ b/src/config/SettingsManager.cpp @@ -137,6 +137,12 @@ bool SettingsManager::save(const QString& path) QJsonDocument doc(m_settings); + const QFileInfo targetInfo(filePath); + if (!QDir().mkpath(targetInfo.absolutePath())) { + qWarning() << "Cannot create settings directory:" << targetInfo.absolutePath(); + return false; + } + QFile file(filePath); if (!file.open(QIODevice::WriteOnly)) { qWarning() << "Cannot open settings file for writing:" << filePath; diff --git a/src/core/AppInitializer.cpp b/src/core/AppInitializer.cpp index 5241d79..1ce80ec 100644 --- a/src/core/AppInitializer.cpp +++ b/src/core/AppInitializer.cpp @@ -30,7 +30,7 @@ QString resolveConfiguredPath(const QString& configuredPath, const QString& fall { const QString path = configuredPath.trimmed().isEmpty() ? fallback : configuredPath.trimmed(); QFileInfo info(path); - return info.isAbsolute() ? info.absoluteFilePath() : QDir::current().absoluteFilePath(path); + return info.isAbsolute() ? info.absoluteFilePath() : QDir(config::Defaults::dataDirectory()).absoluteFilePath(path); } } @@ -209,7 +209,7 @@ bool AppInitializer::initializeLogging() { qDebug() << "AppInitializer - initializing logging"; - const QString logPath = QDir::currentPath() + "/logs"; + const QString logPath = config::Defaults::defaultLogDir(); const bool verboseEnabled = qEnvironmentVariable("MULTIPACK_VERBOSE", "0") == "1"; const config::LogLevel level = verboseEnabled ? config::LogLevel::Debug : config::LogLevel::Info; @@ -232,7 +232,7 @@ bool AppInitializer::initializeSettings() m_settingsManager = std::make_unique(); // Try to load settings - QString settingsPath = QDir::currentPath() + "/settings.json"; + const QString settingsPath = config::Defaults::defaultSettingsPath(); if (QFile::exists(settingsPath)) { if (!m_settingsManager->load(settingsPath)) { qWarning() << "Failed to load settings from" << settingsPath; diff --git a/src/system/AutoUpdater.cpp b/src/system/AutoUpdater.cpp index e133c8f..85a9cb7 100644 --- a/src/system/AutoUpdater.cpp +++ b/src/system/AutoUpdater.cpp @@ -1,3 +1,5 @@ +#include +#include /** * @file AutoUpdater.cpp * @brief Implementation of comprehensive automatic updater @@ -54,14 +56,14 @@ QString sanitizeForFileName(const QString& value) } } // namespace -AutoUpdater::AutoUpdater(QObject* parent) +AutoUpdater::AutoUpdater(QObject* parent, const QString& systemSocketPath) : QObject(parent) , m_networkManager(new QNetworkAccessManager(this)) , m_checkReply(nullptr) , m_downloadReply(nullptr) , m_checkTimer(new QTimer(this)) , m_status(UpdateStatus::Idle) - , m_updateCacheDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/multipack-updates") + , m_updateCacheDir(QDir(config::Defaults::dataDirectory()).filePath("updates")) , m_usbUpdateDir(config::Defaults::defaultUsbPath()) { qDebug() << "AutoUpdater - initialized"; @@ -78,6 +80,61 @@ AutoUpdater::AutoUpdater(QObject* parent) } m_currentVersion = config::Defaults::VERSION; + m_immutable = qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1"; + if (m_immutable) { + m_systemUpdater = new SystemUpdaterClient(this, systemSocketPath); + m_systemPoll = new QTimer(this); + m_systemPoll->setInterval(2000); + connect(m_systemPoll, &QTimer::timeout, this, [this] { m_systemUpdater->status(); }); + connect(m_systemUpdater, &SystemUpdaterClient::error, this, [this](const QString& error) { + const bool reportError = m_systemChecking || m_systemInstalling + || m_status == UpdateStatus::Downloading || m_status == UpdateStatus::Installing; + m_systemInstalling = false; + m_progress.statusText = error; + setStatus(UpdateStatus::Idle); // Allow retry after transport/daemon errors. + if (reportError) emit updateFailed(error); + if (m_systemChecking) { + m_systemChecking = false; + emit checkCompleted(false, error); + } + }); + connect(m_systemUpdater, &SystemUpdaterClient::response, this, [this](const QJsonObject& value) { + const bool wasUpdating = m_status == UpdateStatus::Downloading + || m_status == UpdateStatus::Installing; + m_systemInstalling = false; + if (value.value("accepted").toBool()) { + setStatus(UpdateStatus::Downloading); + m_progress.statusText = tr("System update accepted; preparing inactive slot"); + emit downloadStarted(m_availableUpdate.version); + } else { + const QString phase = value.value("phase").toString(); + const auto boot = value.value("boot").toObject(); + const QString active = boot.value("active").toString(); + const QString version = boot.value("version").toObject().value(active).toString(); + if (!version.isEmpty()) m_currentVersion = version; + m_progress.bytesReceived = static_cast(value.value("bytes").toDouble()); + m_progress.bytesTotal = static_cast(value.value("total").toDouble()); + m_progress.statusText = tr("System: %1; active %2 (%3); boot: %4") + .arg(phase, active, m_currentVersion, boot.value("phase").toString()); + if (phase == "error") { + const QString error = value.value("error").toString(); + m_progress.statusText += tr("; previous update failed: %1").arg(error); + if (wasUpdating) emit updateFailed(error); + } + setStatus(phase == "downloading" ? UpdateStatus::Downloading + : phase == "installing" || phase == "rebooting" ? UpdateStatus::Installing + : UpdateStatus::Idle); + if (m_systemChecking) { + m_systemChecking = false; + emit checkCompleted(true, m_progress.statusText); + } + } + if (m_status == UpdateStatus::Downloading || m_status == UpdateStatus::Installing) + emit downloadProgress(m_progress); + }); + m_systemPoll->start(); + QTimer::singleShot(0, this, [this] { m_systemUpdater->status(); }); + } } AutoUpdater::~AutoUpdater() @@ -100,6 +157,12 @@ AutoUpdater::~AutoUpdater() bool AutoUpdater::checkForUpdates() { + if (m_immutable) { + if (!m_systemUpdater->status()) return false; + m_systemChecking = true; + emit checkStarted(); + return true; + } if (m_status != UpdateStatus::Idle) { qWarning() << "AutoUpdater: Cannot check for updates, operation in progress"; return false; @@ -111,6 +174,10 @@ bool AutoUpdater::checkForUpdates() bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) { + if (m_immutable) { + emit checkFailed(tr("System releases must be installed by VERSION through the A/B updater")); + return false; + } if (m_status != UpdateStatus::Idle) { qWarning() << "AutoUpdater: Cannot check USB updates, operation in progress"; return false; @@ -143,6 +210,21 @@ bool AutoUpdater::checkForUsbUpdates(const QString& usbDirectory) bool AutoUpdater::downloadAndInstallUpdate() { + if (m_immutable) { + if (m_status != UpdateStatus::Idle) { + emit updateFailed(tr("A system update is already running; no new update was submitted.")); + return false; + } + if (m_systemUpdater->busy()) { + emit updateFailed(tr("System updater status request is still pending. Please retry the update.")); + return false; + } + m_systemInstalling = true; + const bool sent = m_systemUpdater->install(m_availableUpdate.version); + if (!sent) m_systemInstalling = false; + return sent; + } + if (m_availableUpdate.version.isEmpty()) { qWarning() << "AutoUpdater: No update available to download"; return false; @@ -244,6 +326,10 @@ bool AutoUpdater::downloadAndInstallUpdate() void AutoUpdater::cancelUpdate() { + if (m_immutable) { + emit updateFailed(tr("An accepted system update cannot be cancelled from the HMI")); + return; + } if (m_checkReply) { m_checkReply->abort(); m_checkReply->deleteLater(); @@ -311,6 +397,17 @@ QString AutoUpdater::usbUpdatePath() const void AutoUpdater::showUpdateDialog() { + if (m_immutable) { + bool ok = false; + const QString version = QInputDialog::getText(nullptr, tr("System A/B Update"), + tr("%1\nEnter published system release VERSION. Installation reboots the HMI.") + .arg(m_progress.statusText), QLineEdit::Normal, QString(), &ok); + if (ok) { + m_availableUpdate.version = version; + downloadAndInstallUpdate(); + } + return; + } if (m_availableUpdate.version.isEmpty()) { QMessageBox::information(nullptr, tr("No Updates Available"), @@ -345,6 +442,7 @@ void AutoUpdater::initializeNetwork() void AutoUpdater::startGitHubCheck() { + if (m_immutable) { checkForUpdates(); return; } setStatus(UpdateStatus::Checking); emit checkStarted(); @@ -987,6 +1085,13 @@ void AutoUpdater::onDownloadNetworkReply(QNetworkReply* reply) bool AutoUpdater::installUpdate(const QString& filePath) { + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + const QString message = tr("Refusing in-place update on immutable system"); + qWarning() << "AutoUpdater:" << message; + emit updateFailed(message); + return false; + } + emit installationStarted(); const QString appDir = QCoreApplication::applicationDirPath(); diff --git a/src/system/SystemUpdaterClient.cpp b/src/system/SystemUpdaterClient.cpp new file mode 100644 index 0000000..2ae33e5 --- /dev/null +++ b/src/system/SystemUpdaterClient.cpp @@ -0,0 +1,64 @@ +#include "multipack/system/SystemUpdaterClient.h" +#include +#include +namespace multipack::system { +SystemUpdaterClient::SystemUpdaterClient(QObject* parent, const QString& path, int timeoutMs) + : QObject(parent), m_socket(new QLocalSocket(this)), m_path(path), m_timeoutMs(timeoutMs) +{ + m_timeout.setSingleShot(true); + connect(&m_timeout, &QTimer::timeout, this, [this] { fail(tr("System updater timed out")); }); + connect(m_socket, &QLocalSocket::connected, this, [this] { m_socket->write(m_request); }); + connect(m_socket, &QLocalSocket::errorOccurred, this, [this] { + if (m_busy) fail(tr("System updater: %1").arg(m_socket->errorString())); + }); + connect(m_socket, &QLocalSocket::disconnected, this, [this] { + if (m_busy) fail(tr("System updater disconnected before replying")); + }); + connect(m_socket, &QLocalSocket::readyRead, this, [this] { + if (!m_busy) return; + m_buffer += m_socket->readAll(); + if (m_buffer.size() > 65536) { fail(tr("System updater reply too large")); return; } + const int end = m_buffer.indexOf('\n'); + if (end < 0) return; + QJsonParseError parseError; + const auto doc = QJsonDocument::fromJson(m_buffer.left(end), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + fail(tr("Invalid system updater reply")); return; + } + const auto value = doc.object(); + const bool statusReply = m_statusRequest && value.value("phase").isString(); + cancel(); + if (statusReply) + emit response(value); // An earlier installation failure is readable status. + else if (value.contains("error") && !value.value("error").isNull()) + emit error(tr("System updater: %1").arg(value.value("error").toString())); + else if (value.value("accepted") != true && !value.value("phase").isString()) + emit error(tr("Invalid system updater status")); + else emit response(value); + }); +} +bool SystemUpdaterClient::validVersion(const QString& version) +{ + static const QRegularExpression pattern(QStringLiteral("\\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}\\z")); + return pattern.match(version).hasMatch(); +} +bool SystemUpdaterClient::status() { return request({{"op", "status"}}); } +bool SystemUpdaterClient::install(const QString& version) +{ + if (!validVersion(version)) { emit error(tr("Invalid system release VERSION")); return false; } + return request({{"op", "install"}, {"version", version}}); +} +bool SystemUpdaterClient::request(const QJsonObject& value) +{ + if (m_busy) return false; + m_buffer.clear(); + m_request = QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'; + m_statusRequest = value.value("op") == "status"; + m_busy = true; + m_timeout.start(m_timeoutMs); + m_socket->connectToServer(m_path); + return true; +} +void SystemUpdaterClient::cancel() { m_busy = false; m_timeout.stop(); m_socket->abort(); } +void SystemUpdaterClient::fail(const QString& message) { cancel(); emit error(message); } +} diff --git a/src/system/UsbKeyCheck.cpp b/src/system/UsbKeyCheck.cpp index 7c2391b..6b974ac 100644 --- a/src/system/UsbKeyCheck.cpp +++ b/src/system/UsbKeyCheck.cpp @@ -246,7 +246,7 @@ void resolveUsbConfig(const multipack::config::SettingsManager* settingsManager, } } - const QString jsonPath = QDir::current().filePath("settings.json"); + const QString jsonPath = config::Defaults::defaultSettingsPath(); if (loadUsbConfigFromJsonFile(jsonPath, usbKey, expectedValue)) { return; } diff --git a/src/system/WifiSsidProbe.cpp b/src/system/WifiSsidProbe.cpp new file mode 100644 index 0000000..3d81ee1 --- /dev/null +++ b/src/system/WifiSsidProbe.cpp @@ -0,0 +1,42 @@ +#include "multipack/system/WifiSsidProbe.h" +#include +namespace multipack::system { +WifiSsidProbe::WifiSsidProbe(QObject* parent, const QString& program, int timeoutMs) + : QObject(parent), m_process(new QProcess(this)), m_program(program), m_timeoutMs(timeoutMs) +{ + m_timer.setSingleShot(true); + connect(&m_timer, &QTimer::timeout, this, [this] { + m_expired = true; m_process->kill(); emit result(tr("SSID unavailable (timeout)")); + }); + connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + m_timer.stop(); emit result(tr("SSID unavailable (nmcli not installed)")); + } + }); + connect(m_process, QOverload::of(&QProcess::finished), this, + [this](int code, QProcess::ExitStatus status) { + m_timer.stop(); + if (m_expired) return; + emit result(code == 0 && status == QProcess::NormalExit + ? parse(m_process->readAllStandardOutput()) : tr("SSID unavailable")); + }); +} +WifiSsidProbe::~WifiSsidProbe() { m_process->disconnect(this); m_process->kill(); } +void WifiSsidProbe::refresh() +{ + if (m_process->state() != QProcess::NotRunning) return; + m_expired = false; + auto environment = QProcessEnvironment::systemEnvironment(); + environment.insert("LC_ALL", "C"); + m_process->setProcessEnvironment(environment); + m_process->start(m_program, {"--terse", "--escape", "no", "--fields", "ACTIVE,SSID", + "device", "wifi", "list", "ifname", "wlan0", "--rescan", "no"}); + m_timer.start(m_timeoutMs); +} +QString WifiSsidProbe::parse(const QByteArray& output) +{ + for (const auto& line : QString::fromUtf8(output).split('\n')) + if (line.startsWith("yes:")) return line.mid(4); // Preserve SSID spaces/colons/backslashes. + return tr("Not connected"); +} +} diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 9994c03..c9aca88 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,3 +1,4 @@ +#include "multipack/system/WifiSsidProbe.h" /** * @file MainWindow.cpp * @brief Implementation of main window using Qt Designer UI @@ -31,10 +32,13 @@ #include #include #include +#include +#include #include #include #include #include +#include #include #include #include @@ -84,6 +88,23 @@ QString palettePlanItemFileName(QListWidgetItem* item) const QString storedName = item->data(Qt::UserRole).toString().trimmed(); return storedName.isEmpty() ? item->text().trimmed() : storedName; } + +QString ipv4ForInterface(const QString& interfaceName) +{ + const QNetworkInterface iface = QNetworkInterface::interfaceFromName(interfaceName); + if (!iface.isValid() || !(iface.flags() & QNetworkInterface::IsUp)) { + return QStringLiteral("-"); + } + + for (const QNetworkAddressEntry& entry : iface.addressEntries()) { + const QHostAddress address = entry.ip(); + if (address.protocol() == QAbstractSocket::IPv4Protocol && !address.isLoopback()) { + return address.toString(); + } + } + return QStringLiteral("-"); +} + } // namespace MainWindow::MainWindow(QWidget* parent) @@ -645,6 +666,12 @@ void MainWindow::setupStatusTab() form->setLabelAlignment(Qt::AlignRight); form->setFormAlignment(Qt::AlignTop); + m_statusHostname = new QLabel("-", statusWidget); + m_statusEth0Ip = new QLabel("-", statusWidget); + m_statusWlan0Ip = new QLabel("-", statusWidget); + m_statusWifiSsid = new QLabel("-", statusWidget); + m_statusTailscaleState = new QLabel("-", statusWidget); + m_statusTailscaleIp = new QLabel("-", statusWidget); m_statusRobotIp = new QLabel("-", statusWidget); m_statusConnection = new QLabel("-", statusWidget); m_statusRobotMode = new QLabel("-", statusWidget); @@ -655,6 +682,23 @@ void MainWindow::setupStatusTab() m_statusSerialNumber = new QLabel("-", statusWidget); m_statusLoadedProgram = new QLabel("-", statusWidget); + auto* networkHeader = new QLabel(tr("Raspberry Pi / Netzwerk"), statusWidget); + QFont networkHeaderFont = networkHeader->font(); + networkHeaderFont.setBold(true); + networkHeader->setFont(networkHeaderFont); + form->addRow(networkHeader); + form->addRow(tr("Hostname:"), m_statusHostname); + form->addRow(tr("eth0 IP:"), m_statusEth0Ip); + form->addRow(tr("wlan0 IP:"), m_statusWlan0Ip); + form->addRow(tr("WLAN SSID:"), m_statusWifiSsid); + form->addRow(tr("Tailscale:"), m_statusTailscaleState); + form->addRow(tr("Tailscale IP:"), m_statusTailscaleIp); + + auto* robotHeader = new QLabel(tr("Roboter"), statusWidget); + QFont robotHeaderFont = robotHeader->font(); + robotHeaderFont.setBold(true); + robotHeader->setFont(robotHeaderFont); + form->addRow(robotHeader); form->addRow(tr("Robot IP:"), m_statusRobotIp); form->addRow(tr("Connection:"), m_statusConnection); form->addRow(tr("Robot Mode:"), m_statusRobotMode); @@ -668,6 +712,40 @@ void MainWindow::setupStatusTab() rootLayout->addLayout(form); ui->tabWidget->addTab(statusWidget, tr("Status")); + + m_wifiSsidProbe = new system::WifiSsidProbe(this); + connect(m_wifiSsidProbe, &system::WifiSsidProbe::result, this, [this](const QString& ssid) { + m_statusWifiSsid->setText(ipv4ForInterface("wlan0") == "-" ? tr("Not connected") : ssid); + }); + m_networkStatusTimer = new QTimer(this); + m_networkStatusTimer->setInterval(5000); + connect(m_networkStatusTimer, &QTimer::timeout, + this, &MainWindow::refreshNetworkStatus); + m_networkStatusTimer->start(); + refreshNetworkStatus(); +} + +void MainWindow::refreshNetworkStatus() +{ + if (!m_statusHostname) { + return; + } + + const QString eth0Ip = ipv4ForInterface(QStringLiteral("eth0")); + const QString wlan0Ip = ipv4ForInterface(QStringLiteral("wlan0")); + const QString tailscaleIp = ipv4ForInterface(QStringLiteral("tailscale0")); + + m_statusHostname->setText(QHostInfo::localHostName().trimmed().isEmpty() + ? QStringLiteral("-") + : QHostInfo::localHostName()); + m_statusEth0Ip->setText(eth0Ip); + m_statusWlan0Ip->setText(wlan0Ip); + if (wlan0Ip == "-") m_statusWifiSsid->setText(tr("Not connected")); + else m_wifiSsidProbe->refresh(); + m_statusTailscaleIp->setText(tailscaleIp); + m_statusTailscaleState->setText(tailscaleIp == QStringLiteral("-") + ? QStringLiteral("Not connected") + : QStringLiteral("Connected")); } void MainWindow::updateStatusTab(const robot::RobotStatus& status) @@ -1059,6 +1137,11 @@ void MainWindow::updateEnabledStates() ui->EingabeStartlage->setEnabled(paletteLoaded); ui->EingabeKartonhoehe->setEnabled(paletteLoaded); ui->EingabeKartonGewicht->setEnabled(paletteLoaded); + ui->label_Startlage->setEnabled(paletteLoaded); + ui->label_Kartonhoehe->setEnabled(paletteLoaded); + ui->label_Kartonhoehe_mm->setEnabled(paletteLoaded); + ui->label_Gewicht->setEnabled(paletteLoaded); + ui->label_Gewicht_kg->setEnabled(paletteLoaded); ui->checkBoxEinzelpaket->setEnabled(paletteLoaded); ui->checkBoxLabelInvert->setEnabled(paletteLoaded); ui->ButtonOpenParameterRoboter->setEnabled(paletteLoaded); @@ -1141,8 +1224,6 @@ void MainWindow::showExperimental() void MainWindow::onLoadPaletteClicked() { - hidePalettePlanCompletionPopup(); - QString fileName = ui->EingabePallettenplan->text().trimmed(); if (fileName.isEmpty()) { showMessage("Bitte Palletierplan eingeben"); @@ -1203,9 +1284,15 @@ void MainWindow::onLoadPaletteClicked() updateVisualizationFromPaletteData(*data); emit paletteLoadRequested(m_currentPaletteFile); + // Successful load commits the selection. + hidePalettePlanCompletionPopup(); qDebug() << "Palette loaded successfully:" << m_currentPaletteFile; } else { showMessage("Palletierplan nicht gefunden: " + fileName); + + // A failed load should keep matching suggestions available so the + // operator can immediately correct the entered prefix/value. + updatePalettePlanCompletionPopup(fileName); } } } @@ -1361,6 +1448,18 @@ void MainWindow::onStartlageChanged(int value) void MainWindow::onRobotStartClicked() { qDebug() << "Robot start clicked"; + + // Commit the currently visible start layer immediately before starting the + // robot. This guarantees that a subsequent UR_Startlage RPC request sees + // the operator's latest selection even if another state update occurred + // after the spin box valueChanged signal. + if (m_state) { + const int startLayer = ui->EingabeStartlage->value(); + m_state->setStartLayer(startLayer); + m_state->setCurrentLayer(startLayer); + qDebug() << "Robot start - committed Startlage:" << startLayer; + } + if (ensureRobotConnected()) { m_robot->play(); } @@ -1595,6 +1694,10 @@ void MainWindow::onUpdateCheckCompleted(bool success, const QString& message) return; } + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + m_autoUpdater->showUpdateDialog(); + return; + } auto updateInfo = m_autoUpdater->availableUpdate(); if (updateInfo.version.isEmpty()) { showMessage("Keine Updates verfuegbar. Aktuelle Version: " + m_autoUpdater->currentVersion()); @@ -1620,6 +1723,10 @@ void MainWindow::onUpdateCheckCompleted(bool success, const QString& message) void MainWindow::onUpdateDownloadProgress(const system::UpdateProgress& progress) { + if (qEnvironmentVariable("MULTIPACK_IMMUTABLE", "0") == "1") { + showMessage(progress.statusText); + return; + } if (progress.bytesTotal > 0) { qint64 percent = (progress.bytesReceived * 100) / progress.bytesTotal; showMessage(QString("Download: %1%").arg(percent)); @@ -2923,6 +3030,11 @@ void MainWindow::setupPalettePlanCompleter() if (value.isEmpty()) { return; } + + // Selecting a suggestion changes the line edit text. Block the + // textChanged callback here so the same suggestion is not shown + // again immediately underneath the selected value. + const QSignalBlocker blocker(ui->EingabePallettenplan); ui->EingabePallettenplan->setText(value); hidePalettePlanCompletionPopup(); ui->EingabePallettenplan->setFocus(Qt::OtherFocusReason); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6ff7611..d00dece 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -82,3 +82,7 @@ if(NOT WIN32) ENVIRONMENT "${TEST_QT_ENV}" ) endif() + +add_executable(test_system_clients test_system_clients.cpp) +target_link_libraries(test_system_clients PRIVATE multipack-lib Qt6::Test) +add_test(NAME test_system_clients COMMAND $) diff --git a/tests/test_mainwindow_integration.cpp b/tests/test_mainwindow_integration.cpp index 5212d21..d1f37f0 100644 --- a/tests/test_mainwindow_integration.cpp +++ b/tests/test_mainwindow_integration.cpp @@ -6,14 +6,23 @@ #include "multipack/database/DatabaseManager.h" #include "multipack/network/XmlRpcServer.h" #include "multipack/ui/MainWindow.h" +#include "multipack/system/AutoUpdater.h" +#include "multipack/message/StatusManager.h" #include #include #include #include +#include +#include +#include #include +#include #include #include +#include +#include +#include #include using multipack::config::SettingsManager; @@ -29,8 +38,77 @@ class MainWindowIntegrationTest : public QObject private slots: void startAndStopServerFromUi(); + void failedPaletteLoadKeepsSuggestionsVisible(); + void successfulPaletteLoadClosesSuggestions(); + void selectedStartLayerReachesRpcAfterRobotStart(); + void immutableUpdateRetryAndOperatorMessages(); }; +void MainWindowIntegrationTest::immutableUpdateRetryAndOperatorMessages() +{ + using namespace multipack::system; + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater(nullptr, server.fullServerName()); + MainWindow window; + window.setAutoUpdater(&updater); + auto* client = updater.findChild(); + QSignalSpy failures(&updater, &AutoUpdater::updateFailed); + QSignalSpy progress(&updater, &AutoUpdater::downloadProgress); + QTRY_VERIFY(server.hasPendingConnections()); + auto* heldStatus = server.nextPendingConnection(); + QTRY_VERIFY(heldStatus->canReadLine()); heldStatus->readLine(); + QTimer acceptDialog; + acceptDialog.setInterval(10); + connect(&acceptDialog, &QTimer::timeout, &window, [] { + for (auto* widget : QApplication::topLevelWidgets()) { + if (auto* dialog = qobject_cast(widget)) { + dialog->setTextValue("rpi-hmi-v2"); + dialog->accept(); + } + } + }); + acceptDialog.start(); + updater.showUpdateDialog(); // User confirms while background status is pending. + QCOMPARE(failures.count(), 1); + QVERIFY(failures.first().first().toString().contains("retry", Qt::CaseInsensitive)); + heldStatus->write("{\"phase\":\"idle\",\"boot\":{}}\n"); heldStatus->flush(); + QTRY_VERIFY(!client->busy()); + auto& messages = multipack::message::StatusManager::instance(); + messages.showError("Operator warning", 10000); + client->response(QJsonObject{{"phase", "idle"}, {"boot", QJsonObject{}}}); + QCOMPARE(messages.currentMessage(), QString("Operator warning")); + QCOMPARE(progress.count(), 0); + int installs = 0; + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* peer = server.nextPendingConnection(); + connect(peer, &QLocalSocket::readyRead, peer, [&, peer] { + if (!peer->canReadLine()) return; + const auto request = QJsonDocument::fromJson(peer->readLine()).object(); + if (request.value("op") == "install") { + QCOMPARE(request.value("version").toString(), QString("rpi-hmi-v2")); + ++installs; + peer->write("{\"accepted\":true}\n"); + } else { + peer->write("{\"phase\":\"error\",\"error\":\"download failed\",\"boot\":{}}\n"); + } + peer->flush(); + }); + }); + QVERIFY(updater.checkForUpdates()); // Saved failure must still open the VERSION dialog. + QTRY_COMPARE(installs, 1); + QTRY_COMPARE(updater.currentStatus(), UpdateStatus::Downloading); + QVERIFY(progress.count() > 0); // Real update progress is still visible. + acceptDialog.stop(); + client->response(QJsonObject{{"phase", "error"}, {"error", "new download failed"}}); + QCOMPARE(failures.count(), 2); + client->response(QJsonObject{{"phase", "error"}, {"error", "new download failed"}}); + QCOMPARE(failures.count(), 2); // Repeated background failure status stays quiet. + qunsetenv("MULTIPACK_IMMUTABLE"); +} + void MainWindowIntegrationTest::startAndStopServerFromUi() { QTemporaryDir tempDir; @@ -89,6 +167,131 @@ void MainWindowIntegrationTest::startAndStopServerFromUi() QTRY_VERIFY(!window.isVisible()); } + +void MainWindowIntegrationTest::failedPaletteLoadKeepsSuggestionsVisible() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + QCOMPARE(database.savePaletteData(testhelpers::makeSamplePaletteData()), SaveResult::Inserted); + + MainWindow window; + window.setDatabaseManager(&database); + window.show(); + + auto* paletteInput = window.findChild("EingabePallettenplan"); + auto* loadButton = window.findChild("LadePallettenplan"); + auto* popup = window.findChild("PalettePlanCompletionPopup"); + + QVERIFY(paletteInput != nullptr); + QVERIFY(loadButton != nullptr); + QVERIFY(popup != nullptr); + + paletteInput->setText("sam"); + QTRY_VERIFY(popup->isVisible()); + + // Simulate the plan disappearing/failing to load after suggestions were + // already presented. Block the database change signal so the popup model + // remains unchanged and we specifically exercise the failed-load UI path. + { + const QSignalBlocker blocker(&database); + QVERIFY(database.deletePalette("sample.rob")); + } + + QTest::mouseClick(loadButton, Qt::LeftButton); + QTRY_VERIFY(popup->isVisible()); +} + +void MainWindowIntegrationTest::successfulPaletteLoadClosesSuggestions() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + + GlobalState::instance().clear(); + + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + QCOMPARE(database.savePaletteData(testhelpers::makeSamplePaletteData()), SaveResult::Inserted); + + MainWindow window; + window.setDatabaseManager(&database); + window.setGlobalState(&GlobalState::instance()); + window.show(); + + auto* paletteInput = window.findChild("EingabePallettenplan"); + auto* loadButton = window.findChild("LadePallettenplan"); + auto* popup = window.findChild("PalettePlanCompletionPopup"); + + QVERIFY(paletteInput != nullptr); + QVERIFY(loadButton != nullptr); + QVERIFY(popup != nullptr); + + paletteInput->setText("sample"); + QTRY_VERIFY(popup->isVisible()); + + QTest::mouseClick(loadButton, Qt::LeftButton); + QTRY_VERIFY(!popup->isVisible()); +} + +void MainWindowIntegrationTest::selectedStartLayerReachesRpcAfterRobotStart() +{ + QTemporaryDir tempDir; + qputenv("MULTIPACK_DISABLE_ROBOT_STATUS_MONITOR", "1"); + auto& state = GlobalState::instance(); + state.clear(); + DatabaseManager database; + QVERIFY(database.open(tempDir.filePath("palettes.db"))); + auto palette = testhelpers::makeSamplePaletteData(); + palette.metadata.anzLagen = 2; + palette.layerAssignments = {1, 1}; + palette.intermediaryLayers = {0, 0}; + QCOMPARE(database.savePaletteData(palette), SaveResult::Inserted); + MainWindow window; + window.setDatabaseManager(&database); + window.setGlobalState(&state); + auto* input = window.findChild("EingabePallettenplan"); + auto* load = window.findChild("LadePallettenplan"); + auto* startLayer = window.findChild("EingabeStartlage"); + QVERIFY(input && load && startLayer); + const QStringList labels = {"label_Startlage", "label_Kartonhoehe", "label_Kartonhoehe_mm", + "label_Gewicht", "label_Gewicht_kg"}; + for (const auto& name : labels) { + auto* label = window.findChild(name); + QVERIFY(label); + QVERIFY(!label->isEnabled()); + } + input->setText("sample"); + load->click(); + for (const auto& name : labels) QVERIFY(window.findChild(name)->isEnabled()); + startLayer->setValue(2); + QCOMPARE(state.startLayer(), 2); + // Reproduce a stale state write after valueChanged. The robot-start slot must + // commit the visible value again even without an available physical robot. + state.setStartLayer(1); + QVERIFY(QMetaObject::invokeMethod(&window, "onRobotStartClicked", Qt::DirectConnection)); + QCOMPARE(state.startLayer(), 2); + XmlRpcServer server; + server.setGlobalState(&state); + server.registerStandardMethods(); + QVERIFY(server.start(0)); + QTcpSocket socket; + socket.connectToHost(QHostAddress::LocalHost, server.port()); + QVERIFY(socket.waitForConnected(1000)); + const QByteArray body = "UR_Startlage"; + socket.write("POST /RPC2 HTTP/1.1\r\nHost: localhost\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\n\r\n" + body); + QTRY_VERIFY_WITH_TIMEOUT(socket.bytesAvailable() > 0, 3000); + const auto reply = socket.readAll(); + QVERIFY2(reply.contains("2"), reply.constData()); + QVERIFY(!reply.contains("")); +} + int main(int argc, char** argv) { #ifdef Q_OS_WIN diff --git a/tests/test_system_clients.cpp b/tests/test_system_clients.cpp new file mode 100644 index 0000000..726e474 --- /dev/null +++ b/tests/test_system_clients.cpp @@ -0,0 +1,192 @@ +#include "multipack/system/SystemUpdaterClient.h" +#include "multipack/system/WifiSsidProbe.h" +#include "multipack/system/AutoUpdater.h" +#include +#include +#include +#include +#include +#include +using namespace multipack::system; +class SystemClientsTest : public QObject { + Q_OBJECT +private slots: + void failedInstallationStatusAllowsAnotherCheckAndInstall() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName()); + QSignalSpy replies(&client, &SystemUpdaterClient::response); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); peer->readLine(); + peer->write("{\"phase\":\"error\",\"error\":\"download failed\"}\n"); peer->flush(); + QTRY_COMPARE(replies.count(), 1); + QCOMPARE(errors.count(), 0); + QCOMPARE(replies.first().first().toJsonObject().value("error").toString(), QString("download failed")); + QVERIFY(client.install("rpi-hmi-v2")); + QTRY_VERIFY(server.hasPendingConnections()); + auto* installPeer = server.nextPendingConnection(); + QTRY_VERIFY(installPeer->canReadLine()); + QCOMPARE(QJsonDocument::fromJson(installPeer->readLine()).object().value("op").toString(), QString("install")); + // An error rejecting this new install remains an error, even with a phase. + installPeer->write("{\"phase\":\"error\",\"error\":\"update already running\"}\n"); installPeer->flush(); + QTRY_COMPARE(errors.count(), 1); + } + void immutableChecksCanRecoverFromSavedFailure() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + connect(&server, &QLocalServer::newConnection, &server, [&] { + auto* peer = server.nextPendingConnection(); + connect(peer, &QLocalSocket::readyRead, peer, [peer] { + if (!peer->canReadLine()) return; + peer->readLine(); + peer->write("{\"phase\":\"error\",\"error\":\"download failed\",\"boot\":{}}\n"); + peer->flush(); + }); + }); + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater(nullptr, server.fullServerName()); + qunsetenv("MULTIPACK_IMMUTABLE"); + auto* client = updater.findChild(); + QSignalSpy replies(client, &SystemUpdaterClient::response); + QSignalSpy completed(&updater, &AutoUpdater::checkCompleted); + QSignalSpy progress(&updater, &AutoUpdater::downloadProgress); + QTRY_COMPARE(replies.count(), 1); + for (int i = 1; i <= 2; ++i) { + QVERIFY(updater.checkForUpdates()); + QTRY_COMPARE(completed.count(), i); + QVERIFY(completed.last().first().toBool()); + QVERIFY(completed.last().at(1).toString().contains("download failed")); + } + QCOMPARE(progress.count(), 0); // Idle/error observations never overwrite operator messages. + } + void versionBoundary() { + QVERIFY(SystemUpdaterClient::validVersion("v2.0.0-rc_1")); + QVERIFY(SystemUpdaterClient::validVersion(QString(80, 'a'))); + for (const QString& v : {QString(), QString("../root"), QString("https://host"), + QString("x\n"), QString("x;reboot"), QString(81, 'a')}) + QVERIFY(!SystemUpdaterClient::validVersion(v)); + } + void fakeSocketInstallAndFragmentedStatus() { + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName()); + QSignalSpy replies(&client, &SystemUpdaterClient::response); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.install("v2.0")); + QVERIFY(!client.status()); // Never overlap requests. + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); + const auto request = QJsonDocument::fromJson(peer->readLine()).object(); + QCOMPARE(request, (QJsonObject{{"op", "install"}, {"version", "v2.0"}})); + peer->write("{\"accepted\":true}\n"); peer->flush(); + QTRY_COMPARE(replies.count(), 1); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* statusPeer = server.nextPendingConnection(); + QTRY_VERIFY(statusPeer->canReadLine()); + QCOMPARE(QJsonDocument::fromJson(statusPeer->readLine()).object(), (QJsonObject{{"op", "status"}})); + statusPeer->write("{\"phase\":"); statusPeer->flush(); + QTest::qWait(20); + QCOMPARE(replies.count(), 1); + statusPeer->write("\"installing\",\"percent\":42}\n"); statusPeer->flush(); + QTRY_COMPARE(replies.count(), 2); + QCOMPARE(errors.count(), 0); + } + void failures_data() { + QTest::addColumn("reply"); + QTest::newRow("daemon error") << QByteArray("{\"error\":\"update already running\"}\n"); + QTest::newRow("malformed") << QByteArray("bad json\n"); + QTest::newRow("schema") << QByteArray("{}\n"); + QTest::newRow("oversized") << QByteArray(65537, 'x'); + QTest::newRow("timeout") << QByteArray(); + } + void failures() { + QFETCH(QByteArray, reply); + QTemporaryDir dir; + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + SystemUpdaterClient client(nullptr, server.fullServerName(), 200); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_VERIFY(server.hasPendingConnections()); + auto* peer = server.nextPendingConnection(); + QTRY_VERIFY(peer->canReadLine()); peer->readLine(); + peer->write(reply); peer->flush(); + QTRY_COMPARE(errors.count(), 1); + QVERIFY(client.status()); // Retry is safe after failure. + client.cancel(); + } + void missingSocketAndClose() { + QTemporaryDir dir; + SystemUpdaterClient client(nullptr, dir.filePath("absent")); + QSignalSpy errors(&client, &SystemUpdaterClient::error); + QVERIFY(client.status()); + QTRY_COMPARE(errors.count(), 1); + QLocalServer server; + QVERIFY(server.listen(dir.filePath("control.sock"))); + auto* closing = new SystemUpdaterClient(nullptr, server.fullServerName()); + QVERIFY(closing->status()); + QTRY_VERIFY(server.hasPendingConnections()); + delete closing; // Pending socket is owned and cancelled on destruction. + } + void wifiParsingAndMissingUtility() { + QCOMPARE(WifiSsidProbe::parse("no:other\nyes: A:B\\C \n"), QString(" A:B\\C ")); + WifiSsidProbe probe(nullptr, "/missing/nmcli"); + QSignalSpy result(&probe, &WifiSsidProbe::result); + probe.refresh(); + QTRY_COMPARE(result.count(), 1); + QVERIFY(result.first().first().toString().contains("unavailable")); + } +#ifndef Q_OS_WIN + void wifiProbeUsesStableLocaleWithoutChangingParentEnvironment() { + QTemporaryDir dir; + QFile script(dir.filePath("localized-nmcli")); + QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nif [ \"$LC_ALL\" = C ]; then printf 'yes:Factory\\n'; else printf 'ja:Factory\\n'; fi\n"); + script.close(); + QVERIFY(script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto previous = qgetenv("LC_ALL"); + const bool wasSet = qEnvironmentVariableIsSet("LC_ALL"); + qputenv("LC_ALL", "de_DE.UTF-8"); + WifiSsidProbe probe(nullptr, script.fileName()); + QSignalSpy results(&probe, &WifiSsidProbe::result); + probe.refresh(); + QTRY_COMPARE(results.count(), 1); + QCOMPARE(results.first().first().toString(), QString("Factory")); + QCOMPARE(qgetenv("LC_ALL"), QByteArray("de_DE.UTF-8")); + if (wasSet) qputenv("LC_ALL", previous); else qunsetenv("LC_ALL"); + } + void slowToolDoesNotBlockEventLoop() { + QTemporaryDir dir; + QFile script(dir.filePath("slow-nmcli")); + QVERIFY(script.open(QIODevice::WriteOnly)); + script.write("#!/bin/sh\nexec sleep 10\n"); script.close(); + QVERIFY(script.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + WifiSsidProbe probe(nullptr, script.fileName(), 100); + QSignalSpy result(&probe, &WifiSsidProbe::result); + bool heartbeat = false; + QTimer::singleShot(10, &probe, [&] { heartbeat = true; }); + probe.refresh(); + QTRY_VERIFY_WITH_TIMEOUT(heartbeat, 90); + QTRY_COMPARE(result.count(), 1); + QVERIFY(result.first().first().toString().contains("timeout")); + } +#endif + void immutableRejectsLegacyUsbPath() { + qputenv("MULTIPACK_IMMUTABLE", "1"); + AutoUpdater updater; + QSignalSpy errors(&updater, &AutoUpdater::checkFailed); + QVERIFY(!updater.checkForUsbUpdates("/any/path")); + QCOMPARE(errors.count(), 1); + qunsetenv("MULTIPACK_IMMUTABLE"); + } +}; +QTEST_GUILESS_MAIN(SystemClientsTest) +#include "test_system_clients.moc"