feat(updater): in-app dragonxd updater + browse-all-releases
Add a full-node daemon updater (util/DaemonUpdater + daemon_download_dialog) reachable from Settings -> NODE & SECURITY: downloads/verifies (SHA-256 + enforced ed25519 signature) and atomically installs the latest dragonxd from the project Gitea, with a "Restart daemon now" step. Add a shared "Browse all releases..." picker (release_list_view) to both the miner and daemon updaters so users can pin older/pre-release builds. Pure no-I/O cores (daemon_updater_core / xmrig_updater_core) are unit-tested; sign-daemon-release.sh signs release archives offline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
478
src/util/daemon_updater.cpp
Normal file
478
src/util/daemon_updater.cpp
Normal file
@@ -0,0 +1,478 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// DaemonUpdater background worker (libcurl download + miniz extract). The pure, no-I/O helpers it
|
||||
// calls (release parsing, asset/platform matching, the checksum-table parser, version core) live in
|
||||
// daemon_updater_core.cpp; the generic SHA-256 / ed25519 verification is reused from the miner
|
||||
// updater (xmrig_updater_core.cpp) via util::sha256Hex / util::verifyXmrigSignature.
|
||||
|
||||
#include "daemon_updater.h"
|
||||
#include "xmrig_updater.h" // util::sha256Hex, util::verifyXmrigSignature (shared crypto)
|
||||
|
||||
#include "logger.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <miniz.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
namespace {
|
||||
|
||||
// Bound the archive/member sizes before they can fill memory/disk — a defense even ahead of the
|
||||
// checksum check (which only runs once the file is fully downloaded). The full-node archive bundles
|
||||
// the daemon binaries plus Sapling params, so the caps are well above the miner updater's.
|
||||
constexpr curl_off_t kMaxArchiveBytes = 256LL * 1024 * 1024; // 256 MiB
|
||||
constexpr std::size_t kMaxMemberBytes = 128u * 1024 * 1024; // 128 MiB per extracted file
|
||||
|
||||
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
|
||||
{
|
||||
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp)
|
||||
{
|
||||
return std::fwrite(contents, size, nmemb, static_cast<FILE*>(userp));
|
||||
}
|
||||
|
||||
// libcurl progress callback: publish live byte counts and abort the transfer on cancel().
|
||||
int xferCb(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t, curl_off_t)
|
||||
{
|
||||
auto* up = static_cast<DaemonUpdater*>(clientp);
|
||||
return up->onDownloadProgress(static_cast<double>(dlnow), static_cast<double>(dltotal)) ? 0 : 1;
|
||||
}
|
||||
|
||||
std::string baseName(const std::string& path)
|
||||
{
|
||||
const auto slash = path.find_last_of("/\\");
|
||||
return slash == std::string::npos ? path : path.substr(slash + 1);
|
||||
}
|
||||
|
||||
std::string toLower(std::string s)
|
||||
{
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DaemonUpdater::~DaemonUpdater()
|
||||
{
|
||||
cancel_requested_ = true;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
}
|
||||
|
||||
void DaemonUpdater::setProgress(State state, const std::string& text, double done, double total)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = state;
|
||||
progress_.status_text = text;
|
||||
if (done > 0) progress_.downloaded_bytes = done;
|
||||
if (total > 0) progress_.total_bytes = total;
|
||||
progress_.percent = (progress_.total_bytes > 0)
|
||||
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
|
||||
: progress_.percent;
|
||||
if (state == State::Failed) progress_.error = text;
|
||||
}
|
||||
|
||||
DaemonUpdater::Progress DaemonUpdater::getProgress() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return progress_;
|
||||
}
|
||||
|
||||
bool DaemonUpdater::onDownloadProgress(double downloadedBytes, double totalBytes)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.downloaded_bytes = downloadedBytes;
|
||||
if (totalBytes > 0) progress_.total_bytes = totalBytes;
|
||||
progress_.percent = (progress_.total_bytes > 0)
|
||||
? static_cast<float>(100.0 * progress_.downloaded_bytes / progress_.total_bytes)
|
||||
: progress_.percent;
|
||||
}
|
||||
return !cancel_requested_.load(); // false -> curl aborts the transfer
|
||||
}
|
||||
|
||||
bool DaemonUpdater::isDone() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return progress_.state == State::Done || progress_.state == State::Failed ||
|
||||
progress_.state == State::Unavailable;
|
||||
}
|
||||
|
||||
void DaemonUpdater::cancel()
|
||||
{
|
||||
cancel_requested_ = true;
|
||||
}
|
||||
|
||||
std::string DaemonUpdater::httpGet(const std::string& url)
|
||||
{
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return {};
|
||||
std::string result;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
const CURLcode res = curl_easy_perform(curl);
|
||||
long httpCode = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
||||
curl_easy_cleanup(curl);
|
||||
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
|
||||
DEBUG_LOGF("[daemon-updater] GET %s failed: %s (HTTP %ld)\n",
|
||||
url.c_str(), curl_easy_strerror(res), httpCode);
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool DaemonUpdater::downloadToFile(const std::string& url, const std::string& destPath)
|
||||
{
|
||||
FILE* fp = std::fopen(destPath.c_str(), "wb");
|
||||
if (!fp) return false;
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) { std::fclose(fp); return false; }
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
|
||||
curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 60L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, kMaxArchiveBytes); // refuse oversized bodies
|
||||
// Live progress + cancellation: the callback publishes byte counts and aborts on cancel().
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
const CURLcode res = curl_easy_perform(curl);
|
||||
long httpCode = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
||||
curl_easy_cleanup(curl);
|
||||
std::fclose(fp);
|
||||
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) {
|
||||
DEBUG_LOGF("[daemon-updater] download %s failed: %s (HTTP %ld)\n",
|
||||
url.c_str(), curl_easy_strerror(res), httpCode);
|
||||
std::error_code ec; fs::remove(destPath, ec);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void DaemonUpdater::startCheck(const std::string& installedVersion)
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_ = Progress{};
|
||||
progress_.installed_tag = installedVersion;
|
||||
progress_.state = State::Checking;
|
||||
progress_.status_text = "Checking for the latest node…";
|
||||
}
|
||||
worker_ = std::thread([this, installedVersion] { runCheck(installedVersion); worker_running_ = false; });
|
||||
}
|
||||
|
||||
void DaemonUpdater::startInstall(const std::string& targetDir)
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = State::Downloading;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Preparing…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this, targetDir] { runInstall(targetDir); worker_running_ = false; });
|
||||
}
|
||||
|
||||
void DaemonUpdater::startListReleases()
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = State::Listing;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Loading releases…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this] { runListReleases(); worker_running_ = false; });
|
||||
}
|
||||
|
||||
void DaemonUpdater::startInstallRelease(const std::string& targetDir, DaemonRelease release)
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = State::Downloading;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Preparing…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this, targetDir, release = std::move(release)] {
|
||||
installResolved(targetDir, release);
|
||||
worker_running_ = false;
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<DaemonRelease> DaemonUpdater::getReleases() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return releases_;
|
||||
}
|
||||
|
||||
void DaemonUpdater::runListReleases()
|
||||
{
|
||||
const std::string body = httpGet(kReleasesUrl);
|
||||
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
||||
std::vector<DaemonRelease> list = parseDaemonReleaseList(body);
|
||||
const bool empty = list.empty();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
releases_ = std::move(list);
|
||||
}
|
||||
if (empty) { setProgress(State::Failed, "No releases found."); return; }
|
||||
setProgress(State::ReleaseList, "Select a version to install.");
|
||||
}
|
||||
|
||||
void DaemonUpdater::runCheck(std::string installedVersion)
|
||||
{
|
||||
const std::string body = httpGet(kApiUrl);
|
||||
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
||||
const DaemonRelease rel = parseDaemonRelease(body);
|
||||
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
|
||||
|
||||
const std::string token = currentDaemonPlatformToken();
|
||||
const int idx = selectDaemonAsset(rel, token);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.latest_tag = rel.tag;
|
||||
progress_.installed_tag = installedVersion;
|
||||
}
|
||||
if (idx < 0) {
|
||||
setProgress(State::Unavailable, "No node build is available for this platform (" +
|
||||
(token.empty() ? "unknown" : token) + ").");
|
||||
return;
|
||||
}
|
||||
// Compare by vN.N.N core so an installed "v1.0.2-<commit>" matches a release "v1.0.2".
|
||||
const std::string instCore = daemonVersionCore(installedVersion);
|
||||
const bool updateAvailable = instCore.empty() || instCore != daemonVersionCore(rel.tag);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.update_available = updateAvailable;
|
||||
}
|
||||
if (updateAvailable)
|
||||
setProgress(State::UpdateAvailable, "A new node version is available (" + rel.tag + ").");
|
||||
else
|
||||
setProgress(State::UpToDate, "The node is up to date (" + rel.tag + ").");
|
||||
}
|
||||
|
||||
void DaemonUpdater::runInstall(std::string targetDir)
|
||||
{
|
||||
setProgress(State::Checking, "Checking for the latest node…");
|
||||
const std::string apiBody = httpGet(kApiUrl);
|
||||
if (apiBody.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
||||
const DaemonRelease rel = parseDaemonRelease(apiBody);
|
||||
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
|
||||
installResolved(targetDir, rel);
|
||||
}
|
||||
|
||||
void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRelease& rel)
|
||||
{
|
||||
const std::string token = currentDaemonPlatformToken();
|
||||
const int idx = selectDaemonAsset(rel, token);
|
||||
if (idx < 0) {
|
||||
setProgress(State::Unavailable, "No node build is available for this platform (" +
|
||||
(token.empty() ? "unknown" : token) + ").");
|
||||
return;
|
||||
}
|
||||
const DaemonReleaseAsset& asset = rel.assets[idx];
|
||||
const auto checksums = parseDaemonChecksums(rel.body);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.latest_tag = rel.tag;
|
||||
}
|
||||
if (cancel_requested_) { setProgress(State::Failed, "Cancelled."); return; }
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directories(targetDir, ec);
|
||||
|
||||
// 1. Download the archive.
|
||||
const std::string zipPath = (fs::path(targetDir) / ".dragonx-daemon-download.zip").string();
|
||||
setProgress(State::Downloading, "Downloading " + asset.name + "…", 0,
|
||||
static_cast<double>(asset.size));
|
||||
if (!downloadToFile(asset.downloadUrl, zipPath)) {
|
||||
setProgress(State::Failed, "Download failed.");
|
||||
return;
|
||||
}
|
||||
if (cancel_requested_) { fs::remove(zipPath, ec); setProgress(State::Failed, "Cancelled."); return; }
|
||||
|
||||
// 2. Verify the downloaded archive. Read it once, then (a) compare its SHA-256 to the published
|
||||
// checksum and (b) verify a detached ed25519 signature over the archive bytes against the
|
||||
// pinned key, so a checksum rewritten in a tampered release body is not sufficient to install.
|
||||
setProgress(State::Verifying, "Verifying download…");
|
||||
{
|
||||
std::ifstream f(zipPath, std::ios::binary);
|
||||
if (!f) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "Could not read the downloaded archive.");
|
||||
return;
|
||||
}
|
||||
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||
if (f.bad()) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "Could not read the downloaded archive.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2a. SHA-256 (integrity / transit corruption).
|
||||
const std::string actual = sha256Hex(bytes.data(), bytes.size());
|
||||
const auto it = checksums.find(toLower(asset.name)); // keys are lowercased
|
||||
if (it == checksums.end()) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "No published checksum for " + asset.name + " — refusing to install.");
|
||||
return;
|
||||
}
|
||||
if (actual != it->second) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "Archive checksum mismatch — refusing to install (possible tampering).");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2b. Detached ed25519 signature (authenticity) against the pinned key.
|
||||
const std::string pubKey = kDaemonSignaturePublicKeyBase64;
|
||||
if (!pubKey.empty()) {
|
||||
const int sigIdx = selectDaemonSignatureAsset(rel, asset.name);
|
||||
if (sigIdx < 0) {
|
||||
if (kDaemonRequireSignature) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "No signature published for this release — refusing to install.");
|
||||
return;
|
||||
}
|
||||
DEBUG_LOGF("[daemon-updater] no signature asset for %s; proceeding on checksum only\n",
|
||||
asset.name.c_str());
|
||||
} else {
|
||||
setProgress(State::Verifying, "Verifying signature…");
|
||||
const std::string sigContent = httpGet(rel.assets[sigIdx].downloadUrl);
|
||||
if (sigContent.empty() || !verifyXmrigSignature(bytes, sigContent, pubKey)) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "Signature verification failed — refusing to install.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (kDaemonRequireSignature) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "No signing key is pinned in this build — refusing to install.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Extract the daemon binaries (flatten the versioned subdir). No per-member hash check is
|
||||
// needed: the whole archive was already verified above (SHA-256 + ed25519 signature), so
|
||||
// every member is authentic by transitivity. The archive also carries params/asmap, which
|
||||
// this updater deliberately leaves to the wallet's own resource extraction.
|
||||
setProgress(State::Extracting, "Installing node…");
|
||||
const std::vector<std::string> wanted = daemonExtractBasenames(token);
|
||||
const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe"
|
||||
|
||||
mz_zip_archive zip{};
|
||||
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
|
||||
fs::remove(zipPath, ec);
|
||||
setProgress(State::Failed, "Could not open the downloaded archive.");
|
||||
return;
|
||||
}
|
||||
bool daemonInstalled = false;
|
||||
bool failed = false;
|
||||
const int numFiles = static_cast<int>(mz_zip_reader_get_num_files(&zip));
|
||||
for (int i = 0; i < numFiles && !failed; ++i) {
|
||||
mz_zip_archive_file_stat st;
|
||||
if (!mz_zip_reader_file_stat(&zip, i, &st)) continue;
|
||||
if (mz_zip_reader_is_file_a_directory(&zip, i)) continue;
|
||||
const std::string base = baseName(st.m_filename);
|
||||
if (std::find(wanted.begin(), wanted.end(), base) == wanted.end()) continue; // skip params/asmap/etc.
|
||||
|
||||
// Reject an implausibly large member before decompressing it into memory.
|
||||
if (st.m_uncomp_size > kMaxMemberBytes) { failed = true; break; }
|
||||
|
||||
size_t outSize = 0;
|
||||
void* mem = mz_zip_reader_extract_to_heap(&zip, i, &outSize, 0);
|
||||
if (!mem) { failed = true; break; }
|
||||
|
||||
const std::string finalPath = (fs::path(targetDir) / base).string();
|
||||
const std::string tmpPath = finalPath + ".tmp";
|
||||
// Tidy any leftover from a previous update (the old binary moved aside, freed after restart).
|
||||
fs::remove(finalPath + ".old", ec);
|
||||
{
|
||||
std::ofstream of(tmpPath, std::ios::binary | std::ios::trunc);
|
||||
if (!of) { mz_free(mem); failed = true; break; }
|
||||
of.write(static_cast<const char*>(mem), static_cast<std::streamsize>(outSize));
|
||||
}
|
||||
mz_free(mem);
|
||||
|
||||
// Atomic install that also works while the daemon is running: POSIX rename() replaces the
|
||||
// path even if the old binary is in use (the running process keeps the unlinked inode). On
|
||||
// Windows a running .exe cannot be renamed-over, so move it aside to ".old" first, then put
|
||||
// the new file in place; the ".old" is cleaned up on a later update once the daemon restarts.
|
||||
fs::rename(tmpPath, finalPath, ec);
|
||||
if (ec) {
|
||||
std::error_code mec;
|
||||
fs::rename(finalPath, finalPath + ".old", mec);
|
||||
ec.clear();
|
||||
fs::rename(tmpPath, finalPath, ec);
|
||||
}
|
||||
if (ec) { fs::remove(tmpPath, ec); failed = true; break; }
|
||||
|
||||
#ifndef _WIN32
|
||||
// Every wanted member is an executable in the daemon set — make them all runnable.
|
||||
fs::permissions(finalPath,
|
||||
fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec |
|
||||
fs::perms::others_read | fs::perms::others_exec,
|
||||
fs::perm_options::replace, ec);
|
||||
#endif
|
||||
if (base == daemonName) daemonInstalled = true;
|
||||
}
|
||||
mz_zip_reader_end(&zip);
|
||||
fs::remove(zipPath, ec);
|
||||
|
||||
if (failed) { setProgress(State::Failed, "Could not verify/install the node binaries."); return; }
|
||||
if (!daemonInstalled) { setProgress(State::Failed, "Daemon binary not found in the archive."); return; }
|
||||
|
||||
setProgress(State::Done, "Node installed (" + rel.tag + ").");
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
189
src/util/daemon_updater.h
Normal file
189
src/util/daemon_updater.h
Normal file
@@ -0,0 +1,189 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// DaemonUpdater — fetch + verify + install the latest dragonxd full node from the DragonX Gitea.
|
||||
//
|
||||
// Sibling of util::XmrigUpdater (the miner updater): same query → download → verify → extract →
|
||||
// install pipeline on a background thread, but for the full-node binaries instead of the miner.
|
||||
// Flow: query the Gitea releases API for the latest dragonx release, pick the archive matching this
|
||||
// platform (dragonx-<ver>-linux-amd64.zip / -win64.zip / -macos.zip), download it, verify it, then
|
||||
// extract the daemon executables (dragonxd / dragonx-cli / dragonx-tx, flattening the versioned
|
||||
// subdir the archive nests them in) into the daemon directory and chmod them executable.
|
||||
//
|
||||
// Security: download-and-execute, so verification is mandatory. TLS is verified (libcurl defaults),
|
||||
// the host is the project's own Gitea over HTTPS, the archive's SHA-256 is checked against the
|
||||
// checksum table published in the release body, AND a detached ed25519 signature over the archive
|
||||
// bytes is verified against the key pinned below (kDaemonRequireSignature enforces it: an install
|
||||
// is refused when no valid .sig is published). The inner binaries are trusted by transitivity once
|
||||
// the whole archive is authenticated. The generic SHA-256 / ed25519 primitives are shared with the
|
||||
// miner updater (util::sha256Hex / util::verifyXmrigSignature in xmrig_updater_core.cpp).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
struct DaemonReleaseAsset {
|
||||
std::string name;
|
||||
std::string downloadUrl;
|
||||
long long size = 0;
|
||||
};
|
||||
|
||||
struct DaemonRelease {
|
||||
bool ok = false;
|
||||
std::string tag; // e.g. "v1.0.2"
|
||||
std::string name; // human title (e.g. "Dragonx v1.0.2")
|
||||
std::string body; // release notes markdown (holds the checksum table)
|
||||
bool prerelease = false; // marked pre-release on the Gitea release
|
||||
std::string publishedAt; // ISO-8601 publish timestamp (date shown in the UI)
|
||||
std::vector<DaemonReleaseAsset> assets;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
// ── Release signature (supply-chain hardening, mandatory for the daemon) ──────
|
||||
//
|
||||
// Pinned ed25519 public key (base64, 32 bytes) used to verify a detached signature over the
|
||||
// downloaded archive. With kDaemonRequireSignature=true an install is refused unless the release
|
||||
// publishes a "<archive-name>.sig" asset (base64 of, or raw, a 64-byte ed25519 signature over the
|
||||
// archive bytes) that verifies against this key — see scripts/sign-daemon-release.sh. To rotate the
|
||||
// key, regenerate with that script and replace the value below. If the key is set empty AND
|
||||
// kDaemonRequireSignature=false, verification falls back to TLS + the published SHA-256 only.
|
||||
inline constexpr const char* kDaemonSignaturePublicKeyBase64 =
|
||||
"d59hosHzh2LtHypgGvsMBlgrRGBhOfS0Xl40D/fEjzQ=";
|
||||
inline constexpr bool kDaemonRequireSignature = true; // enforced: refuse installs without a valid .sig
|
||||
|
||||
// ── Pure helpers (no I/O; unit-tested) ───────────────────────────────────────
|
||||
|
||||
// Parse the Gitea GET /releases/latest JSON into a DaemonRelease (ok=false + error on failure).
|
||||
DaemonRelease parseDaemonRelease(const std::string& json);
|
||||
|
||||
// Parse the Gitea GET /releases (array) JSON into the list of releases, newest first, skipping
|
||||
// drafts. Empty on parse failure. Lets the user browse + pin a specific (or pre-release) version.
|
||||
std::vector<DaemonRelease> parseDaemonReleaseList(const std::string& json);
|
||||
|
||||
// The asset-name token for the host platform: "linux-amd64", "win64", "macos", or "" if
|
||||
// unknown/unsupported (e.g. linux-arm64, for which no build is published -> Unavailable).
|
||||
std::string currentDaemonPlatformToken();
|
||||
|
||||
// Index of the asset whose name matches the platform token (e.g. ends with "-linux-amd64.zip"),
|
||||
// or -1 if none.
|
||||
int selectDaemonAsset(const DaemonRelease& release, const std::string& platformToken);
|
||||
|
||||
// Parse the release-body markdown checksum table ( "| <archive>.zip | `<sha256hex>` |" rows ) into
|
||||
// { archive-name -> lowercase-hex }. Header/separator/prose rows (no 64-hex token) are ignored.
|
||||
std::map<std::string, std::string> parseDaemonChecksums(const std::string& body);
|
||||
|
||||
// The binary file basenames to extract for a platform: {"dragonxd","dragonx-cli","dragonx-tx"} on
|
||||
// POSIX, the ".exe" variants on Windows. The first entry is always the daemon itself.
|
||||
std::vector<std::string> daemonExtractBasenames(const std::string& platformToken);
|
||||
|
||||
// Index of the detached-signature asset for a given archive (name "<archive>.sig" or
|
||||
// "<archive>.minisig"), or -1 if none is published.
|
||||
int selectDaemonSignatureAsset(const DaemonRelease& release, const std::string& archiveName);
|
||||
|
||||
// Reduce a version string to its "vMAJOR.MINOR.PATCH" core, dropping any "-<commit>" suffix the
|
||||
// binary scanner appends, so an installed "v1.0.2-ddd851dc1" compares equal to a release "v1.0.2".
|
||||
// Returns the input unchanged if it holds no vN.N.N pattern.
|
||||
std::string daemonVersionCore(const std::string& version);
|
||||
|
||||
// ── Background worker ────────────────────────────────────────────────────────
|
||||
|
||||
class DaemonUpdater {
|
||||
public:
|
||||
enum class State {
|
||||
Idle,
|
||||
Checking,
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Unavailable, // no daemon build is published for this platform (terminal, not an error)
|
||||
Listing, // fetching the full release list (Browse all releases)
|
||||
ReleaseList, // release list fetched; awaiting the user's pick
|
||||
Downloading,
|
||||
Verifying,
|
||||
Extracting,
|
||||
Done,
|
||||
Failed
|
||||
};
|
||||
|
||||
struct Progress {
|
||||
State state = State::Idle;
|
||||
double downloaded_bytes = 0;
|
||||
double total_bytes = 0;
|
||||
float percent = 0.0f;
|
||||
std::string status_text;
|
||||
std::string error; // non-empty on Failed
|
||||
std::string latest_tag; // tag reported by the API (once checked)
|
||||
std::string installed_tag; // caller-supplied current install (for update detection)
|
||||
bool update_available = false;
|
||||
};
|
||||
|
||||
// Gitea releases API for the dragonx full node.
|
||||
static constexpr const char* kApiUrl =
|
||||
"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest";
|
||||
// Full release list (newest first, includes pre-releases) for the "Browse all releases" picker.
|
||||
static constexpr const char* kReleasesUrl =
|
||||
"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases?limit=50";
|
||||
|
||||
DaemonUpdater() = default;
|
||||
~DaemonUpdater();
|
||||
DaemonUpdater(const DaemonUpdater&) = delete;
|
||||
DaemonUpdater& operator=(const DaemonUpdater&) = delete;
|
||||
|
||||
// Query the latest release on a background thread. `installedVersion` (may be empty/unknown) is
|
||||
// compared (by vN.N.N core) to the API tag to set Progress.update_available. End state:
|
||||
// UpToDate / UpdateAvailable / Failed.
|
||||
void startCheck(const std::string& installedVersion);
|
||||
|
||||
// Download → verify archive → extract (flatten) → install into `targetDir` on a background
|
||||
// thread. Re-fetches the latest release so it is self-contained. End state: Done / Failed. The
|
||||
// new binary takes effect on the next daemon start (the caller offers a restart).
|
||||
void startInstall(const std::string& targetDir);
|
||||
|
||||
// Fetch the full release list on a background thread (for "Browse all releases"). End state:
|
||||
// ReleaseList (then getReleases() holds the list, newest first) / Failed.
|
||||
void startListReleases();
|
||||
|
||||
// Snapshot of the release list fetched by startListReleases().
|
||||
std::vector<DaemonRelease> getReleases() const;
|
||||
|
||||
// Install a SPECIFIC release (chosen from the browse list) into `targetDir` — same verify/extract
|
||||
// path as startInstall, but pinned to `release` instead of latest. End state: Done / Failed.
|
||||
void startInstallRelease(const std::string& targetDir, DaemonRelease release);
|
||||
|
||||
void cancel();
|
||||
Progress getProgress() const;
|
||||
bool isDone() const; // true once the worker reached a terminal state (Done/Failed/Unavailable)
|
||||
|
||||
// Internal: called by the libcurl progress callback. Publishes live download bytes and returns
|
||||
// false to ask curl to abort (when cancel() was requested). Public only so the C callback in the
|
||||
// .cpp can reach it without leaking curl types into this header.
|
||||
bool onDownloadProgress(double downloadedBytes, double totalBytes);
|
||||
|
||||
private:
|
||||
void runCheck(std::string installedVersion);
|
||||
void runListReleases();
|
||||
void runInstall(std::string targetDir);
|
||||
void installResolved(const std::string& targetDir, const DaemonRelease& rel); // shared install body
|
||||
void setProgress(State state, const std::string& text, double done = 0, double total = 0);
|
||||
bool downloadToFile(const std::string& url, const std::string& destPath);
|
||||
std::string httpGet(const std::string& url);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Progress progress_;
|
||||
std::vector<DaemonRelease> releases_;
|
||||
std::atomic<bool> cancel_requested_{false};
|
||||
std::atomic<bool> worker_running_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
206
src/util/daemon_updater_core.cpp
Normal file
206
src/util/daemon_updater_core.cpp
Normal file
@@ -0,0 +1,206 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure (no-I/O) core of the daemon updater: release-JSON parsing, platform/asset matching, the
|
||||
// markdown checksum-table parser, version normalization, and signature-asset selection. Split from
|
||||
// daemon_updater.cpp (the libcurl/miniz worker) so it can be unit-tested without curl/miniz. The
|
||||
// generic SHA-256 / ed25519 primitives are reused from the miner updater (xmrig_updater_core.cpp).
|
||||
|
||||
#include "daemon_updater.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string toLower(std::string s)
|
||||
{
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
bool isHex64(const std::string& s)
|
||||
{
|
||||
if (s.size() != 64) return false;
|
||||
for (unsigned char c : s)
|
||||
if (!std::isxdigit(c)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
// Fill a DaemonRelease from one Gitea release JSON object (ok=true iff it has a tag).
|
||||
DaemonRelease parseOneDaemonRelease(const json& j)
|
||||
{
|
||||
DaemonRelease r;
|
||||
if (!j.is_object()) return r;
|
||||
if (j.contains("tag_name") && j["tag_name"].is_string())
|
||||
r.tag = j["tag_name"].get<std::string>();
|
||||
if (j.contains("name") && j["name"].is_string())
|
||||
r.name = j["name"].get<std::string>();
|
||||
if (j.contains("body") && j["body"].is_string())
|
||||
r.body = j["body"].get<std::string>();
|
||||
if (j.contains("prerelease") && j["prerelease"].is_boolean())
|
||||
r.prerelease = j["prerelease"].get<bool>();
|
||||
if (j.contains("published_at") && j["published_at"].is_string())
|
||||
r.publishedAt = j["published_at"].get<std::string>();
|
||||
if (j.contains("assets") && j["assets"].is_array()) {
|
||||
for (const auto& a : j["assets"]) {
|
||||
if (!a.is_object()) continue;
|
||||
DaemonReleaseAsset asset;
|
||||
if (a.contains("name") && a["name"].is_string())
|
||||
asset.name = a["name"].get<std::string>();
|
||||
if (a.contains("browser_download_url") && a["browser_download_url"].is_string())
|
||||
asset.downloadUrl = a["browser_download_url"].get<std::string>();
|
||||
if (a.contains("size") && a["size"].is_number_integer())
|
||||
asset.size = a["size"].get<long long>();
|
||||
if (!asset.name.empty() && !asset.downloadUrl.empty())
|
||||
r.assets.push_back(std::move(asset));
|
||||
}
|
||||
}
|
||||
if (!r.tag.empty()) r.ok = true;
|
||||
return r;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
DaemonRelease parseDaemonRelease(const std::string& jsonStr)
|
||||
{
|
||||
DaemonRelease r;
|
||||
try {
|
||||
r = parseOneDaemonRelease(json::parse(jsonStr));
|
||||
if (!r.ok) r.error = "release JSON has no tag_name";
|
||||
} catch (const std::exception& e) {
|
||||
r.error = std::string("failed to parse release JSON: ") + e.what();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<DaemonRelease> parseDaemonReleaseList(const std::string& jsonStr)
|
||||
{
|
||||
std::vector<DaemonRelease> out;
|
||||
try {
|
||||
const json j = json::parse(jsonStr);
|
||||
if (!j.is_array()) return out;
|
||||
for (const auto& e : j) {
|
||||
if (e.contains("draft") && e["draft"].is_boolean() && e["draft"].get<bool>())
|
||||
continue; // skip drafts (not meant for end users)
|
||||
DaemonRelease r = parseOneDaemonRelease(e);
|
||||
if (r.ok) out.push_back(std::move(r));
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
// malformed list -> empty (caller treats as "could not load releases")
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string currentDaemonPlatformToken()
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
return "win64";
|
||||
#elif defined(__APPLE__)
|
||||
return "macos"; // single macOS archive (no arm/x86 split in the release naming)
|
||||
#elif defined(__linux__)
|
||||
#if defined(__aarch64__)
|
||||
return "linux-arm64"; // no arm64 build published yet -> resolves to Unavailable
|
||||
#else
|
||||
return "linux-amd64";
|
||||
#endif
|
||||
#else
|
||||
return "";
|
||||
#endif
|
||||
}
|
||||
|
||||
int selectDaemonAsset(const DaemonRelease& release, const std::string& platformToken)
|
||||
{
|
||||
if (platformToken.empty()) return -1;
|
||||
const std::string needle = "-" + toLower(platformToken) + ".zip";
|
||||
for (std::size_t i = 0; i < release.assets.size(); ++i) {
|
||||
const std::string n = toLower(release.assets[i].name);
|
||||
if (n.size() >= needle.size() &&
|
||||
n.compare(n.size() - needle.size(), needle.size(), needle) == 0)
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parseDaemonChecksums(const std::string& body)
|
||||
{
|
||||
// The release body publishes checksums as a markdown table:
|
||||
// | File | SHA-256 |
|
||||
// |------|---------|
|
||||
// | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` |
|
||||
// Per line: blank out the table/code delimiters ('|' and '`'), then find the 64-hex token (the
|
||||
// hash) and a token ending in ".zip" (the archive name). Header/separator/prose rows lack one
|
||||
// or the other and are skipped, so this is robust to surrounding text and column order.
|
||||
std::map<std::string, std::string> out;
|
||||
std::istringstream in(body);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
for (char& c : line)
|
||||
if (c == '|' || c == '`') c = ' ';
|
||||
std::istringstream ls(line);
|
||||
std::string tok, hash, name;
|
||||
while (ls >> tok) {
|
||||
if (hash.empty() && isHex64(tok)) { hash = toLower(tok); continue; }
|
||||
if (name.empty()) {
|
||||
const std::string low = toLower(tok);
|
||||
// Key by the lowercased name so the lookup (also lowercased) is case-insensitive,
|
||||
// in case the markdown table and the JSON asset names differ in case.
|
||||
if (low.size() >= 4 && low.compare(low.size() - 4, 4, ".zip") == 0) name = low;
|
||||
}
|
||||
}
|
||||
if (!hash.empty() && !name.empty()) out[name] = hash;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> daemonExtractBasenames(const std::string& platformToken)
|
||||
{
|
||||
if (platformToken.rfind("win", 0) == 0)
|
||||
return {"dragonxd.exe", "dragonx-cli.exe", "dragonx-tx.exe"};
|
||||
return {"dragonxd", "dragonx-cli", "dragonx-tx"};
|
||||
}
|
||||
|
||||
int selectDaemonSignatureAsset(const DaemonRelease& release, const std::string& archiveName)
|
||||
{
|
||||
if (archiveName.empty()) return -1;
|
||||
for (std::size_t i = 0; i < release.assets.size(); ++i) {
|
||||
const std::string& n = release.assets[i].name;
|
||||
if (n == archiveName + ".sig" || n == archiveName + ".minisig")
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::string daemonVersionCore(const std::string& version)
|
||||
{
|
||||
// Extract a leading "v?MAJOR.MINOR.PATCH" run, ignoring any "-<commit>" / build suffix. Hand-
|
||||
// rolled (no <regex>) to stay light and dependency-free.
|
||||
const std::size_t start = version.find_first_of("0123456789");
|
||||
if (start == std::string::npos) return version;
|
||||
std::size_t i = start;
|
||||
int dots = 0;
|
||||
for (; i < version.size(); ++i) {
|
||||
const char c = version[i];
|
||||
if (c >= '0' && c <= '9') continue;
|
||||
if (c == '.' && dots < 2) { ++dots; continue; }
|
||||
break;
|
||||
}
|
||||
if (dots < 2) return version; // not a full N.N.N — leave as-is
|
||||
const bool hasV = start > 0 && (version[start - 1] == 'v' || version[start - 1] == 'V');
|
||||
return (hasV ? "v" : "") + version.substr(start, i - start);
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -1166,6 +1166,45 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["xmrig_installed_ok"] = "Miner installed";
|
||||
strings_["xmrig_update_failed"] = "Update failed";
|
||||
strings_["xmrig_unknown_error"] = "Unknown error.";
|
||||
strings_["xmrig_browse_releases"] = "Browse all releases…";
|
||||
strings_["xmrig_loading_releases"] = "Loading releases…";
|
||||
|
||||
// --- Shared release picker ("Browse all releases") ---
|
||||
strings_["upd_select_version"] = "Select a version to install";
|
||||
strings_["upd_install"] = "Install";
|
||||
strings_["upd_reinstall"] = "Reinstall";
|
||||
strings_["upd_prerelease"] = "pre-release";
|
||||
strings_["upd_installed_badge"] = "installed";
|
||||
strings_["upd_no_build_platform"] = "No build for this platform";
|
||||
strings_["upd_back"] = "Back";
|
||||
|
||||
// --- Daemon (full node) updater — Settings → daemon binary panel ---
|
||||
strings_["daemon_update_check"] = "Check for updates…";
|
||||
strings_["tt_daemon_update_check"] = "Download and verify the latest dragonxd full node from the project Gitea, then restart to apply";
|
||||
strings_["daemon_update_title"] = "Update Node";
|
||||
strings_["daemon_update_checking"] = "Checking for the latest node…";
|
||||
strings_["daemon_update_unavailable_title"]= "Node updates unavailable";
|
||||
strings_["daemon_update_unavailable_body"] = "No node build is available for this platform.";
|
||||
strings_["daemon_update_available"] = "A new node version is available";
|
||||
strings_["daemon_update_up_to_date"] = "The node is up to date";
|
||||
strings_["daemon_update_latest"] = "Latest:";
|
||||
strings_["daemon_update_installed"] = "Installed:";
|
||||
strings_["daemon_update_version"] = "Version:";
|
||||
strings_["daemon_update_verify_note"] = "The download is verified against the release's published SHA-256 and a pinned ed25519 signature before install.";
|
||||
strings_["daemon_update_download_install"] = "Download & install";
|
||||
strings_["daemon_update_reinstall"] = "Reinstall";
|
||||
strings_["daemon_update_downloading"] = "Downloading…";
|
||||
strings_["daemon_update_verifying"] = "Verifying…";
|
||||
strings_["daemon_update_installing"] = "Installing…";
|
||||
strings_["daemon_update_installed_ok"] = "Node installed";
|
||||
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
|
||||
strings_["daemon_update_restart_now"] = "Restart daemon now";
|
||||
strings_["daemon_update_later"] = "Later";
|
||||
strings_["daemon_update_failed"] = "Update failed";
|
||||
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
||||
strings_["daemon_update_browse"] = "Browse all releases…";
|
||||
strings_["daemon_update_loading"] = "Loading releases…";
|
||||
strings_["daemon_update_downgrade_note"] = "Older versions may be incompatible with your current chain data. Installing a different version takes effect after a daemon restart.";
|
||||
|
||||
// --- Lite Network tab (server browser) ---
|
||||
strings_["lite_console_title"] = "Console";
|
||||
|
||||
@@ -194,10 +194,70 @@ void XmrigUpdater::startInstall(const std::string& targetDir)
|
||||
progress_.state = State::Downloading;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Preparing…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this, targetDir] { runInstall(targetDir); worker_running_ = false; });
|
||||
}
|
||||
|
||||
void XmrigUpdater::startListReleases()
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = State::Listing;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Loading releases…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this] { runListReleases(); worker_running_ = false; });
|
||||
}
|
||||
|
||||
void XmrigUpdater::startInstallRelease(const std::string& targetDir, XmrigRelease release)
|
||||
{
|
||||
if (worker_running_.exchange(true)) return;
|
||||
cancel_requested_ = false;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
progress_.state = State::Downloading;
|
||||
progress_.error.clear();
|
||||
progress_.status_text = "Preparing…";
|
||||
progress_.downloaded_bytes = 0; // clear any prior op's progress so the bar starts at 0%
|
||||
progress_.total_bytes = 0;
|
||||
progress_.percent = 0.0f;
|
||||
}
|
||||
worker_ = std::thread([this, targetDir, release = std::move(release)] {
|
||||
installResolved(targetDir, release);
|
||||
worker_running_ = false;
|
||||
});
|
||||
}
|
||||
|
||||
std::vector<XmrigRelease> XmrigUpdater::getReleases() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return releases_;
|
||||
}
|
||||
|
||||
void XmrigUpdater::runListReleases()
|
||||
{
|
||||
const std::string body = httpGet(kReleasesUrl);
|
||||
if (body.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
||||
std::vector<XmrigRelease> list = parseXmrigReleaseList(body);
|
||||
const bool empty = list.empty();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
releases_ = std::move(list);
|
||||
}
|
||||
if (empty) { setProgress(State::Failed, "No releases found."); return; }
|
||||
setProgress(State::ReleaseList, "Select a version to install.");
|
||||
}
|
||||
|
||||
void XmrigUpdater::runCheck(std::string installedTag)
|
||||
{
|
||||
const std::string body = httpGet(kApiUrl);
|
||||
@@ -235,7 +295,11 @@ void XmrigUpdater::runInstall(std::string targetDir)
|
||||
if (apiBody.empty()) { setProgress(State::Failed, "Could not reach the update server."); return; }
|
||||
const XmrigRelease rel = parseXmrigRelease(apiBody);
|
||||
if (!rel.ok) { setProgress(State::Failed, rel.error.empty() ? "Invalid release data." : rel.error); return; }
|
||||
installResolved(targetDir, rel);
|
||||
}
|
||||
|
||||
void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRelease& rel)
|
||||
{
|
||||
const std::string token = currentXmrigPlatformToken();
|
||||
const int idx = selectXmrigAsset(rel, token);
|
||||
if (idx < 0) {
|
||||
|
||||
@@ -38,7 +38,10 @@ struct XmrigReleaseAsset {
|
||||
struct XmrigRelease {
|
||||
bool ok = false;
|
||||
std::string tag; // e.g. "v1.0.0"
|
||||
std::string name; // human title (e.g. "DRG-XMRig v6.25.3")
|
||||
std::string body; // release notes markdown (holds the checksum blocks)
|
||||
bool prerelease = false; // marked pre-release on the Gitea release
|
||||
std::string publishedAt; // ISO-8601 publish timestamp (date shown in the UI)
|
||||
std::vector<XmrigReleaseAsset> assets;
|
||||
std::string error;
|
||||
};
|
||||
@@ -61,6 +64,10 @@ inline constexpr bool kXmrigRequireSignature = true; // enforced: refus
|
||||
// Parse the Gitea GET /releases/latest JSON into an XmrigRelease (ok=false + error on failure).
|
||||
XmrigRelease parseXmrigRelease(const std::string& json);
|
||||
|
||||
// Parse the Gitea GET /releases (array) JSON into the list of releases, newest first, skipping
|
||||
// drafts. Empty on parse failure. Lets the user browse + pin a specific (or pre-release) version.
|
||||
std::vector<XmrigRelease> parseXmrigReleaseList(const std::string& json);
|
||||
|
||||
// The asset-name token for the host platform: "linux-x64", "win-x64", "macos-x64",
|
||||
// "macos-arm64", or "" if unknown/unsupported.
|
||||
std::string currentXmrigPlatformToken();
|
||||
@@ -102,6 +109,8 @@ public:
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Unavailable, // no miner build is published for this platform (terminal, not an error)
|
||||
Listing, // fetching the full release list (Browse all releases)
|
||||
ReleaseList, // release list fetched; awaiting the user's pick
|
||||
Downloading,
|
||||
Verifying,
|
||||
Extracting,
|
||||
@@ -124,6 +133,9 @@ public:
|
||||
// Gitea releases API for the DRG-XMRig fork.
|
||||
static constexpr const char* kApiUrl =
|
||||
"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest";
|
||||
// Full release list (newest first, includes pre-releases) for the "Browse all releases" picker.
|
||||
static constexpr const char* kReleasesUrl =
|
||||
"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases?limit=50";
|
||||
|
||||
XmrigUpdater() = default;
|
||||
~XmrigUpdater();
|
||||
@@ -136,10 +148,21 @@ public:
|
||||
void startCheck(const std::string& installedTag);
|
||||
|
||||
// Download → verify archive → extract (flatten) → verify binary → install into `targetDir` on a
|
||||
// background thread. Re-fetches the release so it is self-contained. End state: Done / Failed.
|
||||
// On Done, getProgress().latest_tag is the version that should be persisted as the installed tag.
|
||||
// background thread. Re-fetches the latest release so it is self-contained. End state: Done /
|
||||
// Failed. On Done, getProgress().latest_tag is the version that should be persisted as installed.
|
||||
void startInstall(const std::string& targetDir);
|
||||
|
||||
// Fetch the full release list on a background thread (for "Browse all releases"). End state:
|
||||
// ReleaseList (then getReleases() holds the list, newest first) / Failed.
|
||||
void startListReleases();
|
||||
|
||||
// Snapshot of the release list fetched by startListReleases().
|
||||
std::vector<XmrigRelease> getReleases() const;
|
||||
|
||||
// Install a SPECIFIC release (chosen from the browse list) into `targetDir` — same verify/extract
|
||||
// path as startInstall, but pinned to `release` instead of latest. End state: Done / Failed.
|
||||
void startInstallRelease(const std::string& targetDir, XmrigRelease release);
|
||||
|
||||
void cancel();
|
||||
Progress getProgress() const;
|
||||
bool isDone() const; // true once the worker reached a terminal state (Done/Failed/Unavailable)
|
||||
@@ -151,13 +174,16 @@ public:
|
||||
|
||||
private:
|
||||
void runCheck(std::string installedTag);
|
||||
void runListReleases();
|
||||
void runInstall(std::string targetDir);
|
||||
void installResolved(const std::string& targetDir, const XmrigRelease& rel); // shared install body
|
||||
void setProgress(State state, const std::string& text, double done = 0, double total = 0);
|
||||
bool downloadToFile(const std::string& url, const std::string& destPath);
|
||||
std::string httpGet(const std::string& url);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Progress progress_;
|
||||
std::vector<XmrigRelease> releases_;
|
||||
std::atomic<bool> cancel_requested_{false};
|
||||
std::atomic<bool> worker_running_{false};
|
||||
std::thread worker_;
|
||||
|
||||
@@ -40,37 +40,71 @@ bool isHex64(const std::string& s)
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace {
|
||||
// Fill an XmrigRelease from one Gitea release JSON object (ok=true iff it has a tag).
|
||||
XmrigRelease parseOneXmrigRelease(const json& j)
|
||||
{
|
||||
XmrigRelease r;
|
||||
if (!j.is_object()) return r;
|
||||
if (j.contains("tag_name") && j["tag_name"].is_string())
|
||||
r.tag = j["tag_name"].get<std::string>();
|
||||
if (j.contains("name") && j["name"].is_string())
|
||||
r.name = j["name"].get<std::string>();
|
||||
if (j.contains("body") && j["body"].is_string())
|
||||
r.body = j["body"].get<std::string>();
|
||||
if (j.contains("prerelease") && j["prerelease"].is_boolean())
|
||||
r.prerelease = j["prerelease"].get<bool>();
|
||||
if (j.contains("published_at") && j["published_at"].is_string())
|
||||
r.publishedAt = j["published_at"].get<std::string>();
|
||||
if (j.contains("assets") && j["assets"].is_array()) {
|
||||
for (const auto& a : j["assets"]) {
|
||||
if (!a.is_object()) continue;
|
||||
XmrigReleaseAsset asset;
|
||||
if (a.contains("name") && a["name"].is_string())
|
||||
asset.name = a["name"].get<std::string>();
|
||||
if (a.contains("browser_download_url") && a["browser_download_url"].is_string())
|
||||
asset.downloadUrl = a["browser_download_url"].get<std::string>();
|
||||
if (a.contains("size") && a["size"].is_number_integer())
|
||||
asset.size = a["size"].get<long long>();
|
||||
if (!asset.name.empty() && !asset.downloadUrl.empty())
|
||||
r.assets.push_back(std::move(asset));
|
||||
}
|
||||
}
|
||||
if (!r.tag.empty()) r.ok = true;
|
||||
return r;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
XmrigRelease parseXmrigRelease(const std::string& jsonStr)
|
||||
{
|
||||
XmrigRelease r;
|
||||
try {
|
||||
const json j = json::parse(jsonStr);
|
||||
if (j.contains("tag_name") && j["tag_name"].is_string())
|
||||
r.tag = j["tag_name"].get<std::string>();
|
||||
if (j.contains("body") && j["body"].is_string())
|
||||
r.body = j["body"].get<std::string>();
|
||||
if (j.contains("assets") && j["assets"].is_array()) {
|
||||
for (const auto& a : j["assets"]) {
|
||||
if (!a.is_object()) continue;
|
||||
XmrigReleaseAsset asset;
|
||||
if (a.contains("name") && a["name"].is_string())
|
||||
asset.name = a["name"].get<std::string>();
|
||||
if (a.contains("browser_download_url") && a["browser_download_url"].is_string())
|
||||
asset.downloadUrl = a["browser_download_url"].get<std::string>();
|
||||
if (a.contains("size") && a["size"].is_number_integer())
|
||||
asset.size = a["size"].get<long long>();
|
||||
if (!asset.name.empty() && !asset.downloadUrl.empty())
|
||||
r.assets.push_back(std::move(asset));
|
||||
}
|
||||
}
|
||||
if (r.tag.empty()) { r.error = "release JSON has no tag_name"; return r; }
|
||||
r.ok = true;
|
||||
r = parseOneXmrigRelease(json::parse(jsonStr));
|
||||
if (!r.ok) r.error = "release JSON has no tag_name";
|
||||
} catch (const std::exception& e) {
|
||||
r.error = std::string("failed to parse release JSON: ") + e.what();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<XmrigRelease> parseXmrigReleaseList(const std::string& jsonStr)
|
||||
{
|
||||
std::vector<XmrigRelease> out;
|
||||
try {
|
||||
const json j = json::parse(jsonStr);
|
||||
if (!j.is_array()) return out;
|
||||
for (const auto& e : j) {
|
||||
if (e.contains("draft") && e["draft"].is_boolean() && e["draft"].get<bool>())
|
||||
continue; // skip drafts (not meant for end users)
|
||||
XmrigRelease r = parseOneXmrigRelease(e);
|
||||
if (r.ok) out.push_back(std::move(r));
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
// malformed list -> empty (caller treats as "could not load releases")
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string currentXmrigPlatformToken()
|
||||
{
|
||||
#if defined(_WIN32)
|
||||
|
||||
Reference in New Issue
Block a user