Compare commits
156 Commits
2c909d35ea
...
lite-v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
| cc2d383166 | |||
| f30fdc5ed1 | |||
| 290d3c31a2 | |||
| 27fb83ba04 | |||
| 0e78fe02e4 | |||
| 7914e9e9bb | |||
| 06d34dff5e | |||
| c7e48c16f8 | |||
| 4ee0f524d4 | |||
| 43c7be55c6 | |||
| d9fa00bb38 | |||
| 0a042df8e0 | |||
| a60a2f8e39 | |||
| d27f387d6d | |||
| ed675f90d8 | |||
| 4492aa3425 | |||
| 56d93b6128 | |||
| 398fb274fa | |||
| 0343d48c13 | |||
| a3892c0fd3 | |||
| f7df315695 | |||
| aec996a9ce | |||
| 90e02b1ddd | |||
| ef8ceeaf9a | |||
| ba1d760bb3 | |||
| 0942691eb3 | |||
| a2f84be2d4 | |||
| 7e8b99a82b | |||
| 29274c2f48 | |||
| 870793433b | |||
| 08cfeb0e08 | |||
| 5daf2d83b6 | |||
| 6d26ccd0ed | |||
| a7514becbc | |||
| 558cfcbe56 | |||
| d0bd55b9c1 | |||
| b37d3d97b6 | |||
| 8778398d31 | |||
| d5c30237d2 | |||
| a598217975 | |||
| 9205addf55 | |||
| ce8c7696d4 | |||
| 99d1e73676 | |||
| 755cf22ad0 | |||
| e24ca015d1 | |||
| 0de44569b7 | |||
| 06afbee4f8 | |||
| 6ee81a5abe | |||
| ea26c0cbbb | |||
| 13b225d8f5 | |||
| 08aed34bbe | |||
| 5edbe8a276 | |||
| 001e85ac1a | |||
| 393f3d147e | |||
| ac49f44f84 | |||
| 3216debc7d | |||
| 8ffcd9cc8c | |||
| bc183257b7 | |||
| b2e037bb67 | |||
| 975650f11b | |||
| 384d64ea5d | |||
| 3b7423f3a1 | |||
| d136916e80 | |||
| f88304fed2 | |||
| 5296dd7ae5 | |||
| a1d3964e34 | |||
| 5b6ba5094b | |||
| c3e81a5fa6 | |||
| d603a54618 | |||
| c61c211dfe | |||
| 16244d84a0 | |||
| 32be868dbc | |||
| 8bb3198562 | |||
| 4b3f0fa92b | |||
| c7c3440a7b | |||
| e779ded2e8 | |||
| 940dd21464 | |||
| 207f9074db | |||
| 05b00b158b | |||
| f9ddab059e | |||
| de1ae736de | |||
| 03c1b63e03 | |||
| 8c12b27c0a | |||
| c7d163f44a | |||
| 7e4822c021 | |||
| f9b622cb25 | |||
| 9204fa148a | |||
| da0e9f5915 | |||
| d188a08db7 | |||
| ff5f5ddf23 | |||
| 56f9802fb9 | |||
| efb271cb9a | |||
| eb69e491b9 | |||
| 2675b8ab93 | |||
| b3444e0a89 | |||
| 45b652f514 | |||
| c252e60f7f | |||
| fffee9f0b5 | |||
| 00ffc959e5 | |||
| b561406c23 | |||
| 5a0743a17b | |||
| a7b0770ad0 | |||
| 7d8323a622 | |||
| 320944fd18 | |||
| 6d5e0ac614 | |||
| 02554d523d | |||
| 21da9e75fc | |||
| c53b7f771e | |||
| bcee4bfe72 | |||
| e1870c3b23 | |||
| 7cf0bb8bc7 | |||
| 541a9e1fd5 | |||
| 07b8e0b2cb | |||
| de48414c65 | |||
| f0c681b0b5 | |||
| fdf2502ca8 | |||
| e393b0d847 | |||
| 3b041f20c1 | |||
| 77a0ad64b0 | |||
| d0ca2fdf52 | |||
| 9c07b6d33d | |||
| fedfe3d60c | |||
| 95ff9ce9ae | |||
| 203967411a | |||
| 3d0305a9ca | |||
| e7f38c2a45 | |||
| 3a2be661ea | |||
| 17525003c5 | |||
| 267d839d5b | |||
| 987d9b93c7 | |||
| 4c43f2e082 | |||
| 57fa6470b7 | |||
| c68889d276 | |||
| b0a4333cdf | |||
| 4471f54842 | |||
| e08121af2b | |||
| 31ebfc782e | |||
| f3776bcbe5 | |||
| 38f2aa2f8f | |||
| 40f8425d94 | |||
| 2157a30192 | |||
| bef3c0c47d | |||
| 5c570613c8 | |||
| 567732206e | |||
| 0dcc09fc5f | |||
| 82d3178119 | |||
| 7d47c6e14e | |||
| 973bc0d338 | |||
| e4c9b98ca2 | |||
| 1c38f68781 | |||
| 818c29fca7 | |||
| 0a43742897 | |||
| defe14d913 | |||
| 8b55a90102 | |||
| 0fd3d214a4 | |||
| 996e10ed02 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
|
||||
!prebuilt-binaries/dragonxd-win/.gitkeep
|
||||
prebuilt-binaries/dragonxd-mac/*
|
||||
!prebuilt-binaries/dragonxd-mac/.gitkeep
|
||||
prebuilt-binaries/xmrig-hac/*
|
||||
!prebuilt-binaries/xmrig-hac/.gitkeep
|
||||
prebuilt-binaries/drg-xmrig/*
|
||||
!prebuilt-binaries/drg-xmrig/.gitkeep
|
||||
|
||||
|
||||
# External sources / toolchains (created by scripts/setup.sh)
|
||||
@@ -33,7 +33,11 @@ imgui.ini
|
||||
*.bak*
|
||||
*.params
|
||||
asmap.dat
|
||||
/external/xmrig-hac
|
||||
# Wallet files hold PRIVATE KEYS — never commit them
|
||||
wallet.dat
|
||||
wallet-*.dat
|
||||
wallet.dat.*
|
||||
/external/drg-xmrig
|
||||
/memory
|
||||
/todo.md
|
||||
/.github/
|
||||
|
||||
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ⚠️ Breaking changes
|
||||
|
||||
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
|
||||
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
|
||||
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
|
||||
network path) after only a dismissible warning. To reconnect, either:
|
||||
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
|
||||
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
|
||||
|
||||
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
|
||||
|
||||
### Security
|
||||
|
||||
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
|
||||
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
|
||||
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
|
||||
plaintext-RPC protection.
|
||||
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
|
||||
before use, instead of only checking that the files exist. A truncated or corrupt parameter
|
||||
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
|
||||
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
|
||||
process monitor could consume the daemon's exit status, hiding a crash and defeating the
|
||||
automatic-restart cap. The monitor is now the sole reaper.
|
||||
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
|
||||
binary) now reports a precise error immediately instead of briefly showing "running" and
|
||||
then a generic "exited unexpectedly (exit code 127)".
|
||||
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
|
||||
previous daemon to release the data-directory lock and shows a clear, non-crash message
|
||||
instead of exhausting the crash-restart budget.
|
||||
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
|
||||
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
|
||||
can't start.
|
||||
- Directory-creation failures on startup (read-only home, permission denied) now produce a
|
||||
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
|
||||
"binary not found" error (or, in one path, an uncaught exception).
|
||||
|
||||
### Added
|
||||
|
||||
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
|
||||
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
|
||||
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
|
||||
automatically once the daemon connects.
|
||||
|
||||
---
|
||||
|
||||
Engineering detail and the finding-by-finding rationale for this batch live in
|
||||
`docs/daemon-startup-hardening.md`.
|
||||
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.0
|
||||
VERSION 2.0.1
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||
set(DRAGONX_LITE_VERSION "1.0.0")
|
||||
set(DRAGONX_LITE_VERSION "1.1.0")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
@@ -53,7 +53,6 @@ set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
|
||||
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
|
||||
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
|
||||
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
|
||||
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
|
||||
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
|
||||
litelib_wallet_exists
|
||||
litelib_initialize_new
|
||||
@@ -126,36 +125,24 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
||||
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
endif()
|
||||
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
|
||||
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
|
||||
endif()
|
||||
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
|
||||
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
|
||||
endif()
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
|
||||
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
|
||||
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
|
||||
endif()
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
|
||||
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
|
||||
endif()
|
||||
endif()
|
||||
# Note (F15-1): the former signature-metadata gate was removed. It trusted a
|
||||
# "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
|
||||
# self-attested with no cryptographic check (the "verified" SHA was just the artifact's
|
||||
# own SHA). The trust root is now build-from-source: that script builds the backend from
|
||||
# the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
|
||||
# is the one built from reviewed source. The required-symbol inventory check above stays.
|
||||
|
||||
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
|
||||
set_target_properties(dragonx_lite_backend PROPERTIES
|
||||
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
||||
)
|
||||
if(APPLE)
|
||||
# The Rust backend's TLS stack (security-framework / core-foundation crates)
|
||||
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
|
||||
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
|
||||
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
|
||||
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
|
||||
endif()
|
||||
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
||||
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||
@@ -226,7 +213,7 @@ include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
json
|
||||
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||
GIT_TAG v3.11.3
|
||||
GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(json)
|
||||
@@ -235,7 +222,7 @@ FetchContent_MakeAvailable(json)
|
||||
FetchContent_Declare(
|
||||
tomlplusplus
|
||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||
GIT_TAG v3.4.0
|
||||
GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(tomlplusplus)
|
||||
@@ -302,8 +289,17 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
|
||||
FetchContent_Declare(
|
||||
libwebp
|
||||
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
||||
GIT_TAG v1.4.0
|
||||
GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
|
||||
# files when it can't probe SSE support. Under a macOS universal build
|
||||
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
|
||||
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
|
||||
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
|
||||
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
|
||||
PATCH_COMMAND ${CMAKE_COMMAND}
|
||||
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
|
||||
)
|
||||
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
|
||||
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
|
||||
@@ -525,6 +521,8 @@ set(APP_SOURCES
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.cpp
|
||||
src/ui/windows/about_dialog.cpp
|
||||
src/ui/windows/faq_dialog.cpp
|
||||
src/ui/windows/faq_content.cpp
|
||||
src/ui/windows/key_export_dialog.cpp
|
||||
src/ui/windows/transaction_details_dialog.cpp
|
||||
src/ui/windows/qr_popup_dialog.cpp
|
||||
@@ -548,6 +546,7 @@ set(APP_SOURCES
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/base64.cpp
|
||||
src/util/single_instance.cpp
|
||||
src/util/i18n.cpp
|
||||
@@ -555,6 +554,7 @@ set(APP_SOURCES
|
||||
src/util/platform.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/texture_loader.cpp
|
||||
src/util/svg_texture.cpp
|
||||
src/util/noise_texture.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/daemon/seed_wallet_creator.cpp
|
||||
@@ -659,6 +659,8 @@ set(APP_HEADERS
|
||||
src/ui/windows/console_tab_helpers.h
|
||||
src/ui/windows/settings_window.h
|
||||
src/ui/windows/about_dialog.h
|
||||
src/ui/windows/faq_dialog.h
|
||||
src/ui/windows/faq_content.h
|
||||
src/ui/windows/key_export_dialog.h
|
||||
src/ui/windows/transaction_details_dialog.h
|
||||
src/ui/windows/qr_popup_dialog.h
|
||||
@@ -1077,6 +1079,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
|
||||
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
|
||||
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
|
||||
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
|
||||
# -----------------------------------------------------------------------------
|
||||
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
|
||||
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
|
||||
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
|
||||
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
|
||||
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
|
||||
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
|
||||
if(WIN32)
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
|
||||
else()
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
|
||||
endif()
|
||||
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
|
||||
else()
|
||||
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1125,6 +1153,7 @@ if(BUILD_TESTING)
|
||||
src/util/payment_uri.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
@@ -1132,6 +1161,7 @@ if(BUILD_TESTING)
|
||||
src/data/address_book.cpp
|
||||
src/data/wallet_index.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/config/settings.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
@@ -1203,5 +1233,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
|
||||
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
|
||||
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
|
||||
message(STATUS " Lite trust: built-from-source (vendored third_party/silentdragonxlite)")
|
||||
message(STATUS "")
|
||||
|
||||
@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
|
||||
- prebuilt-binaries/dragonxd-win/
|
||||
- prebuilt-binaries/dragonxd-mac/
|
||||
|
||||
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
|
||||
- prebuilt-binaries/xmrig-hac/
|
||||
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
|
||||
- prebuilt-binaries/drg-xmrig/
|
||||
|
||||
|
||||
## Build Steps
|
||||
|
||||
217
build.sh
217
build.sh
@@ -131,7 +131,7 @@ fi
|
||||
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
||||
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
||||
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
||||
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
||||
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
||||
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
@@ -195,6 +195,39 @@ should_bundle_full_node_assets() {
|
||||
! $DO_LITE
|
||||
}
|
||||
|
||||
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
|
||||
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
|
||||
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
|
||||
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
|
||||
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
|
||||
require_wallet_rebuild_helper() {
|
||||
local helper="$1" depends="$2"
|
||||
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
|
||||
if [[ ! -f "$helper" ]]; then
|
||||
err "wallet-rebuild helper was NOT built: $helper"
|
||||
err " → the recovery 'Repair automatically' option would be MISSING from this release."
|
||||
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
|
||||
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
|
||||
exit 1
|
||||
fi
|
||||
info " wallet-rebuild helper present: $helper"
|
||||
}
|
||||
|
||||
# Vendored Berkeley DB depends directory for the macOS wallet-rebuild helper, by TARGET arch. The daemon
|
||||
# links a static libdb-6.2; the helper must match the build arch, so derive the triple from it rather than
|
||||
# hardcoding one. Single-arch: x86_64 -> x86_64-apple-darwin, arm64 -> aarch64-apple-darwin. "universal"
|
||||
# (or anything unexpected) falls back to the host arch — a universal .app needs a universal libdb, so if
|
||||
# only a single-arch libdb is vendored, build single-arch via DRAGONX_MAC_ARCHS.
|
||||
mac_bdb_dir() {
|
||||
local arch="$1" triple
|
||||
case "$arch" in
|
||||
x86_64) triple="x86_64-apple-darwin" ;;
|
||||
arm64) triple="aarch64-apple-darwin" ;;
|
||||
*) [[ "$(uname -m)" == "arm64" ]] && triple="aarch64-apple-darwin" || triple="x86_64-apple-darwin" ;;
|
||||
esac
|
||||
printf '%s/external/dragonx/depends/%s\n' "$SCRIPT_DIR" "$triple"
|
||||
}
|
||||
|
||||
# ── Helper: find resource files ──────────────────────────────────────────────
|
||||
find_sapling_params() {
|
||||
local dirs=(
|
||||
@@ -286,6 +319,9 @@ bundle_linux_daemon() {
|
||||
# asmap.dat
|
||||
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
||||
|
||||
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
|
||||
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
|
||||
|
||||
return $found
|
||||
}
|
||||
|
||||
@@ -336,11 +372,24 @@ build_release_linux() {
|
||||
mkdir -p "$bd" && cd "$bd"
|
||||
|
||||
# ── Compile ──────────────────────────────────────────────────────────────
|
||||
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
|
||||
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
|
||||
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
|
||||
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
|
||||
local BDB_ARGS=()
|
||||
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
|
||||
elif should_bundle_full_node_assets; then
|
||||
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring ..."
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
@@ -348,8 +397,12 @@ build_release_linux() {
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||
|
||||
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
|
||||
|
||||
info "Stripping ..."
|
||||
strip "bin/${APP_BASENAME}"
|
||||
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||
|
||||
if should_bundle_full_node_assets; then
|
||||
@@ -384,9 +437,12 @@ build_release_linux() {
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
|
||||
info " Bundled dragonx-wallet-rebuild"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
|
||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||
|
||||
@@ -417,9 +473,11 @@ build_release_linux() {
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
||||
|
||||
# Desktop entry
|
||||
@@ -478,18 +536,28 @@ APPRUN
|
||||
done
|
||||
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
|
||||
|
||||
# appimagetool
|
||||
# appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
|
||||
# The old "continuous" tag is a MOVING build fetched over the network and run on the release
|
||||
# builder; a compromised/MITM'd artifact would execute here. Verify, or refuse to package.
|
||||
local APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
|
||||
local APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
|
||||
local APPIMAGETOOL=""
|
||||
if command -v appimagetool &>/dev/null; then
|
||||
APPIMAGETOOL="appimagetool"
|
||||
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
|
||||
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
|
||||
else
|
||||
info "Downloading appimagetool ..."
|
||||
wget -q -O "$bd/appimagetool-x86_64.AppImage" \
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
chmod +x "$bd/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
|
||||
local at="$bd/appimagetool-x86_64.AppImage"
|
||||
# Re-verify any cached copy too; a stale unverified download must not be trusted.
|
||||
if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
|
||||
info "Downloading appimagetool 1.9.0 (pinned) ..."
|
||||
wget -q -O "$at" "$APPIMAGETOOL_URL"
|
||||
if ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
|
||||
err "appimagetool SHA-256 verification failed — refusing to use it"
|
||||
rm -f "$at"
|
||||
return 1
|
||||
fi
|
||||
chmod +x "$at"
|
||||
fi
|
||||
APPIMAGETOOL="$at"
|
||||
fi
|
||||
|
||||
local ARCH
|
||||
@@ -628,8 +696,32 @@ HDR
|
||||
info "Lite mode: skipping embedded daemon binaries"
|
||||
fi
|
||||
|
||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
||||
# ── Wallet-rebuild recovery helper ───────────────────────────────
|
||||
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
|
||||
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
|
||||
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
|
||||
if should_bundle_full_node_assets; then
|
||||
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
|
||||
info "Compiling + embedding wallet-rebuild helper ..."
|
||||
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
|
||||
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
|
||||
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
|
||||
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
|
||||
-o "$RES/dragonx-wallet-rebuild.exe" \
|
||||
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
|
||||
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
|
||||
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
|
||||
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
|
||||
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
|
||||
else
|
||||
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
||||
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
||||
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
||||
@@ -740,11 +832,24 @@ HDR
|
||||
fi
|
||||
|
||||
# ── CMake + build ────────────────────────────────────────────────────────
|
||||
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
|
||||
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
|
||||
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
|
||||
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||
local BDB_ARGS=()
|
||||
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
|
||||
elif should_bundle_full_node_assets; then
|
||||
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring (cross-compile) ..."
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${FT_CMAKE_ARG[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
@@ -752,8 +857,16 @@ HDR
|
||||
cmake --build . -j "$JOBS"
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
# Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
|
||||
# helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped
|
||||
# (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort.
|
||||
x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \
|
||||
|| warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe"
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
||||
|
||||
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
|
||||
|
||||
# ── Package: release/windows/ ────────────────────────────────────────────
|
||||
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
||||
mkdir -p "$out"
|
||||
@@ -769,6 +882,9 @@ HDR
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
|
||||
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
|
||||
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
|
||||
|
||||
# Bundle Sapling params + asmap for the zip distribution
|
||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||
@@ -781,7 +897,7 @@ HDR
|
||||
fi
|
||||
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
|
||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
|
||||
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
||||
|
||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||
@@ -891,8 +1007,26 @@ build_release_mac() {
|
||||
fi
|
||||
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
|
||||
else
|
||||
# Native macOS: build universal binary (arm64 + x86_64)
|
||||
MAC_ARCH="universal"
|
||||
# Native macOS: build universal (arm64 + x86_64) by default. Override with
|
||||
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
|
||||
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
|
||||
# When linking the real lite backend, the app can only include architectures
|
||||
# the backend static library actually provides. Its pinned ring 0.16.11 has no
|
||||
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
|
||||
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
|
||||
# otherwise the arm64 slice fails to link.
|
||||
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
|
||||
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
|
||||
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
|
||||
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
|
||||
MAC_ARCHS="$_backend_archs"
|
||||
fi
|
||||
fi
|
||||
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
|
||||
MAC_ARCH="universal"
|
||||
else
|
||||
MAC_ARCH="$MAC_ARCHS"
|
||||
fi
|
||||
export MACOSX_DEPLOYMENT_TARGET="11.0"
|
||||
fi
|
||||
|
||||
@@ -964,6 +1098,12 @@ TOOLCHAIN
|
||||
fi
|
||||
|
||||
info "Configuring (cross-compile via osxcross) ..."
|
||||
# Vendored static libdb for the recovery helper (full-node only), by target arch; see mac_bdb_dir.
|
||||
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
|
||||
local BDB_ARGS=()
|
||||
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
|
||||
fi
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/osxcross-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
@@ -971,6 +1111,7 @@ TOOLCHAIN
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
else
|
||||
# Build libsodium as universal if needed
|
||||
@@ -980,7 +1121,7 @@ TOOLCHAIN
|
||||
need_sodium=true
|
||||
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
|
||||
# Rebuild if existing lib is not universal (single-arch won't link)
|
||||
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
|
||||
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
|
||||
info "Existing libsodium is not universal — rebuilding ..."
|
||||
rm -rf "$SCRIPT_DIR/libs/libsodium"
|
||||
need_sodium=true
|
||||
@@ -991,13 +1132,20 @@ TOOLCHAIN
|
||||
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
|
||||
fi
|
||||
|
||||
info "Configuring (native universal arm64+x86_64) ..."
|
||||
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
|
||||
# Point CMake at the vendored static libdb for the recovery helper (full-node only); see mac_bdb_dir.
|
||||
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
|
||||
local BDB_ARGS=()
|
||||
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
|
||||
fi
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
|
||||
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
fi
|
||||
|
||||
@@ -1006,6 +1154,11 @@ TOOLCHAIN
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
|
||||
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
|
||||
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$(mac_bdb_dir "$MAC_ARCH")"
|
||||
|
||||
# Strip — use osxcross strip for cross-builds
|
||||
if $IS_CROSS; then
|
||||
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
||||
@@ -1027,8 +1180,12 @@ TOOLCHAIN
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||
|
||||
# ── Create .app bundle ───────────────────────────────────────────────────
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
# Clean only THIS variant's prior artifacts so full-node and lite releases can
|
||||
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
|
||||
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
|
||||
# and the ".app" names are exact.
|
||||
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
|
||||
|
||||
local APP="$out/${APP_BASENAME}.app"
|
||||
local CONTENTS="$APP/Contents"
|
||||
@@ -1074,12 +1231,14 @@ TOOLCHAIN
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||
fi
|
||||
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
|
||||
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
|
||||
else
|
||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||
fi
|
||||
|
||||
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
|
||||
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
||||
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
|
||||
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||
if [[ -f "$XMRIG_MAC" ]]; then
|
||||
cp "$XMRIG_MAC" "$MACOS/xmrig"
|
||||
chmod +x "$MACOS/xmrig"
|
||||
@@ -1228,8 +1387,10 @@ PLIST
|
||||
fi
|
||||
|
||||
# ── Create DMG ───────────────────────────────────────────────────────────
|
||||
local DMG_BASENAME="DragonX_Wallet"
|
||||
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
|
||||
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
|
||||
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
|
||||
# (APP_DISPLAY_NAME above).
|
||||
local DMG_BASENAME="${APP_BASENAME}"
|
||||
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
||||
|
||||
if command -v create-dmg &>/dev/null; then
|
||||
@@ -1311,3 +1472,9 @@ if $DO_LINUX || $DO_WIN || $DO_MAC; then
|
||||
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
|
||||
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
|
||||
fi
|
||||
|
||||
# Reaching here means the build completed (real failures exit 1 at their point of failure).
|
||||
# Exit 0 explicitly: the final `[[ -d release/mac ]] && echo` above returns non-zero on a
|
||||
# non-mac build — and since set -e exempts the left side of an &&, that status would otherwise
|
||||
# become the script's exit code and make a successful build report failure (e.g. to CI).
|
||||
exit 0
|
||||
|
||||
31
cmake/patch-libwebp-simd.cmake
Normal file
31
cmake/patch-libwebp-simd.cmake
Normal file
@@ -0,0 +1,31 @@
|
||||
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
|
||||
#
|
||||
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
|
||||
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
|
||||
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
|
||||
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
|
||||
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
|
||||
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
|
||||
# functions unconditionally — so any TU including <math.h> fails to compile with
|
||||
# "_Float16 is not supported on this target".
|
||||
#
|
||||
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
|
||||
# is unnecessary on every platform we target. Blanking the SSE entries (indices
|
||||
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
|
||||
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
|
||||
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
|
||||
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
|
||||
endif()
|
||||
|
||||
file(READ "${CPU_CMAKE}" _contents)
|
||||
string(REPLACE
|
||||
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
|
||||
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
|
||||
_patched "${_contents}")
|
||||
|
||||
if(_patched STREQUAL _contents)
|
||||
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
|
||||
else()
|
||||
file(WRITE "${CPU_CMAKE}" "${_patched}")
|
||||
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
|
||||
endif()
|
||||
549
docs/daemon-startup-hardening.md
Normal file
549
docs/daemon-startup-hardening.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# Daemon Startup Hardening — Implementation Plan
|
||||
|
||||
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
|
||||
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
|
||||
line references), the chosen approach, the call sites, a representative change, and how
|
||||
to verify it.
|
||||
|
||||
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
|
||||
- **Source:** line references are exact against branch `dev` @ `45b652f`.
|
||||
- **Provenance:** findings verified by direct source read; each fix designed by an
|
||||
independent agent grounded in the cited files, with a sequencing pass for ordering,
|
||||
shared helpers, and merge conflicts.
|
||||
|
||||
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.**
|
||||
|
||||
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||
|
||||
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
|
||||
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
|
||||
pure-helper unit tests added.
|
||||
|
||||
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
|
||||
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
|
||||
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
|
||||
rather than render as tofu).
|
||||
|
||||
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
|
||||
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
|
||||
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
|
||||
|
||||
---
|
||||
|
||||
## Recommended rollout sequence
|
||||
|
||||
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
|
||||
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
|
||||
connect-stall panel and the lock gate both build on. The filesystem cluster lands
|
||||
around a single shared helper. The connectivity-breaking security flip lands last.
|
||||
|
||||
| Step | Finding(s) | Site | Why here | Status |
|
||||
|------|-----------|------|----------|--------|
|
||||
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
|
||||
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
|
||||
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
|
||||
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
|
||||
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
|
||||
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
|
||||
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
|
||||
|
||||
---
|
||||
|
||||
## F1 — Double-`waitpid` race can swallow a daemon crash
|
||||
|
||||
**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
|
||||
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
|
||||
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
|
||||
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
|
||||
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
|
||||
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
|
||||
with an atomic read.
|
||||
|
||||
### The fix
|
||||
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
|
||||
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
|
||||
sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because
|
||||
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136)
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
bool EmbeddedDaemon::isRunning() const // POSIX branch
|
||||
{
|
||||
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
|
||||
// sole reaper. Previously both threads reaped; if the UI thread won, the
|
||||
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
|
||||
if (process_pid_ <= 0) return false;
|
||||
|
||||
State s = state_.load(std::memory_order_relaxed);
|
||||
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
|
||||
// state_ == Stopping, before the process has actually terminated.
|
||||
return (s == State::Running || s == State::Stopping);
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
|
||||
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
|
||||
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
|
||||
|
||||
### Dependencies
|
||||
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
|
||||
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
|
||||
iteration — file as its own ticket.
|
||||
|
||||
---
|
||||
|
||||
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
|
||||
|
||||
**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs
|
||||
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
|
||||
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
|
||||
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
|
||||
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
|
||||
as "exited unexpectedly (exit code 127)".
|
||||
|
||||
### The fix
|
||||
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
|
||||
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
|
||||
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
|
||||
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
|
||||
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
|
||||
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path
|
||||
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
|
||||
- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053)
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
|
||||
int execpipe[2]; pipe(execpipe);
|
||||
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) { // child
|
||||
close(execpipe[0]);
|
||||
/* setpgid / chdir / dup2 / argv … */
|
||||
execv(binary_path.c_str(), argv.data());
|
||||
int e = errno; // execv failed
|
||||
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
close(execpipe[1]); // parent: must close or read() never EOFs
|
||||
int child_errno = 0, total = 0;
|
||||
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
|
||||
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
|
||||
if (n == 0) break;
|
||||
if (n < 0) { if (errno == EINTR) continue; break; }
|
||||
if ((total += n) >= (int)sizeof(int)) break;
|
||||
}
|
||||
close(execpipe[0]);
|
||||
if (total >= (int)sizeof(int)) { // exec never happened
|
||||
waitpid(pid, nullptr, 0); // reap the zombie
|
||||
last_error_ = "dragonxd could not be executed: " +
|
||||
std::string(strerror(child_errno)) +
|
||||
" — not executable or wrong architecture";
|
||||
return false; // start() no longer reports Running
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
|
||||
- Success path: real binary still starts with no perceptible added latency.
|
||||
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
|
||||
|
||||
### Dependencies
|
||||
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
|
||||
`FD_CLOEXEC` makes every successful start hang the parent read forever.
|
||||
|
||||
---
|
||||
|
||||
## F4 — Stale datadir-lock start → restart storm that wedges the UI
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
|
||||
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
|
||||
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
|
||||
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
|
||||
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
|
||||
before the lock actually clears.
|
||||
|
||||
### The fix
|
||||
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
|
||||
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
|
||||
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
|
||||
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
|
||||
`crash_count_`. The connect loop's own retry then absorbs the transient.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
|
||||
- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()`
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
static StartLockGateDecision evaluateDatadirLockGate(
|
||||
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
|
||||
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
|
||||
if (!stillRunningAfterWait) return {true, ""};
|
||||
return {false, "A previous dragonxd is still shutting down and holding the "
|
||||
"data directory lock. Retrying shortly…"};
|
||||
}
|
||||
|
||||
// start() — after the isPortInUse() bail, before setState(Starting):
|
||||
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
|
||||
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
|
||||
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
|
||||
stillLocked = true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||
}
|
||||
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
|
||||
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
|
||||
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
|
||||
- Migrate-to-seed second daemon still starts (isolated exemption).
|
||||
|
||||
### Dependencies
|
||||
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
|
||||
"exited unexpectedly"). Same TU, different function.
|
||||
|
||||
---
|
||||
|
||||
## F5 — Extraction / copy write-failures never surfaced up front
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
|
||||
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
|
||||
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
|
||||
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
|
||||
spawn and fails opaquely. The innermost write already returns `false`
|
||||
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
|
||||
|
||||
### The fix
|
||||
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
|
||||
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
|
||||
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
|
||||
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
|
||||
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
|
||||
without re-touching this control flow.
|
||||
|
||||
### Files touched
|
||||
- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152)
|
||||
- `src/app.cpp` — second copy-fallback loop (~4210–4242)
|
||||
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// stop discarding the extraction result (~4152)
|
||||
if (!resources::extractEmbeddedResources()) {
|
||||
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
|
||||
return false; // abort before spawning
|
||||
}
|
||||
|
||||
// second copy-fallback loop — was dropping ec entirely (~4236)
|
||||
bool copyFailed = false;
|
||||
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
|
||||
fs::path dst = fs::path(daemon_dir) / name;
|
||||
if (fs::exists(dst)) continue; // already present — skip
|
||||
for (const auto& dir : searchDirs) {
|
||||
fs::path src = fs::path(dir) / name;
|
||||
if (!fs::exists(src)) continue; // absent source is OK, not a failure
|
||||
fs::copy_file(src, dst, ec);
|
||||
if (ec) { copyFailed = true; ec.clear(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (copyFailed) {
|
||||
char buf[512];
|
||||
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||
daemon_status_ = buf;
|
||||
return false; // don't fall through to spawn
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
|
||||
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
|
||||
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
|
||||
|
||||
### Dependencies
|
||||
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
|
||||
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
|
||||
|
||||
---
|
||||
|
||||
## F6 — Sapling params validated by existence/size only, never hashed
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
|
||||
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
|
||||
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
|
||||
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
|
||||
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
|
||||
> per the cross-cutting note. Non-English locales fall back to English until then.
|
||||
|
||||
### The defect
|
||||
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
|
||||
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
|
||||
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
|
||||
which then fails to build shielded proofs mid-operation — far from the real cause.
|
||||
|
||||
### The fix
|
||||
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
|
||||
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
|
||||
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
|
||||
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
|
||||
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
|
||||
|
||||
### Files touched
|
||||
- `src/rpc/connection.h` — `verifySaplingParams` decl
|
||||
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// connection.cpp — pinned known-good digests
|
||||
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
|
||||
constexpr SaplingParamDigest kSaplingParamDigests[] = {
|
||||
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
|
||||
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
|
||||
};
|
||||
|
||||
bool Connection::verifySaplingParams() {
|
||||
// existence check (unchanged) …
|
||||
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
|
||||
if (readMarkerMatches(marker, statLines)) return true;
|
||||
for (auto& d : kSaplingParamDigests)
|
||||
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
|
||||
writeMarker(marker, statLines);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
|
||||
|
||||
### Dependencies
|
||||
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
|
||||
Third caller of the existing `util::sha256Hex`.
|
||||
|
||||
---
|
||||
|
||||
## F7 — Directory-create errors universally ignored on the daemon-env path
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
|
||||
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
|
||||
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
|
||||
|
||||
### The defect
|
||||
Five startup directory-create sites either drop the `error_code` or use the throwing
|
||||
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
|
||||
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
|
||||
home or permission-denied yields a confusing "conf missing" / "binary not found"
|
||||
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
|
||||
|
||||
### The fix
|
||||
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
|
||||
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
|
||||
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
|
||||
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
|
||||
**structural owner** of the fs-error idiom that F5 and F6 reuse.
|
||||
|
||||
### Files touched
|
||||
- `src/util/platform.h` / `.cpp` — `ensureDirectory()`
|
||||
- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig`
|
||||
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
|
||||
- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory`
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// util/platform.cpp — one shared, non-throwing helper
|
||||
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(dir, ec)) return true;
|
||||
ec.clear();
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (ec) {
|
||||
if (outError)
|
||||
*outError = "Cannot create " + dir + ": " + ec.message() +
|
||||
". Check permissions / free space.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
|
||||
// and its 4 callers bail on it.
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
|
||||
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
|
||||
|
||||
### Dependencies
|
||||
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
|
||||
extension (coordinated with F8). Land before F5/F6/F8.
|
||||
|
||||
---
|
||||
|
||||
## F8 — Plaintext-remote RPC credential transmission is warn-only
|
||||
|
||||
**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
|
||||
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
|
||||
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
|
||||
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
|
||||
> `::1`) are unaffected. Call this out prominently in the release notes.
|
||||
>
|
||||
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
|
||||
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
|
||||
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
|
||||
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
|
||||
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
|
||||
> status/notification tells the user exactly what to add. The toggle can be added later
|
||||
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
|
||||
|
||||
### The defect
|
||||
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
|
||||
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
|
||||
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
|
||||
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
|
||||
suppressing even the warning.
|
||||
|
||||
### The fix
|
||||
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
|
||||
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
|
||||
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
|
||||
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
|
||||
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
|
||||
users hit a hard stop until they opt in — **ship with prominent release notes.**
|
||||
|
||||
### Files touched
|
||||
- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
|
||||
- `src/config/settings.h` / `.cpp` — persisted opt-in
|
||||
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
|
||||
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
|
||||
- `src/ui/pages/settings_page.cpp` — toggle UI
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// Tightened loopback test — "127.evil.com" is NOT local
|
||||
bool Connection::isLocalHost(const std::string& host) {
|
||||
std::string h = stripBrackets(lowercase(host));
|
||||
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
|
||||
}
|
||||
|
||||
// Refuse-by-default with an explicit, persisted opt-in
|
||||
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
|
||||
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|
||||
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
|
||||
if (plaintextRemote && !plaintextAllowed) {
|
||||
connection_status_ = TR("sb_plaintext_remote_blocked");
|
||||
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
|
||||
return; // no creds sent
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
|
||||
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
|
||||
|
||||
### Dependencies
|
||||
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
|
||||
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
|
||||
|
||||
---
|
||||
|
||||
## Shared helpers & coordination points
|
||||
|
||||
| Helper | Purpose | Used by |
|
||||
|--------|---------|---------|
|
||||
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
|
||||
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
|
||||
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
|
||||
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
|
||||
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
|
||||
|
||||
## F3 — Unbounded connect spinner (deferred to step 6)
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
|
||||
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
|
||||
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
|
||||
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
|
||||
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
|
||||
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
|
||||
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
|
||||
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
|
||||
|
||||
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
|
||||
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
|
||||
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
|
||||
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
|
||||
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
|
||||
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
|
||||
firing — recovery clears the panel automatically. Guarded off while the daemon is in
|
||||
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
|
||||
design record; see the shared-helper table above.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting notes
|
||||
|
||||
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
|
||||
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
|
||||
keep "monitorProcess is the sole reaper" coherent.
|
||||
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
|
||||
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
|
||||
Sequence F7→F6→F8.
|
||||
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
|
||||
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
|
||||
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
|
||||
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
|
||||
`scripts/add_missing_translations.py` **once at the very end**
|
||||
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
|
||||
`res/lang/*.json`.
|
||||
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
|
||||
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
|
||||
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
|
||||
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
|
||||
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
|
||||
mid-sleep monitor iteration — file it as its own ticket.
|
||||
|
||||
---
|
||||
|
||||
## Progress log
|
||||
|
||||
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
|
||||
|
||||
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
|
||||
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
|
||||
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
|
||||
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
|
||||
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
|
||||
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
|
||||
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
|
||||
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.
|
||||
61
docs/release-notes/lite-v1.0.0.md
Normal file
61
docs/release-notes/lite-v1.0.0.md
Normal file
@@ -0,0 +1,61 @@
|
||||
**DragonX (DRGX) Lite Desktop Wallet — v1.0.0**
|
||||
|
||||
ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow.
|
||||
|
||||
This is the **first release** of the Lite variant.
|
||||
|
||||
---
|
||||
|
||||
## Why Lite?
|
||||
|
||||
- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB.
|
||||
- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner.
|
||||
- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM.
|
||||
- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation.
|
||||
- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs.
|
||||
- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings.
|
||||
- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked.
|
||||
- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end.
|
||||
- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server.
|
||||
- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output.
|
||||
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64.
|
||||
- QR code generation for receiving addresses
|
||||
- Extensive theme and appearance options
|
||||
|
||||
---
|
||||
|
||||
## How it differs from the full-node wallet
|
||||
|
||||
- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state.
|
||||
- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` |
|
||||
| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` |
|
||||
| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` |
|
||||
| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` |
|
||||
| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` |
|
||||
| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` |
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||
- **Windows:** x86-64, Windows 10 or later
|
||||
- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later
|
||||
- Network access to a DragonX lite-wallet server.
|
||||
|
||||
## License
|
||||
|
||||
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||
86
docs/release-notes/v2.0.0.md
Normal file
86
docs/release-notes/v2.0.0.md
Normal file
@@ -0,0 +1,86 @@
|
||||
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0**
|
||||
|
||||
This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS.
|
||||
|
||||
---
|
||||
|
||||
## What's New in v2.0.0
|
||||
|
||||
v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul.
|
||||
|
||||
### New Features
|
||||
|
||||
- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite.
|
||||
- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.)
|
||||
- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build.
|
||||
- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest.
|
||||
- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar.
|
||||
- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes.
|
||||
- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height.
|
||||
- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately.
|
||||
- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling.
|
||||
|
||||
### Security & Integrity
|
||||
|
||||
- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid.
|
||||
- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**.
|
||||
- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded.
|
||||
- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction.
|
||||
- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive.
|
||||
- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed.
|
||||
- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab.
|
||||
- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown).
|
||||
- **Address list** — modernized with drag-to-transfer, labels, and view-only handling.
|
||||
- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected.
|
||||
- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required.
|
||||
- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced).
|
||||
- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite.
|
||||
- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet.
|
||||
- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking.
|
||||
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon.
|
||||
- Shielded (z-address) and transparent (t-address) send/receive
|
||||
- QR code generation for receiving addresses
|
||||
- Transaction history with memo support, date/amount sorting, and pending-send tracking
|
||||
- Blockchain explorer with live block/transaction search
|
||||
- Blockchain rescan and Sapling witness-rebuild with status-bar progress
|
||||
- Repair Wallet and daemon-binary management in Settings
|
||||
- Built-in console
|
||||
- Extensive theme and appearance options
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` |
|
||||
| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` |
|
||||
| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` |
|
||||
| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` |
|
||||
| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` |
|
||||
| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` |
|
||||
|
||||
## System Requirements
|
||||
|
||||
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||
- **Windows:** x86-64, Windows 10 or later
|
||||
- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later
|
||||
|
||||
## License
|
||||
|
||||
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||
167
docs/wallet-hardening.md
Normal file
167
docs/wallet-hardening.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Wallet Loading & Management — Hardening Plan
|
||||
|
||||
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
|
||||
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
|
||||
|
||||
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
|
||||
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
|
||||
(W1-5), 1 raised (W5-3 Low→Med).
|
||||
- **Severity:** 8 High · 12 Medium · 13 Low.
|
||||
|
||||
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (ordered by risk; shared fixes grouped)
|
||||
|
||||
| Phase | Findings | Theme | Status |
|
||||
|-------|----------|-------|--------|
|
||||
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
|
||||
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
|
||||
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
|
||||
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
|
||||
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
|
||||
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
|
||||
|
||||
---
|
||||
|
||||
## P0-A — Secret hardening
|
||||
|
||||
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
|
||||
key/passphrase paths, plus console redaction and deleting the plaintext export.
|
||||
|
||||
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
|
||||
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
|
||||
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
|
||||
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
|
||||
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
|
||||
unit testing. **← implementing first (self-contained + testable).**
|
||||
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
|
||||
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
|
||||
- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in
|
||||
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
|
||||
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
|
||||
(local + worker-lambda copies).
|
||||
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
|
||||
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
|
||||
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
|
||||
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
|
||||
lite create/open/restore requests (unused; a secret copied for nothing).
|
||||
|
||||
## P0-B — Encryption integrity
|
||||
|
||||
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
|
||||
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
|
||||
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
|
||||
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
|
||||
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
|
||||
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
|
||||
complete it.
|
||||
- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the
|
||||
failure and notify (currently a silent no-op that can leave the wallet unlocked).
|
||||
|
||||
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
|
||||
|
||||
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
|
||||
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
|
||||
- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(<config>/seed-migrate)` unconditionally
|
||||
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
|
||||
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
|
||||
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
|
||||
(`getSeedMigrationPending()`), not only while the dialog is open.
|
||||
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
|
||||
resume/re-poll it instead of silently dropping the txid.
|
||||
|
||||
## P1-B — Missing/wrong wallet-file safety
|
||||
|
||||
- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in
|
||||
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
|
||||
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
|
||||
daemon fabricate an empty wallet.
|
||||
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
|
||||
address/balance readback (idHash non-empty), not bare `onConnected()`.
|
||||
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
|
||||
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
|
||||
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
|
||||
the out-of-datadir path).
|
||||
|
||||
## P2 — State & lite persistence
|
||||
|
||||
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
|
||||
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
|
||||
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a
|
||||
one-shot UI warning (both call sites currently discard the bool).
|
||||
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
|
||||
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
|
||||
of discarding the whole list.
|
||||
|
||||
## F — Diagnostics foundation + QoL
|
||||
|
||||
Land W7-2 first — it unblocks the rest.
|
||||
|
||||
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
|
||||
`main()` on all platforms; add an "Open log folder" action.
|
||||
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
|
||||
POSIX (mirror the Windows SEH path).
|
||||
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
|
||||
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
|
||||
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
|
||||
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
|
||||
|
||||
---
|
||||
|
||||
## Progress log
|
||||
|
||||
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
|
||||
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
|
||||
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
|
||||
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
|
||||
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
|
||||
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
|
||||
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
|
||||
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
|
||||
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
|
||||
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
|
||||
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
|
||||
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
|
||||
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
|
||||
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
|
||||
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
|
||||
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
|
||||
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
|
||||
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
|
||||
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
|
||||
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
|
||||
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
|
||||
Build-clean; `ctest` 1/1.
|
||||
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
|
||||
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
|
||||
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
|
||||
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
|
||||
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
|
||||
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
|
||||
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
|
||||
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
|
||||
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
|
||||
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
|
||||
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
|
||||
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
|
||||
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
|
||||
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
|
||||
|
||||
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
|
||||
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
|
||||
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
|
||||
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
|
||||
|
||||
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
|
||||
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
|
||||
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
|
||||
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
|
||||
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
|
||||
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
|
||||
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
|
||||
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
|
||||
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)
|
||||
3278
libs/nanosvg/nanosvg.h
Normal file
3278
libs/nanosvg/nanosvg.h
Normal file
File diff suppressed because it is too large
Load Diff
1472
libs/nanosvg/nanosvgrast.h
Normal file
1472
libs/nanosvg/nanosvgrast.h
Normal file
File diff suppressed because it is too large
Load Diff
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
Binary file not shown.
BIN
res/img/backgrounds/texture/jade_bg.png
Normal file
BIN
res/img/backgrounds/texture/jade_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
23
res/img/logos/logo_dragonx.svg
Normal file
23
res/img/logos/logo_dragonx.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #d82652;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
|
||||
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
|
||||
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
686
res/lang/de.json
686
res/lang/de.json
File diff suppressed because it is too large
Load Diff
686
res/lang/es.json
686
res/lang/es.json
File diff suppressed because it is too large
Load Diff
686
res/lang/fr.json
686
res/lang/fr.json
File diff suppressed because it is too large
Load Diff
682
res/lang/ja.json
682
res/lang/ja.json
File diff suppressed because it is too large
Load Diff
686
res/lang/ko.json
686
res/lang/ko.json
File diff suppressed because it is too large
Load Diff
686
res/lang/pt.json
686
res/lang/pt.json
File diff suppressed because it is too large
Load Diff
686
res/lang/ru.json
686
res/lang/ru.json
File diff suppressed because it is too large
Load Diff
682
res/lang/zh.json
682
res/lang/zh.json
File diff suppressed because it is too large
Load Diff
190
res/themes/jade.toml
Normal file
190
res/themes/jade.toml
Normal file
@@ -0,0 +1,190 @@
|
||||
[theme]
|
||||
name = "Jade"
|
||||
author = "The Hush Developers"
|
||||
dark = true
|
||||
elevation = { --elevation-0 = "#071210", --elevation-1 = "#0C1A16", --elevation-2 = "#16261F", --elevation-3 = "#1D3128", --elevation-4 = "#243B30" }
|
||||
images = { background_image = "backgrounds/texture/jade_bg.png", logo = "logos/logo_ObsidianDragon_dark.png" }
|
||||
|
||||
[theme.palette]
|
||||
--primary = "#2FA07A"
|
||||
--primary-variant = "#1E7357"
|
||||
--primary-light = "#7FD1B5"
|
||||
--secondary = "#C9A24E"
|
||||
--secondary-variant = "#A8842F"
|
||||
--secondary-light = "#E0C583"
|
||||
--background = "#071210"
|
||||
--surface = "#0C1A16"
|
||||
--surface-variant = "#16261F"
|
||||
--on-primary = "#FFFFFF"
|
||||
--on-secondary = "#000000"
|
||||
--on-background = "#DCEDE4"
|
||||
--on-surface = "#DCEDE4"
|
||||
--on-surface-medium = "rgba(220,237,228,0.85)"
|
||||
--on-surface-disabled = "rgba(220,237,228,0.58)"
|
||||
--error = "#CF6679"
|
||||
--on-error = "#000000"
|
||||
--success = "#81C784"
|
||||
--on-success = "#000000"
|
||||
--warning = "#FFB74D"
|
||||
--on-warning = "#000000"
|
||||
--divider = "rgba(130,205,170,0.14)"
|
||||
--outline = "rgba(130,205,170,0.16)"
|
||||
--scrim = "rgba(0,0,0,0.6)"
|
||||
--surface-hover = "rgba(130,205,170,0.07)"
|
||||
--surface-alt = "rgba(130,205,170,0.05)"
|
||||
--surface-active = "rgba(130,205,170,0.10)"
|
||||
--glass-button = "rgba(130,205,170,0.06)"
|
||||
--glass-button-hover = "rgba(130,205,170,0.12)"
|
||||
--card-border = "rgba(130,205,170,0.26)"
|
||||
--text-shadow = "rgba(0,0,0,0.50)"
|
||||
--input-overlay-text = "rgba(220,237,228,0.30)"
|
||||
--slider-text = "rgba(220,237,228,0.85)"
|
||||
--thumb-fill = "rgba(130,205,170,0.15)"
|
||||
--thumb-border = "rgba(130,205,170,0.50)"
|
||||
--disabled-label = "rgba(130,205,170,0.18)"
|
||||
--chart-grid = "rgba(130,205,170,0.05)"
|
||||
--chart-crosshair = "rgba(130,205,170,0.15)"
|
||||
--chart-hover-ring = "rgba(130,205,170,0.30)"
|
||||
--tooltip-bg = "rgba(9,20,16,0.92)"
|
||||
--tooltip-border = "rgba(130,205,170,0.12)"
|
||||
--glass-fill = "rgba(130,205,170,0.08)"
|
||||
--glass-border = "rgba(47,160,122,0.30)"
|
||||
--glass-noise-tint = "rgba(130,205,170,0.03)"
|
||||
--tactile-top = "rgba(130,205,170,0.06)"
|
||||
--tactile-bottom = "rgba(130,205,170,0.0)"
|
||||
--hover-overlay = "rgba(130,205,170,0.05)"
|
||||
--active-overlay = "rgba(130,205,170,0.10)"
|
||||
--rim-light = "rgba(130,205,170,0.14)"
|
||||
--status-divider = "rgba(130,205,170,0.08)"
|
||||
--sidebar-hover = "rgba(130,205,170,0.10)"
|
||||
--sidebar-icon = "rgba(130,205,170,0.42)"
|
||||
--sidebar-badge = "rgba(220,237,228,1.0)"
|
||||
--sidebar-divider = "rgba(130,205,170,0.06)"
|
||||
--chart-line = "rgba(130,205,170,0.10)"
|
||||
--window-control = "rgba(220,237,228,0.78)"
|
||||
--window-control-hover = "rgba(130,205,170,0.12)"
|
||||
--window-close-hover = "rgba(232,17,35,0.78)"
|
||||
--spinner-track = "rgba(130,205,170,0.10)"
|
||||
--spinner-active = "rgba(79,184,154,0.85)"
|
||||
--shutdown-panel-bg = "rgba(7,18,14,0.90)"
|
||||
--shutdown-panel-border = "rgba(130,205,170,0.07)"
|
||||
--ram-bar-app = "#2FA07A"
|
||||
--ram-bar-system = "rgba(255,255,255,0.18)"
|
||||
--accent-total = "#7FD1B5"
|
||||
--accent-shielded = "#4FB89A"
|
||||
--accent-transparent = "#C9A24E"
|
||||
--accent-action = "#2FA07A"
|
||||
--accent-market = "#4FB89A"
|
||||
--accent-portfolio = "#7FD1B5"
|
||||
--toast-info-accent = "#2FA07A"
|
||||
--toast-info-text = "#7FD1B5"
|
||||
--toast-success-accent = "rgba(50,180,80,1.0)"
|
||||
--toast-success-text = "rgba(180,255,180,1.0)"
|
||||
--toast-warning-accent = "rgba(204,166,50,1.0)"
|
||||
--toast-warning-text = "rgba(255,230,130,1.0)"
|
||||
--toast-error-accent = "rgba(204,64,64,1.0)"
|
||||
--toast-error-text = "rgba(255,153,153,1.0)"
|
||||
--snackbar-bg = "rgba(24,40,34,0.95)"
|
||||
--snackbar-text = "rgba(220,237,228,0.87)"
|
||||
--snackbar-action = "rgba(79,184,154,1.0)"
|
||||
--snackbar-action-hover = "rgba(127,209,181,1.0)"
|
||||
--switch-track-off = "rgba(130,205,170,0.12)"
|
||||
--switch-track-on = "rgba(47,160,122,0.50)"
|
||||
--switch-thumb-off = "#A0C0B4"
|
||||
--switch-thumb-on = "#DCEDE4"
|
||||
--control-shadow = "rgba(0,0,0,0.24)"
|
||||
--checkbox-check = "#000000"
|
||||
--app-bar-shadow = "rgba(0,0,0,0.25)"
|
||||
|
||||
[backdrop]
|
||||
base-color-top = "rgba(14,32,26,210)"
|
||||
base-color-bottom = "rgba(6,18,14,210)"
|
||||
texture-tint-alpha = 120
|
||||
gradient-top-r = 10
|
||||
gradient-top-g = 30
|
||||
gradient-top-b = 22
|
||||
gradient-top-a = 90
|
||||
gradient-bottom-r = 5
|
||||
gradient-bottom-g = 16
|
||||
gradient-bottom-b = 12
|
||||
gradient-bottom-a = 70
|
||||
background-alpha = 0.42
|
||||
surface-alpha = 0.56
|
||||
frame-alpha = 0.78
|
||||
surface-inline-alpha = 0.58
|
||||
background-inline-alpha = 0.40
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme Visual Effects — Jade (veins of gold shifting through the stone)
|
||||
# Jade's signature is a slow jade→gold color-shifting border on every glass
|
||||
# panel + the active nav button — a vein of gold surfacing through nephrite.
|
||||
# It's drawn via AddRect so it hugs the real rounded corners (no polygonal
|
||||
# edge-trace). Sparse jade motes drift up the viewport. No other theme turns
|
||||
# gradient-border-panels on, so the panel-wide vein is Jade's own —
|
||||
# deliberately NOT Obsidian's specular glare.
|
||||
# ---------------------------------------------------------------------------
|
||||
[effects]
|
||||
hue-cycle-enabled = { size = 0.0 }
|
||||
rainbow-border-enabled = { size = 0.0 }
|
||||
|
||||
# No shimmer sweep — replaced by specular glare
|
||||
shimmer-enabled = { size = 0.0 }
|
||||
|
||||
positional-hue-enabled = { size = 0.0 }
|
||||
|
||||
glow-pulse-enabled = { size = 0.0 }
|
||||
|
||||
# Edge-trace OFF — its hand-walked perimeter chamfers rounded corners.
|
||||
# Jade's vein is the gradient-border below (corner-clean via AddRect).
|
||||
edge-trace-enabled = { size = 0.0 }
|
||||
edge-trace-speed = { size = 0.16 }
|
||||
edge-trace-length = { size = 0.34 }
|
||||
edge-trace-thickness = { size = 1.6 }
|
||||
edge-trace-alpha = { size = 0.55 }
|
||||
edge-trace-color = { color = "#C9A24E" }
|
||||
|
||||
# Specular glare OFF — that's Obsidian's signature; Jade shouldn't echo it.
|
||||
specular-glare-enabled = { size = 0.0 }
|
||||
specular-glare-speed = { size = 0.018 }
|
||||
specular-glare-intensity = { size = 0.008 }
|
||||
specular-glare-radius = { size = 0.65 }
|
||||
specular-glare-count = { size = 1.0 }
|
||||
specular-glare-color = { color = "rgba(150,220,180,1.0)" }
|
||||
|
||||
# HERO — vein of gold: a slow jade→gold color-shifting border on the active
|
||||
# nav button AND (via gradient-border-panels) every glass panel. Drawn with
|
||||
# AddRect so it follows the rounded corners exactly. Panels drift at a softer
|
||||
# alpha and position-phased offset, so a screenful reads like veins at
|
||||
# different depths rather than one synchronized pulse.
|
||||
gradient-border-enabled = { size = 1.0 }
|
||||
gradient-border-panels = { size = 1.0 }
|
||||
gradient-border-speed = { size = 0.10 }
|
||||
gradient-border-thickness = { size = 1.5 }
|
||||
gradient-border-alpha = { size = 0.55 }
|
||||
gradient-border-color-a = { color = "#7FD1B5" }
|
||||
gradient-border-color-b = { color = "#C9A24E" }
|
||||
|
||||
# Ambient jade motes — sparse, slow, cool green particles drifting up the
|
||||
# viewport (recolored ember-rise; a different mood from dragonx's fire embers).
|
||||
ember-rise-enabled = { size = 1.0 }
|
||||
ember-rise-count = { size = 5.0 }
|
||||
ember-rise-speed = { size = 0.18 }
|
||||
ember-rise-particle-size = { size = 1.4 }
|
||||
ember-rise-alpha = { size = 0.26 }
|
||||
ember-rise-color = { color = "#7FD1B5" }
|
||||
|
||||
# Shader-like viewport overlay — deep green stone atmosphere
|
||||
viewport-wash-enabled = { size = 1.0 }
|
||||
viewport-wash-alpha = { size = 0.05 }
|
||||
viewport-wash-tl = { color = "#12402E" }
|
||||
viewport-wash-tr = { color = "#0E3828" }
|
||||
viewport-wash-bl = { color = "#16442E" }
|
||||
viewport-wash-br = { color = "#1A4A34" }
|
||||
viewport-wash-rotate = { size = 0.015 }
|
||||
viewport-wash-pulse = { size = 0.0 }
|
||||
viewport-wash-pulse-depth = { size = 0.0 }
|
||||
|
||||
viewport-vignette-enabled = { size = 1.0 }
|
||||
viewport-vignette-color = { color = "#04140D" }
|
||||
viewport-vignette-radius = { size = 0.22 }
|
||||
viewport-vignette-alpha = { size = 0.15 }
|
||||
@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
|
||||
status-pill-y-offset = { size = 1 }
|
||||
confirmed-threshold = { size = 10 }
|
||||
|
||||
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
|
||||
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
|
||||
[banners.node-status]
|
||||
min-height = { size = 26.0 }
|
||||
height = { size = 30.0 }
|
||||
|
||||
[tabs.transactions]
|
||||
search-max-width = 300.0
|
||||
search-width-ratio = 0.3
|
||||
@@ -959,6 +965,11 @@ edition-label = { position = 120 }
|
||||
link-button = { width = 100, font = "button-sm" }
|
||||
close-button = { width = 120, font = "button", align = "center" }
|
||||
|
||||
[dialogs.faq]
|
||||
width = 860.0
|
||||
height = 660.0
|
||||
window = { width = 860, height = 660 }
|
||||
|
||||
[dialogs.settings]
|
||||
width = 600.0
|
||||
height = 550.0
|
||||
@@ -1503,6 +1514,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
|
||||
progress-width = { size = 260.0 }
|
||||
backdrop-alpha = { opacity = 0.80 }
|
||||
vertical-gap = { size = 8.0 }
|
||||
stall-timeout-sec = { size = 45.0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First-Run Wizard Screens
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
|
||||
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
|
||||
# present (Homebrew), and fail with a clear message otherwise.
|
||||
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
|
||||
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
|
||||
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
|
||||
done
|
||||
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
|
||||
echo " On macOS: brew install bash" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -67,25 +79,9 @@ Options:
|
||||
--backend-dir PATH SilentDragonXLite/lib source directory.
|
||||
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
|
||||
--out-dir PATH Output directory for copied artifact and metadata.
|
||||
--artifact PATH Inventory an existing artifact instead of building.
|
||||
--no-build Do not run cargo; requires --artifact.
|
||||
--reproducible Add deterministic Rust path remaps for clean builds.
|
||||
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
|
||||
--builder NAME Redacted builder/provenance label. Default: local.
|
||||
--signature-required Fail if verified signature metadata is not supplied.
|
||||
--signature-file PATH Existing sidecar signature file to record.
|
||||
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
|
||||
--signature-verification-tool T Verification tool and version used by the release builder.
|
||||
--signature-verification-command C
|
||||
Verification command already run by the release builder.
|
||||
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
|
||||
--signature-certificate-identity ID
|
||||
Reviewed certificate identity, when applicable.
|
||||
--signature-certificate-issuer I
|
||||
Reviewed certificate issuer, when applicable.
|
||||
--signature-transparency-log-url URL
|
||||
Transparency log entry, when applicable.
|
||||
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
|
||||
-j, --jobs N Cargo parallel jobs.
|
||||
--cargo-arg ARG Extra argument forwarded to cargo build.
|
||||
-h, --help Show this help.
|
||||
@@ -95,9 +91,13 @@ Outputs:
|
||||
<out>/<platform>/lite-backend-symbols.txt
|
||||
<out>/<platform>/lite-backend-artifact-manifest.json
|
||||
|
||||
The script captures symbols, checksums, and optional read-only signature
|
||||
verification metadata only. It does not load the library, resolve function
|
||||
pointers, call SDXL, sign, upload, or publish artifacts.
|
||||
The lite backend is always built from the vendored in-tree source
|
||||
(third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
|
||||
and self-attested signature metadata are NOT accepted (F15-1) — the previous
|
||||
scheme only recorded an unverified "verified" claim. The script captures the
|
||||
freshly-built artifact's symbols and checksum, and records build provenance.
|
||||
It does not load the library, resolve function pointers, call SDXL, sign,
|
||||
upload, or publish artifacts.
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -166,15 +166,8 @@ while [[ $# -gt 0 ]]; do
|
||||
OUT_DIR="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--artifact)
|
||||
[[ $# -ge 2 ]] || die "--artifact requires a value"
|
||||
ARTIFACT_PATH="$(absolute_path "$2")"
|
||||
BUILD_ARTIFACT=false
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
BUILD_ARTIFACT=false
|
||||
shift
|
||||
--artifact|--no-build)
|
||||
die "$1 was removed (F15-1): the lite backend must be built from the vendored in-tree source (third_party/silentdragonxlite); prebuilt artifacts are no longer accepted."
|
||||
;;
|
||||
--reproducible)
|
||||
REPRODUCIBLE=true
|
||||
@@ -191,54 +184,11 @@ while [[ $# -gt 0 ]]; do
|
||||
BUILDER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-required)
|
||||
SIGNATURE_REQUIRED=true
|
||||
shift
|
||||
;;
|
||||
--signature-file|--signature-path)
|
||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
||||
SIGNATURE_FILE="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--signature-format)
|
||||
[[ $# -ge 2 ]] || die "--signature-format requires a value"
|
||||
SIGNATURE_FORMAT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verification-tool|--signature-tool)
|
||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
||||
SIGNATURE_VERIFICATION_TOOL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verification-command)
|
||||
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
|
||||
SIGNATURE_VERIFICATION_COMMAND="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-key-fingerprint)
|
||||
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
|
||||
SIGNATURE_KEY_FINGERPRINT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-certificate-identity)
|
||||
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
|
||||
SIGNATURE_CERTIFICATE_IDENTITY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-certificate-issuer)
|
||||
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
|
||||
SIGNATURE_CERTIFICATE_ISSUER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-transparency-log-url)
|
||||
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
|
||||
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verified-sha256)
|
||||
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
|
||||
SIGNATURE_VERIFIED_SHA256="$2"
|
||||
shift 2
|
||||
--signature-required|--signature-file|--signature-path|--signature-format|\
|
||||
--signature-verification-tool|--signature-tool|--signature-verification-command|\
|
||||
--signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
|
||||
--signature-transparency-log-url|--signature-verified-sha256)
|
||||
die "signature-attestation flags were removed (F15-1): they recorded a self-attested \"verified\" claim without running any cryptographic verifier. The lite backend is built from the vendored in-tree source, which is the trust root."
|
||||
;;
|
||||
-j|--jobs)
|
||||
[[ $# -ge 2 ]] || die "--jobs requires a value"
|
||||
@@ -374,6 +324,9 @@ prepare_backend_source() {
|
||||
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
||||
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
||||
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
||||
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
|
||||
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
|
||||
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
|
||||
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
||||
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
||||
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
||||
@@ -766,6 +719,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
|
||||
printf ' },\n'
|
||||
printf ' "provenance": {\n'
|
||||
printf ' "owner_ready": true,\n'
|
||||
printf ' "built_from_source": true,\n'
|
||||
printf ' "metadata_provided": true,\n'
|
||||
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
|
||||
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'
|
||||
|
||||
@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for appimagetool
|
||||
# Check for appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
|
||||
# The old "continuous" tag is a moving, unverified network download that runs on the release
|
||||
# builder; verify it or refuse to package.
|
||||
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
|
||||
APPIMAGETOOL=""
|
||||
if command -v appimagetool &> /dev/null; then
|
||||
APPIMAGETOOL="appimagetool"
|
||||
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
|
||||
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
|
||||
else
|
||||
print_status "Downloading appimagetool..."
|
||||
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \
|
||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
||||
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
||||
AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
||||
if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
|
||||
print_status "Downloading appimagetool 1.9.0 (pinned)..."
|
||||
wget -q -O "$AT" "$APPIMAGETOOL_URL"
|
||||
if ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
|
||||
print_error "appimagetool SHA-256 verification failed — refusing to use it"
|
||||
rm -f "$AT"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$AT"
|
||||
fi
|
||||
APPIMAGETOOL="$AT"
|
||||
fi
|
||||
|
||||
print_status "Creating AppDir structure..."
|
||||
|
||||
@@ -256,8 +256,8 @@ HEADER_START
|
||||
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
|
||||
fi
|
||||
|
||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
||||
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
||||
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
|
||||
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
|
||||
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
|
||||
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
|
||||
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
|
||||
#
|
||||
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
|
||||
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
|
||||
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
|
||||
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
|
||||
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
|
||||
# The wallet verifies a detached ed25519 signature over the 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 "<archive>.sig" is
|
||||
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
|
||||
# checksum table in the release body, so `release` prints that table for you to paste in.
|
||||
#
|
||||
# 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).
|
||||
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
|
||||
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
|
||||
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
|
||||
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
|
||||
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
|
||||
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
|
||||
# # binary staged are skipped.
|
||||
#
|
||||
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
|
||||
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
|
||||
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
||||
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
||||
|
||||
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
|
||||
# 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; }
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
|
||||
else shasum -a 256 "$1" | awk '{print $1}'; fi
|
||||
}
|
||||
|
||||
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
|
||||
sign_file() {
|
||||
local key="$1" f="$2" raw
|
||||
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"
|
||||
}
|
||||
|
||||
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
|
||||
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
|
||||
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
|
||||
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
keygen)
|
||||
@@ -42,26 +68,90 @@ case "$cmd" in
|
||||
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
||||
echo " $pub"
|
||||
;;
|
||||
|
||||
pubkey)
|
||||
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
||||
pubkey_b64 "$1"
|
||||
;;
|
||||
|
||||
sign)
|
||||
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
||||
key="$1"; shift
|
||||
[ -f "$key" ] || die "no such key: $key"
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || die "no such file: $f"
|
||||
raw="$(mktemp)"
|
||||
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
||||
openssl base64 -A -in "$raw" > "$f.sig"
|
||||
printf '\n' >> "$f.sig"
|
||||
rm -f "$raw"
|
||||
sign_file "$key" "$f"
|
||||
echo "signed: $f -> $f.sig"
|
||||
done
|
||||
echo "Upload each .sig as a release asset next to its archive."
|
||||
;;
|
||||
|
||||
release)
|
||||
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
|
||||
key="$1"; version="$2"; shift 2
|
||||
src="$PROJECT_ROOT/prebuilt-binaries"
|
||||
out="$PROJECT_ROOT/release/daemon"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
|
||||
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
[ -f "$key" ] || die "no such key: $key"
|
||||
[ -d "$src" ] || die "no such source dir: $src"
|
||||
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
|
||||
mkdir -p "$out"
|
||||
|
||||
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
|
||||
# then reject every signature made with it — only expected when deliberately rotating the key).
|
||||
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
|
||||
mine="$(pubkey_b64 "$key")"
|
||||
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
|
||||
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
|
||||
echo " signing key -> $mine" >&2
|
||||
echo " pinned key -> $pinned" >&2
|
||||
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
|
||||
echo >&2
|
||||
fi
|
||||
|
||||
made=0
|
||||
table=""
|
||||
for plat in linux mac win; do
|
||||
d="$src/$(plat_dir "$plat")"
|
||||
daemon="$d/$(plat_daemon "$plat")"
|
||||
if [ ! -f "$daemon" ]; then
|
||||
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
|
||||
continue
|
||||
fi
|
||||
archive="dragonx-$version-$(plat_token "$plat").zip"
|
||||
apath="$out/$archive"
|
||||
rm -f "$apath"
|
||||
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
|
||||
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
|
||||
files=()
|
||||
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
|
||||
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
|
||||
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
|
||||
sign_file "$key" "$apath"
|
||||
sum="$(sha256_of "$apath")"
|
||||
table+="| $archive | \`$sum\` |"$'\n'
|
||||
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
|
||||
made=$((made + 1))
|
||||
done
|
||||
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
|
||||
|
||||
echo
|
||||
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
|
||||
echo "| Archive | SHA-256 |"
|
||||
echo "|---|---|"
|
||||
printf '%s' "$table"
|
||||
echo
|
||||
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
|
||||
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
|
||||
;;
|
||||
|
||||
*)
|
||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
|
||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
|
||||
;;
|
||||
esac
|
||||
|
||||
90
setup.sh
90
setup.sh
@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
|
||||
libxkbcommon wayland libsodium curl
|
||||
autoconf automake libtool wget python xxd"
|
||||
|
||||
pkgs_core_macos="cmake python xxd"
|
||||
pkgs_core_macos="bash cmake python xxd"
|
||||
|
||||
# Windows cross-compile (from Linux)
|
||||
pkgs_win_debian="mingw-w64 zip"
|
||||
@@ -284,18 +284,27 @@ fi
|
||||
header "Windows Cross-Compile"
|
||||
|
||||
if $SETUP_WIN; then
|
||||
win_pkgs="$(get_pkgs win)"
|
||||
if [[ -n "$win_pkgs" ]]; then
|
||||
install_pkgs "$win_pkgs" "Windows cross-compile"
|
||||
fi
|
||||
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
|
||||
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
|
||||
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
|
||||
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
|
||||
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
|
||||
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
|
||||
ok "Windows cross-compile toolchain already present — skipping apt install"
|
||||
else
|
||||
win_pkgs="$(get_pkgs win)"
|
||||
if [[ -n "$win_pkgs" ]]; then
|
||||
install_pkgs "$win_pkgs" "Windows cross-compile"
|
||||
fi
|
||||
|
||||
# Set posix thread model if available
|
||||
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
||||
if ! $CHECK_ONLY; then
|
||||
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
||||
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
||||
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
||||
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
||||
# Set posix thread model if available
|
||||
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
||||
if ! $CHECK_ONLY; then
|
||||
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
||||
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
||||
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
||||
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -391,11 +400,26 @@ elif $SETUP_SAPLING; then
|
||||
|
||||
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
|
||||
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
|
||||
# Consensus-critical MPC parameters with fixed, well-known SHA-256 (identical across every
|
||||
# Zcash-family node; also pinned in scripts/build-lite-backend-artifact.sh). z.cash is
|
||||
# plain HTTPS with no signature, so verify the digest and refuse a tampered/corrupt file.
|
||||
SPEND_SHA256="8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
|
||||
OUTPUT_SHA256="2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
|
||||
|
||||
curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" && \
|
||||
ok "Downloaded sapling-spend.params"
|
||||
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \
|
||||
ok "Downloaded sapling-output.params"
|
||||
if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
|
||||
&& echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
|
||||
ok "Downloaded + verified sapling-spend.params"
|
||||
else
|
||||
rm -f "$PARAMS_DIR/sapling-spend.params"
|
||||
err "sapling-spend.params download or SHA-256 verification failed — not installed"
|
||||
fi
|
||||
if curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" \
|
||||
&& echo "${OUTPUT_SHA256} $PARAMS_DIR/sapling-output.params" | sha256sum -c --status; then
|
||||
ok "Downloaded + verified sapling-output.params"
|
||||
else
|
||||
rm -f "$PARAMS_DIR/sapling-output.params"
|
||||
err "sapling-output.params download or SHA-256 verification failed — not installed"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"
|
||||
@@ -684,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
|
||||
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
||||
fi
|
||||
|
||||
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
|
||||
header "xmrig-hac Mining Binary"
|
||||
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
|
||||
header "drg-xmrig Mining Binary"
|
||||
|
||||
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
|
||||
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
|
||||
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
|
||||
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
|
||||
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
|
||||
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
|
||||
|
||||
# Clean previous prebuilt xmrig binaries so we always rebuild
|
||||
# Only clean the binary for the platform(s) we are actually building,
|
||||
@@ -700,14 +726,14 @@ if ! $CHECK_ONLY; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Helper: clone xmrig-hac if not present
|
||||
# Helper: clone drg-xmrig if not present
|
||||
clone_xmrig_if_needed() {
|
||||
if [[ ! -d "$XMRIG_SRC" ]]; then
|
||||
info "Cloning xmrig-hac..."
|
||||
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
|
||||
info "Cloning drg-xmrig..."
|
||||
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
|
||||
else
|
||||
ok "xmrig-hac source already present"
|
||||
info "Pulling latest xmrig-hac..."
|
||||
ok "drg-xmrig source already present"
|
||||
info "Pulling latest drg-xmrig..."
|
||||
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
|
||||
fi
|
||||
}
|
||||
@@ -728,15 +754,15 @@ else
|
||||
rm -rf "$XMRIG_SRC/build"
|
||||
|
||||
# Build dependencies (libuv, hwloc, openssl)
|
||||
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
|
||||
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
|
||||
(
|
||||
cd "$XMRIG_SRC/scripts"
|
||||
sh build_deps.sh
|
||||
)
|
||||
ok "xmrig-hac dependencies built"
|
||||
ok "drg-xmrig dependencies built"
|
||||
|
||||
# Build xmrig
|
||||
info "Building xmrig-hac (Linux)..."
|
||||
info "Building drg-xmrig (Linux)..."
|
||||
mkdir -p "$XMRIG_SRC/build"
|
||||
(
|
||||
cd "$XMRIG_SRC/build"
|
||||
@@ -753,7 +779,7 @@ else
|
||||
mkdir -p "$XMRIG_PREBUILT"
|
||||
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
|
||||
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
|
||||
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
|
||||
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||
else
|
||||
err "xmrig (Linux) build failed — binary not found"
|
||||
MISSING=$((MISSING + 1))
|
||||
@@ -777,7 +803,7 @@ else
|
||||
# Clean previous Windows build
|
||||
rm -rf "$XMRIG_SRC/build-windows"
|
||||
|
||||
info "Building xmrig-hac (Windows cross-compile)..."
|
||||
info "Building drg-xmrig (Windows cross-compile)..."
|
||||
(
|
||||
cd "$XMRIG_SRC/scripts"
|
||||
bash build_windows.sh
|
||||
@@ -787,7 +813,7 @@ else
|
||||
mkdir -p "$XMRIG_PREBUILT"
|
||||
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
|
||||
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
|
||||
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
|
||||
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||
else
|
||||
err "xmrig.exe (Windows) build failed — binary not found"
|
||||
MISSING=$((MISSING + 1))
|
||||
@@ -797,7 +823,7 @@ fi
|
||||
# ── 8. Binary directories ───────────────────────────────────────────────────
|
||||
header "Binary Directories"
|
||||
|
||||
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
|
||||
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
|
||||
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
|
||||
if [[ -d "$dir" ]]; then
|
||||
# Count actual files (not .gitkeep)
|
||||
|
||||
2122
src/app.cpp
2122
src/app.cpp
File diff suppressed because it is too large
Load Diff
298
src/app.h
298
src/app.h
@@ -13,6 +13,8 @@
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <deque>
|
||||
#include <condition_variable>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
@@ -70,14 +72,28 @@ enum class EncryptDialogPhase {
|
||||
Done // Finished — close dialog
|
||||
};
|
||||
|
||||
// A status string written by a background/worker thread and read every frame by the UI thread. Its
|
||||
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
|
||||
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
|
||||
class GuardedStatus {
|
||||
public:
|
||||
GuardedStatus() = default;
|
||||
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
|
||||
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
|
||||
private:
|
||||
mutable std::mutex m_;
|
||||
std::string v_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Main application class
|
||||
*
|
||||
*
|
||||
* Manages application state, RPC connection, and coordinates UI rendering.
|
||||
*/
|
||||
class App {
|
||||
public:
|
||||
App();
|
||||
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||
~App();
|
||||
|
||||
// Non-copyable
|
||||
@@ -146,6 +162,18 @@ public:
|
||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
||||
|
||||
// Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
|
||||
// pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
|
||||
bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
|
||||
bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
|
||||
const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
|
||||
const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
|
||||
bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; }
|
||||
|
||||
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
|
||||
// for the "Copy diagnostics" action. Contains no secrets.
|
||||
std::string buildDiagnosticsReport();
|
||||
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
||||
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
||||
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
||||
@@ -155,6 +183,21 @@ public:
|
||||
*/
|
||||
void renderShutdownScreen();
|
||||
|
||||
/**
|
||||
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
|
||||
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
|
||||
* attached to rather than spawned — so the user can still see the node flushing/exiting.
|
||||
*/
|
||||
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
|
||||
|
||||
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
|
||||
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
|
||||
bool daemonWitnessRebuildActive() const;
|
||||
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
|
||||
bool shouldConfirmDaemonStop() const;
|
||||
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
|
||||
void renderDaemonStopConfirm();
|
||||
|
||||
/**
|
||||
* @brief Render loading overlay in content area while daemon is starting/syncing
|
||||
* @param contentH Height of the content area child window
|
||||
@@ -351,6 +394,10 @@ public:
|
||||
|
||||
// Force refresh
|
||||
void refreshNow();
|
||||
// Called on window restore: drop the daemon-output backlog that accumulated while minimized (the
|
||||
// per-frame update loop was paused), so a background witness rebuild that started+finished during
|
||||
// the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
|
||||
void skipDaemonOutputBacklog();
|
||||
void refreshMiningInfo();
|
||||
void refreshPeerInfo();
|
||||
void refreshMarketData();
|
||||
@@ -388,6 +435,9 @@ public:
|
||||
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
|
||||
void startFullUiSweep();
|
||||
std::string screenshotFullDir() const;
|
||||
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
|
||||
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
|
||||
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
|
||||
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
|
||||
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
|
||||
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
|
||||
@@ -413,6 +463,7 @@ public:
|
||||
return 0;
|
||||
}
|
||||
void showAboutDialog() { show_about_ = true; }
|
||||
void showFaqDialog() { show_faq_ = true; }
|
||||
|
||||
// Legacy tab compat — maps int to NavPage
|
||||
void setCurrentTab(int tab);
|
||||
@@ -478,6 +529,10 @@ public:
|
||||
// Coin logo texture accessor (DragonX currency icon for balance tab)
|
||||
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
|
||||
|
||||
// DragonX custom chat emoji (the ":drgx:" shortcode) — the mark recolored to the theme accent (like
|
||||
// the logo), re-rasterized on theme change. Used by the emoji picker tile + inline in chat bubbles.
|
||||
ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; }
|
||||
|
||||
/**
|
||||
* @brief Reload theme images (background gradient + logo) from new paths
|
||||
* @param bgPath Path to background image override (empty = use default)
|
||||
@@ -485,6 +540,10 @@ public:
|
||||
*/
|
||||
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
|
||||
|
||||
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
|
||||
// the top of render() so the wizard, lock screen, and main header all show it.
|
||||
void ensureLogoTexture();
|
||||
|
||||
// Wizard / first-run
|
||||
WizardPhase getWizardPhase() const { return wizard_phase_; }
|
||||
bool isFirstRun() const;
|
||||
@@ -564,6 +623,9 @@ public:
|
||||
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
|
||||
void copySecretToClipboard(const std::string& secret);
|
||||
void pumpSecretClipboardClear();
|
||||
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
|
||||
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
|
||||
void clearSecretClipboardIfArmed();
|
||||
bool isTransactionRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
||||
}
|
||||
@@ -605,10 +667,13 @@ private:
|
||||
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
|
||||
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
|
||||
// retry of a retry is reported as a real error.
|
||||
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
|
||||
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
|
||||
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
||||
const std::string& memo, const nlohmann::json& recipients,
|
||||
const char* traceLabel, bool markFeeGapRetry,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
std::function<void(bool, const std::string&)> callback,
|
||||
bool background = false);
|
||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||
const std::string& txid);
|
||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||
@@ -695,12 +760,101 @@ private:
|
||||
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
||||
// wallets. In-memory only (resets on app restart).
|
||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
||||
|
||||
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
|
||||
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
|
||||
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
|
||||
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
|
||||
mutable int chat_unread_cached_ = 0;
|
||||
mutable double chat_unread_computed_at_ = 0.0;
|
||||
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
|
||||
// size), not every frame. See App::render().
|
||||
std::int64_t sb_unconf_key_ts_ = -1;
|
||||
std::size_t sb_unconf_key_n_ = 0;
|
||||
int sb_unconf_count_ = 0;
|
||||
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
|
||||
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
|
||||
mutable double daemon_mem_cached_mb_ = 0.0;
|
||||
mutable double daemon_mem_probe_at_ = 0.0;
|
||||
|
||||
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
||||
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
|
||||
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
|
||||
// burst of messages each spends a separate verified note, refilled from change + background self-
|
||||
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
|
||||
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
|
||||
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
|
||||
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
|
||||
struct LiteInflightOp {
|
||||
LiteOpKind kind = LiteOpKind::None;
|
||||
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
|
||||
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
|
||||
double submittedAt = 0.0;
|
||||
};
|
||||
struct QueuedChatOp {
|
||||
LiteOpKind kind = LiteOpKind::ChatSend;
|
||||
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
|
||||
std::string echoLocalId;
|
||||
int sessionGen = 0;
|
||||
int retries = 0;
|
||||
};
|
||||
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
||||
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
||||
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
||||
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
|
||||
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||
int chat_verified_note_budget_ = 0;
|
||||
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
|
||||
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
|
||||
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
|
||||
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
|
||||
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
|
||||
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
|
||||
// refill forever). Prevents runaway splitting that would drain balance into fees.
|
||||
bool chat_split_outstanding_ = false;
|
||||
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
|
||||
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
|
||||
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
|
||||
bool chat_note_scan_in_flight_ = false;
|
||||
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
|
||||
double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off)
|
||||
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
|
||||
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
|
||||
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
|
||||
std::int64_t chat_last_chain_height_ = 0;
|
||||
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
|
||||
|
||||
// Coordinator helpers (both variants unless noted; see app_network.cpp).
|
||||
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
|
||||
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
|
||||
// Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
|
||||
// so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
|
||||
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes);
|
||||
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
|
||||
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
bool shouldSplitChatBuffer();
|
||||
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
|
||||
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
|
||||
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
|
||||
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
|
||||
static bool isTransientVerifiedFundsError(const std::string& error);
|
||||
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
|
||||
public:
|
||||
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
|
||||
// active so the buffer's state (ready / building / sending) is visible.
|
||||
std::string chatBufferStatusText();
|
||||
private:
|
||||
public:
|
||||
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
||||
// feature is off / no identity.
|
||||
int chatUnreadCount() const;
|
||||
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
|
||||
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
|
||||
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
|
||||
// whose stamped time predates the deleted thread's last message — still badges as unread).
|
||||
void forgetChatConversationSeen(const std::string& cid);
|
||||
private:
|
||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||
@@ -713,12 +867,21 @@ private:
|
||||
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
||||
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
||||
void maybeRemindSeedBackup();
|
||||
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
|
||||
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
|
||||
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
|
||||
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
|
||||
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
|
||||
|
||||
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
|
||||
void beginCreateSeedWallet(); // starts the isolated create on a background thread
|
||||
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
||||
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
|
||||
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
|
||||
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
|
||||
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
|
||||
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
|
||||
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
@@ -739,6 +902,8 @@ private:
|
||||
void fastScanChatMemos();
|
||||
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
|
||||
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
|
||||
double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off)
|
||||
double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress)
|
||||
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
@@ -748,6 +913,16 @@ private:
|
||||
bool lite_startup_lock_checked_ = false;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
|
||||
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
|
||||
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
|
||||
std::thread mining_ctl_thread_;
|
||||
std::mutex mining_ctl_mutex_;
|
||||
std::condition_variable mining_ctl_cv_;
|
||||
std::deque<std::function<void()>> mining_ctl_queue_;
|
||||
bool mining_ctl_stop_ = false;
|
||||
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
|
||||
void stopMiningControlThread(); // signal + join the control thread (shutdown)
|
||||
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
||||
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
||||
util::PoolStatsService pool_stats_service_;
|
||||
@@ -780,8 +955,13 @@ private:
|
||||
std::atomic<bool> shutting_down_{false};
|
||||
std::atomic<bool> shutdown_complete_{false};
|
||||
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
|
||||
std::string shutdown_status_;
|
||||
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
|
||||
std::thread shutdown_thread_;
|
||||
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
|
||||
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
|
||||
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
|
||||
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
|
||||
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
|
||||
float shutdown_timer_ = 0.0f;
|
||||
bool force_quit_confirm_ = false;
|
||||
std::chrono::steady_clock::time_point shutdown_start_time_;
|
||||
@@ -806,6 +986,49 @@ private:
|
||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||
bool show_switch_stop_daemon_confirm_ = false;
|
||||
std::string pending_switch_wallet_file_;
|
||||
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
|
||||
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
|
||||
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
|
||||
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
|
||||
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
|
||||
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
|
||||
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
|
||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
|
||||
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
|
||||
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
|
||||
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
|
||||
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
|
||||
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
|
||||
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
|
||||
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
|
||||
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
|
||||
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
|
||||
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
|
||||
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
|
||||
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
|
||||
enum class RecoveryPhase { Offer, Working, Done, Failed };
|
||||
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
|
||||
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
|
||||
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
|
||||
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
|
||||
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
|
||||
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
|
||||
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
|
||||
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
|
||||
bool post_recovery_rescan_ = false;
|
||||
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
|
||||
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
|
||||
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
|
||||
std::mutex wallet_restore_mutex_;
|
||||
bool wallet_restore_done_ = false;
|
||||
int wallet_restore_severity_ = 0;
|
||||
std::string wallet_restore_msg_;
|
||||
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
||||
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
||||
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||
@@ -832,6 +1055,7 @@ private:
|
||||
bool show_demo_window_ = false;
|
||||
bool show_settings_ = false;
|
||||
bool show_about_ = false;
|
||||
bool show_faq_ = false;
|
||||
bool show_import_key_ = false;
|
||||
bool show_export_key_ = false;
|
||||
bool show_backup_ = false;
|
||||
@@ -845,6 +1069,7 @@ private:
|
||||
bool seed_backup_loading_ = false;
|
||||
bool seed_backup_no_mnemonic_ = false;
|
||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
|
||||
|
||||
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
||||
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
|
||||
@@ -938,10 +1163,30 @@ private:
|
||||
bool daemon_start_error_shown_ = false;
|
||||
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
||||
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
||||
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
|
||||
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
|
||||
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
|
||||
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
|
||||
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
|
||||
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
|
||||
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
|
||||
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
|
||||
// Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main:
|
||||
// the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a
|
||||
// large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and
|
||||
// starving block connection near the tip (where effectivelySyncing() reads false). See
|
||||
// addressRefreshDue() / txRefreshDue(); only the routine periodic poll is throttled — explicit
|
||||
// refreshes (tab switch, dirty set, in-flight send) call the refresh directly and bypass this.
|
||||
double last_address_scan_ms_ = 0.0; // measured cost of the last address scan
|
||||
double last_tx_scan_ms_ = 0.0; // measured cost of the last history scan
|
||||
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
||||
std::uint64_t clipboard_secret_hash_ = 0;
|
||||
double clipboard_clear_deadline_ = 0.0;
|
||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
||||
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
|
||||
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
|
||||
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
|
||||
|
||||
// Current page (sidebar navigation)
|
||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||
@@ -951,6 +1196,7 @@ private:
|
||||
|
||||
// Debug screenshot sweep state.
|
||||
bool screenshot_sweep_active_ = false;
|
||||
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
|
||||
bool sweep_capture_this_frame_ = false;
|
||||
int sweep_skin_idx_ = 0;
|
||||
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
|
||||
@@ -1010,6 +1256,9 @@ private:
|
||||
int logo_h_ = 0;
|
||||
bool logo_loaded_ = false;
|
||||
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
|
||||
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
|
||||
ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (themed to the accent, like the logo)
|
||||
int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0;
|
||||
|
||||
// Coin logo texture (DragonX currency icon, separate from wallet branding)
|
||||
ImTextureID coin_logo_tex_ = 0;
|
||||
@@ -1041,6 +1290,16 @@ private:
|
||||
|
||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||
std::atomic<bool> auto_shield_pending_{false};
|
||||
// v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
|
||||
// defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
|
||||
// the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
|
||||
// daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
|
||||
bool daemon_autoshield_probed_ = false;
|
||||
bool daemon_autoshield_active_ = false;
|
||||
std::atomic<bool> daemon_autoshield_probe_inflight_{false};
|
||||
std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
|
||||
std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
|
||||
bool daemon_autoshield_seed_recoverable_ = false;
|
||||
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
@@ -1079,6 +1338,11 @@ private:
|
||||
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
|
||||
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
|
||||
bool runtime_rescan_active_ = false;
|
||||
// True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex
|
||||
// restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does
|
||||
// on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it;
|
||||
// cleared when the toast is shown.
|
||||
std::atomic<bool> user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads
|
||||
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
|
||||
// that reconciles the preserved wallet.dat against the freshly-imported chain.
|
||||
bool post_bootstrap_rescan_pending_ = false;
|
||||
@@ -1137,8 +1401,8 @@ private:
|
||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||
|
||||
// Wizard: stopping an external daemon before bootstrap
|
||||
bool wizard_stopping_external_ = false;
|
||||
std::string wizard_stop_status_;
|
||||
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
|
||||
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
@@ -1204,6 +1468,13 @@ private:
|
||||
|
||||
// Private methods - rendering
|
||||
void renderStatusBar();
|
||||
// Persistent node/RPC error strip at the top of the content column when the wallet can't
|
||||
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
||||
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
||||
void renderNodeStatusBanner();
|
||||
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
|
||||
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
|
||||
void renderAlertHistoryPanel();
|
||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||
void renderImportKeyDialog();
|
||||
@@ -1221,6 +1492,16 @@ private:
|
||||
void renderPinDialogs();
|
||||
void renderAntivirusHelpDialog();
|
||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
||||
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
|
||||
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
|
||||
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
|
||||
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
|
||||
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
|
||||
void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
|
||||
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
|
||||
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
|
||||
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
|
||||
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
|
||||
void processDeferredEncryption();
|
||||
|
||||
// Private methods - connection
|
||||
@@ -1251,6 +1532,13 @@ private:
|
||||
void refreshPrice();
|
||||
void refreshWalletEncryptionState();
|
||||
void applyRefreshPolicy(ui::NavPage page);
|
||||
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
|
||||
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
|
||||
bool addressRefreshDue() const; // same adaptive back-off for the address scan (z_listunspent)
|
||||
bool txRefreshDue() const; // same adaptive back-off for the history scan (z_listreceivedbyaddress)
|
||||
// Shared duty-cycle rule: a scan may resume only once (lastScanMs / kScanDutyCycle) has elapsed since
|
||||
// lastUpdate, so any single O(mapWallet) scan can occupy at most ~kScanDutyCycle of wall-clock.
|
||||
bool scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const;
|
||||
bool currentPageNeedsWalletDataRefresh() const;
|
||||
bool shouldRunWalletTransactionRefresh() const;
|
||||
bool shouldRefreshTransactions() const;
|
||||
|
||||
1684
src/app_network.cpp
1684
src/app_network.cpp
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,10 @@
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <sodium.h>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
@@ -234,6 +238,41 @@ private:
|
||||
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
||||
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
||||
// processDeferredEncryption(); must be called on the main thread.
|
||||
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
|
||||
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
|
||||
void App::scrubAndRemoveExport(const std::string& path)
|
||||
{
|
||||
if (path.empty()) return;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (!ec && sz > 0) {
|
||||
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
|
||||
if (scrub) {
|
||||
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
|
||||
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
|
||||
scrub.flush();
|
||||
}
|
||||
}
|
||||
std::filesystem::remove(path, ec);
|
||||
}
|
||||
|
||||
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
|
||||
void App::sweepStaleDecryptExports()
|
||||
{
|
||||
std::error_code ec;
|
||||
const std::string dir = util::Platform::getDragonXDataDir();
|
||||
std::filesystem::directory_iterator it(dir, ec), end;
|
||||
for (; it != end; it.increment(ec)) {
|
||||
if (ec) break;
|
||||
const std::string name = it->path().filename().string();
|
||||
if (name.rfind("obsidiandecryptexport", 0) == 0) {
|
||||
scrubAndRemoveExport(it->path().string());
|
||||
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
if (announceRestartStatus) {
|
||||
@@ -261,14 +300,14 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
TR("sec_restart_daemon_for_encryption"));
|
||||
}
|
||||
}
|
||||
|
||||
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
if (!rpc_ || !rpc_->isConnected()) return;
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
encrypt_status_ = TR("sec_encrypting_wallet");
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
||||
@@ -278,7 +317,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
if (result.encrypted) {
|
||||
return [this]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
|
||||
encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon");
|
||||
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
|
||||
|
||||
// Immediately update local encryption state so the
|
||||
@@ -297,7 +336,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
}
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
"Wallet encrypted successfully", 5.0f);
|
||||
TR("sec_wallet_encrypted_successfully"), 5.0f);
|
||||
|
||||
// The daemon shuts itself down after encryptwallet.
|
||||
// Update connection_status_ so the loading overlay
|
||||
@@ -309,11 +348,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
|
||||
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
|
||||
|
||||
ui::Notifications::instance().error(
|
||||
"Encryption failed: " + err);
|
||||
std::string(TR("sec_encryption_failed_prefix")) + err);
|
||||
|
||||
// Return to passphrase entry on failure
|
||||
if (show_encrypt_dialog_ &&
|
||||
@@ -354,7 +393,7 @@ void App::processDeferredEncryption() {
|
||||
std::string pin = std::move(deferredEncryption.pin);
|
||||
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
encrypt_status_ = TR("sec_encrypting_wallet");
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
||||
@@ -374,13 +413,13 @@ void App::processDeferredEncryption() {
|
||||
if (result.pinStored) {
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
|
||||
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Wallet encrypted but PIN vault failed");
|
||||
TR("sec_wallet_encrypted_but_pin_vault_failed"));
|
||||
}
|
||||
} else {
|
||||
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
|
||||
ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f);
|
||||
}
|
||||
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
@@ -393,9 +432,9 @@ void App::processDeferredEncryption() {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
|
||||
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
||||
ui::Notifications::instance().error("Encryption failed: " + err);
|
||||
ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err);
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
};
|
||||
}
|
||||
@@ -483,7 +522,17 @@ void App::lockWallet() {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
lock_failure_warned_ = false;
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
} else {
|
||||
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
|
||||
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
|
||||
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
|
||||
if (!lock_failure_warned_) {
|
||||
lock_failure_warned_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
TR("sec_couldnt_lock_wallet"), 12.0f);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -492,7 +541,7 @@ void App::lockWallet() {
|
||||
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Changing passphrase...";
|
||||
encrypt_status_ = TR("sec_changing_passphrase");
|
||||
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
@@ -525,9 +574,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
ui::Notifications::instance().info("Passphrase changed successfully");
|
||||
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||
} else {
|
||||
encrypt_status_ = "Failed: " + err_msg;
|
||||
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
|
||||
}
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
};
|
||||
@@ -560,6 +609,12 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
// Wallet is encrypted — any pending deferred-encryption request has now been
|
||||
// satisfied (however it completed). Clear the persisted flag (W2-2).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
@@ -572,6 +627,18 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
|
||||
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
|
||||
// failed connect before it applied. Warn (once/session) instead of silently leaving
|
||||
// an unencrypted wallet the user believes is protected. The flag stays set until the
|
||||
// wallet is actually encrypted, so the warning recurs each launch until resolved.
|
||||
if (settings_ && settings_->getEncryptionPending() &&
|
||||
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
|
||||
!encryption_incomplete_warned_) {
|
||||
encryption_incomplete_warned_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
TR("sec_encryption_did_not_complete"), 30.0f);
|
||||
}
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -691,6 +758,10 @@ void App::checkIdleMining() {
|
||||
// Resolve auto values: active defaults to half, idle defaults to all
|
||||
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
|
||||
if (idleThreads <= 0) idleThreads = maxThreads;
|
||||
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
|
||||
// could otherwise carry an arbitrary count straight past every bound. (M-06)
|
||||
activeThreads = std::clamp(activeThreads, 1, maxThreads);
|
||||
idleThreads = std::clamp(idleThreads, 1, maxThreads);
|
||||
|
||||
if (systemIdle) {
|
||||
// System is idle — scale up to idle thread count
|
||||
@@ -879,7 +950,7 @@ void App::renderLockScreen() {
|
||||
ImU32 textCol = ui::material::OnSurface();
|
||||
|
||||
{
|
||||
const char* title = "Wallet Locked";
|
||||
const char* title = TR("sec_wallet_locked_title");
|
||||
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
|
||||
dl->AddText(titleFont, titleFont->LegacySize,
|
||||
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
|
||||
@@ -892,7 +963,7 @@ void App::renderLockScreen() {
|
||||
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
|
||||
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "Too many attempts. Wait %.0f seconds...", lock_lockout_timer_);
|
||||
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_);
|
||||
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
|
||||
@@ -905,10 +976,10 @@ void App::renderLockScreen() {
|
||||
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
|
||||
if (hasPinVault) {
|
||||
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
|
||||
const char* modeText = lock_use_pin_ ? " PIN" : " Passphrase";
|
||||
const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase");
|
||||
const char* switchLabel = lock_use_pin_
|
||||
? "Use passphrase instead"
|
||||
: "Use PIN instead";
|
||||
? TR("sec_use_passphrase_instead")
|
||||
: TR("sec_use_pin_instead");
|
||||
|
||||
// Current mode indicator — icon with icon font, text with caption font
|
||||
ImFont* iconFont = ui::material::Type().iconSmall();
|
||||
@@ -1010,7 +1081,7 @@ void App::renderLockScreen() {
|
||||
if (lock_unlock_in_progress_) {
|
||||
// Animated spinner dots
|
||||
char msg[64];
|
||||
snprintf(msg, sizeof(msg), "Unlocking%s", ui::material::LoadingDots());
|
||||
snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots());
|
||||
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
|
||||
@@ -1034,7 +1105,7 @@ void App::renderLockScreen() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!canSubmit);
|
||||
bool btnClicked = ui::material::TactileButton("Unlock", ImVec2(unlockW, unlockH));
|
||||
bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH));
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
@@ -1088,7 +1159,7 @@ void App::renderLockScreen() {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
rpcOk = true;
|
||||
} else {
|
||||
rpcErr = "Not connected to daemon";
|
||||
rpcErr = TR("sec_not_connected_to_daemon");
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
rpcErr = e.what();
|
||||
@@ -1104,7 +1175,7 @@ void App::renderLockScreen() {
|
||||
// so route through applyUnlockFailure so the lockout curve applies
|
||||
// (this path previously bumped the counter but skipped the lockout math).
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
applyUnlockFailure("Unlock failed: " + rpcErr);
|
||||
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
@@ -1193,7 +1264,7 @@ void App::renderEncryptWalletDialog() {
|
||||
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
|
||||
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 4.0f;
|
||||
float barH = 4.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1243,7 +1314,7 @@ void App::renderEncryptWalletDialog() {
|
||||
// Indeterminate progress bar
|
||||
{
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 6.0f;
|
||||
float barH = 6.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1311,9 +1382,13 @@ void App::renderEncryptWalletDialog() {
|
||||
enc_dlg_pin_status_.clear();
|
||||
std::string savedPass = enc_dlg_saved_passphrase_;
|
||||
if (worker_ && vault_) {
|
||||
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Argon2id runs here (worker thread)
|
||||
bool ok = vault_->store(pinStr, savedPass);
|
||||
// Scrub the captured PIN + passphrase copies (they live in the worker's task
|
||||
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
|
||||
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
|
||||
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
settings_->setPinEnabled(true);
|
||||
@@ -1374,6 +1449,11 @@ void App::renderEncryptWalletDialog() {
|
||||
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
|
||||
if (BeginOverlayDialog(ov)) {
|
||||
|
||||
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
|
||||
// passphrase is lost — reuse their warning string/header for consistency.
|
||||
DialogWarningHeader(TR("wiz_encrypt_warning"));
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::TextUnformatted(TR("change_pass_current"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
||||
@@ -1400,12 +1480,22 @@ void App::renderEncryptWalletDialog() {
|
||||
bool valid = strlen(change_old_pass_buf_) > 0 &&
|
||||
strlen(change_new_pass_buf_) >= 8 &&
|
||||
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
||||
changePassphrase(std::string(change_old_pass_buf_),
|
||||
std::string(change_new_pass_buf_));
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_change_passphrase_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
show_change_passphrase_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
}
|
||||
|
||||
@@ -1478,15 +1568,17 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Run entire decrypt flow on worker thread
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||
// Scrub the passphrase — unlock is its only use in this flow.
|
||||
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
|
||||
if (!unlock.ok) {
|
||||
return [this]() {
|
||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||
wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt"));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1553,6 +1645,11 @@ void App::renderDecryptWalletDialog() {
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
|
||||
// success, a restart-failure early return, or an exception. A full cleartext dump of all
|
||||
// private keys must never outlive this step. The startup sweep is a further net for a
|
||||
// crash/kill mid-flight. (H-02)
|
||||
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
|
||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
@@ -1597,7 +1694,7 @@ void App::renderDecryptWalletDialog() {
|
||||
});
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
"Importing keys & rescanning blockchain — wallet is usable while this runs",
|
||||
TR("sec_importing_keys_rescanning"),
|
||||
8.0f);
|
||||
};
|
||||
});
|
||||
@@ -1606,6 +1703,8 @@ void App::renderDecryptWalletDialog() {
|
||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||
importAdapter, exportPath);
|
||||
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
|
||||
|
||||
if (!importResult.ok) {
|
||||
std::string err = importResult.error;
|
||||
if (worker_) {
|
||||
@@ -1614,7 +1713,7 @@ void App::renderDecryptWalletDialog() {
|
||||
wallet_security_workflow_.finishImport();
|
||||
ui::Notifications::instance().error(
|
||||
err +
|
||||
"\nEncrypted backup: wallet.dat.encrypted.bak",
|
||||
TR("sec_encrypted_backup_suffix"),
|
||||
12.0f);
|
||||
};
|
||||
});
|
||||
@@ -1640,7 +1739,7 @@ void App::renderDecryptWalletDialog() {
|
||||
refreshPeerInfo();
|
||||
|
||||
ui::Notifications::instance().success(
|
||||
"Wallet decrypted successfully! All keys imported.",
|
||||
TR("sec_wallet_decrypted_all_keys_imported"),
|
||||
8.0f);
|
||||
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
|
||||
};
|
||||
@@ -1730,7 +1829,7 @@ void App::renderDecryptWalletDialog() {
|
||||
// Indeterminate progress bar
|
||||
{
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 6.0f;
|
||||
float barH = 6.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1762,7 +1861,7 @@ void App::renderDecryptWalletDialog() {
|
||||
int tMins = (int)(totalElapsed / 60);
|
||||
int tSecs = (int)(totalElapsed % 60);
|
||||
ImGui::Spacing();
|
||||
ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs);
|
||||
ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs);
|
||||
}
|
||||
|
||||
// ---- Phase 2: Success ----
|
||||
@@ -1860,10 +1959,12 @@ void App::renderPinDialogs() {
|
||||
util::SecureVault::isValidPin(pinStr) &&
|
||||
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = "Verifying passphrase...";
|
||||
pin_status_ = TR("sec_verifying_passphrase");
|
||||
|
||||
// Verify passphrase + store vault on worker thread to avoid
|
||||
// blocking the UI with Argon2id key derivation.
|
||||
@@ -1874,20 +1975,25 @@ void App::renderPinDialogs() {
|
||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||
|
||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
} catch (const std::exception& e) {
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
return [this]() {
|
||||
pin_status_ = "Incorrect passphrase";
|
||||
pin_status_ = TR("sec_incorrect_passphrase_pin_setup");
|
||||
pin_in_progress_ = false;
|
||||
};
|
||||
}
|
||||
|
||||
// Passphrase correct — store in vault (Argon2id, worker thread)
|
||||
bool storeOk = vault_ && vault_->store(pin, passphrase);
|
||||
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
@@ -1902,19 +2008,26 @@ void App::renderPinDialogs() {
|
||||
pin_status_.clear();
|
||||
pin_in_progress_ = false;
|
||||
show_pin_setup_ = false;
|
||||
ui::Notifications::instance().info("PIN set successfully");
|
||||
ui::Notifications::instance().info(TR("sec_pin_set_successfully"));
|
||||
} else {
|
||||
pin_status_ = "Failed to create vault";
|
||||
pin_status_ = TR("sec_failed_to_create_vault");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
pin_status_ = "Not connected to daemon";
|
||||
pin_status_ = TR("sec_not_connected_to_daemon_pin");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_pin_setup_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
show_pin_setup_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
}
|
||||
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
|
||||
@@ -1968,7 +2081,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = "Changing PIN...";
|
||||
pin_status_ = TR("sec_changing_pin");
|
||||
std::string oldPin(pin_old_buf_);
|
||||
std::string newPinCopy = newPin;
|
||||
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
||||
@@ -1984,15 +2097,15 @@ void App::renderPinDialogs() {
|
||||
pin_status_.clear();
|
||||
pin_in_progress_ = false;
|
||||
show_pin_change_ = false;
|
||||
ui::Notifications::instance().info("PIN changed successfully");
|
||||
ui::Notifications::instance().info(TR("sec_pin_changed_successfully"));
|
||||
} else {
|
||||
pin_status_ = "Incorrect current PIN";
|
||||
pin_status_ = TR("sec_incorrect_current_pin");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
pin_status_ = "Internal error";
|
||||
pin_status_ = TR("sec_internal_error_change_pin");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
}
|
||||
@@ -2033,7 +2146,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = "Verifying PIN...";
|
||||
pin_status_ = TR("sec_verifying_pin");
|
||||
std::string oldPin(pin_old_buf_);
|
||||
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
||||
|
||||
@@ -2053,15 +2166,15 @@ void App::renderPinDialogs() {
|
||||
pin_status_.clear();
|
||||
pin_in_progress_ = false;
|
||||
show_pin_remove_ = false;
|
||||
ui::Notifications::instance().info("PIN removed");
|
||||
ui::Notifications::instance().info(TR("sec_pin_removed"));
|
||||
} else {
|
||||
pin_status_ = "Incorrect PIN";
|
||||
pin_status_ = TR("sec_incorrect_pin_remove");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
pin_status_ = "Internal error";
|
||||
pin_status_ = TR("sec_internal_error_remove_pin");
|
||||
pin_in_progress_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,10 +170,10 @@ void App::installDemoWalletData()
|
||||
}
|
||||
|
||||
auto zaddr = [](const char* a, double bal, const char* label) {
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
|
||||
};
|
||||
auto taddr = [](const char* a, double bal, const char* label) {
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
|
||||
};
|
||||
state_.z_addresses = {
|
||||
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
|
||||
@@ -310,6 +310,8 @@ void App::buildSweepCatalog()
|
||||
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
|
||||
add("modal-backup", ui::NavPage::Overview,
|
||||
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
|
||||
add("modal-faq", ui::NavPage::Overview,
|
||||
[](App& a) { a.show_faq_ = true; }, [](App& a) { a.show_faq_ = false; });
|
||||
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
|
||||
add("modal-encrypt", ui::NavPage::Settings,
|
||||
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
|
||||
@@ -704,9 +706,26 @@ void App::startSweepImpl(bool full)
|
||||
if (sk.valid) sweep_skins_.push_back(sk.id);
|
||||
if (sweep_skins_.empty()) return;
|
||||
|
||||
// Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
|
||||
if (sweep_current_theme_only_)
|
||||
sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
|
||||
|
||||
// DEV/TEST hook (dormant unless the env is set): DRAGONX_SWEEP_ONLY="send,receive" restricts the
|
||||
// sweep to the named surfaces and the dark skin, so a slow large-window run captures just the tab
|
||||
// under review instead of all surfaces x every skin.
|
||||
const char* sweepOnly = std::getenv("DRAGONX_SWEEP_ONLY");
|
||||
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
|
||||
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
|
||||
|
||||
sweep_full_ = full;
|
||||
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
||||
buildSweepCatalog();
|
||||
if (!sweepOnlyStr.empty()) {
|
||||
std::vector<SweepTarget> keep;
|
||||
for (const auto& t : sweep_targets_)
|
||||
if (sweepOnlyStr.find(t.name) != std::string::npos) keep.push_back(t);
|
||||
sweep_targets_.swap(keep);
|
||||
}
|
||||
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
|
||||
|
||||
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();
|
||||
|
||||
@@ -177,8 +177,36 @@ void App::renderFirstRunWizard() {
|
||||
// DPI scale factor — multiply all pixel constants by dp
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
|
||||
// Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
|
||||
// font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
|
||||
// / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
|
||||
// last frame's measured content height, so every control stays reachable. The window keeps
|
||||
// NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
|
||||
static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
|
||||
if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
|
||||
const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
|
||||
// Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
|
||||
// scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
|
||||
// IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
|
||||
const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
|
||||
if (wizMaxScroll > 0.0f && !wizPopupOpen &&
|
||||
ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
|
||||
float wheel = ImGui::GetIO().MouseWheel;
|
||||
if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
|
||||
}
|
||||
s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
|
||||
const float scrollY = s_wizScroll;
|
||||
|
||||
// --- Header: Logo + Welcome ---
|
||||
float headerCy = winPos.y + 20.0f * dp;
|
||||
// Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
|
||||
// monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
|
||||
// height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
|
||||
// fight the scroll), push everything down by half the leftover space. No-op once content
|
||||
// fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
|
||||
float vCenter = 0.0f;
|
||||
if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
|
||||
vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
|
||||
float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
|
||||
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
|
||||
if (logo_tex_ != 0) {
|
||||
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
|
||||
@@ -266,29 +294,43 @@ void App::renderFirstRunWizard() {
|
||||
{
|
||||
int state = cardState(0);
|
||||
bool isFocused = (state == 1);
|
||||
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
|
||||
float cx = leftX + cardPad;
|
||||
float cy = card0Top + cardPad;
|
||||
float contentW = colW - 2 * cardPad;
|
||||
|
||||
// Step indicator
|
||||
{
|
||||
// Step indicator + title (inline when collapsed)
|
||||
if (isCollapsed) {
|
||||
// Compact single-line: check icon + "Step 1" + "Appearance"
|
||||
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
||||
cy += captionFont->LegacySize + 6.0f * dp;
|
||||
}
|
||||
float labelX = cx + iconW + 4.0f * dp;
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
|
||||
float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
|
||||
float titleX = labelX + step1W + 12.0f * dp;
|
||||
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
|
||||
cy += captionFont->LegacySize + 4.0f * dp;
|
||||
} else {
|
||||
// Step indicator
|
||||
{
|
||||
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
||||
cy += captionFont->LegacySize + 6.0f * dp;
|
||||
}
|
||||
|
||||
// Title
|
||||
{
|
||||
const char* t = TR("wiz_appearance");
|
||||
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
||||
cy += titleFont->LegacySize + 10.0f * dp;
|
||||
}
|
||||
// Title
|
||||
{
|
||||
const char* t = TR("wiz_appearance");
|
||||
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
||||
cy += titleFont->LegacySize + 10.0f * dp;
|
||||
}
|
||||
|
||||
// Separator
|
||||
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
// Separator
|
||||
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
}
|
||||
|
||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||
@@ -324,6 +366,9 @@ void App::renderFirstRunWizard() {
|
||||
wiz_appearance_init = true;
|
||||
}
|
||||
|
||||
// Controls: rendered for the focused and upcoming states so content is visible under
|
||||
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
|
||||
if (!isCollapsed) {
|
||||
// Render controls always so content is visible under the dim
|
||||
// overlay when not focused; disable interaction when not active.
|
||||
ImGui::BeginDisabled(!isFocused);
|
||||
@@ -640,13 +685,21 @@ void App::renderFirstRunWizard() {
|
||||
cy += btnH;
|
||||
}
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
} // if (!isCollapsed)
|
||||
|
||||
// Card 0 finalization deferred until after cards 1+2 are sized
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
if (isCollapsed) {
|
||||
// Completed: finalize immediately as a compact pill (do not stretch to the
|
||||
// right column height, and skip the deferred stretch below).
|
||||
card0Bot = card0Top + (cy - card0Top);
|
||||
finalizeCard(leftX, colW, card0Top, card0Bot, state);
|
||||
} else {
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
// Card 0 finalization deferred until after cards 1+2 are sized
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -889,8 +942,9 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
|
||||
if (wizard_stopping_external_) {
|
||||
const std::string ws = wizard_stop_status_.get();
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
|
||||
wizard_stop_status_.c_str());
|
||||
ws.c_str());
|
||||
cy += captionFont->LegacySize + 8.0f * dp;
|
||||
} else {
|
||||
float stopW = 150.0f * dp;
|
||||
@@ -1338,6 +1392,10 @@ void App::renderFirstRunWizard() {
|
||||
wallet_security_.beginDeferredEncryption(
|
||||
std::string(encrypt_pass_buf_),
|
||||
(pinEntered && pinOk) ? pinStr : std::string());
|
||||
// Persist that encryption was requested (never the passphrase) so a quit/crash or
|
||||
// failed daemon connect before it applies isn't silent — reconciled on the next
|
||||
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
|
||||
settings_->setEncryptionPending(true);
|
||||
|
||||
// Clear sensitive buffers
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
@@ -1372,6 +1430,13 @@ void App::renderFirstRunWizard() {
|
||||
encrypt_status_ = TR("wiz_skip_confirm");
|
||||
} else {
|
||||
s_skipEncConfirm = false;
|
||||
// Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
|
||||
// typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
|
||||
// path cleared them before). (L-07)
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
||||
memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
|
||||
memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
|
||||
wizard_phase_ = WizardPhase::Done;
|
||||
settings_->setWizardCompleted(true);
|
||||
settings_->save();
|
||||
@@ -1407,7 +1472,9 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
|
||||
// --- Deferred Card 0 finalization: match right column total height ---
|
||||
{
|
||||
// Only for the focused/upcoming Appearance card; a completed one was already finalized
|
||||
// above as a compact pill and must not be re-stretched.
|
||||
if (cardState(0) != 2) {
|
||||
float rightColBot = card2Bot;
|
||||
if (rightColBot > card0Bot) card0Bot = rightColBot;
|
||||
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
|
||||
@@ -1416,6 +1483,24 @@ void App::renderFirstRunWizard() {
|
||||
// Merge channels: backgrounds → content → overlays
|
||||
dl->ChannelsMerge();
|
||||
|
||||
// Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
|
||||
// window, draw a slim scroll indicator so the off-screen content is discoverable.
|
||||
{
|
||||
float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
|
||||
// Subtract vCenter back out: everything below the header was shifted down by it, so the raw
|
||||
// span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
|
||||
// vertical-centering above would feed on itself and oscillate frame-to-frame.
|
||||
s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
|
||||
if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
|
||||
float trackH = winSize.y - 8.0f * dp;
|
||||
float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
|
||||
float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
|
||||
float barX = winPos.x + winSize.x - 6.0f * dp;
|
||||
dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
|
||||
ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
||||
lock();
|
||||
return false;
|
||||
}
|
||||
loadTombstones();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
|
||||
sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
wallet_tag_.clear();
|
||||
tombstones_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::append(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
|
||||
void ChatDatabase::clearWallet()
|
||||
{
|
||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
||||
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
|
||||
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
tombstones_.clear();
|
||||
}
|
||||
|
||||
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
if (messages.empty()) return true;
|
||||
|
||||
if (!exec("BEGIN")) return false;
|
||||
bool ok = true;
|
||||
for (const auto& m : messages) {
|
||||
const std::string dedup = dedupHash(m.txid, m.payload_position);
|
||||
|
||||
sqlite3_stmt* del = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
|
||||
-1, &del, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(del);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (tombstone) {
|
||||
sqlite3_stmt* ins = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
|
||||
-1, &ins, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
|
||||
sqlite3_finalize(ins);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
|
||||
// Only reflect the tombstones in the in-memory cache once they are durably committed.
|
||||
if (ok && tombstone)
|
||||
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
|
||||
{
|
||||
if (!key_ready_ || tombstones_.empty()) return false;
|
||||
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
|
||||
}
|
||||
|
||||
void ChatDatabase::loadTombstones()
|
||||
{
|
||||
tombstones_.clear();
|
||||
if (!key_ready_ || !ensureOpen()) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
||||
!= SQLITE_OK) {
|
||||
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||
if (h) tombstones_.insert(h);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
@@ -228,6 +297,17 @@ bool ChatDatabase::ensureOpen()
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
// C3-1: restrict the chat DB and its WAL/SHM sidecars to owner-only. sqlite creates them with
|
||||
// umask-derived permissions (often world/group-readable); they hold per-row nonces + AEAD
|
||||
// ciphertext of the user's messages. Best-effort (errors swallowed; a no-op-ish on Windows).
|
||||
{
|
||||
std::error_code perr;
|
||||
const auto ownerOnly = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write;
|
||||
std::filesystem::permissions(database_path_, ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||
std::filesystem::permissions(database_path_ + "-wal", ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||
std::filesystem::permissions(database_path_ + "-shm", ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||
}
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
@@ -249,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
|
||||
|
||||
bool ChatDatabase::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))"))
|
||||
return false;
|
||||
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
|
||||
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
|
||||
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
|
||||
"wallet_tag TEXT NOT NULL, "
|
||||
"dedup_hash TEXT NOT NULL, "
|
||||
"nonce BLOB NOT NULL, "
|
||||
"payload BLOB NOT NULL, "
|
||||
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
@@ -54,8 +55,20 @@ public:
|
||||
|
||||
void clearWallet(); // delete the unlocked wallet's rows
|
||||
|
||||
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
|
||||
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
|
||||
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
|
||||
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
|
||||
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
|
||||
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
|
||||
|
||||
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
|
||||
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
|
||||
bool isTombstoned(const ChatMessage& message) const;
|
||||
|
||||
private:
|
||||
bool ensureOpen();
|
||||
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||
@@ -74,6 +87,7 @@ private:
|
||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||
bool key_ready_ = false;
|
||||
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
||||
const std::string& conversationId,
|
||||
const char* type,
|
||||
const std::string& streamHeaderHex,
|
||||
const std::string& publicKeyHex)
|
||||
const std::string& publicKeyHex,
|
||||
std::int64_t sentAt)
|
||||
{
|
||||
nlohmann::json header;
|
||||
header["h"] = 1; // header number (>= 1)
|
||||
@@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
||||
header["t"] = type; // "Memo" or "Cont"
|
||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
||||
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||
return header.dump();
|
||||
}
|
||||
|
||||
@@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||
|
||||
OutgoingChatMemos memos;
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
||||
static_cast<std::int64_t>(std::time(nullptr)));
|
||||
memos.payloadMemo = ciphertextHex;
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
@@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
||||
|
||||
OutgoingChatMemos memos;
|
||||
memos.recipientZaddr = peerZaddr;
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
|
||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
||||
static_cast<std::int64_t>(std::time(nullptr)));
|
||||
memos.payloadMemo = requestText;
|
||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||
|
||||
@@ -124,6 +124,10 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
||||
if (!readRequiredString(object, "t", type, error)) return fail(error);
|
||||
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
|
||||
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
|
||||
// Optional sender compose time (Unix seconds). Absent on older senders — leave sent_at = 0 so the
|
||||
// receiver falls back to the tx/receive time. Read leniently; never fail the header on a bad value.
|
||||
if (auto it = object.find("ts"); it != object.end() && it->is_number_integer())
|
||||
header.sent_at = it->get<std::int64_t>();
|
||||
|
||||
if (header.header_number < 1) return fail("header number must be positive");
|
||||
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
|
||||
@@ -303,6 +307,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
||||
metadata.sender_public_key_hex = pair.header.public_key_hex;
|
||||
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
||||
metadata.payload_memo = pair.payload_memo;
|
||||
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
|
||||
result.metadata.push_back(std::move(metadata));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ struct HushChatHeader {
|
||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||
std::string secretstream_header_hex;
|
||||
std::string public_key_hex;
|
||||
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
||||
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
||||
std::int64_t sent_at = 0;
|
||||
};
|
||||
|
||||
struct HushChatHeaderParseResult {
|
||||
@@ -80,6 +83,7 @@ struct HushChatTransactionMetadata {
|
||||
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
|
||||
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
||||
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
||||
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
|
||||
};
|
||||
|
||||
struct HushChatTransactionExtractionResult {
|
||||
|
||||
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
std::int64_t fallbackTimestamp,
|
||||
std::vector<std::string>* newIncomingCids) {
|
||||
if (!has_identity_) return 0;
|
||||
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
|
||||
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
|
||||
// Skip until the DB is usable — chat is degraded anyway without its store.
|
||||
if (db_ && !db_->hasKey()) return 0;
|
||||
|
||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
@@ -45,10 +49,30 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.conversation_id = meta.conversation_id;
|
||||
message.peer_zaddr = meta.reply_zaddr;
|
||||
message.peer_public_key_hex = meta.sender_public_key_hex;
|
||||
// Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for
|
||||
// a mempool receive).
|
||||
const auto timeIt = txTimestamps.find(meta.txid);
|
||||
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
||||
const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
||||
// Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on
|
||||
// both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock
|
||||
// would otherwise pin their messages to the bottom of the thread forever. A compose time in the
|
||||
// PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a
|
||||
// confirmed tx's block time is always >= the compose time.
|
||||
constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated
|
||||
if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) {
|
||||
message.timestamp = meta.sent_at;
|
||||
} else {
|
||||
message.timestamp = refTime;
|
||||
}
|
||||
message.payload_position = meta.payload_position;
|
||||
|
||||
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
|
||||
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
|
||||
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
||||
// in the same conversation has a different txid and flows through normally.
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||
if (db_ && db_->isTombstoned(message)) continue;
|
||||
|
||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||
message.kind = ChatMessageKind::ContactRequest;
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
@@ -79,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
|
||||
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||
store_.append(message);
|
||||
}
|
||||
}
|
||||
|
||||
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
|
||||
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
|
||||
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
|
||||
// survive — and on the next reload the conversation silently reappears with no tombstone.
|
||||
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
|
||||
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
|
||||
// until unblocked, at which point the conversation re-imports from chain.
|
||||
if (db_) {
|
||||
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
|
||||
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
|
||||
}
|
||||
store_.eraseConversation(conversationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ChatService::identityPublicKeyHex() const {
|
||||
if (!has_identity_) return {};
|
||||
return chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -55,6 +56,19 @@ public:
|
||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||
void loadFromDatabase();
|
||||
|
||||
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
|
||||
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
|
||||
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
|
||||
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
|
||||
|
||||
// Locally delete a conversation: remove its messages from the store and the database. When `block`
|
||||
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
|
||||
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
|
||||
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
|
||||
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
|
||||
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||
bool deleteConversation(const std::string& conversationId, bool block);
|
||||
|
||||
// --- Outgoing (compose) ---
|
||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||
std::string identityPublicKeyHex() const;
|
||||
@@ -90,6 +104,7 @@ private:
|
||||
bool has_identity_ = false;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
if (!seen_.insert(dedupKey(message)).second) return false;
|
||||
messages_.push_back(message);
|
||||
++revision_;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
|
||||
for (auto& message : messages_) {
|
||||
if (message.txid == txid) {
|
||||
message.delivery = delivery;
|
||||
++revision_;
|
||||
return &message;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||
const std::function<std::int64_t(const std::string&)>& seenFor) const {
|
||||
int unread = 0;
|
||||
for (const auto& m : messages_) {
|
||||
if (m.direction != ChatDirection::Incoming) continue;
|
||||
if (excluded && excluded(m.conversation_id)) continue;
|
||||
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
|
||||
std::vector<std::string> ChatStore::conversationIds() const {
|
||||
std::vector<std::string> ids;
|
||||
std::unordered_set<std::string> seenIds;
|
||||
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
|
||||
return ids;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
|
||||
std::vector<ChatMessage> removed;
|
||||
std::vector<ChatMessage> kept;
|
||||
kept.reserve(messages_.size());
|
||||
for (auto& m : messages_) {
|
||||
if (m.conversation_id == conversationId) {
|
||||
seen_.erase(dedupKey(m));
|
||||
removed.push_back(std::move(m));
|
||||
} else {
|
||||
kept.push_back(std::move(m));
|
||||
}
|
||||
}
|
||||
messages_.swap(kept);
|
||||
if (!removed.empty()) ++revision_;
|
||||
return removed;
|
||||
}
|
||||
|
||||
void ChatStore::clear() {
|
||||
messages_.clear();
|
||||
seen_.clear();
|
||||
++revision_;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
@@ -28,15 +30,42 @@ public:
|
||||
// Distinct conversation ids, in first-seen order.
|
||||
std::vector<std::string> conversationIds() const;
|
||||
|
||||
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
|
||||
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
|
||||
std::unordered_set<std::string> chatTxids() const {
|
||||
std::unordered_set<std::string> out;
|
||||
out.reserve(messages_.size());
|
||||
for (const auto& m : messages_)
|
||||
if (!m.txid.empty()) out.insert(m.txid);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remove every message in a conversation from the in-memory view and return the removed messages
|
||||
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
|
||||
// guard (blocked-cid predicate / DB tombstone), not here.
|
||||
std::vector<ChatMessage> eraseConversation(const std::string& conversationId);
|
||||
|
||||
std::size_t size() const { return messages_.size(); }
|
||||
bool empty() const { return messages_.empty(); }
|
||||
void clear();
|
||||
|
||||
// Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
|
||||
// clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
|
||||
// so they only rebuild when the store actually changed.
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
// Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
|
||||
// cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
|
||||
// copy+sort that conversation() does — the count doesn't need ordering.
|
||||
int countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||
const std::function<std::int64_t(const std::string&)>& seenFor) const;
|
||||
|
||||
private:
|
||||
static std::string dedupKey(const ChatMessage& message);
|
||||
|
||||
std::vector<ChatMessage> messages_;
|
||||
std::unordered_set<std::string> seen_;
|
||||
std::uint64_t revision_ = 0;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -108,6 +108,20 @@ void loadClamped(const json& j, const char* key, T& field, T lo, T hi)
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Settings::LiteServerPreference> Settings::defaultLiteServers()
|
||||
{
|
||||
return {
|
||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
||||
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
||||
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
||||
{"https://lite5.dragonx.is", "DragonX Lite 5", true},
|
||||
{"https://lite6.dragonx.is", "DragonX Lite 6", true},
|
||||
{"https://lite7.dragonx.is", "DragonX Lite 7", true}
|
||||
};
|
||||
}
|
||||
|
||||
std::string Settings::getDefaultPath()
|
||||
{
|
||||
// Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the
|
||||
@@ -157,6 +171,13 @@ bool Settings::load(const std::string& path)
|
||||
for (const auto& c : j["hidden_chat_cids"])
|
||||
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
||||
}
|
||||
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
|
||||
blocked_chat_convs_.clear();
|
||||
for (const auto& c : j["blocked_chat_convs"])
|
||||
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||
c.value("name", std::string())});
|
||||
}
|
||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
||||
@@ -196,12 +217,15 @@ bool Settings::load(const std::string& path)
|
||||
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
|
||||
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
|
||||
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
|
||||
loadScalar(j, "contacts_avatar_shape", contacts_avatar_shape_); setContactsAvatarShape(contacts_avatar_shape_);
|
||||
loadScalar(j, "contacts_list_scale", contacts_list_scale_); setContactsListScale(contacts_list_scale_);
|
||||
loadScalar(j, "animate_avatars", animate_avatars_);
|
||||
loadScalar(j, "scanline_enabled", scanline_enabled_);
|
||||
loadScalar(j, "console_line_accents", console_line_accents_);
|
||||
loadScalar(j, "console_text_color", console_text_color_);
|
||||
loadScalar(j, "console_zoom", console_zoom_);
|
||||
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
|
||||
loadScalar(j, "console_auto_focus", console_auto_focus_);
|
||||
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
|
||||
hidden_addresses_.clear();
|
||||
for (const auto& a : j["hidden_addresses"])
|
||||
@@ -229,18 +253,28 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||
empty_wallet_warning_acked_.clear();
|
||||
for (const auto& w : j["empty_wallet_warning_acked"])
|
||||
if (w.is_string()) empty_wallet_warning_acked_.insert(w.get<std::string>());
|
||||
}
|
||||
loadScalar(j, "encryption_pending", encryption_pending_);
|
||||
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
||||
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||
loadScalar(j, "keep_daemon_running", keep_daemon_running_);
|
||||
loadScalar(j, "stop_external_daemon", stop_external_daemon_);
|
||||
loadScalar(j, "max_connections", max_connections_);
|
||||
loadScalar(j, "stratum_host_enabled", stratum_host_enabled_);
|
||||
loadScalar(j, "stratum_allowip", stratum_allowip_);
|
||||
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
|
||||
const auto& lite = j["lite_wallet"];
|
||||
if (lite.contains("server_selection_mode")) {
|
||||
@@ -287,6 +321,16 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
lite_servers_.push_back(preference);
|
||||
}
|
||||
// Surface newly-shipped default servers (e.g. lite6/lite7) on an existing
|
||||
// install whose saved list predates them. Servers are hidden (see
|
||||
// lite_hidden_servers_), never deleted, so appending a missing default won't
|
||||
// resurrect one the user intentionally removed.
|
||||
for (const auto& def : defaultLiteServers()) {
|
||||
bool present = false;
|
||||
for (const auto& s : lite_servers_)
|
||||
if (s.url == def.url) { present = true; break; }
|
||||
if (!present) lite_servers_.push_back(def);
|
||||
}
|
||||
}
|
||||
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
|
||||
const auto v = lite["rollout_override"].get<std::string>();
|
||||
@@ -448,6 +492,13 @@ bool Settings::save(const std::string& path)
|
||||
j["hidden_chat_cids"] = json::array();
|
||||
for (const auto& c : hidden_chat_cids_)
|
||||
j["hidden_chat_cids"].push_back(c);
|
||||
j["blocked_chat_convs"] = json::array();
|
||||
for (const auto& b : blocked_chat_convs_) {
|
||||
json o;
|
||||
o["cid"] = b.cid;
|
||||
o["name"] = b.name;
|
||||
j["blocked_chat_convs"].push_back(o);
|
||||
}
|
||||
j["chat_emoji_color"] = chat_emoji_color_;
|
||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||
j["chat_bubble_style"] = chat_bubble_style_;
|
||||
@@ -467,11 +518,14 @@ bool Settings::save(const std::string& path)
|
||||
j["balance_layout"] = balance_layout_; // saved as string ID
|
||||
j["portfolio_style"] = portfolio_style_;
|
||||
j["contacts_view_mode"] = contacts_view_mode_;
|
||||
j["contacts_avatar_shape"] = contacts_avatar_shape_;
|
||||
j["contacts_list_scale"] = contacts_list_scale_;
|
||||
j["animate_avatars"] = animate_avatars_;
|
||||
j["scanline_enabled"] = scanline_enabled_;
|
||||
j["console_line_accents"] = console_line_accents_;
|
||||
j["console_text_color"] = console_text_color_;
|
||||
j["console_zoom"] = console_zoom_;
|
||||
j["console_auto_focus"] = console_auto_focus_;
|
||||
j["hidden_addresses"] = json::array();
|
||||
for (const auto& addr : hidden_addresses_)
|
||||
j["hidden_addresses"].push_back(addr);
|
||||
@@ -493,18 +547,26 @@ bool Settings::save(const std::string& path)
|
||||
}
|
||||
j["wizard_completed"] = wizard_completed_;
|
||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||
j["large_wallet_warned"] = large_wallet_warned_;
|
||||
j["empty_wallet_warning_acked"] = json::array();
|
||||
for (const auto& w : empty_wallet_warning_acked_)
|
||||
j["empty_wallet_warning_acked"].push_back(w);
|
||||
j["encryption_pending"] = encryption_pending_;
|
||||
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
||||
j["active_wallet_file"] = active_wallet_file_;
|
||||
j["seed_migration_pending"] = seed_migration_pending_;
|
||||
j["seed_migration_dest"] = seed_migration_dest_;
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||
j["unlock_duration"] = unlock_duration_;
|
||||
j["pin_enabled"] = pin_enabled_;
|
||||
j["keep_daemon_running"] = keep_daemon_running_;
|
||||
j["stop_external_daemon"] = stop_external_daemon_;
|
||||
j["max_connections"] = max_connections_;
|
||||
j["stratum_host_enabled"] = stratum_host_enabled_;
|
||||
j["stratum_allowip"] = stratum_allowip_;
|
||||
{
|
||||
json lite = json::object();
|
||||
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);
|
||||
|
||||
@@ -91,7 +91,8 @@ public:
|
||||
bool showValue = true; // show the converted/fiat value on the card
|
||||
bool show24h = false; // show the 24h % change (live-market bases only)
|
||||
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
|
||||
int sparklineInterval = 0; // 0=minute 1=hour 2=day 3=week 4=month (resample of price history)
|
||||
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
|
||||
// from the daily series, vs the young in-session minute buffer)
|
||||
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
|
||||
// hash (shown only when that wallet is active). New entries are tagged with the current
|
||||
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
|
||||
@@ -143,6 +144,26 @@ public:
|
||||
hidden_chat_cids_.end());
|
||||
}
|
||||
|
||||
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
|
||||
// local history AND suppresses every message for the cid — old and future — until you unblock, at
|
||||
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
|
||||
// "Blocked" list can label the entry (its messages are gone from the local store).
|
||||
struct BlockedChatConv { std::string cid; std::string name; };
|
||||
bool isChatBlocked(const std::string& cid) const {
|
||||
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
|
||||
return false;
|
||||
}
|
||||
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
|
||||
const bool already = isChatBlocked(cid);
|
||||
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
|
||||
else if (!blocked && already)
|
||||
blocked_chat_convs_.erase(
|
||||
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
|
||||
[&](const BlockedChatConv& b) { return b.cid == cid; }),
|
||||
blocked_chat_convs_.end());
|
||||
}
|
||||
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
||||
|
||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||
@@ -226,13 +247,19 @@ public:
|
||||
std::string getBalanceLayout() const { return balance_layout_; }
|
||||
void setBalanceLayout(const std::string& v) { balance_layout_ = v; }
|
||||
|
||||
// Market-tab portfolio row style: 0 = single-line, 1 = two-line, 2 = value-hero. Cycled with
|
||||
// Left/Right arrows on the Market tab (like the Overview layouts).
|
||||
// Market-tab portfolio row style: 0 = Table (borderless grid), 1 = Cards (glass card + Z/T bar),
|
||||
// 2 = Spotlight (hero value). Cycled with Left/Right arrows or set in the Market settings modal.
|
||||
int getPortfolioStyle() const { return portfolio_style_; }
|
||||
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
|
||||
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
|
||||
int getContactsViewMode() const { return contacts_view_mode_; }
|
||||
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
|
||||
// Contacts customization (gear modal): avatar shape + card/list row scale.
|
||||
// Avatar shape: 0 = circle, 1 = rounded square, 2 = full-row-height left tab (rounded-left, flat right).
|
||||
int getContactsAvatarShape() const { return contacts_avatar_shape_; }
|
||||
void setContactsAvatarShape(int v) { contacts_avatar_shape_ = (v < 0 || v > 2) ? 0 : v; }
|
||||
float getContactsListScale() const { return contacts_list_scale_; }
|
||||
void setContactsListScale(float v) { contacts_list_scale_ = std::max(0.8f, std::min(1.5f, v)); }
|
||||
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
|
||||
bool getAnimateAvatars() const { return animate_avatars_; }
|
||||
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
|
||||
@@ -249,6 +276,9 @@ public:
|
||||
void setConsoleTextColor(bool v) { console_text_color_ = v; }
|
||||
float getConsoleZoom() const { return console_zoom_; }
|
||||
void setConsoleZoom(float v) { console_zoom_ = v; }
|
||||
// Auto-place the text cursor in the command box when the Console tab is opened.
|
||||
bool getConsoleAutoFocus() const { return console_auto_focus_; }
|
||||
void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
|
||||
|
||||
// Hidden addresses (addresses hidden from the UI by the user)
|
||||
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
|
||||
@@ -320,6 +350,24 @@ public:
|
||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||
|
||||
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
|
||||
|
||||
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
|
||||
// warning has been dismissed. Keyed per active wallet file so switching to a different empty
|
||||
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
|
||||
bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
|
||||
return empty_wallet_warning_acked_.count(walletFile) > 0;
|
||||
}
|
||||
void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
|
||||
|
||||
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
|
||||
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
|
||||
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
|
||||
bool getEncryptionPending() const { return encryption_pending_; }
|
||||
void setEncryptionPending(bool v) { encryption_pending_ = v; }
|
||||
|
||||
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
|
||||
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
|
||||
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
||||
@@ -343,6 +391,11 @@ public:
|
||||
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
||||
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
||||
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
|
||||
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
|
||||
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -367,6 +420,11 @@ public:
|
||||
// Daemon — maximum peer connections (0 = daemon default)
|
||||
int getMaxConnections() const { return max_connections_; }
|
||||
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
|
||||
// Host a RandomX stratum pool from the node (v1.3.0+ daemons). Empty allow-IP = loopback only (safe).
|
||||
bool getStratumHost() const { return stratum_host_enabled_; }
|
||||
void setStratumHost(bool v) { stratum_host_enabled_ = v; }
|
||||
const std::string& getStratumAllowIp() const { return stratum_allowip_; }
|
||||
void setStratumAllowIp(const std::string& v) { stratum_allowip_ = v; }
|
||||
|
||||
// Lite wallet server selection
|
||||
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
|
||||
@@ -381,6 +439,9 @@ public:
|
||||
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
|
||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
||||
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
||||
// Servers shipped as defaults. Also merged into an existing install's saved list on load
|
||||
// (see Settings::load), so upgrades surface newly-added servers (e.g. lite6/lite7).
|
||||
static std::vector<LiteServerPreference> defaultLiteServers();
|
||||
|
||||
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
||||
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
||||
@@ -521,6 +582,7 @@ private:
|
||||
std::string chat_reply_zaddr_;
|
||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
||||
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
||||
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||
@@ -553,30 +615,39 @@ private:
|
||||
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
|
||||
#endif
|
||||
std::string balance_layout_ = "classic";
|
||||
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
|
||||
int portfolio_style_ = 0; // Market portfolio row style (0 Table / 1 Cards / 2 Spotlight)
|
||||
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
|
||||
int contacts_avatar_shape_ = 0; // 0 = circle, 1 = rounded square, 2 = full-height left tab
|
||||
float contacts_list_scale_ = 1.0f; // card/list row scale (does not affect the table view)
|
||||
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
|
||||
bool scanline_enabled_ = true;
|
||||
bool console_line_accents_ = true; // left color accent bars in console output
|
||||
bool console_text_color_ = true; // per-channel text coloring in console output
|
||||
float console_zoom_ = 1.0f; // console output font zoom factor
|
||||
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
|
||||
std::set<std::string> hidden_addresses_;
|
||||
std::set<std::string> favorite_addresses_;
|
||||
std::map<std::string, AddressMeta> address_meta_;
|
||||
bool wizard_completed_ = false;
|
||||
bool seed_backup_reminded_ = false;
|
||||
bool large_wallet_warned_ = false;
|
||||
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||
bool encryption_pending_ = false;
|
||||
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
||||
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
||||
bool seed_migration_pending_ = false;
|
||||
std::string seed_migration_dest_;
|
||||
std::string seed_migration_temp_dir_;
|
||||
std::string seed_migration_sweep_txid_;
|
||||
std::string seed_migration_sweep_opid_;
|
||||
int auto_lock_timeout_ = 900; // 15 minutes
|
||||
int unlock_duration_ = 600; // 10 minutes
|
||||
bool pin_enabled_ = false;
|
||||
bool keep_daemon_running_ = false;
|
||||
bool stop_external_daemon_ = false;
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
bool stratum_host_enabled_ = false; // host a RandomX stratum pool from the node (v1.3.0+ daemons)
|
||||
std::string stratum_allowip_; // -stratumallowip filter (empty = daemon default: loopback only)
|
||||
|
||||
// Lite wallet server preferences. These are user/server settings only;
|
||||
// wallet secrets, wallet files, and lifecycle state are never stored here.
|
||||
@@ -587,14 +658,7 @@ private:
|
||||
bool lite_persist_selected_server_ = true;
|
||||
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
|
||||
std::string lite_install_id_; // random local-only id; rollout-bucket source
|
||||
std::vector<LiteServerPreference> lite_servers_ = {
|
||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
||||
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
||||
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
||||
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
|
||||
};
|
||||
std::vector<LiteServerPreference> lite_servers_ = defaultLiteServers();
|
||||
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
|
||||
|
||||
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
|
||||
|
||||
@@ -26,6 +26,7 @@ void DaemonController::syncSettings(const config::Settings* settings)
|
||||
if (!settings) return;
|
||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||
daemon_->setStratumHosting(settings->getStratumHost(), settings->getStratumAllowIp());
|
||||
|
||||
std::string walletFile = settings->getActiveWalletFile();
|
||||
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
|
||||
@@ -71,9 +72,11 @@ DaemonController::State DaemonController::state() const
|
||||
return daemon_->getState();
|
||||
}
|
||||
|
||||
const std::string& DaemonController::lastError() const
|
||||
std::string DaemonController::lastError() const
|
||||
{
|
||||
return daemon_->getLastError();
|
||||
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
|
||||
// dangle (bind a reference to that temporary). (M-04 follow-through)
|
||||
return daemon_ ? daemon_->getLastError() : std::string();
|
||||
}
|
||||
|
||||
int DaemonController::crashCount() const
|
||||
@@ -126,6 +129,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setReindexOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
@@ -95,7 +95,7 @@ public:
|
||||
bool externalDaemonDetected() const;
|
||||
void clearExternalDaemonDetected();
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
@@ -108,6 +108,7 @@ public:
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
void setSalvageOnNextStart(bool enabled);
|
||||
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
|
||||
107
src/daemon/daemon_startup_diagnosis.h
Normal file
107
src/daemon/daemon_startup_diagnosis.h
Normal file
@@ -0,0 +1,107 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
|
||||
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
|
||||
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
|
||||
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
|
||||
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
|
||||
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
|
||||
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
|
||||
inline bool blockDbOutputLooksBroken(const std::string& out)
|
||||
{
|
||||
return out.find("Error loading block database") != std::string::npos
|
||||
|| out.find("non-canonical optional discriminant") != std::string::npos
|
||||
|| out.find("Aborted block database rebuild") != std::string::npos
|
||||
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
|
||||
}
|
||||
|
||||
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
|
||||
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
|
||||
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
|
||||
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
|
||||
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
|
||||
inline bool walletAutoRecovered(const std::string& out)
|
||||
{
|
||||
// Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
|
||||
// warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
|
||||
// records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
|
||||
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
|
||||
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
|
||||
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|
||||
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|
||||
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|
||||
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|
||||
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|
||||
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
|
||||
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
|
||||
}
|
||||
|
||||
// True when dragonxd (v1.3.0+) opened the wallet in DEGRADED mode: a wallet.dat that lost its hdchain
|
||||
// record (e.g. an old `-salvagewallet` output) now OPENS — existing keys stay intact and spendable —
|
||||
// instead of aborting, but the daemon can no longer derive NEW HD keys, so z_getnewaddress /
|
||||
// z_shieldcoinbase / a t->z z_sendmany fail with "HD seed not found". The only signal is a startup log
|
||||
// line; pre-1.3.0 daemons never emit it, so this classifier is naturally a no-op against them.
|
||||
inline bool walletOpenedDegraded(const std::string& out)
|
||||
{
|
||||
return out.find("Wallet opened in DEGRADED mode") != std::string::npos;
|
||||
}
|
||||
|
||||
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
|
||||
inline long long parseWalletSalvageBakTs(const std::string& name)
|
||||
{
|
||||
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
|
||||
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
|
||||
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
|
||||
if (mid.empty() || mid.size() > 18) return -1;
|
||||
for (char c : mid) if (c < '0' || c > '9') return -1;
|
||||
long long ts = 0;
|
||||
for (char c : mid) ts = ts * 10 + (c - '0');
|
||||
return ts;
|
||||
}
|
||||
|
||||
// Most RECENT salvage backup (highest timestamp). Pure, testable.
|
||||
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
|
||||
{
|
||||
long long best = -1;
|
||||
std::string bestName;
|
||||
for (const auto& f : filenames) {
|
||||
const long long ts = parseWalletSalvageBakTs(f);
|
||||
if (ts > best) { best = ts; bestName = f; }
|
||||
}
|
||||
return bestName;
|
||||
}
|
||||
|
||||
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
|
||||
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
|
||||
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
|
||||
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
|
||||
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
|
||||
{
|
||||
std::string bestName;
|
||||
unsigned long long bestSize = 0;
|
||||
long long bestTs = -1;
|
||||
for (const auto& fp : files) {
|
||||
const long long ts = parseWalletSalvageBakTs(fp.first);
|
||||
if (ts < 0) continue;
|
||||
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
|
||||
bestSize = fp.second; bestTs = ts; bestName = fp.first;
|
||||
}
|
||||
}
|
||||
return bestName;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -205,11 +205,13 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
||||
"-ac_reward=300000000",
|
||||
"-ac_blocktime=36",
|
||||
"-ac_private=1",
|
||||
"-addnode=node.dragonx.is",
|
||||
// Seeds: seed.dragonx.is is a round-robin A record over the live seed set (self-updates without
|
||||
// a wallet release), with node1/node5 as static fallbacks — mirrors the daemon's own vSeeds.
|
||||
// Plain -addnode hostname resolution works on EVERY daemon version, and is load-bearing for
|
||||
// pre-1.3.0 daemons whose built-in peer discovery was broken (they rely on these to find peers).
|
||||
"-addnode=seed.dragonx.is",
|
||||
"-addnode=node1.dragonx.is",
|
||||
"-addnode=node2.dragonx.is",
|
||||
"-addnode=node3.dragonx.is",
|
||||
"-addnode=node4.dragonx.is",
|
||||
"-addnode=node5.dragonx.is",
|
||||
"-experimentalfeatures",
|
||||
"-developerencryptwallet",
|
||||
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
|
||||
@@ -224,12 +226,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
||||
void EmbeddedDaemon::setState(State s, const std::string& message)
|
||||
{
|
||||
state_ = s;
|
||||
if (!message.empty()) {
|
||||
if (s == State::Error) {
|
||||
last_error_ = message;
|
||||
}
|
||||
if (!message.empty() && s == State::Error) {
|
||||
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
|
||||
last_error_ = message;
|
||||
}
|
||||
|
||||
|
||||
if (state_callback_) {
|
||||
state_callback_(s, message);
|
||||
}
|
||||
@@ -488,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
return false;
|
||||
}
|
||||
external_daemon_detected_ = false;
|
||||
|
||||
// A previous dragonxd can release the RPC port well before it releases the datadir
|
||||
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
|
||||
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
|
||||
// on data directory"; the crash monitor reports that generically and, three times in
|
||||
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
|
||||
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
|
||||
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
|
||||
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
|
||||
{
|
||||
constexpr int kDatadirLockWaitPollMs = 100;
|
||||
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||
bool stillRunning = false;
|
||||
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||
stillRunning = isDaemonProcessRunning();
|
||||
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||
stillRunning = isDaemonProcessRunning();
|
||||
}
|
||||
}
|
||||
const StartLockGateDecision gate =
|
||||
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
|
||||
if (!gate.proceed) {
|
||||
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
|
||||
setState(State::Error, gate.errorMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setState(State::Starting, "Looking for dragonxd binary...");
|
||||
|
||||
@@ -516,6 +545,15 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
||||
}
|
||||
|
||||
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
|
||||
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
|
||||
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
|
||||
// daemon serves loopback only (safe default); a subnet opens it to that LAN.
|
||||
if (stratum_enabled_) {
|
||||
args.push_back("-stratum");
|
||||
if (!stratum_allowip_.empty()) args.push_back("-stratumallowip=" + stratum_allowip_);
|
||||
}
|
||||
|
||||
// Active wallet file (multi-wallet). The daemon loads <datadir>/<name>. Only pass it for a
|
||||
// non-default name so the common case's command line is unchanged; skip during an isolated
|
||||
// start (seed migration manages its own throwaway wallet).
|
||||
@@ -543,6 +581,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-rescan");
|
||||
}
|
||||
|
||||
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
|
||||
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
|
||||
// chain above (and implies its own wallet rescan). One-shot, consumed here.
|
||||
if (reindex_on_next_start_.exchange(false)) {
|
||||
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
|
||||
args.push_back("-reindex");
|
||||
}
|
||||
|
||||
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
|
||||
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
|
||||
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
|
||||
@@ -557,8 +603,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
override_extra_args_.clear();
|
||||
|
||||
if (!startProcess(daemon_path, args)) {
|
||||
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
|
||||
setState(State::Error, "Failed to start dragonxd process");
|
||||
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||
// string that would then be all getLastError()/the UI ever sees.
|
||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
||||
: last_error_;
|
||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||
setState(State::Error, detail);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -579,12 +631,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
// Forward declaration — defined after startProcess
|
||||
static DWORD findProcessByName(const char* name);
|
||||
|
||||
// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
|
||||
// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
|
||||
static std::string quoteWinArg(const std::string& arg) {
|
||||
if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
|
||||
std::string out = "\"";
|
||||
for (size_t i = 0; ; ++i) {
|
||||
size_t nbs = 0;
|
||||
while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
|
||||
if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
|
||||
if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
|
||||
else { out.append(nbs, '\\'); out.push_back(arg[i]); }
|
||||
}
|
||||
out.push_back('"');
|
||||
return out;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
|
||||
{
|
||||
// Build command line
|
||||
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
|
||||
std::string cmd = "\"" + binary_path + "\"";
|
||||
for (const auto& arg : args) {
|
||||
cmd += " " + arg;
|
||||
cmd += " " + quoteWinArg(arg);
|
||||
}
|
||||
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
|
||||
|
||||
@@ -632,7 +700,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
debug_log_path_.c_str(), debug_log_offset_);
|
||||
}
|
||||
|
||||
// Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE).
|
||||
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
|
||||
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
|
||||
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
|
||||
// window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
|
||||
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
|
||||
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
|
||||
STARTUPINFOA si;
|
||||
@@ -642,7 +713,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
si.dwFlags = STARTF_USESHOWWINDOW;
|
||||
si.wShowWindow = SW_HIDE;
|
||||
ZeroMemory(&pi, sizeof(pi));
|
||||
|
||||
|
||||
char* cmd_line = _strdup(cmd.c_str());
|
||||
BOOL success = CreateProcessA(
|
||||
NULL,
|
||||
@@ -650,7 +721,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE,
|
||||
CREATE_NEW_CONSOLE,
|
||||
CREATE_NO_WINDOW,
|
||||
NULL,
|
||||
work_dir.c_str(),
|
||||
&si,
|
||||
@@ -745,11 +816,21 @@ void EmbeddedDaemon::drainOutput()
|
||||
}
|
||||
|
||||
size_t currentSize = static_cast<size_t>(fileSize.QuadPart);
|
||||
// Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps
|
||||
// only the tail once it has grown large), so the file can become SMALLER than where we left off.
|
||||
// Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead
|
||||
// of the freshly-truncated file and we read NOTHING for the whole session — no block height (status
|
||||
// bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling
|
||||
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
|
||||
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
|
||||
if (currentSize < debug_log_offset_) {
|
||||
debug_log_offset_ = 0;
|
||||
}
|
||||
if (currentSize <= debug_log_offset_) {
|
||||
CloseHandle(hFile);
|
||||
return; // No new data
|
||||
}
|
||||
|
||||
|
||||
// Seek to where we left off
|
||||
LARGE_INTEGER seekPos;
|
||||
seekPos.QuadPart = static_cast<LONGLONG>(debug_log_offset_);
|
||||
@@ -962,18 +1043,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Self-pipe used purely as an exec-success/failure handshake, separate from
|
||||
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
|
||||
// closes the write end for free (parent reads EOF); on execv() failure the
|
||||
// child writes errno here, so the parent learns synchronously instead of
|
||||
// reporting State::Running for a child that never became dragonxd. We use
|
||||
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
|
||||
// macOS, which has no pipe2().
|
||||
int execpipe[2];
|
||||
if (pipe(execpipe) == -1) {
|
||||
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return false;
|
||||
}
|
||||
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == -1) {
|
||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
close(execpipe[0]);
|
||||
close(execpipe[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// Child process
|
||||
close(pipefd[0]); // Close read end
|
||||
close(pipefd[0]); // Close read end of the stdout pipe
|
||||
close(execpipe[0]); // Child only writes the exec-status pipe
|
||||
|
||||
// Put child in its own process group so we can kill the entire
|
||||
// group later (including dragonxd spawned by a wrapper script).
|
||||
@@ -1040,22 +1141,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
execv(binary_path.c_str(), argv.data());
|
||||
}
|
||||
|
||||
// If we get here, exec failed
|
||||
fprintf(stderr, "execv failed: %s\n", strerror(errno));
|
||||
// If we get here, execv() failed — the child never became dragonxd.
|
||||
// Capture errno before fprintf/strerror can clobber it, report it to
|
||||
// the parent over the exec-status pipe (EINTR-safe), then exit.
|
||||
int exec_errno = errno;
|
||||
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
|
||||
ssize_t w;
|
||||
do {
|
||||
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
|
||||
} while (w < 0 && errno == EINTR);
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
// Parent process
|
||||
close(pipefd[1]); // Close write end
|
||||
close(pipefd[1]); // Close our copy of the stdout write end
|
||||
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
|
||||
|
||||
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
|
||||
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
|
||||
int child_errno = 0;
|
||||
size_t got = 0;
|
||||
char* ep = reinterpret_cast<char*>(&child_errno);
|
||||
for (;;) {
|
||||
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
|
||||
if (n == 0) break; // EOF: exec succeeded
|
||||
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
|
||||
got += static_cast<size_t>(n);
|
||||
if (got >= sizeof(child_errno)) break; // full errno: exec failed
|
||||
}
|
||||
close(execpipe[0]);
|
||||
|
||||
if (got >= sizeof(child_errno)) {
|
||||
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
|
||||
// the already-dead zombie here — monitorProcess() is only started after
|
||||
// this function returns true, so there is no competing reaper.
|
||||
close(pipefd[0]);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||
" — not executable or wrong architecture";
|
||||
return false;
|
||||
}
|
||||
|
||||
stdout_fd_ = pipefd[0];
|
||||
|
||||
// Also set process group from parent side (race with child's setpgid)
|
||||
setpgid(pid, pid);
|
||||
|
||||
|
||||
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
|
||||
// just closes the fork/exec race window. A failure here is not fatal to
|
||||
// startup, so we log rather than abort.
|
||||
if (setpgid(pid, pid) != 0) {
|
||||
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
|
||||
}
|
||||
|
||||
// Set non-blocking
|
||||
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
||||
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
|
||||
process_pid_ = pid;
|
||||
return true;
|
||||
}
|
||||
@@ -1135,17 +1275,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
|
||||
bool EmbeddedDaemon::isRunning() const
|
||||
{
|
||||
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
|
||||
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
|
||||
// Calling waitpid() from this method too (as it used to, and this is invoked
|
||||
// from the UI thread nearly every frame) meant whichever thread reaped the
|
||||
// child's exit first consumed the status; if isRunning() won that race,
|
||||
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
|
||||
// code / the State::Error transition were all silently lost. Mirrors the
|
||||
// fix already in XmrigManager::isRunning().
|
||||
if (process_pid_ <= 0) return false;
|
||||
|
||||
int status;
|
||||
pid_t result = waitpid(process_pid_, &status, WNOHANG);
|
||||
|
||||
if (result == 0) {
|
||||
// Still running
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
const State s = state_.load(std::memory_order_relaxed);
|
||||
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
|
||||
// isRunning() while state_ == Stopping — before the process has actually
|
||||
// terminated — and must keep seeing "alive" to wait/escalate correctly.
|
||||
return (s == State::Running || s == State::Stopping);
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::drainOutput()
|
||||
|
||||
@@ -79,7 +79,9 @@ public:
|
||||
/**
|
||||
* @brief Get last error message
|
||||
*/
|
||||
const std::string& getLastError() const { return last_error_; }
|
||||
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
|
||||
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
|
||||
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||
|
||||
/**
|
||||
* @brief Get dragonxd process output (thread-safe copy)
|
||||
@@ -180,6 +182,7 @@ public:
|
||||
* @brief Set maximum peer connections (0 = use daemon default)
|
||||
*/
|
||||
void setMaxConnections(int v) { max_connections_ = v; }
|
||||
void setStratumHosting(bool enabled, const std::string& allowIp) { stratum_enabled_ = enabled; stratum_allowip_ = allowIp; }
|
||||
|
||||
/**
|
||||
* @brief Request a blockchain rescan on the next daemon start
|
||||
@@ -206,6 +209,13 @@ public:
|
||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||
|
||||
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
|
||||
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
|
||||
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
|
||||
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
|
||||
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
|
||||
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||
@@ -235,6 +245,32 @@ public:
|
||||
*/
|
||||
static bool isDaemonProcessRunning();
|
||||
|
||||
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||
struct StartLockGateDecision {
|
||||
bool proceed = true; // false => bail before spawning
|
||||
const char* errorMessage = ""; // set (a string literal) when proceed == false
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
|
||||
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
|
||||
* override) are exempt — they run their own throwaway datadir+port and can coexist
|
||||
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
|
||||
* so it is directly unit-testable; defined inline so tests need only this header.
|
||||
*/
|
||||
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
|
||||
bool isolatedOverride,
|
||||
bool stillRunningAfterWait)
|
||||
{
|
||||
if (skipPortCheck || isolatedOverride) return {true, ""};
|
||||
if (stillRunningAfterWait) {
|
||||
return {false,
|
||||
"A previous dragonxd is still shutting down and holding the data "
|
||||
"directory lock. Retrying shortly…"};
|
||||
}
|
||||
return {true, ""};
|
||||
}
|
||||
|
||||
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
||||
static bool tcpPortInUse(int port);
|
||||
|
||||
@@ -253,6 +289,7 @@ private:
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
std::atomic<bool> external_daemon_detected_{false};
|
||||
std::string last_error_;
|
||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||
mutable std::mutex output_mutex_; // protects process_output_
|
||||
std::string process_output_;
|
||||
StateCallback state_callback_;
|
||||
@@ -275,11 +312,14 @@ private:
|
||||
std::atomic<bool> should_stop_{false};
|
||||
std::set<std::string> debug_categories_;
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
|
||||
std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
|
||||
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
|
||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||
|
||||
@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
|
||||
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
||||
const std::string dataDir = base + "/DRAGONX";
|
||||
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
|
||||
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
|
||||
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
|
||||
// one — refuse and point the user at it rather than silently destroying it.
|
||||
if (fs::exists(dataDir + "/wallet.dat")) {
|
||||
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
|
||||
"\nResume or cancel it first. If you are certain its funds are already in your main "
|
||||
"wallet, delete that folder and try again.";
|
||||
return r;
|
||||
}
|
||||
fs::remove_all(base, ec);
|
||||
fs::create_directories(dataDir, ec);
|
||||
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
||||
@@ -136,6 +146,14 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
}
|
||||
|
||||
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
|
||||
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
|
||||
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
|
||||
if (!r.ok && !r.seedPhrase.empty()) {
|
||||
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
|
||||
r.seedPhrase.clear();
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
|
||||
cli.disconnect();
|
||||
temp.stop(20000);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
|
||||
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
|
||||
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
|
||||
|
||||
#include "xmrig_manager.h"
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/pool_registry.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
|
||||
// libcurl write callback
|
||||
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
auto* s = static_cast<std::string*>(userdata);
|
||||
s->append(static_cast<char*>(ptr), sz * n);
|
||||
return sz * n;
|
||||
const size_t add = sz * n;
|
||||
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
|
||||
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
|
||||
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
|
||||
if (s->size() + add > kMaxStatsBytes) return 0;
|
||||
s->append(static_cast<char*>(ptr), add);
|
||||
return add;
|
||||
}
|
||||
|
||||
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
|
||||
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
|
||||
static bool hostLooksInternal(const std::string& host) {
|
||||
if (host.empty() || host == "localhost") return true;
|
||||
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
|
||||
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
|
||||
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
|
||||
const int second = std::atoi(host.c_str() + 4);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
|
||||
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
|
||||
host.rfind("fe80", 0) == 0) return true;
|
||||
}
|
||||
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
|
||||
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
XmrigManager::XmrigManager() = default;
|
||||
|
||||
XmrigManager::~XmrigManager() {
|
||||
should_stop_ = true;
|
||||
if (isRunning()) {
|
||||
stop(3000);
|
||||
}
|
||||
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
|
||||
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Fallback: system PATH
|
||||
// Fallback: system PATH — windowless so it never flashes a console.
|
||||
#ifdef _WIN32
|
||||
FILE* f = _popen("where xmrig.exe 2>nul", "r");
|
||||
std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
|
||||
#else
|
||||
FILE* f = popen("which xmrig 2>/dev/null", "r");
|
||||
#endif
|
||||
if (f) {
|
||||
char line[512];
|
||||
if (fgets(line, sizeof(line), f)) {
|
||||
std::string s(line);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
|
||||
s.pop_back();
|
||||
if (!s.empty() && fs::exists(s)) {
|
||||
#ifdef _WIN32
|
||||
_pclose(f);
|
||||
#else
|
||||
pclose(f);
|
||||
#endif
|
||||
return s;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
_pclose(f);
|
||||
#else
|
||||
pclose(f);
|
||||
std::string out = util::Platform::runHiddenCapture("which xmrig");
|
||||
#endif
|
||||
{
|
||||
std::string s = out;
|
||||
const auto nl = s.find_first_of("\r\n"); // first line only
|
||||
if (nl != std::string::npos) s.erase(nl);
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
|
||||
if (!s.empty() && fs::exists(s)) return s;
|
||||
}
|
||||
|
||||
return {};
|
||||
@@ -208,22 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
try {
|
||||
fs::create_directories(fs::path(outPath).parent_path());
|
||||
std::ofstream ofs(outPath);
|
||||
if (!ofs.is_open()) {
|
||||
last_error_ = "Cannot write xmrig config: " + outPath;
|
||||
const std::string dumped = j.dump(4);
|
||||
#ifndef _WIN32
|
||||
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
|
||||
// in a world/group-readable file — even for a local attacker who opened it in the old
|
||||
// create-then-chmod window and held the fd open across the chmod. (L-01)
|
||||
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
ofs << j.dump(4);
|
||||
ofs.close();
|
||||
|
||||
#ifndef _WIN32
|
||||
// 0600 permissions — only owner can read/write
|
||||
chmod(outPath.c_str(), 0600);
|
||||
#endif
|
||||
size_t off = 0;
|
||||
bool wrote = true;
|
||||
while (off < dumped.size()) {
|
||||
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
|
||||
if (nw <= 0) { wrote = false; break; }
|
||||
off += static_cast<size_t>(nw);
|
||||
}
|
||||
::close(fd);
|
||||
if (!wrote) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
std::ofstream ofs(outPath, std::ios::trunc);
|
||||
if (!ofs.is_open()) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
ofs << dumped;
|
||||
ofs.close();
|
||||
return true;
|
||||
#endif
|
||||
} catch (const std::exception& e) {
|
||||
last_error_ = std::string("Config write error: ") + e.what();
|
||||
setLastError(std::string("Config write error: ") + e.what());
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -235,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
bool XmrigManager::start(const Config& cfg) {
|
||||
if (state_ == State::Running || state_ == State::Starting) {
|
||||
last_error_ = "Already running";
|
||||
setLastError("Already running");
|
||||
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
state_ = State::Starting;
|
||||
should_stop_ = false;
|
||||
last_error_.clear();
|
||||
setLastError(std::string());
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
process_output_.clear();
|
||||
}
|
||||
stats_ = PoolStats{};
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mutex_);
|
||||
stats_ = PoolStats{};
|
||||
}
|
||||
|
||||
// Extract pool hostname for stats API queries
|
||||
{
|
||||
@@ -264,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
// Find binary
|
||||
std::string binary = findXmrigBinary();
|
||||
if (binary.empty()) {
|
||||
last_error_ = "xmrig binary not found";
|
||||
setLastError("xmrig binary not found");
|
||||
state_ = State::Error;
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
|
||||
return false;
|
||||
@@ -292,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start monitor thread
|
||||
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
|
||||
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
|
||||
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
|
||||
state_ = State::Running;
|
||||
DEBUG_LOGF("[INFO] XmrigManager: started\n");
|
||||
@@ -367,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
||||
|
||||
HANDLE hRead = nullptr, hWrite = nullptr;
|
||||
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
|
||||
last_error_ = "CreatePipe failed";
|
||||
setLastError("CreatePipe failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -399,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
||||
char errBuf[256];
|
||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
|
||||
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -450,14 +494,14 @@ void XmrigManager::drainOutput() {
|
||||
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
last_error_ = "pipe() failed";
|
||||
setLastError("pipe() failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
last_error_ = "fork() failed";
|
||||
setLastError("fork() failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
@@ -628,7 +672,7 @@ void XmrigManager::monitorProcess() {
|
||||
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
|
||||
state_ = State::Error;
|
||||
last_error_ = "xmrig process exited unexpectedly";
|
||||
setLastError("xmrig process exited unexpectedly");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -639,7 +683,7 @@ void XmrigManager::monitorProcess() {
|
||||
if (ret == process_pid_ || ret < 0) {
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
|
||||
state_ = State::Error;
|
||||
last_error_ = "xmrig process exited unexpectedly";
|
||||
setLastError("xmrig process exited unexpectedly");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -779,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
||||
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
||||
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
||||
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
|
||||
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
|
||||
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
|
||||
// registry statsUrl and are exempt. (M-09)
|
||||
if (!known && hostLooksInternal(pool_host_)) return;
|
||||
const std::string url = known ? known->statsUrl
|
||||
: ("https://" + pool_host_ + "/api/stats");
|
||||
|
||||
@@ -857,25 +906,18 @@ void XmrigManager::startVersionDetection()
|
||||
std::thread([]() {
|
||||
const std::string bin = findXmrigBinary();
|
||||
std::string ver;
|
||||
if (!bin.empty()) {
|
||||
const std::string cmd = "\"" + bin + "\" --version 2>&1";
|
||||
#ifdef _WIN32
|
||||
FILE* fp = _popen(cmd.c_str(), "r");
|
||||
#else
|
||||
FILE* fp = popen(cmd.c_str(), "r");
|
||||
#endif
|
||||
if (fp) {
|
||||
std::string out;
|
||||
char buf[256];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
|
||||
#ifdef _WIN32
|
||||
_pclose(fp);
|
||||
#else
|
||||
pclose(fp);
|
||||
#endif
|
||||
ver = parseMinerVersion(out);
|
||||
}
|
||||
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
|
||||
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
|
||||
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
|
||||
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
|
||||
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
|
||||
const bool binShellSafe =
|
||||
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
|
||||
if (binShellSafe) {
|
||||
// Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
|
||||
const std::string cmd = "\"" + bin + "\" --version";
|
||||
const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
|
||||
if (!out.empty()) ver = parseMinerVersion(out);
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
||||
g_installed_ver = ver;
|
||||
|
||||
@@ -86,8 +86,10 @@ public:
|
||||
bool isRunning() const;
|
||||
State getState() const { return state_.load(std::memory_order_relaxed); }
|
||||
|
||||
const PoolStats& getStats() const { return stats_; }
|
||||
const std::string& getLastError() const { return last_error_; }
|
||||
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
|
||||
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
|
||||
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
|
||||
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||
|
||||
/// Thread count requested at start() — available immediately, unlike
|
||||
/// PoolStats::threads_active which requires an API response.
|
||||
@@ -156,11 +158,14 @@ private:
|
||||
void monitorProcess();
|
||||
void drainOutput();
|
||||
void appendOutput(const char* data, size_t len);
|
||||
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
|
||||
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
|
||||
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
|
||||
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
||||
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
std::string last_error_;
|
||||
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
|
||||
|
||||
mutable std::mutex output_mutex_;
|
||||
std::string process_output_;
|
||||
|
||||
@@ -46,25 +46,31 @@ bool AddressBook::load()
|
||||
entries_.clear();
|
||||
|
||||
if (j.contains("entries") && j["entries"].is_array()) {
|
||||
size_t skipped = 0;
|
||||
for (const auto& entry : j["entries"]) {
|
||||
AddressBookEntry e;
|
||||
e.label = entry.value("label", "");
|
||||
e.address = entry.value("address", "");
|
||||
e.notes = entry.value("notes", "");
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
e.avatar = entry.value("avatar", "");
|
||||
|
||||
if (!e.address.empty()) {
|
||||
entries_.push_back(e);
|
||||
}
|
||||
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
|
||||
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
|
||||
if (!entry.is_object()) { ++skipped; continue; }
|
||||
try {
|
||||
AddressBookEntry e;
|
||||
e.label = entry.value("label", "");
|
||||
e.address = entry.value("address", "");
|
||||
e.notes = entry.value("notes", "");
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
e.avatar = entry.value("avatar", "");
|
||||
if (!e.address.empty()) entries_.push_back(e);
|
||||
} catch (const std::exception&) { ++skipped; }
|
||||
}
|
||||
if (skipped > 0)
|
||||
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
|
||||
}
|
||||
|
||||
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||
++revision_;
|
||||
return true;
|
||||
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Error loading address book: %s\n", e.what());
|
||||
return false;
|
||||
@@ -116,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
|
||||
}
|
||||
|
||||
entries_.push_back(entry);
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -131,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
||||
}
|
||||
|
||||
entries_[index] = entry;
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -141,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
|
||||
}
|
||||
|
||||
entries_.erase(entries_.begin() + index);
|
||||
++revision_;
|
||||
return save();
|
||||
}
|
||||
|
||||
@@ -154,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
||||
e.scope = scopeId;
|
||||
++rescoped;
|
||||
}
|
||||
if (rescoped > 0) save();
|
||||
if (rescoped > 0) { ++revision_; save(); }
|
||||
return rescoped;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -121,11 +122,18 @@ public:
|
||||
*/
|
||||
size_t size() const { return entries_.size(); }
|
||||
|
||||
/**
|
||||
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
|
||||
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
|
||||
* address change that keeps size() constant — still invalidates them.
|
||||
*/
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
/**
|
||||
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
|
||||
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
|
||||
*/
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
|
||||
|
||||
/**
|
||||
* @brief Check if empty
|
||||
@@ -134,6 +142,7 @@ public:
|
||||
|
||||
private:
|
||||
std::vector<AddressBookEntry> entries_;
|
||||
std::uint64_t revision_ = 0;
|
||||
std::string file_path_;
|
||||
};
|
||||
|
||||
|
||||
37
src/data/seed_migration_resume.h
Normal file
37
src/data/seed_migration_resume.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
|
||||
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
|
||||
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
|
||||
// connectivity and switches on the result. See src/app_network.cpp.
|
||||
namespace dragonx {
|
||||
|
||||
enum class MigrationResume {
|
||||
Intro, // no pending migration → start fresh at the intro
|
||||
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
|
||||
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
|
||||
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
|
||||
};
|
||||
|
||||
// Decide where reopening the migration dialog lands.
|
||||
//
|
||||
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
|
||||
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
|
||||
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
|
||||
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
|
||||
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
|
||||
// adopt) — never trapping the user.
|
||||
inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||
bool haveDest,
|
||||
const std::string& sweepTxid,
|
||||
const std::string& sweepOpid,
|
||||
bool connected) {
|
||||
if (!pending || !haveDest) return MigrationResume::Intro;
|
||||
if (!sweepTxid.empty()) return MigrationResume::Confirming;
|
||||
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
|
||||
return MigrationResume::SweepGate;
|
||||
}
|
||||
|
||||
} // namespace dragonx
|
||||
@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
|
||||
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
|
||||
bestBalance = addresses[i].spendableBalance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,17 @@ namespace dragonx {
|
||||
*/
|
||||
struct AddressInfo {
|
||||
std::string address;
|
||||
double balance = 0.0;
|
||||
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
|
||||
std::string type; // "shielded" or "transparent"
|
||||
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
||||
|
||||
// For display
|
||||
std::string label;
|
||||
|
||||
|
||||
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
|
||||
// brace-init of the leading fields (used in tests) still compiles.
|
||||
double spendableBalance = 0.0;
|
||||
|
||||
// Derived
|
||||
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
||||
bool isShielded() const { return type == "shielded"; }
|
||||
@@ -252,12 +256,18 @@ struct WalletState {
|
||||
// Sync status
|
||||
SyncInfo sync;
|
||||
|
||||
// Balances (named to match UI usage)
|
||||
double privateBalance = 0.0; // shielded balance
|
||||
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
|
||||
// user's own pending change and don't crater during an unconfirmed send.
|
||||
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
|
||||
double transparentBalance = 0.0;
|
||||
double totalBalance = 0.0;
|
||||
double unconfirmedBalance = 0.0;
|
||||
|
||||
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
|
||||
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
|
||||
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
|
||||
double spendablePrivateBalance = 0.0;
|
||||
double spendableTransparentBalance = 0.0;
|
||||
double spendableTotalBalance = 0.0;
|
||||
|
||||
// Aliases for backward compatibility
|
||||
double& shielded_balance = privateBalance;
|
||||
double& transparent_balance = transparentBalance;
|
||||
@@ -302,6 +312,7 @@ struct WalletState {
|
||||
|
||||
// Timestamps for refresh logic
|
||||
int64_t last_balance_update = 0;
|
||||
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
|
||||
int64_t last_tx_update = 0;
|
||||
int64_t last_peer_update = 0;
|
||||
int64_t last_mining_update = 0;
|
||||
@@ -325,6 +336,7 @@ struct WalletState {
|
||||
sync = SyncInfo{};
|
||||
privateBalance = transparentBalance = totalBalance = 0.0;
|
||||
unconfirmedBalance = 0.0;
|
||||
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
|
||||
encrypted = false;
|
||||
locked = false;
|
||||
unlocked_until = 0;
|
||||
@@ -335,6 +347,15 @@ struct WalletState {
|
||||
transactions.clear();
|
||||
peers.clear();
|
||||
bannedPeers.clear();
|
||||
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
|
||||
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
|
||||
mining = MiningInfo{};
|
||||
pool_mining = PoolMiningState{};
|
||||
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
|
||||
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
|
||||
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
|
||||
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
|
||||
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
|
||||
}
|
||||
|
||||
// Rebuild combined addresses list from z/t lists
|
||||
|
||||
39
src/embedded/logo_dragonx_svg.h
Normal file
39
src/embedded/logo_dragonx_svg.h
Normal file
@@ -0,0 +1,39 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Embedded source of the DragonX mark (res/img/logos/logo_dragonx.svg). Kept as a string so the logo
|
||||
// is available in every build (dev + portable single-file) with no resource-pipeline or file dependency.
|
||||
// It is rasterized + recolored per theme at runtime (see util/svg_texture.*). Two fills: the crimson
|
||||
// body (.cls-2 #d82652) becomes the theme accent; the white detail (.cls-1 #fff) becomes the light tone.
|
||||
|
||||
#pragma once
|
||||
|
||||
// Global `embedded` namespace to match the generated embedded resources (embedded::ui_toml_data, etc.).
|
||||
namespace embedded {
|
||||
|
||||
inline constexpr const char* kLogoDragonXSvg = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #d82652;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
|
||||
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
|
||||
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>)SVG";
|
||||
|
||||
} // namespace embedded
|
||||
157
src/main.cpp
157
src/main.cpp
@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
|
||||
}
|
||||
}
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#if defined(__has_include)
|
||||
# if __has_include(<execinfo.h>)
|
||||
# include <execinfo.h>
|
||||
# define DRAGONX_HAVE_BACKTRACE 1
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
|
||||
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
|
||||
static char g_crashLogPath[1024] = {0};
|
||||
|
||||
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
|
||||
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
|
||||
static void PosixCrashHandler(int sig)
|
||||
{
|
||||
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
|
||||
if (fd >= 0) {
|
||||
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
|
||||
put("\n=== CRASH: signal ");
|
||||
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
|
||||
if (v == 0) { num[i++] = '0'; }
|
||||
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
|
||||
while (t > 0) num[i++] = tmp[--t]; }
|
||||
num[i] = '\n';
|
||||
ssize_t nn = write(fd, num, i + 1); (void)nn;
|
||||
#ifdef DRAGONX_HAVE_BACKTRACE
|
||||
void* frames[64];
|
||||
int nframes = backtrace(frames, 64);
|
||||
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
|
||||
#endif
|
||||
put("=== END CRASH ===\n");
|
||||
close(fd);
|
||||
}
|
||||
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
|
||||
signal(sig, SIG_DFL);
|
||||
raise(sig);
|
||||
}
|
||||
|
||||
static void installPosixCrashHandler(const std::string& crashLogPath)
|
||||
{
|
||||
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
|
||||
struct sigaction sa;
|
||||
std::memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = PosixCrashHandler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
|
||||
sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
}
|
||||
#endif // !_WIN32
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||
{
|
||||
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(odDir, ec);
|
||||
std::string odErr;
|
||||
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
|
||||
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
|
||||
// Windows log redirect below isn't set up yet — report loudly before any setup.
|
||||
std::fprintf(stderr, "%s\n", odErr.c_str());
|
||||
#ifdef _WIN32
|
||||
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
|
||||
#endif
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Redirect stdout/stderr to a log file so diagnostic output is visible
|
||||
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
|
||||
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
|
||||
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
|
||||
// file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
|
||||
{
|
||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
||||
const std::string logPath =
|
||||
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
||||
dragonx::util::Logger::instance().init(logPath);
|
||||
}
|
||||
|
||||
#if !defined(_WIN32)
|
||||
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
|
||||
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
|
||||
{
|
||||
const std::string crashPath =
|
||||
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
|
||||
installPosixCrashHandler(crashPath);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
|
||||
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
|
||||
// above so the two writers don't interleave/contend on one file.
|
||||
{
|
||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
|
||||
freopen(logPath.c_str(), "w", stdout);
|
||||
freopen(logPath.c_str(), "a", stderr);
|
||||
}
|
||||
@@ -1178,6 +1263,36 @@ int main(int argc, char* argv[])
|
||||
SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale));
|
||||
}
|
||||
|
||||
// DEV/TEST hook (dormant unless the env is set): DRAGONX_WIN_GEOM="WxH" forces an exact window
|
||||
// size, placing it on the largest display that can hold it and bypassing the primary-monitor
|
||||
// clamp. Lets a headless WSLg sweep render at sizes wider than the 1280 primary (e.g. 2560x1440
|
||||
// on the mirrored 4K/1440p outputs). Needs the x11 backend so absolute positioning takes effect.
|
||||
int wantW = 0, wantH = 0;
|
||||
if (const char* geom = std::getenv("DRAGONX_WIN_GEOM"))
|
||||
sscanf(geom, "%dx%d", &wantW, &wantH);
|
||||
if (wantW > 0 && wantH > 0) {
|
||||
int count = 0;
|
||||
SDL_DisplayID* disp = SDL_GetDisplays(&count);
|
||||
SDL_DisplayID best = 0; SDL_Rect bestUsable{0, 0, 0, 0};
|
||||
for (int i = 0; i < count; ++i) {
|
||||
SDL_Rect u;
|
||||
if (!SDL_GetDisplayUsableBounds(disp[i], &u)) continue;
|
||||
bool fits = (u.w >= wantW && u.h >= wantH);
|
||||
bool bestFits = (bestUsable.w >= wantW && bestUsable.h >= wantH);
|
||||
// Prefer a display that fits; among those, the smallest; else the largest available.
|
||||
if ((fits && !bestFits) ||
|
||||
(fits && bestFits && (long)u.w * u.h < (long)bestUsable.w * bestUsable.h) ||
|
||||
(!fits && !bestFits && (long)u.w * u.h > (long)bestUsable.w * bestUsable.h)) {
|
||||
bestUsable = u; best = disp[i];
|
||||
}
|
||||
}
|
||||
if (disp) SDL_free(disp);
|
||||
if (best) {
|
||||
SDL_SetWindowMinimumSize(window, 320, 240);
|
||||
SDL_SetWindowPosition(window, bestUsable.x + 10, bestUsable.y + 10);
|
||||
SDL_SetWindowSize(window, wantW, wantH);
|
||||
}
|
||||
} else {
|
||||
// Clamp to the current display's work area — runs on EVERY startup (this clamp used to live
|
||||
// inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the
|
||||
// window off-screen or bigger than the screen on a same-DPI cold start).
|
||||
@@ -1196,6 +1311,7 @@ int main(int argc, char* argv[])
|
||||
DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n",
|
||||
curW, curH, newW, newH, currentDpiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale);
|
||||
@@ -1384,6 +1500,7 @@ int main(int argc, char* argv[])
|
||||
// WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H.
|
||||
int dpiSettleFrames = 0;
|
||||
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
|
||||
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
|
||||
{
|
||||
float s = dragonx::ui::material::Typography::instance().getDpiScale();
|
||||
int w = 0, h = 0;
|
||||
@@ -1467,6 +1584,7 @@ int main(int argc, char* argv[])
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||
waitEvent.window.windowID == SDL_GetWindowID(window)) {
|
||||
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||
app.refreshNow();
|
||||
}
|
||||
// Handle DPI change that arrived while idle (same logic as poll loop)
|
||||
@@ -1596,6 +1714,7 @@ int main(int argc, char* argv[])
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||
event.window.windowID == SDL_GetWindowID(window)) {
|
||||
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||
app.refreshNow();
|
||||
}
|
||||
// Handle DPI/display scale changes (e.g. window dragged to a
|
||||
@@ -1616,13 +1735,28 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Check if window is minimized
|
||||
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
|
||||
// Still check shouldQuit while minimized to avoid hang
|
||||
if (app.shouldQuit()) {
|
||||
running = false;
|
||||
}
|
||||
SDL_Delay(10);
|
||||
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
|
||||
// refresh scheduler, keeps the daemon connection/reconnect + sync status live) but skip the
|
||||
// ImGui frame + GPU present since nothing is visible. app.update() only reads
|
||||
// GetIO()/GetTime()/IsAnyItemActive() — all valid outside a frame — so it's safe without a
|
||||
// NewFrame; feed it a real-clock DeltaTime (NewFrame, which normally sets it, is skipped) and
|
||||
// let app.update() clamp it. Throttled to ~5 Hz so CPU stays near-idle (refresh cadences are
|
||||
// seconds-scale). shouldQuit is still checked so a quit request never hangs behind minimize.
|
||||
Uint64 nowMs = SDL_GetTicks();
|
||||
float minDelta = (minimizedLastTickMs == 0) ? 0.001f
|
||||
: (float)(nowMs - minimizedLastTickMs) / 1000.0f;
|
||||
minimizedLastTickMs = nowMs;
|
||||
ImGui::GetIO().DeltaTime = (minDelta > 0.0f) ? minDelta : 0.001f;
|
||||
try {
|
||||
app.update();
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[Main] minimized app.update() threw: %s\n", e.what());
|
||||
} catch (...) {}
|
||||
if (app.shouldQuit()) running = false;
|
||||
SDL_Delay(200);
|
||||
continue;
|
||||
}
|
||||
minimizedLastTickMs = 0; // visible again — reset the minimized clock
|
||||
|
||||
// --- PerfLog: begin frame ---
|
||||
dragonx::util::PerfLog::instance().beginFrame();
|
||||
@@ -2042,6 +2176,7 @@ int main(int argc, char* argv[])
|
||||
// deadlocks waiting for detached pthreads. On Linux, static
|
||||
// destructors and atexit handlers can also block. _Exit() bypasses
|
||||
// all of that.
|
||||
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_Exit(0);
|
||||
|
||||
@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
|
||||
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
|
||||
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
|
||||
#endif
|
||||
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
|
||||
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
|
||||
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (rebuildRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
|
||||
if (!extractResource(rebuildRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
|
||||
return success;
|
||||
}
|
||||
|
||||
std::string ensureWalletRebuildHelperExtracted()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (!res || res->size == 0) return {};
|
||||
#ifdef _WIN32
|
||||
const char sep = '\\';
|
||||
#else
|
||||
const char sep = '/';
|
||||
#endif
|
||||
const std::string dir = getDaemonDirectory();
|
||||
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (!extractResource(res, dest)) return {};
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||
return dest;
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string getDaemonDirectory()
|
||||
{
|
||||
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
|
||||
|
||||
@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
|
||||
// caller should stop the daemon first. Returns true if all present resources were written.
|
||||
bool reextractBundledDaemon();
|
||||
|
||||
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
|
||||
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
|
||||
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
|
||||
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
|
||||
std::string ensureWalletRebuildHelperExtracted();
|
||||
|
||||
// Resource names
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
|
||||
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
|
||||
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
|
||||
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/xmrig_updater.h" // util::sha256Hex
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <shlobj.h>
|
||||
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
|
||||
return resources::getDaemonDirectory();
|
||||
}
|
||||
|
||||
bool Connection::verifySaplingParams()
|
||||
namespace {
|
||||
|
||||
std::string joinParamPath(const std::string& dir, const std::string& file) {
|
||||
#ifdef _WIN32
|
||||
return dir + "\\" + file;
|
||||
#else
|
||||
return dir + "/" + file;
|
||||
#endif
|
||||
}
|
||||
|
||||
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
|
||||
std::string paramStatLine(const std::string& path) {
|
||||
std::error_code ec;
|
||||
auto sz = fs::file_size(path, ec);
|
||||
if (ec) return {};
|
||||
auto mtime = fs::last_write_time(path, ec);
|
||||
long long ticks = ec ? 0 :
|
||||
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
|
||||
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
|
||||
}
|
||||
|
||||
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return false;
|
||||
std::streamsize sz = f.tellg();
|
||||
if (sz <= 0) return false;
|
||||
f.seekg(0, std::ios::beg);
|
||||
std::vector<char> buf(static_cast<size_t>(sz));
|
||||
if (!f.read(buf.data(), sz)) return false;
|
||||
std::string got = util::sha256Hex(buf.data(), buf.size());
|
||||
return !got.empty() && got == expectedHex;
|
||||
}
|
||||
|
||||
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
|
||||
// in list order, from the last successful hash check.
|
||||
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
|
||||
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
|
||||
std::ifstream f(markerPath);
|
||||
if (!f) return false;
|
||||
std::vector<std::string> lines;
|
||||
std::string l;
|
||||
while (std::getline(f, l)) lines.push_back(l);
|
||||
return lines == expected;
|
||||
}
|
||||
|
||||
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
|
||||
std::ofstream f(markerPath, std::ios::trunc);
|
||||
if (!f) return;
|
||||
for (const auto& l : lines) f << l << "\n";
|
||||
}
|
||||
|
||||
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
|
||||
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
|
||||
// Keep in sync if the params are ever rotated.
|
||||
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
|
||||
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
|
||||
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Connection::verifySaplingParamsIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||
{
|
||||
std::string params_dir = getSaplingParamsDir();
|
||||
if (params_dir.empty()) {
|
||||
if (dir.empty()) {
|
||||
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string spend_path = params_dir + "\\sapling-spend.params";
|
||||
std::string output_path = params_dir + "\\sapling-output.params";
|
||||
#else
|
||||
std::string spend_path = params_dir + "/sapling-spend.params";
|
||||
std::string output_path = params_dir + "/sapling-output.params";
|
||||
#endif
|
||||
|
||||
bool spend_exists = fs::exists(spend_path);
|
||||
bool output_exists = fs::exists(output_path);
|
||||
|
||||
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
|
||||
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
|
||||
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
|
||||
|
||||
return spend_exists && output_exists;
|
||||
if (digests.empty()) return false;
|
||||
|
||||
// 1) Every param must exist.
|
||||
std::vector<std::string> paths;
|
||||
paths.reserve(digests.size());
|
||||
for (const auto& d : digests) {
|
||||
std::string p = joinParamPath(dir, d.first);
|
||||
if (!fs::exists(p)) {
|
||||
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
|
||||
return false;
|
||||
}
|
||||
paths.push_back(std::move(p));
|
||||
}
|
||||
|
||||
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
|
||||
// the previous successful hash instead of re-hashing ~48MB on every startup.
|
||||
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
|
||||
std::vector<std::string> current;
|
||||
current.reserve(paths.size());
|
||||
for (const auto& p : paths) current.push_back(paramStatLine(p));
|
||||
if (saplingMarkerMatches(markerPath, current)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
|
||||
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
|
||||
// here instead of being handed to the daemon and failing later on a shielded operation.
|
||||
for (size_t i = 0; i < paths.size(); ++i) {
|
||||
if (!paramHashMatches(paths[i], digests[i].second)) {
|
||||
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
|
||||
paths[i].c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Record the verified state so later startups take the fast path.
|
||||
writeSaplingMarker(markerPath, current);
|
||||
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Connection::verifySaplingParams()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::string>> digests;
|
||||
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
} else if (key == "rpcallowplaintext") {
|
||||
config.allow_plaintext_remote = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
{
|
||||
ConnectionConfig config;
|
||||
|
||||
// Ensure data directory exists
|
||||
// Ensure the data directory exists. Use the non-throwing helper and report any failure
|
||||
// via config.dir_error so callers can surface it — the old throwing create_directories()
|
||||
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
|
||||
// callers (read-only home, permission denied, etc.).
|
||||
std::string data_dir = getDefaultDataDir();
|
||||
if (!fs::exists(data_dir)) {
|
||||
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
|
||||
fs::create_directories(data_dir);
|
||||
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
|
||||
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
|
||||
return config; // data dir unusable — bail early with dir_error set
|
||||
}
|
||||
|
||||
// Try to find DRAGONX.conf
|
||||
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
||||
return true;
|
||||
}
|
||||
|
||||
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||
static bool isExactIPv4Loopback(const std::string& host)
|
||||
{
|
||||
int octets = 0, value = 0, digits = 0;
|
||||
bool firstIs127 = false;
|
||||
for (size_t i = 0; i <= host.size(); ++i) {
|
||||
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||
if (c == '.') {
|
||||
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||
if (octets == 0) firstIs127 = (value == 127);
|
||||
++octets;
|
||||
value = 0;
|
||||
digits = 0;
|
||||
} else if (c >= '0' && c <= '9') {
|
||||
value = value * 10 + (c - '0');
|
||||
++digits;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return octets == 4 && firstIs127;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
isExactIPv4Loopback(lowered);
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||
return config.allow_plaintext_remote;
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
@@ -324,11 +456,11 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
file << "exportdir=" << dataDir << "\n";
|
||||
file << "experimentalfeatures=1\n";
|
||||
file << "developerencryptwallet=1\n";
|
||||
file << "addnode=node.dragonx.is\n";
|
||||
// Round-robin DNS seed (self-updating) + static fallbacks; mirrors the daemon's vSeeds and keeps
|
||||
// pre-1.3.0 daemons (broken peer discovery) able to find peers. Works on every daemon version.
|
||||
file << "addnode=seed.dragonx.is\n";
|
||||
file << "addnode=node1.dragonx.is\n";
|
||||
file << "addnode=node2.dragonx.is\n";
|
||||
file << "addnode=node3.dragonx.is\n";
|
||||
file << "addnode=node4.dragonx.is\n";
|
||||
file << "addnode=node5.dragonx.is\n";
|
||||
|
||||
file.close();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
@@ -27,7 +29,11 @@ struct ConnectionConfig {
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||
// should surface it and abort the connect rather than proceeding blindly.
|
||||
std::string dir_error;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +75,14 @@ public:
|
||||
*/
|
||||
static bool verifySaplingParams();
|
||||
|
||||
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
|
||||
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
|
||||
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
|
||||
// pinned production digests and getSaplingParamsDir().
|
||||
static bool verifySaplingParamsIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
/**
|
||||
* @brief Get the Sapling params directory
|
||||
*/
|
||||
@@ -119,6 +133,11 @@ public:
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||
// means the connect is refused.
|
||||
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
|
||||
@@ -25,9 +25,11 @@ namespace {
|
||||
|
||||
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
|
||||
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
|
||||
void scrubJsonSecrets(nlohmann::json& j) {
|
||||
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
|
||||
template <typename J>
|
||||
void scrubJsonSecrets(J& j) {
|
||||
if (j.is_string()) {
|
||||
auto& s = j.get_ref<std::string&>();
|
||||
auto& s = j.template get_ref<std::string&>();
|
||||
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
||||
} else if (j.is_object() || j.is_array()) {
|
||||
for (auto& el : j) scrubJsonSecrets(el);
|
||||
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
|
||||
// Callback for libcurl to write response data
|
||||
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t totalSize = size * nmemb;
|
||||
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
|
||||
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
|
||||
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
|
||||
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
|
||||
userp->append((char*)contents, totalSize);
|
||||
return totalSize;
|
||||
}
|
||||
@@ -140,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
|
||||
{
|
||||
}
|
||||
|
||||
RPCClient::~RPCClient() = default;
|
||||
RPCClient::~RPCClient() {
|
||||
// Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is
|
||||
// still destroyed normally afterward (curl cleanup unchanged). (L-04)
|
||||
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size());
|
||||
}
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password)
|
||||
@@ -160,6 +170,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
|
||||
// "user:password" temporary (std::string does not zero its buffer on destruction).
|
||||
std::string credentials = user + ":" + password;
|
||||
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04)
|
||||
auth_ = util::base64_encode(credentials);
|
||||
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
|
||||
|
||||
@@ -187,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
|
||||
std::string auth_header = "Authorization: Basic " + auth_;
|
||||
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
|
||||
if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04)
|
||||
|
||||
// Configure curl
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
|
||||
@@ -202,6 +214,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
||||
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
||||
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
|
||||
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
|
||||
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
|
||||
@@ -289,6 +305,7 @@ void RPCClient::disconnect()
|
||||
curl_slist_free_all(impl_->headers);
|
||||
impl_->headers = nullptr;
|
||||
}
|
||||
if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04)
|
||||
}
|
||||
|
||||
json RPCClient::makePayload(const std::string& method, const json& params)
|
||||
@@ -508,14 +525,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
}
|
||||
|
||||
auto& result = oj["result"];
|
||||
std::string out;
|
||||
if (result.is_null()) {
|
||||
return "null";
|
||||
out = "null";
|
||||
} else if (result.is_string()) {
|
||||
// Return the raw string (not JSON-encoded) — caller wraps as needed
|
||||
return result.get<std::string>();
|
||||
out = result.get<std::string>();
|
||||
} else {
|
||||
return result.dump(4);
|
||||
out = result.dump(4);
|
||||
}
|
||||
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
|
||||
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
|
||||
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
|
||||
// the caller's to manage.
|
||||
scrubJsonSecrets(oj);
|
||||
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
@@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
||||
{
|
||||
if (!unspent.is_array()) return;
|
||||
|
||||
std::map<std::string, double> balances;
|
||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||
std::map<std::string, double> total;
|
||||
std::map<std::string, double> spendable;
|
||||
for (const auto& output : unspent) {
|
||||
auto address = readOptional<std::string>(output, "address");
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (address && amount) balances[*address] += *amount;
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (!address || !amount) continue;
|
||||
total[*address] += *amount;
|
||||
auto conf = readOptional<int>(output, "confirmations");
|
||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
||||
}
|
||||
|
||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
|
||||
for (auto& info : addresses) {
|
||||
auto balance = balances.find(info.address);
|
||||
if (balance != balances.end()) info.balance = balance->second;
|
||||
auto t = total.find(info.address);
|
||||
auto s = spendable.find(info.address);
|
||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +261,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
|
||||
}
|
||||
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
|
||||
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||
{
|
||||
CoreRefreshResult result;
|
||||
result.balanceOk = balanceOk && totalBalance.is_object();
|
||||
@@ -258,6 +270,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||
}
|
||||
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
|
||||
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
|
||||
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
|
||||
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
|
||||
}
|
||||
|
||||
result.blockchainOk = blockOk && blockInfo.is_object();
|
||||
if (result.blockchainOk) {
|
||||
@@ -274,19 +291,17 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
||||
{
|
||||
json totalBalance;
|
||||
json spendableBalance;
|
||||
json blockInfo;
|
||||
bool balanceOk = false;
|
||||
bool blockOk = false;
|
||||
double balanceScanMs = 0.0;
|
||||
|
||||
if (includeBalance) {
|
||||
try {
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array());
|
||||
balanceOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Balance error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
|
||||
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
|
||||
// scan keeps sync detection from being delayed behind (or, under contention, starved by) that
|
||||
// scan — the failure mode where the wallet kept reading "synced" while actually falling behind,
|
||||
// so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch.
|
||||
try {
|
||||
blockInfo = rpc.call("getblockchaininfo", json::array());
|
||||
blockOk = true;
|
||||
@@ -294,7 +309,39 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
|
||||
// If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
|
||||
// the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
|
||||
// anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
|
||||
// end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the
|
||||
// sticky-behind latch engage promptly the first time the node falls behind.
|
||||
bool behind = false;
|
||||
if (blockOk && blockInfo.is_object()) {
|
||||
const long long b = blockInfo.value("blocks", 0LL);
|
||||
const long long lc = blockInfo.value("longestchain", 0LL);
|
||||
if (lc > 0 && b < lc - 2) behind = true;
|
||||
}
|
||||
|
||||
if (includeBalance && !behind) {
|
||||
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
|
||||
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
|
||||
// (balanceRefreshDue()), keeping balance scans from starving block connection.
|
||||
const auto balanceStart = std::chrono::steady_clock::now();
|
||||
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
|
||||
balanceOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Balance error: %s\n", e.what());
|
||||
}
|
||||
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
|
||||
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
|
||||
} catch (...) {}
|
||||
balanceScanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - balanceStart).count();
|
||||
}
|
||||
|
||||
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||
result.balanceScanMs = balanceScanMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
@@ -426,16 +473,32 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
|
||||
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
||||
|
||||
const auto& data = parsed["dragonx-2"];
|
||||
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
|
||||
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
|
||||
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
|
||||
// which the outer catch turns into "no price update at all", so read null-tolerantly and
|
||||
// keep the valid usd/btc rather than discarding the whole refresh.
|
||||
auto num = [&data](const char* key, double def) {
|
||||
auto it = data.find(key);
|
||||
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
|
||||
};
|
||||
PriceRefreshResult result;
|
||||
result.market.price_usd = data.value("usd", 0.0);
|
||||
result.market.price_btc = data.value("btc", 0.0);
|
||||
result.market.change_24h = data.value("usd_24h_change", 0.0);
|
||||
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
|
||||
result.market.market_cap = data.value("usd_market_cap", 0.0);
|
||||
result.market.price_usd = num("usd", 0.0);
|
||||
result.market.price_btc = num("btc", 0.0);
|
||||
result.market.change_24h = num("usd_24h_change", 0.0);
|
||||
result.market.volume_24h = num("usd_24h_vol", 0.0);
|
||||
result.market.market_cap = num("usd_market_cap", 0.0);
|
||||
|
||||
char buf[64];
|
||||
std::tm* tm = std::localtime(&fetchedAt);
|
||||
if (tm && std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm) > 0) {
|
||||
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
||||
// reentrant variant into a local tm (matches the rest of the codebase).
|
||||
std::tm tmv{};
|
||||
#ifdef _WIN32
|
||||
localtime_s(&tmv, &fetchedAt);
|
||||
#else
|
||||
localtime_r(&fetchedAt, &tmv);
|
||||
#endif
|
||||
if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv) > 0) {
|
||||
result.market.last_updated = buf;
|
||||
}
|
||||
return result;
|
||||
@@ -557,6 +620,10 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
const AddressRefreshSnapshot& snapshot)
|
||||
{
|
||||
AddressRefreshResult result;
|
||||
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
|
||||
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
|
||||
// caller's adaptive throttle so the address poll can't starve block connection.
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
|
||||
try {
|
||||
json zList = rpc.call("z_listaddresses", json::array());
|
||||
@@ -592,18 +659,39 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("z_listunspent", json::array());
|
||||
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
|
||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
|
||||
// of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
|
||||
// dPoW-clamped to 1 and understates it.
|
||||
if (unspent.is_array()) {
|
||||
result.unspentNotes.reserve(unspent.size());
|
||||
for (const auto& nz : unspent) {
|
||||
if (!nz.is_object()) continue;
|
||||
UnspentNoteLite lite;
|
||||
lite.amount = nz.value("amount", 0.0);
|
||||
lite.locked = nz.value("locked", false);
|
||||
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
|
||||
? nz["rawconfirmations"].get<int>()
|
||||
: nz.value("confirmations", 0);
|
||||
result.unspentNotes.push_back(lite);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try {
|
||||
json balance = rpc.call("z_getbalance", json::array({info.address}));
|
||||
if (!balance.is_null()) info.balance = balance.get<double>();
|
||||
try { // display total (minconf=0, includes pending change)
|
||||
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||
if (!total.is_null()) info.balance = total.get<double>();
|
||||
} catch (...) {}
|
||||
try { // spendable (minconf=1); degrade to the display value on old daemons
|
||||
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
|
||||
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||
} catch (...) { info.spendableBalance = info.balance; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,15 +700,18 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("listunspent", json::array());
|
||||
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
|
||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||
}
|
||||
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -812,6 +903,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
result.blockHeight = currentBlockHeight;
|
||||
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
||||
result.shieldedScanHeights = snapshot.shieldedScanHeights;
|
||||
// Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
|
||||
// daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
|
||||
// routine full history rescan can't starve block connection on a large wallet.
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
|
||||
std::set<std::string> knownTxids;
|
||||
HushChatMemoOutputMap hushChatReceivedOutputs;
|
||||
@@ -992,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
}
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1011,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
? &hushChatReceivedOutputs
|
||||
: nullptr;
|
||||
|
||||
// Index result.transactions by (txid, type) once so the two replace-loops below
|
||||
// can find-and-replace in O(1) instead of a nested linear scan over the full
|
||||
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
|
||||
// holds the index of the FIRST occurrence of each key (preserving the linear
|
||||
// scan's break-on-first-match), and is kept in sync on every append so a later
|
||||
// entry in the same cycle still finds an earlier appended one — exactly as the
|
||||
// re-scanned vector did before.
|
||||
auto txKey = [](const TransactionInfo& tx) {
|
||||
return tx.txid + '\x1f' + tx.type;
|
||||
};
|
||||
std::unordered_map<std::string, std::size_t> byKey;
|
||||
byKey.reserve(result.transactions.size());
|
||||
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
|
||||
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
|
||||
}
|
||||
|
||||
try {
|
||||
std::set<std::string> recentTxids;
|
||||
std::vector<TransactionInfo> recentTransactions;
|
||||
@@ -1018,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||
|
||||
for (auto& recent : recentTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == recent.txid && existing.type == recent.type) {
|
||||
existing = recent;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
auto it = byKey.find(txKey(recent));
|
||||
if (it != byKey.end()) {
|
||||
result.transactions[it->second] = recent;
|
||||
} else {
|
||||
byKey.emplace(txKey(recent), result.transactions.size());
|
||||
result.transactions.push_back(std::move(recent));
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(recent));
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||
@@ -1052,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
snapshot.miningAddresses,
|
||||
hushChatReceivedOutputsPtr);
|
||||
for (auto& scanned : scannedTransactions) {
|
||||
bool replaced = false;
|
||||
for (auto& existing : result.transactions) {
|
||||
if (existing.txid == scanned.txid && existing.type == scanned.type) {
|
||||
existing = scanned;
|
||||
replaced = true;
|
||||
break;
|
||||
}
|
||||
auto it = byKey.find(txKey(scanned));
|
||||
if (it != byKey.end()) {
|
||||
result.transactions[it->second] = scanned;
|
||||
} else {
|
||||
byKey.emplace(txKey(scanned), result.transactions.size());
|
||||
result.transactions.push_back(std::move(scanned));
|
||||
}
|
||||
if (!replaced) result.transactions.push_back(std::move(scanned));
|
||||
}
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
++result.shieldedAddressesScanned;
|
||||
@@ -1094,12 +1203,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
||||
std::set<std::string> reported;
|
||||
for (const auto& op : result) {
|
||||
if (!op.is_object()) continue;
|
||||
std::string opid = op.value("id", std::string());
|
||||
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
|
||||
// type, which would abort the whole poll (and wedge it for the session — see the call site).
|
||||
if (!op.contains("id") || !op["id"].is_string()) continue;
|
||||
std::string opid = op["id"].get<std::string>();
|
||||
if (opid.empty()) continue;
|
||||
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
||||
reported.insert(opid);
|
||||
|
||||
std::string status = op.value("status", std::string());
|
||||
std::string status = (op.contains("status") && op["status"].is_string())
|
||||
? op["status"].get<std::string>() : std::string();
|
||||
if (status == "success") {
|
||||
parsed.doneOpids.push_back(opid);
|
||||
parsed.anySuccess = true;
|
||||
@@ -1177,6 +1290,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
||||
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
||||
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
||||
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
|
||||
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
|
||||
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
|
||||
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
|
||||
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
|
||||
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
|
||||
state.last_balance_update = updatedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,12 @@ public:
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
|
||||
std::optional<double> spendableTransparentBalance;
|
||||
std::optional<double> spendableTotalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
@@ -108,6 +111,7 @@ public:
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -143,9 +147,27 @@ public:
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
// Minimal per-note view of a z_listunspent entry — just what a downstream consumer needs to derive
|
||||
// spendable-note budgets without re-scanning. Kept UI/feature-agnostic (no chat specifics here).
|
||||
struct UnspentNoteLite {
|
||||
double amount = 0.0; // note value, DRGX
|
||||
bool locked = false; // tied up by an in-flight send
|
||||
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
std::vector<AddressInfo> shieldedAddresses;
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
// False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the
|
||||
// lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the
|
||||
// empty-wallet warning) must not trust a 0 count unless this is true.
|
||||
bool addressListOk = true;
|
||||
// Wall-clock spent in the address scan (dominated by z_listunspent — O(mapWallet), holds the
|
||||
// daemon's cs_main). Lets the caller throttle how often it polls (addressRefreshDue()).
|
||||
double scanMs = 0.0;
|
||||
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
|
||||
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
|
||||
std::vector<UnspentNoteLite> unspentNotes;
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
@@ -197,6 +219,9 @@ public:
|
||||
std::size_t shieldedAddressCount = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
bool shieldedScanComplete = true;
|
||||
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
|
||||
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
|
||||
double scanMs = 0.0;
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
@@ -227,6 +252,7 @@ public:
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
const nlohmann::json& spendableBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#include "wallet_security_controller.h"
|
||||
#include "../util/secure_vault.h"
|
||||
#include "../util/address_validation.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
|
||||
|
||||
bool WalletSecurityController::isViewingKey(const std::string& key)
|
||||
{
|
||||
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
|
||||
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
|
||||
return key.rfind("zxview", 0) == 0;
|
||||
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
|
||||
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
|
||||
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
|
||||
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
|
||||
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
|
||||
const std::string hrp = util::bech32Hrp(key);
|
||||
return hrp == "zivks" // mainnet
|
||||
|| hrp == "zivktestsapling" // testnet
|
||||
|| hrp == "zivkregtestsapling"; // regtest
|
||||
}
|
||||
|
||||
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
||||
{
|
||||
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
|
||||
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
|
||||
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
||||
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
||||
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
|
||||
if (key.size() >= 51 && key.size() <= 52 &&
|
||||
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
|
||||
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
|
||||
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
|
||||
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
|
||||
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
|
||||
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
|
||||
std::vector<std::uint8_t> payload;
|
||||
if (util::decodeBase58Check(key, payload) &&
|
||||
(payload.size() == 33 || payload.size() == 34) &&
|
||||
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
|
||||
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ public:
|
||||
std::size_t minLength = 4);
|
||||
static KeyKind classifyAddress(const std::string& address);
|
||||
static KeyKind classifyPrivateKey(const std::string& key);
|
||||
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
|
||||
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
|
||||
static bool isViewingKey(const std::string& key);
|
||||
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
||||
static bool isRecognizedPrivateKey(const std::string& key);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "theme_effects.h"
|
||||
#include "low_spec.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../layout.h"
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -58,6 +59,7 @@ void ThemeEffects::beginFrame() {
|
||||
|
||||
void ThemeEffects::loadFromTheme() {
|
||||
auto& S = schema::UI();
|
||||
const float dp = Layout::dpiScale();
|
||||
auto eff = [&](const char* name) {
|
||||
return S.drawElement("effects", name);
|
||||
};
|
||||
@@ -98,7 +100,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
// ---- Shimmer ----
|
||||
shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f;
|
||||
shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f);
|
||||
shimmer_.width = eff("shimmer-width").sizeOr(80.0f);
|
||||
shimmer_.width = eff("shimmer-width").sizeOr(80.0f) * dp;
|
||||
shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f);
|
||||
shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f);
|
||||
// Shimmer color: read from the schema's color resolver
|
||||
@@ -124,7 +126,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f);
|
||||
glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f);
|
||||
glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f);
|
||||
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f);
|
||||
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f) * dp;
|
||||
auto glowColorElem = eff("glow-pulse-color");
|
||||
if (!glowColorElem.color.empty()) {
|
||||
glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255));
|
||||
@@ -136,7 +138,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f;
|
||||
edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f);
|
||||
edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f);
|
||||
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f);
|
||||
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f) * dp;
|
||||
edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f);
|
||||
auto edgeColorElem = eff("edge-trace-color");
|
||||
if (!edgeColorElem.color.empty()) {
|
||||
@@ -149,7 +151,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f;
|
||||
ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f);
|
||||
ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f);
|
||||
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f);
|
||||
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f) * dp;
|
||||
ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f);
|
||||
auto emberColorElem = eff("ember-rise-color");
|
||||
if (!emberColorElem.color.empty()) {
|
||||
@@ -160,8 +162,12 @@ void ThemeEffects::loadFromTheme() {
|
||||
|
||||
// ---- Gradient Border Shift ----
|
||||
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f;
|
||||
// Opt-in: also draw the shifting border on every glass panel (not just the
|
||||
// active nav button). Off by default so themes that only want the button
|
||||
// accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero.
|
||||
gradient_border_.panels = eff("gradient-border-panels").sizeOr(0.0f) > 0.5f;
|
||||
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
|
||||
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
|
||||
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f) * dp;
|
||||
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
|
||||
auto gbColorA = eff("gradient-border-color-a");
|
||||
if (!gbColorA.color.empty()) {
|
||||
@@ -190,7 +196,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f);
|
||||
sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f);
|
||||
sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f);
|
||||
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f);
|
||||
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f) * dp;
|
||||
sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f);
|
||||
sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f);
|
||||
sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f);
|
||||
@@ -512,11 +518,15 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
// ============================================================================
|
||||
|
||||
void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding) const {
|
||||
float rounding, float phaseOffset,
|
||||
float alphaMul) const {
|
||||
if (!enabled_ || !gradient_border_.enabled) return;
|
||||
|
||||
// Smooth sinusoidal oscillation between color A and color B
|
||||
float phase = std::sin(time_ * gradient_border_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||
// Smooth sinusoidal oscillation between color A and color B.
|
||||
// phaseOffset shifts where in the cycle this element sits so a wall of
|
||||
// panels reads like veins at different depths rather than one pulse.
|
||||
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
|
||||
* 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||
|
||||
// Extract RGBA from both colors and lerp
|
||||
RGB ca = unpackRGB(gradient_border_.colorA);
|
||||
@@ -525,7 +535,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p
|
||||
int r = ca.r + (int)((cb.r - ca.r) * phase);
|
||||
int g = ca.g + (int)((cb.g - ca.g) * phase);
|
||||
int b = ca.b + (int)((cb.b - ca.b) * phase);
|
||||
int a = scaledAlpha(gradient_border_.alpha, bgOpacity_);
|
||||
int a = scaledAlpha(gradient_border_.alpha * alphaMul, bgOpacity_);
|
||||
|
||||
// Draw the shifting border
|
||||
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a),
|
||||
@@ -686,6 +696,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const {
|
||||
if (!enabled_ || !ember_rise_.enabled) return;
|
||||
|
||||
const float dp = Layout::dpiScale();
|
||||
float w = pMax.x - pMin.x;
|
||||
float h = pMax.y - pMin.y;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
@@ -699,7 +710,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
|
||||
// Deterministic pseudo-random x position per particle
|
||||
// Simple hash: sin of large prime multiples
|
||||
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
|
||||
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f; // gentle sway
|
||||
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f * dp; // gentle sway
|
||||
|
||||
float x = pMin.x + w * xHash + xDrift;
|
||||
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
|
||||
@@ -737,6 +748,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
|
||||
void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
||||
if (!enabled_ || !ember_rise_.enabled) return;
|
||||
|
||||
const float dp = Layout::dpiScale();
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
float vpW = vp->WorkSize.x;
|
||||
float vpH = vp->WorkSize.y;
|
||||
@@ -757,7 +769,7 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
||||
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
|
||||
xHash = xHash - (int)xHash; // fractional part
|
||||
if (xHash < 0) xHash += 1.0f;
|
||||
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f;
|
||||
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f * dp;
|
||||
|
||||
float x = vpX + vpW * xHash + xDrift;
|
||||
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
|
||||
@@ -896,6 +908,22 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding) const {
|
||||
if (!enabled_ || effects::isLowSpecMode()) return;
|
||||
|
||||
// Gradient border on panels — a slow color-shifting outline that hugs the
|
||||
// panel's rounded corners (drawn via AddRect, so it follows the rounding
|
||||
// exactly — no polygonal corners). Position-based phase offset makes each
|
||||
// panel drift like a vein at a different depth; softer than the active
|
||||
// nav button (alphaMul 0.6) so a screenful of panels stays calm.
|
||||
if (gradient_border_.enabled && gradient_border_.panels) {
|
||||
float w = pMax.x - pMin.x;
|
||||
float h = pMax.y - pMin.y;
|
||||
if (w > 80 && h > 40) { // skip small panels
|
||||
float posKey = (pMin.x * 0.0073f + pMin.y * 0.0137f);
|
||||
posKey = posKey - (int)posKey; // fractional 0..1
|
||||
if (posKey < 0) posKey += 1.0f;
|
||||
drawGradientBorderShift(dl, pMin, pMax, rounding, posKey, 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Edge trace on panels — use position-based phase offset so each
|
||||
// panel's tracer is at a different position around the border
|
||||
if (edge_trace_.enabled) {
|
||||
|
||||
@@ -69,9 +69,12 @@ public:
|
||||
void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding) const;
|
||||
|
||||
/// Draw a border that shifts between two colors over time (gem-like)
|
||||
/// Draw a border that shifts between two colors over time (gem-like).
|
||||
/// phaseOffset (0..1) shifts this element's point in the color cycle so
|
||||
/// many panels don't pulse in unison; alphaMul scales the whole effect.
|
||||
void drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding) const;
|
||||
float rounding, float phaseOffset = 0.0f,
|
||||
float alphaMul = 1.0f) const;
|
||||
|
||||
/// Draw ember particles that rise from an element (fire theme)
|
||||
void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const;
|
||||
@@ -186,6 +189,7 @@ private:
|
||||
|
||||
struct GradientBorderConfig {
|
||||
bool enabled = false;
|
||||
bool panels = false; ///< also draw on glass panels, not just the active nav button
|
||||
float speed = 0.15f; ///< full color shift cycles per second
|
||||
float thickness = 1.5f; ///< border line thickness in pixels
|
||||
float alpha = 0.6f; ///< peak alpha
|
||||
|
||||
@@ -173,6 +173,24 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels",
|
||||
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
|
||||
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
|
||||
|
||||
// Overall content-column cap: the max width a tab's content occupies before it is centered in wider
|
||||
// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default —
|
||||
// requested so large windows don't leave a big empty gutter on the right). Set a positive
|
||||
// ui.toml [layout] content-max-width to re-enable a centered readable column.
|
||||
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); }
|
||||
|
||||
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
|
||||
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
|
||||
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
|
||||
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
|
||||
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
|
||||
struct CardBox { float width; float offsetX; };
|
||||
inline CardBox mainComposeCardBox(float availW) {
|
||||
float cap = kContentMaxWidth();
|
||||
float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width
|
||||
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
|
||||
}
|
||||
|
||||
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
|
||||
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }
|
||||
|
||||
|
||||
@@ -116,6 +116,26 @@ inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warni
|
||||
// Convenience Functions for Common Patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Theme-aware translucent overlay for tracks / hover fills / dividers.
|
||||
*
|
||||
* A raw white overlay (IM_COL32(255,255,255,a)) reads on dark skins but vanishes
|
||||
* on light/pastel skins (white-on-white). This picks a dark overlay on light
|
||||
* themes and a white overlay on dark themes so the alpha reads either way.
|
||||
* (Self-contained luminance check so colors.h stays free of draw_helpers.h.)
|
||||
*
|
||||
* @param alpha 0-255 opacity of the overlay
|
||||
*/
|
||||
inline ImU32 SurfaceOverlay(int alpha)
|
||||
{
|
||||
ImU32 bg = Background();
|
||||
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
bool light = (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
|
||||
return light ? IM_COL32(0, 0, 0, alpha) : IM_COL32(255, 255, 255, alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get color with applied state overlay
|
||||
*
|
||||
|
||||
@@ -57,6 +57,21 @@ inline ImU32 ReadableError() {
|
||||
return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
|
||||
}
|
||||
|
||||
// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
|
||||
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
|
||||
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
|
||||
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
|
||||
if (text.empty() || !font || maxWidth <= 0.0f) return text;
|
||||
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
|
||||
const int n = static_cast<int>(text.size());
|
||||
for (int f = n / 2; f >= 3; --f) {
|
||||
const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal
|
||||
std::string t = text.substr(0, f) + "..." + text.substr(n - b);
|
||||
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
|
||||
}
|
||||
return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
|
||||
}
|
||||
|
||||
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
|
||||
inline const char* LoadingDots() {
|
||||
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
||||
@@ -64,6 +79,63 @@ inline const char* LoadingDots() {
|
||||
return kDots[n];
|
||||
}
|
||||
|
||||
// ── Centered empty state ─────────────────────────────────────────────────
|
||||
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
|
||||
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
|
||||
// read the same across tabs. Call at the start of the region you want it centered in
|
||||
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
|
||||
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
|
||||
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
|
||||
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
|
||||
{
|
||||
auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const ImVec2 origin = ImGui::GetCursorPos();
|
||||
ImFont* iconF = Type().iconXL();
|
||||
ImFont* titleF = Type().subtitle1();
|
||||
ImFont* hintF = Type().body2();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float gap = 8.0f * dp;
|
||||
const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
|
||||
const float iconSz = iconF ? scaled(iconF) : 40.0f;
|
||||
const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
|
||||
const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
|
||||
const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
|
||||
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
|
||||
|
||||
if (iconF && iconGlyph && iconGlyph[0]) {
|
||||
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
|
||||
ImGui::PushFont(iconF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
|
||||
ImGui::TextUnformatted(iconGlyph);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
y += iconH + gap;
|
||||
}
|
||||
{
|
||||
const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
|
||||
ImGui::PushFont(titleF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(title);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
y += titleH + gap;
|
||||
}
|
||||
if (hint) {
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
|
||||
ImGui::PushFont(hintF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||
ImGui::TextUnformatted(hint);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Drop Shadow
|
||||
// ============================================================================
|
||||
@@ -472,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
|
||||
// For icon fonts, manually draw centered icon after getting button rect
|
||||
// For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
|
||||
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
|
||||
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
|
||||
// shove the glyph left off-center (and try to draw the notdef id chars).
|
||||
if (isIconFont && size.x > 0 && size.y > 0) {
|
||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
|
||||
const char* labelEnd = label;
|
||||
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
|
||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
|
||||
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
|
||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
|
||||
}
|
||||
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
@@ -848,7 +925,7 @@ inline void DrawStatCard(ImDrawList* dl,
|
||||
// Draw a full-height rounded rect with card rounding (left corners)
|
||||
// and clip to stripe width so the shape follows the corner radius.
|
||||
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
|
||||
float stripeW = 4.0f;
|
||||
float stripeW = 4.0f * Layout::dpiScale();
|
||||
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
|
||||
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
|
||||
ImDrawFlags_RoundCornersLeft);
|
||||
@@ -1205,7 +1282,8 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImVec2 winPos = ImGui::GetWindowPos();
|
||||
float winWidth = ImGui::GetWindowWidth();
|
||||
float barHeight = 36.0f;
|
||||
const float dp = Layout::dpiScale();
|
||||
float barHeight = 36.0f * dp;
|
||||
|
||||
// Get accent color from theme if not provided
|
||||
if (!accent_col) {
|
||||
@@ -1229,15 +1307,15 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
ImFont* titleFont = Type().subtitle1();
|
||||
ImGui::PushFont(titleFont);
|
||||
ImVec2 titleSize = ImGui::CalcTextSize(title);
|
||||
float titleX = barMin.x + 16.0f;
|
||||
float titleX = barMin.x + 16.0f * dp;
|
||||
float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
|
||||
DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
|
||||
ImGui::PopFont();
|
||||
|
||||
// Close button (X) on right side
|
||||
if (p_open) {
|
||||
float btnSize = 24.0f;
|
||||
float btnX = barMax.x - btnSize - 12.0f;
|
||||
float btnSize = 24.0f * dp;
|
||||
float btnX = barMax.x - btnSize - 12.0f * dp;
|
||||
float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
|
||||
ImVec2 btnMin(btnX, btnY);
|
||||
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
|
||||
@@ -1250,7 +1328,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
|
||||
// Button background on hover
|
||||
if (hovered) {
|
||||
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f);
|
||||
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f * dp);
|
||||
}
|
||||
|
||||
// Draw X icon
|
||||
@@ -1271,7 +1349,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
}
|
||||
|
||||
// Reserve space for title bar so content starts below it
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f);
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f * dp);
|
||||
|
||||
return closeClicked;
|
||||
}
|
||||
@@ -1347,10 +1425,17 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h
|
||||
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
||||
WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f);
|
||||
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]);
|
||||
// Center when the label fits; otherwise left-align with a small pad (so a long translation clips
|
||||
// on the right, not on BOTH sides). Clip to the cell so no label can bleed into a neighbouring
|
||||
// segment or past the rounded track — English fits, but de/es/fr/pt/ru labels can overrun.
|
||||
const float lpad = 4.0f * dp;
|
||||
const float tx = (ts.x <= cellW - 2.0f * lpad) ? (cellW - ts.x) * 0.5f : lpad;
|
||||
dl->PushClipRect(cMin, cMax, true);
|
||||
dl->AddText(font, font->LegacySize,
|
||||
ImVec2(cMin.x + (cellW - ts.x) * 0.5f, cMin.y + (height - ts.y) * 0.5f),
|
||||
ImVec2(cMin.x + tx, cMin.y + (height - ts.y) * 0.5f),
|
||||
active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()),
|
||||
labels[i]);
|
||||
dl->PopClipRect();
|
||||
ImGui::PushID(i);
|
||||
ImGui::SetCursorScreenPos(cMin);
|
||||
if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i;
|
||||
@@ -1381,6 +1466,7 @@ struct OverlayCardState {
|
||||
int stableCount = 0; // consecutive frames the height held steady (within 1px)
|
||||
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
|
||||
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
|
||||
bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
|
||||
};
|
||||
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
|
||||
inline std::string g_overlayCurrentKey;
|
||||
@@ -1506,6 +1592,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
||||
float cardY, cardBottomY;
|
||||
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
|
||||
bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll
|
||||
const bool fixedHeight = (spec.cardHeight > 0.0f);
|
||||
if (fixedHeight) {
|
||||
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
|
||||
@@ -1515,9 +1602,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
} else {
|
||||
g_overlayCurrentKey = childId;
|
||||
OverlayCardState& cs = g_overlayCardHeights[childId];
|
||||
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
|
||||
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
|
||||
if (!cs.shown) cs.appearFrames++;
|
||||
const float measuredH = cs.height;
|
||||
const float maxCardH = vp_size.y - 32.0f;
|
||||
// Once the measured content is taller than the viewport, lock the card to the viewport height and
|
||||
// let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
|
||||
// open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
|
||||
// oscillate — decide once and hold until the dialog re-opens.
|
||||
if (measuredH > maxCardH) cs.overflow = true;
|
||||
autoOverflow = cs.overflow;
|
||||
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
|
||||
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
|
||||
// guarantees a pathological ever-changing height can't hide the dialog forever.
|
||||
@@ -1525,11 +1619,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
|
||||
if (ready) {
|
||||
cs.shown = true;
|
||||
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
||||
cardY = (measuredH < vp_size.y - 32.0f)
|
||||
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
||||
: vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + measuredH;
|
||||
if (autoOverflow) {
|
||||
// Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
|
||||
// content child (below) becomes the scroll region so the footer/actions stay reachable.
|
||||
cardY = vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + maxCardH;
|
||||
} else {
|
||||
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
||||
cardY = (measuredH < maxCardH)
|
||||
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
||||
: vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + measuredH;
|
||||
}
|
||||
} else {
|
||||
// Still settling: lay the content out (so the auto-height child gets measured) but keep
|
||||
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
|
||||
@@ -1545,7 +1646,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
|
||||
if (!floating && !hideForMeasure) {
|
||||
GlassPanelSpec cardGlass;
|
||||
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
|
||||
// Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
|
||||
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
|
||||
// tiles, chat) while staying translucent rather than opaque.
|
||||
cardGlass.rounding = 16.0f * dp; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
|
||||
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
||||
}
|
||||
|
||||
@@ -1559,17 +1663,24 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
|
||||
// Content child.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f * dp : 16.0f * dp);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28 * dp, 20 * dp) : ImVec2(28 * dp, 24 * dp));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
|
||||
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY);
|
||||
// A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
|
||||
// content overflowed the viewport); otherwise the child auto-resizes to its content.
|
||||
const bool clampedCard = fixedHeight || autoOverflow;
|
||||
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
|
||||
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
|
||||
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
|
||||
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
|
||||
// their own; auto-height cards resize to content so they never overflow anyway.
|
||||
// their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
|
||||
// content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
|
||||
ImGuiWindowFlags childScroll = autoOverflow
|
||||
? ImGuiWindowFlags_None
|
||||
: (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
|
||||
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
|
||||
cflags, childScroll);
|
||||
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
|
||||
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
|
||||
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
|
||||
@@ -1692,7 +1803,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa
|
||||
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
|
||||
bool danger, bool& outCancel, bool& outConfirm)
|
||||
{
|
||||
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
|
||||
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
|
||||
outCancel = true;
|
||||
|
||||
195
src/ui/material/settings_controls.h
Normal file
195
src/ui/material/settings_controls.h
Normal file
@@ -0,0 +1,195 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Settings design-system controls — the polished chat-settings look (accent subsection headers,
|
||||
// labeled rows with right-aligned controls, iOS-style segmented controls) promoted to reusable
|
||||
// components, plus a tiered ActionButton (Primary/Secondary/Tertiary/Destructive) with optional
|
||||
// leading Material icon and a ButtonFlow that wraps rows of buttons instead of shrinking them.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "draw_helpers.h" // colors, type, layout, icons, WithAlpha, ScaleAlpha, DrawButtonGlassOverlay
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// Accent small-caps subsection header ("KEYS & BACKUP", "APPEARANCE"…) — the chat-settings section() look.
|
||||
inline void SettingsSubheader(const char* text) {
|
||||
const float dp = Layout::dpiScale();
|
||||
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
|
||||
ImGui::PushFont(Type().caption());
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(Primary(), 235));
|
||||
ImGui::TextUnformatted(text);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
|
||||
}
|
||||
|
||||
// A labeled settings row: label left, control right-aligned in a fixed column. Construct once per card
|
||||
// section with the content width (0 = auto), then call .label(text) before drawing each control (leaves
|
||||
// the cursor at the control origin and sets the next item width to the control column).
|
||||
struct SettingsRow {
|
||||
float leftX, rowW, ctrlW, rowGap;
|
||||
explicit SettingsRow(float contentWidth, float controlWidth = 250.0f) {
|
||||
const float dp = Layout::dpiScale();
|
||||
leftX = ImGui::GetCursorPosX();
|
||||
rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
|
||||
ctrlW = controlWidth * dp;
|
||||
rowGap = 5.0f * dp;
|
||||
}
|
||||
void label(const char* text) {
|
||||
ImGui::Dummy(ImVec2(0.0f, rowGap));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(text);
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
|
||||
ImGui::SetNextItemWidth(ctrlW);
|
||||
}
|
||||
};
|
||||
|
||||
// iOS-style segmented control (rounded track + inset pill on the selection). Draws its own labeled row
|
||||
// and returns the (possibly changed) index.
|
||||
inline int SegmentedControl(SettingsRow& row, const char* label, const char* const* items, int count, int value) {
|
||||
const float dp = Layout::dpiScale();
|
||||
row.label(label);
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const float h = ImGui::GetFrameHeight();
|
||||
const float seg = row.ctrlW / static_cast<float>(count);
|
||||
const float round = 7.0f * dp;
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(origin, ImVec2(origin.x + row.ctrlW, origin.y + h), WithAlpha(OnSurface(), 20), round);
|
||||
int result = value;
|
||||
ImGui::PushID(label);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
ImGui::PushID(i);
|
||||
const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
|
||||
ImGui::SetCursorScreenPos(mn);
|
||||
if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
|
||||
const bool hov = ImGui::IsItemHovered();
|
||||
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
const bool sel = (value == i);
|
||||
if (sel) {
|
||||
const float in = 2.0f * dp;
|
||||
dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
|
||||
WithAlpha(Primary(), 210), std::max(1.0f, round - in));
|
||||
} else if (hov) {
|
||||
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 26), round);
|
||||
}
|
||||
const ImVec2 ts = ImGui::CalcTextSize(items[i]);
|
||||
const float lpad = 4.0f * dp;
|
||||
const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
|
||||
ImGui::PushClipRect(mn, mx, true);
|
||||
dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
|
||||
sel ? IM_COL32(255, 255, 255, 236) : OnSurfaceMedium(), items[i]);
|
||||
ImGui::PopClipRect();
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::SetCursorScreenPos(origin);
|
||||
ImGui::Dummy(ImVec2(row.ctrlW, h)); // reserve the control's rect for layout flow
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Tiered action buttons ───────────────────────────────────────────────────
|
||||
// Primary = filled accent (the one main action of a group)
|
||||
// Secondary = glass (default — common actions)
|
||||
// Tertiary = ghost / low-emphasis (rarely used)
|
||||
// Destructive = error-tinted outline (delete / reset)
|
||||
enum class ActionTier { Primary, Secondary, Tertiary, Destructive };
|
||||
|
||||
// The width an ActionButton will occupy (for ButtonFlow / manual layout).
|
||||
inline float ActionButtonWidth(const char* label, const char* icon, float minWidth = 0.0f) {
|
||||
ImFont* lf = Type().button();
|
||||
ImFont* icf = Type().iconSmall();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal
|
||||
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
|
||||
const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
|
||||
const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
|
||||
return std::max(w, minWidth);
|
||||
}
|
||||
|
||||
// A tiered action button with an optional leading Material icon (ICON_MD_* or nullptr). Auto-sizes to
|
||||
// its content (>= minWidth). Respects BeginDisabled() (dims via the style alpha, not clickable).
|
||||
inline bool ActionButton(const char* id, const char* label, const char* icon, ActionTier tier, float minWidth = 0.0f) {
|
||||
ImFont* lf = Type().button();
|
||||
ImFont* icf = Type().iconSmall();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float gap = 6.0f * dp;
|
||||
const float h = ImGui::GetFrameHeight();
|
||||
const bool hasIcon = icon && icon[0] && icf;
|
||||
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
|
||||
const float iconW = hasIcon ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
|
||||
const float w = ActionButtonWidth(label, icon, minWidth);
|
||||
|
||||
const ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, h));
|
||||
const bool hov = ImGui::IsItemHovered(), act = ImGui::IsItemActive();
|
||||
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMax(pos.x + w, pos.y + h);
|
||||
const float round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame)
|
||||
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
|
||||
|
||||
ImU32 bg = 0, border = 0, fg = OnSurface();
|
||||
bool glass = false;
|
||||
switch (tier) {
|
||||
case ActionTier::Primary:
|
||||
// Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button.
|
||||
bg = WithAlpha(Primary(), hov ? 52 : 38);
|
||||
border = WithAlpha(Primary(), hov ? 150 : 110);
|
||||
fg = Primary();
|
||||
break;
|
||||
case ActionTier::Secondary:
|
||||
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
|
||||
border = WithAlpha(OnSurface(), 48);
|
||||
glass = true;
|
||||
fg = OnSurface();
|
||||
break;
|
||||
case ActionTier::Tertiary:
|
||||
bg = hov ? WithAlpha(OnSurface(), 18) : 0;
|
||||
fg = OnSurfaceMedium();
|
||||
break;
|
||||
case ActionTier::Destructive:
|
||||
bg = hov ? WithAlpha(Error(), 32) : WithAlpha(Error(), 12);
|
||||
border = WithAlpha(Error(), 90);
|
||||
fg = Error();
|
||||
break;
|
||||
}
|
||||
if (bg) dl->AddRectFilled(pos, pMax, ScaleAlpha(bg, a), round);
|
||||
if (border) dl->AddRect(pos, pMax, ScaleAlpha(border, a), round, 0, 1.0f);
|
||||
if (glass) DrawButtonGlassOverlay(dl, pos, pMax, round, act, hov);
|
||||
fg = ScaleAlpha(fg, a);
|
||||
|
||||
const float contentW = iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
|
||||
float cx = pos.x + (w - contentW) * 0.5f;
|
||||
if (hasIcon) {
|
||||
dl->AddText(icf, icf->LegacySize, ImVec2(cx, pos.y + (h - icf->LegacySize) * 0.5f), fg, icon);
|
||||
cx += iconW + gap;
|
||||
}
|
||||
dl->AddText(lf, lf->LegacySize, ImVec2(cx, pos.y + (h - lf->LegacySize) * 0.5f), fg, label);
|
||||
return pressed;
|
||||
}
|
||||
|
||||
// Places ActionButtons left→right, wrapping to a new row when the next one won't fit (instead of the
|
||||
// old font-scale-to-fit). Call next(width) before each ActionButton.
|
||||
struct ButtonFlow {
|
||||
float availW, gap; float x = 0.0f; bool firstOnRow = true;
|
||||
explicit ButtonFlow(float availWidth, float gapPx = 8.0f) : availW(availWidth) {
|
||||
gap = gapPx * Layout::dpiScale();
|
||||
}
|
||||
void next(float w) {
|
||||
if (firstOnRow) { firstOnRow = false; x = w; return; }
|
||||
if (x + gap + w <= availW) { ImGui::SameLine(0, gap); x += gap + w; }
|
||||
else { x = w; } // natural newline wraps to the next row
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
111
src/ui/node_status_banner.h
Normal file
111
src/ui/node_status_banner.h
Normal file
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Persistent node-connectivity banner shown at the top of the content column when the wallet
|
||||
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
|
||||
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
|
||||
// one. The decision (whether to show, how severe, which action) is a pure function of a state
|
||||
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
|
||||
// draws the strip. See src/app.cpp.
|
||||
namespace dragonx::ui {
|
||||
|
||||
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
|
||||
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
|
||||
enum class NodeBannerSeverity {
|
||||
Warning,
|
||||
Error,
|
||||
};
|
||||
|
||||
// What the banner's action button does. App maps this to the concrete call.
|
||||
enum class NodeBannerAction {
|
||||
None, // no button — nothing the user can usefully do from here
|
||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||
};
|
||||
|
||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
|
||||
enum class NodeBannerReason {
|
||||
None,
|
||||
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||
LiteOpenFailed, // lite build: the wallet failed to open
|
||||
};
|
||||
|
||||
struct NodeBannerState {
|
||||
bool show = false;
|
||||
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
|
||||
NodeBannerReason reason = NodeBannerReason::None;
|
||||
NodeBannerAction action = NodeBannerAction::None;
|
||||
std::string detail; // passthrough status/error text (may be empty)
|
||||
};
|
||||
|
||||
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
|
||||
// without an App instance.
|
||||
struct NodeBannerInputs {
|
||||
bool lite = false; // lite build (no embedded daemon / RPC)
|
||||
bool connected = false; // state_.connected — the master "online" flag
|
||||
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
|
||||
bool daemon_initializing = false; // daemon launching / block index loading
|
||||
bool connection_in_progress = false; // a connect attempt is actively running
|
||||
|
||||
// Full-node embedded-daemon crash signal.
|
||||
bool using_embedded_daemon = false;
|
||||
bool has_daemon_controller = false;
|
||||
bool daemon_running = false;
|
||||
int daemon_crash_count = 0;
|
||||
|
||||
std::string connection_status; // human-readable status line (already translated)
|
||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||
};
|
||||
|
||||
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
|
||||
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
|
||||
|
||||
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||
NodeBannerState s;
|
||||
|
||||
if (in.lite) {
|
||||
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
|
||||
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
|
||||
// "No wallet open" prompt, and leaves lite_open_error empty).
|
||||
if (!in.connected && !in.lite_open_error.empty()) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Error;
|
||||
s.reason = NodeBannerReason::LiteOpenFailed;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.lite_open_error;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||
// error — don't flicker a banner over it.
|
||||
if (in.connected) return s;
|
||||
if (in.warming_up || in.daemon_initializing) return s;
|
||||
if (in.connection_in_progress) return s;
|
||||
|
||||
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Error;
|
||||
s.reason = NodeBannerReason::DaemonCrashed;
|
||||
s.action = NodeBannerAction::RestartNode;
|
||||
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
|
||||
return s;
|
||||
}
|
||||
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::FullNodeOffline;
|
||||
s.action = NodeBannerAction::Reconnect;
|
||||
s.detail = in.connection_status;
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace dragonx::ui
|
||||
@@ -32,19 +32,22 @@ void Notifications::render()
|
||||
return v >= 0 ? v : fb;
|
||||
};
|
||||
|
||||
// Status bar geometry
|
||||
float sbHeight = S.window("components.status-bar").height;
|
||||
if (sbHeight <= 0.0f) sbHeight = 30.0f;
|
||||
// Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill
|
||||
// are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar
|
||||
// and keep the icon/text inside the pill at HiDPI.
|
||||
const float dp = Layout::dpiScale();
|
||||
float sbHeight = S.window("components.status-bar").height * dp;
|
||||
if (sbHeight <= 0.0f) sbHeight = 30.0f * dp;
|
||||
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
|
||||
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
|
||||
|
||||
// Toast pill sizing — fit inside status bar with margin
|
||||
float pillMarginY = nde("pill-margin-y", 3.0f);
|
||||
float pillMarginY = nde("pill-margin-y", 3.0f) * dp;
|
||||
float pillHeight = sbHeight - pillMarginY * 2.0f;
|
||||
float pillPadX = nde("padding-x", 12.0f);
|
||||
float pillRounding = nde("pill-rounding", 12.0f);
|
||||
float pillPadX = nde("padding-x", 12.0f) * dp;
|
||||
float pillRounding = nde("pill-rounding", 12.0f) * dp;
|
||||
|
||||
// Get accent color based on type — resolved from theme palette
|
||||
ImVec4 accent_color, text_color;
|
||||
@@ -89,7 +92,7 @@ void Notifications::render()
|
||||
ImFont* textFont = material::Type().caption();
|
||||
ImFont* iconFont = material::Type().iconSmall();
|
||||
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
|
||||
float iconGap = 4.0f;
|
||||
float iconGap = 4.0f * dp;
|
||||
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
||||
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
||||
// Clamp to reasonable bounds
|
||||
@@ -122,7 +125,7 @@ void Notifications::render()
|
||||
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
||||
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
||||
// bottom-left progress strip so both bottom corners are respected.
|
||||
float progH = nde("progress-bar-height", 2.0f);
|
||||
float progH = nde("progress-bar-height", 2.0f) * dp;
|
||||
float progW = pillWidth * (1.0f - progress);
|
||||
if (progW > 0.0f) {
|
||||
ImVec2 clipMin(pillX, pMax.y - progH);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include "../util/logger.h"
|
||||
#include "schema/ui_schema.h"
|
||||
|
||||
@@ -22,6 +24,17 @@ enum class NotificationType {
|
||||
Error
|
||||
};
|
||||
|
||||
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
|
||||
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
|
||||
// after the toast is gone. See App::renderAlertHistoryPanel.
|
||||
struct AlertRecord {
|
||||
std::string message;
|
||||
NotificationType type;
|
||||
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
|
||||
std::function<void()> onClick; // optional: makes this bell-panel entry actionable
|
||||
std::string actionHint; // optional: accent link label rendered for the action
|
||||
};
|
||||
|
||||
struct Notification {
|
||||
std::string message;
|
||||
NotificationType type;
|
||||
@@ -81,23 +94,43 @@ public:
|
||||
if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f);
|
||||
push(message, NotificationType::Error, duration);
|
||||
}
|
||||
|
||||
// An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel.
|
||||
// onClick fires when the user clicks the accent `actionHint` link in that panel.
|
||||
void action(const std::string& message, NotificationType type, std::function<void()> onClick,
|
||||
const std::string& actionHint, float duration = -1.0f) {
|
||||
if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f);
|
||||
push(message, type, duration, std::move(onClick), actionHint);
|
||||
}
|
||||
|
||||
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
||||
void push(const std::string& message, NotificationType type, float duration = 5.0f,
|
||||
std::function<void()> onClick = nullptr, const std::string& actionHint = "") {
|
||||
notifications_.emplace_back(message, type, duration);
|
||||
|
||||
|
||||
// Retain a copy in the persistent history (the toast above will fade in seconds; this
|
||||
// survives so the user can review what happened). Thread note: every push is on the UI
|
||||
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
|
||||
// consistent with the rest of this class. Do NOT push from a raw worker thread.
|
||||
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr)),
|
||||
std::move(onClick), actionHint});
|
||||
++total_pushed_;
|
||||
while (history_.size() > kMaxHistory) {
|
||||
history_.pop_front();
|
||||
}
|
||||
|
||||
// Log errors and warnings (debug-only output)
|
||||
if (type == NotificationType::Error) {
|
||||
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
||||
} else if (type == NotificationType::Warning) {
|
||||
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
||||
}
|
||||
|
||||
|
||||
// Forward errors and warnings to console callback
|
||||
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
||||
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
||||
console_callback_(prefix + message, type == NotificationType::Error);
|
||||
}
|
||||
|
||||
|
||||
// Limit max notifications
|
||||
while (notifications_.size() > max_notifications_) {
|
||||
notifications_.pop_front();
|
||||
@@ -122,21 +155,34 @@ public:
|
||||
void clear() {
|
||||
notifications_.clear();
|
||||
}
|
||||
|
||||
|
||||
void setMaxNotifications(size_t max) {
|
||||
max_notifications_ = max;
|
||||
}
|
||||
|
||||
|
||||
// ── Persistent alert history (for the status-bar bell panel) ──
|
||||
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
|
||||
const std::deque<AlertRecord>& history() const { return history_; }
|
||||
bool hasHistory() const { return !history_.empty(); }
|
||||
void clearHistory() { history_.clear(); }
|
||||
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
|
||||
/// the correct basis for an "unseen since last opened" count (deque size is not).
|
||||
std::uint64_t totalPushed() const { return total_pushed_; }
|
||||
|
||||
private:
|
||||
Notifications() = default;
|
||||
~Notifications() = default;
|
||||
Notifications(const Notifications&) = delete;
|
||||
Notifications& operator=(const Notifications&) = delete;
|
||||
|
||||
|
||||
std::deque<Notification> notifications_;
|
||||
size_t max_notifications_ = 5;
|
||||
std::function<void(const std::string&, bool)> console_callback_;
|
||||
|
||||
std::deque<AlertRecord> history_;
|
||||
std::uint64_t total_pushed_ = 0;
|
||||
static constexpr size_t kMaxHistory = 100;
|
||||
|
||||
static float schemaDuration(const char* key, float fallback) {
|
||||
float v = schema::UI().drawElement("components.notifications", key).size;
|
||||
return v > 0.0f ? v : fallback;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,7 +219,7 @@ inline void DrawGlassCutout(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
float lineW = s_cc.lineW;
|
||||
|
||||
// --- Outer glow pass: wider, softer dark edge on top-left ---
|
||||
float glowExpand = s_cc.glowExpand;
|
||||
float glowExpand = s_cc.glowExpand * Layout::dpiScale();
|
||||
int glowA = (int)s_cc.glowAlpha;
|
||||
float glowLineW = s_cc.glowLineW;
|
||||
{
|
||||
@@ -289,6 +289,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
// inner pass gives crisp bevel edge. All use AddRect with rounding so
|
||||
// every layer follows the rounded corners perfectly — no clip rects needed.
|
||||
{
|
||||
const float dp = Layout::dpiScale();
|
||||
float cx = (mn.x + mx.x) * 0.5f;
|
||||
float cy = (mn.y + mx.y) * 0.5f;
|
||||
|
||||
@@ -304,7 +305,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
|
||||
struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; };
|
||||
BevelPass passes[] = {
|
||||
{ 0.5f, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
|
||||
{ 0.5f * dp, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
|
||||
{ 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel
|
||||
};
|
||||
|
||||
@@ -387,7 +388,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
||||
}
|
||||
}
|
||||
if (depth > s_ic.threshold) {
|
||||
float baseInset = s_ic.inset;
|
||||
float baseInset = s_ic.inset * Layout::dpiScale();
|
||||
int shadowMax = (int)(s_ic.maxAlpha * depth);
|
||||
float fadeRatio = s_ic.fadeRatio;
|
||||
float bW = mx.x - mn.x - baseInset * 2.0f;
|
||||
@@ -487,7 +488,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
float fixedH = stripH; // collapse strip
|
||||
for (int i = 0; i < (int)NavPage::Count_; ++i)
|
||||
if (IsNavPageVisible(kNavItems[i].page) && kNavItems[i].section_label && showLabels)
|
||||
fixedH += olFsz + 2.0f + sectionLabelPadBot; // section label + pad below
|
||||
fixedH += olFsz + 2.0f * dp + sectionLabelPadBot; // section label + pad below
|
||||
fixedH += bottomPadding + stripH; // exit area
|
||||
|
||||
float baseFlexH = baseNavGap;
|
||||
@@ -525,7 +526,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
if (showLabels) {
|
||||
curY += sectionGap;
|
||||
if (nSectionLabels < 4) sectionLabelY[nSectionLabels++] = curY;
|
||||
curY += olFsz + 2.0f + sectionLabelPadBot;
|
||||
curY += olFsz + 2.0f * dp + sectionLabelPadBot;
|
||||
} else {
|
||||
curY += sectionGap * 0.4f;
|
||||
if (nSeparators < 4) separatorY[nSeparators++] = curY;
|
||||
@@ -538,11 +539,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
float exitRelY = curY + bottomPadding;
|
||||
float panelH = exitRelY + stripH;
|
||||
|
||||
// Vertical centering — offset so panel is centered in the child window
|
||||
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
||||
if (centerOffset + panelH > contentHeight)
|
||||
centerOffset = std::max(0.0f, contentHeight - panelH);
|
||||
|
||||
// ===================================================================
|
||||
// PASS 2: Render using computed positions
|
||||
// ===================================================================
|
||||
@@ -552,6 +548,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImVec2 wp = ImGui::GetWindowPos();
|
||||
|
||||
// Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight)
|
||||
// to the visible area (child top -> status-bar top) using window-local geometry, so this yields
|
||||
// equal top/bottom gaps at any height on every platform, no viewport dependency.
|
||||
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
||||
if (centerOffset + panelH > contentHeight)
|
||||
centerOffset = std::max(0.0f, contentHeight - panelH);
|
||||
|
||||
float panelLeft = wp.x + glassMarginL;
|
||||
float panelRight = wp.x + sidebarWidth - glassMarginR;
|
||||
float panelTopY = wp.y + centerOffset;
|
||||
@@ -651,7 +654,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
fx.drawShimmer(dl, indMin, indMax, btnRnd);
|
||||
fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd);
|
||||
}
|
||||
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f);
|
||||
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f * dp);
|
||||
DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18);
|
||||
buttonRects.push_back({indMin, indMax, btnRnd});
|
||||
}
|
||||
@@ -676,10 +679,31 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
|
||||
|
||||
if (showLabels) {
|
||||
// The badge is a fixed top-right corner overlay, so it must NOT move
|
||||
// the icon+label — otherwise the text jumps sideways the moment a live
|
||||
// count toggles the badge on/off. Reserve clearance from whether the
|
||||
// page CAN show a badge (constant per item), never from the current
|
||||
// count, and keep the icon+label centered in the FULL button width so
|
||||
// the text position and size stay identical with or without a badge.
|
||||
bool itemBadgeCapable =
|
||||
item.page == NavPage::History ||
|
||||
item.page == NavPage::Mining ||
|
||||
item.page == NavPage::Chat;
|
||||
float badgeReserve = 0.0f;
|
||||
if (itemBadgeCapable) {
|
||||
bool dotOnlyReserve = (item.page == NavPage::Mining);
|
||||
float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber;
|
||||
float badgeInsetXReserve = sde("badge-inset-x", 6.0f);
|
||||
badgeReserve = badgeRReserve * 2.0f + badgeInsetXReserve;
|
||||
}
|
||||
|
||||
ImFont* font = selected ? Type().subtitle2() : Type().body2();
|
||||
float lblFsz = ScaledFontSize(font);
|
||||
float btnW = indMax.x - indMin.x;
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2;
|
||||
// Clearance is symmetric (2x) because the group stays centered in the
|
||||
// full width: reserving on both sides keeps the label's right edge clear
|
||||
// of the right-side corner badge without shifting the center off-axis.
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve * 2.0f;
|
||||
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||
if (labelSz.x > maxLabelW && maxLabelW > 0) {
|
||||
lblFsz *= maxLabelW / labelSz.x;
|
||||
@@ -714,16 +738,16 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
badgeCol = Warning(); badgeTextCol = OnWarning();
|
||||
} else if (item.page == NavPage::Mining && status.miningActive) {
|
||||
dotOnly = true; badgeCol = Success();
|
||||
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
|
||||
badgeCount = status.peerCount;
|
||||
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
|
||||
badgeCount = status.chatUnreadCount;
|
||||
}
|
||||
|
||||
if (badgeCount > 0 || dotOnly) {
|
||||
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
|
||||
float bx = indMax.x - badgeR - 6.0f;
|
||||
float by = indMin.y + badgeR + 5.0f;
|
||||
float badgeInsetX = sde("badge-inset-x", 6.0f);
|
||||
float badgeInsetY = sde("badge-inset-y", 5.0f);
|
||||
float bx = indMax.x - badgeR - badgeInsetX;
|
||||
float by = indMin.y + badgeR + badgeInsetY;
|
||||
dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol);
|
||||
if (!dotOnly && showLabels) {
|
||||
char buf[16];
|
||||
|
||||
48
src/ui/staleness_badge.h
Normal file
48
src/ui/staleness_badge.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
|
||||
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
|
||||
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
|
||||
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
|
||||
// This badge is the surface that reflects that "connected but the number may be out of date" state.
|
||||
//
|
||||
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
|
||||
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
|
||||
// stamps with, so age = now - last_update is consistent.
|
||||
namespace dragonx::ui {
|
||||
|
||||
enum class StalenessSeverity {
|
||||
Warning, // amber — noticeably behind
|
||||
Error, // red — very stale, something is likely wrong
|
||||
};
|
||||
|
||||
struct StalenessBadge {
|
||||
bool show = false;
|
||||
StalenessSeverity severity = StalenessSeverity::Warning;
|
||||
std::int64_t seconds_old = 0;
|
||||
};
|
||||
|
||||
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
|
||||
// with no successful update means refreshes are failing, not merely slow.
|
||||
inline constexpr std::int64_t kStaleAfterSeconds = 45;
|
||||
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
|
||||
|
||||
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
|
||||
StalenessBadge b;
|
||||
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
|
||||
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
|
||||
if (!connected || last_update <= 0) return b;
|
||||
|
||||
std::int64_t age = now - last_update;
|
||||
if (age < 0) age = 0; // clock skew guard
|
||||
if (age < kStaleAfterSeconds) return b;
|
||||
|
||||
b.show = true;
|
||||
b.seconds_old = age;
|
||||
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
|
||||
return b;
|
||||
}
|
||||
|
||||
} // namespace dragonx::ui
|
||||
@@ -125,29 +125,26 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", TR("about_license_text"));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Links
|
||||
if (material::StyledButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
// Links — 3-button action row, centered via the shared footer helper (draws its own
|
||||
// Spacing/Separator/Spacing above the row, replacing the hand-rolled divider block).
|
||||
const float linksTotalW = linkW * 3.0f + ImGui::GetStyle().ItemSpacing.x * 2.0f;
|
||||
material::BeginOverlayDialogFooter(linksTotalW);
|
||||
if (material::TactileButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://dragonx.is");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
if (material::TactileButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
if (material::TactileButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://explorer.dragonx.is");
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Close button
|
||||
float button_width = closeW;
|
||||
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - button_width) * 0.5f);
|
||||
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
||||
|
||||
// Close button — lone dismiss action, centered via the shared footer helper (no extra
|
||||
// divider above it, so it sits directly under the links row).
|
||||
material::BeginOverlayDialogFooter(closeW, false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(closeW, 0), S.resolveFont(closeBtn.font))) {
|
||||
*p_open = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,13 @@ public:
|
||||
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
|
||||
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
|
||||
// Scrollbar visible (not NoScrollbar) — the icon set exceeds the fixed-height
|
||||
// grid, so a real scrollbar is the discoverable way to reach the rest.
|
||||
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
@@ -185,6 +190,7 @@ public:
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
if (ImGui::GetCursorPosY() < controlsTopY) {
|
||||
|
||||
@@ -93,7 +93,7 @@ public:
|
||||
// Arrow
|
||||
{
|
||||
float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f;
|
||||
ImGui::SetCursorPosX(arrowCX - 8.0f);
|
||||
ImGui::SetCursorPosX(arrowCX - 8.0f * dp);
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
float fsz = ScaledFontSize(iconFont);
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
@@ -169,9 +169,9 @@ public:
|
||||
}
|
||||
if (amountValid && newFromBal < 1e-9) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
ImGui::TextWrapped("%s", TR("sends_full_balance_warning"));
|
||||
ImGui::PopStyleColor();
|
||||
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
||||
// so this stakes-bearing line reads as distinct from the neutral preview text.
|
||||
DialogWarningHeader(TR("sends_full_balance_warning"));
|
||||
}
|
||||
|
||||
// Buttons
|
||||
@@ -180,16 +180,14 @@ public:
|
||||
const char* sendingLabel = TR("sending");
|
||||
ImFont* buttonFont = Type().button();
|
||||
float buttonFontSize = ScaledFontSize(buttonFont);
|
||||
float minBtnW = 120.0f * dp;
|
||||
float confirmMinW = 160.0f * dp;
|
||||
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
||||
float cancelW = std::max(minBtnW,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
|
||||
// Both footer buttons share one width (equal-width primary/Close pair), sized to fit the
|
||||
// widest label — the "Sending…" swap label included — so nothing clips.
|
||||
float confirmTextW = std::max(
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
||||
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||
float totalW = cancelW + confirmW + Layout::spacingMd();
|
||||
float btnW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
||||
@@ -201,19 +199,18 @@ public:
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
// Standardized primary + Close footer (centered, no divider). The primary is the
|
||||
// Confirm/"Sending…" action; the Close button dismisses the dialog.
|
||||
bool outConfirm = false;
|
||||
bool outClose = false;
|
||||
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
||||
amountValid && !s_sending,
|
||||
cancelLabel, outConfirm, outClose, btnW);
|
||||
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
|
||||
|
||||
if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) {
|
||||
if (outClose) {
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
|
||||
ImGui::BeginDisabled(!amountValid || s_sending);
|
||||
if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) {
|
||||
if (outConfirm) {
|
||||
s_sending = true;
|
||||
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
||||
amount, s_fee, "",
|
||||
@@ -231,7 +228,6 @@ public:
|
||||
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
|
||||
// (with its own Close button). Previously closing here made that result screen dead code.
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
EndOverlayDialog();
|
||||
}
|
||||
@@ -316,7 +312,8 @@ private:
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Spacing();
|
||||
float btnW = 120.0f;
|
||||
const float dp = Layout::dpiScale();
|
||||
float btnW = 120.0f * dp;
|
||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f);
|
||||
if (TactileButton(TR("close"), ImVec2(btnW, 0))) {
|
||||
s_open = false;
|
||||
|
||||
@@ -70,6 +70,7 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
float spacingSm,
|
||||
float spacingXs)
|
||||
{
|
||||
(void)spacingXs; // trailing button now insets by rowPadLeft (mirrors the left margin)
|
||||
AddressRowLayout layout;
|
||||
layout.contentStartX = rowX + rowPadLeft;
|
||||
layout.contentStartY = rowY + spacingMd;
|
||||
@@ -77,7 +78,9 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
|
||||
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
|
||||
const float rightEdge = rowX + rowWidth;
|
||||
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
|
||||
// Inset the trailing (favorite/star) button by the card's inner padding — the same margin the
|
||||
// left content uses (rowPadLeft) — so it mirrors the left edge instead of hugging the card edge.
|
||||
const float favoriteX = rightEdge - layout.buttonSize - rowPadLeft;
|
||||
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
|
||||
|
||||
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||
|
||||
@@ -116,7 +116,7 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
|
||||
|
||||
// Render the shared address list section (used by all layouts)
|
||||
void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
float glassRound, float hs, float vs) {
|
||||
float glassRound, float hs, float vs, float reserveBelow) {
|
||||
using namespace material;
|
||||
const auto& S = schema::UISchema::instance();
|
||||
const float dp = Layout::dpiScale();
|
||||
@@ -188,8 +188,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
}
|
||||
}
|
||||
|
||||
float buttonWidth = (addrBtn.width > 0) ? addrBtn.width : 140.0f;
|
||||
float spacing = (addrBtn.gap > 0) ? addrBtn.gap : 8.0f;
|
||||
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
|
||||
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
|
||||
float totalButtonsWidth = buttonWidth * 2 + spacing;
|
||||
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
|
||||
S.drawElement("tabs.balance", "buttons-position").size * hs);
|
||||
@@ -225,6 +225,14 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
|
||||
// ---- Glass panel container ----
|
||||
float addrListH = listH;
|
||||
// Cap the card to the space that actually remains here (measured AFTER the title + toolbar are laid
|
||||
// out, so no chrome modelling is needed) minus what the caller reserves for the section below it
|
||||
// (recent-tx). Without this, a fixed dp-scaled listH grows ~1.5x at high font scale and evicts the
|
||||
// Recent Transactions list off the bottom of the fixed, non-scrolling tab host.
|
||||
if (reserveBelow > 0.0f) {
|
||||
float maxH = ImGui::GetContentRegionAvail().y - reserveBelow;
|
||||
if (maxH < addrListH) addrListH = maxH;
|
||||
}
|
||||
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
|
||||
|
||||
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
|
||||
@@ -256,7 +264,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
} else if (rows.empty()) {
|
||||
float cw = ImGui::GetContentRegionAvail().x;
|
||||
float ch = ImGui::GetContentRegionAvail().y;
|
||||
if (ch < 60) ch = 60;
|
||||
if (ch < 60.0f * dp) ch = 60.0f * dp;
|
||||
const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet");
|
||||
ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg);
|
||||
ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f);
|
||||
@@ -324,12 +332,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
s_dragIdx < (int)rows.size()) {
|
||||
const auto& srcRow = rows[s_dragIdx];
|
||||
const auto& dstRow = rows[s_dropTargetIdx];
|
||||
if (srcRow.info->balance > 1e-9) {
|
||||
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
|
||||
AddressTransferDialog::TransferInfo ti;
|
||||
ti.fromAddr = srcRow.info->address;
|
||||
ti.toAddr = dstRow.info->address;
|
||||
ti.fromBalance = srcRow.info->balance;
|
||||
ti.toBalance = dstRow.info->balance;
|
||||
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
|
||||
ti.toBalance = dstRow.info->balance; // destination display only
|
||||
ti.fromIsZ = srcRow.isZ;
|
||||
ti.toIsZ = dstRow.isZ;
|
||||
AddressTransferDialog::show(app, ti);
|
||||
@@ -466,7 +474,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
{
|
||||
const auto& starRect = rowLayout.favoriteButton;
|
||||
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
|
||||
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -492,7 +501,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (showEye) {
|
||||
const auto& eyeRect = rowLayout.visibilityButton;
|
||||
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
||||
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -539,12 +549,24 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// User label next to type
|
||||
// User label next to type — clip to the gap before the right-aligned
|
||||
// balance so a long custom label can't overrun the balance on this line
|
||||
// (mirrors the address-line width guard below).
|
||||
if (!row.label.empty()) {
|
||||
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy),
|
||||
OnSurfaceMedium(), row.label.c_str());
|
||||
float userLabelX = labelX + typeLabelW + Layout::spacingLg();
|
||||
// Balance is drawn right-aligned at contentRight; recompute its left edge here.
|
||||
char balBufPeek[32];
|
||||
snprintf(balBufPeek, sizeof(balBufPeek), "%.8f", addr.balance);
|
||||
float balW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBufPeek).x;
|
||||
float userLabelAvailW = (contentRight - balW - Layout::spacingMd()) - userLabelX;
|
||||
std::string userLabel = material::TruncateToWidth(
|
||||
row.label, capFont, capFont->LegacySize, userLabelAvailW);
|
||||
if (userLabelAvailW > 0.0f) {
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(userLabelX, cy),
|
||||
OnSurfaceMedium(), userLabel.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +805,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
|
||||
const auto& state = app->state();
|
||||
|
||||
float headerStartY = ImGui::GetCursorPosY();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
|
||||
ImGui::SameLine();
|
||||
@@ -790,6 +813,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
app->setCurrentPage(NavPage::History);
|
||||
}
|
||||
ImGui::Spacing();
|
||||
float headerHeight = ImGui::GetCursorPosY() - headerStartY;
|
||||
|
||||
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
||||
float availableListH = ImGui::GetContentRegionAvail().y;
|
||||
@@ -798,14 +822,17 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = std::min(4, (int)txs.size()); // show only the 4 most recent (state.transactions is newest-first)
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
// Only draw as many rows as fully fit within the reserved section height (header + rows);
|
||||
// dropping the overflow row is fine since "View All" already links to full History.
|
||||
int maxRows = std::max(1, (int)((recentH - headerHeight) / rowH));
|
||||
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
||||
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
||||
} else {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
|
||||
@@ -820,7 +847,11 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
// Start the address column past the MEASURED type-label width (plus a fixed gap) so it can
|
||||
// never overlap the label — a fixed schema offset shrinks below the label width at narrow
|
||||
// widths (hs < 1) and collides. Mirrors how amtX/agoSz measure their own text below.
|
||||
ImVec2 typeSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.typeText.c_str());
|
||||
float addrX = tx_x + typeSz.x + Layout::spacingMd();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
@@ -835,13 +866,13 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs, rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
if (material::IsRectHovered(rowPos, rowEnd)) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
dl->AddRectFilled(rowPos, rowEnd, SurfaceOverlay(15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(NavPage::History);
|
||||
@@ -868,7 +899,7 @@ void RenderSyncBar(App* app, ImDrawList* dl, float vs) {
|
||||
ImVec2 barPos = ImGui::GetCursorScreenPos();
|
||||
dl->AddRectFilled(barPos,
|
||||
ImVec2(barPos.x + barW, barPos.y + barH),
|
||||
IM_COL32(255, 255, 255, 15), 1.0f * dp);
|
||||
SurfaceOverlay(15), 1.0f * dp);
|
||||
dl->AddRectFilled(barPos,
|
||||
ImVec2(barPos.x + barW * prog, barPos.y + barH),
|
||||
WithAlpha(Warning(), 200), 1.0f * dp);
|
||||
|
||||
@@ -26,7 +26,8 @@ extern bool s_generating_z_address;
|
||||
void UpdateBalanceLerp(App* app);
|
||||
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs,
|
||||
float heroHeightOverride = -1.0f);
|
||||
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs);
|
||||
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs,
|
||||
float reserveBelow = 0.0f);
|
||||
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
||||
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
#include "../effects/imgui_acrylic.h"
|
||||
#include "../sidebar.h"
|
||||
#include "../notifications.h"
|
||||
#include "../staleness_badge.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include <toml++/toml.hpp>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
@@ -195,7 +197,9 @@ void RenderBalanceTab(App* app)
|
||||
for (const auto& l : allLayouts) {
|
||||
if (l.id == layoutId) { displayName = l.name; break; }
|
||||
}
|
||||
Notifications::instance().info("Layout: " + displayName);
|
||||
char layoutToast[128];
|
||||
snprintf(layoutToast, sizeof(layoutToast), TR("balance_layout_switched"), displayName.c_str());
|
||||
Notifications::instance().info(layoutToast);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,9 +302,9 @@ static void RenderBalanceClassic(App* app)
|
||||
float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg();
|
||||
|
||||
// Card height: must fit the Market card's content (overline + price + 24h)
|
||||
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f);
|
||||
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f);
|
||||
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f);
|
||||
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f) * dp;
|
||||
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f) * dp;
|
||||
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f) * dp;
|
||||
float marketContentH = cardPadLg
|
||||
+ ovFont->LegacySize + ovGap
|
||||
+ sub1->LegacySize + 2.0f * dp
|
||||
@@ -319,7 +323,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Helper: draw accent stripe on left edge, clipped to card rounded corners.
|
||||
// We draw a full-size rounded rect (left corners only) and clip it to the
|
||||
// stripe width so the shape itself follows the card rounding.
|
||||
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
|
||||
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
|
||||
auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) {
|
||||
dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true);
|
||||
dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding,
|
||||
@@ -358,8 +362,11 @@ static void RenderBalanceClassic(App* app)
|
||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
|
||||
}
|
||||
|
||||
std::string totalLabelUpper = TR("total_balance_label");
|
||||
std::transform(totalLabelUpper.begin(), totalLabelUpper.end(), totalLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
OnSurfaceMedium(), totalLabelUpper.c_str());
|
||||
cy += ovFont->LegacySize + ovGap;
|
||||
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
@@ -386,7 +393,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Sync progress or mining indicator (whichever fits)
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
||||
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||
Warning(), buf);
|
||||
|
||||
@@ -400,7 +407,7 @@ static void RenderBalanceClassic(App* app)
|
||||
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
|
||||
// Background track
|
||||
dl->AddRectFilled(cMin, cMax,
|
||||
IM_COL32(255, 255, 255, 15), cardSpec.rounding);
|
||||
SurfaceOverlay(15), cardSpec.rounding);
|
||||
// Progress fill — additional horizontal clip
|
||||
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
|
||||
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
|
||||
@@ -417,16 +424,39 @@ static void RenderBalanceClassic(App* app)
|
||||
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
|
||||
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
|
||||
double hr = state.mining.localHashrate;
|
||||
snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str());
|
||||
// Leading indent clears the mining dot drawn at cx+4dp; the text
|
||||
// itself starts at cx+12dp so keep the two spaces for spacing parity.
|
||||
char mineFmt[64];
|
||||
snprintf(mineFmt, sizeof(mineFmt), " %s", TR("balance_mining_rate"));
|
||||
snprintf(buf, sizeof(buf), mineFmt, FormatHashrate(hr).c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx + 12 * dp, cy),
|
||||
WithAlpha(Success(), 200), buf);
|
||||
} else {
|
||||
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
|
||||
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
|
||||
// dropping the whole connection). The node banner only covers full disconnects, so
|
||||
// this pill is the sole signal that the shown number may be out of date.
|
||||
StalenessBadge badge = evaluateStalenessBadge(
|
||||
state.last_balance_update, std::time(nullptr), state.connected);
|
||||
if (badge.show) {
|
||||
const bool err = (badge.severity == StalenessSeverity::Error);
|
||||
ImU32 fg = err ? Error() : Warning();
|
||||
ImU32 bg = WithAlpha(fg, 38);
|
||||
ImU32 bd = WithAlpha(fg, 90);
|
||||
snprintf(buf, sizeof(buf), "%s %s",
|
||||
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
|
||||
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd, ImVec2(4.0f * dp, 2.0f * dp));
|
||||
// Hover → explain what stale means and how old the data actually is.
|
||||
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
|
||||
Tooltip("%s", TR("data_stale_tooltip"));
|
||||
}
|
||||
}
|
||||
|
||||
// Hover glow
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +487,7 @@ static void RenderBalanceClassic(App* app)
|
||||
{
|
||||
float privPct = (s_dispTotal > 1e-9)
|
||||
? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
|
||||
snprintf(buf, sizeof(buf), "%.0f%% of total · %d Z-addr",
|
||||
snprintf(buf, sizeof(buf), TR("baltab_pct_of_total_zaddr"),
|
||||
privPct, (int)state.z_addresses.size());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||
WithAlpha(Success(), 160), buf);
|
||||
@@ -468,8 +498,8 @@ static void RenderBalanceClassic(App* app)
|
||||
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
|
||||
ImVec2 ts = capFont->CalcTextSizeA(
|
||||
capFont->LegacySize, 10000, 0, buf);
|
||||
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f);
|
||||
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f);
|
||||
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
||||
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f) * dp;
|
||||
ImVec2 bMin(cMax.x - ts.x - bp * 3,
|
||||
cMin.y + cardPadLg);
|
||||
ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp);
|
||||
@@ -483,7 +513,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Hover glow + click to Receive
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(NavPage::Receive);
|
||||
@@ -513,7 +543,7 @@ static void RenderBalanceClassic(App* app)
|
||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||
cy += sub1->LegacySize + valGap;
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d T-addresses",
|
||||
snprintf(buf, sizeof(buf), TR("baltab_t_addresses_count"),
|
||||
(int)state.t_addresses.size());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceDisabled(), buf);
|
||||
@@ -521,7 +551,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Hover glow + click to Receive
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(NavPage::Receive);
|
||||
@@ -554,15 +584,18 @@ static void RenderBalanceClassic(App* app)
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
|
||||
|
||||
// Measure widest text line to determine sparkline left edge
|
||||
std::string marketLabel = TR("market");
|
||||
std::transform(marketLabel.begin(), marketLabel.end(), marketLabel.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
float textW = std::max(pSz.x + tickGap + usdSz.x,
|
||||
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, "MARKET").x);
|
||||
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f);
|
||||
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, marketLabel.c_str()).x);
|
||||
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||
float sparkLeft = cx + textW + sparkGap;
|
||||
float sparkRight = cMax.x - cardPadLg;
|
||||
|
||||
// Left side: label + price + 24h change
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceMedium(), "MARKET");
|
||||
OnSurfaceMedium(), marketLabel.c_str());
|
||||
cy += ovFont->LegacySize + ovGap;
|
||||
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
|
||||
@@ -578,7 +611,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool pos = market.change_24h >= 0;
|
||||
ImU32 chgCol = pos ? Success()
|
||||
: Error();
|
||||
snprintf(buf, sizeof(buf), "%s%.1f%% 24h",
|
||||
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"),
|
||||
pos ? "+" : "", market.change_24h);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx, cy), chgCol, buf);
|
||||
@@ -600,7 +633,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Hover glow + click to Market
|
||||
if (material::IsRectHovered(cMin, cMax)) {
|
||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(NavPage::Market);
|
||||
@@ -628,7 +661,7 @@ static void RenderBalanceClassic(App* app)
|
||||
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
|
||||
}
|
||||
}
|
||||
@@ -662,7 +695,7 @@ static void RenderBalanceDonut(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -757,20 +790,20 @@ static void RenderBalanceDonut(App* app) {
|
||||
ImFont* capFont = Type().caption();
|
||||
ImFont* body2 = Type().body2();
|
||||
|
||||
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f);
|
||||
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f);
|
||||
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f);
|
||||
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f);
|
||||
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f) * dp;
|
||||
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f) * dp;
|
||||
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f) * dp;
|
||||
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f) * dp;
|
||||
|
||||
// Shielded legend
|
||||
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success());
|
||||
snprintf(buf, sizeof(buf), "Shielded %.8f", s_dispShielded);
|
||||
snprintf(buf, sizeof(buf), TR("baltab_shielded_amount"), s_dispShielded);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf);
|
||||
legendY += capFont->LegacySize + legendLineGap;
|
||||
|
||||
// Transparent legend
|
||||
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
|
||||
snprintf(buf, sizeof(buf), "Transparent %.8f", s_dispTransparent);
|
||||
snprintf(buf, sizeof(buf), TR("baltab_transparent_amount"), s_dispTransparent);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
|
||||
legendY += capFont->LegacySize + legendSectionGap;
|
||||
|
||||
@@ -778,15 +811,15 @@ static void RenderBalanceDonut(App* app) {
|
||||
const auto& market = state.market;
|
||||
if (market.price_usd > 0) {
|
||||
if (market.price_usd >= 0.01)
|
||||
snprintf(buf, sizeof(buf), "Market: $%.4f", market.price_usd);
|
||||
snprintf(buf, sizeof(buf), TR("baltab_market_price_4dp"), market.price_usd);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "Market: $%.8f", market.price_usd);
|
||||
snprintf(buf, sizeof(buf), TR("baltab_market_price_8dp"), market.price_usd);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
||||
OnSurfaceMedium(), buf);
|
||||
legendY += capFont->LegacySize + 4 * dp;
|
||||
|
||||
bool pos = market.change_24h >= 0;
|
||||
snprintf(buf, sizeof(buf), "%s%.1f%% 24h", pos ? "+" : "", market.change_24h);
|
||||
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"), pos ? "+" : "", market.change_24h);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
||||
pos ? Success() : Error(), buf);
|
||||
}
|
||||
@@ -802,7 +835,7 @@ static void RenderBalanceDonut(App* app) {
|
||||
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -913,7 +946,7 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f);
|
||||
dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY),
|
||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)),
|
||||
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f));
|
||||
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f) * dp);
|
||||
|
||||
// Bottom half: proportion bars
|
||||
float barY = divY + Layout::spacingSm();
|
||||
@@ -927,10 +960,13 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
|
||||
// Shielded bar
|
||||
float shieldX = cardMin.x + pad;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), "SHIELDED");
|
||||
std::string shieldLabelUpper = TR("shielded");
|
||||
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), shieldLabelUpper.c_str());
|
||||
barY += ovFont->LegacySize + 4 * dp;
|
||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
|
||||
WithAlpha(Success(), 180), barH * 0.5f);
|
||||
barY += barH + 2 * dp;
|
||||
@@ -940,10 +976,13 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
// Transparent bar
|
||||
float transX = cardMin.x + pad * 2 + halfW;
|
||||
barY = divY + Layout::spacingSm();
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), "TRANSPARENT");
|
||||
std::string transLabelUpper = TR("transparent");
|
||||
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), transLabelUpper.c_str());
|
||||
barY += ovFont->LegacySize + 4 * dp;
|
||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
|
||||
WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
barY += barH + 2 * dp;
|
||||
@@ -962,7 +1001,7 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
|
||||
// Background track
|
||||
dl->AddRectFilled(cardMin, cardMax,
|
||||
IM_COL32(255, 255, 255, 15), glassRound);
|
||||
SurfaceOverlay(15), glassRound);
|
||||
// Progress fill — additional horizontal clip
|
||||
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
|
||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
|
||||
@@ -979,7 +1018,7 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1055,11 +1094,20 @@ static void RenderBalanceDashboard(App* app) {
|
||||
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
|
||||
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
|
||||
|
||||
// Localized captions — raw dl->AddText (no auto-uppercase), so uppercase to preserve the caption look.
|
||||
auto upperTR = [](const char* key) {
|
||||
std::string s = TR(key);
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||
return s;
|
||||
};
|
||||
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("transparent");
|
||||
std::string lblQuickSend = upperTR("quick_send"), lblQuickReceive = upperTR("quick_receive");
|
||||
|
||||
TileInfo tiles[4] = {
|
||||
{"SHIELDED", shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
||||
{"TRANSPARENT", trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
||||
{"QUICK SEND", "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
||||
{"QUICK RECEIVE", "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
||||
{lblShielded.c_str(), shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
||||
{lblTransparent.c_str(), trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
||||
{lblQuickSend.c_str(), "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
||||
{lblQuickReceive.c_str(), "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
||||
};
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -1074,7 +1122,7 @@ static void RenderBalanceDashboard(App* app) {
|
||||
|
||||
// Accent stripe — clipped to tile rounded corners
|
||||
{
|
||||
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
|
||||
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
|
||||
dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true);
|
||||
dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding,
|
||||
ImDrawFlags_RoundCornersLeft);
|
||||
@@ -1105,13 +1153,13 @@ static void RenderBalanceDashboard(App* app) {
|
||||
tiles[i].accent, tiles[i].value);
|
||||
} else {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
|
||||
OnSurfaceMedium(), "Click to open");
|
||||
OnSurfaceMedium(), TR("tile_click_to_open"));
|
||||
}
|
||||
|
||||
// Click
|
||||
if (material::IsRectHovered(tMin, tMax)) {
|
||||
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
||||
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(tiles[i].nav);
|
||||
@@ -1127,7 +1175,7 @@ static void RenderBalanceDashboard(App* app) {
|
||||
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1160,7 +1208,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
// Font-content floor per row: icon + label + value must fit
|
||||
float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize)
|
||||
+ Layout::spacingSm() * 2;
|
||||
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f);
|
||||
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f) * dp;
|
||||
float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3;
|
||||
float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size;
|
||||
float stackH;
|
||||
@@ -1190,10 +1238,10 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
};
|
||||
|
||||
RowInfo rowInfos[4] = {
|
||||
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
|
||||
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
|
||||
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
|
||||
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
|
||||
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
|
||||
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
|
||||
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
|
||||
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
|
||||
};
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -1248,7 +1296,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
|
||||
if (i == 1 || i == 2) {
|
||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
||||
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
|
||||
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||
float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
|
||||
float barH = std::max(
|
||||
S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f),
|
||||
@@ -1259,7 +1307,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
float barW = barRight - barLeft;
|
||||
float barY = rowPos.y + (rowH - barH) * 0.5f;
|
||||
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(barLeft, barY),
|
||||
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
|
||||
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
|
||||
@@ -1278,8 +1326,8 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
// Sparkline in the gap between label and 24h change
|
||||
if (state.market.price_history.size() >= 2) {
|
||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
||||
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
|
||||
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
|
||||
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f) * dp;
|
||||
float sparkLeft = px + labelSz.x + sparkGap;
|
||||
float sparkRight = chgX - sparkGap;
|
||||
if (sparkLeft < sparkRight) {
|
||||
@@ -1306,7 +1354,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1339,8 +1387,8 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
// Font-content floor per row: caption text + vertical padding
|
||||
float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2;
|
||||
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f);
|
||||
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f);
|
||||
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f) * dp;
|
||||
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f) * dp;
|
||||
float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap;
|
||||
float cardHOverride = S.drawElement(cfgSec, "card-height").size;
|
||||
float stackH;
|
||||
@@ -1383,13 +1431,13 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
CellInfo cells[2][2] = {
|
||||
// Row 0: Total Balance (left), Shielded (right)
|
||||
{
|
||||
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
|
||||
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
|
||||
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
|
||||
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
|
||||
},
|
||||
// Row 1: Market (left), Transparent (right)
|
||||
{
|
||||
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
|
||||
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
|
||||
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
|
||||
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1453,7 +1501,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
|
||||
float barY = cellMin.y + (rowH - barH) * 0.5f;
|
||||
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(barX, barY),
|
||||
ImVec2(barX + barW * cell.ratio, barY + barH),
|
||||
WithAlpha(cell.accent, 180), barH * 0.5f);
|
||||
@@ -1471,8 +1519,8 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
// Sparkline between label and 24h change
|
||||
if (state.market.price_history.size() >= 2) {
|
||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label);
|
||||
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f);
|
||||
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f);
|
||||
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f) * dp;
|
||||
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f) * dp;
|
||||
float sparkLeft = px + labelSz.x + sparkGap;
|
||||
float sparkRight = chgX - sparkGap;
|
||||
if (sparkLeft < sparkRight) {
|
||||
@@ -1501,7 +1549,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1531,7 +1579,7 @@ static void RenderBalanceShield(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -1617,7 +1665,7 @@ static void RenderBalanceShield(App* app) {
|
||||
ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen,
|
||||
gaugeCy + sinf(needleAngle) * needleLen);
|
||||
dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol,
|
||||
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f));
|
||||
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f) * dp);
|
||||
|
||||
// Center text: percentage
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
@@ -1645,13 +1693,19 @@ static void RenderBalanceShield(App* app) {
|
||||
float infoY = panelMin.y + shieldPad;
|
||||
ImFont* ovFont = Type().overline();
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), "SHIELDED");
|
||||
std::string shieldLabelUpper = TR("shielded");
|
||||
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
std::string transLabelUpper = TR("transparent");
|
||||
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), shieldLabelUpper.c_str());
|
||||
infoY += ovFont->LegacySize + 2 * dp;
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
|
||||
infoY += capFont->LegacySize + 6 * dp;
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), "TRANSPARENT");
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), transLabelUpper.c_str());
|
||||
infoY += ovFont->LegacySize + 2 * dp;
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
|
||||
@@ -1677,7 +1731,7 @@ static void RenderBalanceShield(App* app) {
|
||||
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1707,7 +1761,7 @@ static void RenderBalanceTimeline(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -1796,10 +1850,16 @@ static void RenderBalanceTimeline(App* app) {
|
||||
spec.rounding = glassRound;
|
||||
|
||||
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
|
||||
auto upperTR = [](const char* key) {
|
||||
std::string s = TR(key);
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||
return s;
|
||||
};
|
||||
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("transparent"), cMarket = upperTR("market");
|
||||
SumCard cards[3] = {
|
||||
{"SHIELDED", Success(), s_dispShielded, false},
|
||||
{"TRANSPARENT", Warning(), s_dispTransparent, false},
|
||||
{"MARKET", Primary(), state.market.price_usd, true},
|
||||
{cShielded.c_str(), Success(), s_dispShielded, false},
|
||||
{cTransparent.c_str(), Warning(), s_dispTransparent, false},
|
||||
{cMarket.c_str(), Primary(), state.market.price_usd, true},
|
||||
};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
|
||||
@@ -1830,7 +1890,7 @@ static void RenderBalanceTimeline(App* app) {
|
||||
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1867,32 +1927,36 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
ImFont* capFont = Type().caption();
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
||||
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||
Type().textColored(TypeStyle::Caption, Warning(), buf);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (state.mining.generate) {
|
||||
double hr = state.mining.localHashrate;
|
||||
snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str());
|
||||
snprintf(buf, sizeof(buf), TR("balance_mining_rate"), FormatHashrate(hr).c_str());
|
||||
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Action buttons right-aligned
|
||||
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f);
|
||||
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f) * dp;
|
||||
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
|
||||
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
|
||||
if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
// Stable ## ids keep the button identity fixed across translations.
|
||||
char sendBtn[64], recvBtn[64];
|
||||
snprintf(sendBtn, sizeof(sendBtn), "%s##tworow-send", TR("send"));
|
||||
snprintf(recvBtn, sizeof(recvBtn), "%s##tworow-receive", TR("receive"));
|
||||
if (TactileButton(sendBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
app->setCurrentPage(NavPage::Send);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
if (TactileButton(recvBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
app->setCurrentPage(NavPage::Receive);
|
||||
}
|
||||
}
|
||||
|
||||
RenderSyncBar(app, dl, vs);
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f)));
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f) * dp));
|
||||
|
||||
// Row 2: 3 mini-cards inline
|
||||
{
|
||||
@@ -1915,7 +1979,7 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f),
|
||||
glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f));
|
||||
ImFont* capFont = Type().caption();
|
||||
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f);
|
||||
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f) * dp;
|
||||
int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f);
|
||||
float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size;
|
||||
float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm();
|
||||
@@ -1990,8 +2054,8 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
|
||||
// Sparkline between price and percentage
|
||||
if (market.price_history.size() >= 2) {
|
||||
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f);
|
||||
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f);
|
||||
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f) * dp;
|
||||
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f) * dp;
|
||||
float sparkLeft = cx + priceSz.x + sparkGap;
|
||||
float sparkRightEdge = sparkRight - sparkGap;
|
||||
if (sparkLeft < sparkRightEdge) {
|
||||
@@ -2017,7 +2081,7 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -2089,10 +2153,10 @@ static void RenderBalanceMinimal(App* app) {
|
||||
{
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImVec2 sepPos = ImGui::GetCursorScreenPos();
|
||||
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f);
|
||||
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f);
|
||||
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f) * dp;
|
||||
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f) * dp;
|
||||
float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f);
|
||||
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f);
|
||||
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f) * dp;
|
||||
float x = sepPos.x;
|
||||
float endX = sepPos.x + availW;
|
||||
while (x < endX) {
|
||||
@@ -2110,7 +2174,7 @@ static void RenderBalanceMinimal(App* app) {
|
||||
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
|
||||
s_loading = false;
|
||||
|
||||
if (!error.empty()) {
|
||||
s_error = "Error: " + error;
|
||||
s_error = std::string(TR("grpa_error_prefix")) + error;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
|
||||
|
||||
s_has_data = true;
|
||||
} else {
|
||||
s_error = "Invalid response from daemon";
|
||||
s_error = TR("grpa_invalid_response_from_daemon");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ void BlockInfoDialog::render(App* app)
|
||||
|
||||
// Height input
|
||||
ImGui::Text("%s", TR("block_height"));
|
||||
ImGui::SetNextItemWidth(heightInput.width);
|
||||
ImGui::SetNextItemWidth(heightInput.width * Layout::dpiScale());
|
||||
ImGui::InputInt("##Height", &s_height);
|
||||
if (s_height < 1) s_height = 1;
|
||||
// Clamp to the chain tip so navigation/typing can't request a height
|
||||
@@ -125,7 +125,7 @@ void BlockInfoDialog::render(App* app)
|
||||
|
||||
// Current block info
|
||||
if (state.sync.blocks > 0) {
|
||||
ImGui::TextDisabled("(Current: %d)", state.sync.blocks);
|
||||
ImGui::TextDisabled(TR("grpa_current_block_paren"), state.sync.blocks);
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
@@ -135,7 +135,7 @@ void BlockInfoDialog::render(App* app)
|
||||
ImGui::BeginDisabled();
|
||||
}
|
||||
|
||||
if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (rpc && rpc->isConnected() && app->worker()) {
|
||||
s_loading = true;
|
||||
s_error.clear();
|
||||
@@ -152,7 +152,7 @@ void BlockInfoDialog::render(App* app)
|
||||
rpc::RPCClient::TraceScope trace("Explorer / Block info");
|
||||
auto hashResult = rpc->call("getblockhash", {height});
|
||||
if (!hashResult.is_string()) {
|
||||
error = "unexpected getblockhash result";
|
||||
error = TR("grpa_unexpected_getblockhash_result");
|
||||
} else {
|
||||
block = rpc->call("getblock", {hashResult.get<std::string>()});
|
||||
}
|
||||
@@ -303,7 +303,7 @@ void BlockInfoDialog::render(App* app)
|
||||
// Navigation buttons
|
||||
if (s_has_data) {
|
||||
if (s_height > 1) {
|
||||
if (material::StyledButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
s_height--;
|
||||
s_has_data = false;
|
||||
s_error.clear();
|
||||
@@ -315,7 +315,7 @@ void BlockInfoDialog::render(App* app)
|
||||
// nextblockhash, so this stays hidden there).
|
||||
if (!s_next_hash.empty() &&
|
||||
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
|
||||
if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
s_height++;
|
||||
s_has_data = false;
|
||||
s_error.clear();
|
||||
@@ -323,9 +323,10 @@ void BlockInfoDialog::render(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// Close button at bottom
|
||||
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 40);
|
||||
if (material::StyledButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
||||
// Close button at bottom — centered via the shared footer helper (no separator, matching
|
||||
// the prior hand-rolled placement).
|
||||
material::BeginOverlayDialogFooter(closeBtn.width, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
||||
s_open = false;
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
|
||||
@@ -144,7 +144,7 @@ private:
|
||||
|
||||
if (!s_bootstrap) {
|
||||
s_state = State::Failed;
|
||||
s_errorMsg = "Bootstrap not initialized";
|
||||
s_errorMsg = TR("grpc_bootstrap_not_initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ private:
|
||||
s_state = State::Done;
|
||||
} else {
|
||||
s_errorMsg = finalProg.error;
|
||||
if (s_errorMsg.empty()) s_errorMsg = "Bootstrap failed";
|
||||
if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed");
|
||||
s_state = State::Failed;
|
||||
}
|
||||
s_bootstrap.reset();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ void RenderChatTab(App* app);
|
||||
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
|
||||
* (use the current content region, correct inside the chat modal's dialog).
|
||||
*/
|
||||
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
|
||||
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCards = false);
|
||||
|
||||
/**
|
||||
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "console_command_executor.h"
|
||||
#include "console_channel.h"
|
||||
#include "console_command_reference.h" // consoleCommandCategories / liteConsoleCommandCategories
|
||||
#include "console_input_model.h" // BuildConsoleRpcCall
|
||||
|
||||
#include "../../app.h"
|
||||
@@ -28,6 +29,10 @@ namespace ui {
|
||||
|
||||
using namespace material;
|
||||
|
||||
// Classifies a diagnostics line into a console channel (defined below in an anonymous namespace); both
|
||||
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
|
||||
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
|
||||
|
||||
// ============================================================================
|
||||
// FullNodeConsoleExecutor
|
||||
// ============================================================================
|
||||
@@ -172,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
|
||||
} else {
|
||||
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
||||
}
|
||||
|
||||
// Chat diagnostics ring: chat code logs via wallet::liteLog on both variants (send lifecycle, the
|
||||
// 0-conf harvest, the note buffer). Surface those lines here as App messages so the full-node console
|
||||
// shows chat activity too. Same generation-cursor drain the lite console uses.
|
||||
{
|
||||
auto& diag = wallet::LiteDiagnostics::instance();
|
||||
const std::uint64_t gen = diag.generation();
|
||||
if (gen != diag_gen_) {
|
||||
const auto snap = diag.snapshot();
|
||||
std::size_t startIdx = 0;
|
||||
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
|
||||
const std::uint64_t added = gen - diag_gen_;
|
||||
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
|
||||
}
|
||||
for (std::size_t i = startIdx; i < snap.size(); ++i)
|
||||
add(snap[i], liteLogChannel(snap[i]));
|
||||
diag_gen_ = gen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||
@@ -194,6 +218,11 @@ void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||
add(TR("console_tab_completion"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
const std::vector<ConsoleCommandCategory>* FullNodeConsoleExecutor::commandReference() const
|
||||
{
|
||||
return &consoleCommandCategories();
|
||||
}
|
||||
|
||||
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
|
||||
{
|
||||
ConsoleStatusLine s;
|
||||
@@ -258,7 +287,7 @@ bool LiteConsoleExecutor::pollResult(std::string& result, bool& isError)
|
||||
if (!lw) return false;
|
||||
wallet::LiteConsoleResult res;
|
||||
if (!lw->takeConsoleResult(res)) return false;
|
||||
result = res.response.empty() ? std::string("(no output)") : res.response;
|
||||
result = res.response.empty() ? std::string(TR("console_no_output")) : res.response;
|
||||
isError = !res.ok;
|
||||
return true;
|
||||
}
|
||||
@@ -286,6 +315,19 @@ void LiteConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||
add(TR("console_help_clear"), ConsoleChannel::None);
|
||||
add(TR("console_help_help"), ConsoleChannel::None);
|
||||
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
|
||||
// The lite backend's own command verbs (literal command tokens, not RPC methods — mirrors the
|
||||
// backend's get_commands() registry). Listed for discoverability: the C++ tab intercepts `help`
|
||||
// before it can reach the backend's own HelpCommand, so its "type help" advice would dead-end.
|
||||
add(TR("lite_console_backend_commands"), ConsoleChannel::Info);
|
||||
add(" sync syncstatus balance addresses height info list notes encryptionstatus", ConsoleChannel::None);
|
||||
add(" send shield new seed import timport export", ConsoleChannel::None);
|
||||
add(" encrypt decrypt lock unlock rescan save sietch saplingtree coinsupply", ConsoleChannel::None);
|
||||
add(TR("console_click_commands"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
const std::vector<ConsoleCommandCategory>* LiteConsoleExecutor::commandReference() const
|
||||
{
|
||||
return &liteConsoleCommandCategories();
|
||||
}
|
||||
|
||||
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
||||
@@ -294,20 +336,20 @@ std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (lw && lw->walletOpen()) {
|
||||
const SyncInfo& sync = app_->state().sync;
|
||||
char buf[96];
|
||||
char buf[128];
|
||||
if (sync.syncing && !sync.isSynced()) {
|
||||
double vp = sync.verification_progress;
|
||||
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
||||
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
|
||||
vp * 100.0, sync.blocks, sync.headers);
|
||||
std::snprintf(buf, sizeof(buf), "%s %.1f%% (block %d / %d)",
|
||||
TR("lite_net_syncing"), vp * 100.0, sync.blocks, sync.headers);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
|
||||
std::snprintf(buf, sizeof(buf), "%s (block %d)", TR("lite_net_synced"), sync.blocks);
|
||||
}
|
||||
out.push_back({std::string(buf), OnSurfaceMedium(), false});
|
||||
}
|
||||
const std::string& err = app_->liteOpenError();
|
||||
if (!err.empty() && (!lw || !lw->walletOpen()))
|
||||
out.push_back({std::string("Last error: ") + err, Error(), false});
|
||||
out.push_back({std::string(TR("console_last_error")) + " " + err, Error(), false});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -315,10 +357,10 @@ ConsoleStatusLine LiteConsoleExecutor::toolbarStatus() const
|
||||
{
|
||||
ConsoleStatusLine s;
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (!lw) { s.text = "No backend"; s.color = Error(); return s; }
|
||||
if (lw->walletOpen()) { s.text = "Connected"; s.color = Success(); }
|
||||
else if (lw->openInProgress()) { s.text = "Connecting"; s.color = Warning(); s.pulse = true; }
|
||||
else { s.text = "Disconnected"; s.color = Error(); }
|
||||
if (!lw) { s.text = TR("console_backend_unavailable"); s.color = Error(); return s; }
|
||||
if (lw->walletOpen()) { s.text = TR("lite_net_connected"); s.color = Success(); }
|
||||
else if (lw->openInProgress()) { s.text = TR("lite_net_connecting"); s.color = Warning(); s.pulse = true; }
|
||||
else { s.text = TR("lite_net_disconnected"); s.color = Error(); }
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace dragonx {
|
||||
class App;
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleCommandCategory; // console_command_reference.h — command-reference table
|
||||
|
||||
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
|
||||
|
||||
struct ConsoleStatusLine {
|
||||
@@ -64,7 +66,13 @@ public:
|
||||
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
|
||||
|
||||
// UI-chrome capabilities.
|
||||
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
|
||||
// True when this backend speaks JSON-RPC to a daemon (the full node). Gates daemon-specific
|
||||
// console behavior (the 'stop' shutdown confirm, "not connected to daemon" wording) and the
|
||||
// command-reference modal's JSON string-arg quoting — the lite backend takes bare tokens.
|
||||
virtual bool hasRpcReference() const { return false; }
|
||||
// The command-reference table this backend offers (browsed by the console's reference modal),
|
||||
// or nullptr for none. Full node = the JSON-RPC reference; lite = its own backend verbs.
|
||||
virtual const std::vector<ConsoleCommandCategory>* commandReference() const { return nullptr; }
|
||||
// Which log-filter toggles the toolbar should show (default: none).
|
||||
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
|
||||
|
||||
@@ -88,6 +96,7 @@ public:
|
||||
bool pollResult(std::string& result, bool& isError) override;
|
||||
void pollLogLines(const ConsoleAddLineFn& add) override;
|
||||
bool hasRpcReference() const override { return true; }
|
||||
const std::vector<ConsoleCommandCategory>* commandReference() const override;
|
||||
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
|
||||
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
|
||||
void printHelp(const ConsoleAddLineFn& add) override;
|
||||
@@ -104,6 +113,9 @@ private:
|
||||
bool last_rpc_connected_ = false;
|
||||
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
||||
std::mutex results_mutex_;
|
||||
// Chat diagnostics ring cursor: chat code logs via wallet::liteLog on BOTH variants, so the full-node
|
||||
// console surfaces those lines (as App messages) too — mirrors LiteConsoleExecutor's diag drain.
|
||||
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1);
|
||||
};
|
||||
|
||||
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
||||
@@ -119,6 +131,7 @@ public:
|
||||
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
|
||||
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
|
||||
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
|
||||
const std::vector<ConsoleCommandCategory>* commandReference() const override;
|
||||
void printHelp(const ConsoleAddLineFn& add) override;
|
||||
std::vector<ConsoleStatusLine> statusLines() const override;
|
||||
ConsoleStatusLine toolbarStatus() const override;
|
||||
|
||||
@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
|
||||
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
|
||||
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"},
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
|
||||
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"},
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
|
||||
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
|
||||
"listtransactions", "transactions history recent payments received sent"},
|
||||
@@ -280,6 +280,105 @@ const ConsoleCommandEntry kUtilityCommands[] = {
|
||||
"reconsiderblock \"0000000000abc123\"", "undo invalidate accept block again re-enable block restore chain reconsider fix rollback fork", true},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Lite backend command set — the verbs the SDXL lite backend accepts (see its
|
||||
// commands.rs get_commands()). Unlike the full node these are NOT JSON-RPC: the
|
||||
// backend takes plain, space-separated tokens (no quoting), so the reference
|
||||
// modal inserts them bare. clear/help/quit are intentionally omitted — the C++
|
||||
// console tab intercepts those before they reach the backend.
|
||||
// ============================================================================
|
||||
|
||||
const ConsoleCommandEntry kLiteWalletCommands[] = {
|
||||
{"balance", "Show your DRGX balance", "",
|
||||
"Shows the DRGX balance held in this wallet across all its shielded and transparent addresses.",
|
||||
"balance", "money funds amount total holdings"},
|
||||
{"addresses", "List all addresses in the wallet", "",
|
||||
"Lists every address this wallet owns \xE2\x80\x94 shielded (zs1...) and transparent (R.../t...) \xE2\x80\x94 so you can pick one to receive to.",
|
||||
"addresses", "receive address list wallet mine deposit"},
|
||||
{"new", "Create a new address in this wallet", "type",
|
||||
"Creates a fresh receive address. Pass zs for a shielded (private) sapling address, or R (a capital R) for a transparent one \xE2\x80\x94 those exact tokens (case-sensitive).",
|
||||
"new zs", "create address receive generate new shielded transparent zs"},
|
||||
{"list", "List all transactions in the wallet", "",
|
||||
"Shows the wallet's transaction history \xE2\x80\x94 sends, receives and shields \xE2\x80\x94 with amounts, addresses and confirmations.",
|
||||
"list", "history transactions txs payments activity sent received"},
|
||||
{"notes", "List sapling notes and UTXOs", "[all]",
|
||||
"Lists the individual shielded notes and transparent UTXOs that make up your balance. Pass all to include spent ones.",
|
||||
"notes", "utxo notes unspent coins inputs sapling"},
|
||||
{"info", "Get the lightwalletd server's info", "",
|
||||
"Reports the lightwalletd server the wallet is connected to \xE2\x80\x94 its version, chain and block height.",
|
||||
"info", "server node lightwalletd version connection status"},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kLiteSyncCommands[] = {
|
||||
{"sync", "Download compact blocks and sync to the server", "",
|
||||
"Fetches new compact blocks from the lightwalletd server and scans them for transactions belonging to this wallet.",
|
||||
"sync", "update refresh scan blocks download catch up"},
|
||||
{"syncstatus", "Get the sync status of the wallet", "",
|
||||
"Reports how far the wallet has synced \xE2\x80\x94 whether a sync is in progress and the block it has reached.",
|
||||
"syncstatus", "progress status syncing scanning percent blocks"},
|
||||
{"height", "Get the latest block height the wallet is at", "",
|
||||
"Shows the block height the wallet has scanned up to. Compare with the network height to gauge sync.",
|
||||
"height", "block height number chain tip synced"},
|
||||
{"rescan", "Rescan the wallet from scratch", "",
|
||||
"Discards the scanned state and re-downloads/re-scans every block from the wallet's birthday. Slow, but fixes a stuck or incomplete balance.",
|
||||
"rescan", "rescan resync repair fix balance rebuild from scratch"},
|
||||
{"save", "Save the wallet file to disk", "",
|
||||
"Writes the current wallet state to disk. The wallet also saves automatically after a sync or send.",
|
||||
"save", "save persist write disk store"},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kLiteSendCommands[] = {
|
||||
{"send", "Send DRGX to an address", "[{\"address\":\"zs1...\",\"amount\":0}]",
|
||||
"Sends DRGX from the console using a JSON array of recipients (the Send tab is the easy way; this is the power-user form). amount is in puposhis (the base unit); memo is optional and delivered privately to shielded recipients.",
|
||||
"send [{\"address\":\"zs1exampleaddress\",\"amount\":100000000,\"memo\":\"thanks\"}]",
|
||||
"pay transfer send spend money transaction json", true},
|
||||
{"shield", "Shield transparent DRGX into a sapling address", "[address]",
|
||||
"Moves your transparent (public) DRGX into a shielded sapling address for privacy. With no address it shields to the wallet's own sapling address.",
|
||||
"shield", "shield private sapling transparent move protect", true},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kLiteKeyCommands[] = {
|
||||
{"seed", "Display the wallet seed phrase", "",
|
||||
"Reveals the seed phrase that backs up this wallet. Anyone who sees it can spend your funds \xE2\x80\x94 keep it secret and offline.",
|
||||
"seed", "seed phrase mnemonic backup recovery words secret", true},
|
||||
{"export", "Export the private key for an address", "[address]",
|
||||
"Prints the private/spending key for a wallet address \xE2\x80\x94 anyone with it controls the funds. With no address it exports every key.",
|
||||
"export zs1exampleaddress", "export private key spending backup secret", true},
|
||||
{"import", "Import a spending or viewing key", "key",
|
||||
"Imports a shielded spending or viewing key (pass just the key). The wallet rescans from the sapling activation height to find the key's transactions.",
|
||||
"import somekey", "import restore key spending viewing add watch"},
|
||||
{"timport", "Import a transparent WIF private key", "wif",
|
||||
"Imports a transparent private key in WIF format (begins with U, 5, K or L) so the wallet can spend its funds.",
|
||||
"timport somewifkey", "import transparent wif private key taddr"},
|
||||
{"encrypt", "Encrypt the wallet with a password", "password",
|
||||
"Encrypts the wallet with a password and locks it immediately. You will need the password to send or reveal keys afterwards. If you forget it, only the seed phrase can recover the wallet.",
|
||||
"encrypt strongpassword", "encrypt password protect lock secure passphrase", true},
|
||||
{"decrypt", "Completely remove wallet encryption", "password",
|
||||
"Permanently removes the wallet's password encryption, leaving it unprotected on disk. Requires the current password.",
|
||||
"decrypt strongpassword", "decrypt remove encryption password unprotect", true},
|
||||
{"unlock", "Unlock the wallet for spending", "password",
|
||||
"Temporarily unlocks an encrypted wallet so it can send or reveal keys. Use lock to re-lock it.",
|
||||
"unlock strongpassword", "unlock password spend open temporarily"},
|
||||
{"lock", "Lock a temporarily-unlocked wallet", "",
|
||||
"Re-locks a wallet that was unlocked for spending, without removing its encryption.",
|
||||
"lock", "lock secure re-lock protect close"},
|
||||
{"encryptionstatus", "Check if the wallet is encrypted and locked", "",
|
||||
"Reports whether the wallet is encrypted and, if so, whether it is currently locked or unlocked.",
|
||||
"encryptionstatus", "encryption status locked unlocked encrypted state"},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kLiteAdvancedCommands[] = {
|
||||
{"sietch", "Create a Sietch address", "[type]",
|
||||
"Creates a Sietch address, used for enhanced-privacy sends. Pass zs for a sapling Sietch address.",
|
||||
"sietch zs", "sietch privacy address decoy sapling advanced"},
|
||||
{"saplingtree", "Dump the latest Sapling commitment tree (debug)", "",
|
||||
"Prints the latest Sapling commitment tree state \xE2\x80\x94 a debugging aid, not needed for everyday use.",
|
||||
"saplingtree", "sapling tree commitment debug advanced merkle"},
|
||||
{"coinsupply", "Get the coin supply info", "",
|
||||
"Reports coin-supply figures for the chain as seen by the wallet's server.",
|
||||
"coinsupply", "supply coins total emission circulating amount"},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
||||
@@ -296,5 +395,17 @@ const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
||||
return categories;
|
||||
}
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories()
|
||||
{
|
||||
static const std::vector<ConsoleCommandCategory> categories = {
|
||||
{"Wallet", kLiteWalletCommands, CountOf(kLiteWalletCommands)},
|
||||
{"Sync", kLiteSyncCommands, CountOf(kLiteSyncCommands)},
|
||||
{"Send", kLiteSendCommands, CountOf(kLiteSendCommands)},
|
||||
{"Keys & Security", kLiteKeyCommands, CountOf(kLiteKeyCommands)},
|
||||
{"Advanced", kLiteAdvancedCommands, CountOf(kLiteAdvancedCommands)},
|
||||
};
|
||||
return categories;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -23,7 +23,12 @@ struct ConsoleCommandCategory {
|
||||
int count;
|
||||
};
|
||||
|
||||
// The full-node daemon's JSON-RPC command reference (browsed by the console's command-reference modal).
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
|
||||
|
||||
// The lite backend's own command set (the ~25 verbs the SDXL backend accepts) — the lite-variant
|
||||
// analog of the RPC reference, shown by the same modal when the executor is the lite one.
|
||||
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
|
||||
lines_.pop_front();
|
||||
++result.popped;
|
||||
}
|
||||
++revision_; // deque changed — lets the view memoize its filter/layout passes
|
||||
return result;
|
||||
}
|
||||
|
||||
void ConsoleModel::clear()
|
||||
{
|
||||
lines_.clear();
|
||||
++revision_;
|
||||
}
|
||||
|
||||
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
||||
{
|
||||
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
||||
lines_[i].collapsed = !lines_[i].collapsed;
|
||||
++revision_; // fold change alters which lines are visible
|
||||
return lines_[i].collapsed;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user