Compare commits
125
Commits
v2.0.0
...
71ed92b9f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ed92b9f1 | ||
|
|
e367eb47ff | ||
|
|
31d8742ce7 | ||
|
|
5246898968 | ||
|
|
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`.
|
||||
+81
-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
|
||||
@@ -455,6 +467,7 @@ set(APP_SOURCES
|
||||
src/app_sweep.cpp
|
||||
src/app_wizard.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
@@ -521,6 +534,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 +559,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 +672,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 +1092,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
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1082,6 +1147,7 @@ if(BUILD_TESTING)
|
||||
add_executable(ObsidianDragonTests
|
||||
tests/test_phase4.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
@@ -1122,6 +1188,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 +1196,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 +1246,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,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,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.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+653
-33
File diff suppressed because it is too large
Load Diff
+653
-33
File diff suppressed because it is too large
Load Diff
+653
-33
File diff suppressed because it is too large
Load Diff
+651
-31
File diff suppressed because it is too large
Load Diff
+653
-33
File diff suppressed because it is too large
Load Diff
+653
-33
File diff suppressed because it is too large
Load Diff
+653
-33
File diff suppressed because it is too large
Load Diff
+651
-31
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,644 @@ def load_english_keys():
|
||||
|
||||
# All translations for the 158 missing keys
|
||||
TRANSLATIONS = {
|
||||
"merge_where": {
|
||||
"de": "Wohin sollen die Mittel gehen?",
|
||||
"es": "¿Adónde deben ir los fondos?",
|
||||
"fr": "Où doivent aller les fonds ?",
|
||||
"pt": "Para onde devem ir os fundos?",
|
||||
"ru": "Куда направить средства?",
|
||||
"zh": "资金应转到哪里?",
|
||||
"ja": "資金の移動先",
|
||||
"ko": "자금을 어디로 보낼까요?",
|
||||
},
|
||||
"merge_into_one": {
|
||||
"de": "In eine Adresse",
|
||||
"es": "A una sola dirección",
|
||||
"fr": "Vers une seule adresse",
|
||||
"pt": "Para um só endereço",
|
||||
"ru": "На один адрес",
|
||||
"zh": "合并到一个地址",
|
||||
"ja": "1 つのアドレスへ",
|
||||
"ko": "한 주소로",
|
||||
},
|
||||
"merge_keep_each": {
|
||||
"de": "In jeder Adresse belassen",
|
||||
"es": "Mantener en cada dirección",
|
||||
"fr": "Garder dans chaque adresse",
|
||||
"pt": "Manter em cada endereço",
|
||||
"ru": "Оставить в каждом адресе",
|
||||
"zh": "保留在各自地址",
|
||||
"ja": "各アドレスに残す",
|
||||
"ko": "각 주소에 유지",
|
||||
},
|
||||
"merge_keep_each_desc": {
|
||||
"de": "Die Notes jeder Adresse werden in genau diese Adresse zurückgeführt, sodass Mittel, die Sie unter verschiedenen Bezeichnungen getrennt halten, getrennt bleiben. Nur abgeschirmte Adressen – transparente Mittel können nur abgeschirmt werden (In eine Adresse).",
|
||||
"es": "Las notas de cada dirección se fusionan de nuevo en esa misma dirección, así que los fondos que mantienes separados con distintas etiquetas siguen separados. Solo direcciones blindadas: los fondos transparentes solo pueden blindarse (A una sola dirección).",
|
||||
"fr": "Les notes de chaque adresse sont refusionnées dans cette même adresse : les fonds que vous gardez séparés sous des libellés différents restent séparés. Adresses blindées uniquement — les fonds transparents ne peuvent qu'être blindés (Vers une seule adresse).",
|
||||
"pt": "As notas de cada endereço são mescladas de volta nesse mesmo endereço, então os fundos que você mantém separados com rótulos diferentes continuam separados. Só endereços blindados — fundos transparentes só podem ser blindados (Para um só endereço).",
|
||||
"ru": "Заметки каждого адреса объединяются обратно в тот же адрес, поэтому средства, которые вы держите раздельно под разными метками, остаются раздельными. Только экранированные адреса — прозрачные средства можно только экранировать («На один адрес»).",
|
||||
"zh": "每个地址的票据会合并回该地址本身,因此您用不同标签分开存放的资金仍保持分开。仅限屏蔽地址——透明资金只能被屏蔽(合并到一个地址)。",
|
||||
"ja": "各アドレスのノートはそのアドレス自身に統合されるため、ラベルごとに分けている資金は分かれたままです。シールドアドレスのみ対象です。透明アドレスの資金はシールドすることしかできません(1 つのアドレスへ)。",
|
||||
"ko": "각 주소의 노트는 같은 주소로 다시 병합되므로, 다른 라벨로 나눠 둔 자금은 계속 나뉜 상태로 유지됩니다. 차폐 주소만 해당되며, 투명 자금은 차폐만 할 수 있습니다(한 주소로).",
|
||||
},
|
||||
"merge_keep_each_none": {
|
||||
"de": "Jede abgeschirmte Adresse hat bereits nur eine Note – nichts zu konsolidieren.",
|
||||
"es": "Cada dirección blindada ya tiene una sola nota: no hay nada que consolidar.",
|
||||
"fr": "Chaque adresse blindée ne contient déjà qu'une seule note — rien à consolider.",
|
||||
"pt": "Cada endereço blindado já tem uma única nota — nada a consolidar.",
|
||||
"ru": "На каждом экранированном адресе уже одна заметка — объединять нечего.",
|
||||
"zh": "每个屏蔽地址都只有一张票据——无需整合。",
|
||||
"ja": "すべてのシールドアドレスのノートはすでに 1 つです。統合するものはありません。",
|
||||
"ko": "모든 차폐 주소에 노트가 이미 하나뿐입니다. 통합할 것이 없습니다.",
|
||||
},
|
||||
"merge_keep_each_row_fmt": {
|
||||
"de": "%s · %d Notes · %s DRGX",
|
||||
"es": "%s · %d notas · %s DRGX",
|
||||
"fr": "%s · %d notes · %s DRGX",
|
||||
"pt": "%s · %d notas · %s DRGX",
|
||||
"ru": "%s · заметок: %d · %s DRGX",
|
||||
"zh": "%s · %d 张票据 · %s DRGX",
|
||||
"ja": "%s · ノート %d 件 · %s DRGX",
|
||||
"ko": "%s · 노트 %d개 · %s DRGX",
|
||||
},
|
||||
"merge_keep_each_too_small": {
|
||||
"de": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
|
||||
"es": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
|
||||
"fr": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
|
||||
"pt": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
|
||||
"ru": "Слишком мало для объединения: комиссия съест эти средства.",
|
||||
"zh": "金额太小,无法整合:手续费会耗尽这些资金。",
|
||||
"ja": "少なすぎて統合できません。手数料でこの資金がなくなります。",
|
||||
"ko": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
|
||||
},
|
||||
"merge_keep_each_batches_fmt": {
|
||||
"de": "Adressen mit mehr als %d Notes werden in mehreren Durchgängen zusammengeführt; führen Sie es nach deren Bestätigung erneut aus, um den Rest zusammenzuführen.",
|
||||
"es": "Las direcciones con más de %d notas se fusionan en varios lotes; vuelve a ejecutarlo cuando se confirmen para fusionar el resto.",
|
||||
"fr": "Les adresses de plus de %d notes sont fusionnées en plusieurs lots ; relancez l'opération une fois ceux-ci confirmés pour fusionner le reste.",
|
||||
"pt": "Endereços com mais de %d notas são mesclados em vários lotes; execute de novo depois que forem confirmados para mesclar o restante.",
|
||||
"ru": "Адреса с более чем %d заметками объединяются в несколько партий; запустите снова после их подтверждения, чтобы объединить остальное.",
|
||||
"zh": "超过 %d 张票据的地址会分多批合并;确认后再运行一次即可合并其余部分。",
|
||||
"ja": "ノートが %d 件を超えるアドレスは複数回に分けて統合されます。承認後にもう一度実行すると残りが統合されます。",
|
||||
"ko": "노트가 %d개를 넘는 주소는 여러 번에 나눠 병합됩니다. 확인된 후 다시 실행하면 나머지가 병합됩니다.",
|
||||
},
|
||||
"merge_keep_each_confirm_fmt": {
|
||||
"de": "%d Notes in %d Adressen konsolidieren? Jede Adresse behält ihre eigenen Mittel. Gebühren: etwa %s DRGX (eine pro Durchgang).",
|
||||
"es": "¿Consolidar %d notas en %d direcciones? Cada dirección conserva sus propios fondos. Comisiones: unos %s DRGX (una por lote).",
|
||||
"fr": "Consolider %d notes dans %d adresses ? Chaque adresse garde ses propres fonds. Frais : environ %s DRGX (un par lot).",
|
||||
"pt": "Consolidar %d notas em %d endereços? Cada endereço mantém seus próprios fundos. Taxas: cerca de %s DRGX (uma por lote).",
|
||||
"ru": "Объединить %d заметок на %d адресах? Каждый адрес сохраняет свои средства. Комиссии: около %s DRGX (по одной на партию).",
|
||||
"zh": "整合 %d 张票据,涉及 %d 个地址?每个地址保留各自的资金。手续费:约 %s DRGX(每批一次)。",
|
||||
"ja": "%d 件のノートを %d 個のアドレスで統合しますか?各アドレスの資金はそのまま残ります。手数料:約 %s DRGX(1 回ごとに 1 件)。",
|
||||
"ko": "노트 %d개를 주소 %d개에서 통합할까요? 각 주소는 자기 자금을 그대로 유지합니다. 수수료: 약 %s DRGX(배치마다 1회).",
|
||||
},
|
||||
"merge_keep_each_select": {
|
||||
"de": "Wählen Sie mindestens eine Adresse zum Konsolidieren aus.",
|
||||
"es": "Marca al menos una dirección para consolidar.",
|
||||
"fr": "Cochez au moins une adresse à consolider.",
|
||||
"pt": "Marque pelo menos um endereço para consolidar.",
|
||||
"ru": "Отметьте хотя бы один адрес для объединения.",
|
||||
"zh": "请至少勾选一个要整合的地址。",
|
||||
"ja": "統合するアドレスを 1 つ以上選んでください。",
|
||||
"ko": "통합할 주소를 하나 이상 선택하세요.",
|
||||
},
|
||||
"merge_keep_each_nothing_submitted": {
|
||||
"de": "Nichts wurde konsolidiert – der Node hat jede Zusammenführung abgelehnt.",
|
||||
"es": "No se consolidó nada: el nodo rechazó todas las fusiones.",
|
||||
"fr": "Rien n'a été consolidé — le nœud a refusé chaque fusion.",
|
||||
"pt": "Nada foi consolidado — o nó recusou todas as mesclagens.",
|
||||
"ru": "Ничего не объединено — узел отклонил все объединения.",
|
||||
"zh": "没有任何整合——节点拒绝了所有合并。",
|
||||
"ja": "何も統合されませんでした。ノードがすべての統合を拒否しました。",
|
||||
"ko": "아무것도 통합되지 않았습니다. 노드가 모든 병합을 거부했습니다.",
|
||||
},
|
||||
"merge_keep_each_some_failed": {
|
||||
"de": "Einige Zusammenführungen sind fehlgeschlagen – siehe Liste oben.",
|
||||
"es": "Algunas fusiones fallaron; consulta la lista de arriba.",
|
||||
"fr": "Certaines fusions ont échoué — voir la liste ci-dessus.",
|
||||
"pt": "Algumas mesclagens falharam — veja a lista acima.",
|
||||
"ru": "Некоторые объединения не удались — см. список выше.",
|
||||
"zh": "部分合并失败——请查看上方列表。",
|
||||
"ja": "一部の統合に失敗しました。上の一覧を確認してください。",
|
||||
"ko": "일부 병합에 실패했습니다. 위 목록을 확인하세요.",
|
||||
},
|
||||
"merge_keep_each_status_not_started": {
|
||||
"de": "%s – nicht gestartet: %s",
|
||||
"es": "%s: no iniciado: %s",
|
||||
"fr": "%s — non démarré : %s",
|
||||
"pt": "%s — não iniciado: %s",
|
||||
"ru": "%s — не запущено: %s",
|
||||
"zh": "%s——未开始:%s",
|
||||
"ja": "%s — 開始されませんでした:%s",
|
||||
"ko": "%s — 시작되지 않음: %s",
|
||||
},
|
||||
"merge_keep_each_status_failed": {
|
||||
"de": "%s – fehlgeschlagen: %s",
|
||||
"es": "%s: falló: %s",
|
||||
"fr": "%s — échec : %s",
|
||||
"pt": "%s — falhou: %s",
|
||||
"ru": "%s — ошибка: %s",
|
||||
"zh": "%s——失败:%s",
|
||||
"ja": "%s — 失敗:%s",
|
||||
"ko": "%s — 실패: %s",
|
||||
},
|
||||
"merge_keep_each_status_done": {
|
||||
"de": "%s – fertig: %d Notes in %d Durchgang/Durchgängen zusammengeführt",
|
||||
"es": "%s: listo: %d notas fusionadas en %d lote(s)",
|
||||
"fr": "%s — terminé : %d notes fusionnées en %d lot(s)",
|
||||
"pt": "%s — concluído: %d notas mescladas em %d lote(s)",
|
||||
"ru": "%s — готово: объединено заметок: %d, партий: %d",
|
||||
"zh": "%s——完成:已合并 %d 张票据,共 %d 批",
|
||||
"ja": "%s — 完了:%d 件のノートを %d 回で統合",
|
||||
"ko": "%s — 완료: 노트 %d개를 %d번에 걸쳐 병합",
|
||||
},
|
||||
"merge_keep_each_status_running": {
|
||||
"de": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
|
||||
"es": "%s: fusionando %d notas (%d de %d lotes listos)…",
|
||||
"fr": "%s — fusion de %d notes (%d lots sur %d terminés)…",
|
||||
"pt": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
|
||||
"ru": "%s — объединение %d заметок (готово партий: %d из %d)…",
|
||||
"zh": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
|
||||
"ja": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
|
||||
"ko": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
|
||||
},
|
||||
"merge_keep_each_run_again": {
|
||||
"de": "Adressen, die in mehreren Durchgängen zusammengeführt wurden, haben jetzt eine Note pro Durchgang. Führen Sie dies nach deren Bestätigung erneut aus, um sie zu einer zusammenzuführen.",
|
||||
"es": "Las direcciones fusionadas en varios lotes tienen ahora una nota por lote. Vuelve a ejecutarlo cuando se confirmen para fusionarlas en una.",
|
||||
"fr": "Les adresses fusionnées en plusieurs lots contiennent maintenant une note par lot. Relancez l'opération une fois ceux-ci confirmés pour les fusionner en une seule.",
|
||||
"pt": "Endereços mesclados em vários lotes agora têm uma nota por lote. Execute de novo depois que forem confirmados para mesclá-las em uma.",
|
||||
"ru": "На адресах, объединённых в несколько партий, теперь по одной заметке на партию. Запустите снова после их подтверждения, чтобы объединить их в одну.",
|
||||
"zh": "分多批合并的地址现在每批各有一张票据。确认后再运行一次,即可把它们合并为一张。",
|
||||
"ja": "複数回に分けて統合したアドレスには、回数分のノートが残っています。承認後にもう一度実行すると 1 つにまとめられます。",
|
||||
"ko": "여러 번에 나눠 병합된 주소에는 배치마다 노트가 하나씩 남아 있습니다. 확인된 후 다시 실행하면 하나로 병합됩니다.",
|
||||
},
|
||||
"mining_addr_explainer": {
|
||||
"de": "Empfängt nur Solo-Mining-Belohnungen – das Netzwerk lehnt Zahlungen an diese Adresse ab. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"es": "Solo recibe recompensas de minería en solitario; la red rechaza los pagos a esta dirección. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"fr": "Ne reçoit que des récompenses de minage en solo — le réseau refuse les paiements vers cette adresse. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"pt": "Recebe apenas recompensas de mineração solo — a rede rejeita pagamentos para este endereço. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"ru": "Принимает только награды за соло-майнинг — сеть отклоняет платежи на этот адрес. Награды экранируются автоматически, когда созревают.",
|
||||
"zh": "仅接收单人挖矿奖励——网络会拒绝向此地址付款。奖励成熟后会自动屏蔽。",
|
||||
"ja": "ソロマイニング報酬のみを受け取ります。ネットワークはこのアドレスへの支払いを拒否します。報酬は成熟すると自動的にシールドされます。",
|
||||
"ko": "솔로 채굴 보상만 받습니다. 네트워크가 이 주소로의 결제를 거부합니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
},
|
||||
"receive_use_shielded": {
|
||||
"de": "Abgeschirmte Adresse verwenden",
|
||||
"es": "Usar una dirección blindada",
|
||||
"fr": "Utiliser une adresse blindée",
|
||||
"pt": "Usar um endereço blindado",
|
||||
"ru": "Использовать экранированный адрес",
|
||||
"zh": "使用屏蔽地址",
|
||||
"ja": "シールドアドレスを使う",
|
||||
"ko": "차폐 주소 사용",
|
||||
},
|
||||
"receive_not_for_payments": {
|
||||
"de": "Nicht für Zahlungen",
|
||||
"es": "No para pagos",
|
||||
"fr": "Pas pour les paiements",
|
||||
"pt": "Não é para pagamentos",
|
||||
"ru": "Не для платежей",
|
||||
"zh": "不用于付款",
|
||||
"ja": "支払いには使えません",
|
||||
"ko": "결제용이 아님",
|
||||
},
|
||||
"validate_mining_type": {
|
||||
"de": "Mining-Adresse (transparent)",
|
||||
"es": "Dirección de minería (transparente)",
|
||||
"fr": "Adresse de minage (transparente)",
|
||||
"pt": "Endereço de mineração (transparente)",
|
||||
"ru": "Адрес для майнинга (прозрачный)",
|
||||
"zh": "挖矿地址(透明)",
|
||||
"ja": "マイニングアドレス(透明)",
|
||||
"ko": "채굴 주소(투명)",
|
||||
},
|
||||
"send_mining_addr_short": {
|
||||
"de": "Mining-Adresse – kann keine Zahlungen empfangen",
|
||||
"es": "Dirección de minería: no puede recibir pagos",
|
||||
"fr": "Adresse de minage — ne peut pas recevoir de paiements",
|
||||
"pt": "Endereço de mineração — não pode receber pagamentos",
|
||||
"ru": "Адрес для майнинга — не принимает платежи",
|
||||
"zh": "挖矿地址——无法接收付款",
|
||||
"ja": "マイニングアドレス — 支払いを受け取れません",
|
||||
"ko": "채굴 주소 — 결제를 받을 수 없음",
|
||||
},
|
||||
"send_mining_addr_blocked": {
|
||||
"de": "Dies ist eine Mining-Adresse (transparent). Bei DragonX können solche Adressen nur Mining-Belohnungen empfangen – das Netzwerk lehnt Zahlungen an sie ab. Bitten Sie den Empfänger um eine abgeschirmte Adresse (sie beginnt mit zs).",
|
||||
"es": "Esta es una dirección de minería (transparente). En DragonX solo pueden recibir recompensas de minería; la red rechaza los pagos a ellas. Pide al destinatario una dirección blindada (empieza por zs).",
|
||||
"fr": "Ceci est une adresse de minage (transparente). Sur DragonX, elles ne peuvent recevoir que des récompenses de minage — le réseau refuse les paiements vers elles. Demandez au destinataire une adresse blindée (elle commence par zs).",
|
||||
"pt": "Este é um endereço de mineração (transparente). No DragonX eles só podem receber recompensas de mineração — a rede rejeita pagamentos para eles. Peça ao destinatário um endereço blindado (começa com zs).",
|
||||
"ru": "Это адрес для майнинга (прозрачный). В DragonX такие адреса могут получать только награды за майнинг — сеть отклоняет платежи на них. Попросите у получателя экранированный адрес (начинается с zs).",
|
||||
"zh": "这是一个挖矿(透明)地址。在 DragonX 上,这类地址只能接收挖矿奖励——网络会拒绝向其付款。请向收款人索取屏蔽地址(以 zs 开头)。",
|
||||
"ja": "これはマイニング(透明)アドレスです。DragonX ではこのアドレスはマイニング報酬しか受け取れず、ネットワークはこのアドレスへの支払いを拒否します。受取人にシールドアドレス(zs で始まる)を依頼してください。",
|
||||
"ko": "채굴(투명) 주소입니다. DragonX에서 이런 주소는 채굴 보상만 받을 수 있으며, 네트워크가 이 주소로의 결제를 거부합니다. 받는 사람에게 차폐 주소(zs로 시작)를 요청하세요.",
|
||||
},
|
||||
"mining_payout_needs_shielded": {
|
||||
"de": "Pools zahlen mit gewöhnlichen Transaktionen aus, die bei DragonX keine Mining-Adresse (transparent, R…) erreichen können. Verwenden Sie eine abgeschirmte Adresse (sie beginnt mit zs) als Auszahlungsadresse.",
|
||||
"es": "Los pools pagan con transacciones normales, que en DragonX no pueden llegar a una dirección de minería (transparente, R…). Usa una dirección blindada (empieza por zs) como dirección de pago.",
|
||||
"fr": "Les pools paient avec des transactions ordinaires, qui ne peuvent pas atteindre une adresse de minage (transparente, R…) sur DragonX. Utilisez une adresse blindée (elle commence par zs) comme adresse de paiement.",
|
||||
"pt": "Os pools pagam com transações comuns, que no DragonX não conseguem chegar a um endereço de mineração (transparente, R…). Use um endereço blindado (começa com zs) como endereço de pagamento.",
|
||||
"ru": "Пулы выплачивают обычными транзакциями, которые в DragonX не могут дойти до адреса для майнинга (прозрачного, R…). Укажите экранированный адрес (начинается с zs) как адрес для выплат.",
|
||||
"zh": "矿池使用普通交易付款,而在 DragonX 上普通交易无法到达挖矿(透明,R…)地址。请使用屏蔽地址(以 zs 开头)作为收款地址。",
|
||||
"ja": "プールは通常のトランザクションで支払いますが、DragonX ではそれがマイニング(透明、R…)アドレスに届きません。支払い先にはシールドアドレス(zs で始まる)を使用してください。",
|
||||
"ko": "풀은 일반 트랜잭션으로 지급하는데, DragonX에서는 이것이 채굴(투명, R…) 주소에 도달할 수 없습니다. 지급 주소로 차폐 주소(zs로 시작)를 사용하세요.",
|
||||
},
|
||||
"new_mining_address": {
|
||||
"de": "Neue Mining-Adresse",
|
||||
"es": "Nueva dirección de minería",
|
||||
"fr": "Nouvelle adresse de minage",
|
||||
"pt": "Novo endereço de mineração",
|
||||
"ru": "Новый адрес для майнинга",
|
||||
"zh": "新建挖矿地址",
|
||||
"ja": "新しいマイニングアドレス",
|
||||
"ko": "새 채굴 주소",
|
||||
},
|
||||
"tt_new_mining_address": {
|
||||
"de": "Erstellt eine transparente Adresse (R…) für Solo-Mining-Belohnungen. Sie kann keine Zahlungen empfangen – teilen Sie dafür eine abgeschirmte Adresse.",
|
||||
"es": "Crea una dirección transparente (R…) para recompensas de minería en solitario. No puede recibir pagos; para eso comparte una dirección blindada.",
|
||||
"fr": "Crée une adresse transparente (R…) pour les récompenses de minage en solo. Elle ne peut pas recevoir de paiements — partagez une adresse blindée pour cela.",
|
||||
"pt": "Cria um endereço transparente (R…) para recompensas de mineração solo. Ele não pode receber pagamentos — compartilhe um endereço blindado para isso.",
|
||||
"ru": "Создаёт прозрачный адрес (R…) для наград за соло-майнинг. Он не принимает платежи — для них сообщайте экранированный адрес.",
|
||||
"zh": "创建一个用于单人挖矿奖励的透明(R…)地址。它无法接收付款——收款请提供屏蔽地址。",
|
||||
"ja": "ソロマイニング報酬用の透明(R…)アドレスを作成します。支払いは受け取れません。支払いにはシールドアドレスを共有してください。",
|
||||
"ko": "솔로 채굴 보상용 투명(R…) 주소를 만듭니다. 결제는 받을 수 없으니, 결제용으로는 차폐 주소를 공유하세요.",
|
||||
},
|
||||
"mining_address": {
|
||||
"de": "Mining-Adresse",
|
||||
"es": "Dirección de minería",
|
||||
"fr": "Adresse de minage",
|
||||
"pt": "Endereço de mineração",
|
||||
"ru": "Адрес для майнинга",
|
||||
"zh": "挖矿地址",
|
||||
"ja": "マイニングアドレス",
|
||||
"ko": "채굴 주소",
|
||||
},
|
||||
"addr_type_mining": {
|
||||
"de": "Mining",
|
||||
"es": "Minería",
|
||||
"fr": "Minage",
|
||||
"pt": "Mineração",
|
||||
"ru": "Майнинг",
|
||||
"zh": "挖矿",
|
||||
"ja": "マイニング",
|
||||
"ko": "채굴",
|
||||
},
|
||||
"mining_rewards": {
|
||||
"de": "Mining-Belohnungen",
|
||||
"es": "Recompensas de minería",
|
||||
"fr": "Récompenses de minage",
|
||||
"pt": "Recompensas de mineração",
|
||||
"ru": "Награды за майнинг",
|
||||
"zh": "挖矿奖励",
|
||||
"ja": "マイニング報酬",
|
||||
"ko": "채굴 보상",
|
||||
},
|
||||
"mining_rewards_unshielded": {
|
||||
"de": "Noch nicht abgeschirmt",
|
||||
"es": "Aún sin blindar",
|
||||
"fr": "Pas encore blindées",
|
||||
"pt": "Ainda não blindadas",
|
||||
"ru": "Ещё не экранированы",
|
||||
"zh": "尚未屏蔽",
|
||||
"ja": "未シールド",
|
||||
"ko": "아직 차폐되지 않음",
|
||||
},
|
||||
"baltab_mining_addresses_count": {
|
||||
"de": "%d Mining-Adressen",
|
||||
"es": "%d direcciones de minería",
|
||||
"fr": "%d adresses de minage",
|
||||
"pt": "%d endereços de mineração",
|
||||
"ru": "Адресов для майнинга: %d",
|
||||
"zh": "%d 个挖矿地址",
|
||||
"ja": "マイニングアドレス %d 件",
|
||||
"ko": "채굴 주소 %d개",
|
||||
},
|
||||
"baltab_mining_amount": {
|
||||
"de": "Mining-Belohnungen %.8f",
|
||||
"es": "Recompensas de minería %.8f",
|
||||
"fr": "Récompenses de minage %.8f",
|
||||
"pt": "Recompensas de mineração %.8f",
|
||||
"ru": "Награды за майнинг %.8f",
|
||||
"zh": "挖矿奖励 %.8f",
|
||||
"ja": "マイニング報酬 %.8f",
|
||||
"ko": "채굴 보상 %.8f",
|
||||
},
|
||||
"balance_mining_fmt": {
|
||||
"de": "Mining-Belohnungen: %.8f",
|
||||
"es": "Recompensas de minería: %.8f",
|
||||
"fr": "Récompenses de minage : %.8f",
|
||||
"pt": "Recompensas de mineração: %.8f",
|
||||
"ru": "Награды за майнинг: %.8f",
|
||||
"zh": "挖矿奖励:%.8f",
|
||||
"ja": "マイニング報酬:%.8f",
|
||||
"ko": "채굴 보상: %.8f",
|
||||
},
|
||||
"mining_card_tooltip": {
|
||||
"de": "Solo-Mining-Belohnungen gehen an Ihre Mining-Adressen (transparent, R…). Diese Adressen können keine Zahlungen empfangen. Belohnungen werden automatisch abgeschirmt, sobald sie gereift sind.",
|
||||
"es": "Las recompensas de minería en solitario llegan a tus direcciones de minería (transparentes, R…). Esas direcciones no pueden recibir pagos. Las recompensas se blindan automáticamente cuando maduran.",
|
||||
"fr": "Les récompenses de minage en solo arrivent sur vos adresses de minage (transparentes, R…). Ces adresses ne peuvent pas recevoir de paiements. Les récompenses sont blindées automatiquement une fois arrivées à maturité.",
|
||||
"pt": "As recompensas de mineração solo chegam aos seus endereços de mineração (transparentes, R…). Esses endereços não podem receber pagamentos. As recompensas são blindadas automaticamente quando amadurecem.",
|
||||
"ru": "Награды за соло-майнинг поступают на ваши адреса для майнинга (прозрачные, R…). Эти адреса не принимают платежи. Награды экранируются автоматически, когда созревают.",
|
||||
"zh": "单人挖矿奖励会发送到您的挖矿(透明,R…)地址。这些地址无法接收付款。奖励成熟后会自动屏蔽。",
|
||||
"ja": "ソロマイニング報酬はマイニング(透明、R…)アドレスに届きます。これらのアドレスは支払いを受け取れません。報酬は成熟すると自動的にシールドされます。",
|
||||
"ko": "솔로 채굴 보상은 채굴(투명, R…) 주소로 들어옵니다. 이 주소는 결제를 받을 수 없습니다. 보상은 성숙하면 자동으로 차폐됩니다.",
|
||||
},
|
||||
"mining_card_click_to_shield": {
|
||||
"de": "Klicken, um gereifte Belohnungen jetzt abzuschirmen.",
|
||||
"es": "Haz clic para blindar ahora las recompensas maduras.",
|
||||
"fr": "Cliquez pour blinder dès maintenant les récompenses arrivées à maturité.",
|
||||
"pt": "Clique para blindar agora as recompensas amadurecidas.",
|
||||
"ru": "Нажмите, чтобы экранировать созревшие награды сейчас.",
|
||||
"zh": "点击立即屏蔽已成熟的奖励。",
|
||||
"ja": "クリックすると成熟した報酬を今すぐシールドします。",
|
||||
"ko": "클릭하면 성숙한 보상을 지금 차폐합니다.",
|
||||
},
|
||||
"contact_chip_mining": {
|
||||
"de": "Nur Mining",
|
||||
"es": "Solo minería",
|
||||
"fr": "Minage seul",
|
||||
"pt": "Só mineração",
|
||||
"ru": "Только майнинг",
|
||||
"zh": "仅限挖矿",
|
||||
"ja": "マイニング専用",
|
||||
"ko": "채굴 전용",
|
||||
},
|
||||
"failed_create_mining_address": {
|
||||
"de": "Mining-Adresse konnte nicht erstellt werden",
|
||||
"es": "No se pudo crear la dirección de minería",
|
||||
"fr": "Impossible de créer l'adresse de minage",
|
||||
"pt": "Falha ao criar o endereço de mineração",
|
||||
"ru": "Не удалось создать адрес для майнинга",
|
||||
"zh": "创建挖矿地址失败",
|
||||
"ja": "マイニングアドレスを作成できませんでした",
|
||||
"ko": "채굴 주소를 만들지 못했습니다",
|
||||
},
|
||||
"new_mining_address_created": {
|
||||
"de": "Neue Mining-Adresse erstellt",
|
||||
"es": "Nueva dirección de minería creada",
|
||||
"fr": "Nouvelle adresse de minage créée",
|
||||
"pt": "Novo endereço de mineração criado",
|
||||
"ru": "Новый адрес для майнинга создан",
|
||||
"zh": "已创建新的挖矿地址",
|
||||
"ja": "新しいマイニングアドレスを作成しました",
|
||||
"ko": "새 채굴 주소를 만들었습니다",
|
||||
},
|
||||
"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;
|
||||
|
||||
+1309
-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;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -19,6 +19,7 @@
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/send_tab.h"
|
||||
#include "ui/windows/receive_tab.h"
|
||||
#include "ui/windows/wallets_dialog.h"
|
||||
#include "ui/windows/export_transactions_dialog.h"
|
||||
#include "ui/windows/export_all_keys_dialog.h"
|
||||
@@ -102,7 +103,7 @@ void seedSweepContacts(App& a)
|
||||
s_contactsSweepBackup = a.addressBook().entries();
|
||||
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
|
||||
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
|
||||
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
|
||||
data::AddressBookEntry e2("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
|
||||
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
|
||||
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.
|
||||
e2.scope = a.activeWalletIdentityHash();
|
||||
@@ -170,10 +171,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 +311,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; },
|
||||
@@ -368,6 +371,13 @@ void App::buildSweepCatalog()
|
||||
add("modal-validate-address", ui::NavPage::Receive,
|
||||
[](App&) { ui::ValidateAddressDialog::show(); },
|
||||
[](App&) { ui::ValidateAddressDialog::hide(); });
|
||||
add("modal-validate-mining", ui::NavPage::Receive,
|
||||
[](App&) { ui::ValidateAddressDialog::sweepShowMiningResult("t1DemoTransparentAddressForUiSweep00000"); },
|
||||
[](App&) { ui::ValidateAddressDialog::hide(); });
|
||||
// A mining (transparent) address selected on Receive: no payment request, no QR — the explainer instead.
|
||||
add("receive-mining-address", ui::NavPage::Receive,
|
||||
[](App& a) { ui::SweepSelectReceiveAddress(&a, "t1DemoTransparentAddressForUiSweep00000"); },
|
||||
[](App& a) { ui::SweepSelectReceiveAddress(&a, nullptr); });
|
||||
add("modal-address-label", ui::NavPage::Overview,
|
||||
[](App& a) { ui::AddressLabelDialog::show(&a, kDemoZAddr, true); },
|
||||
[](App&) { ui::AddressLabelDialog::hide(); });
|
||||
@@ -391,6 +401,10 @@ void App::buildSweepCatalog()
|
||||
add("modal-merge", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showMerge(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// Consolidate (wallet-size preset): keeps each address's funds in that address by default.
|
||||
add("modal-consolidate", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showConsolidate(); },
|
||||
[](App&) { ui::ShieldDialog::hide(); });
|
||||
// z->t transfer so the (converted) deshielding DialogWarningHeader renders.
|
||||
add("modal-transfer", ui::NavPage::Overview,
|
||||
[](App& a) {
|
||||
@@ -586,6 +600,11 @@ void App::buildSweepCatalog()
|
||||
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
|
||||
[](App& a) { a.applyHealthyDemoState(); });
|
||||
|
||||
// A mining (transparent) address as the recipient: shown as unpayable, Send disabled.
|
||||
add("send-mining-recipient", ui::NavPage::Send,
|
||||
[](App& a) { ui::SweepSetSendRecipient(&a, "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"); },
|
||||
[](App& a) { ui::SweepSetSendRecipient(&a, nullptr); });
|
||||
|
||||
// Send-confirm popup (state lives in send_tab statics → driven via a debug hook).
|
||||
add("popup-send-confirm", ui::NavPage::Send,
|
||||
[](App& a) { ui::SweepShowSendConfirm(&a, true); },
|
||||
@@ -704,9 +723,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(); }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "note_consolidation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
||||
{
|
||||
std::map<std::string, NoteGroup> byAddress;
|
||||
if (zListUnspent.is_array()) {
|
||||
for (const auto& n : zListUnspent) {
|
||||
if (!n.is_object()) continue;
|
||||
const std::string address = n.value("address", std::string());
|
||||
if (address.empty()) continue;
|
||||
if (n.value("confirmations", 0) < 1) continue; // z_mergetoaddress spends confirmed notes only
|
||||
if (!n.value("spendable", true)) continue; // watch-only: no key to spend it
|
||||
NoteGroup& g = byAddress[address];
|
||||
g.address = address;
|
||||
++g.notes;
|
||||
g.amount += n.value("amount", 0.0);
|
||||
}
|
||||
}
|
||||
std::vector<NoteGroup> out;
|
||||
out.reserve(byAddress.size());
|
||||
for (auto& kv : byAddress) out.push_back(std::move(kv.second));
|
||||
std::sort(out.begin(), out.end(), [](const NoteGroup& a, const NoteGroup& b) {
|
||||
if (a.notes != b.notes) return a.notes > b.notes;
|
||||
return a.address < b.address;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
bool worthConsolidating(const NoteGroup& group, double fee)
|
||||
{
|
||||
// The daemon rejects a merge whose fee is more than what it nets (merged value < 2 x fee).
|
||||
return group.notes >= 2 && group.amount > 2.0 * fee;
|
||||
}
|
||||
|
||||
int estimateBatches(int notes, int limit)
|
||||
{
|
||||
if (notes < 2) return 0;
|
||||
if (limit < 2) limit = 2;
|
||||
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches)
|
||||
{
|
||||
AddressRun run;
|
||||
run.address = address;
|
||||
for (int b = 0; b < maxBatches; ++b) {
|
||||
MergeCallResult r = call(address);
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
break;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
if (r.remaining < 2) break; // one leftover note can't be merged into its own address
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Consolidating notes WITHOUT moving funds between addresses: each address's notes are merged back into
|
||||
// that same address (z_mergetoaddress [addr] addr), so funds kept apart under different labels stay apart
|
||||
// while the wallet sheds notes (fewer notes = smaller wallet, faster witness rebuilds and scans).
|
||||
// Shielded only: a transparent address can't receive a non-coinbase output on DragonX (private chain).
|
||||
// Pure apart from the merge call it is handed, so the test target can drive it.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
// One shielded address's spendable notes (confirmed, spendable), from z_listunspent.
|
||||
struct NoteGroup {
|
||||
std::string address;
|
||||
int notes = 0;
|
||||
double amount = 0.0;
|
||||
};
|
||||
|
||||
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed or
|
||||
// watch-only notes are left out (z_mergetoaddress only spends confirmed notes it has keys for).
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
|
||||
|
||||
// Worth consolidating in place: at least two notes, and more than one batch's fee to work with.
|
||||
bool worthConsolidating(const NoteGroup& group, double fee);
|
||||
|
||||
// Merge calls needed to bring `notes` down when each call takes at most `limit` of them. A lone leftover
|
||||
// note isn't a call (the daemon refuses to merge one note into its own address).
|
||||
int estimateBatches(int notes, int limit);
|
||||
|
||||
// What one z_mergetoaddress call reported.
|
||||
struct MergeCallResult {
|
||||
std::string opid;
|
||||
int merging = 0; // notes this call is merging
|
||||
int remaining = 0; // notes of the address it left for a later call
|
||||
std::string error; // non-empty: the call failed (nothing was submitted)
|
||||
};
|
||||
using MergeCall = std::function<MergeCallResult(const std::string& address)>;
|
||||
|
||||
// The outcome for one address.
|
||||
struct AddressRun {
|
||||
std::string address;
|
||||
std::vector<std::string> opids; // one per submitted batch
|
||||
int notesMerged = 0;
|
||||
int remaining = 0; // notes still left after the last batch
|
||||
std::string error; // the call that stopped it, if any (earlier batches still went out)
|
||||
};
|
||||
|
||||
// Submit merges of `address` into itself until fewer than two of its notes are left unmerged. The daemon
|
||||
// locks a batch's notes before the call returns, so each call takes the next batch. At most maxBatches.
|
||||
AddressRun consolidateInPlace(const std::string& address, const MergeCall& call, int maxBatches = 20);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ public:
|
||||
|
||||
// Address preview
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
|
||||
s_isZ ? TR("shielded_address") : TR("transparent_address"));
|
||||
s_isZ ? TR("shielded_address") : TR("mining_address"));
|
||||
ImGui::Spacing();
|
||||
{
|
||||
std::string display = s_address;
|
||||
@@ -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);
|
||||
@@ -213,18 +213,27 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
});
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileButton(TR("new_t_address"), ImVec2(buttonWidth, 0),
|
||||
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
|
||||
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
|
||||
app->createNewTAddress([](const std::string& addr) {
|
||||
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
|
||||
});
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// ---- 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 +265,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 +333,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);
|
||||
@@ -394,13 +403,13 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
const auto& target = rows[s_dropTargetIdx];
|
||||
material::Tooltip("%s\n%s\n\n%s %s",
|
||||
truncateAddress(addr.address, 32).c_str(),
|
||||
row.isZ ? TR("shielded") : TR("transparent"),
|
||||
row.isZ ? TR("shielded") : TR("addr_type_mining"),
|
||||
TR("transfer_to"),
|
||||
truncateAddress(target.info->address, 32).c_str());
|
||||
} else {
|
||||
material::Tooltip("%s\n%s\n\n%s",
|
||||
truncateAddress(addr.address, 32).c_str(),
|
||||
row.isZ ? TR("shielded") : TR("transparent"),
|
||||
row.isZ ? TR("shielded") : TR("addr_type_mining"),
|
||||
TR("address_reorder_hint"));
|
||||
}
|
||||
}
|
||||
@@ -432,7 +441,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (s_dragActive && s_dragIdx != row_idx && material::IsRectHovered(rowPos, rowEnd)) {
|
||||
s_dropTargetIdx = row_idx;
|
||||
const float relY = rowH > 0.0f ? (mousePos.y - rowPos.y) / rowH : 0.5f;
|
||||
s_dropMode = (relY < 0.30f || relY > 0.70f) ? 1 : 0;
|
||||
// A mining (transparent) address can't be paid, so it is never a transfer target: reorder only.
|
||||
s_dropMode = (!row.isZ || relY < 0.30f || relY > 0.70f) ? 1 : 0;
|
||||
if (s_dropMode == 0) {
|
||||
// Transfer: highlight the whole target row.
|
||||
dl->AddRectFilled(rowPos, rowEnd, WithAlpha(Primary(), 40), 4.0f * dp);
|
||||
@@ -466,7 +476,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 +503,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();
|
||||
@@ -532,19 +544,32 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
// ---- Type label (first line) ----
|
||||
float labelX = rowLayout.labelX;
|
||||
{
|
||||
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
|
||||
const char* typeLabel = row.isZ ? TR("shielded") : TR("addr_type_mining");
|
||||
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
|
||||
const char* miningTag = row.mining ? TR("mining_tag") : "";
|
||||
// A transparent row is already typed "Mining"; the tag is for shielded payout addresses.
|
||||
const char* miningTag = (row.mining && row.isZ) ? TR("mining_tag") : "";
|
||||
char typeBuf[64];
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +656,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowY[row_idx]));
|
||||
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
|
||||
if (ImGui::IsItemHovered() && !s_dragActive) {
|
||||
material::Tooltip("%s", addr.address.c_str());
|
||||
if (row.isZ) material::Tooltip("%s", addr.address.c_str());
|
||||
else material::Tooltip("%s\n\n%s", addr.address.c_str(), TR("mining_addr_explainer"));
|
||||
}
|
||||
|
||||
// Context menu
|
||||
@@ -660,7 +686,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Viewing);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("show_qr_code")))
|
||||
QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("t_address"));
|
||||
QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("mining_address"));
|
||||
ImGui::Separator();
|
||||
if (row.hidden) {
|
||||
if (ImGui::MenuItem(TR("restore_address")))
|
||||
@@ -783,6 +809,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 +817,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 +826,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 +851,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 +870,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 +903,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);
|
||||
|
||||
|
||||
+170
-99
@@ -12,6 +12,7 @@
|
||||
#include "qr_popup_dialog.h"
|
||||
#include "address_label_dialog.h"
|
||||
#include "address_transfer_dialog.h"
|
||||
#include "shield_dialog.h"
|
||||
#include "send_tab.h"
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
@@ -26,10 +27,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 +198,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 +303,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 +324,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 +363,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 +394,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 +408,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 +425,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 +488,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 +499,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 +514,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);
|
||||
@@ -501,7 +532,7 @@ static void RenderBalanceClassic(App* app)
|
||||
float cy = cMin.y + cardPadLg;
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceMedium(), TR("transparent"));
|
||||
OnSurfaceMedium(), TR("mining_rewards"));
|
||||
cy += ovFont->LegacySize + ovGap;
|
||||
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
|
||||
@@ -513,18 +544,24 @@ static void RenderBalanceClassic(App* app)
|
||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||
cy += sub1->LegacySize + valGap;
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d T-addresses",
|
||||
(int)state.t_addresses.size());
|
||||
// Rewards waiting at a mining address: say so, and let a click shield them now.
|
||||
const bool unshielded = s_dispTransparent > 0.0;
|
||||
if (unshielded) snprintf(buf, sizeof(buf), "%s", TR("mining_rewards_unshielded"));
|
||||
else snprintf(buf, sizeof(buf), TR("baltab_mining_addresses_count"), (int)state.t_addresses.size());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceDisabled(), buf);
|
||||
unshielded ? amberCol : OnSurfaceDisabled(), buf);
|
||||
|
||||
// Hover glow + click to Receive
|
||||
// Hover glow + explain; click shields waiting rewards (else opens 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);
|
||||
material::Tooltip("%s%s%s", TR("mining_card_tooltip"), unshielded ? "\n\n" : "",
|
||||
unshielded ? TR("mining_card_click_to_shield") : "");
|
||||
if (ImGui::IsMouseClicked(0)) {
|
||||
if (unshielded) ShieldDialog::showShieldCoinbase();
|
||||
else app->setCurrentPage(NavPage::Receive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,15 +591,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 +618,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 +640,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 +668,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 +702,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 +797,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_mining_amount"), s_dispTransparent);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
|
||||
legendY += capFont->LegacySize + legendSectionGap;
|
||||
|
||||
@@ -778,15 +818,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 +842,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 +953,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 +967,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 +983,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("mining_rewards");
|
||||
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 +1008,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 +1025,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 +1101,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("mining_rewards");
|
||||
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 +1129,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 +1160,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 +1182,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 +1215,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 +1245,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("mining_rewards"), 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 +1303,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 +1314,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 +1333,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 +1361,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 +1394,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 +1438,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("mining_rewards"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1453,7 +1508,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 +1526,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 +1556,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 +1586,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 +1672,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 +1700,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("mining_rewards");
|
||||
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 +1738,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 +1768,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 +1857,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("mining_rewards"), 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 +1897,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 +1934,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 +1986,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 +2061,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 +2088,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);
|
||||
}
|
||||
|
||||
@@ -2073,7 +2144,7 @@ static void RenderBalanceMinimal(App* app) {
|
||||
snprintf(buf, sizeof(buf), TR("balance_shielded_fmt"), s_dispShielded);
|
||||
Type().textColored(TypeStyle::Caption, Success(), buf);
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
snprintf(buf, sizeof(buf), TR("balance_transparent_fmt"), s_dispTransparent);
|
||||
snprintf(buf, sizeof(buf), TR("balance_mining_fmt"), s_dispTransparent);
|
||||
Type().textColored(TypeStyle::Caption, Warning(), buf);
|
||||
|
||||
// USD value
|
||||
@@ -2089,10 +2160,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 +2181,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;
|
||||
@@ -601,7 +624,7 @@ void RenderContactsTab(App* app)
|
||||
float cy = origin.y + std::max(0.0f, (a.y - blockH) * 0.42f);
|
||||
float cx = origin.x + a.x * 0.5f;
|
||||
struct Chip { bool sh; const char* lbl; };
|
||||
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_avatar_transparent") } };
|
||||
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_chip_mining") } };
|
||||
for (int i = 0; i < 2; i++) {
|
||||
float ccx = cx + (i == 0 ? -(chipR + chipGap * 0.5f) : (chipR + chipGap * 0.5f));
|
||||
ImVec2 cc(ccx, cy + chipR);
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// FAQ content model. The FAQ screen is data-driven: this header exposes the two
|
||||
// top-level groups (Wallet, Daemon) as ordered lists of subcategories, each a list
|
||||
// of question/answer entries. Every string is an i18n KEY (looked up with TR at
|
||||
// render time), so wording + translations live in src/util/i18n.cpp + res/lang/*.json
|
||||
// and never require touching UI code. Add a Q&A by appending a {qKey,aKey} pair here
|
||||
// and its two strings to loadBuiltinEnglish().
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace faq {
|
||||
|
||||
// One question and its answer, both i18n keys. The answer may contain "\n\n"
|
||||
// paragraph breaks; it is rendered wrapped. Keep answers free of printf specifiers
|
||||
// (%d/%s/…) — the i18n layer rejects translations whose format signature drifts.
|
||||
struct FaqEntry {
|
||||
const char* questionKey;
|
||||
const char* answerKey;
|
||||
};
|
||||
|
||||
// A named group of questions. titleKey is an i18n key for the subcategory header.
|
||||
struct FaqSubcategory {
|
||||
const char* titleKey;
|
||||
std::vector<FaqEntry> entries;
|
||||
};
|
||||
|
||||
// The two top-level groups. daemonFaq() is full-node material and is only shown when
|
||||
// the build supports full-node lifecycle actions (see App::supportsFullNodeLifecycleActions()).
|
||||
// walletFaq() is variant-aware: pass fullNode=false for the Lite variant, which swaps in
|
||||
// lite-appropriate answers (no local node / daemon), drops full-node-only entries
|
||||
// (e.g. migrate-to-seed), and appends a "Lite Wallet" subcategory explaining the server model.
|
||||
const std::vector<FaqSubcategory>& walletFaq(bool fullNode);
|
||||
const std::vector<FaqSubcategory>& daemonFaq();
|
||||
|
||||
} // 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