diff --git a/CLAUDE.md b/CLAUDE.md index e1538e2..5c967fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ...` (OpenSSL-based, no extra deps) and upload each `.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 ` ` 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 `.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 dragonx--{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`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 70fc162..54f11e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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} ) diff --git a/scripts/sign-daemon-release.sh b/scripts/sign-daemon-release.sh new file mode 100755 index 0000000..a574530 --- /dev/null +++ b/scripts/sign-daemon-release.sh @@ -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 .zip this produces .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] # -> .ed25519.{key,pub.b64} +# scripts/sign-daemon-release.sh pubkey # print the base64 public key to pin +# scripts/sign-daemon-release.sh sign ...# -> .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 " + pubkey_b64 "$1" + ;; + sign) + [ $# -ge 2 ] || die "usage: sign ..." + 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 | sign ...}" + ;; +esac diff --git a/src/app.cpp b/src/app.cpp index c0c83bf..cd3f63d 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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); } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 4c6f003..14a83b4 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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 @@ -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)); diff --git a/src/ui/windows/daemon_download_dialog.h b/src/ui/windows/daemon_download_dialog.h new file mode 100644 index 0000000..cd19480 --- /dev/null +++ b/src/ui/windows/daemon_download_dialog.h @@ -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 +#include +#include + +#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(); + 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(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 s_rows; // built once per listing (UI rows) + static inline std::vector s_releases; // matching releases (install by index) + static inline App* s_app = nullptr; + static inline std::unique_ptr s_updater; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/release_list_view.h b/src/ui/windows/release_list_view.h new file mode 100644 index 0000000..391e999 --- /dev/null +++ b/src/ui/windows/release_list_view.h @@ -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 +#include + +#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& 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(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 diff --git a/src/ui/windows/xmrig_download_dialog.h b/src/ui/windows/xmrig_download_dialog.h index 836bf96..e9c8cdd 100644 --- a/src/ui/windows/xmrig_download_dialog.h +++ b/src/ui/windows/xmrig_download_dialog.h @@ -11,6 +11,7 @@ #include #include +#include #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(); - 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(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 s_rows; // built once per listing (UI rows) + static inline std::vector s_releases; // matching releases (install by index) static inline App* s_app = nullptr; static inline std::unique_ptr s_updater; }; diff --git a/src/util/daemon_updater.cpp b/src/util/daemon_updater.cpp new file mode 100644 index 0000000..a869e85 --- /dev/null +++ b/src/util/daemon_updater.cpp @@ -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 +#include + +#include +#include +#include +#include +#include +#include + +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(userp)->append(static_cast(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(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(clientp); + return up->onDownloadProgress(static_cast(dlnow), static_cast(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(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 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(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 lk(mutex_); + return progress_; +} + +bool DaemonUpdater::onDownloadProgress(double downloadedBytes, double totalBytes) +{ + { + std::lock_guard lk(mutex_); + progress_.downloaded_bytes = downloadedBytes; + if (totalBytes > 0) progress_.total_bytes = totalBytes; + progress_.percent = (progress_.total_bytes > 0) + ? static_cast(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 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 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 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 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 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 DaemonUpdater::getReleases() const +{ + std::lock_guard 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 list = parseDaemonReleaseList(body); + const bool empty = list.empty(); + { + std::lock_guard 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 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-" matches a release "v1.0.2". + const std::string instCore = daemonVersionCore(installedVersion); + const bool updateAvailable = instCore.empty() || instCore != daemonVersionCore(rel.tag); + { + std::lock_guard 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 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(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(f)), std::istreambuf_iterator()); + 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 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(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(mem), static_cast(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 diff --git a/src/util/daemon_updater.h b/src/util/daemon_updater.h new file mode 100644 index 0000000..5989c7f --- /dev/null +++ b/src/util/daemon_updater.h @@ -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--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 +#include +#include +#include +#include +#include +#include + +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 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 ".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 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 ( "| .zip | `` |" rows ) into +// { archive-name -> lowercase-hex }. Header/separator/prose rows (no 64-hex token) are ignored. +std::map 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 daemonExtractBasenames(const std::string& platformToken); + +// Index of the detached-signature asset for a given archive (name ".sig" or +// ".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 "-" 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 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 releases_; + std::atomic cancel_requested_{false}; + std::atomic worker_running_{false}; + std::thread worker_; +}; + +} // namespace util +} // namespace dragonx diff --git a/src/util/daemon_updater_core.cpp b/src/util/daemon_updater_core.cpp new file mode 100644 index 0000000..6968804 --- /dev/null +++ b/src/util/daemon_updater_core.cpp @@ -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 + +#include +#include +#include + +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(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(); + if (j.contains("name") && j["name"].is_string()) + r.name = j["name"].get(); + if (j.contains("body") && j["body"].is_string()) + r.body = j["body"].get(); + if (j.contains("prerelease") && j["prerelease"].is_boolean()) + r.prerelease = j["prerelease"].get(); + if (j.contains("published_at") && j["published_at"].is_string()) + r.publishedAt = j["published_at"].get(); + 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(); + if (a.contains("browser_download_url") && a["browser_download_url"].is_string()) + asset.downloadUrl = a["browser_download_url"].get(); + if (a.contains("size") && a["size"].is_number_integer()) + asset.size = a["size"].get(); + 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 parseDaemonReleaseList(const std::string& jsonStr) +{ + std::vector 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()) + 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(i); + } + return -1; +} + +std::map 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 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 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(i); + } + return -1; +} + +std::string daemonVersionCore(const std::string& version) +{ + // Extract a leading "v?MAJOR.MINOR.PATCH" run, ignoring any "-" / build suffix. Hand- + // rolled (no ) 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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index a877b3b..7e4be2c 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 442dbf4..047d120 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -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 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 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 XmrigUpdater::getReleases() const +{ + std::lock_guard 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 list = parseXmrigReleaseList(body); + const bool empty = list.empty(); + { + std::lock_guard 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) { diff --git a/src/util/xmrig_updater.h b/src/util/xmrig_updater.h index 2d56d79..17ce4d5 100644 --- a/src/util/xmrig_updater.h +++ b/src/util/xmrig_updater.h @@ -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 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 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 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 releases_; std::atomic cancel_requested_{false}; std::atomic worker_running_{false}; std::thread worker_; diff --git a/src/util/xmrig_updater_core.cpp b/src/util/xmrig_updater_core.cpp index c0387d2..017901b 100644 --- a/src/util/xmrig_updater_core.cpp +++ b/src/util/xmrig_updater_core.cpp @@ -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(); + if (j.contains("name") && j["name"].is_string()) + r.name = j["name"].get(); + if (j.contains("body") && j["body"].is_string()) + r.body = j["body"].get(); + if (j.contains("prerelease") && j["prerelease"].is_boolean()) + r.prerelease = j["prerelease"].get(); + if (j.contains("published_at") && j["published_at"].is_string()) + r.publishedAt = j["published_at"].get(); + 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(); + if (a.contains("browser_download_url") && a["browser_download_url"].is_string()) + asset.downloadUrl = a["browser_download_url"].get(); + if (a.contains("size") && a["size"].is_number_integer()) + asset.size = a["size"].get(); + 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(); - if (j.contains("body") && j["body"].is_string()) - r.body = j["body"].get(); - 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(); - if (a.contains("browser_download_url") && a["browser_download_url"].is_string()) - asset.downloadUrl = a["browser_download_url"].get(); - if (a.contains("size") && a["size"].is_number_integer()) - asset.size = a["size"].get(); - 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 parseXmrigReleaseList(const std::string& jsonStr) +{ + std::vector 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()) + 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) diff --git a/tests/fixtures/daemon/release_latest.json b/tests/fixtures/daemon/release_latest.json new file mode 100644 index 0000000..0fea424 --- /dev/null +++ b/tests/fixtures/daemon/release_latest.json @@ -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"}]} diff --git a/tests/fixtures/daemon/releases_list.json b/tests/fixtures/daemon/releases_list.json new file mode 100644 index 0000000..b5d49cc --- /dev/null +++ b/tests/fixtures/daemon/releases_list.json @@ -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"}]},{"id":13,"tag_name":"v1.0.1","target_commitish":"dragonx","name":"Dragonx v1.0.1","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 fresh sync failure at diff reset height 2838976\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.1-linux-amd64.zip | `e543dc9cb733877bc53f8526ae60b744d15c82b40005349e04cefd88a3757a6f` |\r\n| dragonx-1.0.1-win64.zip | `428a1567d962d7594b16ad9ae4081752102a578912990e580d7245a4f5607d4c` |\r\n\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/13","html_url":"https://git.dragonx.is/DragonX/dragonx/releases/tag/v1.0.1","tarball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.1.tar.gz","zipball_url":"https://git.dragonx.is/DragonX/dragonx/archive/v1.0.1.zip","upload_url":"https://git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/13/assets","draft":false,"prerelease":false,"created_at":"2026-03-12T01:27:06-05:00","published_at":"2026-03-12T01:27:06-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":40,"name":"dragonx-1.0.1-linux-amd64.zip","size":61789646,"download_count":30,"created_at":"2026-03-12T01:27:05-05:00","uuid":"81299793-21b0-4fa3-b37d-47b9becff032","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.1/dragonx-1.0.1-linux-amd64.zip"},{"id":41,"name":"dragonx-1.0.1-win64.zip","size":62449808,"download_count":17,"created_at":"2026-03-12T01:27:05-05:00","uuid":"6ec4d23d-1704-4b67-94d0-48d984c1635a","browser_download_url":"https://git.dragonx.is/DragonX/dragonx/releases/download/v1.0.1/dragonx-1.0.1-win64.zip"}]}] diff --git a/tests/fixtures/xmrig/releases_list.json b/tests/fixtures/xmrig/releases_list.json new file mode 100644 index 0000000..95f6d10 --- /dev/null +++ b/tests/fixtures/xmrig/releases_list.json @@ -0,0 +1 @@ +[{"id":28,"tag_name":"v6.25.3","target_commitish":"master","name":"DRG-XMRig v6.25.3","body":"# DRG-XMRig 6.25.3 — Release Notes\r\n\r\n**Release date:** 2026-06-07\r\n**Base:** XMRig 6.25.1 (drg fork — unified pow-hash share model, DragonX/`rx/dragonx` primary algo)\r\n\r\n## About this build\r\n\r\nDRG-XMRig is a fork of xmrig-hac with a unified pow-hash share model.\r\n`rx/dragonx` is the primary algorithm name (with `rx/hush` retained as an alias).\r\n\r\n### Changes in 6.25.3\r\n\r\n- Added a macOS release build target: `./build.sh --macos-release` produces a\r\n portable host-arch binary (Intel `x86_64` or Apple Silicon `arm64`) with\r\n libuv/hwloc/OpenSSL linked statically from Homebrew, so it runs on stock\r\n macOS without Homebrew at runtime.\r\n\r\n(6.25.2 carried the pool-config fix: `url` includes the stratum port\r\n`pool.dragonx.is:3433`, `algo` defaults to `rx/dragonx`, and the worker name\r\nmust be a shielded `zs-address`.)\r\n\r\n## Artifacts\r\n\r\n| Platform | File | Size |\r\n|----------|------|------|\r\n| Linux x86_64 | `drg-xmrig-6.25.3-linux-x64.zip` | 3.8 MiB |\r\n| Windows x86_64 | `drg-xmrig-6.25.3-win-x64.zip` | 2.7 MiB |\r\n| macOS x86_64 | `drg-xmrig-6.25.3-macos-x86_64.zip` | 3.2 MiB |\r\n\r\nEach archive contains the miner binary, a starter `config.json`, and `README.md`.\r\nThe Windows archive additionally includes `WinRing0x64.sys` (MSR driver required\r\nfor MSR mods and large-page tweaks).\r\n\r\n## Build configuration\r\n\r\nAll binaries are **statically linked** release builds (`CMAKE_BUILD_TYPE=Release`)\r\nwith GPU backends disabled (CPU-only): `WITH_OPENCL=OFF`, `WITH_CUDA=OFF`.\r\n\r\n- Linux: built with GCC 11.4.0; `-DBUILD_STATIC=ON`; `WITH_HWLOC=ON`\r\n- Windows: cross-compiled with MinGW-w64; `-DBUILD_STATIC=ON`; `WITH_HWLOC=OFF`\r\n- macOS: built for the host arch (`x86_64`); `WITH_HWLOC=ON`; libuv/hwloc/OpenSSL\r\n statically linked from Homebrew (`openssl@3`), so no Homebrew is needed at runtime\r\n\r\nBundled dependencies:\r\n\r\n| Dependency | Linux | Windows | macOS |\r\n|------------|-------|---------|-------|\r\n| libuv | 1.51.0 | 1.51.0 | Homebrew |\r\n| OpenSSL | 3.0.16 | 1.1.1w | 3.x (Homebrew `openssl@3`) |\r\n| hwloc | 2.12.1 | — (disabled) | Homebrew |\r\n\r\n## Checksums (SHA-256)\r\n\r\n### Release archives\r\n\r\n```\r\n7dfcb2ab27fe6836995d52522e666c35d7d5797aa5aa977342dadc855b3dfb0c drg-xmrig-6.25.3-linux-x64.zip\r\n00ae3c1652eafb10c2c0210e4aa33f924360bcc4b953eb5a37b5c6ba32c943ef drg-xmrig-6.25.3-win-x64.zip\r\naf6bf9f2a97a651f0ce8a88a8e63c9a0084f5017f2167ccd15b43cc58b2d481d drg-xmrig-6.25.3-macos-x86_64.zip\r\n```\r\n\r\n### Binaries (inside the archives)\r\n\r\n```\r\nf93c199b41064973556388fed1188f42e4ce2b749c68ae396a95040a2540b88c xmrig (linux-x64)\r\n33d336b1a91898eae0cf70813964a6d3cc80d6e729b4e02fc297b4eedd667d1d xmrig.exe (win-x64)\r\nd57be5328c2ff98d0078e8675d99ff551a8631a8f7b220717a634a3a856c884e xmrig (macos-x86_64)\r\n```\r\n\r\n### Verify\r\n\r\n```bash\r\n# Linux / macOS\r\nsha256sum -c \u003c\u003c'EOF'\r\n7dfcb2ab27fe6836995d52522e666c35d7d5797aa5aa977342dadc855b3dfb0c drg-xmrig-6.25.3-linux-x64.zip\r\n00ae3c1652eafb10c2c0210e4aa33f924360bcc4b953eb5a37b5c6ba32c943ef drg-xmrig-6.25.3-win-x64.zip\r\naf6bf9f2a97a651f0ce8a88a8e63c9a0084f5017f2167ccd15b43cc58b2d481d drg-xmrig-6.25.3-macos-x86_64.zip\r\nEOF\r\n```\r\n\r\n```powershell\r\n# Windows (PowerShell)\r\nGet-FileHash .\\drg-xmrig-6.25.3-win-x64.zip -Algorithm SHA256\r\n```\r\n","url":"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/28","html_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/tag/v6.25.3","tarball_url":"https://git.dragonx.is/DragonX/drg-xmrig/archive/v6.25.3.tar.gz","zipball_url":"https://git.dragonx.is/DragonX/drg-xmrig/archive/v6.25.3.zip","upload_url":"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/28/assets","draft":false,"prerelease":false,"created_at":"2026-06-07T09:22:27-05:00","published_at":"2026-06-07T09:22:27-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":106,"name":"drg-xmrig-6.25.3-linux-x64.zip","size":4017018,"download_count":95,"created_at":"2026-06-07T09:20:13-05:00","uuid":"1b6725ee-b046-44ba-b48c-9b3cffb717c5","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-linux-x64.zip"},{"id":107,"name":"drg-xmrig-6.25.3-linux-x64.zip.sig","size":89,"download_count":8,"created_at":"2026-06-07T09:22:07-05:00","uuid":"32c3db67-035f-4383-b397-86ede29910f6","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-linux-x64.zip.sig"},{"id":105,"name":"drg-xmrig-6.25.3-macos-x86_64.zip","size":3342081,"download_count":3,"created_at":"2026-06-07T09:20:11-05:00","uuid":"9534cd18-08f9-4c88-a7c1-675d2c7b41c0","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-macos-x86_64.zip"},{"id":108,"name":"drg-xmrig-6.25.3-macos-x86_64.zip.sig","size":89,"download_count":2,"created_at":"2026-06-07T09:22:07-05:00","uuid":"74dee887-c632-4f54-97c2-34214537388c","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-macos-x86_64.zip.sig"},{"id":104,"name":"drg-xmrig-6.25.3-win-x64.zip","size":2843459,"download_count":41,"created_at":"2026-06-07T09:20:11-05:00","uuid":"649f9233-9f89-49d7-8721-2bf9e65062f0","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-win-x64.zip"},{"id":109,"name":"drg-xmrig-6.25.3-win-x64.zip.sig","size":89,"download_count":6,"created_at":"2026-06-07T09:22:38-05:00","uuid":"84fd3d84-2ccc-4674-b6fd-affbe526fb94","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.3/drg-xmrig-6.25.3-win-x64.zip.sig"}]},{"id":27,"tag_name":"v6.25.2","target_commitish":"master","name":"DRG-XMRig v6.25.2","body":"# DRG-XMRig 6.25.2 — Release Notes\r\n\r\n**Release date:** 2026-06-06\r\n**Base:** XMRig 6.25.1 (drg fork — unified pow-hash share model, DragonX/`rx/dragonx` primary algo)\r\n\r\n## About this build\r\n\r\nDRG-XMRig is a fork of xmrig-hac with a unified pow-hash share model.\r\n`rx/dragonx` is the primary algorithm name (with `rx/hush` retained as an alias).\r\n\r\n### Changes in 6.25.2\r\n\r\n- Fixed default pool config: `url` now includes the stratum port\r\n `pool.dragonx.is:3433` (was portless, falling back to the firewalled default\r\n 3333, which caused repeating `connect error: \"operation canceled\"`), and\r\n `algo` defaults to `rx/dragonx`.\r\n- Clarified that the pool requires a shielded `zs-address` as the worker name\r\n (config/example placeholders updated to `YOUR_DRGX_ZS_ADDRESS`).\r\n- `build_windows.sh`: fetch OpenSSL 1.1.1w from the GitHub release mirror\r\n (the old openssl.org URL is gone).\r\n\r\n## Artifacts\r\n\r\n| Platform | File | Size |\r\n|----------|------|------|\r\n| Linux x86_64 | `drg-xmrig-6.25.2-linux-x64.zip` | 3.9 MiB |\r\n| Windows x86_64 | `drg-xmrig-6.25.2-win-x64.zip` | 2.7 MiB |\r\n\r\nEach archive contains the miner binary, a starter `config.json`, and `README.md`.\r\nThe Windows archive additionally includes `WinRing0x64.sys` (MSR driver required\r\nfor MSR mods and large-page tweaks).\r\n\r\n## Build configuration\r\n\r\nBoth binaries are **statically linked** release builds (`-DBUILD_STATIC=ON`,\r\n`CMAKE_BUILD_TYPE=Release`) with GPU backends disabled (CPU-only):\r\n\r\n- `WITH_OPENCL=OFF`, `WITH_CUDA=OFF`\r\n- Linux: built with GCC 11.4.0; `WITH_HWLOC=ON`\r\n- Windows: cross-compiled with MinGW-w64; `WITH_HWLOC=OFF`\r\n\r\nBundled dependencies:\r\n\r\n| Dependency | Linux | Windows |\r\n|------------|-------|---------|\r\n| libuv | 1.51.0 | 1.51.0 |\r\n| OpenSSL | 3.0.16 | 1.1.1w |\r\n| hwloc | 2.12.1 | — (disabled) |\r\n\r\n## Checksums (SHA-256)\r\n\r\n### Release archives\r\n\r\n```\r\nc19da36ac761e472b66892422a914570d47c8db89f66b6a57bceb61609c5dc32 drg-xmrig-6.25.2-linux-x64.zip\r\n6f293f2146a719a79ca577d76e582f45032fc4703c6245e46b57ca7a5c49f6a0 drg-xmrig-6.25.2-win-x64.zip\r\n```\r\n\r\n### Binaries (inside the archives)\r\n\r\n```\r\nd8f089eef9b832ca23091935e3856132148dcbab3e764aaff0aa5d546ab7d985 xmrig (linux-x64)\r\n7df8ca5aebda5e5c4cb7279156cae44b397b91cc9b95880756de0a26865f6b93 xmrig.exe (win-x64)\r\n```\r\n\r\n### Verify\r\n\r\n```bash\r\n# Linux\r\nsha256sum -c \u003c\u003c'EOF'\r\nc19da36ac761e472b66892422a914570d47c8db89f66b6a57bceb61609c5dc32 drg-xmrig-6.25.2-linux-x64.zip\r\n6f293f2146a719a79ca577d76e582f45032fc4703c6245e46b57ca7a5c49f6a0 drg-xmrig-6.25.2-win-x64.zip\r\nEOF\r\n```\r\n\r\n```powershell\r\n# Windows (PowerShell)\r\nGet-FileHash .\\drg-xmrig-6.25.2-win-x64.zip -Algorithm SHA256\r\n```\r\n","url":"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/27","html_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/tag/v6.25.2","tarball_url":"https://git.dragonx.is/DragonX/drg-xmrig/archive/v6.25.2.tar.gz","zipball_url":"https://git.dragonx.is/DragonX/drg-xmrig/archive/v6.25.2.zip","upload_url":"https://git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/27/assets","draft":false,"prerelease":false,"created_at":"2026-06-06T23:24:49-05:00","published_at":"2026-06-06T23:24:49-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":101,"name":"drg-xmrig-6.25.2-linux-x64.zip","size":4016870,"download_count":79,"created_at":"2026-06-06T23:23:12-05:00","uuid":"9ef93d40-3c8c-47f9-add2-61485e334118","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.2/drg-xmrig-6.25.2-linux-x64.zip"},{"id":102,"name":"drg-xmrig-6.25.2-linux-x64.zip.sig","size":89,"download_count":1,"created_at":"2026-06-07T08:11:48-05:00","uuid":"f3c37645-fc32-4811-9f3a-b5989866aa5b","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.2/drg-xmrig-6.25.2-linux-x64.zip.sig"},{"id":100,"name":"drg-xmrig-6.25.2-win-x64.zip","size":2843310,"download_count":4,"created_at":"2026-06-06T23:23:12-05:00","uuid":"1bbec583-5498-4874-808b-893723db6213","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.2/drg-xmrig-6.25.2-win-x64.zip"},{"id":103,"name":"drg-xmrig-6.25.2-win-x64.zip.sig","size":89,"download_count":4,"created_at":"2026-06-07T08:11:48-05:00","uuid":"513de5f8-094b-4d9e-ac9c-d626f0af013b","browser_download_url":"https://git.dragonx.is/DragonX/drg-xmrig/releases/download/v6.25.2/drg-xmrig-6.25.2-win-x64.zip.sig"}]}] diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f065c16..2bb4afd 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(f)), std::istreambuf_iterator()); +} + +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(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(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 ( "| .zip | `` |" ) 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(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-" 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(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(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();