diff --git a/build_x64.bat b/build_x64.bat index 4c7cf0e..d0ed543 100644 --- a/build_x64.bat +++ b/build_x64.bat @@ -61,14 +61,33 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) -echo === [5/5] Uploading and sending link to Telegram === -for /f "delims=" %%B in ('powershell -NoProfile -Command "[guid]::NewGuid().ToString('N').Substring(0,16)"') do set BIN_ID=arc-%%B -curl -s -H "Content-Type: application/octet-stream" -X POST --data-binary "@%PROJECT_DIR%%ZIP_NAME%" "https://filebin.net/!BIN_ID!/%ZIP_NAME%" > nul +echo === [5/5] Uploading to update server and sending link to Telegram === + +REM Целевой сервер обновлений (нужен настроенный SSH-ключ для root@%UPDATE_HOST%). +set UPDATE_HOST=142.93.102.40 +set UPDATE_DIR=/var/www/updates +set DOWNLOAD_URL=http://%UPDATE_HOST%/updates/%ZIP_NAME% + +REM SHA256 архива — для проверки целостности на клиенте. +for /f "delims=" %%H in ('powershell -NoProfile -Command "(Get-FileHash '%PROJECT_DIR%%ZIP_NAME%' -Algorithm SHA256).Hash.ToLower()"') do set ZIP_SHA256=%%H +echo SHA256: !ZIP_SHA256! + +REM Сначала заливаем zip, потом манифест — чтобы клиент не увидел version.json +REM на ещё не загруженный архив. +scp "%PROJECT_DIR%%ZIP_NAME%" root@%UPDATE_HOST%:%UPDATE_DIR%/%ZIP_NAME% if %ERRORLEVEL% neq 0 ( - echo ERROR: Failed to upload archive + echo ERROR: Failed to upload archive to update server + exit /b 1 +) + +REM Генерируем version.json (UTF-8 без BOM — иначе QJsonDocument::fromJson падает) +REM и заливаем последним. +powershell -NoProfile -Command "$j = @{ latestVersion='!CURRENT_VERSION!'; zipUrl='!DOWNLOAD_URL!'; sha256='!ZIP_SHA256!'; minVersion='1.0.0'; notes='Обновление ARC'; mandatory=$false } | ConvertTo-Json; [System.IO.File]::WriteAllText('%PROJECT_DIR%version.json', $j, (New-Object System.Text.UTF8Encoding($false)))" +scp "%PROJECT_DIR%version.json" root@%UPDATE_HOST%:%UPDATE_DIR%/version.json +if %ERRORLEVEL% neq 0 ( + echo ERROR: Failed to upload version.json to update server exit /b 1 ) -set DOWNLOAD_URL=https://filebin.net/!BIN_ID!/%ZIP_NAME% echo Upload URL: !DOWNLOAD_URL! curl -s -X POST "https://api.telegram.org/bot%TG_BOT_TOKEN%/sendMessage" -d "chat_id=%TG_CHAT_ID%" -d "text=ARC x64 v!CURRENT_VERSION!: !DOWNLOAD_URL!" diff --git a/config.ini b/config.ini index 07fb67c..042eddf 100644 --- a/config.ini +++ b/config.ini @@ -24,6 +24,9 @@ pathMonitoringDump=/monitoring/dump monitoringBase=http://142.93.102.40:8080 monitoringToken=509b91592749959b9ff56f9ed9189e5b3e91e1260003f63c477255e1c187298a +[update] +manifestUrl=http://142.93.102.40/updates/version.json + [black] android_package_name=app.blackwallet.ar currency=ARS diff --git a/main.cpp b/main.cpp index 2c9eaf1..cfe5414 100644 --- a/main.cpp +++ b/main.cpp @@ -55,6 +55,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) { #include "dao/SettingsDAO.h" #include "EventHandler.h" #include "net/NetworkService.h" +#include "update/ApplyUpdate.h" #include "black/PayByCardScript.h" #include "ozon/PayByPhoneScript.h" #include "widget/loader/ErrorWindow.h" @@ -301,6 +302,14 @@ int main(int argc, char *argv[]) { #ifdef Q_OS_WIN SetUnhandledExceptionFilter(crashHandler); #endif + + // Режим установки обновления: запущены как временная копия ARC.exe с + // --apply-update. Перехватываем ДО QApplication/БД/логгера — это headless + // файловый воркер, а не основное приложение. + if (isApplyUpdateMode(argc, argv)) { + return runApplyUpdate(argc, argv); + } + QApplication app(argc, argv); app.setApplicationName("ARC"); app.setWindowIcon(QIcon("arc.png")); @@ -380,6 +389,7 @@ int main(int argc, char *argv[]) { mainWindow->show(); registrationWindow->close(); NetworkService *net = startNetworkService(app); + mainWindow->setNetworkService(net); QObject::connect(net, &NetworkService::syncHealthChanged, mainWindow, &MainWindow::setSyncHealth, Qt::QueuedConnection); @@ -414,6 +424,7 @@ int main(int argc, char *argv[]) { auto *mainWindow = new MainWindow(); mainWindow->show(); NetworkService *net = startNetworkService(app); + mainWindow->setNetworkService(net); QObject::connect(net, &NetworkService::syncHealthChanged, mainWindow, &MainWindow::setSyncHealth, Qt::QueuedConnection); @@ -438,6 +449,7 @@ int main(int argc, char *argv[]) { auto *mainWindow = new MainWindow(); mainWindow->show(); NetworkService *net = startNetworkService(app); + mainWindow->setNetworkService(net); QObject::connect(net, &NetworkService::syncHealthChanged, mainWindow, &MainWindow::setSyncHealth, Qt::QueuedConnection); diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index a33d567..a9cfc2c 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -18,6 +18,7 @@ #include "dao/TransactionDAO.h" #include "db/BankProfileInfo.h" #include "adb/AdbUtils.h" +#include "update/UpdatePause.h" #include "dao/GeneralLogDAO.h" #include "net/NetworkService.h" #include "black/GetLastTransactionsScript.h" @@ -48,6 +49,9 @@ void EventHandler::start() { m_pollTimer = new QTimer(this); m_pollTimer->setInterval(5000); connect(m_pollTimer, &QTimer::timeout, this, [this]() { + // На время установки обновления не трогаем устройства и не запрашиваем задачи. + if (UpdatePause::isPaused()) return; + // Пробуем запустить задачи из очереди для свободных устройств const QStringList onlineDevices = DeviceDAO::getOnlineDeviceCodes(); for (const QString &deviceId : onlineDevices) { diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 0d0ca0f..bd03cca 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -26,6 +26,7 @@ #include "dao/SettingsDAO.h" #include "dao/TransactionDAO.h" #include "db/EventInfo.h" +#include "update/UpdatePause.h" // Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только // Хелперы для логирования сетевых ошибок вынесены в NetworkErrorUtils.h @@ -1822,6 +1823,13 @@ void NetworkService::scheduleNextStatusSync() { QTimer::singleShot(kPeriodicSyncMs, this, [this]() { if (!m_running) return; + // На время установки обновления пропускаем тик: иначе sync перепушит + // локальное "active" обратно на сервер и отменит выключение профилей. + if (UpdatePause::isPaused()) { + scheduleNextStatusSync(); + return; + } + // Если предыдущий тик ещё не завершился — пропускаем (правило #10). if (m_syncInProgress.exchange(true)) { qDebug() << "[NetworkService] periodic sync tick skipped — previous still in progress"; diff --git a/services/update/ApplyUpdate.cpp b/services/update/ApplyUpdate.cpp new file mode 100644 index 0000000..8c7c516 --- /dev/null +++ b/services/update/ApplyUpdate.cpp @@ -0,0 +1,129 @@ +#include "ApplyUpdate.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#endif + +namespace { + +void logLine(const QString &logPath, const QString &msg) { + QFile f(logPath); + if (f.open(QIODevice::Append | QIODevice::Text)) { + QTextStream(&f) << msg << '\n'; + } + fprintf(stderr, "[apply-update] %s\n", msg.toLocal8Bit().constData()); +} + +QString argValue(const QStringList &args, const QString &key) { + const int idx = args.indexOf(key); + if (idx >= 0 && idx + 1 < args.size()) return args.at(idx + 1); + return QString(); +} + +} // namespace + +bool isApplyUpdateMode(int argc, char **argv) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--apply-update") == 0) return true; + } + return false; +} + +int runApplyUpdate(int argc, char **argv) { + // Headless-приложение: даёт QProcess/QFile/QDir, но не поднимает GUI. + QCoreApplication app(argc, argv); + const QStringList args = app.arguments(); + + const QString src = QDir::cleanPath(argValue(args, QStringLiteral("--src"))); + const QString dst = QDir::cleanPath(argValue(args, QStringLiteral("--dst"))); + const QString relaunch = argValue(args, QStringLiteral("--relaunch")); + const QString pidStr = argValue(args, QStringLiteral("--pid")); + + // Корень стейджинга = родитель src (…/ARC_update/unpacked → …/ARC_update). + const QString root = QFileInfo(src).path(); + const QString logPath = root + QStringLiteral("/updater.log"); + + logLine(logPath, QStringLiteral("start: src=%1 dst=%2 pid=%3").arg(src, dst, pidStr)); + + if (src.isEmpty() || dst.isEmpty() || relaunch.isEmpty()) { + logLine(logPath, QStringLiteral("ERROR: missing arguments")); + return 2; + } + +#ifdef Q_OS_WIN + // 1) Ждём выхода родительского процесса. + const DWORD pid = pidStr.toULong(); + if (pid != 0) { + HANDLE h = OpenProcess(SYNCHRONIZE, FALSE, pid); + if (h) { + logLine(logPath, QStringLiteral("waiting for parent to exit...")); + WaitForSingleObject(h, 30000); + CloseHandle(h); + } + } + + // 2) Settle-цикл: дожидаемся, пока install/ARC.exe можно открыть на запись + // (переживаем антивирус и подвисшие хэндлы). До ~12 секунд. + const QString dstExe = dst + QStringLiteral("/ARC.exe"); + if (QFile::exists(dstExe)) { + const std::wstring wexe = QDir::toNativeSeparators(dstExe).toStdWString(); + for (int i = 0; i < 12; ++i) { + HANDLE fh = CreateFileW(wexe.c_str(), GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (fh != INVALID_HANDLE_VALUE) { + CloseHandle(fh); + break; + } + QThread::msleep(1000); + } + } + + // 3) Копируем новые файлы поверх install-папки, сохраняя database/. + // robocopy сам ретраит залоченные файлы; коды возврата 0–7 = успех. + QProcess robo; + robo.start(QStringLiteral("robocopy"), { + src, dst, QStringLiteral("/E"), + QStringLiteral("/XD"), QStringLiteral("database"), + QStringLiteral("/R:5"), QStringLiteral("/W:1"), + QStringLiteral("/NFL"), QStringLiteral("/NDL"), + QStringLiteral("/NJH"), QStringLiteral("/NJS"), + }); + robo.waitForFinished(-1); + const int code = robo.exitCode(); + logLine(logPath, QStringLiteral("robocopy exit code: %1").arg(code)); + if (code >= 8) { + logLine(logPath, QStringLiteral("ERROR: robocopy failed")); + // Всё равно пробуем перезапустить — часть файлов могла скопироваться. + } + + // 4) Перезапускаем установленный (уже обновлённый) ARC.exe. + const bool relaunched = QProcess::startDetached(relaunch, {}, dst); + logLine(logPath, QStringLiteral("relaunch %1: %2") + .arg(relaunch, relaunched ? QStringLiteral("ok") : QStringLiteral("FAILED"))); + + // 5) Самоудаление временной папки: апдейтер не может удалить свой работающий + // exe, поэтому откладываем через cmd с задержкой. + const QString rmCmd = QStringLiteral("ping 127.0.0.1 -n 4 >nul & rmdir /s /q \"%1\"") + .arg(QDir::toNativeSeparators(root)); + QProcess::startDetached(QStringLiteral("cmd.exe"), + {QStringLiteral("/c"), rmCmd}); + + return 0; +#else + Q_UNUSED(relaunch); + logLine(logPath, QStringLiteral("apply-update is Windows-only; no-op")); + return 0; +#endif +} diff --git a/services/update/ApplyUpdate.h b/services/update/ApplyUpdate.h new file mode 100644 index 0000000..0c32d0a --- /dev/null +++ b/services/update/ApplyUpdate.h @@ -0,0 +1,14 @@ +#pragma once + +// Режим установки обновления. Запускается из свежераспакованной копии ARC.exe +// (в ней есть все Qt-DLL), ДО создания QApplication в main(). Ждёт выхода +// родительского процесса, копирует новые файлы поверх install-папки (сохраняя +// database/), перезапускает установленный ARC.exe и самоудаляет временную папку. + +// true, если среди аргументов есть "--apply-update". Дёшево, без Qt — можно +// звать в самом начале main() до QApplication. +bool isApplyUpdateMode(int argc, char **argv); + +// Выполняет установку. Создаёт собственный headless QCoreApplication. Возвращает +// код возврата процесса (0 — успех). На не-Windows — no-op, возвращает 0. +int runApplyUpdate(int argc, char **argv); diff --git a/services/update/UpdatePause.h b/services/update/UpdatePause.h new file mode 100644 index 0000000..8c82671 --- /dev/null +++ b/services/update/UpdatePause.h @@ -0,0 +1,16 @@ +#pragma once +#include + +// Глобальная пауза на время установки обновления. Ставится из UI сразу после +// подтверждения апдейта (до выключения профилей и скачивания) и снимается, если +// обновление не удалось. Пока выставлена: +// - EventHandler не опрашивает getTasks и не запускает новые задачи; +// - периодический sync NetworkService пропускает тик (не перепушивает локальное +// "active" обратно на сервер, чтобы профили остались выключенными). +// Процесс-локальный флаг: при перезапуске после установки сбрасывается сам. +namespace UpdatePause { +inline std::atomic g_paused{false}; + +inline void setPaused(bool v) { g_paused.store(v); } +inline bool isPaused() { return g_paused.load(); } +} // namespace UpdatePause diff --git a/services/update/UpdateService.cpp b/services/update/UpdateService.cpp new file mode 100644 index 0000000..303657c --- /dev/null +++ b/services/update/UpdateService.cpp @@ -0,0 +1,241 @@ +#include "UpdateService.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Прокручивает event loop пока reply не завершится либо не сработает таймаут. +// true — reply finished, false — сработал наш QTimer (client timeout). +bool awaitReply(QNetworkReply *reply, int timeoutMs) { + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(timeoutMs); + loop.exec(); + return timer.isActive(); +} + +constexpr int kManifestTimeoutMs = 15000; + +// Корень временной папки для стейджинга обновления (вне install-директории). +QString stagingRoot() { + return QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/ARC_update"; +} +} // namespace + +UpdateService::UpdateService(QObject *parent) : QObject(parent) { + const QSettings settings("config.ini", QSettings::IniFormat); + m_manifestUrl = settings.value("update/manifestUrl").toString(); + m_currentVersion = settings.value("common/version").toString(); + m_manager = new QNetworkAccessManager(this); +} + +UpdateService::~UpdateService() = default; + +int UpdateService::compareVersions(const QString &a, const QString &b) { + const QStringList pa = a.split('.'); + const QStringList pb = b.split('.'); + const int n = qMax(pa.size(), pb.size()); + for (int i = 0; i < n; ++i) { + const int va = i < pa.size() ? pa[i].toInt() : 0; + const int vb = i < pb.size() ? pb[i].toInt() : 0; + if (va != vb) return va < vb ? -1 : 1; + } + return 0; +} + +void UpdateService::checkForUpdates() { + if (m_manifestUrl.isEmpty()) { + emit checkFailed(QStringLiteral("Не настроен адрес обновлений (update/manifestUrl)")); + return; + } + + QNetworkRequest req((QUrl(m_manifestUrl))); + QNetworkReply *reply = m_manager->get(req); + if (!awaitReply(reply, kManifestTimeoutMs)) { + reply->abort(); + reply->deleteLater(); + emit checkFailed(QStringLiteral("Таймаут соединения с сервером обновлений")); + return; + } + if (reply->error() != QNetworkReply::NoError) { + const QString err = reply->errorString(); + reply->deleteLater(); + emit checkFailed(QStringLiteral("Сеть: ") + err); + return; + } + + const QByteArray body = reply->readAll(); + reply->deleteLater(); + + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) { + emit checkFailed(QStringLiteral("Некорректный формат манифеста")); + return; + } + const QJsonObject o = doc.object(); + const QString latest = o.value("latestVersion").toString(); + const QString zipUrl = o.value("zipUrl").toString(); + const QString sha = o.value("sha256").toString(); + const QString notes = o.value("notes").toString(); + if (latest.isEmpty() || zipUrl.isEmpty()) { + emit checkFailed(QStringLiteral("В манифесте нет latestVersion/zipUrl")); + return; + } + + if (compareVersions(latest, m_currentVersion) <= 0) { + emit upToDate(m_currentVersion); + return; + } + emit updateAvailable(latest, notes, zipUrl, sha); +} + +void UpdateService::downloadAndApply(const QString &zipUrl, + const QString &expectedSha256, + const QString &newVersion) { + const QString root = stagingRoot(); + // Чистим прошлый стейджинг, если остался. + QDir(root).removeRecursively(); + if (!QDir().mkpath(root)) { + emit stageFailed(QStringLiteral("Не удалось создать временную папку")); + return; + } + + const QString zipFile = root + QStringLiteral("/ARC_x64_") + newVersion + QStringLiteral(".zip"); + if (!downloadZip(zipUrl, zipFile)) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("Не удалось скачать обновление")); + return; + } + + if (!expectedSha256.isEmpty() && !verifySha256(zipFile, expectedSha256)) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("Контрольная сумма архива не совпала")); + return; + } + + const QString unpacked = root + QStringLiteral("/unpacked"); + if (!QDir().mkpath(unpacked)) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("Не удалось создать папку распаковки")); + return; + } + if (!unpackZip(zipFile, unpacked)) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("Не удалось распаковать обновление")); + return; + } + if (!QFile::exists(unpacked + QStringLiteral("/ARC.exe"))) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("В архиве не найден ARC.exe")); + return; + } + + if (!spawnUpdater(unpacked)) { + QDir(root).removeRecursively(); + emit stageFailed(QStringLiteral("Не удалось запустить апдейтер")); + return; + } + + emit updateStaged(); +} + +bool UpdateService::downloadZip(const QString &zipUrl, const QString &destFile) { + QFile file(destFile); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "[UpdateService] cannot open" << destFile << "for write"; + return false; + } + + QNetworkRequest req((QUrl(zipUrl))); + QNetworkReply *reply = m_manager->get(req); + + QObject::connect(reply, &QNetworkReply::downloadProgress, this, + [this](qint64 r, qint64 t) { emit downloadProgress(r, t); }); + QObject::connect(reply, &QNetworkReply::readyRead, reply, + [reply, &file]() { file.write(reply->readAll()); }); + + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + file.write(reply->readAll()); + file.close(); + + const bool ok = reply->error() == QNetworkReply::NoError; + if (!ok) qWarning() << "[UpdateService] download error:" << reply->errorString(); + reply->deleteLater(); + return ok; +} + +bool UpdateService::verifySha256(const QString &file, const QString &expectedHex) { + QFile f(file); + if (!f.open(QIODevice::ReadOnly)) return false; + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!hash.addData(&f)) return false; + const QString got = QString::fromLatin1(hash.result().toHex()); + return got.compare(expectedHex.trimmed(), Qt::CaseInsensitive) == 0; +} + +bool UpdateService::unpackZip(const QString &zipFile, const QString &destDir) { +#ifdef Q_OS_WIN + // Expand-Archive — тот же инструмент, что создаёт архив в build_x64.bat. + const QString cmd = QStringLiteral( + "Expand-Archive -Path '%1' -DestinationPath '%2' -Force").arg(zipFile, destDir); + const int rc = QProcess::execute(QStringLiteral("powershell"), + {QStringLiteral("-NoProfile"), QStringLiteral("-Command"), cmd}); + return rc == 0; +#else + // Для локальной разработки/тестов на macOS/Linux. + const int rc = QProcess::execute(QStringLiteral("unzip"), + {QStringLiteral("-o"), zipFile, QStringLiteral("-d"), destDir}); + return rc == 0; +#endif +} + +bool UpdateService::spawnUpdater(const QString &unpackedDir) { +#ifdef Q_OS_WIN + const QString updaterExe = unpackedDir + QStringLiteral("/ARC.exe"); + if (!QFile::exists(updaterExe)) { + qWarning() << "[UpdateService] updater exe not found:" << updaterExe; + return false; + } + + const QString installDir = QCoreApplication::applicationDirPath(); + const QString installedExe = QCoreApplication::applicationFilePath(); + const qint64 pid = QCoreApplication::applicationPid(); + + const QStringList args = { + QStringLiteral("--apply-update"), + QStringLiteral("--pid"), QString::number(pid), + QStringLiteral("--src"), unpackedDir, + QStringLiteral("--dst"), installDir, + QStringLiteral("--relaunch"), installedExe, + }; + + // Рабочая папка — нейтральный temp, чтобы апдейтер ничего не держал залоченным. + const QString workDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + const bool ok = QProcess::startDetached(updaterExe, args, workDir); + if (!ok) qWarning() << "[UpdateService] failed to start updater process"; + return ok; +#else + Q_UNUSED(unpackedDir); + qWarning() << "[UpdateService] spawnUpdater is Windows-only"; + return false; +#endif +} diff --git a/services/update/UpdateService.h b/services/update/UpdateService.h new file mode 100644 index 0000000..92fb313 --- /dev/null +++ b/services/update/UpdateService.h @@ -0,0 +1,61 @@ +#pragma once +#include +#include + +class QNetworkAccessManager; + +// Сервис ручной проверки и установки обновлений (Windows). Живёт в собственном +// QThread, как NetworkService. Полный цикл: GET манифеста → сравнение версий → +// скачивание zip → проверка sha256 → распаковка → запуск распакованного +// ARC.exe в режиме --apply-update → выход приложения (UI делает qApp->quit()). +// +// Установку выполняет НЕ это приложение, а свежераспакованная копия ARC.exe +// (в ней есть все Qt-DLL): она ждёт выхода текущего процесса, копирует файлы +// поверх install-папки (сохраняя database/), перезапускает установленный +// ARC.exe и самоудаляет временную папку. +class UpdateService final : public QObject { + Q_OBJECT + +public: + explicit UpdateService(QObject *parent = nullptr); + ~UpdateService() override; + + // Сравнение версий вида "MAJOR.MINOR.PATCH" покомпонентно как чисел. + // <0 если a0 если a>b. Недостающие сегменты считаются 0, + // поэтому "1.0.9" < "1.0.64" (в отличие от строкового сравнения). + static int compareVersions(const QString &a, const QString &b); + +public slots: + // Шаг 1. GET манифеста, сравнение с локальной версией из config.ini. + // Эмитит upToDate / updateAvailable / checkFailed. + void checkForUpdates(); + + // Шаг 2. Скачать zip → sha256 → распаковать → запустить апдейтер. + // Вызывается из UI после подтверждения пользователя (через invokeMethod). + // Эмитит downloadProgress по ходу, затем updateStaged либо stageFailed. + void downloadAndApply(const QString &zipUrl, + const QString &expectedSha256, + const QString &newVersion); + +signals: + void upToDate(const QString ¤tVersion); + void updateAvailable(const QString &newVersion, const QString ¬es, + const QString &zipUrl, const QString &sha256); + void checkFailed(const QString &reason); + void downloadProgress(qint64 received, qint64 total); + void stageFailed(const QString &reason); + // Всё готово, апдейтер запущен. UI должен вызвать qApp->quit(). + void updateStaged(); + +private: + QString m_manifestUrl; + QString m_currentVersion; + QNetworkAccessManager *m_manager; + + bool downloadZip(const QString &zipUrl, const QString &destFile); + static bool verifySha256(const QString &file, const QString &expectedHex); + bool unpackZip(const QString &zipFile, const QString &destDir); + // Запускает /ARC.exe --apply-update с PID/путями. На не-Windows + // возвращает false (апдейтер только для Windows). + bool spawnUpdater(const QString &unpackedDir); +}; diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index 0bba6f2..a578052 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -28,8 +28,12 @@ #include "dao/SettingsDAO.h" #include #include +#include +#include #include #include "widget/common/FlowLayout.h" +#include "update/UpdateService.h" +#include "update/UpdatePause.h" MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { const QSettings settings("config.ini", QSettings::IniFormat); @@ -64,12 +68,15 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *tutorialPageAction = new QAction("Инструкция", this); auto *eventAction = new QAction("События", this); auto *fileLogAction = new QAction("Логи", this); + auto *updateAction = new QAction("Проверить обновления", this); + m_updateAction = updateAction; auto *exitAction = new QAction("Выйти", this); menu->addAction(devicesPageAction); // menu->addAction(accountPageAction); menu->addAction(eventAction); menu->addAction(tutorialPageAction); menu->addAction(fileLogAction); + menu->addAction(updateAction); menu->addAction(exitAction); connect(devicesPageAction, &QAction::triggered, this, [=]() { @@ -117,6 +124,8 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { } }); + connect(updateAction, &QAction::triggered, this, &MainWindow::checkForUpdates); + connect(exitAction, &QAction::triggered, this, [this]() { QMessageBox msgBox(this); msgBox.setWindowTitle("Выход"); @@ -162,6 +171,94 @@ void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) { m_syncHealthLabel->setVisible(true); } +void MainWindow::checkForUpdates() { + if (m_updateAction) m_updateAction->setEnabled(false); + auto reEnable = [this]() { if (m_updateAction) m_updateAction->setEnabled(true); }; + + // UpdateService живёт в своём потоке (как NetworkService). Сигналы приходят + // в главный поток через QueuedConnection — диалоги показываем здесь. + auto *thread = new QThread; + auto *svc = new UpdateService; + svc->moveToThread(thread); + connect(thread, &QThread::started, svc, &UpdateService::checkForUpdates); + connect(svc, &QObject::destroyed, thread, &QThread::quit); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + + connect(svc, &UpdateService::upToDate, this, [this, svc, reEnable](const QString &ver) { + QMessageBox::information(this, "Обновление", + QString("У вас последняя версия (%1).").arg(ver)); + reEnable(); + svc->deleteLater(); + }); + + connect(svc, &UpdateService::checkFailed, this, [this, svc, reEnable](const QString &reason) { + QMessageBox::warning(this, "Обновление", + "Не удалось проверить обновления:\n" + reason); + reEnable(); + svc->deleteLater(); + }); + + connect(svc, &UpdateService::updateAvailable, this, + [this, svc, reEnable](const QString &ver, const QString ¬es, + const QString &zipUrl, const QString &sha) { + QString text = QString("Доступна новая версия: %1.\nУстановить сейчас?\n\n" + "Приложение перезапустится автоматически.").arg(ver); + if (!notes.isEmpty()) text += "\n\n" + notes; + const auto btn = QMessageBox::question(this, "Обновление", text, + QMessageBox::Yes | QMessageBox::No); + if (btn != QMessageBox::Yes) { + reEnable(); + svc->deleteLater(); + return; + } + + // Ставим паузу: EventHandler перестаёт опрашивать задачи, а + // периодический sync — перепушивать "active" на сервер. Затем сразу + // выключаем все профили на сервере (на выходе stop() повторит идемпотентно). + UpdatePause::setPaused(true); + if (m_network) { + QMetaObject::invokeMethod(m_network, "disableAllProfilesOnServer", + Qt::QueuedConnection); + } + + auto *progress = new QProgressDialog("Загрузка обновления…", QString(), 0, 100, this); + progress->setWindowTitle("Обновление"); + progress->setWindowModality(Qt::WindowModal); + progress->setMinimumDuration(0); + progress->setAutoClose(false); + progress->setAutoReset(false); + progress->setCancelButton(nullptr); + progress->setValue(0); + + connect(svc, &UpdateService::downloadProgress, progress, + [progress](qint64 r, qint64 t) { + if (t > 0) progress->setValue(static_cast(r * 100 / t)); + }); + connect(svc, &UpdateService::stageFailed, this, + [this, svc, progress, reEnable](const QString &reason) { + progress->close(); + progress->deleteLater(); + // Обновление не удалось — снимаем паузу, приложение продолжает + // работу. Периодический sync восстановит профили на сервере. + UpdatePause::setPaused(false); + QMessageBox::warning(this, "Обновление", "Ошибка установки:\n" + reason); + reEnable(); + svc->deleteLater(); + }); + connect(svc, &UpdateService::updateStaged, this, [progress]() { + progress->setLabelText("Перезапуск…"); + progress->setValue(100); + qApp->quit(); // штатное завершение: aboutToQuit → stop() → disable + }); + + QMetaObject::invokeMethod(svc, "downloadAndApply", Qt::QueuedConnection, + Q_ARG(QString, zipUrl), Q_ARG(QString, sha), + Q_ARG(QString, ver)); + }); + + thread->start(); +} + void MainWindow::createDevicePage() { if (m_devicesWindow) { diff --git a/views/MainWindow.h b/views/MainWindow.h index 1e7e637..6847985 100644 --- a/views/MainWindow.h +++ b/views/MainWindow.h @@ -13,6 +13,9 @@ #include "log/FileLogWindow.h" #include "widget/common/FlowLayout.h" +class NetworkService; +class QAction; + class MainWindow final : public QMainWindow { Q_OBJECT @@ -21,6 +24,10 @@ public: void loadDevices(); + // Указатель на живой NetworkService (создаётся в main.cpp). Нужен, чтобы при + // старте обновления выключить все профили на сервере. Может быть nullptr. + void setNetworkService(NetworkService *net) { m_network = net; } + public slots: // Подключается к NetworkService::syncHealthChanged. healthy=false показывает // индикатор «нет связи» в status bar, healthy=true прячет (правило #9). @@ -45,8 +52,14 @@ private: QListWidget *m_logList = nullptr; QLabel *m_syncHealthLabel = nullptr; + NetworkService *m_network = nullptr; + QAction *m_updateAction = nullptr; + void createDevicePage(); void deleteDevicePage(); void updateLogPanel(); + // Ручная проверка обновлений: поднимает UpdateService в отдельном потоке, + // показывает диалоги/прогресс, на готовности апдейтера завершает приложение. + void checkForUpdates(); void resizeEvent(QResizeEvent *event) override; };