250 lines
9.8 KiB
C++
250 lines
9.8 KiB
C++
#include "UpdateService.h"
|
||
|
||
#include <QCoreApplication>
|
||
#include <QCryptographicHash>
|
||
#include <QDir>
|
||
#include <QEventLoop>
|
||
#include <QFile>
|
||
#include <QJsonDocument>
|
||
#include <QJsonObject>
|
||
#include <QNetworkAccessManager>
|
||
#include <QNetworkReply>
|
||
#include <QNetworkRequest>
|
||
#include <QProcess>
|
||
#include <QSettings>
|
||
#include <QStandardPaths>
|
||
#include <QTimer>
|
||
#include <QUrl>
|
||
|
||
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;
|
||
// Таймаут простоя загрузки zip: если данные перестали течь на это время —
|
||
// reply прерывается с OperationCanceledError, downloadZip вернёт false и
|
||
// downloadAndApply штатно эмитит stageFailed (снимает паузу обновления).
|
||
constexpr int kDownloadStallTimeoutMs = 60000;
|
||
|
||
// Корень временной папки для стейджинга обновления (вне 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();
|
||
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, 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)));
|
||
// Стол-таймаут (Qt 5.15+): сбрасывается, пока данные текут, и срабатывает
|
||
// только при простое. Без него обрыв сети посреди загрузки оставляет reply
|
||
// в half-open state — finished не эмитится, loop.exec() висит вечно, а вместе
|
||
// с ним залипает UpdatePause (глушит периодический sync и обработку задач).
|
||
req.setTransferTimeout(kDownloadStallTimeoutMs);
|
||
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
|
||
}
|