Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3361020d1 | ||
|
|
e20b70d73f | ||
|
|
1215ded1a0 | ||
|
|
0f25c67600 | ||
|
|
0c821333c8 | ||
|
|
98fa2154b4 | ||
|
|
6b14098bb2 | ||
|
|
821bebd13e | ||
|
|
5993488176 | ||
|
|
b1d5806f9e | ||
|
|
a091bbc52f | ||
|
|
d8175b7dc1 | ||
|
|
2c04b88e19 | ||
|
|
bade958849 | ||
|
|
9339bb0663 | ||
|
|
7b6e8a8a8e | ||
|
|
46189da0da | ||
|
|
bc6c1de7d6 | ||
|
|
ec88dac459 | ||
|
|
d058e5fc2a | ||
|
|
86b9d2e00d | ||
|
|
0056c46929 | ||
|
|
1151141271 | ||
|
|
fae8b20fb7 | ||
|
|
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 |
@@ -33,6 +33,10 @@ imgui.ini
|
||||
*.bak*
|
||||
*.params
|
||||
asmap.dat
|
||||
# Wallet files hold PRIVATE KEYS — never commit them
|
||||
wallet.dat
|
||||
wallet-*.dat
|
||||
wallet.dat.*
|
||||
/external/drg-xmrig
|
||||
/memory
|
||||
/todo.md
|
||||
|
||||
@@ -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`.
|
||||
+79
-6
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.0
|
||||
VERSION 2.0.2
|
||||
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.1")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
@@ -45,6 +45,7 @@ option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
|
||||
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
|
||||
option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF)
|
||||
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
|
||||
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
|
||||
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
|
||||
@@ -154,6 +155,11 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
||||
set(DRAGONX_LITE_BACKEND_READY ON)
|
||||
endif()
|
||||
|
||||
# Windows (mingw cross) builds skip the large test target unless asked for with -DBUILD_TESTING=ON
|
||||
# (scripts/test-windows.sh does that in its own build/windows-tests tree), so release builds are unchanged.
|
||||
if(WIN32 AND NOT DEFINED BUILD_TESTING)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "Build the testing tree.")
|
||||
endif()
|
||||
include(CTest)
|
||||
|
||||
# Output directories
|
||||
@@ -213,7 +219,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)
|
||||
@@ -222,7 +228,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)
|
||||
@@ -265,7 +271,11 @@ if(WIN32)
|
||||
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(CURL_STATICLIB ON CACHE BOOL "" FORCE)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
# Keep curl's own tests out, without switching off ObsidianDragonTests for every Windows build:
|
||||
# hide BUILD_TESTING behind a normal variable while curl configures, then restore our cache value
|
||||
# (curl's cmake_dependent_option(BUILD_TESTING ...) FORCE-writes the OFF it saw into the cache).
|
||||
set(_dragonx_build_testing "${BUILD_TESTING}")
|
||||
set(BUILD_TESTING OFF)
|
||||
set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE)
|
||||
set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE)
|
||||
set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) # Use Windows native SSL
|
||||
@@ -274,6 +284,8 @@ if(WIN32)
|
||||
set(CURL_ZLIB OFF CACHE BOOL "" FORCE)
|
||||
set(HTTP_ONLY ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(curl)
|
||||
unset(BUILD_TESTING)
|
||||
set(BUILD_TESTING "${_dragonx_build_testing}" CACHE BOOL "Build the testing tree." FORCE)
|
||||
set(CURL_LIBRARIES libcurl_static)
|
||||
set(CURL_INCLUDE_DIRS ${curl_SOURCE_DIR}/include ${curl_BINARY_DIR}/lib/curl)
|
||||
else()
|
||||
@@ -289,7 +301,7 @@ 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
|
||||
@@ -521,6 +533,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
|
||||
@@ -544,6 +558,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
|
||||
@@ -656,6 +671,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
|
||||
@@ -1074,6 +1091,53 @@ 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()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# fake_dragonxd — a deliberately slow, scriptable dragonxd stand-in that reproduces the daemon-stop
|
||||
# incident (usage and the incident it models: tools/fake_dragonxd/README.md).
|
||||
# Test tooling only: OFF by default, never packaged, and written to <build>/fake_dragonxd/ so its
|
||||
# "dragonxd" name can't shadow the real bin/dragonxd.
|
||||
# -----------------------------------------------------------------------------
|
||||
if(DRAGONX_BUILD_FAKE_DAEMON)
|
||||
add_executable(fake_dragonxd tools/fake_dragonxd/fake_dragonxd.cpp)
|
||||
target_link_libraries(fake_dragonxd PRIVATE nlohmann_json::nlohmann_json)
|
||||
if(WIN32)
|
||||
target_link_libraries(fake_dragonxd PRIVATE ws2_32)
|
||||
else()
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(fake_dragonxd PRIVATE Threads::Threads)
|
||||
endif()
|
||||
set_target_properties(fake_dragonxd PROPERTIES
|
||||
OUTPUT_NAME dragonxd
|
||||
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/fake_dragonxd)
|
||||
message(STATUS "fake_dragonxd test stub: ON (${CMAKE_BINARY_DIR}/fake_dragonxd/)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1122,6 +1186,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
|
||||
@@ -1129,6 +1194,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
|
||||
@@ -1178,6 +1244,13 @@ if(BUILD_TESTING)
|
||||
if(UNIX)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ws2_32 crypt32 wldap32 psapi iphlpapi shell32 ole32 advapi32 bcrypt)
|
||||
endif()
|
||||
# i18n.cpp includes the generated embedded/lang_*.h headers; a tree that builds only this target needs them too.
|
||||
if(TARGET copy_langs)
|
||||
add_dependencies(ObsidianDragonTests copy_langs)
|
||||
endif()
|
||||
|
||||
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
|
||||
endif()
|
||||
|
||||
@@ -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,6 +437,9 @@ 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/drg-xmrig/xmrig"
|
||||
@@ -417,6 +473,8 @@ 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/drg-xmrig/xmrig"
|
||||
@@ -638,6 +696,30 @@ HDR
|
||||
info "Lite mode: skipping embedded daemon binaries"
|
||||
fi
|
||||
|
||||
# ── 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
|
||||
@@ -750,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[@]}"
|
||||
|
||||
@@ -762,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"
|
||||
@@ -779,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
|
||||
@@ -992,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 \
|
||||
@@ -999,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
|
||||
@@ -1020,12 +1133,19 @@ TOOLCHAIN
|
||||
fi
|
||||
|
||||
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="$MAC_ARCHS" \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
fi
|
||||
|
||||
@@ -1034,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"
|
||||
@@ -1106,6 +1231,8 @@ 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,43 @@
|
||||
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
|
||||
|
||||
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Security fix — please read
|
||||
|
||||
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
|
||||
|
||||
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
|
||||
|
||||
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
|
||||
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
|
||||
|
||||
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
|
||||
|
||||
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Also fixed
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
|
||||
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
|
||||
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
|
||||
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,41 @@
|
||||
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
|
||||
|
||||
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
|
||||
|
||||
---
|
||||
|
||||
## What's Fixed in v2.0.2
|
||||
|
||||
### Transaction history
|
||||
|
||||
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
|
||||
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
|
||||
|
||||
### Sync & fund safety
|
||||
|
||||
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
|
||||
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
|
||||
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
|
||||
|
||||
### Display / internationalization
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
|
||||
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
|
||||
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
|
||||
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
|
||||
@@ -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.)
|
||||
Binary file not shown.
+618
-22
File diff suppressed because it is too large
Load Diff
+618
-22
File diff suppressed because it is too large
Load Diff
+618
-22
File diff suppressed because it is too large
Load Diff
+616
-20
File diff suppressed because it is too large
Load Diff
+618
-22
File diff suppressed because it is too large
Load Diff
+618
-22
File diff suppressed because it is too large
Load Diff
+618
-22
File diff suppressed because it is too large
Load Diff
+616
-20
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -25,6 +25,264 @@ def load_english_keys():
|
||||
|
||||
# All translations for the 158 missing keys
|
||||
TRANSLATIONS = {
|
||||
"appx_node_busy_try_later": {
|
||||
"de": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.",
|
||||
"es": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.",
|
||||
"fr": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.",
|
||||
"pt": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.",
|
||||
"ru": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
|
||||
"zh": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
|
||||
"ja": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
|
||||
"ko": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
|
||||
},
|
||||
"appx_node_not_stopped": {
|
||||
"de": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.",
|
||||
"es": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.",
|
||||
"fr": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.",
|
||||
"pt": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.",
|
||||
"ru": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
|
||||
"zh": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
|
||||
"ja": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
|
||||
"ko": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
|
||||
},
|
||||
# --- Daemon shutdown hardening: stopping the node on quit (never kill unasked) ---
|
||||
"shutdown_leave_running": {
|
||||
"de": "Wallet schließen – Node weiterlaufen lassen", "es": "Cerrar la cartera y dejar el nodo en marcha",
|
||||
"fr": "Fermer le portefeuille — laisser le nœud actif", "pt": "Fechar a carteira e deixar o nó em execução",
|
||||
"ru": "Закрыть кошелёк — оставить узел работать", "zh": "关闭钱包——保持节点运行",
|
||||
"ja": "ウォレットを閉じる — ノードは実行したまま", "ko": "지갑 닫기 — 노드는 계속 실행"
|
||||
},
|
||||
"shutdown_leave_running_tip": {
|
||||
"de": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
|
||||
"es": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
|
||||
"fr": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
|
||||
"pt": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
|
||||
"ru": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
|
||||
"zh": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
|
||||
"ja": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
|
||||
"ko": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다."
|
||||
},
|
||||
"shutdown_kill_node": {
|
||||
"de": "Node erzwungen beenden (unsicher)", "es": "Forzar cierre del nodo (inseguro)",
|
||||
"fr": "Tuer le nœud (risqué)", "pt": "Forçar encerramento do nó (inseguro)",
|
||||
"ru": "Принудительно завершить узел (небезопасно)", "zh": "强制结束节点(不安全)",
|
||||
"ja": "ノードを強制終了(危険)", "ko": "노드 강제 종료(위험)"
|
||||
},
|
||||
"shutdown_kill_node_tip": {
|
||||
"de": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
|
||||
"es": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
|
||||
"fr": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
|
||||
"pt": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
|
||||
"ru": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
|
||||
"zh": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
|
||||
"ja": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
|
||||
"ko": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다."
|
||||
},
|
||||
"shutdown_kill_confirm_title": {
|
||||
"de": "Node erzwungen beenden?", "es": "¿Forzar el cierre del nodo?", "fr": "Tuer le nœud ?",
|
||||
"pt": "Forçar o encerramento do nó?", "ru": "Принудительно завершить узел?", "zh": "强制结束节点?",
|
||||
"ja": "ノードを強制終了しますか?", "ko": "노드를 강제 종료할까요?"
|
||||
},
|
||||
"shutdown_kill_confirm_msg": {
|
||||
"de": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
|
||||
"es": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
|
||||
"fr": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
|
||||
"pt": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
|
||||
"ru": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
|
||||
"zh": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
|
||||
"ja": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
|
||||
"ko": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다."
|
||||
},
|
||||
"shutdown_kill_confirm_yes": {
|
||||
"de": "Erzwungen beenden", "es": "Forzar cierre", "fr": "Tuer le nœud", "pt": "Forçar encerramento",
|
||||
"ru": "Завершить узел", "zh": "强制结束", "ja": "強制終了", "ko": "강제 종료"
|
||||
},
|
||||
"shutdown_stop_asking": {
|
||||
"de": "Node wird zum Beenden aufgefordert…", "es": "Pidiendo al nodo que se detenga…",
|
||||
"fr": "Demande d'arrêt envoyée au nœud…", "pt": "Pedindo ao nó para parar…",
|
||||
"ru": "Отправка узлу команды остановки…", "zh": "正在请求节点停止…",
|
||||
"ja": "ノードに停止を要求しています…", "ko": "노드에 중지를 요청하는 중…"
|
||||
},
|
||||
"shutdown_stop_queued": {
|
||||
"de": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
|
||||
"es": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
|
||||
"fr": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
|
||||
"pt": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
|
||||
"ru": "Узел получил команду остановки, но занят — он остановится, как только сможет",
|
||||
"zh": "节点已收到停止请求,但正忙——它会尽快停止",
|
||||
"ja": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
|
||||
"ko": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다"
|
||||
},
|
||||
"shutdown_stop_delivered": {
|
||||
"de": "Der Node fährt herunter und speichert seinen Zustand", "es": "El nodo se está cerrando y guardando su estado",
|
||||
"fr": "Le nœud s'arrête et enregistre son état", "pt": "O nó está encerrando e salvando seu estado",
|
||||
"ru": "Узел завершает работу и сохраняет своё состояние", "zh": "节点正在关闭并保存其状态",
|
||||
"ja": "ノードを終了し、状態を保存しています", "ko": "노드가 종료 중이며 상태를 저장하고 있습니다"
|
||||
},
|
||||
"shutdown_stop_cpu_suffix": {
|
||||
"de": " · CPU %d%%", "es": " · CPU %d%%", "fr": " · CPU %d%%", "pt": " · CPU %d%%",
|
||||
"ru": " · ЦП %d%%", "zh": " · CPU %d%%", "ja": " · CPU %d%%", "ko": " · CPU %d%%"
|
||||
},
|
||||
"shutdown_stop_idle": {
|
||||
"de": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
|
||||
"es": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
|
||||
"fr": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
|
||||
"pt": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
|
||||
"ru": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
|
||||
"zh": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
|
||||
"ja": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
|
||||
"ko": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다"
|
||||
},
|
||||
# --- The node is busy: overlay, banner, status bar ---
|
||||
"node_busy_rebuild_title": {
|
||||
"de": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
|
||||
"es": "Nodo ocupado: reconstruyendo testigos de notas",
|
||||
"fr": "Nœud occupé : reconstruction des témoins de notes",
|
||||
"pt": "Nó ocupado: reconstruindo testemunhas de notas",
|
||||
"ru": "Узел занят: перестройка свидетелей заметок",
|
||||
"zh": "节点繁忙:正在重建票据见证",
|
||||
"ja": "ノード処理中:ノートウィットネスを再構築しています",
|
||||
"ko": "노드 작업 중: 노트 위트니스 재구성 중",
|
||||
},
|
||||
"node_busy_running": {
|
||||
"de": "Läuft seit %s",
|
||||
"es": "En curso desde hace %s",
|
||||
"fr": "En cours depuis %s",
|
||||
"pt": "Em andamento há %s",
|
||||
"ru": "Выполняется %s",
|
||||
"zh": "已运行 %s",
|
||||
"ja": "経過時間 %s",
|
||||
"ko": "%s 동안 진행 중",
|
||||
},
|
||||
"node_busy_rebuild_desc": {
|
||||
"de": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
|
||||
"es": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
|
||||
"fr": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
|
||||
"pt": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
|
||||
"ru": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
|
||||
"zh": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
|
||||
"ja": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
|
||||
"ko": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다."
|
||||
},
|
||||
"node_busy_rebuild_reading": {
|
||||
"de": "Blöcke werden gelesen: %ld / %ld", "es": "Leyendo bloques: %ld / %ld", "fr": "Lecture des blocs : %ld / %ld",
|
||||
"pt": "Lendo blocos: %ld / %ld", "ru": "Чтение блоков: %ld / %ld", "zh": "正在读取区块:%ld / %ld",
|
||||
"ja": "ブロックを読み込み中:%ld / %ld", "ko": "블록 읽는 중: %ld / %ld"
|
||||
},
|
||||
"node_busy_rebuild_extending": {
|
||||
"de": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
|
||||
"es": "Ampliando testigos (el nodo no informa del progreso de este paso).",
|
||||
"fr": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
|
||||
"pt": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
|
||||
"ru": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
|
||||
"zh": "正在扩展见证(此步骤节点不报告进度)。",
|
||||
"ja": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
|
||||
"ko": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다)."
|
||||
},
|
||||
"node_busy_replay_title": {
|
||||
"de": "Node beschäftigt: Blöcke werden nachgeholt", "es": "Nodo ocupado: poniéndose al día con los bloques",
|
||||
"fr": "Nœud occupé : rattrapage des blocs", "pt": "Nó ocupado: recuperando blocos",
|
||||
"ru": "Узел занят: догоняет блоки", "zh": "节点繁忙:正在追赶区块",
|
||||
"ja": "ノード処理中:ブロックを追いかけています", "ko": "노드 작업 중: 블록 따라잡는 중"
|
||||
},
|
||||
"node_busy_replay_desc": {
|
||||
"de": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
|
||||
"es": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
|
||||
"fr": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
|
||||
"pt": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
|
||||
"ru": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
|
||||
"zh": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
|
||||
"ja": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
|
||||
"ko": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다."
|
||||
},
|
||||
"node_busy_rpc_title": {
|
||||
"de": "Node beschäftigt: antwortet noch nicht", "es": "Nodo ocupado: aún no responde",
|
||||
"fr": "Nœud occupé : ne répond pas encore", "pt": "Nó ocupado: ainda não responde",
|
||||
"ru": "Узел занят: пока не отвечает", "zh": "节点繁忙:暂未响应",
|
||||
"ja": "ノード処理中:まだ応答していません", "ko": "노드 작업 중: 아직 응답하지 않음"
|
||||
},
|
||||
"node_busy_rpc_desc": {
|
||||
"de": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
|
||||
"es": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
|
||||
"fr": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
|
||||
"pt": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
|
||||
"ru": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
|
||||
"zh": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
|
||||
"ja": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
|
||||
"ko": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다."
|
||||
},
|
||||
"node_starting_title": {
|
||||
"de": "DragonX-Node wird gestartet…", "es": "Iniciando el nodo DragonX…", "fr": "Démarrage du nœud DragonX…",
|
||||
"pt": "Iniciando o nó DragonX…", "ru": "Запуск узла DragonX…", "zh": "正在启动 DragonX 节点…",
|
||||
"ja": "DragonX ノードを起動しています…", "ko": "DragonX 노드 시작 중…"
|
||||
},
|
||||
"node_starting_busy_desc": {
|
||||
"de": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
|
||||
"es": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
|
||||
"fr": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
|
||||
"pt": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
|
||||
"ru": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
|
||||
"zh": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
|
||||
"ja": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
|
||||
"ko": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다."
|
||||
},
|
||||
"node_starting_launch_desc": {
|
||||
"de": "dragonxd wird gestartet, warte auf Verbindung…", "es": "Iniciando dragonxd y esperando a que esté en línea…",
|
||||
"fr": "Lancement de dragonxd, en attente de sa disponibilité…", "pt": "Iniciando o dragonxd e aguardando ficar online…",
|
||||
"ru": "Запуск dragonxd и ожидание его готовности…", "zh": "正在启动 dragonxd 并等待其上线…",
|
||||
"ja": "dragonxd を起動し、オンラインになるのを待っています…", "ko": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…"
|
||||
},
|
||||
"sb_node_busy": {
|
||||
"de": "Node beschäftigt", "es": "Nodo ocupado", "fr": "Nœud occupé", "pt": "Nó ocupado",
|
||||
"ru": "Узел занят", "zh": "节点繁忙", "ja": "ノード処理中", "ko": "노드 작업 중"
|
||||
},
|
||||
# --- Quitting while the node is busy ---
|
||||
"stopconfirm_rebuild_title": {
|
||||
"de": "Der Node baut die Note-Witnesses neu auf", "es": "El nodo está reconstruyendo los testigos de notas",
|
||||
"fr": "Le nœud reconstruit les témoins de notes", "pt": "O nó está reconstruindo as testemunhas de notas",
|
||||
"ru": "Узел перестраивает свидетелей заметок", "zh": "节点正在重建票据见证",
|
||||
"ja": "ノードはノートウィットネスを再構築しています", "ko": "노드가 노트 위트니스를 재구성하고 있습니다"
|
||||
},
|
||||
"stopconfirm_rebuild_body": {
|
||||
"de": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"es": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"fr": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"pt": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"ru": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_busy_title": {
|
||||
"de": "Der Node ist beschäftigt", "es": "El nodo está ocupado", "fr": "Le nœud est occupé",
|
||||
"pt": "O nó está ocupado", "ru": "Узел занят", "zh": "节点正忙", "ja": "ノードは処理中です",
|
||||
"ko": "노드가 작업 중입니다"
|
||||
},
|
||||
"stopconfirm_busy_body": {
|
||||
"de": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"es": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"fr": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"pt": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"ru": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_stop_wait": {
|
||||
"de": "Node stoppen & warten", "es": "Detener el nodo y esperar", "fr": "Arrêter le nœud et attendre",
|
||||
"pt": "Parar o nó e aguardar", "ru": "Остановить узел и подождать", "zh": "停止节点并等待",
|
||||
"ja": "ノードを停止して待つ", "ko": "노드 중지 후 대기"
|
||||
},
|
||||
"appx_daemon_reinstall_node_busy": {
|
||||
"de": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
|
||||
"es": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
|
||||
"fr": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
|
||||
"pt": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
|
||||
"ru": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
|
||||
"zh": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
|
||||
"ja": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
|
||||
"ko": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요."
|
||||
},
|
||||
# --- Balance tab ---
|
||||
"hide_zero_balances": {
|
||||
"es": "Ocultar saldos 0", "de": "Nullsalden ausblenden", "fr": "Masquer les soldes à 0",
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-compile the test suite (ObsidianDragonTests.exe) and the fake daemon (tools/fake_dragonxd)
|
||||
# for Windows with mingw-w64, then run the tests: directly under WSL (Windows interop), else with
|
||||
# wine if installed, else just build.
|
||||
#
|
||||
# scripts/test-windows.sh [--no-run] [--jobs N]
|
||||
#
|
||||
# Uses its own build/windows-tests tree (full-node config), so build/windows is left alone. Needs
|
||||
# the mingw toolchain file that ./build.sh --win-release writes to build/windows/. Dependency sources
|
||||
# already fetched into build/windows/_deps are reused, so nothing is downloaded twice.
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
bd="$repo/build/windows-tests"
|
||||
toolchain="$repo/build/windows/mingw-toolchain.cmake"
|
||||
deps="$repo/build/windows/_deps"
|
||||
run=true
|
||||
jobs=8 # each test TU is large; several parallel mingw builds can exhaust RAM
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-run) run=false; shift ;;
|
||||
--jobs) jobs="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$toolchain" ]]; then
|
||||
echo "missing $toolchain; run ./build.sh --win-release once to create the mingw toolchain" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
src_args=()
|
||||
for dep in sdl3 json tomlplusplus sqlite3 curl libwebp; do
|
||||
if [[ -d "$deps/$dep-src" ]]; then
|
||||
src_args+=("-DFETCHCONTENT_SOURCE_DIR_${dep^^}=$deps/$dep-src")
|
||||
fi
|
||||
done
|
||||
|
||||
cmake -S "$repo" -B "$bd" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_BUILD_LITE=OFF \
|
||||
-DDRAGONX_ENABLE_LITE_BACKEND=OFF \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DDRAGONX_BUILD_FAKE_DAEMON=ON \
|
||||
"${src_args[@]}"
|
||||
cmake --build "$bd" --target ObsidianDragonTests fake_dragonxd -j "$jobs"
|
||||
|
||||
exe="$bd/bin/ObsidianDragonTests.exe"
|
||||
echo "built: $exe"
|
||||
echo "built: $bd/fake_dragonxd/dragonxd.exe"
|
||||
$run || exit 0
|
||||
|
||||
# The fixture path compiled into the binary is a Linux path; hand the Windows process its own.
|
||||
# APPDATA/LOCALAPPDATA point at a sandbox so anything resolving them from the environment stays out
|
||||
# of the real profile. (SHGetFolderPath-based paths ignore the environment; the tests that would
|
||||
# write through those are compiled out on Windows.)
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$repo/tests/fixtures"
|
||||
sandbox="$bd/test-appdata"
|
||||
mkdir -p "$sandbox"
|
||||
export APPDATA="$sandbox" LOCALAPPDATA="$sandbox"
|
||||
# The fake dragonxd.exe plays a "foreign" node (isolated datadir/port) for the never-kill-by-name test.
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$bd/fake_dragonxd/dragonxd.exe"
|
||||
if grep -qi microsoft /proc/version 2>/dev/null && [[ -e /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
|
||||
# /p: translate each to a Windows path for the Windows process
|
||||
export WSLENV="${WSLENV:+$WSLENV:}DRAGONX_TEST_FIXTURE_DIR/p:APPDATA/p:LOCALAPPDATA/p:DRAGONX_TEST_FAKE_DAEMON/p"
|
||||
cd "$bd/bin"
|
||||
exec "$exe"
|
||||
elif command -v wine >/dev/null 2>&1; then
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$(winepath -w "$DRAGONX_TEST_FIXTURE_DIR")"
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$(winepath -w "$DRAGONX_TEST_FAKE_DAEMON")"
|
||||
cd "$bd/bin"
|
||||
exec wine "$exe"
|
||||
else
|
||||
echo "not on WSL and no wine: copy $exe (and tests/fixtures) to Windows to run it" >&2
|
||||
fi
|
||||
+2289
-409
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,16 @@
|
||||
#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"
|
||||
#include "data/wallet_index.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "daemon/stop_delivery.h"
|
||||
#include "daemon/daemon_busy_detect.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
@@ -71,14 +75,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
|
||||
@@ -147,6 +165,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()); }
|
||||
@@ -156,6 +186,39 @@ 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;
|
||||
|
||||
// Whether beginShutdown() should pause and confirm before stopping the daemon: this quit would stop it,
|
||||
// and it is rebuilding witnesses (its own log says so) or has been alive but too busy to answer RPC for
|
||||
// a while. Stopping mid-rebuild throws the rebuild away, and a node killed then replays and redoes it.
|
||||
bool shouldConfirmDaemonStop() const;
|
||||
// The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that
|
||||
// logged no marker, e.g. an external node whose output we can't read).
|
||||
bool rpcBusyWhileAlive() const;
|
||||
// The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a
|
||||
// while: stopping it now either waits up to an hour or makes it start over. UI thread only.
|
||||
bool nodeBusyForStop() const;
|
||||
// May quitting (or a default-policy stop) stop a node this session didn't launch? With stop_external_daemon
|
||||
// on, or when it is the node an earlier session of ours launched and left running (its ownership record
|
||||
// matches a live process). That one is stopped gracefully and waited for, never killed.
|
||||
bool mayStopExternalNode() const;
|
||||
// For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when
|
||||
// nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing.
|
||||
bool refuseIfNodeBusy();
|
||||
// Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout /
|
||||
// QueueFull) start the clock, an answer stops it, anything else leaves it alone.
|
||||
void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind);
|
||||
// Called by main.cpp: brings the window back (restore + raise) when a quit is deferred for the confirm
|
||||
// modal while minimized or behind other windows — the modal only renders in a visible frame.
|
||||
void setRaiseWindowCallback(std::function<void()> cb) { raise_window_ = std::move(cb); }
|
||||
// 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
|
||||
@@ -225,7 +288,7 @@ public:
|
||||
bool isConnected() const { return state_.connected; }
|
||||
int getBlockHeight() const { return state_.sync.blocks; }
|
||||
const std::string& getConnectionStatus() const { return connection_status_; }
|
||||
const std::string& getDaemonStatus() const { return daemon_status_; }
|
||||
std::string getDaemonStatus() const; // thread-safe copy (daemon_status_ is written off-thread)
|
||||
|
||||
// Balance info (convenience wrappers)
|
||||
double getShieldedBalance() const { return state_.shielded_balance; }
|
||||
@@ -352,6 +415,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();
|
||||
@@ -389,6 +456,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_; }
|
||||
@@ -414,6 +484,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);
|
||||
@@ -430,12 +501,22 @@ public:
|
||||
|
||||
// Embedded daemon control
|
||||
bool startEmbeddedDaemon();
|
||||
void stopEmbeddedDaemon();
|
||||
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
|
||||
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
|
||||
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
|
||||
// daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release.
|
||||
// Returns true once the RPC port is free (or we're shutting down).
|
||||
// How long stopEmbeddedDaemon() may wait, and who decides. It never kills the node on its own.
|
||||
enum class StopMode {
|
||||
AppShutdown, // quitting: no time limit; the shutdown screen offers "Leave running" (and "Kill" when stuck)
|
||||
Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting
|
||||
Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s
|
||||
};
|
||||
// Which node it may stop. Policy: our own, or an external one only when the "stop external daemon"
|
||||
// setting says so. Connected: also the node we're connected to (adopted or external), with the
|
||||
// credentials we connected with — a wallet switch, a reinstall and decrypt must free the port, the binary
|
||||
// and wallet.dat. Only a node that accepts those credentials obeys.
|
||||
enum class StopScope { Policy, Connected };
|
||||
daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background, StopScope scope = StopScope::Policy);
|
||||
// Stop the node for a wallet switch / adopt / restore / rebuild, which swap wallet.dat or relaunch on
|
||||
// -wallet=<name>. Unlike stopEmbeddedDaemon()'s default policy (which leaves an adopted/external node
|
||||
// running) it stops the node we're connected to too (StopScope::Connected). True only once the process
|
||||
// has exited and the RPC port is free; false if it is still running, or the app started quitting.
|
||||
bool stopDaemonForWalletSwitch();
|
||||
bool isEmbeddedDaemonRunning() const;
|
||||
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
|
||||
@@ -582,6 +663,8 @@ public:
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppStopRuntime;
|
||||
friend class AppStopTaskContext;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
// Global keyboard-shortcut handling, dispatched once per frame from update().
|
||||
@@ -701,6 +784,7 @@ private:
|
||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
||||
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
|
||||
// value at post time and its completion callback discards its (previous-wallet) secret if the id
|
||||
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
|
||||
@@ -711,6 +795,22 @@ private:
|
||||
// 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
|
||||
@@ -752,6 +852,7 @@ private:
|
||||
// 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.
|
||||
@@ -761,6 +862,9 @@ private:
|
||||
// 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
|
||||
@@ -782,6 +886,9 @@ public:
|
||||
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
|
||||
@@ -794,14 +901,24 @@ 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 locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||
@@ -820,6 +937,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;
|
||||
@@ -829,6 +948,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_;
|
||||
@@ -861,12 +990,27 @@ 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_;
|
||||
|
||||
// One stop session at a time (stopEmbeddedDaemon), and the shutdown screen's view of it.
|
||||
std::mutex stop_mutex_;
|
||||
std::atomic<bool> stop_session_active_{false};
|
||||
std::atomic<int> stop_user_choice_{0}; // daemon::StopUserChoice, set by the shutdown screen
|
||||
std::atomic<bool> stop_kill_offered_{false}; // the node looks stuck: the screen may offer "Kill"
|
||||
std::atomic<int> stop_idle_minutes_{0};
|
||||
std::mutex stop_client_mutex_;
|
||||
rpc::RPCClient* active_stop_client_ = nullptr; // aborted on "Leave running" so a pending attempt ends at once
|
||||
bool stop_kill_confirm_ = false;
|
||||
void chooseStopAction(daemon::StopUserChoice choice);
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
|
||||
@@ -887,6 +1031,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.
|
||||
@@ -904,6 +1091,11 @@ private:
|
||||
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
|
||||
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
|
||||
std::atomic<int> pending_delete_result_{-1};
|
||||
std::atomic<bool> pending_reinstall_node_busy_{false}; // reinstall gave up: the old node never exited
|
||||
// A worker stopped the node for an operation and it didn't exit: the operation was cancelled. Told (and,
|
||||
// for a rescan/repair, its armed state undone) on the UI thread. Bits: kStopFailed*.
|
||||
static constexpr int kStopFailedNotify = 1, kStopFailedUndoRescan = 2;
|
||||
std::atomic<int> pending_stop_failed_{0};
|
||||
|
||||
// Encryption state check timeout
|
||||
float encryption_check_timer_ = 0.0f;
|
||||
@@ -913,6 +1105,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;
|
||||
@@ -926,6 +1119,8 @@ 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
|
||||
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive 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
|
||||
@@ -979,9 +1174,58 @@ private:
|
||||
|
||||
// Embedded daemon state
|
||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||
std::string daemon_status_;
|
||||
// Written by the DaemonController state callback (which fires on EmbeddedDaemon's monitor thread)
|
||||
// AND read on the UI thread — so all access goes through set/getDaemonStatus() under this mutex.
|
||||
// getDaemonStatus() returns a COPY: a returned reference could dangle if the monitor thread
|
||||
// reassigned the string mid-read (data race / use-after-free).
|
||||
mutable std::mutex daemon_status_mutex_;
|
||||
std::string daemon_status_; // guarded by daemon_status_mutex_ — use set/getDaemonStatus()
|
||||
void setDaemonStatus(std::string s);
|
||||
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
||||
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
||||
// What the node's own output says it is doing (witness rebuild / startup replay). Fed on the UI thread
|
||||
// from the captured output; node_rebuilding_ is its snapshot for the shutdown thread's stop loop.
|
||||
daemon::DaemonBusyTracker daemon_busy_;
|
||||
std::atomic<bool> node_rebuilding_{false};
|
||||
std::atomic<bool> daemon_busy_reset_pending_{false}; // set from any thread; applied by feedDaemonBusy()
|
||||
std::chrono::steady_clock::time_point rpc_busy_since_{}; // epoch = not busy
|
||||
std::function<void()> raise_window_;
|
||||
// S6: once the node is too busy to answer (a reply timeout or a full queue), the polling clients
|
||||
// (rpc_, fast_rpc_) hold back everything that needs cs_main, and one canary request on its own
|
||||
// client waits for the node to answer again. rpc_busy_gate_ is shared with those clients.
|
||||
std::shared_ptr<rpc::RpcBusyGate> rpc_busy_gate_ = std::make_shared<rpc::RpcBusyGate>();
|
||||
rpc::ConnectRetryBackoff connect_backoff_; // S8: per-failure-kind delay before the next connect attempt
|
||||
std::thread canary_thread_;
|
||||
std::atomic<int> canary_state_{0}; // CanaryState
|
||||
std::mutex canary_mutex_;
|
||||
rpc::RPCClient* canary_client_ = nullptr; // aborted on shutdown
|
||||
rpc::ConnectRetryBackoff canary_backoff_; // a canary refused as queue-full waits before the next
|
||||
std::chrono::steady_clock::time_point canary_retry_at_{};
|
||||
void pumpRpcBusyCanary();
|
||||
// A node this session didn't launch (left running by an earlier session, or found running): feed the busy
|
||||
// tracker from its <datadir>/debug.log, while that log is tied to the live process (see
|
||||
// daemon::externalLogFeedOffset). Our own running node's output feeds the tracker directly. UI thread.
|
||||
void pumpExternalDaemonLog();
|
||||
long externalNodePid() const; // that node: our ownership record, or (POSIX) its hushd.pid; 0 if none
|
||||
bool busyTrackerIsLive() const; // the tracker describes a node that is running now
|
||||
bool ext_log_tied_ = false;
|
||||
long ext_log_pid_ = 0;
|
||||
std::uintmax_t ext_log_offset_ = 0;
|
||||
double ext_log_next_at_ = 0.0;
|
||||
void abortRpcBusyCanary();
|
||||
void feedDaemonBusy(const std::string& chunk);
|
||||
void resetDaemonBusy();
|
||||
std::chrono::steady_clock::time_point busy_first_seen_{}; // when the tracker last turned busy (UI thread)
|
||||
// What to tell the user while the node is busy (overlay, banner, quit prompt). busy=false: it isn't.
|
||||
struct NodeBusyText {
|
||||
bool busy = false;
|
||||
bool rebuilding = false;
|
||||
std::string title; // e.g. "Node busy: rebuilding note witnesses — do not close"
|
||||
std::string detail; // running time, what it's doing, progress when the node reports any
|
||||
std::string elapsed; // "12:03"
|
||||
std::string compact; // one line for the banner: "Running for 12:03 · Reading blocks: 45438 / 62583"
|
||||
};
|
||||
NodeBusyText nodeBusyText() const;
|
||||
|
||||
// Export/Import state
|
||||
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
|
||||
@@ -1019,10 +1263,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;
|
||||
@@ -1032,6 +1296,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
|
||||
@@ -1125,6 +1390,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
|
||||
@@ -1163,6 +1438,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;
|
||||
@@ -1221,8 +1501,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_;
|
||||
@@ -1288,6 +1568,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();
|
||||
@@ -1305,6 +1592,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
|
||||
@@ -1317,6 +1614,17 @@ private:
|
||||
// `reachableButBusy` is true when the probe connected but got no RPC reply (a timeout),
|
||||
// false when the daemon is merely launching (not bound yet). Returns the status title.
|
||||
std::string applyDaemonInitStatus(bool reachableButBusy);
|
||||
// The overlay text applyDaemonInitStatus last produced. It runs only on a connect failure, which a busy
|
||||
// node backs off to every 30s, so update() re-applies it once a second (the elapsed time and the
|
||||
// node's own progress tick) for as long as nothing else has replaced the status.
|
||||
std::string init_status_title_;
|
||||
bool init_status_busy_ = false;
|
||||
double status_refresh_at_ = 0.0;
|
||||
// The warm-up overlay text, from the node's last -28 message — unless its own log says it is rebuilding
|
||||
// witnesses: a startup rebuild runs under "Activating best chain...", which hides the step users must
|
||||
// not interrupt. update() re-applies it once a second too (see init_status_title_).
|
||||
void applyWarmupText(const std::string& rpcMessage);
|
||||
std::string warmup_rpc_message_;
|
||||
// Tear down a connection that died mid-session (daemon crash / restart / dropped
|
||||
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
|
||||
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
|
||||
@@ -1335,6 +1643,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;
|
||||
|
||||
+1307
-182
File diff suppressed because it is too large
Load Diff
+187
-63
@@ -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>
|
||||
@@ -210,7 +214,7 @@ public:
|
||||
: app_(app), token_(token) {}
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
|
||||
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
|
||||
bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
|
||||
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
bool cancelled() const override { return token_.cancelled(); }
|
||||
bool shuttingDown() const override { return app_.isShuttingDown(); }
|
||||
@@ -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) {
|
||||
@@ -253,22 +292,28 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (!token.cancelled() && !shutting_down_) {
|
||||
stopEmbeddedDaemon();
|
||||
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
|
||||
if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) {
|
||||
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
|
||||
} else if (!shutting_down_) {
|
||||
// Still running: never start a second node over it. The connect loop goes back to it.
|
||||
DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n");
|
||||
pending_stop_failed_.fetch_or(kStopFailedNotify);
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
|
||||
});
|
||||
} 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;
|
||||
if (refuseIfNodeBusy()) return; // encrypting ends in a node restart
|
||||
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 +323,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 +342,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 +354,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 +399,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 +419,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 +438,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 +528,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 +547,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 +580,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 +615,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 +633,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 +764,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 +956,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 +969,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 +982,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 +1087,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 +1111,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 +1165,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 +1181,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 +1270,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 +1320,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 +1388,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 +1455,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 +1486,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();
|
||||
}
|
||||
|
||||
@@ -1471,22 +1567,26 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||
if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) {
|
||||
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
|
||||
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
|
||||
!refuseIfNodeBusy()) {
|
||||
std::string passphrase(decrypt_pass_buf_);
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
wallet_security_workflow_.start(std::chrono::steady_clock::now());
|
||||
|
||||
// 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"));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1521,15 +1621,18 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Continue with step 3
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
// Stop the node and wait for its PROCESS to exit before wallet.dat is
|
||||
// renamed: dragonxd keeps the file open until it exits, which can take
|
||||
// minutes on a large wallet (a fixed 3s wait let the rename run under a
|
||||
// live node). Ours or the one we're connected to; bounded, never killed.
|
||||
const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) ==
|
||||
daemon::StopOutcome::Exited;
|
||||
if (!stopped) {
|
||||
App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it
|
||||
return [this]() {
|
||||
wallet_security_workflow_.fail(TR("appx_node_not_stopped"));
|
||||
};
|
||||
}
|
||||
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
|
||||
@@ -1553,6 +1656,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 +1705,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 +1714,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 +1724,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 +1750,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 +1840,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 +1872,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 +1970,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 +1986,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 +2019,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 +2092,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 +2108,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 +2157,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 +2177,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;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-2
@@ -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();
|
||||
|
||||
+118
-29
@@ -77,6 +77,7 @@ void App::restartWizard()
|
||||
return;
|
||||
}
|
||||
|
||||
if (refuseIfNodeBusy()) return; // the wizard stops the node
|
||||
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
|
||||
|
||||
// Reset crash counter for fresh wizard attempt
|
||||
@@ -177,8 +178,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 +295,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 +367,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 +686,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -821,7 +875,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
@@ -889,8 +943,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;
|
||||
@@ -920,9 +975,12 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
}
|
||||
wizard_stop_status_ = TR("wiz_daemon_waiting_stop");
|
||||
for (int i = 0; i < 60 && !token.cancelled(); i++) {
|
||||
// Stopped means the PROCESS is gone, not just its RPC port: dragonxd unbinds RPC
|
||||
// early in its shutdown and holds the datadir lock until it exits.
|
||||
for (int i = 0; i < 120 && !token.cancelled(); i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse() &&
|
||||
!daemon::EmbeddedDaemon::isDaemonProcessRunning()) {
|
||||
wizard_stop_status_ = TR("wiz_daemon_stopped_ok");
|
||||
wizard_stopping_external_ = false;
|
||||
return;
|
||||
@@ -1004,7 +1062,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2)) && !refuseIfNodeBusy()) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
@@ -1023,7 +1081,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2))) {
|
||||
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
@@ -1338,6 +1396,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 +1434,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 +1476,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 +1487,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);
|
||||
}
|
||||
|
||||
@@ -260,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
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
}
|
||||
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
|
||||
@@ -92,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>
|
||||
@@ -38,15 +40,32 @@ public:
|
||||
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_);
|
||||
@@ -204,6 +225,7 @@ bool Settings::load(const std::string& path)
|
||||
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"])
|
||||
@@ -231,18 +253,29 @@ 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, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
|
||||
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")) {
|
||||
@@ -289,6 +322,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>();
|
||||
@@ -450,6 +493,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_;
|
||||
@@ -476,6 +526,7 @@ bool Settings::save(const std::string& path)
|
||||
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);
|
||||
@@ -497,18 +548,27 @@ 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["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
|
||||
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_);
|
||||
|
||||
+70
-8
@@ -144,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; }
|
||||
@@ -256,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_; }
|
||||
@@ -327,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_; }
|
||||
@@ -350,6 +391,17 @@ 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; }
|
||||
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
|
||||
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
|
||||
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
|
||||
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
|
||||
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
|
||||
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -374,6 +426,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_; }
|
||||
@@ -388,6 +445,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_; }
|
||||
@@ -528,6 +588,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)
|
||||
@@ -569,23 +630,31 @@ private:
|
||||
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_;
|
||||
bool seed_migration_sweep_submitted_ = false;
|
||||
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.
|
||||
@@ -596,14 +665,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
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#pragma once
|
||||
|
||||
// Is the node busy with something that stopping it would throw away? Tracks dragonxd's own log markers,
|
||||
// in order, for two independent states:
|
||||
// - rebuilding: a witness-cache rebuild (BuildWitnessCache). It holds cs_main for up to an hour on a
|
||||
// big wallet, its second phase logs nothing and ignores shutdown, and a node killed mid-rebuild redoes
|
||||
// it (after replaying everything since its last flush) on the next start;
|
||||
// - replaying: the startup replay ("Activating best chain..."). Stopping there is safe — the daemon
|
||||
// checks for shutdown between steps and flushes — it just takes a moment.
|
||||
// A rebuild can start in the middle of a replay, so its end must not clear `replaying`, and the state is
|
||||
// order-based: it never compares timestamps (POSIX nodes log to stdout without them). Pure and
|
||||
// header-only, so the test target can feed it lines.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
/**
|
||||
* "YYYY-MM-DD HH:MM:SS ..." (dragonxd's debug.log prefix, which is UTC) -> seconds since the epoch, or 0
|
||||
* when the line has no such prefix. Days-from-civil arithmetic, not mktime(): mktime reads local time.
|
||||
*/
|
||||
inline std::int64_t parseDaemonLogUtc(const std::string& line)
|
||||
{
|
||||
int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
|
||||
if (line.size() < 19 || std::sscanf(line.c_str(), "%4d-%2d-%2d %2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6)
|
||||
return 0;
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return 0;
|
||||
// Howard Hinnant's days_from_civil.
|
||||
const int yy = y - (mo <= 2 ? 1 : 0);
|
||||
const int era = (yy >= 0 ? yy : yy - 399) / 400;
|
||||
const unsigned yoe = static_cast<unsigned>(yy - era * 400);
|
||||
const unsigned doy = (153u * static_cast<unsigned>(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast<unsigned>(d) - 1u;
|
||||
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
|
||||
const std::int64_t days = static_cast<std::int64_t>(era) * 146097 + static_cast<std::int64_t>(doe) - 719468;
|
||||
return days * 86400 + h * 3600 + mi * 60 + s;
|
||||
}
|
||||
|
||||
class DaemonBusyTracker {
|
||||
public:
|
||||
// Feed raw output: any chunking, a line split across two chunks is reassembled.
|
||||
void feed(const std::string& chunk)
|
||||
{
|
||||
for (char c : chunk) {
|
||||
if (c == '\n') {
|
||||
feedLine(carry_);
|
||||
carry_.clear();
|
||||
} else if (c != '\r') {
|
||||
carry_.push_back(c);
|
||||
if (carry_.size() > 64 * 1024) carry_.clear(); // not a log line; don't grow without bound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void feedLine(const std::string& line)
|
||||
{
|
||||
// A new daemon process, or the end of the last one: forget everything.
|
||||
if (contains(line, "DragonX version ") || contains(line, "Hush version ") || contains(line, "Shutdown: done")) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Activating best chain")) {
|
||||
replaying_ = true;
|
||||
replayStartUtc_ = parseDaemonLogUtc(line);
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Full Node Done Loading") || contains(line, "init message: Done loading")) {
|
||||
replaying_ = false;
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
const size_t read = line.find("Reading blocks for witness rebuild: ");
|
||||
if (read != std::string::npos || contains(line, "Building Witnesses for block ")) {
|
||||
if (!rebuilding_) rebuildStartUtc_ = parseDaemonLogUtc(line);
|
||||
rebuilding_ = true;
|
||||
if (read != std::string::npos) {
|
||||
long done = 0, total = 0;
|
||||
if (std::sscanf(line.c_str() + read, "Reading blocks for witness rebuild: %ld / %ld", &done, &total) == 2) {
|
||||
readDone_ = done;
|
||||
readTotal_ = total;
|
||||
}
|
||||
}
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
// The rebuild's end. UpdateTip is logged under cs_main, so one after a rebuild marker means the
|
||||
// rebuild has released it (exceptions and non-bulk paths print no end line).
|
||||
if (rebuilding_ && (contains(line, "note witness cache(s) to height") ||
|
||||
contains(line, "aborting witness rebuild") ||
|
||||
contains(line, "aborted during witness rebuild") ||
|
||||
contains(line, "aborting building witnesses") ||
|
||||
contains(line, "UpdateTip: "))) {
|
||||
rebuilding_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = 0;
|
||||
lastMarker_ = line;
|
||||
}
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
rebuilding_ = replaying_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = replayStartUtc_ = 0;
|
||||
lastMarker_.clear();
|
||||
}
|
||||
|
||||
bool rebuilding() const { return rebuilding_; }
|
||||
bool replaying() const { return replaying_; }
|
||||
bool busy() const { return rebuilding_ || replaying_; }
|
||||
long readDone() const { return readDone_; } // the last Phase-1 "reading blocks" progress, 0 if none
|
||||
long readTotal() const { return readTotal_; }
|
||||
std::int64_t rebuildStartUtc() const { return rebuildStartUtc_; } // 0: unknown (no timestamps)
|
||||
std::int64_t replayStartUtc() const { return replayStartUtc_; }
|
||||
const std::string& lastMarker() const { return lastMarker_; }
|
||||
|
||||
private:
|
||||
static bool contains(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; }
|
||||
|
||||
std::string carry_;
|
||||
bool rebuilding_ = false;
|
||||
bool replaying_ = false;
|
||||
long readDone_ = 0;
|
||||
long readTotal_ = 0;
|
||||
std::int64_t rebuildStartUtc_ = 0;
|
||||
std::int64_t replayStartUtc_ = 0;
|
||||
std::string lastMarker_;
|
||||
};
|
||||
|
||||
/**
|
||||
* The "keep the node running?" guard on quit. Prompt only when this quit would stop the node and it is
|
||||
* rebuilding witnesses, or has been alive but too busy to answer RPC for a while (a rebuild that logged
|
||||
* no marker). A replay alone never prompts: stopping it is safe. No daemon version suppresses the prompt
|
||||
* (v1.3.0 does not checkpoint a witness rebuild; nothing survives a kill).
|
||||
*/
|
||||
inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker, bool rpcBusyWhileAlive)
|
||||
{
|
||||
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to start feeding a DaemonBusyTracker from the last `window` of the debug.log of a node someone else
|
||||
* started at `processStartUtc` (UTC seconds). Only lines that process wrote count: a node started with
|
||||
* -printtoconsole leaves an old debug.log behind, whose last run may have died mid-rebuild. Returns the
|
||||
* offset of that process's startup banner in the window; 0 when its banner is older than the window
|
||||
* (`windowStartsMidFile`) and the window's first stamped line postdates the process start; npos when the
|
||||
* log isn't that process's.
|
||||
*/
|
||||
inline size_t externalLogFeedOffset(const std::string& window, bool windowStartsMidFile, std::int64_t processStartUtc)
|
||||
{
|
||||
size_t lastBanner = std::string::npos;
|
||||
std::int64_t lastBannerAt = 0, firstStamp = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < window.size()) {
|
||||
size_t end = window.find('\n', pos);
|
||||
if (end == std::string::npos) end = window.size();
|
||||
const std::string line = window.substr(pos, end - pos);
|
||||
const std::int64_t at = parseDaemonLogUtc(line);
|
||||
if (at > 0 && firstStamp == 0) firstStamp = at;
|
||||
if (line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos) {
|
||||
lastBanner = pos;
|
||||
lastBannerAt = at;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
if (processStartUtc <= 0) return std::string::npos;
|
||||
const std::int64_t since = processStartUtc - 5; // the log's whole seconds vs the process's start time
|
||||
if (lastBanner != std::string::npos) return (lastBannerAt > 0 && lastBannerAt >= since) ? lastBanner : std::string::npos;
|
||||
if (windowStartsMidFile && firstStamp > 0 && firstStamp >= since) return 0;
|
||||
return std::string::npos;
|
||||
}
|
||||
|
||||
// What the "node is starting / not answering" overlay should say, most specific first.
|
||||
enum class DaemonStatusKind {
|
||||
Rebuilding, // its own log says it's rebuilding note witnesses
|
||||
Replaying, // its own log says it's catching up on blocks it saved before (startup replay)
|
||||
InitPhase, // a recognisable init phase line (loading the block index, verifying, ...)
|
||||
Busy, // it answers the connection but not the request (reply timeout / queue full)
|
||||
Launching, // nothing bound to the RPC port yet
|
||||
};
|
||||
|
||||
inline DaemonStatusKind chooseDaemonStatus(bool hasInitPhaseLine, bool reachableButBusy,
|
||||
const DaemonBusyTracker& tracker)
|
||||
{
|
||||
if (tracker.rebuilding()) return DaemonStatusKind::Rebuilding;
|
||||
if (tracker.replaying()) return DaemonStatusKind::Replaying;
|
||||
if (hasInitPhaseLine) return DaemonStatusKind::InitPhase;
|
||||
if (reachableButBusy) return DaemonStatusKind::Busy;
|
||||
return DaemonStatusKind::Launching;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -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
|
||||
@@ -51,6 +52,11 @@ void DaemonController::stop(int waitMs)
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
void DaemonController::markStopRequested()
|
||||
{
|
||||
daemon_->markStopRequested();
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
@@ -71,9 +77,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 +134,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setReindexOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
+237
-10
@@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
#include "stop_delivery.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -61,17 +63,21 @@ public:
|
||||
class LifecycleRuntime {
|
||||
public:
|
||||
virtual ~LifecycleRuntime() = default;
|
||||
virtual void stopDaemonWithPolicy() = 0;
|
||||
// Stop the node and wait (bounded, never killing it) for it to exit. False: it is still running
|
||||
// (or we stopped waiting), and nothing that needs it gone may run.
|
||||
virtual bool stopDaemonWithPolicy() = 0;
|
||||
virtual bool startDaemon() = 0;
|
||||
virtual int deleteBlockchainData() = 0;
|
||||
virtual void resetOutputOffset() = 0;
|
||||
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
|
||||
// Disconnect and stop the node off this thread; whoever needs it gone waits for the process to exit.
|
||||
virtual void beginBackgroundStop(const char* reason) = 0;
|
||||
};
|
||||
|
||||
struct LifecycleExecutionResult {
|
||||
bool completed = false;
|
||||
bool cancelled = false;
|
||||
bool stopped = false;
|
||||
bool stopFailed = false; // the node didn't exit: the operation was cancelled before changing anything
|
||||
bool started = false;
|
||||
int deletedItems = 0;
|
||||
};
|
||||
@@ -90,12 +96,13 @@ public:
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
void stop(int waitMs);
|
||||
void markStopRequested();
|
||||
|
||||
bool isRunning() const;
|
||||
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 +115,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,
|
||||
@@ -190,22 +198,24 @@ public:
|
||||
|
||||
switch (decision.operation) {
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
// The download takes minutes; the bootstrap worker waits for the process to exit before it
|
||||
// touches chain data.
|
||||
if (decision.wasRunning) {
|
||||
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
|
||||
runtime.beginBackgroundStop("Bootstrap");
|
||||
result.stopped = true;
|
||||
}
|
||||
result.completed = true;
|
||||
return result;
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (decision.wasRunning) {
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
}
|
||||
break;
|
||||
case LifecycleOperation::Rescan:
|
||||
case LifecycleOperation::RepairWallet:
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
runtime.stopDaemonWithPolicy();
|
||||
if (decision.operation == LifecycleOperation::ManualRestart && !decision.wasRunning) break;
|
||||
// Never start a second node, or delete chain data, under one that hasn't exited.
|
||||
if (!runtime.stopDaemonWithPolicy()) {
|
||||
result.stopFailed = true;
|
||||
return result;
|
||||
}
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
@@ -232,6 +242,223 @@ public:
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Stopping the node: deliver "stop" until it lands, then wait for the process to exit. Never kill it
|
||||
// on our own: a node that is busy (a witness rebuild can hold it for an hour) or flushing would lose
|
||||
// everything since its last save and replay it on the next start. Only an explicit user "Kill",
|
||||
// offered once the node has gone idle, ends it by force; "Leave running" lets the wallet close while
|
||||
// the node finishes on its own.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
using StopUserChoice = daemon::StopUserChoice;
|
||||
using StopOutcome = daemon::StopOutcome;
|
||||
|
||||
struct StopPolicy {
|
||||
bool owned = true; // we launched it: watch the process, signal it, offer Kill
|
||||
bool gracefulSignal = false; // POSIX: a group SIGTERM is a stop channel (dragonxd treats it as stop)
|
||||
double maxWaitSec = 0.0; // stop waiting (never kill) after this long; 0 = no limit
|
||||
bool userChoices = false; // the shutdown screen offers "Leave running" / "Kill"
|
||||
bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers)
|
||||
double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill
|
||||
};
|
||||
// A bounded stop that has no way to reach the node (a node we don't own refused our credentials, or our
|
||||
// own did with every source and there is no signal) gives up after this long rather than its full budget.
|
||||
static constexpr double kUndeliverableGraceSec = 5.0;
|
||||
|
||||
// What the stop loop reports for display.
|
||||
struct StopStatus {
|
||||
bool delivered = false; // stop accepted (or the node is shutting down, or was signalled)
|
||||
bool heard = false; // the node has the request (possibly still queued)
|
||||
bool signalled = false; // a graceful signal (POSIX SIGTERM) went out
|
||||
double elapsedSec = 0.0;
|
||||
double cpuPercent = -1.0; // < 0: unknown
|
||||
double idleSec = 0.0;
|
||||
bool killOffered = false;
|
||||
bool rebuildActive = false;
|
||||
StopSendResult lastSend = StopSendResult::Other;
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
class StopRuntime {
|
||||
public:
|
||||
virtual ~StopRuntime() = default;
|
||||
virtual double nowSec() = 0;
|
||||
virtual bool processAlive() = 0;
|
||||
virtual StopSendResult trySendStop() = 0;
|
||||
virtual bool nextCredentials() = 0; // switch to the next stop credentials; false when none left
|
||||
virtual bool sendGracefulSignal() = 0; // POSIX group SIGTERM; false where there is none (Windows)
|
||||
virtual double cpuSeconds() = 0; // cumulative CPU of the node; < 0 if unknown
|
||||
virtual double ioBytes() = 0; // cumulative I/O bytes of the node; < 0 if unknown
|
||||
virtual bool rebuildActive() = 0; // a witness rebuild is running: never offer Kill
|
||||
virtual StopUserChoice userChoice() = 0;
|
||||
virtual void offerForceKill(bool offered) = 0;
|
||||
virtual void forceKill() = 0;
|
||||
virtual void setStatus(const StopStatus& status) = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Making progress" = >= 0.05 cores of CPU or >= 1 MB of I/O over the last >= 60s. A flush that
|
||||
* fsyncs a big wallet is mostly I/O at low CPU; a rebuild is mostly CPU. Idle time counts from the
|
||||
* first sample. With no readings at all (unknown), it never reports idle, so Kill is never offered.
|
||||
*/
|
||||
class StopProgressTracker {
|
||||
public:
|
||||
static constexpr double kWindowSec = 60.0;
|
||||
static constexpr double kMinCores = 0.05;
|
||||
static constexpr double kMinIoBytes = 1024.0 * 1024.0;
|
||||
|
||||
void add(double t, double cpuSec, double ioBytes)
|
||||
{
|
||||
if (cpuSec >= 0 || ioBytes >= 0) measurable_ = true;
|
||||
if (samples_.empty()) lastProgressAt_ = t;
|
||||
samples_.push_back({t, cpuSec, ioBytes});
|
||||
// Keep the newest sample that is at least a full window old as the comparison base.
|
||||
while (samples_.size() > 2 && samples_[1].t <= t - kWindowSec) samples_.pop_front();
|
||||
const Sample& base = samples_.front();
|
||||
const double span = t - base.t;
|
||||
if (span >= kWindowSec) {
|
||||
const bool cpuMoved = cpuSec >= 0 && base.cpu >= 0 && (cpuSec - base.cpu) / span >= kMinCores;
|
||||
const bool ioMoved = ioBytes >= 0 && base.io >= 0 && (ioBytes - base.io) >= kMinIoBytes;
|
||||
if (cpuMoved || ioMoved) lastProgressAt_ = t;
|
||||
}
|
||||
}
|
||||
double idleSec(double t) const { return measurable_ ? std::max(0.0, t - lastProgressAt_) : 0.0; }
|
||||
// CPU over the most recent sample interval (100 = one core), for display; < 0 unknown.
|
||||
double cpuPercent() const
|
||||
{
|
||||
if (samples_.size() < 2) return -1.0;
|
||||
const Sample& a = samples_[samples_.size() - 2];
|
||||
const Sample& b = samples_.back();
|
||||
if (a.cpu < 0 || b.cpu < 0 || b.t <= a.t) return -1.0;
|
||||
return std::max(0.0, (b.cpu - a.cpu) / (b.t - a.t) * 100.0);
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample { double t, cpu, io; };
|
||||
std::deque<Sample> samples_;
|
||||
double lastProgressAt_ = 0.0;
|
||||
bool measurable_ = false;
|
||||
};
|
||||
|
||||
enum class StopWaitDecision { Done, Wait, GiveUp, Detach, OfferForceKill, ForceKill };
|
||||
|
||||
struct StopWaitSample {
|
||||
bool processAlive = true;
|
||||
bool delivered = false;
|
||||
double elapsedSec = 0.0;
|
||||
double idleSec = 0.0;
|
||||
bool rebuildActive = false;
|
||||
StopUserChoice choice = StopUserChoice::None;
|
||||
bool killOffered = false; // the Kill offer is on screen right now
|
||||
bool shuttingDown = false; // the app began shutting down / the task was cancelled
|
||||
bool undeliverable = false; // no stop delivered, none will be sent, and no signal to send
|
||||
};
|
||||
|
||||
static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s)
|
||||
{
|
||||
if (!s.processAlive) return StopWaitDecision::Done;
|
||||
if (p.userChoices && s.choice == StopUserChoice::Detach) return StopWaitDecision::Detach;
|
||||
if (p.userChoices && p.owned && s.choice == StopUserChoice::Kill && s.killOffered)
|
||||
return StopWaitDecision::ForceKill;
|
||||
if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp;
|
||||
if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp;
|
||||
if (p.maxWaitSec > 0 && s.undeliverable && s.elapsedSec >= kUndeliverableGraceSec)
|
||||
return StopWaitDecision::GiveUp;
|
||||
if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec)
|
||||
return StopWaitDecision::OfferForceKill;
|
||||
return StopWaitDecision::Wait;
|
||||
}
|
||||
|
||||
static constexpr int kStopTickMs = 250;
|
||||
|
||||
static inline StopOutcome executeDaemonStop(const StopPolicy& policy, StopRuntime& rt,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
if (!rt.processAlive()) return StopOutcome::Exited; // already gone: not a single RPC call
|
||||
|
||||
const double t0 = rt.nowSec();
|
||||
StopDeliveryPolicy delivery(policy.owned);
|
||||
StopProgressTracker progress;
|
||||
bool signalled = false;
|
||||
bool offered = false;
|
||||
auto withdrawOffer = [&] {
|
||||
if (offered) { offered = false; rt.offerForceKill(false); }
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
double now = rt.nowSec() - t0;
|
||||
if (delivery.shouldSend(now)) {
|
||||
const StopSendResult r = delivery.record(rt.trySendStop(), now);
|
||||
if (r == StopSendResult::Auth && policy.owned) {
|
||||
// Our own node refusing our credentials means they're stale (e.g. a regenerated
|
||||
// .cookie): try the next source; out of sources, the graceful signal below remains.
|
||||
if (rt.nextCredentials()) delivery.retryNow(rt.nowSec() - t0);
|
||||
else delivery.giveUp();
|
||||
}
|
||||
// POSIX: a group SIGTERM bypasses the RPC queue altogether (dragonxd's handler sets the same
|
||||
// flag as stop). Send it as soon as an attempt shows stop didn't get through.
|
||||
if (policy.gracefulSignal && !signalled && !delivery.delivered() &&
|
||||
r != StopSendResult::Queued && rt.sendGracefulSignal()) {
|
||||
signalled = true;
|
||||
delivery.markDelivered();
|
||||
}
|
||||
}
|
||||
if (!rt.processAlive()) {
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
}
|
||||
|
||||
now = rt.nowSec() - t0;
|
||||
progress.add(now, rt.cpuSeconds(), rt.ioBytes());
|
||||
StopWaitSample s;
|
||||
s.delivered = delivery.delivered();
|
||||
s.elapsedSec = now;
|
||||
s.idleSec = progress.idleSec(now);
|
||||
s.rebuildActive = rt.rebuildActive();
|
||||
s.choice = rt.userChoice();
|
||||
s.killOffered = offered;
|
||||
s.shuttingDown = task.cancelled() || task.shuttingDown();
|
||||
s.undeliverable = delivery.finished() && !delivery.delivered() && !signalled &&
|
||||
!(policy.gracefulSignal && policy.owned);
|
||||
|
||||
switch (evaluateStopWait(policy, s)) {
|
||||
case StopWaitDecision::Done:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
case StopWaitDecision::Detach:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Detached;
|
||||
case StopWaitDecision::ForceKill:
|
||||
withdrawOffer();
|
||||
rt.forceKill();
|
||||
return StopOutcome::Killed;
|
||||
case StopWaitDecision::GiveUp:
|
||||
withdrawOffer();
|
||||
return StopOutcome::StillRunning;
|
||||
case StopWaitDecision::OfferForceKill:
|
||||
if (!offered) { offered = true; rt.offerForceKill(true); }
|
||||
break;
|
||||
case StopWaitDecision::Wait:
|
||||
withdrawOffer(); // progress resumed (or a rebuild started): the node isn't stuck after all
|
||||
break;
|
||||
}
|
||||
|
||||
StopStatus st;
|
||||
st.delivered = delivery.delivered();
|
||||
st.heard = delivery.heard();
|
||||
st.signalled = signalled;
|
||||
st.elapsedSec = now;
|
||||
st.cpuPercent = progress.cpuPercent();
|
||||
st.idleSec = s.idleSec;
|
||||
st.killOffered = offered;
|
||||
st.rebuildActive = s.rebuildActive;
|
||||
st.lastSend = delivery.last();
|
||||
st.attempts = delivery.attempts();
|
||||
rt.setStatus(st);
|
||||
task.sleepForMs(kStopTickMs);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
+753
-165
File diff suppressed because it is too large
Load Diff
+231
-13
@@ -4,6 +4,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
@@ -55,11 +58,63 @@ public:
|
||||
bool start(const std::string& binary_path = "");
|
||||
|
||||
/**
|
||||
* @brief Stop the running daemon
|
||||
* @brief Stop the daemon process this object launched (never any other dragonxd)
|
||||
* @param wait_ms Maximum milliseconds to wait for graceful shutdown
|
||||
* @return true if it exited on its own (or there was nothing to stop), false if it had to be
|
||||
* force-killed — then its wallet/chain files may not have been closed cleanly
|
||||
*/
|
||||
void stop(int wait_ms = 5000);
|
||||
|
||||
bool stop(int wait_ms = 5000);
|
||||
|
||||
/**
|
||||
* @brief Record that we are about to ask the daemon to stop (e.g. over RPC)
|
||||
*
|
||||
* Call it before sending the stop. If the daemon then exits before stop() runs, the monitor
|
||||
* records a clean stop instead of a crash (crash_count_ drives auto-restart). stop() sets it
|
||||
* too; start() clears it.
|
||||
*/
|
||||
void markStopRequested() { stop_requested_ = true; }
|
||||
|
||||
// --- A stop session, driven by DaemonController::executeDaemonStop through the app's StopRuntime ---
|
||||
|
||||
/** Marks the stop as requested and stops the monitor, so the session is the only reaper.
|
||||
* @return true if a process this object launched is still alive; then finish with endStop(). */
|
||||
bool beginStop();
|
||||
/** Polls our own process (reaping it on POSIX) and drains its output; false once it has exited. */
|
||||
bool pollOwnedProcess();
|
||||
/** POSIX: SIGTERM our process group (dragonxd treats it like RPC stop). Windows has no such channel. */
|
||||
bool signalGracefulStop();
|
||||
/** Ends our own process by force (SIGKILL / TerminateProcess) and reaps it. Explicit user choice only. */
|
||||
void forceKillOwnedProcess();
|
||||
struct ResourceUsage {
|
||||
double cpuSeconds = -1.0; // cumulative; < 0 if unknown
|
||||
double ioBytes = -1.0; // cumulative storage I/O; < 0 if unknown
|
||||
};
|
||||
/** CPU and I/O used so far by our process (its whole process group on Linux). */
|
||||
ResourceUsage ownedResourceUsage() const;
|
||||
/** Finishes a stop session. The process is gone: release everything. Still running: keepWatching
|
||||
* resumes the monitor (its exit is still seen, and booked as a clean stop); otherwise let it go —
|
||||
* the user chose to leave it running. */
|
||||
void endStop(bool keepWatching);
|
||||
|
||||
/**
|
||||
* @brief Parse a /proc/<pid>/stat line: process group (field 5) and utime+stime ticks (fields 14+15)
|
||||
*
|
||||
* comm (field 2) is parenthesised and may itself contain spaces and ')', so fields are read from
|
||||
* after the LAST ')'.
|
||||
*/
|
||||
static bool parseProcStatLine(const std::string& line, long& pgrp, unsigned long long& cpuTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
unsigned long long utime = 0, stime = 0;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
if (std::sscanf(line.c_str() + close + 1, " %*c %*d %ld %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu",
|
||||
&pgrp, &utime, &stime) != 3)
|
||||
return false;
|
||||
cpuTicks = utime + stime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if daemon process is running
|
||||
*/
|
||||
@@ -79,7 +134,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)
|
||||
@@ -91,16 +148,26 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Get new output since a given offset (thread-safe).
|
||||
* Returns the new text and updates offset to the current size.
|
||||
* Returns the new text and advances offset. Offsets are absolute positions in everything ever
|
||||
* appended (start from 0), so trimming old output never shifts them: a reader that falls behind a
|
||||
* trim only loses the trimmed part, not output that is still buffered.
|
||||
*/
|
||||
std::string getOutputSince(size_t& offset) const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
if (offset >= process_output_.size()) {
|
||||
offset = process_output_.size();
|
||||
return outputSince(process_output_, output_total_, offset);
|
||||
}
|
||||
|
||||
/** getOutputSince() on a buffer holding the LAST buffer.size() of `total` bytes ever appended. */
|
||||
static std::string outputSince(const std::string& buffer, size_t total, size_t& offset)
|
||||
{
|
||||
const size_t base = total - buffer.size(); // absolute position of buffer[0]
|
||||
if (offset < base) offset = base; // trimmed before it was read: resume at what's left
|
||||
if (offset >= total) {
|
||||
offset = total;
|
||||
return {};
|
||||
}
|
||||
std::string result = process_output_.substr(offset);
|
||||
offset = process_output_.size();
|
||||
std::string result = buffer.substr(offset - base);
|
||||
offset = total;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -180,6 +247,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 +274,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 +310,135 @@ public:
|
||||
*/
|
||||
static bool isDaemonProcessRunning();
|
||||
|
||||
/**
|
||||
* @brief Does a /proc entry (its comm + its exe link's basename) belong to a dragonxd? (Linux)
|
||||
*
|
||||
* comm alone misses a node in its final shutdown phase: Shutdown() renames the main thread to
|
||||
* "hush-shutoff" while the process still holds the datadir lock. The exe link keeps the binary
|
||||
* name, with " (deleted)" appended once the binary was replaced underneath it (reinstall/update).
|
||||
*/
|
||||
static bool isDaemonProcessIdentity(const std::string& comm, std::string exeName)
|
||||
{
|
||||
static const std::string kDeleted = " (deleted)";
|
||||
if (exeName.size() > kDeleted.size() &&
|
||||
exeName.compare(exeName.size() - kDeleted.size(), kDeleted.size(), kDeleted) == 0)
|
||||
exeName.erase(exeName.size() - kDeleted.size());
|
||||
return comm == "dragonxd" || exeName == "dragonxd";
|
||||
}
|
||||
|
||||
// ---- Ownership record --------------------------------------------------------------------------
|
||||
// <datadir>/obsidiandragon-dragonxd.pid names the node this wallet launched, so a later session that
|
||||
// finds it still running (after "Leave running") knows it is ours: it may stop it gracefully and wait
|
||||
// for it, never kill it. The start token (when that process started) defeats PID reuse and makes a
|
||||
// record from before a reboot stale.
|
||||
struct OwnershipRecord {
|
||||
long pid = 0;
|
||||
std::string startToken;
|
||||
std::string exePath;
|
||||
};
|
||||
static std::string formatOwnershipRecord(const OwnershipRecord& r)
|
||||
{
|
||||
return "pid=" + std::to_string(r.pid) + "\nstart=" + r.startToken + "\nexe=" + r.exePath + "\n";
|
||||
}
|
||||
static bool parseOwnershipRecord(const std::string& text, OwnershipRecord& out)
|
||||
{
|
||||
OwnershipRecord r;
|
||||
size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
size_t end = text.find('\n', pos);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
std::string line = text.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.rfind("pid=", 0) == 0) r.pid = std::strtol(line.c_str() + 4, nullptr, 10);
|
||||
else if (line.rfind("start=", 0) == 0) r.startToken = line.substr(6);
|
||||
else if (line.rfind("exe=", 0) == 0) r.exePath = line.substr(4);
|
||||
}
|
||||
if (r.pid <= 0 || r.startToken.empty()) return false;
|
||||
out = r;
|
||||
return true;
|
||||
}
|
||||
/** Linux /proc/<pid>/stat: the process state and its start time (clock ticks after boot, field 22). */
|
||||
static bool parseProcStatStart(const std::string& line, char& state, unsigned long long& startTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime
|
||||
// priority nice num_threads itrealvalue starttime
|
||||
return std::sscanf(line.c_str() + close + 1,
|
||||
" %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu",
|
||||
&state, &startTicks) == 2;
|
||||
}
|
||||
/**
|
||||
* dragonxd shrinks a large debug.log at startup (it keeps the tail), so the file can become smaller
|
||||
* than where we were reading. Resume at THIS run's startup banner — the first banner line stamped at or
|
||||
* after `launchUtc` (UTC) — not at 0, which would replay the previous run's lines (old rebuild markers,
|
||||
* old corruption errors) as if they were new. No such banner yet: skip to the end (it follows).
|
||||
*/
|
||||
static size_t shrinkResumeOffset(const std::string& content, std::int64_t launchUtc);
|
||||
|
||||
/** When process `pid` started, as an opaque token; "" if it isn't running (or is a zombie). */
|
||||
static std::string processStartToken(long pid);
|
||||
/** When process `pid` started, in UTC seconds; 0 if unknown. */
|
||||
static std::int64_t processStartUtc(long pid);
|
||||
/** Is process `pid` a dragonxd (by name / executable)? */
|
||||
static bool processIsDaemon(long pid);
|
||||
static std::string ownershipRecordPath(); // <datadir>/obsidiandragon-dragonxd.pid
|
||||
static void writeOwnershipRecord(const std::string& path, const OwnershipRecord& r);
|
||||
/** The record at `path`, if its process is still running; a stale or unreadable record is deleted. */
|
||||
static bool liveOwnershipRecord(const std::string& path, OwnershipRecord* out = nullptr);
|
||||
|
||||
/**
|
||||
* Does this DRAGONX.conf text set `key` on an active line ("key=value", spaces allowed around '=';
|
||||
* '#' starts a comment)? Used before adding a flag of our own: a command-line flag overrides the conf,
|
||||
* so the user's own setting must win.
|
||||
*/
|
||||
static bool confSetsKey(const std::string& confText, const std::string& key)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos < confText.size()) {
|
||||
size_t end = confText.find('\n', pos);
|
||||
if (end == std::string::npos) end = confText.size();
|
||||
std::string line = confText.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
const size_t hash = line.find('#');
|
||||
if (hash != std::string::npos) line.erase(hash);
|
||||
const size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
size_t b = line.find_first_not_of(" \t\r");
|
||||
size_t e = line.find_last_not_of(" \t\r", eq == 0 ? 0 : eq - 1);
|
||||
if (b == std::string::npos || b >= eq || e == std::string::npos || e < b) continue;
|
||||
if (line.compare(b, e - b + 1, key) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 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,33 +457,47 @@ 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_;
|
||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||
StateCallback state_callback_;
|
||||
|
||||
// Process handle
|
||||
#ifdef _WIN32
|
||||
HANDLE process_handle_ = nullptr;
|
||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||
std::string debug_log_path_; // path to daemon's debug.log for output tailing
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||
std::string launch_binary_; // binary path used (for diagnostics)
|
||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1;
|
||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||
void tailDebugLog();
|
||||
|
||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||
// after monitor_thread_ is joined, so that stop() is the sole reaper.
|
||||
bool reapWithin(pid_t pid, int timeout_ms, int& status);
|
||||
#endif
|
||||
|
||||
|
||||
// The node's output is its <datadir>/debug.log (timestamped; it outlives us if the node is left running).
|
||||
std::string debug_log_path_;
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::int64_t launch_utc_ = 0; // when we launched it (to find this run's banner after a log shrink)
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
|
||||
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
|
||||
|
||||
@@ -17,6 +17,21 @@ namespace daemon {
|
||||
|
||||
namespace {
|
||||
|
||||
// Asks the isolated node to stop over its own RPC (port + throwaway credentials, never the main node's)
|
||||
// and waits for it to exit. Without the RPC stop it was only ever force-killed, holding the wallet.dat
|
||||
// it had just created. Returns false if it still had to be force-killed.
|
||||
bool stopIsolatedNode(EmbeddedDaemon& node, rpc::RPCClient& cli, int port, const std::string& user,
|
||||
const std::string& pass, int waitMs)
|
||||
{
|
||||
node.markStopRequested();
|
||||
// connect() also succeeds against a node still in warmup, which accepts stop too.
|
||||
if (cli.isConnected() || cli.connect("127.0.0.1", std::to_string(port), user, pass, /*useTls=*/false)) {
|
||||
try { cli.call("stop", nlohmann::json::array(), 10L); } catch (const std::exception&) {}
|
||||
}
|
||||
cli.disconnect();
|
||||
return node.stop(waitMs);
|
||||
}
|
||||
|
||||
// Random alphanumeric token for the isolated node's throwaway RPC credentials (libsodium CSPRNG;
|
||||
// sodium_init() has already run at app startup for the chat crypto).
|
||||
std::string randomToken(int n)
|
||||
@@ -54,6 +69,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; }
|
||||
@@ -103,7 +128,7 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
if (!ready) {
|
||||
r.error = "The isolated node did not become ready in time.";
|
||||
temp.stop(20000);
|
||||
stopIsolatedNode(temp, cli, port, user, pass, 20000); // datadir is thrown away: a forced stop is harmless
|
||||
fs::remove_all(base, ec);
|
||||
return r;
|
||||
}
|
||||
@@ -136,18 +161,24 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly). Adopt later copies
|
||||
// this wallet.dat, so it must have been closed cleanly: a node that had to be force-killed
|
||||
// fails the creation (nothing has been swept into it yet, so nothing is lost by retrying).
|
||||
if (!stopIsolatedNode(temp, cli, port, user, pass, 60000) && r.ok) {
|
||||
r.ok = false;
|
||||
r.error = "The isolated node had to be force-stopped, so its new wallet can't be trusted. "
|
||||
"Please try again.";
|
||||
}
|
||||
|
||||
// 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.
|
||||
// later step failed (empty address, z_getnewaddress threw, or an unclean stop), 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);
|
||||
|
||||
// 8. Keep the temp wallet for a later sweep/adopt step, or scrub it. tempDatadir is the
|
||||
// migration root `base`; the new wallet.dat lives in <base>/DRAGONX.
|
||||
r.tempDatadir = base;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
|
||||
// Delivering "stop" to dragonxd: what one attempt achieved, and when to send another. Pure and
|
||||
// header-only so the test target can drive it without the RPC client.
|
||||
//
|
||||
// Why retrying matters: dragonxd's HTTP server has 8 workers and a 16-deep queue. While a long
|
||||
// operation holds cs_main, the GUI's own abandoned polls can fill every slot, and each new request is
|
||||
// refused at once with "Work queue depth exceeded" until a slot frees. The old code made one or two
|
||||
// attempts, gated on a getinfo probe that needs cs_main, so stop was never delivered and the node was
|
||||
// then killed.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// What the user picked on the shutdown screen while the node was stopping.
|
||||
enum class StopUserChoice { None, Detach, Kill };
|
||||
|
||||
// How a stop session ended. The wallet never kills the node unless the user chose Kill.
|
||||
enum class StopOutcome {
|
||||
Exited, // the process is gone
|
||||
StillRunning, // we stopped waiting (time budget, app shutdown, cancellation); never killed
|
||||
Detached, // the user chose to leave it running
|
||||
Killed, // the user chose Kill
|
||||
};
|
||||
|
||||
inline const char* stopOutcomeName(StopOutcome o)
|
||||
{
|
||||
switch (o) {
|
||||
case StopOutcome::Exited: return "Exited";
|
||||
case StopOutcome::StillRunning: return "StillRunning";
|
||||
case StopOutcome::Detached: return "Detached";
|
||||
case StopOutcome::Killed: return "Killed";
|
||||
}
|
||||
return "Exited";
|
||||
}
|
||||
|
||||
enum class StopSendResult {
|
||||
Accepted, // HTTP 200: the daemon ran stop
|
||||
Queued, // reply timeout: it reached the daemon and waits for a worker; it will run
|
||||
QueueFull, // "Work queue depth exceeded": refused before queueing (holds no slot)
|
||||
NotListening, // refused / connect timeout: RPC isn't bound (not up yet, or already unbound)
|
||||
ShuttingDown, // 503 / "Unhandled request": the daemon is already shutting down
|
||||
Auth, // 401: our credentials are wrong or stale
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* stopSendResultName(StopSendResult r)
|
||||
{
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted: return "Accepted";
|
||||
case StopSendResult::Queued: return "Queued";
|
||||
case StopSendResult::QueueFull: return "QueueFull";
|
||||
case StopSendResult::NotListening: return "NotListening";
|
||||
case StopSendResult::ShuttingDown: return "ShuttingDown";
|
||||
case StopSendResult::Auth: return "Auth";
|
||||
case StopSendResult::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// What a stop call's failure kind (RpcFailureKind::None = HTTP 200) means for delivery.
|
||||
inline StopSendResult stopSendResultFromKind(rpc::RpcFailureKind kind)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return StopSendResult::Accepted;
|
||||
case K::ReplyTimeout: return StopSendResult::Queued;
|
||||
case K::QueueFull: return StopSendResult::QueueFull;
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout: return StopSendResult::NotListening;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return StopSendResult::ShuttingDown;
|
||||
case K::Auth: return StopSendResult::Auth;
|
||||
default: return StopSendResult::Other;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When to (re)send stop. Times are seconds on the caller's clock.
|
||||
*
|
||||
* - Accepted / ShuttingDown: delivered, never resend.
|
||||
* - Queued: the daemon has it. Resend at most every 30s: each resend takes another queue slot, and
|
||||
* concurrent stops race on the miner threads (stop's GenerateBitcoins(false)).
|
||||
* - QueueFull / NotListening / Other: not delivered, cheap to retry: again after 1.5s.
|
||||
* - NotListening AFTER the daemon had heard us means it unbound its listener to shut down: delivered.
|
||||
* - Auth: give up for a node we don't own. For our own node the caller may switch credentials and
|
||||
* retry at once (retryNow), or give up (giveUp).
|
||||
*/
|
||||
class StopDeliveryPolicy {
|
||||
public:
|
||||
static constexpr double kRetrySec = 1.5;
|
||||
static constexpr double kQueuedResendSec = 30.0;
|
||||
|
||||
explicit StopDeliveryPolicy(bool owned) : owned_(owned) {}
|
||||
|
||||
bool shouldSend(double now) const { return !finished_ && now >= nextSendAt_; }
|
||||
|
||||
// Records one attempt; returns it as interpreted (a refusal after we were heard reads as ShuttingDown).
|
||||
StopSendResult record(StopSendResult r, double now)
|
||||
{
|
||||
++attempts_;
|
||||
if (r == StopSendResult::NotListening && heard_) r = StopSendResult::ShuttingDown;
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted:
|
||||
heard_ = true;
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::ShuttingDown:
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::Queued:
|
||||
heard_ = true;
|
||||
nextSendAt_ = now + kQueuedResendSec;
|
||||
break;
|
||||
case StopSendResult::Auth:
|
||||
authRejected_ = true;
|
||||
if (!owned_) finished_ = true;
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
case StopSendResult::QueueFull:
|
||||
case StopSendResult::NotListening:
|
||||
case StopSendResult::Other:
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
}
|
||||
last_ = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
void markDelivered() { delivered_ = finished_ = true; } // also for a graceful signal (POSIX SIGTERM)
|
||||
void retryNow(double now) { nextSendAt_ = now; }
|
||||
void giveUp() { finished_ = true; }
|
||||
|
||||
bool delivered() const { return delivered_; } // the daemon is stopping (or was signalled)
|
||||
bool heard() const { return heard_; } // it has (or ran) our request
|
||||
bool finished() const { return finished_; } // no more attempts
|
||||
bool authRejected() const { return authRejected_; }
|
||||
int attempts() const { return attempts_; }
|
||||
StopSendResult last() const { return last_; }
|
||||
|
||||
private:
|
||||
bool owned_ = true;
|
||||
bool delivered_ = false;
|
||||
bool heard_ = false;
|
||||
bool finished_ = false;
|
||||
bool authRejected_ = false;
|
||||
int attempts_ = 0;
|
||||
double nextSendAt_ = 0.0;
|
||||
StopSendResult last_ = StopSendResult::Other;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
+107
-66
@@ -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,23 +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, std::ios::trunc);
|
||||
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;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
|
||||
// address, worker name). The file is still empty here, so the config is never
|
||||
// world-readable — closing the window between creation and the previous post-write chmod.
|
||||
chmod(outPath.c_str(), 0600);
|
||||
#endif
|
||||
ofs << j.dump(4);
|
||||
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;
|
||||
}
|
||||
@@ -236,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
|
||||
{
|
||||
@@ -265,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;
|
||||
@@ -293,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");
|
||||
@@ -368,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;
|
||||
}
|
||||
@@ -400,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;
|
||||
}
|
||||
@@ -451,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]);
|
||||
@@ -629,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;
|
||||
}
|
||||
}
|
||||
@@ -640,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;
|
||||
}
|
||||
}
|
||||
@@ -780,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");
|
||||
|
||||
@@ -858,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_;
|
||||
|
||||
+23
-14
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
|
||||
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
|
||||
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
|
||||
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
|
||||
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
|
||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||
|
||||
} // 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);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-7
@@ -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"; }
|
||||
@@ -145,7 +149,13 @@ struct SyncInfo {
|
||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||
|
||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
|
||||
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
|
||||
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
|
||||
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
|
||||
bool tip_known = false;
|
||||
|
||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -252,12 +262,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 +318,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 +342,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 +353,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
|
||||
|
||||
+154
-15
@@ -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);
|
||||
@@ -1203,6 +1319,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Create application instance
|
||||
dragonx::App app;
|
||||
// A quit deferred for the "keep the node running?" prompt brings the window back so it can be seen.
|
||||
app.setRaiseWindowCallback([window]() {
|
||||
SDL_RestoreWindow(window);
|
||||
SDL_RaiseWindow(window);
|
||||
});
|
||||
if (!app.init()) {
|
||||
fprintf(stderr, "Failed to initialize application!\n");
|
||||
#ifdef _WIN32
|
||||
@@ -1384,6 +1505,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 +1589,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 +1719,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 +1740,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();
|
||||
@@ -2013,10 +2152,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Watchdog: if cleanup takes too long the process lingers without a
|
||||
// window, showing up as a "Background Service" in Task Manager.
|
||||
// Force-exit after timeout — all critical state (settings, daemon
|
||||
// stop) was handled in beginShutdown().
|
||||
// Allow enough time for the daemon to shut down gracefully (up to
|
||||
// 10s in stop()) plus RPC disconnect overhead.
|
||||
// Force-exit after timeout. The daemon stop is already over by the time the render loop exits: the
|
||||
// node has exited, or the user chose to leave it running (it carries on as its own process), so this
|
||||
// only bounds the remaining thread joins and RPC disconnects — it never cuts a node's shutdown short.
|
||||
std::thread([]() {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(15));
|
||||
fflush(stdout);
|
||||
@@ -2042,6 +2180,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";
|
||||
|
||||
+161
-29
@@ -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:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#pragma once
|
||||
|
||||
// Keeping the GUI from flooding a busy node (S6). dragonxd's HTTP server has 8 workers and a 16-deep
|
||||
// queue. While a long operation holds cs_main (a witness rebuild can take an hour), every request that
|
||||
// needs cs_main waits, and a client that gives up does NOT free its slot. The GUI's polls used to time
|
||||
// out and be re-sent until all 24 slots were its own abandoned requests, after which even "stop" was
|
||||
// refused. The gate stops that: after the first sign the node is busy (a reply timeout, or a queue-full
|
||||
// refusal), the GUI's clients send nothing that needs cs_main until a single "canary" request (which
|
||||
// waits as long as it takes) gets through. Pure and header-only; shared by the GUI's RPC clients.
|
||||
|
||||
#include "rpc_failure.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// Kinds that mean "reached a node that is too busy to answer", as opposed to "no node there".
|
||||
inline bool rpcKindMeansBusy(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull ||
|
||||
kind == RpcFailureKind::Deferred;
|
||||
}
|
||||
|
||||
// Kinds that mean the connection itself is in doubt (counted toward a reconnect). Busy kinds, a
|
||||
// warmup reply, an application error and our own abort are not: the node is there.
|
||||
inline bool rpcKindMeansLost(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None:
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::AppError:
|
||||
case RpcFailureKind::Aborted:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class RpcBusyGate {
|
||||
public:
|
||||
// Methods dragonxd runs without cs_main: always sent, and their results say nothing about cs_main.
|
||||
static bool isLockFree(const std::string& method)
|
||||
{
|
||||
return method == "stop" || method == "help" || method == "getrescaninfo";
|
||||
}
|
||||
|
||||
// May this request be sent now? Lock-free methods always; the rest only while the node isn't busy.
|
||||
bool admit(const std::string& method) const
|
||||
{
|
||||
if (isLockFree(method)) return true;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return !busy_;
|
||||
}
|
||||
|
||||
// How a sent request went. Any real answer (even an error) means the node is serving cs_main
|
||||
// requests again; a reply timeout or a queue-full refusal means it isn't. Other failures (nothing
|
||||
// listening, auth, our own abort) say nothing about busyness and leave the state alone.
|
||||
void record(const std::string& method, RpcFailureKind kind)
|
||||
{
|
||||
if (isLockFree(method)) return;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (kind == RpcFailureKind::None || kind == RpcFailureKind::AppError || kind == RpcFailureKind::Warmup) {
|
||||
clearLocked();
|
||||
} else if (kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull) {
|
||||
if (!busy_) {
|
||||
busy_ = true;
|
||||
since_ = std::chrono::steady_clock::now();
|
||||
++busyPeriods_;
|
||||
}
|
||||
if (kind == RpcFailureKind::ReplyTimeout) ++abandoned_; // it still holds a daemon slot
|
||||
}
|
||||
}
|
||||
|
||||
// The canary got through (or the node is gone): poll normally again.
|
||||
void clear()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
clearLocked();
|
||||
}
|
||||
|
||||
bool busy() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busy_;
|
||||
}
|
||||
// Requests we gave up on that the node may still be holding, since the gate last cleared.
|
||||
int abandoned() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return abandoned_;
|
||||
}
|
||||
// Increments each time the gate turns busy: lets the owner arm exactly one canary per busy period.
|
||||
int busyPeriods() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busyPeriods_;
|
||||
}
|
||||
double busySeconds() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (!busy_) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - since_).count();
|
||||
}
|
||||
|
||||
private:
|
||||
void clearLocked()
|
||||
{
|
||||
busy_ = false;
|
||||
abandoned_ = 0;
|
||||
}
|
||||
|
||||
mutable std::mutex mx_;
|
||||
bool busy_ = false;
|
||||
int abandoned_ = 0;
|
||||
int busyPeriods_ = 0;
|
||||
std::chrono::steady_clock::time_point since_{};
|
||||
};
|
||||
|
||||
/**
|
||||
* How long to wait before the next connection attempt after a failure of this kind. Each repeat of the
|
||||
* same kind waits longer, up to its cap; a success or a different kind starts over.
|
||||
* nothing listening / connect timeout: 1, 2, 5s (the node is starting, or gone)
|
||||
* queue full: 2, 4, 8, 16, 30s (the queue is someone's; hammering it only adds to it)
|
||||
* busy (reply timeout / deferred): 30s; the canary brings the connection back as soon as it can
|
||||
* anything else: 4s
|
||||
*/
|
||||
class ConnectRetryBackoff {
|
||||
public:
|
||||
float next(RpcFailureKind kind)
|
||||
{
|
||||
streak_ = (kind == last_) ? streak_ + 1 : 0;
|
||||
last_ = kind;
|
||||
switch (kind) {
|
||||
case RpcFailureKind::NotListening:
|
||||
case RpcFailureKind::ConnectTimeout: {
|
||||
static const float steps[] = {1.0f, 2.0f, 5.0f};
|
||||
return steps[streak_ < 2 ? streak_ : 2];
|
||||
}
|
||||
case RpcFailureKind::QueueFull: {
|
||||
static const float steps[] = {2.0f, 4.0f, 8.0f, 16.0f, 30.0f};
|
||||
return steps[streak_ < 4 ? streak_ : 4];
|
||||
}
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::Deferred:
|
||||
return 30.0f;
|
||||
default:
|
||||
return 4.0f;
|
||||
}
|
||||
}
|
||||
void reset()
|
||||
{
|
||||
streak_ = 0;
|
||||
last_ = RpcFailureKind::None;
|
||||
}
|
||||
|
||||
private:
|
||||
RpcFailureKind last_ = RpcFailureKind::None;
|
||||
int streak_ = 0;
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
+117
-68
@@ -146,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)
|
||||
@@ -159,6 +163,17 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!open(host, port, user, password, useTls)) return false;
|
||||
return probeConnection(host);
|
||||
}
|
||||
|
||||
bool RPCClient::open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false; // only a successful probe (connect()) sets these
|
||||
warming_up_ = false;
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
@@ -166,6 +181,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());
|
||||
|
||||
@@ -193,6 +209,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());
|
||||
@@ -212,13 +229,18 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// 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);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RPCClient::probeConnection(const std::string& host)
|
||||
{
|
||||
// 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
|
||||
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
|
||||
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
|
||||
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
|
||||
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
|
||||
last_connect_kind_ = RpcFailureKind::Other; // until the probe says otherwise
|
||||
try {
|
||||
json result = call("getinfo", json::array(), probeTimeout);
|
||||
if (result.contains("version")) {
|
||||
@@ -226,36 +248,32 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_kind_ = RpcFailureKind::None;
|
||||
last_connect_info_ = result;
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_.clear();
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
last_connect_error_ = e.what();
|
||||
// Daemon warmup messages (Loading block index, Verifying blocks, etc.)
|
||||
// are normal startup progress — the daemon is reachable and auth works,
|
||||
// it just hasn't finished initializing yet. Mark as connected+warmup
|
||||
// so the wallet can show the UI instead of a blocking overlay.
|
||||
last_connect_kind_ = rpcFailureKindOf(e);
|
||||
// Daemon warmup (Loading block index, Verifying blocks, etc.) is normal startup progress — the
|
||||
// daemon is reachable and auth works, it just hasn't finished initializing yet. Mark as
|
||||
// connected+warmup so the wallet can show the UI instead of a blocking overlay. Only the
|
||||
// JSON-RPC code (-28, RPC_IN_WARMUP) counts: matching the message text would also match a
|
||||
// plain-text HTTP body that happens to say "Loading".
|
||||
std::string msg = e.what();
|
||||
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
|
||||
// to message substrings for any path that didn't carry the numeric code.
|
||||
int code = 0;
|
||||
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
|
||||
bool isWarmup = (code == -28) ||
|
||||
(msg.find("Loading") != std::string::npos ||
|
||||
msg.find("Verifying") != std::string::npos ||
|
||||
msg.find("Activating") != std::string::npos ||
|
||||
msg.find("Rewinding") != std::string::npos ||
|
||||
msg.find("Rescanning") != std::string::npos ||
|
||||
msg.find("Pruning") != std::string::npos);
|
||||
if (isWarmup) {
|
||||
if (last_connect_kind_ == RpcFailureKind::Warmup) {
|
||||
connected_ = true;
|
||||
warming_up_ = true;
|
||||
warmup_status_ = msg;
|
||||
DEBUG_LOGF("Daemon warming up: %s\n", msg.c_str());
|
||||
return true;
|
||||
} else {
|
||||
DEBUG_LOGF("Connection failed: %s\n", msg.c_str());
|
||||
logConnectFailure(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +284,34 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
return false;
|
||||
}
|
||||
|
||||
// A busy or absent daemon makes every connect() fail the same way, and logging each one flooded the
|
||||
// log (60k lines in one incident). Log a new error at once; a repeat only every 100th time.
|
||||
void RPCClient::logConnectFailure(const std::string& msg)
|
||||
{
|
||||
const std::string line = std::string("[") + rpcFailureKindName(last_connect_kind_) + "] " + msg;
|
||||
if (line != logged_connect_error_) {
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_ = line;
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connection failed %s\n", line.c_str());
|
||||
return;
|
||||
}
|
||||
if (++connect_error_repeats_ % 100 == 0)
|
||||
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
|
||||
}
|
||||
|
||||
RpcFailureKind RPCClient::trySendStop(long timeoutSec)
|
||||
{
|
||||
try {
|
||||
TraceScope trace("Daemon lifecycle / Stop command");
|
||||
call("stop", json::array(), timeoutSec);
|
||||
return RpcFailureKind::None;
|
||||
} catch (const std::exception& e) {
|
||||
return rpcFailureKindOf(e);
|
||||
}
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
@@ -299,6 +345,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)
|
||||
@@ -313,6 +360,8 @@ json RPCClient::makePayload(const std::string& method, const json& params)
|
||||
|
||||
std::string RPCClient::performCall(const std::string& method, const json& params, long& httpCodeOut)
|
||||
{
|
||||
if (busy_gate_ && !busy_gate_->admit(method))
|
||||
throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy", CURLE_OK, 0, false);
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
@@ -328,38 +377,60 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
// libcurl reports connect- and reply-phase timeouts with the same code. The request size
|
||||
// (reset for every transfer) tells them apart: 0 means it never reached the daemon.
|
||||
long requestSize = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_REQUEST_SIZE, &requestSize);
|
||||
const bool sent = requestSize > 0;
|
||||
const RpcFailureKind kind = classifyRpcFailure(res, sent, 0, 0, std::string());
|
||||
if (busy_gate_) busy_gate_->record(method, kind);
|
||||
throw RpcTransportError(kind, "RPC request failed: " + std::string(curl_easy_strerror(res)), res, 0, sent);
|
||||
}
|
||||
|
||||
// Check HTTP response code
|
||||
httpCodeOut = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &httpCodeOut);
|
||||
if (busy_gate_) {
|
||||
// Any HTTP answer means a daemon worker ran our request, except the queue-full refusal.
|
||||
const bool queueFull = httpCodeOut == 500 &&
|
||||
response_data.find("Work queue depth exceeded") != std::string::npos;
|
||||
busy_gate_->record(method, queueFull ? RpcFailureKind::QueueFull : RpcFailureKind::None);
|
||||
}
|
||||
|
||||
return response_data;
|
||||
}
|
||||
|
||||
void RPCClient::throwHttpFailure(long httpCode, const std::string& body, bool hideBody)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors (insufficient funds, bad
|
||||
// params, etc.), and 400/404 for a bad request / unknown method, with a JSON body carrying the
|
||||
// real message. Parse the body first to extract it.
|
||||
bool isJson = false;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
isJson = true;
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
int errCode = 0;
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>(), httpCode);
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump(), httpCode);
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Not JSON: the daemon's HTTP layer refused the request itself ("Work queue depth exceeded",
|
||||
// "Unhandled request") or sent a 401/503 page.
|
||||
}
|
||||
const std::string plain = isJson ? std::string() : body;
|
||||
throw RpcTransportError(classifyRpcFailure(CURLE_OK, true, httpCode, 0, plain),
|
||||
formatHttpError(httpCode, hideBody ? std::string() : plain),
|
||||
CURLE_OK, httpCode, true);
|
||||
}
|
||||
|
||||
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
|
||||
// (insufficient funds, bad params, etc.) with a valid JSON body.
|
||||
// Parse the body first to extract the real error message.
|
||||
if (httpCode != 200) {
|
||||
int errCode = 0;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>());
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump());
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Body wasn't valid JSON — fall through to generic HTTP error
|
||||
}
|
||||
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(httpCode));
|
||||
}
|
||||
if (httpCode != 200) throwHttpFailure(httpCode, body, /*hideBody=*/scrubSource);
|
||||
|
||||
json response = json::parse(body);
|
||||
|
||||
@@ -373,7 +444,7 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
|
||||
err_msg = response["error"]["message"].get<std::string>();
|
||||
}
|
||||
if (err_msg.empty()) err_msg = response["error"].dump();
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
|
||||
}
|
||||
|
||||
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
|
||||
@@ -474,34 +545,9 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data);
|
||||
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
}
|
||||
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
|
||||
if (http_code != 200) {
|
||||
try {
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error(err_msg);
|
||||
}
|
||||
} catch (const json::exception&) {}
|
||||
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
|
||||
}
|
||||
std::string response_data = performCall(method, params, http_code);
|
||||
if (http_code != 200) throwHttpFailure(http_code, response_data, /*hideBody=*/false);
|
||||
|
||||
// Parse with ordered_json to preserve the daemon's original key order
|
||||
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
|
||||
@@ -510,11 +556,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
|
||||
const auto& err = oj["error"];
|
||||
std::string err_msg;
|
||||
int errCode = 0;
|
||||
if (err.is_object() && err.contains("code") && err["code"].is_number_integer())
|
||||
errCode = err["code"].get<int>();
|
||||
if (err.is_object() && err.contains("message") && err["message"].is_string())
|
||||
err_msg = err["message"].get<std::string>();
|
||||
else
|
||||
err_msg = err.dump();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, http_code);
|
||||
}
|
||||
|
||||
auto& result = oj["result"];
|
||||
|
||||
+38
-15
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "types.h"
|
||||
#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind
|
||||
#include "rpc_busy_gate.h"
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
@@ -20,21 +22,6 @@ using json = nlohmann::json;
|
||||
using Callback = std::function<void(const json&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief A JSON-RPC error carrying the daemon's numeric error code.
|
||||
*
|
||||
* what() preserves the exact human-readable message (so existing string matching
|
||||
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
|
||||
* for a daemon still starting up. Derives from std::runtime_error, so every existing
|
||||
* `catch (const std::exception&)` continues to handle it unchanged.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message)
|
||||
: std::runtime_error(message), code(errorCode) {}
|
||||
int code = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief JSON-RPC client for dragonxd
|
||||
*
|
||||
@@ -78,6 +65,31 @@ public:
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Set up the connection without probing it (connect() = open() + a getinfo probe)
|
||||
*
|
||||
* For sending "stop": the getinfo probe needs the daemon's cs_main, so against a busy node it
|
||||
* times out (holding one of the daemon's few RPC slots) or is refused, and stop never went out.
|
||||
* isConnected() stays false; call() works once this returns true.
|
||||
*/
|
||||
bool open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password, bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Send "stop" once, with its own timeout; returns how it went (None = the daemon ran it)
|
||||
*
|
||||
* A ReplyTimeout here is NOT a failure: the request reached the daemon and waits for a worker.
|
||||
*/
|
||||
RpcFailureKind trySendStop(long timeoutSec = 2);
|
||||
|
||||
/**
|
||||
* @brief Share a busy gate with the app's other polling clients (nullptr: none)
|
||||
*
|
||||
* While the gate is busy, calls that need the daemon's cs_main fail at once with a Deferred
|
||||
* RpcTransportError instead of being sent; every sent call reports how it went to the gate.
|
||||
*/
|
||||
void setBusyGate(std::shared_ptr<RpcBusyGate> gate) { busy_gate_ = std::move(gate); }
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
@@ -117,6 +129,8 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
/** @brief How the last connect() probe went: None on success, Warmup for a warming-up node, else the failure. */
|
||||
RpcFailureKind getLastConnectFailureKind() const { return last_connect_kind_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
@@ -274,6 +288,11 @@ private:
|
||||
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
|
||||
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
|
||||
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
|
||||
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
|
||||
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
|
||||
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
|
||||
bool probeConnection(const std::string& host); // connect()'s getinfo probe, after open()
|
||||
void logConnectFailure(const std::string& msg);
|
||||
|
||||
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
|
||||
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
|
||||
@@ -286,6 +305,10 @@ private:
|
||||
bool warming_up_ = false;
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
|
||||
std::shared_ptr<RpcBusyGate> busy_gate_;
|
||||
std::string logged_connect_error_; // last "Connection failed" text logged (rate limit)
|
||||
unsigned connect_error_repeats_ = 0; // identical failures since it was logged
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
|
||||
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
|
||||
// test target can use it without compiling rpc_client.cpp.
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
enum class RpcFailureKind {
|
||||
None, // the call succeeded
|
||||
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
|
||||
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
|
||||
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
|
||||
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
|
||||
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
|
||||
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
|
||||
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
|
||||
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
|
||||
AppError, // the daemon ran the method and answered with a JSON-RPC error
|
||||
Aborted, // our own requestAbort() cut the transfer
|
||||
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* rpcFailureKindName(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None: return "None";
|
||||
case RpcFailureKind::NotListening: return "NotListening";
|
||||
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
|
||||
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
|
||||
case RpcFailureKind::QueueFull: return "QueueFull";
|
||||
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
|
||||
case RpcFailureKind::Unhandled: return "Unhandled";
|
||||
case RpcFailureKind::Auth: return "Auth";
|
||||
case RpcFailureKind::Warmup: return "Warmup";
|
||||
case RpcFailureKind::AppError: return "AppError";
|
||||
case RpcFailureKind::Aborted: return "Aborted";
|
||||
case RpcFailureKind::Deferred: return "Deferred";
|
||||
case RpcFailureKind::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
|
||||
inline bool isRpcTimeout(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
|
||||
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
|
||||
* connect- and reply-phase timeouts with the same code; this tells them apart.
|
||||
* @param httpStatus HTTP status, 0 when there was no response
|
||||
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
|
||||
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
|
||||
*/
|
||||
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
|
||||
const std::string& plainBody)
|
||||
{
|
||||
if (curlCode != CURLE_OK) {
|
||||
switch (curlCode) {
|
||||
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
|
||||
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
|
||||
case CURLE_OPERATION_TIMEDOUT:
|
||||
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
|
||||
default: return RpcFailureKind::Other;
|
||||
}
|
||||
}
|
||||
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
|
||||
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
|
||||
if (httpStatus == 200) return RpcFailureKind::None;
|
||||
if (httpStatus == 401) return RpcFailureKind::Auth;
|
||||
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
|
||||
if (httpStatus == 500) {
|
||||
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
|
||||
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
|
||||
}
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
/**
|
||||
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
|
||||
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
|
||||
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
|
||||
*/
|
||||
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
|
||||
{
|
||||
std::string text;
|
||||
bool pendingSpace = false;
|
||||
for (unsigned char c : plainBody) {
|
||||
if (text.size() >= 120) break;
|
||||
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
|
||||
pendingSpace = !text.empty();
|
||||
} else if (c >= 0x21 && c <= 0x7e) {
|
||||
if (pendingSpace && text.size() < 119) text.push_back(' ');
|
||||
pendingSpace = false;
|
||||
text.push_back(static_cast<char>(c));
|
||||
}
|
||||
}
|
||||
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
|
||||
if (!text.empty()) out += ": " + text;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
|
||||
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
|
||||
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message, long status = 0)
|
||||
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
|
||||
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
|
||||
int code = 0;
|
||||
long httpStatus = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
|
||||
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
|
||||
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
|
||||
*/
|
||||
class RpcTransportError : public std::runtime_error {
|
||||
public:
|
||||
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
|
||||
long status = 0, bool sent = false)
|
||||
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
|
||||
RpcFailureKind kind = RpcFailureKind::Other;
|
||||
int curlCode = CURLE_OK;
|
||||
long httpStatus = 0;
|
||||
bool requestSent = false;
|
||||
};
|
||||
|
||||
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
|
||||
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
{
|
||||
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -1,8 +1,10 @@
|
||||
#include "network_refresh_service.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
@@ -37,16 +39,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 +262,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 +271,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,27 +292,65 @@ 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;
|
||||
rpc::RpcFailureKind blockErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
|
||||
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;
|
||||
} catch (const std::exception& e) {
|
||||
blockErrKind = rpc::rpcFailureKindOf(e);
|
||||
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;
|
||||
}
|
||||
|
||||
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
|
||||
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
|
||||
if (includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
|
||||
// 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) {
|
||||
balanceErrKind = rpc::rpcFailureKindOf(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;
|
||||
result.blockchainErrKind = blockErrKind;
|
||||
result.balanceErrKind = balanceErrKind;
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
@@ -426,12 +482,21 @@ 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];
|
||||
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
||||
@@ -564,6 +629,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());
|
||||
@@ -599,18 +668,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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,15 +709,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;
|
||||
}
|
||||
|
||||
@@ -769,8 +862,11 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
for (const auto& out : entry.outgoing_outputs) {
|
||||
bool alreadyTracked = false;
|
||||
for (const auto& existing : transactions) {
|
||||
// Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
|
||||
// recipients in one tx are distinct rows — an amount-only match would drop the second.
|
||||
if (existing.txid == txid && existing.type == "send" &&
|
||||
std::abs(existing.amount + out.value) < 0.00000001) {
|
||||
std::abs(existing.amount + out.value) < 0.00000001 &&
|
||||
existing.address == out.address) {
|
||||
alreadyTracked = true;
|
||||
break;
|
||||
}
|
||||
@@ -801,6 +897,55 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry)
|
||||
{
|
||||
if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
|
||||
return;
|
||||
const auto& sends = sentEntry["sends"];
|
||||
auto it = sends.find("transparentSends");
|
||||
if (it == sends.end() || !it->is_array()) return;
|
||||
|
||||
// Per-tx metadata for the synthesized rows.
|
||||
int confirmations = 0;
|
||||
std::int64_t timestamp = 0;
|
||||
if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
|
||||
if (timestamp == 0) {
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
|
||||
}
|
||||
|
||||
for (const auto& out : *it) {
|
||||
auto amount = readOptional<double>(out, "amount");
|
||||
if (!amount) continue;
|
||||
const double debit = -std::abs(*amount); // the daemon reports -nValue; a send is a debit
|
||||
std::string address;
|
||||
if (auto a = readOptional<std::string>(out, "address")) address = *a;
|
||||
|
||||
// Dedup against rows already present — notably listtransactions-derived t->t sends, which
|
||||
// this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
|
||||
bool exists = false;
|
||||
for (const auto& e : transactions) {
|
||||
if (e.txid == txid && e.type == "send" && e.address == address &&
|
||||
std::abs(e.amount - debit) < 0.00000001) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exists) continue;
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = txid;
|
||||
info.type = "send";
|
||||
info.address = address;
|
||||
info.amount = debit;
|
||||
info.confirmations = confirmations;
|
||||
info.timestamp = timestamp;
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
||||
{
|
||||
std::sort(transactions.begin(), transactions.end(),
|
||||
@@ -819,6 +964,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;
|
||||
@@ -897,6 +1046,45 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
||||
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
||||
|
||||
// Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
|
||||
// transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
|
||||
// and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
|
||||
// either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
|
||||
// below is never run for it and the send is invisible in History (the reported bug).
|
||||
//
|
||||
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
|
||||
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
|
||||
// txids. Two legs need handling:
|
||||
// * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
|
||||
// `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
|
||||
// * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
|
||||
// enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
|
||||
// the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
|
||||
// build those rows directly from the payload's `sends.transparentSends[]` here. They are
|
||||
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
|
||||
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
|
||||
//
|
||||
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
|
||||
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
|
||||
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
|
||||
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
|
||||
try {
|
||||
json sent = rpc.call("z_listsentbyaddress",
|
||||
json::array({snapshot.shieldedAddresses.front(), 0}));
|
||||
if (sent.is_array()) {
|
||||
for (const auto& sentTx : sent) {
|
||||
auto txid = readOptional<std::string>(sentTx, "txid");
|
||||
if (!txid || txid->empty()) continue;
|
||||
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
|
||||
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
knownTxids.insert(txid);
|
||||
}
|
||||
@@ -999,6 +1187,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;
|
||||
}
|
||||
|
||||
@@ -1018,6 +1208,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;
|
||||
@@ -1025,15 +1231,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());
|
||||
@@ -1059,15 +1263,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;
|
||||
@@ -1101,12 +1303,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;
|
||||
@@ -1184,6 +1390,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;
|
||||
}
|
||||
|
||||
@@ -1193,6 +1405,10 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.headers) state.sync.headers = *result.headers;
|
||||
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||
// A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
|
||||
// THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
|
||||
// peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
|
||||
state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
if (state.longestchain > 0)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -98,9 +99,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 +112,11 @@ 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)
|
||||
// How those calls failed (None when they succeeded or weren't made), so a caller can tell a busy
|
||||
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
|
||||
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -143,9 +152,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 +224,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 +257,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);
|
||||
@@ -284,6 +315,12 @@ public:
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
@@ -349,6 +386,7 @@ public:
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
|
||||
@@ -47,10 +47,16 @@ void RefreshScheduler::tick(float deltaSeconds)
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
for (float& h : holds_) {
|
||||
if (h > 0.0f) h = std::max(0.0f, h - delta);
|
||||
}
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
const float h = hold(timer);
|
||||
if (h > 0.0f) return false; // held by deferFor()
|
||||
if (h == 0.0f) return true; // the hold ran out: due now, whatever the interval
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
@@ -65,19 +71,27 @@ bool RefreshScheduler::consumeDue(Timer timer)
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
holdRef(timer) = -1.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
holdRef(timer) = -1.0f; // an explicit "now" (user action) overrides a backoff
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::deferFor(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
@@ -55,6 +57,11 @@ public:
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
// Hold a timer for exactly `seconds`, whatever the page's interval (which only measures elapsed
|
||||
// time): not due until then, due as soon as it runs out. consumeDue/reset/markDue/setTimer end it.
|
||||
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
|
||||
// the page interval was shorter than the delay (every frame on Overview/Send).
|
||||
void deferFor(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
@@ -79,9 +86,14 @@ private:
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
static constexpr int kTimerCount = 8;
|
||||
float& holdRef(Timer timer) { return holds_[static_cast<int>(timer)]; }
|
||||
float hold(Timer timer) const { return holds_[static_cast<int>(timer)]; }
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
// deferFor() holds: > 0 counting down (not due), 0 ran out (due), < 0 none.
|
||||
std::array<float, kTimerCount> holds_{{-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f}};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -64,8 +64,10 @@ WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartD
|
||||
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
|
||||
|
||||
if (daemon.isUsingEmbeddedDaemon()) {
|
||||
daemon.stopEmbeddedDaemon();
|
||||
const bool stopped = daemon.stopEmbeddedDaemon();
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
// Never start a second node over one that hasn't exited (it holds wallet.dat and the datadir lock).
|
||||
if (!stopped) return {false, "The node did not stop in time, so it was not restarted.", false};
|
||||
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
|
||||
daemon.startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public:
|
||||
public:
|
||||
virtual ~DaemonGateway() = default;
|
||||
virtual bool isUsingEmbeddedDaemon() const = 0;
|
||||
virtual void stopEmbeddedDaemon() = 0;
|
||||
virtual bool stopEmbeddedDaemon() = 0; // true: the node exited (false: still running; never killed)
|
||||
virtual bool startEmbeddedDaemon() = 0;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
|
||||
@@ -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()) {
|
||||
@@ -165,7 +167,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
// 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()) {
|
||||
@@ -194,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);
|
||||
@@ -694,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;
|
||||
@@ -707,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
|
||||
@@ -745,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;
|
||||
@@ -765,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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
+113
-24
@@ -79,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
|
||||
// ============================================================================
|
||||
@@ -487,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;
|
||||
@@ -863,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);
|
||||
@@ -1220,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) {
|
||||
@@ -1244,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);
|
||||
@@ -1265,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
|
||||
@@ -1286,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;
|
||||
}
|
||||
@@ -1403,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;
|
||||
@@ -1528,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);
|
||||
@@ -1537,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.
|
||||
@@ -1547,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,
|
||||
@@ -1567,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);
|
||||
}
|
||||
|
||||
@@ -1581,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.
|
||||
@@ -1714,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;
|
||||
|
||||
@@ -107,7 +107,7 @@ inline float ActionButtonWidth(const char* label, const char* icon, float minWid
|
||||
ImFont* lf = Type().button();
|
||||
ImFont* icf = Type().iconSmall();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float padX = 12.0f * dp, gap = 6.0f * dp;
|
||||
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;
|
||||
@@ -133,19 +133,21 @@ inline bool ActionButton(const char* id, const char* label, const char* icon, Ac
|
||||
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMax(pos.x + w, pos.y + h);
|
||||
const float round = ImGui::GetStyle().FrameRounding;
|
||||
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:
|
||||
bg = WithAlpha(Primary(), act ? 255 : (hov ? 245 : 220));
|
||||
fg = IM_COL32(255, 255, 255, 240);
|
||||
// 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 ? 26 : 16);
|
||||
border = WithAlpha(OnSurface(), 40);
|
||||
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
|
||||
border = WithAlpha(OnSurface(), 48);
|
||||
glass = true;
|
||||
fg = OnSurface();
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#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
|
||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
|
||||
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
|
||||
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
|
||||
// before the first chain-info refresh. (audit: no-peer-banner)
|
||||
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
|
||||
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
|
||||
|
||||
// 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;
|
||||
|
||||
// The node is running but busy with something it can't be interrupted out of (a witness rebuild, the
|
||||
// startup replay) or too busy to answer RPC. Wallet data may be stale; a reconnect would only add
|
||||
// requests, so the banner offers no action. node_busy_detail is the live, translated description.
|
||||
bool node_busy = false;
|
||||
std::string node_busy_detail;
|
||||
|
||||
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, busy. Before the startup-phase returns below: the overlay sets daemon_initializing while
|
||||
// the node is busy too, and the banner must still say why the wallet isn't updating. No action: a
|
||||
// "Reconnect" would only queue more requests behind the busy node.
|
||||
if (in.node_busy) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::NodeBusy;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.node_busy_detail;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. 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. (These return even if `connected` has flipped early, matching prior behavior.)
|
||||
if (in.warming_up || in.daemon_initializing) return s;
|
||||
if (in.connection_in_progress) return s;
|
||||
|
||||
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
|
||||
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
|
||||
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
|
||||
if (in.connected) {
|
||||
if (!in.tip_known && in.blocks > 0) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::NoPeers;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.connection_status;
|
||||
}
|
||||
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);
|
||||
|
||||
+53
-7
@@ -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;
|
||||
|
||||
+929
-817
File diff suppressed because it is too large
Load Diff
+40
-16
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+156
-92
@@ -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();
|
||||
|
||||
+324
-52
@@ -10,6 +10,7 @@
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../chat/chat_service.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
|
||||
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
|
||||
#include "../../config/settings.h" // per-conversation mute (Q10)
|
||||
#include "../material/colors.h"
|
||||
@@ -59,6 +60,10 @@ bool s_msgsel_dragging = false;
|
||||
// Composer + new-conversation UI state.
|
||||
char s_compose[512] = "";
|
||||
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
||||
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
||||
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
||||
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
||||
int s_composeCursor = -1;
|
||||
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
|
||||
// The composer hard-caps input to this; the emoji picker respects it too.
|
||||
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
|
||||
@@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block
|
||||
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
|
||||
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
|
||||
int composeInputCallback(ImGuiInputTextCallbackData* data) {
|
||||
// Track the live caret so the emoji picker can insert at the cursor. This callback runs under
|
||||
// CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
|
||||
// focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
|
||||
s_composeCursor = data->CursorPos;
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (io.KeyShift
|
||||
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
|
||||
&& data->BufTextLen < kChatBodyMaxBytes) {
|
||||
data->InsertChars(data->CursorPos, "\n");
|
||||
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -83,6 +93,10 @@ char s_new_zaddr[128] = "";
|
||||
char s_new_msg[256] = "";
|
||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
||||
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
|
||||
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
|
||||
std::string s_delete_cid; // conversation targeted by the delete confirm
|
||||
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
||||
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
||||
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
||||
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
||||
|
||||
@@ -415,9 +429,9 @@ void RenderChatSettingsPreview(App* app, float width) {
|
||||
struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; };
|
||||
const std::string peer = "Ava";
|
||||
const PMsg msgs[] = {
|
||||
{ u8"Did the payment go through? \U0001F642", false, true, true, peer + " " + t1 },
|
||||
{ u8"Yep — just confirmed ✅", true, true, false, std::string(TR("chat_you")) + " " + t2 },
|
||||
{ u8"Sending the rest now \U0001F44D", true, false, true, std::string() },
|
||||
{ TR("grpb_preview_msg_payment_through"), false, true, true, peer + " " + t1 },
|
||||
{ TR("grpb_preview_msg_yep_confirmed"), true, true, false, std::string(TR("chat_you")) + " " + t2 },
|
||||
{ TR("grpb_preview_msg_sending_rest"), true, false, true, std::string() },
|
||||
};
|
||||
const int N = 3;
|
||||
|
||||
@@ -441,7 +455,7 @@ void RenderChatSettingsPreview(App* app, float width) {
|
||||
totalH += padIn;
|
||||
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 16; g.borderAlpha = 36;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36;
|
||||
material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g);
|
||||
|
||||
const float leftX = origin.x + padIn;
|
||||
@@ -513,6 +527,16 @@ struct ConvSummary {
|
||||
bool hidden = false; // shown only while "Show hidden" is on
|
||||
};
|
||||
|
||||
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
||||
// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them
|
||||
// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild.
|
||||
std::vector<ConvSummary> s_convs;
|
||||
int s_convsHidden = 0;
|
||||
std::uint64_t s_convsKey = ~0ull;
|
||||
std::string s_threadCid;
|
||||
std::uint64_t s_threadRev = ~0ull;
|
||||
std::vector<chat::ChatMessage> s_threadMsgs;
|
||||
|
||||
// Centered, muted, wrapped hint for the empty states.
|
||||
void centeredHint(const char* text) {
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
@@ -537,7 +561,7 @@ void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||
ImFont* titleF = material::Type().subtitle1();
|
||||
ImFont* hintF = material::Type().body2();
|
||||
const float gap = 8.0f * Layout::dpiScale();
|
||||
const float wrap = std::min(avail.x - 40.0f, 360.0f);
|
||||
const float wrap = std::min(avail.x - 40.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
|
||||
const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
|
||||
const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
|
||||
@@ -625,16 +649,22 @@ static const EmojiEntry kEmoji[] = {
|
||||
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
|
||||
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
|
||||
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
|
||||
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
|
||||
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
|
||||
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
|
||||
// kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
|
||||
// caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
|
||||
// The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
|
||||
// here shows immediately.
|
||||
auto insertToken = [&](const char* tok) {
|
||||
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
|
||||
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
|
||||
const std::size_t pos = (s_composeCursor < 0)
|
||||
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
|
||||
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
|
||||
const std::size_t pad = needsSpace ? 1 : 0;
|
||||
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
|
||||
if (needsSpace) buf[cur] = ' ';
|
||||
std::memcpy(buf + cur + pad, tok, add);
|
||||
buf[cur + pad + add] = '\0';
|
||||
std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
|
||||
if (needsSpace) buf[pos] = ' ';
|
||||
std::memcpy(buf + pos + pad, tok, add);
|
||||
s_composeCursor = static_cast<int>(pos + pad + add); // keep the caret after the inserted token
|
||||
}
|
||||
};
|
||||
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
|
||||
@@ -709,32 +739,46 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
||||
std::vector<ConvSummary> convs;
|
||||
int hiddenCount = 0;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
|
||||
if (hidden) ++hiddenCount;
|
||||
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.hidden = hidden;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||
// MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild
|
||||
// only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew
|
||||
// (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a
|
||||
// rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset
|
||||
// in ResetChatTab on wallet switch.
|
||||
const std::uint64_t convsKey =
|
||||
store.revision() * 1000003ull
|
||||
+ static_cast<std::uint64_t>(s_show_hidden ? 1 : 0)
|
||||
+ (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size()
|
||||
if (convsKey != s_convsKey) {
|
||||
s_convs.clear();
|
||||
s_convsHidden = 0;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
|
||||
if (hidden) ++s_convsHidden;
|
||||
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.hidden = hidden;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
c.lastTs = last.timestamp;
|
||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||
s_convs.push_back(std::move(c));
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
c.lastTs = last.timestamp;
|
||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||
convs.push_back(std::move(c));
|
||||
std::sort(s_convs.begin(), s_convs.end(),
|
||||
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
|
||||
s_convsKey = convsKey;
|
||||
}
|
||||
std::sort(convs.begin(), convs.end(),
|
||||
[](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; });
|
||||
std::vector<ConvSummary>& convs = s_convs;
|
||||
int hiddenCount = s_convsHidden;
|
||||
|
||||
// Keep the selection valid (only when there is something to select).
|
||||
if (!convs.empty() &&
|
||||
@@ -747,6 +791,7 @@ void RenderChatTab(App* app)
|
||||
// for one contact can't be sent to another (B5).
|
||||
if (s_selected_cid != s_compose_cid) {
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again
|
||||
s_compose_cid = s_selected_cid;
|
||||
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
|
||||
}
|
||||
@@ -759,7 +804,7 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
|
||||
// Row geometry is logical px — scale by dpiScale() so rows/padding grow with the (DPI-scaled)
|
||||
// fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's
|
||||
// right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
|
||||
@@ -778,7 +823,7 @@ void RenderChatTab(App* app)
|
||||
{
|
||||
ImDrawList* paneDL = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * Layout::dpiScale(); g.fillAlpha = 20; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 20; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + listW, pMin.y + avail.y), g);
|
||||
}
|
||||
// Inner padding so the list content (buttons, search, conversation cards) doesn't hug the glass
|
||||
@@ -828,6 +873,15 @@ void RenderChatTab(App* app)
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
|
||||
}
|
||||
// Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no
|
||||
// stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock.
|
||||
const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0;
|
||||
if (blockedCount > 0) {
|
||||
const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")";
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
const std::string search = s_search;
|
||||
ImGui::Separator();
|
||||
if (convs.empty()) {
|
||||
@@ -878,16 +932,22 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
const float textX = avC.x + avR + pad;
|
||||
|
||||
// Name (top). Hidden conversations (shown via "Show hidden") render dimmed.
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||
// Time (top-right, muted) — compact relative form (Q5).
|
||||
// Time (top-right, muted) — compact relative form (Q5). Measure/draw it FIRST so the name can be
|
||||
// clipped to the column left of it — otherwise a long peer name overruns the timestamp (worse at
|
||||
// HiDPI, where the fixed-length name grows ~1.5x).
|
||||
const std::string when = relativeTime(c.lastTs);
|
||||
float nameRight = mx.x - pad;
|
||||
if (!when.empty()) {
|
||||
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
|
||||
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
|
||||
material::OnSurfaceMedium(), when.c_str());
|
||||
nameRight = mx.x - pad - wsz.x - pad; // reserve the timestamp column + a gap
|
||||
}
|
||||
// Name (top), clipped to the space left of the timestamp. Hidden conversations render dimmed.
|
||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, nameRight), mx.y), true);
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||
dl->PopClipRect();
|
||||
// Preview (bottom, clipped to the text column, muted).
|
||||
const std::string preview = previewOf(c.lastBody);
|
||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
|
||||
@@ -950,7 +1010,7 @@ void RenderChatTab(App* app)
|
||||
ImDrawList* paneDL = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
const float pW = ImGui::GetContentRegionAvail().x;
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * tdp; g.fillAlpha = 12; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 12; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + pW, pMin.y + (avail.y - composerAreaH)), g);
|
||||
}
|
||||
// Inner padding so the header + messages don't hug the glass card's edges (the message child
|
||||
@@ -995,7 +1055,7 @@ void RenderChatTab(App* app)
|
||||
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
||||
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
||||
// always the rightmost icon.
|
||||
const int nBtns = 4 + (hasAddr ? 1 : 0);
|
||||
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
|
||||
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
||||
|
||||
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
||||
@@ -1046,6 +1106,7 @@ void RenderChatTab(App* app)
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild
|
||||
}
|
||||
s_rename_cid.clear();
|
||||
} else if (cancel) {
|
||||
@@ -1147,6 +1208,21 @@ void RenderChatTab(App* app)
|
||||
s_selected_cid.clear();
|
||||
Notifications::instance().info(TR("chat_hidden_toast"));
|
||||
}
|
||||
s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild
|
||||
}
|
||||
bx += ib + gap;
|
||||
}
|
||||
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
||||
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
||||
{
|
||||
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||
material::IconButtonStyle a = base;
|
||||
a.tooltip = TR("chat_delete");
|
||||
a.hoverColor = material::Error();
|
||||
if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) {
|
||||
s_delete_cid = sel->cid;
|
||||
s_delete_name = sel->peerName;
|
||||
s_show_delete_confirm = true;
|
||||
}
|
||||
bx += ib + gap;
|
||||
}
|
||||
@@ -1255,7 +1331,15 @@ void RenderChatTab(App* app)
|
||||
const float groupGap = (compact ? 4.0f : 7.0f) * dp;
|
||||
const float msgGap = (compact ? 2.0f : 3.0f) * dp;
|
||||
const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0);
|
||||
const auto messages = store.conversation(s_selected_cid);
|
||||
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
|
||||
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
|
||||
// not every frame while the thread is simply being read/scrolled.
|
||||
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
|
||||
s_threadMsgs = store.conversation(s_selected_cid);
|
||||
s_threadCid = s_selected_cid;
|
||||
s_threadRev = store.revision();
|
||||
}
|
||||
const auto& messages = s_threadMsgs;
|
||||
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
|
||||
// one meta header and stack tightly; a date pill is drawn once per calendar day.
|
||||
const std::int64_t nowTs = static_cast<std::int64_t>(std::time(nullptr));
|
||||
@@ -1290,7 +1374,7 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
const float availW = ImGui::GetContentRegionAvail().x;
|
||||
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
|
||||
const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
|
||||
const float innerW = maxBubbleW - 2.0f * bpad;
|
||||
|
||||
// ── Date separator (once per calendar day): a centered pill.
|
||||
@@ -1576,7 +1660,8 @@ void RenderChatTab(App* app)
|
||||
const float ringR = std::max(7.0f, lineH * 0.42f);
|
||||
const float ringPad = 9.0f * tdp;
|
||||
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
|
||||
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
|
||||
const float inputW = std::min(720.0f * tdp,
|
||||
std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW));
|
||||
const float sendX = inputX + inputW + inGap;
|
||||
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
|
||||
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
|
||||
@@ -1598,7 +1683,7 @@ void RenderChatTab(App* app)
|
||||
// FrameBg); a flat FrameBg showed the sharp texture.
|
||||
{
|
||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||
material::GlassPanelSpec g; g.rounding = 8.0f * tdp; g.fillAlpha = 16; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(cdl, ImVec2(inputX, rowY),
|
||||
ImVec2(inputX + inputW, rowY + composerBoxH), g);
|
||||
}
|
||||
@@ -1705,6 +1790,7 @@ void RenderChatTab(App* app)
|
||||
if (submit && s_compose[0] != '\0' && !overCap) {
|
||||
app->sendChatMessage(sel->cid, s_compose);
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_composeCursor = -1;
|
||||
s_scroll_to_cid = sel->cid;
|
||||
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
|
||||
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
|
||||
@@ -1726,6 +1812,17 @@ void RenderChatTab(App* app)
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
const float fieldW = ImGui::GetContentRegionAvail().x;
|
||||
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
|
||||
// Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
|
||||
// address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
|
||||
// warn when the entry isn't a valid z-address and keep Send disabled below.
|
||||
const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
|
||||
if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
|
||||
// Selecting one fills the field above (manual paste still works).
|
||||
ImGui::SetNextItemWidth(fieldW);
|
||||
@@ -1757,7 +1854,7 @@ void RenderChatTab(App* app)
|
||||
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
|
||||
const bool canSend = newAddrIsZ && s_new_msg[0] != '\0';
|
||||
const float actionW = std::max(130.0f * dp,
|
||||
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
|
||||
const float actionGap = Layout::spacingSm();
|
||||
@@ -1784,6 +1881,122 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
||||
if (s_show_delete_confirm) {
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("chat_delete_title");
|
||||
ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op)
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name +
|
||||
TR("chat_delete_body_suffix")).c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_delete_revive_note"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
ImGui::TextUnformatted(TR("chat_delete_local_note"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
auto doDelete = [&](bool block) {
|
||||
// Delete first — if the persisted rows can't be removed, change nothing else (no block,
|
||||
// no toast) so the store and DB can't diverge.
|
||||
if (!app->chatService().deleteConversation(s_delete_cid, block)) {
|
||||
Notifications::instance().error(TR("chat_delete_failed"));
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear(); s_delete_name.clear();
|
||||
return;
|
||||
}
|
||||
if (app->settings()) {
|
||||
if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true);
|
||||
app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag
|
||||
app->settings()->save();
|
||||
}
|
||||
// Revive mode: forget the seen-watermark so a re-imported message badges as unread even if
|
||||
// its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored
|
||||
// history doesn't all re-badge.
|
||||
if (!block) app->forgetChatConversationSeen(s_delete_cid);
|
||||
if (s_selected_cid == s_delete_cid) s_selected_cid.clear();
|
||||
Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast"));
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear(); s_delete_name.clear();
|
||||
};
|
||||
|
||||
auto textW = [&](const char* t) {
|
||||
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
||||
};
|
||||
const float gap2 = Layout::spacingSm();
|
||||
const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm")));
|
||||
const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block")));
|
||||
const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel")));
|
||||
material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false);
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
|
||||
const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0));
|
||||
ImGui::SameLine(0, gap2);
|
||||
const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0));
|
||||
ImGui::PopStyleColor(4);
|
||||
ImGui::SameLine(0, gap2);
|
||||
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
|
||||
|
||||
if (doDel) doDelete(false);
|
||||
if (doBlk) doDelete(true);
|
||||
if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); }
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Blocked-conversations manager (unblock) ----
|
||||
if (s_show_blocked) {
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("chat_blocked_title");
|
||||
ov.p_open = &s_show_blocked;
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_blocked_desc"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
if (app->settings()) {
|
||||
// Copy so unblocking (which mutates the settings vector) during iteration is safe.
|
||||
const auto blocked = app->settings()->blockedChatConversations();
|
||||
std::string unblockCid;
|
||||
for (const auto& b : blocked) {
|
||||
ImGui::PushID(b.cid.c_str());
|
||||
const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name;
|
||||
const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x +
|
||||
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(label.c_str());
|
||||
ImGui::SameLine();
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
|
||||
if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid;
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (!unblockCid.empty()) {
|
||||
app->settings()->setChatBlocked(unblockCid, "", false);
|
||||
app->settings()->save();
|
||||
Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan
|
||||
if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false;
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
||||
if (s_show_chat_settings) {
|
||||
material::OverlayDialogSpec ov;
|
||||
@@ -1847,22 +2060,51 @@ void ResetChatTab()
|
||||
s_rename_focus = false;
|
||||
s_show_new_convo = false;
|
||||
s_show_chat_settings = false;
|
||||
s_show_delete_confirm = false;
|
||||
s_delete_cid.clear();
|
||||
s_delete_name.clear();
|
||||
s_show_blocked = false;
|
||||
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
||||
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
||||
s_convs.clear();
|
||||
s_convsHidden = 0;
|
||||
s_convsKey = ~0ull;
|
||||
s_threadCid.clear();
|
||||
s_threadRev = ~0ull;
|
||||
s_threadMsgs.clear();
|
||||
}
|
||||
|
||||
void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
||||
{
|
||||
auto* st = app ? app->settings() : nullptr;
|
||||
if (!st) return;
|
||||
const float dp = Layout::dpiScale();
|
||||
const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably)
|
||||
const float rowGap = 5.0f * dp;
|
||||
const float rowGap = 10.0f * dp;
|
||||
|
||||
// Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the
|
||||
// Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false
|
||||
// and keeps its plain single-surface layout — the controls themselves are identical either way.
|
||||
ImDrawList* cardDL = ImGui::GetWindowDrawList();
|
||||
const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f;
|
||||
material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding();
|
||||
float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f;
|
||||
if (drawCards) {
|
||||
cardTopScr = ImGui::GetCursorScreenPos().y;
|
||||
cardBaseXScr = ImGui::GetCursorScreenPos().x;
|
||||
cardDL->ChannelsSplit(2);
|
||||
cardDL->ChannelsSetCurrent(1);
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad));
|
||||
ImGui::Indent(cardPad);
|
||||
}
|
||||
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
|
||||
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
|
||||
// the chat modal's dialog content region is correct, so it passes 0 (auto).
|
||||
// leftX/rowW define the current column the rows lay out in; retargeted below
|
||||
// to split Appearance | Messaging into two columns when the card is wide.
|
||||
float leftX = ImGui::GetCursorPosX();
|
||||
float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
|
||||
float rowW = drawCards ? (contentWidth - 2.0f * cardPad)
|
||||
: ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x);
|
||||
|
||||
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
|
||||
auto beginRow = [&](const char* label) {
|
||||
@@ -1930,7 +2172,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
// Two internal columns when the card is wide enough: Appearance on the left,
|
||||
// Messaging on the right — fills the width and roughly halves the height.
|
||||
// (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
|
||||
const float chatColGap = 24.0f * dp;
|
||||
const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp);
|
||||
const bool chatTwoCol = rowW > 760.0f * dp;
|
||||
const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
|
||||
const float chatBaseLeftX = leftX;
|
||||
@@ -1987,6 +2229,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
// line-start holds the column; retarget leftX so controls right-align in it).
|
||||
if (chatTwoCol) {
|
||||
chatLeftBottomY = ImGui::GetCursorPosY();
|
||||
cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel
|
||||
ImGui::SetCursorPosY(chatTopY);
|
||||
ImGui::Indent(chatColW + chatColGap);
|
||||
leftX = chatBaseLeftX + chatColW + chatColGap;
|
||||
@@ -2020,12 +2263,41 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
}
|
||||
|
||||
// Close the two-column band: un-indent and drop below the taller column.
|
||||
const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen
|
||||
if (chatTwoCol) {
|
||||
ImGui::Unindent(chatColW + chatColGap);
|
||||
const float chatRightBottomY = ImGui::GetCursorPosY();
|
||||
ImGui::SetCursorPosX(chatBaseLeftX);
|
||||
ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
|
||||
}
|
||||
|
||||
// Paint the glass card(s) behind the content, then merge the channels.
|
||||
if (drawCards) {
|
||||
ImGui::Unindent(cardPad);
|
||||
cardDL->ChannelsSetCurrent(0);
|
||||
const float cardW = (contentWidth - Layout::cardGap()) * 0.5f;
|
||||
if (chatTwoCol) {
|
||||
const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch)
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
|
||||
ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec);
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr),
|
||||
ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec);
|
||||
} else {
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
|
||||
ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec);
|
||||
}
|
||||
cardDL->ChannelsMerge();
|
||||
const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr;
|
||||
// Reserve the card footprint with a Dummy so the parent scroll region grows to include it
|
||||
// (a bare SetCursorScreenPos past content warns in ImGui).
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr));
|
||||
ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad));
|
||||
|
||||
// Live conversation preview below the two cards (Settings tab only — the chat modal renders
|
||||
// its own preview column beside these controls, so it passes drawCards=false and skips this).
|
||||
ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd()));
|
||||
RenderChatSettingsPreview(app, contentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "console_channel.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -76,11 +77,16 @@ public:
|
||||
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
||||
const ConsoleModelLine& back() const { return lines_.back(); }
|
||||
|
||||
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
|
||||
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
|
||||
std::uint64_t revision() const { return revision_; }
|
||||
|
||||
private:
|
||||
const std::size_t max_lines_;
|
||||
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
|
||||
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
|
||||
std::mutex ingest_mutex_;
|
||||
std::uint64_t revision_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -337,7 +337,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
||||
float outputH = ComputeConsoleOutputHeight(
|
||||
availHeight,
|
||||
input_height,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height").size,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height").size * Layout::dpiScale(),
|
||||
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
|
||||
|
||||
ImDrawList* dlOut = ImGui::GetWindowDrawList();
|
||||
@@ -564,12 +564,27 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Line count
|
||||
ImGui::TextDisabled(TR("console_line_count"), model_.size());
|
||||
// Line count — the least-critical trailing element. When the row is too narrow to fit the
|
||||
// filter box (at its placeholder-sized minimum) AND its trailing controls, drop the line count
|
||||
// rather than starve/hide the filter (worst at 1024px). Mirror the reservation formula in
|
||||
// drawFilterInput(): trailing = 4 frame-height buttons + the group spacers, and the filter's
|
||||
// hard floor = its placeholder width + frame padding.
|
||||
{
|
||||
char lineCountBuf[64];
|
||||
snprintf(lineCountBuf, sizeof(lineCountBuf), TR("console_line_count"), model_.size());
|
||||
float lineCountW = ImGui::CalcTextSize(lineCountBuf).x + Layout::spacingSm() * 2.0f; // text + its trailing spacer
|
||||
float trailingW = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
|
||||
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
|
||||
bool showLineCount = ImGui::GetContentRegionAvail().x >= lineCountW + trailingW + filterMinW;
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
if (showLineCount) {
|
||||
ImGui::TextDisabled(TR("console_line_count"), model_.size());
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Output filter input
|
||||
drawFilterInput();
|
||||
@@ -617,7 +632,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
|
||||
ConsoleStatusLine st = exec.toolbarStatus();
|
||||
if (!st.text.empty()) {
|
||||
ImVec2 cp = ImGui::GetCursorScreenPos();
|
||||
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
|
||||
float dotR = (schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size) * Layout::hScale();
|
||||
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
|
||||
float dotX = cp.x + dotR + 2.0f * Layout::dpiScale();
|
||||
|
||||
@@ -687,9 +702,21 @@ void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
|
||||
void ConsoleTab::drawFilterInput()
|
||||
{
|
||||
using namespace material;
|
||||
float zoomBtnSpace = ImGui::GetFrameHeight() * 2.0f + Layout::spacingSm() * 3.0f;
|
||||
float filterAvail = ImGui::GetContentRegionAvail().x - zoomBtnSpace;
|
||||
float filterW = std::min(schema::UI().drawElement("tabs.console", "filter-max-width").size, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
|
||||
// Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar
|
||||
// row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the
|
||||
// group spacers between them. Otherwise the filter eats the row and the trailing controls
|
||||
// run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide.
|
||||
float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
|
||||
float filterAvail = ImGui::GetContentRegionAvail().x - trailingBtnSpace;
|
||||
float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale();
|
||||
float filterW = std::min(filterMaxW, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
|
||||
// Never shrink below the placeholder — otherwise the hint clips to "Filter outp" (or the box
|
||||
// vanishes) at narrow widths. Floor = placeholder text + frame padding + a little breathing room.
|
||||
// (drawToolbar() drops the "NNN lines" count when even this floor won't fit alongside the row's
|
||||
// trailing controls, so this max() doesn't push the zoom/color buttons off-window.)
|
||||
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
|
||||
filterW = std::max(filterMinW, filterW);
|
||||
ImGui::SetNextItemWidth(filterW);
|
||||
ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_));
|
||||
if (filter_text_[0] != '\0') {
|
||||
@@ -768,7 +795,10 @@ void ConsoleTab::renderOutput()
|
||||
// height. The inter-line gap is added explicitly to layout_.heights
|
||||
// so that layout_.cumulativeY stays perfectly in sync with actual
|
||||
// cursor positions (avoiding selection-offset drift).
|
||||
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
|
||||
// Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5
|
||||
// (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the
|
||||
// gap only, never the line height).
|
||||
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale();
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||
|
||||
// Inner padding for glass panel
|
||||
@@ -790,11 +820,23 @@ void ConsoleTab::renderOutput()
|
||||
// segment records which bytes of the source text appear on that visual row, so
|
||||
// hit-testing and selection highlight can map screen positions to exact char offsets.
|
||||
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
||||
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||
layout_ = BuildConsoleLayout(
|
||||
static_cast<int>(visible_indices_.size()),
|
||||
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
||||
wrap_width, line_height, interLineGap, measure);
|
||||
// Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
|
||||
// zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
|
||||
// frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
|
||||
// drawVisibleLines / screenToTextPos when we skip the rebuild.
|
||||
std::uint64_t layoutKey = vis_generation_ * 1000003ull;
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
|
||||
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
|
||||
if (layoutKey != layout_key_) {
|
||||
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||
layout_ = BuildConsoleLayout(
|
||||
static_cast<int>(visible_indices_.size()),
|
||||
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
||||
wrap_width, line_height, interLineGap, measure);
|
||||
layout_key_ = layoutKey;
|
||||
}
|
||||
|
||||
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
|
||||
// the child window's event consumption.
|
||||
@@ -861,6 +903,20 @@ void ConsoleTab::renderOutput()
|
||||
|
||||
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
|
||||
{
|
||||
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
|
||||
// Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
|
||||
// frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
|
||||
// key moves, so an early return leaves last frame's (still-correct) results in place.
|
||||
std::uint64_t visKey = model_.revision() * 1000003ull;
|
||||
for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
|
||||
visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u);
|
||||
visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u);
|
||||
if (visKey == vis_key_) return; // nothing that affects the visible set changed
|
||||
vis_key_ = visKey;
|
||||
++vis_generation_; // the layout pass keys off this
|
||||
|
||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||
s_errors_only_enabled, s_rpc_trace_enabled,
|
||||
s_app_messages_enabled};
|
||||
@@ -1050,8 +1106,11 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
|
||||
ImVec2(cx, cy + sz * 0.7f), triCol); // ▼ expanded
|
||||
}
|
||||
// Click anywhere in the gutter cell for this line's first row toggles the fold.
|
||||
// Guard against a click that is actually dismissing the ConsoleContextMenu (or any
|
||||
// popup) — the same popup guard the text-selection path uses (see mouse_in_output).
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) &&
|
||||
mp.x >= output_origin_.x - padX && mp.x < output_origin_.x &&
|
||||
mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) {
|
||||
pendingFoldToggle = i;
|
||||
@@ -1406,18 +1465,24 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::PopFont();
|
||||
|
||||
// Auto-focus on input
|
||||
if (reclaim_focus) {
|
||||
// Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
|
||||
// (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting).
|
||||
// Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway.
|
||||
if ((reclaim_focus || focus_input_pending_) && !busy) {
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
focus_input_pending_ = false;
|
||||
}
|
||||
|
||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||
{
|
||||
if (cmd.empty()) return false;
|
||||
|
||||
addLine("> " + cmd, ConsoleChannel::Command);
|
||||
AppendConsoleHistory(command_history_, cmd, 100);
|
||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||
const std::string display = RedactConsoleCommand(cmd);
|
||||
addLine("> " + display, ConsoleChannel::Command);
|
||||
AppendConsoleHistory(command_history_, display, 100);
|
||||
history_index_ = -1;
|
||||
|
||||
// First token, lowercased, for built-in interception.
|
||||
@@ -1885,7 +1950,7 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
|
||||
|
||||
// Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding.
|
||||
GlassPanelSpec paneGlass;
|
||||
paneGlass.rounding = 14.0f;
|
||||
paneGlass.rounding = 14.0f * dp;
|
||||
paneGlass.fillAlpha = 30;
|
||||
paneGlass.borderAlpha = 30;
|
||||
|
||||
@@ -2040,6 +2105,12 @@ void ConsoleTab::clear()
|
||||
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
|
||||
visible_indices_.clear();
|
||||
selection_.clear();
|
||||
// Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
|
||||
// visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
|
||||
// memo would skip the rebuild and keep stale geometry for the just-emptied set.
|
||||
vis_key_ = ~0ull;
|
||||
layout_key_ = ~0ull;
|
||||
++vis_generation_;
|
||||
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
||||
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
@@ -65,6 +66,10 @@ public:
|
||||
*/
|
||||
void clear();
|
||||
|
||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||
void requestInputFocus() { focus_input_pending_ = true; }
|
||||
|
||||
// Scanline effect toggle (set from settings)
|
||||
static bool s_scanline_enabled;
|
||||
|
||||
@@ -156,6 +161,7 @@ private:
|
||||
int history_index_ = -1;
|
||||
char input_buffer_[4096] = {0};
|
||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
|
||||
@@ -183,6 +189,14 @@ private:
|
||||
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
|
||||
std::string filter_lower_; // lowercased filter needle for match highlighting
|
||||
|
||||
// Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame):
|
||||
// computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter
|
||||
// state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the
|
||||
// resulting visible-set generation + wrap width + line height/zoom.
|
||||
std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines
|
||||
std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt
|
||||
std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout
|
||||
|
||||
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
||||
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
||||
// consumed by the renderer + hit-testing.
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
#include "console_tab_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
// First tokens (lowercase) of console/RPC commands that carry a secret argument on the command line.
|
||||
// Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) are deliberately absent —
|
||||
// their secret is in the RESULT, which is a separate redaction concern.
|
||||
const char* const kSecretConsoleCommands[] = {
|
||||
"walletpassphrase", "walletpassphrasechange", "encryptwallet",
|
||||
"importprivkey", "importwallet", "importmulti",
|
||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||
};
|
||||
|
||||
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||
size_t b = cmd.find_first_not_of(" \t");
|
||||
if (b == std::string::npos) { tokenEnd = cmd.size(); return {}; }
|
||||
size_t e = cmd.find_first_of(" \t", b);
|
||||
tokenEnd = (e == std::string::npos) ? cmd.size() : e;
|
||||
std::string t = cmd.substr(b, tokenEnd - b);
|
||||
std::transform(t.begin(), t.end(), t.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return t;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
|
||||
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
||||
}
|
||||
|
||||
bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
||||
{
|
||||
size_t end = 0;
|
||||
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||
if (name.empty()) return false;
|
||||
for (const char* s : kSecretConsoleCommands) if (name == s) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string RedactConsoleCommand(const std::string& cmd)
|
||||
{
|
||||
size_t end = 0;
|
||||
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||
if (name.empty()) return cmd;
|
||||
bool secret = false;
|
||||
for (const char* s : kSecretConsoleCommands) if (name == s) { secret = true; break; }
|
||||
if (!secret) return cmd;
|
||||
// Only redact if there are actually arguments after the command name.
|
||||
if (cmd.find_first_not_of(" \t", end) == std::string::npos) return cmd;
|
||||
return cmd.substr(0, end) + " ****";
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float minHeightRatio);
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
||||
|
||||
// True if `cmd`'s first token names a console/RPC command that carries a SECRET on its command line
|
||||
// (passphrase, private/spending/viewing key, mnemonic). Output-secret commands (dumpprivkey,
|
||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||
|
||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||
std::string RedactConsoleCommand(const std::string& cmd);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -40,6 +40,7 @@ static bool s_show_edit_dialog = false;
|
||||
static bool s_show_contacts_settings = false; // Contacts customization modal (gear button)
|
||||
static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens
|
||||
static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms
|
||||
static int s_confirm_avatar_del_idx = -1; // armed avatar-library index; a 2nd badge click confirms the (irreversible) file delete
|
||||
static char s_edit_label[128] = "";
|
||||
static char s_edit_address[512] = "";
|
||||
static char s_edit_notes[512] = "";
|
||||
@@ -206,6 +207,28 @@ static bool isShieldedAddr(const std::string& a) {
|
||||
return !a.empty() && a[0] == 'z';
|
||||
}
|
||||
|
||||
// Width-aware middle-ellipsis truncation (mirrors the add/edit dialog's local fitMiddle lambda, but
|
||||
// file-scope so the Cards/List rows can share it too): keeps head + tail, shrinking symmetrically
|
||||
// until the rendered width fits maxW. minFront/minBack are the schema-configured floor — below that
|
||||
// the fixed-length util::truncateMiddle result is used instead, so very cramped rows still read as
|
||||
// "front...back" rather than collapsing to a near-empty stub. Addresses are ASCII, so byte-wise
|
||||
// trimming is safe.
|
||||
static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW,
|
||||
int minFront, int minBack) {
|
||||
const std::string floor = util::truncateMiddle(s, minFront, minBack);
|
||||
auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; };
|
||||
if (w(s) <= maxW) return s; // fits in full — no truncation needed at all
|
||||
if (maxW <= 0.0f) return floor; // no room to measure against — fall back to the floor
|
||||
const std::string ell = "\xE2\x80\xA6";
|
||||
size_t head = s.size() / 2, tail = s.size() - head;
|
||||
while (head + tail > static_cast<size_t>(minFront + minBack)) {
|
||||
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
|
||||
if (w(cand) <= maxW) return cand;
|
||||
if (head >= tail) --head; else --tail;
|
||||
}
|
||||
return floor; // couldn't fit even at the configured floor — use the fixed-length result
|
||||
}
|
||||
|
||||
// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
|
||||
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
|
||||
static ImU32 contactTypeColor(bool shielded, bool light) {
|
||||
@@ -555,7 +578,7 @@ void RenderContactsTab(App* app)
|
||||
sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
|
||||
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
||||
material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
|
||||
ImU32 fg = active ? IM_COL32(255, 255, 255, 255) : (hov ? material::OnSurface() : material::OnSurfaceMedium());
|
||||
ImU32 fg = active ? material::OnPrimary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
|
||||
float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
|
||||
float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
|
||||
float gapI = 5.0f * dp;
|
||||
@@ -747,12 +770,16 @@ void RenderContactsTab(App* app)
|
||||
float dr = std::max(7.0f * dp, cell * 0.15f);
|
||||
ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
|
||||
bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr));
|
||||
const int libIdx = n - 1;
|
||||
const bool delArmed = (s_confirm_avatar_del_idx == libIdx);
|
||||
ImGui::PushID(n);
|
||||
bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell));
|
||||
bool thumbHov = ImGui::IsItemHovered();
|
||||
ImGui::PopID();
|
||||
if (thumbHov || sel || hov) { // draw the delete badge
|
||||
gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
|
||||
if (thumbHov || sel || hov || delArmed) { // draw the delete badge (persist while armed)
|
||||
gdl->AddCircleFilled(dcc, dr, (dhov || delArmed) ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
|
||||
// While armed, ring the badge so the "click again to delete" state is unmistakable.
|
||||
if (delArmed) gdl->AddCircle(dcc, dr + 1.5f * dp, material::ReadableError(), 0, 1.5f * dp);
|
||||
ImFont* xf = material::Type().iconSmall();
|
||||
float xsz = dr * 1.35f;
|
||||
ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
|
||||
@@ -760,11 +787,18 @@ void RenderContactsTab(App* app)
|
||||
}
|
||||
if (dhov) { // badge takes priority over selecting the thumbnail
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("delete"));
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) pendingDelete = n - 1;
|
||||
// Two-stage confirm: the first click arms this badge; a second click on the SAME
|
||||
// badge deletes the image file (fs::remove is irreversible — no undo).
|
||||
material::Tooltip("%s", delArmed ? TR("address_book_confirm_delete") : TR("delete"));
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
if (delArmed) { pendingDelete = libIdx; s_confirm_avatar_del_idx = -1; }
|
||||
else s_confirm_avatar_del_idx = libIdx; // arm; requires a 2nd deliberate click
|
||||
}
|
||||
} else {
|
||||
if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (thumbClicked) s_edit_avatar = "img:" + path;
|
||||
// Any click that lands off this badge (selecting the thumbnail or elsewhere)
|
||||
// disarms it, so a stale armed badge can't be confirmed by an unrelated click.
|
||||
if (thumbClicked) { s_edit_avatar = "img:" + path; s_confirm_avatar_del_idx = -1; }
|
||||
}
|
||||
}
|
||||
col = (col + 1) % cols;
|
||||
@@ -948,7 +982,7 @@ void RenderContactsTab(App* app)
|
||||
}
|
||||
|
||||
// Search / filter (tight against the toolbar row above — no extra spacer)
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 700.0f * dp));
|
||||
ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
|
||||
s_search, sizeof(s_search));
|
||||
bool searchActive = ImGui::IsItemActive();
|
||||
@@ -1014,7 +1048,7 @@ void RenderContactsTab(App* app)
|
||||
const float tpW = ImGui::GetContentRegionAvail().x;
|
||||
const float tIpad = 12.0f * dp, tVpad = 10.0f * dp;
|
||||
{
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(tpdl, tpMin, ImVec2(tpMin.x + tpW, tpMin.y + listH), g);
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(tpMin.x + tIpad, tpMin.y + tVpad));
|
||||
@@ -1117,7 +1151,7 @@ void RenderContactsTab(App* app)
|
||||
ImDrawList* pdl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
const float pW = ImGui::GetContentRegionAvail().x;
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(pdl, pMin, ImVec2(pMin.x + pW, pMin.y + listH), g);
|
||||
}
|
||||
// AlwaysUseWindowPadding: a borderless child ignores WindowPadding without it, so the rows
|
||||
@@ -1207,10 +1241,13 @@ void RenderContactsTab(App* app)
|
||||
dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
|
||||
dl->PopClipRect();
|
||||
// Un-collapse to the full address on hover (clipped to the text column so it never
|
||||
// runs under the trailing actions); middle-truncated otherwise.
|
||||
// runs under the trailing actions); otherwise middle-truncated to FIT the actual text
|
||||
// column width (tx..textMaxX) rather than a fixed char count — wide rows show more of
|
||||
// the address instead of leaving a dead gap before the action-icon cluster.
|
||||
std::string addr = rowHovered
|
||||
? entry.address
|
||||
: util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
: truncateAddressToWidth(entry.address, adrF, adrSz, textMaxX - tx,
|
||||
addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
|
||||
dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp),
|
||||
material::OnSurfaceMedium(), addr.c_str());
|
||||
@@ -1378,7 +1415,7 @@ void RenderContactsTab(App* app)
|
||||
const float rowH = avR * 2.0f + 16.0f * dp;
|
||||
const float panelH = 2.0f * rowH + gap + 2.0f * padIn;
|
||||
const ImVec2 pOrigin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(pdl, pOrigin, ImVec2(pOrigin.x + w, pOrigin.y + panelH), g);
|
||||
const ImVec2 rmn(pOrigin.x + padIn, pOrigin.y + padIn);
|
||||
const float rowW = w - 2.0f * padIn;
|
||||
|
||||
@@ -408,7 +408,9 @@ private:
|
||||
|
||||
// ---- Below the info card (outside the surface): verify note + install button ----
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
|
||||
Type().textColored(TypeStyle::Caption, downgrade ? Warning() : OnSurfaceMedium(), noteStr);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Spacing();
|
||||
// Install button sized to its text and centered in the pane.
|
||||
const float bw = ImGui::CalcTextSize(label).x + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
||||
|
||||
@@ -108,13 +108,13 @@ static const char* relativeTime(int64_t timestamp) {
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60)
|
||||
snprintf(buf, sizeof(buf), "%lld sec ago", (long long)diff);
|
||||
snprintf(buf, sizeof(buf), TR("grpa_sec_ago"), (long long)diff);
|
||||
else if (diff < 3600)
|
||||
snprintf(buf, sizeof(buf), "%lld min ago", (long long)(diff / 60));
|
||||
snprintf(buf, sizeof(buf), TR("grpa_min_ago"), (long long)(diff / 60));
|
||||
else if (diff < 86400)
|
||||
snprintf(buf, sizeof(buf), "%lld hr ago", (long long)(diff / 3600));
|
||||
snprintf(buf, sizeof(buf), TR("grpa_hr_ago"), (long long)(diff / 3600));
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%lld days ago", (long long)(diff / 86400));
|
||||
snprintf(buf, sizeof(buf), TR("grpa_days_ago"), (long long)(diff / 86400));
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -446,10 +446,10 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
|
||||
|
||||
float inputW = std::min(
|
||||
S.drawElement("tabs.explorer", "search-input-width").size,
|
||||
S.drawElement("tabs.explorer", "search-input-width").size * Layout::dpiScale(),
|
||||
availWidth * 0.65f);
|
||||
float btnW = S.drawElement("tabs.explorer", "search-button-width").size;
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
|
||||
float btnW = S.drawElement("tabs.explorer", "search-button-width").size * Layout::dpiScale();
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size * Layout::dpiScale();
|
||||
|
||||
// Clamp so search bar never overflows
|
||||
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
|
||||
@@ -620,7 +620,16 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
|
||||
drawStatusPill(cardMin, cardW);
|
||||
|
||||
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
|
||||
// Distribute the two stat blocks (Height, Best Block) evenly through the
|
||||
// content region so the card matches the density of the sibling 2x2 metric
|
||||
// grid instead of pinning Height to the top and Best Block to the bottom
|
||||
// edge with a large empty gap between them.
|
||||
float contentTop = cardMin.y + pad * 0.5f + headerH;
|
||||
float contentBottom = cardMax.y - pad;
|
||||
float blockGap = std::max(Layout::spacingMd(),
|
||||
(contentBottom - contentTop - heroLineH - hashLineH) / 3.0f);
|
||||
|
||||
float labelY = contentTop + blockGap;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
|
||||
@@ -640,7 +649,7 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
}
|
||||
|
||||
float hashLabelY = cardMax.y - pad - hashLineH;
|
||||
float hashLabelY = labelY + heroLineH + blockGap;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
|
||||
@@ -755,8 +764,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImFont* body2 = Type().body2();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size * dp;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size * dp;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
|
||||
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
@@ -989,7 +998,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size;
|
||||
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size * dp;
|
||||
ApplyScrollEdgeMask(dl, parentVtx, childDL, childVtx,
|
||||
rowAreaTop, rowAreaTop + rowAreaH, fadeZone, scrollY, scrollMaxY);
|
||||
|
||||
@@ -1116,8 +1125,9 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
// ── Info grid ──
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
float dp = Layout::dpiScale();
|
||||
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float labelW = S.drawElement("tabs.explorer", "label-column").size;
|
||||
float labelW = S.drawElement("tabs.explorer", "label-column").size * dp;
|
||||
{
|
||||
ImVec2 gridPos = ImGui::GetCursorScreenPos();
|
||||
float gx = gridPos.x;
|
||||
@@ -1178,7 +1188,7 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size;
|
||||
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size * dp;
|
||||
ImU32 linkCol = schema::UI().resolveColor("var(--secondary-light)");
|
||||
|
||||
for (int i = 0; i < (int)s_detail_txids.size(); i++) {
|
||||
@@ -1206,7 +1216,7 @@ static void renderBlockDetailModal(App* app) {
|
||||
txDL->AddRectFilled(rowStart,
|
||||
ImVec2(rowStart.x + txContentW, rowStart.y + txRowH),
|
||||
WithAlpha(OnSurface(), 10),
|
||||
S.drawElement("tabs.explorer", "row-rounding").size);
|
||||
S.drawElement("tabs.explorer", "row-rounding").size * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", txid.c_str());
|
||||
}
|
||||
@@ -1305,7 +1315,7 @@ static void renderBlockDetailModal(App* app) {
|
||||
}
|
||||
|
||||
if (s_detail_txids.size() > 100) {
|
||||
snprintf(buf, sizeof(buf), "... showing first 100 of %d", (int)s_detail_txids.size());
|
||||
snprintf(buf, sizeof(buf), TR("grpa_showing_first_100_of"), (int)s_detail_txids.size());
|
||||
ImGui::TextDisabled("%s", buf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "faq_content.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace faq {
|
||||
|
||||
// ── Wallet group ───────────────────────────────────────────────────────────
|
||||
// Variant-aware: gs_1 (what the app IS) and sec_1 (where encryption lives) differ between the
|
||||
// full-node and Lite builds; migrate-to-seed (seed_2) is full-node only; and the Lite build gets a
|
||||
// trailing "Lite Wallet" subcategory that stands in for the (hidden) Daemon group. The remaining
|
||||
// answers are worded neutrally so one string serves both variants.
|
||||
static std::vector<FaqSubcategory> buildWalletFaq(bool fullNode)
|
||||
{
|
||||
std::vector<FaqSubcategory> w;
|
||||
w.push_back({ "faq_w_gs_title", {
|
||||
{ fullNode ? "faq_w_gs_1_q" : "faq_l_gs_1_q", fullNode ? "faq_w_gs_1_a" : "faq_l_gs_1_a" },
|
||||
{ "faq_w_gs_2_q", "faq_w_gs_2_a" },
|
||||
{ "faq_w_gs_3_q", "faq_w_gs_3_a" },
|
||||
{ "faq_w_gs_4_q", "faq_w_gs_4_a" },
|
||||
}});
|
||||
w.push_back({ "faq_w_addr_title", {
|
||||
{ "faq_w_addr_1_q", "faq_w_addr_1_a" },
|
||||
{ "faq_w_addr_2_q", "faq_w_addr_2_a" },
|
||||
{ "faq_w_addr_3_q", "faq_w_addr_3_a" },
|
||||
{ "faq_w_addr_4_q", "faq_w_addr_4_a" },
|
||||
}});
|
||||
w.push_back({ "faq_w_send_title", {
|
||||
{ "faq_w_send_1_q", "faq_w_send_1_a" },
|
||||
{ "faq_w_send_2_q", "faq_w_send_2_a" },
|
||||
{ "faq_w_send_3_q", "faq_w_send_3_a" },
|
||||
{ "faq_w_send_4_q", "faq_w_send_4_a" },
|
||||
}});
|
||||
w.push_back({ "faq_w_bal_title", {
|
||||
{ "faq_w_bal_1_q", "faq_w_bal_1_a" },
|
||||
{ "faq_w_bal_2_q", "faq_w_bal_2_a" },
|
||||
{ "faq_w_bal_3_q", "faq_w_bal_3_a" },
|
||||
}});
|
||||
w.push_back({ "faq_w_sec_title", {
|
||||
{ "faq_w_sec_1_q", fullNode ? "faq_w_sec_1_a" : "faq_l_sec_1_a" },
|
||||
{ "faq_w_sec_2_q", "faq_w_sec_2_a" },
|
||||
{ "faq_w_sec_3_q", "faq_w_sec_3_a" },
|
||||
}});
|
||||
{
|
||||
std::vector<FaqEntry> seed = { { "faq_w_seed_1_q", "faq_w_seed_1_a" } };
|
||||
if (fullNode) seed.push_back({ "faq_w_seed_2_q", "faq_w_seed_2_a" }); // migrate-to-seed is full-node only
|
||||
seed.push_back({ "faq_w_seed_3_q", "faq_w_seed_3_a" });
|
||||
seed.push_back({ "faq_w_seed_4_q", "faq_w_seed_4_a" });
|
||||
w.push_back({ "faq_w_seed_title", std::move(seed) });
|
||||
}
|
||||
w.push_back({ "faq_w_chat_title", {
|
||||
{ "faq_w_chat_1_q", "faq_w_chat_1_a" },
|
||||
{ "faq_w_chat_2_q", "faq_w_chat_2_a" },
|
||||
{ "faq_w_chat_3_q", "faq_w_chat_3_a" },
|
||||
{ "faq_w_chat_4_q", "faq_w_chat_4_a" },
|
||||
}});
|
||||
w.push_back({ "faq_w_set_title", {
|
||||
{ "faq_w_set_1_q", "faq_w_set_1_a" },
|
||||
{ "faq_w_set_2_q", "faq_w_set_2_a" },
|
||||
{ "faq_w_set_3_q", "faq_w_set_3_a" },
|
||||
{ "faq_w_set_4_q", "faq_w_set_4_a" },
|
||||
}});
|
||||
// Lite-only: explain the server model (stands in for the hidden Daemon group).
|
||||
if (!fullNode) {
|
||||
w.push_back({ "faq_l_lite_title", {
|
||||
{ "faq_l_lite_1_q", "faq_l_lite_1_a" },
|
||||
{ "faq_l_lite_2_q", "faq_l_lite_2_a" },
|
||||
{ "faq_l_lite_3_q", "faq_l_lite_3_a" },
|
||||
}});
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
const std::vector<FaqSubcategory>& walletFaq(bool fullNode)
|
||||
{
|
||||
static const std::vector<FaqSubcategory> kFull = buildWalletFaq(true);
|
||||
static const std::vector<FaqSubcategory> kLite = buildWalletFaq(false);
|
||||
return fullNode ? kFull : kLite;
|
||||
}
|
||||
|
||||
// ── Daemon group (full-node only) ──────────────────────────────────────────
|
||||
const std::vector<FaqSubcategory>& daemonFaq()
|
||||
{
|
||||
static const std::vector<FaqSubcategory> kDaemon = {
|
||||
{ "faq_d_node_title", {
|
||||
{ "faq_d_node_1_q", "faq_d_node_1_a" },
|
||||
{ "faq_d_node_2_q", "faq_d_node_2_a" },
|
||||
{ "faq_d_node_3_q", "faq_d_node_3_a" },
|
||||
}},
|
||||
{ "faq_d_sync_title", {
|
||||
{ "faq_d_sync_1_q", "faq_d_sync_1_a" },
|
||||
{ "faq_d_sync_2_q", "faq_d_sync_2_a" },
|
||||
{ "faq_d_sync_3_q", "faq_d_sync_3_a" },
|
||||
{ "faq_d_sync_4_q", "faq_d_sync_4_a" },
|
||||
}},
|
||||
{ "faq_d_mgmt_title", {
|
||||
{ "faq_d_mgmt_1_q", "faq_d_mgmt_1_a" },
|
||||
{ "faq_d_mgmt_2_q", "faq_d_mgmt_2_a" },
|
||||
{ "faq_d_mgmt_3_q", "faq_d_mgmt_3_a" },
|
||||
}},
|
||||
{ "faq_d_upd_title", {
|
||||
{ "faq_d_upd_1_q", "faq_d_upd_1_a" },
|
||||
{ "faq_d_upd_2_q", "faq_d_upd_2_a" },
|
||||
{ "faq_d_upd_3_q", "faq_d_upd_3_a" },
|
||||
}},
|
||||
{ "faq_d_mine_title", {
|
||||
{ "faq_d_mine_1_q", "faq_d_mine_1_a" },
|
||||
{ "faq_d_mine_2_q", "faq_d_mine_2_a" },
|
||||
{ "faq_d_mine_3_q", "faq_d_mine_3_a" },
|
||||
{ "faq_d_mine_4_q", "faq_d_mine_4_a" },
|
||||
}},
|
||||
{ "faq_d_net_title", {
|
||||
{ "faq_d_net_1_q", "faq_d_net_1_a" },
|
||||
{ "faq_d_net_2_q", "faq_d_net_2_a" },
|
||||
}},
|
||||
{ "faq_d_perf_title", {
|
||||
{ "faq_d_perf_1_q", "faq_d_perf_1_a" },
|
||||
{ "faq_d_perf_2_q", "faq_d_perf_2_a" },
|
||||
}},
|
||||
{ "faq_d_trbl_title", {
|
||||
{ "faq_d_trbl_1_q", "faq_d_trbl_1_a" },
|
||||
{ "faq_d_trbl_2_q", "faq_d_trbl_2_a" },
|
||||
{ "faq_d_trbl_3_q", "faq_d_trbl_3_a" },
|
||||
{ "faq_d_trbl_4_q", "faq_d_trbl_4_a" },
|
||||
}},
|
||||
};
|
||||
return kDaemon;
|
||||
}
|
||||
|
||||
} // namespace faq
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user