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:
2026-06-27 21:27:13 -05:00
parent 2e8e214689
commit 4473e7e00a
19 changed files with 1848 additions and 108 deletions

View File

@@ -79,10 +79,16 @@ The detailed milestone plan and design history (the v2 plan, backend artifact/AB
## Miner updater (xmrig)
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live.
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live. A **"Browse all releases…"** button (the `/releases` list, newest first, pre-releases included) lets users pin an older or pre-release build — same verify/install path via `startInstallRelease()`; the picker UI is shared with the daemon updater (`ui/windows/release_list_view.h`).
**Signature verification is enforced** (`kXmrigRequireSignature = true` in `src/util/xmrig_updater.h`), checked against the public key pinned in `kXmrigSignaturePublicKeyBase64`. **Consequence for releases:** every `drg-xmrig` release MUST ship a detached signature per archive or the in-app updater refuses it. To cut a release: build the archives, then `scripts/sign-xmrig-release.sh sign <secret.key> <archive.zip>...` (OpenSSL-based, no extra deps) and upload each `<archive>.sig` as a release asset alongside its `.zip`. The signing **secret key must stay offline** (it is gitignored: `*.ed25519.key`); only its base64 public key is pinned in the source. To rotate the key, regenerate (`scripts/sign-xmrig-release.sh keygen`) and update `kXmrigSignaturePublicKeyBase64`. An emergency env override is not provided — disabling verification means setting `kXmrigSignaturePublicKeyBase64` empty (and rebuilding).
## Daemon updater (dragonxd)
Settings → **NODE & SECURITY → DAEMON BINARY** has a **"Check for updates…"** button that downloads/verifies/installs the latest **dragonxd full node** from the project Gitea — the full-node sibling of the xmrig updater (`util/DaemonUpdater` + `ui/windows/daemon_download_dialog.h`, pure no-I/O core in `daemon_updater_core.cpp`; gated full-node-only via `supportsFullNodeLifecycleActions()`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest` → pick the archive for this platform (`linux-amd64` / `macos` / `win64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (parsed from the release body's markdown **checksum table**, not xmrig's `<hash> <name>` lines) **and** a detached **ed25519 signature** → miniz-extract the three executables (`dragonxd`/`dragonx-cli`/`dragonx-tx`, flattening the versioned subdir) into `resources::getDaemonDirectory()`. The archive also bundles Sapling params/asmap, which the updater deliberately leaves to the wallet's own resource extraction. Install is **atomic and safe while the node runs** (POSIX `rename()` replaces the in-use binary; Windows moves the locked `.exe` aside to `.old`); the new binary takes effect on the **next daemon start**, so the Done screen offers **"Restart daemon now"** (`App::restartDaemon()`). A **"Browse all releases…"** button (shared `release_list_view.h` picker) lets users pin a specific/older/pre-release node build via `startInstallRelease()` — with a downgrade caution, since an older binary may not match current chain data.
**Signature verification is enforced** (`kDaemonRequireSignature = true` in `src/util/daemon_updater.h`), checked against `kDaemonSignaturePublicKeyBase64`. **Consequence for releases:** every `dragonx` release MUST ship a detached `<archive>.sig` per platform archive or the in-app updater refuses it (as of v1.0.2 the releases publish SHA-256 but **no** signatures yet — sign + upload them to enable in-app updates). To cut a release: `scripts/sign-daemon-release.sh sign <secret.key> dragonx-<ver>-{linux-amd64,macos,win64}.zip` (OpenSSL-based) and upload each `.sig` next to its `.zip`. The signing **secret key stays offline** (gitignored `*.ed25519.key`; this repo's is `dragonx-daemon.ed25519.key`); only the base64 public key is pinned. To rotate: `scripts/sign-daemon-release.sh keygen` and update `kDaemonSignaturePublicKeyBase64`. The generic SHA-256 / ed25519 primitives are shared with the miner updater (`util::sha256Hex` / `util::verifyXmrigSignature`).
## Versioning
The version has a **single source of truth**: `project(... VERSION 1.2.0 ...)` plus `DRAGONX_VERSION_SUFFIX` in `CMakeLists.txt`. CMake generates `build/.../generated/dragonx_generated_version.h` from `src/config/version.h.in`. Do not hand-edit generated version output or hardcode version strings — bump the `project()` version in `CMakeLists.txt`.

View File

@@ -502,6 +502,8 @@ set(APP_SOURCES
src/util/lite_server_probe.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
src/util/secure_vault.cpp
src/ui/effects/framebuffer.cpp
src/ui/effects/blur_shader.cpp
@@ -744,6 +746,30 @@ if(DRAGONX_LITE_BACKEND_READY)
if(UNIX)
target_link_libraries(lite_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
# Real-backend SEND smoke tool — drives the exact GUI send path (bridge.execute("send", ...)).
add_executable(lite_send_smoke
tools/lite_send_smoke.cpp
src/wallet/lite_client_bridge.cpp
src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_connection_service.cpp
src/wallet/lite_result_parsers.cpp
)
target_include_directories(lite_send_smoke PRIVATE
${CMAKE_SOURCE_DIR}/src
${CMAKE_BINARY_DIR}/generated
${SODIUM_INCLUDE_DIR}
)
target_compile_definitions(lite_send_smoke PRIVATE DRAGONX_ENABLE_LITE_BACKEND=1)
target_link_libraries(lite_send_smoke PRIVATE
dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS}
nlohmann_json::nlohmann_json
${SODIUM_LIBRARY}
)
if(UNIX)
target_link_libraries(lite_send_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
endif()
endif()
# Platform-specific settings
@@ -1011,6 +1037,8 @@ if(BUILD_TESTING)
src/util/lite_server_probe.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
src/util/daemon_updater_core.cpp
${MINIZ_SOURCES}
)

67
scripts/sign-daemon-release.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
#
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
#
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
# (the same flow the wallet's unit tests verify for the miner updater).
#
# Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
#
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail
die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
# Raw 32-byte ed25519 public key (base64) from a private key file. The DER SubjectPublicKeyInfo for
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
cmd="${1:-}"; shift || true
case "$cmd" in
keygen)
prefix="${1:-dragonx-daemon}"
[ -e "$prefix.ed25519.key" ] && die "$prefix.ed25519.key already exists — refusing to overwrite"
openssl genpkey -algorithm ed25519 -out "$prefix.ed25519.key"
chmod 600 "$prefix.ed25519.key"
pub="$(pubkey_b64 "$prefix.ed25519.key")"
printf '%s\n' "$pub" > "$prefix.ed25519.pub.b64"
echo "secret key : $prefix.ed25519.key (KEEP OFFLINE, mode 600)"
echo "public key : $prefix.ed25519.pub.b64"
echo
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub"
;;
pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1"
;;
sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift
[ -f "$key" ] || die "no such key: $key"
for f in "$@"; do
[ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
echo "signed: $f -> $f.sig"
done
echo "Upload each .sig as a release asset next to its archive."
;;
*)
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
;;
esac

View File

@@ -50,6 +50,7 @@
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/bootstrap_download_dialog.h"
#include "ui/windows/xmrig_download_dialog.h"
#include "ui/windows/daemon_download_dialog.h"
#include "ui/windows/console_tab.h"
#include "ui/pages/settings_page.h"
#include "ui/theme.h"
@@ -1923,6 +1924,7 @@ void App::render()
// Bootstrap download from settings
ui::BootstrapDownloadDialog::render();
ui::XmrigDownloadDialog::render();
ui::DaemonUpdateDialog::render();
// Windows Defender antivirus help dialog
renderAntivirusHelpDialog();
@@ -3609,7 +3611,7 @@ void App::renderShutdownScreen()
force_quit_confirm_ = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("%s", TR("force_quit_warning"));
ui::material::Tooltip("%s", TR("force_quit_warning"));
}
ImGui::PopStyleColor(3);
}

View File

@@ -40,6 +40,7 @@
#include "../windows/export_all_keys_dialog.h"
#include "../windows/export_transactions_dialog.h"
#include "../windows/bootstrap_download_dialog.h"
#include "../windows/daemon_download_dialog.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <nlohmann/json.hpp>
@@ -642,7 +643,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndDisabled();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", skin.validationError.c_str());
material::Tooltip("%s", skin.validationError.c_str());
} else {
std::string lbl = skin.name;
if (!skin.author.empty()) lbl += " (" + skin.author + ")";
@@ -684,7 +685,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_theme_hotkey"));
material::Tooltip("%s", TR("tt_theme_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
@@ -707,7 +708,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_layout_hotkey"));
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
@@ -724,7 +725,7 @@ void RenderSettingsPage(App* app) {
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_language"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
@@ -732,7 +733,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("tt_scan_themes"),
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
@@ -762,7 +763,7 @@ void RenderSettingsPage(App* app) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_font_scale"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
@@ -830,20 +831,20 @@ void RenderSettingsPage(App* app) {
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_low_spec"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
@@ -853,14 +854,14 @@ void RenderSettingsPage(App* app) {
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_scanline"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "effects").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_theme_effects"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
// Row 1: Acrylic preset slider + Noise slider (side by side, labels above)
float effCtrlMinW = S.drawElement("components.settings-page", "effects-input-min-width").size;
@@ -886,7 +887,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_blur"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
float afterRow1Y = ImGui::GetCursorScreenPos().y;
float lblH = ImGui::GetTextLineHeight() + ImGui::GetStyle().ItemSpacing.y;
@@ -906,7 +907,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_noise"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow1Y));
@@ -922,7 +923,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_ui_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
float afterRow2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rightX, row2Y - lblH));
@@ -937,7 +938,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_window_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow2Y));
@@ -964,11 +965,11 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_theme_hotkey"));
material::Tooltip("%s", TR("tt_theme_hotkey"));
if (active_is_custom) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "*");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_custom_theme"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_theme"));
}
ImGui::SameLine();
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
@@ -976,7 +977,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(TR("tt_scan_themes"),
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
@@ -1007,7 +1008,7 @@ void RenderSettingsPage(App* app) {
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_layout_hotkey"));
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::PopFont();
}
@@ -1030,7 +1031,7 @@ void RenderSettingsPage(App* app) {
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_language"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::PopFont();
}
@@ -1059,7 +1060,7 @@ void RenderSettingsPage(App* app) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_font_scale"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
@@ -1127,18 +1128,18 @@ void RenderSettingsPage(App* app) {
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_low_spec"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
if (ImGui::Checkbox(TrId("settings_gradient_bg", "gradient_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
@@ -1147,14 +1148,14 @@ void RenderSettingsPage(App* app) {
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_scanline"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "theme_fx").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_theme_effects"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
float ctrlW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size,
availWidth - pad * 2.0f);
@@ -1174,7 +1175,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_blur"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
ImGui::TextUnformatted(TR("noise"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1190,7 +1191,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_noise"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::TextUnformatted(TR("ui_opacity"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1203,7 +1204,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_ui_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
ImGui::TextUnformatted(TR("window_opacity"));
ImGui::SetNextItemWidth(ctrlW);
@@ -1215,7 +1216,7 @@ void RenderSettingsPage(App* app) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_window_opacity"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::EndDisabled(); // low-spec
ImGui::PopFont();
@@ -1274,26 +1275,26 @@ void RenderSettingsPage(App* app) {
float sp = cbSpacing * scale;
ImGui::Checkbox(TrId("save_z_transactions", "save_ztx").c_str(), &s_settingsState.save_ztxs);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_save_ztx"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("auto_shield", "auto_shld").c_str(), &s_settingsState.auto_shield);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_auto_shield"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("use_tor", "tor").c_str(), &s_settingsState.use_tor);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_tor"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor"));
if (showDaemonOptions) {
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("keep_daemon", "keep_dmn").c_str(), &s_settingsState.keep_daemon_running)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_keep_daemon"));
material::Tooltip("%s", TR("tt_keep_daemon"));
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("stop_external", "stop_ext").c_str(), &s_settingsState.stop_external_daemon)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_stop_external"));
material::Tooltip("%s", TR("tt_stop_external"));
}
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("verbose_logging", "verbose").c_str(), &s_settingsState.verbose_logging)) {
@@ -1301,7 +1302,7 @@ void RenderSettingsPage(App* app) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("%s", TR("tt_verbose"));
material::Tooltip("%s", TR("tt_verbose"));
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
}
@@ -1340,28 +1341,28 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
AddressBookDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_address_book"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button")))
ValidateAddressDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_validate"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_request_payment"), ImVec2(bw, 0), S.resolveFont("button")))
RequestPaymentDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_request_payment"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment"));
if (btnsPerRow >= 3) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } else { ImGui::SameLine(0, btnSpacing); }
if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_shield_mining"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::MergeToAddress);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_merge"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) {
s_settingsState.confirm_clear_ztx = true;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_clear_ztx"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -1425,23 +1426,23 @@ void RenderSettingsPage(App* app) {
if (TactileButton(r1[0], ImVec2(0, 0), btnFont))
app->showImportKeyDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[0]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[0]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[1], ImVec2(0, 0), btnFont))
app->showExportKeyDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[1]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[1]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[2], ImVec2(0, 0), btnFont))
ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[2]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[2]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[3], ImVec2(0, 0), btnFont))
app->showBackupDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[3]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[3]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[4], ImVec2(0, 0), btnFont))
ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t1[4]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[4]);
if (showFullNodeLifecycleActions) {
// Right-align Setup Wizard + Download Bootstrap
@@ -1460,11 +1461,11 @@ void RenderSettingsPage(App* app) {
}
if (TactileButton(bsLabel, ImVec2(0, 0), btnFont))
BootstrapDownloadDialog::show(app);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_download_bootstrap"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap"));
ImGui::SameLine(0, scaledSp);
if (TactileButton(wizLabel, ImVec2(0, 0), btnFont))
app->restartWizard();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_wizard"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard"));
}
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
@@ -1675,7 +1676,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getLiteWalletDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
// ---- Backup & keys (open wallet only) ----------------------------------
if (app->liteWallet() && app->liteWallet()->walletOpen()) {
@@ -1859,7 +1860,7 @@ void RenderSettingsPage(App* app) {
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", TR("tt_lite_redownload"));
material::Tooltip("%s", TR("tt_lite_redownload"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
scanning ? TR("lite_redownload_running") : TR("lite_redownload_desc"));
@@ -1926,7 +1927,7 @@ void RenderSettingsPage(App* app) {
}
}
if (hovered) {
ImGui::SetTooltip("%s", TR("tt_open_dir"));
material::Tooltip("%s", TR("tt_open_dir"));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
if (ImGui::IsItemClicked())
@@ -1947,7 +1948,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -1965,7 +1966,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_host"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
float afterRow1Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row1Y));
@@ -1974,7 +1975,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(rpcRightColX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_user"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow1Y, ImGui::GetCursorScreenPos().y)));
@@ -1986,7 +1987,7 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_port"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
float afterRow2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row2Y));
@@ -1996,7 +1997,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetNextItemWidth(rpcHalfInputW);
ImGui::InputText("##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password),
ImGuiInputTextFlags_Password);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_rpc_pass"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow2Y, ImGui::GetCursorScreenPos().y)));
@@ -2038,13 +2039,13 @@ void RenderSettingsPage(App* app) {
if (!isEncrypted) {
if (TactileButton(TR("settings_encrypt_wallet"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showEncryptDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_encrypt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_encrypt"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_not_encrypted"));
} else {
if (TactileButton(TR("settings_change_passphrase"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showChangePassphraseDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_change_pass"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pass"));
ImGui::SameLine(0, Layout::spacingMd());
if (isLocked) {
ImGui::PushFont(Type().iconSmall());
@@ -2055,7 +2056,7 @@ void RenderSettingsPage(App* app) {
} else {
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->lockWallet();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_lock"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
@@ -2067,7 +2068,7 @@ void RenderSettingsPage(App* app) {
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y + Layout::spacingXs()));
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showDecryptDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_remove_encrypt"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -2091,7 +2092,7 @@ void RenderSettingsPage(App* app) {
app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]);
app->settings()->save();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_auto_lock"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock"));
ImGui::PopItemWidth();
}
@@ -2107,17 +2108,17 @@ void RenderSettingsPage(App* app) {
if (!hasPIN) {
if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinSetupDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_set_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin"));
} else {
if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinChangeDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_change_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinRemoveDialog();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_remove_pin"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
@@ -2210,6 +2211,17 @@ void RenderSettingsPage(App* app) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_differ"));
}
// In-app node update: download + verify the latest dragonxd from the project Gitea.
// Refresh the cached daemon info once an install has completed.
if (ui::DaemonUpdateDialog::consumeInstalled())
s_settingsState.daemon_info_loaded = false;
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TR("daemon_update_check"), ImVec2(0, 0), dbBtnFont)) {
ui::DaemonUpdateDialog::show(app, inst.exists ? inst.version : std::string());
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_update_check"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// All node actions on one full-width toolbar row: daemon-binary actions first
// (Install bundled | Refresh), then maintenance (Test connection | Rescan |
@@ -2219,7 +2231,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("daemon_install_bundled"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_reinstall_daemon = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_daemon_install_bundled"));
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_install_bundled"));
ImGui::EndDisabled();
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("refresh"), ImVec2(0, 0), dbBtnFont)) {
@@ -2243,7 +2255,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().warning("Not connected to daemon");
}
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_test_conn"));
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("rescan"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_rescan = true;
@@ -2251,19 +2263,19 @@ void RenderSettingsPage(App* app) {
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = false;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_rescan"));
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_rescan"));
ImGui::EndDisabled();
ImGui::SameLine(0, Layout::spacingMd());
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
if (TactileButton(TR("delete_blockchain"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_delete_blockchain = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_delete_blockchain"));
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("repair_wallet"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_repair_wallet = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_repair_wallet"));
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet"));
ImGui::EndDisabled();
}
}
@@ -2310,29 +2322,29 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputTxW);
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_tx_url"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::SameLine(pad + halfW + Layout::spacingLg());
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("address_url"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputAddrW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_addr_url"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Row 2: Checkboxes + Block Explorer button (on one line)
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_custom_fees"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_fetch_prices"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
ImGui::SameLine(0, Layout::spacingLg());
if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://explorer.dragonx.is");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_block_explorer"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer"));
ImGui::Dummy(ImVec2(0, bottomPad));
ImGui::Unindent(pad);
@@ -2433,18 +2445,18 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://dragonx.is");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_website"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_website"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("report_bug", "about_bug").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_report_bug"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
saveSettingsPageState(app->settings());
Notifications::instance().success("Settings saved");
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_save_settings"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
if (app->settings()) {
@@ -2452,7 +2464,7 @@ void RenderSettingsPage(App* app) {
Notifications::instance().info("Settings reloaded from disk");
}
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reset_settings"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
@@ -2482,7 +2494,7 @@ void RenderSettingsPage(App* app) {
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
s_settingsState.debug_expanded = !s_settingsState.debug_expanded;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
ImGui::PopStyleColor(3);
// Draw overline label + arrow on top of the invisible button
@@ -2562,7 +2574,7 @@ void RenderSettingsPage(App* app) {
s_settingsState.debug_cats_dirty = true;
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", debugTips[i]);
if (ImGui::IsItemHovered()) material::Tooltip("%s", debugTips[i]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -2585,7 +2597,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_restart_daemon = true;
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_restart_daemon"));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
}
ImGui::Dummy(ImVec2(0, bottomPad));

View File

@@ -0,0 +1,254 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Modal dialog to download / update the dragonxd full node from the project Gitea, driven by
// util::DaemonUpdater. Sibling of XmrigDownloadDialog (the miner updater). States follow the
// updater: Checking -> UpToDate/UpdateAvailable -> Downloading/Verifying/Extracting -> Done /
// Failed. Header-only (static inline state). The new binary is installed into the daemon directory
// without touching the running node; on Done the user is offered a daemon restart to apply it.
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "../../app.h"
#include "../../resources/embedded_resources.h" // resources::getDaemonDirectory()
#include "../../util/daemon_updater.h"
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../theme.h"
#include "release_list_view.h"
#include "imgui.h"
namespace dragonx {
namespace ui {
class DaemonUpdateDialog {
public:
// `installedVersion` is the version scanned from the installed dragonxd (may be empty/unknown),
// used to tell "up to date" from "update available".
static void show(App* app, const std::string& installedVersion) {
if (!app) return;
s_app = app;
s_open = true;
s_notified = false;
s_installed_flag = false; // start each session clean (don't leak a prior install's flag)
s_installed_version = installedVersion;
s_rows.clear();
s_releases.clear();
s_updater = std::make_unique<util::DaemonUpdater>();
s_updater->startCheck(installedVersion);
}
static bool isOpen() { return s_open; }
// True (once) after an install succeeded, so the Settings panel can refresh its cached daemon
// info. Clears on read.
static bool consumeInstalled() {
const bool v = s_installed_flag;
s_installed_flag = false;
return v;
}
static void render() {
if (!s_open || !s_app || !s_updater) {
if (!s_open) s_updater.reset(); // closed: drop the updater (dtor joins the worker)
return;
}
using namespace material;
const float dp = Layout::dpiScale();
if (BeginOverlayDialog(TR("daemon_update_title"), &s_open, 480.0f, 0.94f)) {
const auto p = s_updater->getProgress();
using St = util::DaemonUpdater::State;
switch (p.state) {
case St::Checking: renderChecking(dp, p); break;
case St::UpToDate:
case St::UpdateAvailable: renderPrompt(dp, p); break;
case St::Unavailable: renderUnavailable(dp, p); break;
case St::Listing: renderListing(dp, p); break;
case St::ReleaseList: renderReleaseList(dp, p); break;
case St::Downloading:
case St::Verifying:
case St::Extracting: renderProgress(dp, p); break;
case St::Done: renderDone(dp, p); break;
case St::Failed: renderFailed(dp, p); break;
default: break;
}
EndOverlayDialog();
}
if (!s_open) s_updater.reset();
}
private:
using Progress = util::DaemonUpdater::Progress;
using St = util::DaemonUpdater::State;
static float fullW() { return ImGui::GetContentRegionAvail().x; }
static void installAction(const char* label) {
using namespace material;
if (TactileButton(label, ImVec2(fullW(), 0)))
s_updater->startInstall(resources::getDaemonDirectory());
}
static void renderChecking(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("daemon_update_checking"));
}
static void renderUnavailable(float, const Progress& p) {
using namespace material;
Type().text(TypeStyle::Subtitle2, TR("daemon_update_unavailable_title"));
ImGui::Spacing();
ImGui::TextWrapped("%s", p.status_text.empty()
? TR("daemon_update_unavailable_body")
: p.status_text.c_str());
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderPrompt(float, const Progress& p) {
using namespace material;
const std::string installed = p.installed_tag.empty() ? TR("xmrig_none") : p.installed_tag;
if (p.state == St::UpdateAvailable) {
Type().text(TypeStyle::Subtitle2, TR("daemon_update_available"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_latest"), p.latest_tag.c_str());
ImGui::Text("%s %s", TR("daemon_update_installed"), installed.c_str());
ImGui::Spacing();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_verify_note"));
ImGui::Spacing();
installAction(TR("daemon_update_download_install"));
} else {
Type().text(TypeStyle::Subtitle2, TR("daemon_update_up_to_date"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_installed"), p.latest_tag.c_str());
ImGui::Spacing();
installAction(TR("daemon_update_reinstall"));
}
ImGui::Spacing();
// Browse all releases (pre-releases / older versions).
if (TactileButton(TR("daemon_update_browse"), ImVec2(fullW(), 0))) {
s_rows.clear();
s_updater->startListReleases();
}
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderListing(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("daemon_update_loading"));
}
static void renderReleaseList(float dp, const Progress&) {
using namespace material;
// Snapshot the worker's list + build the rows once per listing (not every frame — the
// release bodies are large). Caches are cleared when a new listing starts (browse / show).
if (s_rows.empty()) {
s_releases = s_updater->getReleases();
const std::string token = util::currentDaemonPlatformToken();
const std::string instCore = util::daemonVersionCore(s_installed_version);
s_rows.reserve(s_releases.size());
for (const auto& r : s_releases) {
ReleaseRow row;
row.tag = r.tag;
row.title = r.name;
row.date = r.publishedAt.size() >= 10 ? r.publishedAt.substr(0, 10) : r.publishedAt;
row.prerelease = r.prerelease;
row.hasAsset = util::selectDaemonAsset(r, token) >= 0;
row.installed = !instCore.empty() && util::daemonVersionCore(r.tag) == instCore;
s_rows.push_back(std::move(row));
}
}
// Downgrade caution: an older node binary may not match the current chain data.
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_downgrade_note"));
ImGui::Spacing();
bool back = false;
const int idx = RenderReleaseList(s_rows, dp, &back);
if (back) { s_rows.clear(); s_updater->startCheck(s_installed_version); return; }
if (idx >= 0 && idx < static_cast<int>(s_releases.size())) {
const util::DaemonRelease rel = s_releases[idx];
s_rows.clear();
s_updater->startInstallRelease(resources::getDaemonDirectory(), rel);
}
}
static void renderProgress(float dp, const Progress& p) {
using namespace material;
const char* title = p.state == St::Downloading ? TR("daemon_update_downloading")
: p.state == St::Verifying ? TR("daemon_update_verifying")
: TR("daemon_update_installing");
Type().text(TypeStyle::Subtitle2, title);
ImGui::Spacing();
const float barH = 8.0f * dp;
const float barW = fullW();
const ImVec2 bMin = ImGui::GetCursorScreenPos();
const ImVec2 bMax(bMin.x + barW, bMin.y + barH);
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(bMin, bMax, IM_COL32(255, 255, 255, 30), 4.0f * dp);
const float fillW = barW * (p.percent / 100.0f);
if (fillW > 0)
dl->AddRectFilled(bMin, ImVec2(bMin.x + fillW, bMax.y), Primary(), 4.0f * dp);
ImGui::Dummy(ImVec2(0, barH));
ImGui::Spacing();
ImGui::Text("%s", p.status_text.c_str());
ImGui::Spacing();
// Cancel aborts the in-flight transfer promptly (the curl progress callback returns abort).
if (TactileButton(TR("cancel"), ImVec2(fullW(), 0))) {
s_updater->cancel();
s_open = false;
}
}
static void renderDone(float, const Progress& p) {
using namespace material;
// Tell the Settings panel to refresh its cached daemon info — once, not every frame (a
// re-scan reads the whole daemon binary).
if (!s_notified) { s_installed_flag = true; s_notified = true; }
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
ImGui::Spacing();
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
ImGui::Spacing();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
ImGui::Spacing();
if (s_app->isUsingEmbeddedDaemon()) {
if (TactileButton(TR("daemon_update_restart_now"), ImVec2(fullW(), 0))) {
s_app->restartDaemon();
s_open = false;
}
ImGui::Spacing();
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
} else {
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
}
static void renderFailed(float, const Progress& p) {
using namespace material;
Type().textColored(TypeStyle::Subtitle2, Error(), TR("daemon_update_failed"));
ImGui::Spacing();
ImGui::TextWrapped("%s", p.error.empty() ? TR("daemon_update_unknown_error") : p.error.c_str());
ImGui::Spacing();
installAction(TR("retry"));
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static inline bool s_open = false;
static inline bool s_installed_flag = false;
static inline bool s_notified = false;
static inline std::string s_installed_version;
static inline std::vector<ReleaseRow> s_rows; // built once per listing (UI rows)
static inline std::vector<util::DaemonRelease> s_releases; // matching releases (install by index)
static inline App* s_app = nullptr;
static inline std::unique_ptr<util::DaemonUpdater> s_updater;
};
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,87 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Shared "Browse all releases" picker, used by both the miner updater (XmrigDownloadDialog) and the
// node updater (DaemonUpdateDialog). Renders a scrollable list of releases (tag, title, date, with
// pre-release / installed badges) inside the current overlay dialog; the caller maps its updater's
// release list into ReleaseRow values and acts on the clicked index.
#pragma once
#include <string>
#include <vector>
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "imgui.h"
namespace dragonx {
namespace ui {
struct ReleaseRow {
std::string tag; // version tag, e.g. "v1.0.2"
std::string title; // human title (release "name"), shown dimmed
std::string date; // YYYY-MM-DD (already trimmed)
bool prerelease = false; // show a pre-release badge
bool hasAsset = true; // a build exists for this platform (else the row's Install is disabled)
bool installed = false; // matches the currently-installed version (Install -> Reinstall)
};
// Renders the picker. Returns the row index whose Install button was clicked this frame, or -1.
// Sets *back when the Back button is clicked. If `globalDisabledTooltip` is non-null, every Install
// button is disabled and shows that tooltip (e.g. "stop mining before updating the miner").
inline int RenderReleaseList(const std::vector<ReleaseRow>& rows, float dp, bool* back,
const char* globalDisabledTooltip = nullptr) {
using namespace material;
int clicked = -1;
Type().text(TypeStyle::Subtitle2, TR("upd_select_version"));
ImGui::Spacing();
const float listH = 300.0f * dp;
if (ImGui::BeginChild("##release_list", ImVec2(0, listH), true)) {
const ImVec4 dim = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
const ImVec4 warn = ImVec4(1.0f, 0.78f, 0.25f, 1.0f);
const ImVec4 succ = ImGui::ColorConvertU32ToFloat4(Success());
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const ReleaseRow& r = rows[i];
ImGui::PushID(i);
ImGui::TextUnformatted(r.tag.c_str());
if (r.prerelease) { ImGui::SameLine(); ImGui::TextColored(warn, "[%s]", TR("upd_prerelease")); }
if (r.installed) { ImGui::SameLine(); ImGui::TextColored(succ, "[%s]", TR("upd_installed_badge")); }
if (!r.title.empty() || !r.date.empty()) {
std::string meta = r.title;
if (!r.title.empty() && !r.date.empty()) meta += " · ";
meta += r.date;
ImGui::TextColored(dim, "%s", meta.c_str());
}
const char* lbl = r.installed ? TR("upd_reinstall") : TR("upd_install");
const bool disabled = !r.hasAsset || globalDisabledTooltip != nullptr;
ImGui::BeginDisabled(disabled);
if (TactileButton(lbl, ImVec2(140.0f * dp, 0))) clicked = i;
ImGui::EndDisabled();
if (disabled && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", globalDisabledTooltip ? globalDisabledTooltip
: TR("upd_no_build_platform"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::PopID();
}
}
ImGui::EndChild();
ImGui::Spacing();
if (TactileButton(TR("upd_back"), ImVec2(ImGui::GetContentRegionAvail().x, 0))) *back = true;
return clicked;
}
} // namespace ui
} // namespace dragonx

View File

@@ -11,6 +11,7 @@
#include <memory>
#include <string>
#include <vector>
#include "../../app.h"
#include "../../config/settings.h"
@@ -21,6 +22,7 @@
#include "../material/type.h"
#include "../material/colors.h"
#include "../theme.h"
#include "release_list_view.h"
#include "imgui.h"
namespace dragonx {
@@ -33,8 +35,11 @@ public:
s_app = app;
s_open = true;
s_persisted = false;
s_installed_tag = app->settings() ? app->settings()->getXmrigVersion() : std::string();
s_rows.clear();
s_releases.clear();
s_updater = std::make_unique<util::XmrigUpdater>();
s_updater->startCheck(app->settings() ? app->settings()->getXmrigVersion() : std::string());
s_updater->startCheck(s_installed_tag);
}
static bool isOpen() { return s_open; }
@@ -54,6 +59,8 @@ public:
case St::UpToDate:
case St::UpdateAvailable: renderPrompt(dp, p); break;
case St::Unavailable: renderUnavailable(dp, p); break;
case St::Listing: renderListing(dp, p); break;
case St::ReleaseList: renderReleaseList(dp, p); break;
case St::Downloading:
case St::Verifying:
case St::Extracting: renderProgress(dp, p); break;
@@ -121,9 +128,50 @@ private:
installAction(TR("xmrig_reinstall"));
}
ImGui::Spacing();
// Browse all releases (pre-releases / older versions).
if (TactileButton(TR("xmrig_browse_releases"), ImVec2(fullW(), 0))) {
s_rows.clear();
s_updater->startListReleases();
}
ImGui::Spacing();
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
}
static void renderListing(float, const Progress&) {
using namespace material;
Type().text(TypeStyle::Body2, TR("xmrig_loading_releases"));
}
static void renderReleaseList(float dp, const Progress&) {
using namespace material;
// Snapshot + build rows once per listing (not every frame). Cleared on browse / show.
if (s_rows.empty()) {
s_releases = s_updater->getReleases();
const std::string token = util::currentXmrigPlatformToken();
s_rows.reserve(s_releases.size());
for (const auto& r : s_releases) {
ReleaseRow row;
row.tag = r.tag;
row.title = r.name;
row.date = r.publishedAt.size() >= 10 ? r.publishedAt.substr(0, 10) : r.publishedAt;
row.prerelease = r.prerelease;
row.hasAsset = util::selectXmrigAsset(r, token) >= 0;
row.installed = !s_installed_tag.empty() && r.tag == s_installed_tag;
s_rows.push_back(std::move(row));
}
}
// Can't replace a running miner binary — disable installs while mining (TOCTOU guard).
const char* disabledNote = s_app->isPoolMinerRunning() ? TR("xmrig_stop_mining_first") : nullptr;
bool back = false;
const int idx = RenderReleaseList(s_rows, dp, &back, disabledNote);
if (back) { s_rows.clear(); s_updater->startCheck(s_installed_tag); return; }
if (idx >= 0 && !s_app->isPoolMinerRunning() && idx < static_cast<int>(s_releases.size())) {
const util::XmrigRelease rel = s_releases[idx];
s_rows.clear();
s_updater->startInstallRelease(resources::getDaemonDirectory(), rel);
}
}
static void renderProgress(float dp, const Progress& p) {
using namespace material;
const char* title = p.state == St::Downloading ? TR("xmrig_downloading")
@@ -180,6 +228,9 @@ private:
static inline bool s_open = false;
static inline bool s_persisted = false;
static inline std::string s_installed_tag;
static inline std::vector<ReleaseRow> s_rows; // built once per listing (UI rows)
static inline std::vector<util::XmrigRelease> s_releases; // matching releases (install by index)
static inline App* s_app = nullptr;
static inline std::unique_ptr<util::XmrigUpdater> s_updater;
};

478
src/util/daemon_updater.cpp Normal file
View 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
View 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

View 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

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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_;

View File

@@ -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)

View File

@@ -0,0 +1 @@
{"id":15,"tag_name":"v1.0.2","target_commitish":"dragonx","name":"Dragonx v1.0.2","body":"## What is DragonX?\r\n\r\nDragonX is a privacy-focused cryptocurrency built on zero-knowledge mathematics. It uses the\r\nSapling protocol for shielded transactions and enforces mandatory z2z (shielded-to-shielded)\r\ntransactions after block 340,000, meaning funds can only be sent to shielded z-addresses at\r\nthe consensus level.\r\n\r\n### Bug Fixes\r\n * Fix sapling pool persistence — pool total no longer resets to 0 on node restart. Explorer nodes should reindex once after upgrading.\r\n\r\n### New Features\r\n * Add `subsidy` and `fees` fields to the `getblock` RPC response so explorers can display the correct 3 DRGX block reward separately from fees\r\n\r\n### Key Features\r\n\r\n * **RandomX Proof-of-Work** — CPU-mineable, ASIC-resistant mining algorithm\r\n * **Sapling zk-SNARKs** — zero-knowledge proofs for fully private transactions\r\n * **Mandatory shielded transactions** — z2z enforced at consensus after block 340,000\r\n * **Encrypted P2P** — all connections secured with TLS 1.3 via WolfSSL (AES-256-GCM and ChaCha20-Poly1305)\r\n * **Anonymous networking** — built-in Tor, i2p, and cjdns support\r\n * **Passive network spy protection** — prevents ISPs and observers from identifying transaction origins\r\n\r\n## Checksums\r\n\r\n| File | SHA-256 |\r\n|------|---------|\r\n| dragonx-1.0.2-linux-amd64.zip | `85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16` |\r\n| dragonx-1.0.2-macos.zip | `102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432` |\r\n| dragonx-1.0.2-win64.zip | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |\r\n\r\n## License\r\n\r\nDragonX is released under the [GNU General Public License v3 (GPLv3)](https://git.dragonx.is/DragonX/dragonx/src/branch/dragonx/COPYING).\r\n\r\nCopyright © 2024-2026 The DragonX Developers\r\n","url":"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/15","html_url":"https://git.dragonx.is/DragonX/dragonx/releases/tag/v1.0.2","tarball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.2.tar.gz","zipball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.2.zip","upload_url":"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/15/assets","draft":false,"prerelease":false,"created_at":"2026-03-19T10:09:18-05:00","published_at":"2026-03-19T10:09:18-05:00","author":{"id":1,"login":"DanS","login_name":"","source_id":0,"full_name":"","email":"dans@noreply.localhost","avatar_url":"https://git.dragonx.is/avatars/ac3f843e96162174082a0b0e2b02b4d894ea793139c2e181e5971483e233a946","html_url":"https://git.dragonx.is/DanS","language":"","is_admin":false,"last_login":"0001-01-01T00:00:00Z","created":"2026-02-27T13:12:08-06:00","restricted":false,"active":false,"prohibit_login":false,"location":"","website":"","description":"","visibility":"public","followers_count":0,"following_count":0,"starred_repos_count":1,"username":"DanS"},"assets":[{"id":53,"name":"dragonx-1.0.2-linux-amd64.zip","size":61792264,"download_count":98,"created_at":"2026-03-19T10:21:03-05:00","uuid":"ff53b136-e128-438b-82d8-76b9f33b830a","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-linux-amd64.zip"},{"id":52,"name":"dragonx-1.0.2-macos.zip","size":59382241,"download_count":35,"created_at":"2026-03-19T10:21:03-05:00","uuid":"aefc7f79-ae87-4da9-a168-8b731166c080","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-macos.zip"},{"id":54,"name":"dragonx-1.0.2-win64.zip","size":62453466,"download_count":150,"created_at":"2026-03-19T10:21:06-05:00","uuid":"ab44576e-e354-4019-8f5a-a5536257e854","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.2/dragonx-1.0.2-win64.zip"}]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -23,6 +23,7 @@
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
@@ -4604,6 +4605,190 @@ void testXmrigSignatureVerify()
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
}
// Reads any fixture file by repo-relative path under tests/fixtures/.
static std::string readFixtureFile(const std::string& rel)
{
std::ifstream f(std::string(DRAGONX_TEST_FIXTURE_DIR) + "/" + rel, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
void testXmrigReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseXmrigReleaseList(readFixtureFile("xmrig/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v6.25.3")); // newest first (Gitea order preserved)
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.tag.empty());
EXPECT_TRUE(!r.assets.empty());
}
// Fail closed on non-array / garbage.
EXPECT_TRUE(parseXmrigReleaseList("not json").empty());
EXPECT_TRUE(parseXmrigReleaseList("{}").empty());
// Drafts are skipped; the pre-release flag is captured.
const std::string inj = R"([
{"tag_name":"v9.9.9","prerelease":true,"assets":[{"name":"a-linux-x64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v9.9.8","draft":true,"assets":[]}
])";
const auto injList = parseXmrigReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1)); // draft dropped
EXPECT_EQ(injList.front().tag, std::string("v9.9.9"));
EXPECT_TRUE(injList.front().prerelease);
}
// ── daemon updater pure core (util/daemon_updater.h) — driven by the real v1.0.2 release fixture ──
static std::string readDaemonFixture()
{
return readFixtureFile("daemon/release_latest.json");
}
void testDaemonReleaseParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
EXPECT_TRUE(rel.ok);
EXPECT_EQ(rel.tag, std::string("v1.0.2"));
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(3)); // linux-amd64 / macos / win64
for (const auto& a : rel.assets) {
EXPECT_TRUE(!a.name.empty());
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
EXPECT_TRUE(a.size > 0);
}
EXPECT_FALSE(parseDaemonRelease("not json at all").ok);
EXPECT_FALSE(parseDaemonRelease("{}").ok); // no tag_name
}
void testDaemonAssetSelection()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const int lin = selectDaemonAsset(rel, "linux-amd64");
const int mac = selectDaemonAsset(rel, "macos");
const int win = selectDaemonAsset(rel, "win64");
EXPECT_TRUE(lin >= 0 && mac >= 0 && win >= 0);
EXPECT_TRUE(lin != mac && mac != win && lin != win);
EXPECT_TRUE(rel.assets[lin].name.find("linux-amd64.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[mac].name.find("macos.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[win].name.find("win64.zip") != std::string::npos);
// Wrong/foreign tokens (e.g. the miner's naming) must NOT match the daemon archives.
EXPECT_EQ(selectDaemonAsset(rel, "linux-x64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, "linux-arm64"), -1);
EXPECT_EQ(selectDaemonAsset(rel, ""), -1);
}
void testDaemonChecksumParsing()
{
using namespace dragonx::util;
const auto rel = parseDaemonRelease(readDaemonFixture());
const auto sums = parseDaemonChecksums(rel.body);
// The markdown checksum table ( "| <archive>.zip | `<sha256>` |" ) is parsed by archive name.
EXPECT_EQ(sums.at("dragonx-1.0.2-linux-amd64.zip"),
std::string("85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16"));
EXPECT_EQ(sums.at("dragonx-1.0.2-macos.zip"),
std::string("102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432"));
EXPECT_EQ(sums.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
// Header/separator/prose rows are ignored.
EXPECT_TRUE(parseDaemonChecksums("| File | SHA-256 |\n|---|---|\njust prose, no hashes").empty());
// Keys are lowercased so the (also-lowercased) lookup is case-insensitive vs the JSON names.
const auto mixed = parseDaemonChecksums(
"| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |");
EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"),
std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a"));
}
void testDaemonBasenamesAndVersionCore()
{
using namespace dragonx::util;
const auto posix = daemonExtractBasenames("linux-amd64");
EXPECT_EQ(posix.size(), static_cast<std::size_t>(3));
EXPECT_EQ(posix.front(), std::string("dragonxd")); // daemon binary is always first
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-cli")) != posix.end());
EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-tx")) != posix.end());
const auto win = daemonExtractBasenames("win64");
EXPECT_EQ(win.front(), std::string("dragonxd.exe"));
EXPECT_TRUE(std::find(win.begin(), win.end(), std::string("dragonx-cli.exe")) != win.end());
// Version-core normalization: a scanned "vX.Y.Z-<commit>" must compare equal to a release tag.
EXPECT_EQ(daemonVersionCore("v1.0.2-ddd851dc1"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.0.2"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("1.0.2"), std::string("1.0.2"));
EXPECT_EQ(daemonVersionCore("DragonX v1.0.2 (abc)"), std::string("v1.0.2"));
EXPECT_EQ(daemonVersionCore("v1.2"), std::string("v1.2")); // not full N.N.N -> unchanged
EXPECT_EQ(daemonVersionCore(""), std::string(""));
EXPECT_EQ(daemonVersionCore("nightly"), std::string("nightly"));
}
void testDaemonSignatureAssetSelection()
{
using namespace dragonx::util;
DaemonRelease rel; rel.ok = true; rel.tag = "v1.0.2";
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip", "https://x/zip", 100});
EXPECT_EQ(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip"), -1); // none published
rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip.sig", "https://x/sig", 64});
EXPECT_TRUE(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip") >= 0);
EXPECT_EQ(selectDaemonSignatureAsset(rel, "other.zip"), -1);
}
void testDaemonPinnedKeyValidity()
{
using namespace dragonx::util;
// The daemon updater REQUIRES signatures, so the pinned key must be a valid 32-byte ed25519 key.
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(kDaemonRequireSignature);
EXPECT_TRUE(!pin.empty());
EXPECT_TRUE(sodium_init() >= 0);
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
std::size_t n = 0; const char* end = nullptr;
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
&n, &end, sodium_base64_VARIANT_ORIGINAL);
EXPECT_EQ(rc, 0);
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}
void testDaemonSignatureInterop()
{
using namespace dragonx::util;
// Known answer produced by scripts/sign-daemon-release.sh (OpenSSL ed25519) over the message
// below with the pinned key's secret half. Proves the pinned key + the release-signing flow
// produce signatures the wallet's libsodium verifier accepts (closes the OpenSSL<->libsodium
// interop loop), and that the pinned public key actually matches the signing key.
const std::string msg = "dragonx daemon archive payload bytes for signing test";
const std::string sigB64 =
"rmQ5qOw+W8vu56GeZrooD7Wh1N/WHRP4siD19Mxq/8WXQQuNrFY3DPCNU9C7jHB2jg/VfKrLVna57K/lkSDBDA==";
const std::string pin = kDaemonSignaturePublicKeyBase64;
EXPECT_TRUE(verifyXmrigSignature(msg, sigB64, pin));
EXPECT_TRUE(verifyXmrigSignature(msg, " " + sigB64 + "\n", pin)); // trailing newline tolerated
// Fails closed on tampered payload or wrong key.
EXPECT_FALSE(verifyXmrigSignature(msg + "x", sigB64, pin));
EXPECT_FALSE(verifyXmrigSignature(msg, sigB64, std::string(kXmrigSignaturePublicKeyBase64)));
}
void testDaemonReleaseListParsing()
{
using namespace dragonx::util;
const auto list = parseDaemonReleaseList(readFixtureFile("daemon/releases_list.json"));
EXPECT_TRUE(list.size() >= 2);
EXPECT_EQ(list.front().tag, std::string("v1.0.2")); // newest first
for (const auto& r : list) {
EXPECT_TRUE(r.ok);
EXPECT_TRUE(!r.assets.empty());
}
EXPECT_TRUE(parseDaemonReleaseList("not json").empty());
EXPECT_TRUE(parseDaemonReleaseList("{}").empty());
// Draft skipped; pre-release + name + published_at captured.
const std::string inj = R"([
{"tag_name":"v2.0.0","prerelease":true,"name":"RC","published_at":"2026-09-01T00:00:00Z","assets":[{"name":"dragonx-2.0.0-linux-amd64.zip","browser_download_url":"https://x/a","size":1}]},
{"tag_name":"v1.9.9","draft":true,"assets":[]}
])";
const auto injList = parseDaemonReleaseList(inj);
EXPECT_EQ(injList.size(), static_cast<std::size_t>(1));
EXPECT_TRUE(injList.front().prerelease);
EXPECT_EQ(injList.front().name, std::string("RC"));
EXPECT_EQ(injList.front().publishedAt.substr(0, 10), std::string("2026-09-01"));
}
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
@@ -4707,6 +4892,15 @@ int main()
testXmrigSignatureAssetSelection();
testXmrigPinnedKeyValidity();
testXmrigSignatureVerify();
testXmrigReleaseListParsing();
testDaemonReleaseParsing();
testDaemonAssetSelection();
testDaemonChecksumParsing();
testDaemonBasenamesAndVersionCore();
testDaemonSignatureAssetSelection();
testDaemonPinnedKeyValidity();
testDaemonSignatureInterop();
testDaemonReleaseListParsing();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testAtomicFileWrite();