Compare commits
219
Commits
71ed92b9f1
...
e40c6c0a7c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40c6c0a7c | ||
|
|
4d95ef68f6 | ||
|
|
3f85953452 | ||
|
|
5a3d36904c | ||
|
|
ef14518089 | ||
|
|
39292b1b1f | ||
|
|
6d4ed96f6d | ||
|
|
b49f85bcbe | ||
|
|
0bdea68337 | ||
|
|
c045e56d4e | ||
|
|
be69c9bafe | ||
|
|
d9dae45c39 | ||
|
|
e2210cf5d3 | ||
|
|
25ad23aee4 | ||
|
|
6a6ac8cf99 | ||
|
|
998788d351 | ||
|
|
16371c5879 | ||
|
|
d9143f0e18 | ||
|
|
7e7804e577 | ||
|
|
8df0fe1734 | ||
|
|
30f5243dd1 | ||
|
|
b407b8927a | ||
|
|
60b3ad5d5a | ||
|
|
614dfbbc5b | ||
|
|
a6b7b966b0 | ||
|
|
e9042f091e | ||
|
|
e77b960cc5 | ||
|
|
3fd52fbdaf | ||
|
|
1416654385 | ||
|
|
3554ba1e2d | ||
|
|
aa486266c6 | ||
|
|
8c5bd485dd | ||
|
|
8168e26d93 | ||
|
|
e685d425ed | ||
|
|
2396ed33db | ||
|
|
f47528de53 | ||
|
|
bf5cb39566 | ||
|
|
667c89ba51 | ||
|
|
4d9d528566 | ||
|
|
b3a1a9e654 | ||
|
|
e1003cba43 | ||
|
|
f1b0ae5ce7 | ||
|
|
edbb98d9ec | ||
|
|
2a535c844d | ||
|
|
63a422a615 | ||
|
|
100d76b88f | ||
|
|
fa1ba97286 | ||
|
|
53da160546 | ||
|
|
49240facff | ||
|
|
13059412ce | ||
|
|
45d8bdccf0 | ||
|
|
a24ffea86e | ||
|
|
5d9bd1688c | ||
|
|
f3f85d602d | ||
|
|
2c9e99c022 | ||
|
|
1cd4ea4026 | ||
|
|
d601f786e2 | ||
|
|
29c1674094 | ||
|
|
95605dd7e9 | ||
|
|
96352ce9ad | ||
|
|
bbe2bf94dc | ||
|
|
f76d71fede | ||
|
|
2a3e88c8dc | ||
|
|
b4ff9a10a2 | ||
|
|
76daf9619d | ||
|
|
3f82af99c4 | ||
|
|
ce00621a4a | ||
|
|
a631e4421a | ||
|
|
32ea4d0524 | ||
|
|
fae5ef05b9 | ||
|
|
13989cf268 | ||
|
|
c5b325a752 | ||
|
|
969718e03a | ||
|
|
7a9b29f358 | ||
|
|
968d6d3875 | ||
|
|
fdd95aa457 | ||
|
|
75e0c5048e | ||
|
|
cd83038de5 | ||
|
|
fbc429f914 | ||
|
|
944fafa1c7 | ||
|
|
ebbeb846e8 | ||
|
|
c3f981b52e | ||
|
|
a5e4fd5c02 | ||
|
|
01b59d24db | ||
|
|
3ca7a5c90e | ||
|
|
b8a4a11f90 | ||
|
|
c1b289cc27 | ||
|
|
f7ed1ae115 | ||
|
|
116a997a3a | ||
|
|
70e96f75f0 | ||
|
|
6a24355c79 | ||
|
|
5376681af5 | ||
|
|
8255923f33 | ||
|
|
c3ffc97b31 | ||
|
|
d4339be94b | ||
|
|
579accd0c6 | ||
|
|
abc82136c4 | ||
|
|
21dc874988 | ||
|
|
fbe09c6a4c | ||
|
|
2fce9a7860 | ||
|
|
0aaf33ecf0 | ||
|
|
1ea584311e | ||
|
|
3f7af2d028 | ||
|
|
5ae5e5213b | ||
|
|
e7f084e3eb | ||
|
|
a5185737e6 | ||
|
|
a581f42a33 | ||
|
|
ae66b54694 | ||
|
|
81733f5603 | ||
|
|
b3a57b07ad | ||
|
|
78e38570a8 | ||
|
|
70d8b0f687 | ||
|
|
f6e5038043 | ||
|
|
62a5577491 | ||
|
|
3ac46abe1a | ||
|
|
eac0993512 | ||
|
|
e52c9e1b92 | ||
|
|
b6cffdf942 | ||
|
|
31304a1a63 | ||
|
|
39333f80b7 | ||
|
|
a5db2e9a3d | ||
|
|
55431cd606 | ||
|
|
20b63bf45d | ||
|
|
026d0e92a6 | ||
|
|
0a72b47ca6 | ||
|
|
1848025a95 | ||
|
|
1bf803f107 | ||
|
|
261883ca21 | ||
|
|
f970cae193 | ||
|
|
73f7f33a4b | ||
|
|
b50d5ac31f | ||
|
|
e0f9d36780 | ||
|
|
1f162ec830 | ||
|
|
04ad1462d5 | ||
|
|
abb63d9441 | ||
|
|
70eacd90ad | ||
|
|
8b094595b1 | ||
|
|
e65be1d24b | ||
|
|
25ceb2cfe1 | ||
|
|
22e3f104d6 | ||
|
|
7c0afd06a8 | ||
|
|
24a90ae532 | ||
|
|
db8d5359a4 | ||
|
|
c233d4d503 | ||
|
|
ba08cab95a | ||
|
|
6f1ae874fb | ||
|
|
965f36cecb | ||
|
|
e7ebc568b0 | ||
|
|
7f99fbb9c6 | ||
|
|
6de9b5cc90 | ||
|
|
bd4c06dfd0 | ||
|
|
d28cc0da31 | ||
|
|
8895038c40 | ||
|
|
3f13d79ccb | ||
|
|
8c0a22b7bc | ||
|
|
e025a314d1 | ||
|
|
1ced49d622 | ||
|
|
ee0a486676 | ||
|
|
c2c2a8325b | ||
|
|
bb973a1007 | ||
|
|
88fa13dd1a | ||
|
|
888a41ddc2 | ||
|
|
3069151e7f | ||
|
|
4a95d9ecb7 | ||
|
|
a69380817e | ||
|
|
e45f4c7d90 | ||
|
|
d3fa09128e | ||
|
|
860916d203 | ||
|
|
4a2a1bdd54 | ||
|
|
3af7b0ef44 | ||
|
|
c6fc1ac168 | ||
|
|
971f2abae5 | ||
|
|
47180ff840 | ||
|
|
a700d2c7ee | ||
|
|
1658950386 | ||
|
|
bb84243114 | ||
|
|
7c24400c83 | ||
|
|
55975901a2 | ||
|
|
b2a57a61a0 | ||
|
|
296c04386c | ||
|
|
2618744e59 | ||
|
|
ad0f60a747 | ||
|
|
93b6871511 | ||
|
|
c0568eb930 | ||
|
|
67260cc6f0 | ||
|
|
6c02e67c6d | ||
|
|
c9cb50f4dd | ||
|
|
7649b1f8a3 | ||
|
|
72054fe17f | ||
|
|
8f8dc4c922 | ||
|
|
0e2f853025 | ||
|
|
d5202f76ca | ||
|
|
ed067f4a91 | ||
|
|
3f1194a84b | ||
|
|
cee8b9d7f6 | ||
|
|
b29a09eaa0 | ||
|
|
f86cc4d6b7 | ||
|
|
323143e111 | ||
|
|
879de37d18 | ||
|
|
8b9a498eee | ||
|
|
4eed4283d6 | ||
|
|
7fa7c3d6da | ||
|
|
2cf183ddd9 | ||
|
|
7ceff1eaaa | ||
|
|
16e0ee9947 | ||
|
|
2d29a04981 | ||
|
|
43fc0090d4 | ||
|
|
1cb03332e7 | ||
|
|
575abd4bcc | ||
|
|
fffab228d1 | ||
|
|
402a3053dc | ||
|
|
ffce7cad24 | ||
|
|
3961f84d16 | ||
|
|
424954ff4f | ||
|
|
0e5533f6b9 | ||
|
|
080f3d9668 | ||
|
|
c6bd4eb733 | ||
|
|
13788b5dd0 | ||
|
|
5554016029 |
+100
-19
@@ -46,6 +46,18 @@ option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node fe
|
||||
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)
|
||||
# The screenshot / full UI sweep injects demo data and touches the wallet's data and config dirs, so it is a
|
||||
# dev tool only. build.sh passes it EXPLICITLY (ON for dev, OFF for every release configure) because build/linux
|
||||
# and build/mac are shared between dev and release builds; dragonx_release refuses to package while it is ON.
|
||||
option(DRAGONX_ENABLE_UI_SWEEP "Compile the UI sweep dev tool (never for shipped builds)" OFF)
|
||||
# Embedded fallback copy of res/lang/*.json (xxd -i -> lang_*.h). Needed only by the Windows single-file exe (no res/ beside it);
|
||||
# Linux zip/AppImage and the macOS .app always ship res/lang, which loadLanguage() reads first.
|
||||
if(WIN32)
|
||||
set(_DRAGONX_EMBED_LANG_DEFAULT ON)
|
||||
else()
|
||||
set(_DRAGONX_EMBED_LANG_DEFAULT OFF)
|
||||
endif()
|
||||
option(DRAGONX_EMBED_LANG "Embed res/lang/*.json in the binary as a fallback" ${_DRAGONX_EMBED_LANG_DEFAULT})
|
||||
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")
|
||||
@@ -398,6 +410,12 @@ set(IMGUI_SOURCES
|
||||
${IMGUI_DIR}/backends/imgui_impl_sdl3.cpp
|
||||
)
|
||||
|
||||
# The ImGui demo window is unreachable in the wallet, so compile imgui_demo.cpp to its empty stubs
|
||||
# (ShowDemoWindow/ShowStyleEditor/... stay defined; imgui.h declares them unconditionally, so this is ODR-safe).
|
||||
# Do NOT use IMGUI_DISABLE_DEBUG_TOOLS: it changes imgui.h declarations and removes duplicate-ID highlighting.
|
||||
# (Makefiles: a per-source define lands in the target's flags.make, so existing trees do one full app rebuild.)
|
||||
set_source_files_properties(${IMGUI_DIR}/imgui_demo.cpp PROPERTIES COMPILE_DEFINITIONS IMGUI_DISABLE_DEMO_WINDOWS)
|
||||
|
||||
set(IMGUI_HEADERS
|
||||
${IMGUI_DIR}/imgui.h
|
||||
${IMGUI_DIR}/imconfig.h
|
||||
@@ -466,9 +484,11 @@ set(APP_SOURCES
|
||||
src/app_security.cpp
|
||||
src/app_sweep.cpp
|
||||
src/app_wizard.cpp
|
||||
src/services/history_scan_policy.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_scan_governor.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
@@ -493,7 +513,9 @@ set(APP_SOURCES
|
||||
src/wallet/lite_wallet_server_selection_adapter.cpp
|
||||
src/wallet/lite_wallet_lifecycle_service.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_csv.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/data/transaction_history_store_policy.cpp
|
||||
src/ui/theme.cpp
|
||||
src/ui/theme_loader.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
@@ -557,6 +579,7 @@ set(APP_SOURCES
|
||||
src/data/exchange_info.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/coalescing_writer.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
@@ -567,6 +590,7 @@ set(APP_SOURCES
|
||||
src/util/platform.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/texture_loader.cpp
|
||||
src/util/image_decode_core.cpp
|
||||
src/util/svg_texture.cpp
|
||||
src/util/noise_texture.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
@@ -613,8 +637,15 @@ endif()
|
||||
|
||||
set(APP_HEADERS
|
||||
src/app.h
|
||||
src/services/history_scan_policy.h
|
||||
src/services/network_refresh_service.h
|
||||
src/services/refresh_scheduler.h
|
||||
src/services/wallet_scan_governor.h
|
||||
src/services/connect_scan_gate.h
|
||||
src/services/scan_policy.h
|
||||
src/services/chat_scan_policy.h
|
||||
src/services/mining_thread_commit.h
|
||||
src/services/lowest_block_search.h
|
||||
src/services/wallet_security_controller.h
|
||||
src/services/wallet_security_workflow.h
|
||||
src/services/wallet_security_workflow_executor.h
|
||||
@@ -642,6 +673,7 @@ set(APP_HEADERS
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
src/data/transaction_history_store_policy.h
|
||||
src/ui/theme.h
|
||||
src/ui/theme_loader.h
|
||||
src/ui/explorer/explorer_block_cache.h
|
||||
@@ -650,6 +682,8 @@ set(APP_HEADERS
|
||||
src/ui/windows/balance_tab.h
|
||||
src/ui/windows/balance_address_list.h
|
||||
src/ui/windows/balance_recent_tx.h
|
||||
src/ui/windows/balance_lerp.h
|
||||
src/ui/windows/chat_thread_layout.h
|
||||
src/ui/windows/balance_tab_helpers.h
|
||||
src/ui/windows/send_tab.h
|
||||
src/ui/windows/receive_tab.h
|
||||
@@ -690,12 +724,19 @@ set(APP_HEADERS
|
||||
src/rpc/rpc_client.h
|
||||
src/rpc/rpc_worker.h
|
||||
src/rpc/connection.h
|
||||
src/rpc/long_call_lane.h
|
||||
src/rpc/types.h
|
||||
src/config/settings.h
|
||||
src/data/address_book.h
|
||||
src/data/exchange_info.h
|
||||
src/util/logger.h
|
||||
src/util/async_task_manager.h
|
||||
src/util/serial_job_lane.h
|
||||
src/util/fetch_backoff.h
|
||||
src/util/coalescing_writer.h
|
||||
src/util/local_time.h
|
||||
src/util/background_sampler.h
|
||||
src/util/text_fit.h
|
||||
src/util/amount_format.h
|
||||
src/util/base64.h
|
||||
src/util/single_instance.h
|
||||
@@ -779,9 +820,21 @@ ${CMAKE_SOURCE_DIR}/res/fonts/NotoEmoji-Subset.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/TwemojiMozilla-Color.ttf"
|
||||
)
|
||||
|
||||
# Embed ui.toml + default_banlist via INCBIN (same pattern as fonts) - zero configure cost, no
|
||||
# generated header, so editing them re-assembles one tiny TU instead of recompiling every includer.
|
||||
configure_file(
|
||||
${CMAKE_SOURCE_DIR}/src/embedded/embedded_text.cpp.in
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
@ONLY)
|
||||
set_source_files_properties(
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
PROPERTIES OBJECT_DEPENDS
|
||||
"${CMAKE_SOURCE_DIR}/res/themes/ui.toml;${CMAKE_SOURCE_DIR}/res/default_banlist.txt")
|
||||
|
||||
add_executable(ObsidianDragon
|
||||
${APP_SOURCES}
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_fonts.cpp
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
${IMGUI_SOURCES}
|
||||
${QRCODE_SOURCES}
|
||||
${GLAD_SOURCES}
|
||||
@@ -902,6 +955,8 @@ target_compile_definitions(ObsidianDragon PRIVATE
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
|
||||
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
|
||||
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
|
||||
)
|
||||
if(WIN32)
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_USE_DX11)
|
||||
@@ -918,7 +973,9 @@ if(DRAGONX_FREETYPE)
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAVE_FREETYPE)
|
||||
endif()
|
||||
|
||||
add_executable(HushChatFixtureCheck
|
||||
# Dev-only HushChat fixture CLI (nothing runs it; not linked into the app or tests). Not part of `all`:
|
||||
# cmake --build build/linux --target HushChatFixtureCheck (output: build/linux/bin/HushChatFixtureCheck)
|
||||
add_executable(HushChatFixtureCheck EXCLUDE_FROM_ALL
|
||||
tools/hushchat_fixture_check.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/chat/chat_fixture_tooling.cpp
|
||||
@@ -961,9 +1018,11 @@ endif()
|
||||
# so edits to res/lang/*.json are picked up by 'make' without re-running cmake.
|
||||
file(GLOB LANG_FILES ${CMAKE_SOURCE_DIR}/res/lang/*.json)
|
||||
if(LANG_FILES)
|
||||
find_program(XXD_EXECUTABLE NAMES xxd)
|
||||
if(NOT XXD_EXECUTABLE)
|
||||
message(WARNING "xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated")
|
||||
if(DRAGONX_EMBED_LANG)
|
||||
find_program(XXD_EXECUTABLE NAMES xxd)
|
||||
endif()
|
||||
if(DRAGONX_EMBED_LANG AND NOT XXD_EXECUTABLE)
|
||||
message(FATAL_ERROR "DRAGONX_EMBED_LANG is ON but xxd was not found; it generates build/generated/embedded/lang_*.h. Install xxd or configure with -DDRAGONX_EMBED_LANG=OFF")
|
||||
endif()
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded)
|
||||
|
||||
@@ -980,7 +1039,7 @@ if(LANG_FILES)
|
||||
list(APPEND LANG_OUTPUTS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/lang/${LANG_FILENAME})
|
||||
|
||||
# Also regenerate the embedded header so the binary always has fresh translations
|
||||
if(XXD_EXECUTABLE)
|
||||
if(DRAGONX_EMBED_LANG AND XXD_EXECUTABLE)
|
||||
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
|
||||
set(LANG_HEADER ${CMAKE_BINARY_DIR}/generated/embedded/lang_${LANG_CODE}.h)
|
||||
add_custom_command(
|
||||
@@ -998,20 +1057,6 @@ if(LANG_FILES)
|
||||
message(STATUS " Language files: ${LANG_FILES}")
|
||||
endif()
|
||||
|
||||
# Embed ui.toml into the binary so it's always available at runtime
|
||||
include(${CMAKE_SOURCE_DIR}/cmake/EmbedResources.cmake)
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated)
|
||||
embed_resource(
|
||||
${CMAKE_SOURCE_DIR}/res/themes/ui.toml
|
||||
${CMAKE_BINARY_DIR}/generated/ui_toml_embedded.h
|
||||
ui_toml
|
||||
)
|
||||
embed_resource(
|
||||
${CMAKE_SOURCE_DIR}/res/default_banlist.txt
|
||||
${CMAKE_BINARY_DIR}/generated/default_banlist_embedded.h
|
||||
default_banlist
|
||||
)
|
||||
|
||||
# Note: xmrig is embedded via build.sh (embedded_data.h) for Windows builds,
|
||||
# following the same pattern as daemon embedding.
|
||||
|
||||
@@ -1118,6 +1163,26 @@ else()
|
||||
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||
endif()
|
||||
|
||||
# Release packaging (build.sh --linux-release / --win-release / --mac-release) builds ONLY what ships:
|
||||
# cmake --build . --target dragonx_release
|
||||
# The test binary, the lite smoke tools and the dev CLIs are skipped WITHOUT writing BUILD_TESTING=OFF into the
|
||||
# build/<platform> cache that dev builds share. A stale OFF would leave ctest running an old test binary.
|
||||
# If a release path starts copying a new CMake-built file out of bin/, add its target here.
|
||||
add_custom_target(dragonx_release)
|
||||
add_dependencies(dragonx_release ObsidianDragon)
|
||||
foreach(_dragonx_release_dep dragonx-wallet-rebuild copy_langs copy_themes)
|
||||
if(TARGET ${_dragonx_release_dep})
|
||||
add_dependencies(dragonx_release ${_dragonx_release_dep})
|
||||
endif()
|
||||
endforeach()
|
||||
# Backstop for the UI sweep dev tool: a tree whose cache says ON (e.g. hand-configured) cannot package a release.
|
||||
if(DRAGONX_ENABLE_UI_SWEEP)
|
||||
add_custom_target(dragonx_release_sweep_guard
|
||||
COMMAND ${CMAKE_COMMAND} -E echo "DRAGONX_ENABLE_UI_SWEEP=ON: refusing to package a release"
|
||||
COMMAND ${CMAKE_COMMAND} -E false)
|
||||
add_dependencies(dragonx_release dragonx_release_sweep_guard)
|
||||
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).
|
||||
@@ -1146,9 +1211,11 @@ endif()
|
||||
if(BUILD_TESTING)
|
||||
add_executable(ObsidianDragonTests
|
||||
tests/test_phase4.cpp
|
||||
src/services/history_scan_policy.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/note_consolidation.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_scan_governor.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
@@ -1191,17 +1258,24 @@ if(BUILD_TESTING)
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/util/image_decode_core.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_csv.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/data/transaction_history_store_policy.cpp
|
||||
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/rpc/rpc_client.cpp
|
||||
src/util/base64.cpp
|
||||
src/config/settings.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
src/util/secure_vault.cpp
|
||||
src/util/platform.cpp
|
||||
${CMAKE_BINARY_DIR}/generated/embedded_text.cpp
|
||||
src/util/coalescing_writer.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/pool_registry_core.cpp
|
||||
@@ -1210,6 +1284,8 @@ if(BUILD_TESTING)
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
src/util/daemon_updater_core.cpp
|
||||
src/util/bootstrap.cpp
|
||||
src/util/single_instance.cpp
|
||||
${MINIZ_SOURCES}
|
||||
)
|
||||
|
||||
@@ -1222,6 +1298,7 @@ if(BUILD_TESTING)
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
${CURL_INCLUDE_DIRS}
|
||||
${MINIZ_DIR}
|
||||
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> for image_decode_core.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE
|
||||
@@ -1229,6 +1306,8 @@ if(BUILD_TESTING)
|
||||
sqlite3_amalgamation
|
||||
${SODIUM_LIBRARY}
|
||||
${CURL_LIBRARIES}
|
||||
webp
|
||||
webpdemux
|
||||
)
|
||||
|
||||
target_compile_definitions(ObsidianDragonTests PRIVATE
|
||||
@@ -1236,6 +1315,8 @@ if(BUILD_TESTING)
|
||||
DRAGONX_LITE_BUILD=$<BOOL:${DRAGONX_BUILD_LITE}>
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_EMBED_LANG=$<BOOL:${DRAGONX_EMBED_LANG}>
|
||||
DRAGONX_ENABLE_UI_SWEEP=$<BOOL:${DRAGONX_ENABLE_UI_SWEEP}>
|
||||
DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures"
|
||||
)
|
||||
|
||||
|
||||
@@ -349,6 +349,7 @@ build_dev() {
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=ON \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
@@ -363,8 +364,8 @@ build_dev() {
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
build_release_linux() {
|
||||
header "Release: Linux x86_64"
|
||||
local bd="$SCRIPT_DIR/build/linux"
|
||||
local out="$SCRIPT_DIR/release/linux"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/linux"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/linux"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -385,15 +386,17 @@ build_release_linux() {
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring ..."
|
||||
# UI sweep OFF explicitly: build/linux is shared with dev builds, which cache it ON.
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||
|
||||
@@ -577,8 +580,8 @@ APPRUN
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
build_release_win() {
|
||||
header "Release: Windows x86_64 (cross-compile)"
|
||||
local bd="$SCRIPT_DIR/build/windows"
|
||||
local out="$SCRIPT_DIR/release/windows"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/windows"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/windows"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -646,13 +649,16 @@ TOOLCHAIN
|
||||
mkdir -p "$RES"
|
||||
|
||||
SAPLING_SPEND="" SAPLING_OUTPUT="" ASMAP_DAT=""
|
||||
find_sapling_params || true
|
||||
find_asmap || true
|
||||
|
||||
if [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
|
||||
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
|
||||
local EMBED_NODE_DATA=false
|
||||
should_bundle_full_node_assets && EMBED_NODE_DATA=true
|
||||
if $EMBED_NODE_DATA; then
|
||||
find_sapling_params || true
|
||||
find_asmap || true
|
||||
fi
|
||||
|
||||
# Lite needs no Sapling params/asmap (its backend embeds its own), so it always gets a header for
|
||||
# images/themes/xmrig; the full node still requires the params to be found.
|
||||
if ! $EMBED_NODE_DATA || [[ -n "$SAPLING_SPEND" && -n "$SAPLING_OUTPUT" ]]; then
|
||||
cat > "$GEN/embedded_data.h" <<HDR
|
||||
// Auto-generated — DO NOT EDIT (build.sh --win-release)
|
||||
#pragma once
|
||||
@@ -660,16 +666,27 @@ TOOLCHAIN
|
||||
#include <cstddef>
|
||||
#include "incbin.h"
|
||||
|
||||
HDR
|
||||
if $EMBED_NODE_DATA; then
|
||||
cp -f "$SAPLING_SPEND" "$RES/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "$RES/sapling-output.params"
|
||||
cat >> "$GEN/embedded_data.h" <<HDR
|
||||
INCBIN(sapling_spend_params, "$RES/sapling-spend.params");
|
||||
INCBIN(sapling_output_params, "$RES/sapling-output.params");
|
||||
HDR
|
||||
|
||||
if [[ -n "$ASMAP_DAT" ]]; then
|
||||
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
|
||||
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
|
||||
if [[ -n "$ASMAP_DAT" ]]; then
|
||||
cp -f "$ASMAP_DAT" "$RES/asmap.dat"
|
||||
echo "INCBIN(asmap_dat, \"$RES/asmap.dat\");" >> "$GEN/embedded_data.h"
|
||||
else
|
||||
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
|
||||
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
|
||||
fi
|
||||
else
|
||||
echo 'extern "C" { static const uint8_t* g_asmap_dat_data = nullptr; }' >> "$GEN/embedded_data.h"
|
||||
echo 'static const unsigned int g_asmap_dat_size = 0;' >> "$GEN/embedded_data.h"
|
||||
info "Lite mode: not embedding Sapling params/asmap (the SDXL backend embeds its own)"
|
||||
for sym in sapling_spend_params sapling_output_params asmap_dat; do
|
||||
echo "extern \"C\" { static const uint8_t* g_${sym}_data = nullptr; }" >> "$GEN/embedded_data.h"
|
||||
echo "static const unsigned int g_${sym}_size = 0;" >> "$GEN/embedded_data.h"
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Daemon binaries ──────────────────────────────────────────────
|
||||
@@ -809,6 +826,7 @@ HDR
|
||||
info "Embedded resources header generated"
|
||||
else
|
||||
warn "Building WITHOUT embedded resources (Sapling params not found)"
|
||||
rm -f "$GEN/embedded_data.h" # never compile a stale header left by the other variant in this shared build dir
|
||||
fi
|
||||
|
||||
# ── Fetch libsodium for Windows if needed ──────────────────────────────
|
||||
@@ -849,12 +867,13 @@ HDR
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${FT_CMAKE_ARG[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -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
|
||||
@@ -896,9 +915,14 @@ HDR
|
||||
info "Lite mode: skipping daemon and Sapling/asmap assets in Windows zip"
|
||||
fi
|
||||
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
|
||||
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
||||
# The exe embeds xmrig (HAS_EMBEDDED_XMRIG) and getXmrigPath() always prefers / re-extracts the copy in
|
||||
# %APPDATA%\<app>\dragonx, so a loose exe-dir xmrig.exe is never used. Ship it loose only when not embedded.
|
||||
if grep -q '^#define HAS_EMBEDDED_XMRIG 1' "$GEN/embedded_data.h" 2>/dev/null; then
|
||||
info "xmrig.exe is embedded in the exe — not copied loose into the zip"
|
||||
else
|
||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
|
||||
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
||||
fi
|
||||
|
||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||
|
||||
@@ -1031,8 +1055,8 @@ build_release_mac() {
|
||||
fi
|
||||
|
||||
header "Release: macOS ($MAC_ARCH$(${IS_CROSS} && echo ' — cross-compile'))"
|
||||
local bd="$SCRIPT_DIR/build/mac"
|
||||
local out="$SCRIPT_DIR/release/mac"
|
||||
local bd="${DRAGONX_BUILD_ROOT:-$SCRIPT_DIR/build}/mac"
|
||||
local out="${DRAGONX_RELEASE_ROOT:-$SCRIPT_DIR/release}/mac"
|
||||
|
||||
if $CLEAN; then
|
||||
info "Cleaning $bd ..."; rm -rf "$bd"
|
||||
@@ -1109,6 +1133,7 @@ TOOLCHAIN
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
||||
"${BDB_ARGS[@]}" \
|
||||
@@ -1143,6 +1168,7 @@ TOOLCHAIN
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_ENABLE_UI_SWEEP=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
|
||||
"${BDB_ARGS[@]}" \
|
||||
@@ -1150,7 +1176,7 @@ TOOLCHAIN
|
||||
fi
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
cmake --build . -j "$JOBS" --target dragonx_release
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# CMake script to embed binary files as C arrays
|
||||
|
||||
function(embed_resource RESOURCE_FILE OUTPUT_FILE VAR_NAME)
|
||||
file(READ "${RESOURCE_FILE}" HEX_CONTENT HEX)
|
||||
|
||||
# Convert hex to C array format
|
||||
string(LENGTH "${HEX_CONTENT}" HEX_LENGTH)
|
||||
math(EXPR BYTE_COUNT "${HEX_LENGTH} / 2")
|
||||
|
||||
set(ARRAY_CONTENT "")
|
||||
set(INDEX 0)
|
||||
while(INDEX LESS HEX_LENGTH)
|
||||
string(SUBSTRING "${HEX_CONTENT}" ${INDEX} 2 BYTE)
|
||||
string(APPEND ARRAY_CONTENT "0x${BYTE},")
|
||||
math(EXPR INDEX "${INDEX} + 2")
|
||||
math(EXPR BYTE_NUM "${INDEX} / 2")
|
||||
math(EXPR MOD "${BYTE_NUM} % 16")
|
||||
if(MOD EQUAL 0)
|
||||
string(APPEND ARRAY_CONTENT "\n ")
|
||||
endif()
|
||||
endwhile()
|
||||
|
||||
# Generate header file
|
||||
set(HEADER_CONTENT "// Auto-generated embedded resource - DO NOT EDIT
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace embedded {
|
||||
|
||||
inline constexpr unsigned char ${VAR_NAME}_data[] = {
|
||||
${ARRAY_CONTENT}
|
||||
};
|
||||
|
||||
inline constexpr size_t ${VAR_NAME}_size = ${BYTE_COUNT};
|
||||
|
||||
} // namespace embedded
|
||||
")
|
||||
|
||||
file(WRITE "${OUTPUT_FILE}" "${HEADER_CONTENT}")
|
||||
message(STATUS "Embedded ${RESOURCE_FILE} -> ${OUTPUT_FILE} (${BYTE_COUNT} bytes)")
|
||||
endfunction()
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.",
|
||||
"appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Ihr Wallet konnte nicht mit den Bootstrap-Chain-Daten abgeglichen werden: Der Node war zu ausgelastet, um seine Blöcke zu prüfen. Starten Sie Neu scannen in den Einstellungen, sobald er aufgeholt hat.",
|
||||
"appx_cancel": "Abbrechen",
|
||||
"appx_cleaning_up": "Aufräumen...",
|
||||
"appx_confirm_your_backup": "Backup bestätigen",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "dragonxd-Ausgabe",
|
||||
"appx_encrypting_wallet": "Wallet wird verschlüsselt...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar",
|
||||
"appx_import_cancelled": "Abgebrochen: Der Node scannt möglicherweise noch. Wenn das Guthaben des Schlüssels nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
|
||||
"appx_import_interrupted": "Der Import wurde unterbrochen, daher ist der Schlüssel möglicherweise schon in der Wallet. Wenn sein Guthaben nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
|
||||
"appx_import_refused": "Der Node hat die Anfrage abgelehnt, daher wurde der Schlüssel nicht importiert.",
|
||||
"appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...",
|
||||
"appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ",
|
||||
"appx_ive_written_it_down": "Ich habe sie notiert",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...",
|
||||
"appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.",
|
||||
"appx_wallet_op_node_busy": "Der Node ist beschäftigt und antwortet nicht. Versuchen Sie den Import oder Neuscan erneut, wenn er fertig ist.",
|
||||
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
|
||||
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
|
||||
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
|
||||
"auto_shield": "Mining automatisch abschirmen",
|
||||
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "Neue bestätigen:",
|
||||
"change_pass_current": "Aktuelle Passphrase:",
|
||||
"change_pass_new": "Neue Passphrase:",
|
||||
"change_pass_pin_note": "Diese Wallet hat eine Schnell-Entsperr-PIN. Nach der Änderung ist sie veraltet: Entfernen Sie die PIN in den Einstellungen und legen Sie sie neu fest.",
|
||||
"change_pass_title": "Passphrase ändern",
|
||||
"characters": "Zeichen",
|
||||
"chat": "Chat",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
|
||||
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
|
||||
"lite_birthday_label": "Geburtsblock",
|
||||
"lite_confirm_label": "Bestätigen",
|
||||
"lite_console_backend_commands": "Backend-Befehle:",
|
||||
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
|
||||
"lite_copy": "Kopieren",
|
||||
"lite_could_not_start": "Der Vorgang konnte nicht gestartet werden",
|
||||
"lite_could_not_write": "Konnte nicht schreiben ",
|
||||
"lite_encrypt_offline_warning": "Wer die Wallet-Datei hat, kann Passphrasen offline durchprobieren. Sichern Sie zuerst Ihre Wiederherstellungsphrase und verwenden Sie eine lange Passphrase.",
|
||||
"lite_encrypt_wallet": "Wallet verschlüsseln",
|
||||
"lite_encryption_removed": "Verschlüsselung entfernt",
|
||||
"lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "Ausblenden & löschen",
|
||||
"lite_import": "Importieren",
|
||||
"lite_import_key_label": "Schlüssel importieren",
|
||||
"lite_key_import_failed": "Schlüssel konnte nicht importiert werden — prüfen Sie ihn, entsperren Sie die Wallet (falls verschlüsselt) und versuchen Sie es erneut.",
|
||||
"lite_key_imported": "Schlüssel importiert — führen Sie eine Synchronisierung durch, um den Verlauf zu scannen",
|
||||
"lite_lock_failed": "Sperren fehlgeschlagen",
|
||||
"lite_lock_now": "Jetzt sperren",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s – fehlgeschlagen: %s",
|
||||
"merge_keep_each_status_not_started": "%s – nicht gestartet: %s",
|
||||
"merge_keep_each_status_running": "%s – %d Notes werden zusammengeführt (%d von %d Durchgängen fertig)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s – Status konnte nicht bestätigt werden",
|
||||
"merge_keep_each_too_small": "Zu klein zum Konsolidieren: Die Gebühr würde diese Mittel aufbrauchen.",
|
||||
"merge_max_inputs": "Max. Inputs pro Batch",
|
||||
"merge_no_spendable": "Noch keine verfügbaren Inputs zum Konsolidieren.",
|
||||
"merge_progress": "Konsolidierung läuft… dies kann einige Minuten dauern. Sie können dieses Fenster schließen.",
|
||||
"merge_run_aborted_toast": "Die Verbindung zum Node wurde zurückgesetzt, daher wurde die Konsolidierung gestoppt. Führen Sie sie erneut aus, um fortzufahren.",
|
||||
"merge_run_busy": "Eine Konsolidierung läuft. Warten Sie, bis sie fertig ist, oder stoppen Sie sie zuerst.",
|
||||
"merge_run_chip_fmt": "Konsolidierung %d/%d",
|
||||
"merge_run_err_timeout": "Der Node hat nicht rechtzeitig geantwortet; der Durchgang ist möglicherweise trotzdem eingereiht.",
|
||||
"merge_run_halted_fmt": "Konsolidierung nach wiederholten Fehlern gestoppt: %s",
|
||||
"merge_run_migration_pending": "Schließen Sie die Seed-Migration ab oder brechen Sie sie ab, bevor Sie konsolidieren.",
|
||||
"merge_run_progress_fmt": "Konsolidierung läuft… Durchgang %d von etwa %d, bisher %d Notes zusammengeführt. Sie können dieses Fenster schließen; der Vorgang läuft im Hintergrund weiter.",
|
||||
"merge_run_start_failed": "Die Konsolidierung konnte nicht gestartet werden – der Node ist nicht verbunden.",
|
||||
"merge_run_status_not_run": "%s – nicht konsolidiert (%d Notes)",
|
||||
"merge_run_status_waiting": "%s – wartet (%d Notes)",
|
||||
"merge_run_stop": "Stoppen",
|
||||
"merge_run_stopped_toast": "Konsolidierung gestoppt. Führen Sie sie erneut aus, um den Rest zusammenzuführen.",
|
||||
"merge_run_stopping": "Wird nach den bereits gesendeten Durchgängen gestoppt…",
|
||||
"merge_run_wait_batch": "Die Durchgänge werden verteilt, damit der Node reaktionsfähig bleibt…",
|
||||
"merge_run_wait_send": "Pausiert, bis Ihre ausstehende Transaktion durch ist.",
|
||||
"merge_run_wait_sync": "Pausiert: Der Node synchronisiert noch.",
|
||||
"merge_scope_failed_fmt": "Ihre Inputs konnten nicht gelesen werden: %s",
|
||||
"merge_scope_fmt": "%d transparente + %d geschützte Inputs · ~%s DRGX verfügbar",
|
||||
"merge_scope_loading": "Ihre Inputs werden geprüft…",
|
||||
"merge_send_failed": "Zusammenführen fehlgeschlagen: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "Geschützt",
|
||||
"merge_src_transparent": "Transparent",
|
||||
"merge_started": "Zusammenführung gestartet",
|
||||
"merge_submit_uncertain": "Der Node hat das Zusammenführen nicht bestätigt, bevor die Verbindung unterbrochen wurde. Es kann trotzdem ausgeführt werden – prüfen Sie nach dem erneuten Verbinden den Verlauf, bevor Sie erneut zusammenführen.",
|
||||
"merge_title": "An Adresse zusammenführen",
|
||||
"merge_wait_locked": "Die Wallet ist gesperrt. Entsperren Sie sie, um fortzufahren.",
|
||||
"merge_waiting_node": "Warten auf den Node…",
|
||||
"merge_where": "Wohin sollen die Mittel gehen?",
|
||||
"mig_adopt": "Dies zu meiner Wallet machen",
|
||||
"mig_adopt_now": "Seed-Wallet übernehmen",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "Bewegung reduzieren",
|
||||
"refresh": "Aktualisieren",
|
||||
"refresh_now": "Jetzt aktualisieren",
|
||||
"refresh_waits_for_sync": "Der Knoten synchronisiert noch. Die Wallet-Daten werden aktualisiert, sobald er fertig ist.",
|
||||
"remove_favorite": "Favorit entfernen",
|
||||
"repair_wallet": "Wallet reparieren",
|
||||
"report_bug": "Fehler melden",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "Ihr Node wurde per Bootstrap eingerichtet, daher liegen Blöcke unterhalb des Snapshots nicht auf der Festplatte und ein Neuscan ab dem Genesis-Block würde fehlschlagen. Führen Sie den Neuscan ab einer Höhe durch, die Ihr Snapshot umfasst, um das ausgegebene Guthaben Ihrer Wallet abzugleichen. Ihre wallet.dat und Ihre Blockchain-Daten werden nicht gelöscht.",
|
||||
"rescan_detecting": "Prüfe, welche Blöcke Ihr Node auf der Festplatte hat…",
|
||||
"rescan_from_height": "Neu scannen ab Blockhöhe:",
|
||||
"rescan_probe_failed": "Es konnte nicht geprüft werden, welche Blöcke Ihr Node auf der Festplatte hat (er ist ausgelastet oder eine Anfrage ist abgelaufen). Wenn dieser Node per Bootstrap eingerichtet wurde, brechen Sie ab und versuchen Sie es in einer Minute erneut: Der vollständige -rescan-Neustart unten schlägt auf einem per Bootstrap eingerichteten Node fehl.",
|
||||
"reset_to_defaults": "Standardwerte zurücksetzen",
|
||||
"restarting_after_encryption": "Daemon wird nach Verschlüsselung neu gestartet...",
|
||||
"restore_address": "Adresse wiederherstellen",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
|
||||
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
|
||||
"screenshot_sweep_full": "Vollständiger UI-Durchlauf",
|
||||
"screenshot_sweep_refused": "Vollständiger UI-Durchlauf abgelehnt; siehe Debug-Log",
|
||||
"search_icons": "Symbole suchen...",
|
||||
"search_placeholder": "Suchen...",
|
||||
"sec_changing_passphrase": "Passphrase wird geändert...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " Passphrase",
|
||||
"sec_not_connected_to_daemon": "Nicht mit Daemon verbunden",
|
||||
"sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden",
|
||||
"sec_passphrase_changed_pin_stale": "Passphrase geändert; Ihre PIN ist jetzt veraltet",
|
||||
"sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert",
|
||||
"sec_pin_changed_successfully": "PIN erfolgreich geändert",
|
||||
"sec_pin_removed": "PIN entfernt",
|
||||
"sec_pin_set_successfully": "PIN erfolgreich festgelegt",
|
||||
"sec_pin_stale_msg": "PIN veraltet. Verwenden Sie Ihre Passphrase.",
|
||||
"sec_pin_unreadable": "PIN-Datei unlesbar. Verwenden Sie Ihre Passphrase.",
|
||||
"sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.",
|
||||
"sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...",
|
||||
"sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds",
|
||||
"sec_unlock_button": "Entsperren",
|
||||
"sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Entsperren%s",
|
||||
"sec_use_passphrase_instead": "Stattdessen Passphrase verwenden",
|
||||
"sec_use_pin_instead": "Stattdessen PIN verwenden",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "Log-Ordner öffnen",
|
||||
"settings_other": "Sonstiges",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN veraltet",
|
||||
"settings_privacy": "Datenschutz",
|
||||
"settings_quick_unlock_pin": "Schnell-Entsperr-PIN",
|
||||
"settings_reduce_transparency": "Transparenz reduzieren",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "Neueste zuerst",
|
||||
"sort_date_oldest": "Älteste zuerst",
|
||||
"start_mining": "Mining starten",
|
||||
"startup_files_preparing": "Wallet-Dateien werden vorbereitet... %d%%",
|
||||
"startup_files_verifying": "Wallet-Dateien werden überprüft...",
|
||||
"status": "Status",
|
||||
"stop_external": "Externen Daemon stoppen",
|
||||
"stop_mining": "Mining stoppen",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "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.",
|
||||
"stopconfirm_rebuild_title": "Der Node baut die Note-Witnesses neu auf",
|
||||
"stopconfirm_stop_wait": "Node stoppen & warten",
|
||||
"stopconfirm_walletop_body": "Der Node durchsucht die Blockchain für einen Schlüsselimport oder Neuscan. Wenn Sie ihn jetzt stoppen, endet dieser Scan vorzeitig, und Guthaben, das er noch nicht erreicht hat, erscheint erst nach Einstellungen → Neu scannen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet den Scan selbstständig.",
|
||||
"stopconfirm_walletop_title": "Ein Schlüsselimport oder Neuscan läuft",
|
||||
"stratum_allowip_hint": "Miner von IP oder CIDR zulassen (leer = nur dieser Computer)",
|
||||
"stratum_expose_warn": "Öffnet einen Mining-Port für das von Ihnen zugelassene Netzwerk — nur in einem vertrauenswürdigen LAN verwenden.",
|
||||
"stratum_host": "Mining-Pool hosten (Stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "WERKZEUGE",
|
||||
"tools_actions": "Werkzeuge & Aktionen...",
|
||||
"tools_actions_hdr": "WERKZEUGE & AKTIONEN",
|
||||
"tor_never_applied_notice": "Die Option „Tor verwenden“ hat nie funktioniert: Ihre Verbindungen wurden NICHT über Tor geleitet. Sie wurde ausgeschaltet. Alternativen finden Sie im Tooltip der Option in den Einstellungen.",
|
||||
"total": "Gesamt",
|
||||
"total_balance_label": "Gesamtguthaben",
|
||||
"transaction_id": "TRANSAKTIONS-ID",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt",
|
||||
"tt_language": "Schnittstellensprache der Wallet-UI",
|
||||
"tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts",
|
||||
"tt_lite_confirm_pass": "Geben Sie die neue Passphrase erneut ein; sie muss übereinstimmen und mindestens 8 Zeichen lang sein",
|
||||
"tt_lite_copy": "Das angezeigte Geheimnis in die Zwischenablage kopieren",
|
||||
"tt_lite_decrypt_pass": "Gib deine Passphrase ein, um die Verschlüsselung von der Wallet zu entfernen",
|
||||
"tt_lite_encrypt": "Die Wallet mit der obigen Passphrase verschlüsseln; sie wird sofort gesperrt und benötigt die Passphrase zum Entsperren",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
|
||||
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
|
||||
"tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
|
||||
"tt_pin_out_of_date": "Die Wallet-Passphrase wurde geändert, nachdem diese PIN festgelegt wurde, daher kann die PIN die Wallet nicht mehr entsperren. Entfernen Sie die PIN und legen Sie sie neu fest.",
|
||||
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
|
||||
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
|
||||
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "Schimmer, Glühen, Farbton-Zyklen pro Theme",
|
||||
"tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes",
|
||||
"tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten",
|
||||
"tt_tor_unavailable": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nur für den Node können Sie proxy=127.0.0.1:9050 zu DRAGONX.conf hinzufügen und den Node neu starten. Tor muss laufen, sonst findet der Node keine Peers. Preis-, Update-, Bootstrap- und Pool-Anfragen verbinden sich weiterhin direkt; nutzen Sie dafür ein systemweites VPN/Tor.",
|
||||
"tt_tor_unavailable_lite": "Noch nicht verfügbar: Diese Wallet leitet keinen Datenverkehr über Tor. Nutzen Sie ein systemweites VPN/Tor.",
|
||||
"tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer",
|
||||
"tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)",
|
||||
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "Transaktionsaktualisierung in der Warteschlange",
|
||||
"tx_loading_refreshing_cached": "Wallet-Verlauf wird aktualisiert (%d zwischengespeichert)",
|
||||
"tx_loading_scanning_shielded": "Abgeschirmter Verlauf wird gescannt (%d Adressen)",
|
||||
"tx_loading_waits_for_sync": "Der Verlauf wird aktualisiert, sobald der Knoten fertig synchronisiert ist",
|
||||
"tx_mined": "GEMINED",
|
||||
"tx_progress_balances": "Guthaben wird aktualisiert",
|
||||
"tx_progress_finalizing": "Transaktion wird abgeschlossen",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "Ordner anzeigen",
|
||||
"wallets_scan_folder": "Einen weiteren Ordner nach Wallets durchsuchen…",
|
||||
"wallets_scanned_folders": "Durchsuchte Ordner:",
|
||||
"wallets_scanning": "Ordner werden durchsucht…",
|
||||
"wallets_sort_addresses": "Adressen",
|
||||
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
|
||||
"wallets_sort_by": "Sortieren:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado",
|
||||
"appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "No se pudo reconciliar tu cartera con los datos de la cadena del bootstrap: el nodo estuvo demasiado ocupado para comprobar qué bloques tiene. Ejecuta Re-escanear desde Ajustes cuando se haya puesto al día.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpiando...",
|
||||
"appx_confirm_your_backup": "Confirma tu copia de seguridad",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "Salida de dragonxd",
|
||||
"appx_encrypting_wallet": "Cifrando la cartera...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite",
|
||||
"appx_import_cancelled": "Cancelado: puede que el nodo siga escaneando. Si los fondos de la clave no aparecen, ejecuta Ajustes → Re-escanear.",
|
||||
"appx_import_interrupted": "La importación se interrumpió, así que la clave puede que ya esté en la cartera. Si sus fondos no aparecen, ejecuta Ajustes → Re-escanear.",
|
||||
"appx_import_refused": "El nodo rechazó la solicitud, así que la clave no se importó.",
|
||||
"appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de pago no válida: ",
|
||||
"appx_ive_written_it_down": "Ya la anoté",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...",
|
||||
"appx_wallet_created_and_backed_up": "Cartera creada y respaldada.",
|
||||
"appx_wallet_op_node_busy": "El nodo está ocupado y no responde. Vuelve a intentar la importación o el reescaneo cuando termine.",
|
||||
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
|
||||
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
|
||||
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
|
||||
"auto_shield": "Auto-proteger minería",
|
||||
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmar nueva:",
|
||||
"change_pass_current": "Frase de contraseña actual:",
|
||||
"change_pass_new": "Nueva frase de contraseña:",
|
||||
"change_pass_pin_note": "Esta cartera tiene un PIN de desbloqueo rápido. Tras el cambio quedará desactualizado: quita el PIN en Ajustes y vuelve a establecerlo.",
|
||||
"change_pass_title": "Cambiar frase de contraseña",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
|
||||
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
|
||||
"lite_birthday_label": "Fecha de creación",
|
||||
"lite_confirm_label": "Confirmar",
|
||||
"lite_console_backend_commands": "Comandos del backend:",
|
||||
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "No se pudo iniciar la operación",
|
||||
"lite_could_not_write": "No se pudo escribir ",
|
||||
"lite_encrypt_offline_warning": "Cualquiera que tenga el archivo de la cartera puede probar frases de contraseña sin conexión. Haz primero una copia de seguridad de tu frase de recuperación y usa una frase de contraseña larga.",
|
||||
"lite_encrypt_wallet": "Cifrar cartera",
|
||||
"lite_encryption_removed": "Cifrado eliminado",
|
||||
"lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "Ocultar y borrar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar clave",
|
||||
"lite_key_import_failed": "No se pudo importar la clave: compruébala, desbloquea el monedero (si está cifrado) e inténtalo de nuevo.",
|
||||
"lite_key_imported": "Clave importada: ejecuta una sincronización para escanear su historial",
|
||||
"lite_lock_failed": "Error al bloquear",
|
||||
"lite_lock_now": "Bloquear ahora",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s: falló: %s",
|
||||
"merge_keep_each_status_not_started": "%s: no iniciado: %s",
|
||||
"merge_keep_each_status_running": "%s: fusionando %d notas (%d de %d lotes listos)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s: no se pudo confirmar el estado",
|
||||
"merge_keep_each_too_small": "Demasiado poco para consolidar: la comisión se llevaría estos fondos.",
|
||||
"merge_max_inputs": "Máximo de entradas por lote",
|
||||
"merge_no_spendable": "Aún no hay entradas disponibles para consolidar.",
|
||||
"merge_progress": "Consolidando… esto puede tardar varios minutos. Puedes cerrar esta ventana.",
|
||||
"merge_run_aborted_toast": "La conexión con el nodo se reinició, así que la consolidación se detuvo. Ejecútala de nuevo para continuar.",
|
||||
"merge_run_busy": "Hay una consolidación en curso. Espera a que termine o detenla primero.",
|
||||
"merge_run_chip_fmt": "Consolidando %d/%d",
|
||||
"merge_run_err_timeout": "El nodo no respondió a tiempo; puede que el lote siga en cola.",
|
||||
"merge_run_halted_fmt": "Consolidación detenida tras fallos repetidos: %s",
|
||||
"merge_run_migration_pending": "Termina o cancela la migración de la semilla antes de consolidar.",
|
||||
"merge_run_progress_fmt": "Consolidando… lote %d de unos %d, %d notas fusionadas hasta ahora. Puedes cerrar esta ventana; seguirá en segundo plano.",
|
||||
"merge_run_start_failed": "No se pudo iniciar la consolidación: el nodo no está conectado.",
|
||||
"merge_run_status_not_run": "%s: sin consolidar (%d notas)",
|
||||
"merge_run_status_waiting": "%s: en espera (%d notas)",
|
||||
"merge_run_stop": "Detener",
|
||||
"merge_run_stopped_toast": "Consolidación detenida. Ejecútala de nuevo para fusionar el resto.",
|
||||
"merge_run_stopping": "Deteniendo tras los lotes ya enviados…",
|
||||
"merge_run_wait_batch": "Espaciando los lotes para que el nodo siga respondiendo…",
|
||||
"merge_run_wait_send": "En pausa hasta que se complete tu transacción pendiente.",
|
||||
"merge_run_wait_sync": "En pausa: el nodo aún se está sincronizando.",
|
||||
"merge_scope_failed_fmt": "No se pudieron leer tus entradas: %s",
|
||||
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponibles",
|
||||
"merge_scope_loading": "Comprobando tus entradas…",
|
||||
"merge_send_failed": "Error al fusionar: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "Blindadas",
|
||||
"merge_src_transparent": "Transparentes",
|
||||
"merge_started": "Operación de fusión iniciada",
|
||||
"merge_submit_uncertain": "El nodo no confirmó la fusión antes de que se interrumpiera la conexión. Puede que aún se complete: revisa el Historial tras reconectar antes de fusionar de nuevo.",
|
||||
"merge_title": "Fusionar a Dirección",
|
||||
"merge_wait_locked": "La cartera está bloqueada. Desbloquéala para continuar.",
|
||||
"merge_waiting_node": "Esperando al nodo…",
|
||||
"merge_where": "¿Adónde deben ir los fondos?",
|
||||
"mig_adopt": "Hacer que esta sea mi cartera",
|
||||
"mig_adopt_now": "Adoptar cartera con frase",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "Reducir Movimiento",
|
||||
"refresh": "Actualizar",
|
||||
"refresh_now": "Actualizar Ahora",
|
||||
"refresh_waits_for_sync": "El nodo aún se está sincronizando. Los datos de la cartera se actualizarán cuando termine.",
|
||||
"remove_favorite": "Quitar favorito",
|
||||
"repair_wallet": "Reparar cartera",
|
||||
"report_bug": "Reportar Error",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "Tu nodo se inicializó con un bootstrap, por lo que los bloques anteriores a la instantánea no están en disco y un reescaneo desde el génesis fallaría. Reescanea desde una altura que incluya tu instantánea para reconciliar el saldo gastado de tu cartera. Tu wallet.dat y los datos de la cadena no se eliminan.",
|
||||
"rescan_detecting": "Comprobando qué bloques tiene tu nodo en disco…",
|
||||
"rescan_from_height": "Reescanear desde la altura de bloque:",
|
||||
"rescan_probe_failed": "No se pudo comprobar qué bloques tiene tu nodo en disco (está ocupado o una solicitud agotó el tiempo de espera). Si este nodo se inicializó con un bootstrap, cancela y vuelve a intentarlo en un minuto: el reinicio completo con -rescan de abajo falla en un nodo inicializado con bootstrap.",
|
||||
"reset_to_defaults": "Restablecer Valores",
|
||||
"restarting_after_encryption": "Reiniciando daemon después del cifrado...",
|
||||
"restore_address": "Restaurar dirección",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "Ejecutar barrido de capturas",
|
||||
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
|
||||
"screenshot_sweep_full": "Barrido completo de la interfaz",
|
||||
"screenshot_sweep_refused": "Barrido completo de la interfaz rechazado; ver registro de depuración",
|
||||
"search_icons": "Buscar iconos...",
|
||||
"search_placeholder": "Buscar...",
|
||||
"sec_changing_passphrase": "Cambiando la frase de contraseña...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " Frase de contraseña",
|
||||
"sec_not_connected_to_daemon": "Sin conexión con el daemon",
|
||||
"sec_not_connected_to_daemon_pin": "Sin conexión con el daemon",
|
||||
"sec_passphrase_changed_pin_stale": "Frase de contraseña cambiada; tu PIN está desactualizado",
|
||||
"sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente",
|
||||
"sec_pin_changed_successfully": "PIN cambiado correctamente",
|
||||
"sec_pin_removed": "PIN eliminado",
|
||||
"sec_pin_set_successfully": "PIN configurado correctamente",
|
||||
"sec_pin_stale_msg": "PIN desactualizado. Usa tu frase de contraseña.",
|
||||
"sec_pin_unreadable": "Archivo del PIN ilegible. Usa tu frase de contraseña.",
|
||||
"sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.",
|
||||
"sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...",
|
||||
"sec_total_elapsed_fmt": "Tiempo total: %dm %02ds",
|
||||
"sec_unlock_button": "Desbloquear",
|
||||
"sec_unlock_failed_prefix": "Error al desbloquear: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Desbloqueando%s",
|
||||
"sec_use_passphrase_instead": "Usar frase de contraseña en su lugar",
|
||||
"sec_use_pin_instead": "Usar PIN en su lugar",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "Abrir carpeta de registros",
|
||||
"settings_other": "Otros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN desactualizado",
|
||||
"settings_privacy": "Privacidad",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueo rápido",
|
||||
"settings_reduce_transparency": "Reducir transparencia",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "Más recientes primero",
|
||||
"sort_date_oldest": "Más antiguas primero",
|
||||
"start_mining": "Iniciar Minería",
|
||||
"startup_files_preparing": "Preparando los archivos de la cartera... %d%%",
|
||||
"startup_files_verifying": "Verificando los archivos de la cartera...",
|
||||
"status": "Estado",
|
||||
"stop_external": "Detener daemon externo",
|
||||
"stop_mining": "Detener Minería",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "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.",
|
||||
"stopconfirm_rebuild_title": "El nodo está reconstruyendo los testigos de notas",
|
||||
"stopconfirm_stop_wait": "Detener el nodo y esperar",
|
||||
"stopconfirm_walletop_body": "El nodo está escaneando la cadena para una importación de clave o un reescaneo. Si lo detienes ahora, ese escaneo termina antes de tiempo, y los fondos a los que aún no ha llegado solo aparecerán tras Ajustes → Re-escanear.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará el escaneo por su cuenta.",
|
||||
"stopconfirm_walletop_title": "Hay una importación de clave o un reescaneo en curso",
|
||||
"stratum_allowip_hint": "Permitir mineros desde IP o CIDR (en blanco = solo este equipo)",
|
||||
"stratum_expose_warn": "Abre un puerto de minería a la red que permitas — úsalo solo en una LAN de confianza.",
|
||||
"stratum_host": "Alojar un pool de minería (stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "HERRAMIENTAS",
|
||||
"tools_actions": "Herramientas y Acciones...",
|
||||
"tools_actions_hdr": "HERRAMIENTAS Y ACCIONES",
|
||||
"tor_never_applied_notice": "La opción \"Usar Tor\" nunca funcionó: tus conexiones NO se enrutaron a través de Tor. Se ha desactivado. Consulta su descripción emergente en Ajustes para ver alternativas.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DE TRANSACCIÓN",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración",
|
||||
"tt_language": "Idioma de la interfaz de la billetera",
|
||||
"tt_layout_hotkey": "Atajo: teclas de flecha izquierda/derecha para cambiar diseños de Balance",
|
||||
"tt_lite_confirm_pass": "Escribe de nuevo la nueva frase de contraseña; debe coincidir y tener al menos 8 caracteres",
|
||||
"tt_lite_copy": "Copia el secreto revelado al portapapeles",
|
||||
"tt_lite_decrypt_pass": "Introduce tu frase de contraseña para quitar el cifrado de la cartera",
|
||||
"tt_lite_encrypt": "Cifra la cartera con la frase de contraseña de arriba; se bloquea de inmediato y requiere la frase para desbloquearse",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
|
||||
"tt_open_dir": "Clic para abrir en explorador de archivos",
|
||||
"tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
|
||||
"tt_pin_out_of_date": "La frase de contraseña de la cartera cambió después de establecer este PIN, así que el PIN ya no puede desbloquearla. Quita el PIN y vuelve a establecerlo.",
|
||||
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
|
||||
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
|
||||
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "Brillo, resplandor, ciclo de tono por tema",
|
||||
"tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas",
|
||||
"tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato",
|
||||
"tt_tor_unavailable": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Solo para el nodo, puedes añadir proxy=127.0.0.1:9050 a DRAGONX.conf y reiniciar el nodo. Tor debe estar en ejecución o el nodo no encontrará otros nodos. Las solicitudes de precios, actualizaciones, bootstrap y pool siguen conectándose directamente; para ellas usa una VPN/Tor de todo el sistema.",
|
||||
"tt_tor_unavailable_lite": "Aún no disponible: esta cartera no enruta ningún tráfico a través de Tor. Usa una VPN/Tor de todo el sistema.",
|
||||
"tt_tx_url": "URL base para ver transacciones en un explorador de bloques",
|
||||
"tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)",
|
||||
"tt_validate": "Comprobar si una dirección DragonX es válida",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "Actualización de transacciones en cola",
|
||||
"tx_loading_refreshing_cached": "Actualizando el historial de la cartera (%d en caché)",
|
||||
"tx_loading_scanning_shielded": "Escaneando historial protegido (%d direcciones)",
|
||||
"tx_loading_waits_for_sync": "El historial se actualizará cuando el nodo termine de sincronizarse",
|
||||
"tx_mined": "MINADO",
|
||||
"tx_progress_balances": "Actualizando saldos",
|
||||
"tx_progress_finalizing": "Finalizando transacción",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "Mostrar carpeta",
|
||||
"wallets_scan_folder": "Escanear otra carpeta en busca de carteras…",
|
||||
"wallets_scanned_folders": "Carpetas escaneadas:",
|
||||
"wallets_scanning": "Buscando en las carpetas…",
|
||||
"wallets_sort_addresses": "Direcciones",
|
||||
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.",
|
||||
"appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée",
|
||||
"appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Impossible de réconcilier votre portefeuille avec les données de chaîne de l'amorçage : le nœud est resté trop occupé pour vérifier ses blocs. Lancez Re-scanner depuis les Paramètres une fois qu'il aura rattrapé son retard.",
|
||||
"appx_cancel": "Annuler",
|
||||
"appx_cleaning_up": "Nettoyage...",
|
||||
"appx_confirm_your_backup": "Confirmez votre sauvegarde",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "Sortie de dragonxd",
|
||||
"appx_encrypting_wallet": "Chiffrement du portefeuille...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée",
|
||||
"appx_import_cancelled": "Annulé : le nœud est peut-être encore en train d'analyser. Si les fonds de la clé n'apparaissent pas, lancez Paramètres → Re-scanner.",
|
||||
"appx_import_interrupted": "L'importation a été interrompue, la clé est donc peut-être déjà dans le portefeuille. Si ses fonds n'apparaissent pas, lancez Paramètres → Re-scanner.",
|
||||
"appx_import_refused": "Le nœud a refusé la requête, la clé n'a donc pas été importée.",
|
||||
"appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de paiement invalide : ",
|
||||
"appx_ive_written_it_down": "Je les ai notés",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...",
|
||||
"appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.",
|
||||
"appx_wallet_op_node_busy": "Le nœud est occupé et ne répond pas. Relancez l'importation ou la réanalyse une fois qu'il a fini.",
|
||||
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
|
||||
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
|
||||
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
|
||||
"auto_shield": "Auto-blindage du minage",
|
||||
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmer la nouvelle :",
|
||||
"change_pass_current": "Phrase secrète actuelle :",
|
||||
"change_pass_new": "Nouvelle phrase secrète :",
|
||||
"change_pass_pin_note": "Ce portefeuille a un PIN de déverrouillage rapide. Après le changement, il sera obsolète : supprimez le PIN dans les Paramètres puis définissez-le à nouveau.",
|
||||
"change_pass_title": "Changer la phrase secrète",
|
||||
"characters": "caractères",
|
||||
"chat": "Discussion",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
|
||||
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
|
||||
"lite_birthday_label": "Bloc de création",
|
||||
"lite_confirm_label": "Confirmer",
|
||||
"lite_console_backend_commands": "Commandes du backend :",
|
||||
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
|
||||
"lite_copy": "Copier",
|
||||
"lite_could_not_start": "Impossible de démarrer l'opération",
|
||||
"lite_could_not_write": "Impossible d'écrire ",
|
||||
"lite_encrypt_offline_warning": "Toute personne disposant du fichier du portefeuille peut essayer des phrases secrètes hors ligne. Sauvegardez d'abord votre phrase de récupération et utilisez une phrase secrète longue.",
|
||||
"lite_encrypt_wallet": "Chiffrer le portefeuille",
|
||||
"lite_encryption_removed": "Chiffrement supprimé",
|
||||
"lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "Masquer et effacer",
|
||||
"lite_import": "Importer",
|
||||
"lite_import_key_label": "Importer une clé",
|
||||
"lite_key_import_failed": "Impossible d'importer la clé — vérifiez-la, déverrouillez le portefeuille (s'il est chiffré) et réessayez.",
|
||||
"lite_key_imported": "Clé importée — lancez une synchronisation pour analyser son historique",
|
||||
"lite_lock_failed": "Échec du verrouillage",
|
||||
"lite_lock_now": "Verrouiller maintenant",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s — échec : %s",
|
||||
"merge_keep_each_status_not_started": "%s — non démarré : %s",
|
||||
"merge_keep_each_status_running": "%s — fusion de %d notes (%d lots sur %d terminés)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — statut non confirmé",
|
||||
"merge_keep_each_too_small": "Trop faible pour être consolidé : les frais absorberaient ces fonds.",
|
||||
"merge_max_inputs": "Nombre max d'entrées par lot",
|
||||
"merge_no_spendable": "Aucune entrée dépensable à consolider pour l'instant.",
|
||||
"merge_progress": "Consolidation en cours… cela peut prendre quelques minutes. Vous pouvez fermer cette fenêtre.",
|
||||
"merge_run_aborted_toast": "La connexion au nœud a été réinitialisée, la consolidation s'est donc arrêtée. Relancez-la pour continuer.",
|
||||
"merge_run_busy": "Une consolidation est en cours. Attendez qu'elle se termine ou arrêtez-la d'abord.",
|
||||
"merge_run_chip_fmt": "Consolidation %d/%d",
|
||||
"merge_run_err_timeout": "Le nœud n'a pas répondu à temps ; le lot est peut-être encore en file d'attente.",
|
||||
"merge_run_halted_fmt": "Consolidation arrêtée après des échecs répétés : %s",
|
||||
"merge_run_migration_pending": "Terminez ou annulez la migration de la phrase de récupération avant de consolider.",
|
||||
"merge_run_progress_fmt": "Consolidation en cours… lot %d sur environ %d, %d notes fusionnées jusqu'ici. Vous pouvez fermer cette fenêtre ; l'opération continue en arrière-plan.",
|
||||
"merge_run_start_failed": "Impossible de démarrer la consolidation — le nœud n'est pas connecté.",
|
||||
"merge_run_status_not_run": "%s — non consolidé (%d notes)",
|
||||
"merge_run_status_waiting": "%s — en attente (%d notes)",
|
||||
"merge_run_stop": "Arrêter",
|
||||
"merge_run_stopped_toast": "Consolidation arrêtée. Relancez-la pour fusionner le reste.",
|
||||
"merge_run_stopping": "Arrêt après les lots déjà envoyés…",
|
||||
"merge_run_wait_batch": "Espacement des lots pour que le nœud reste réactif…",
|
||||
"merge_run_wait_send": "En pause jusqu'à ce que votre transaction en attente soit passée.",
|
||||
"merge_run_wait_sync": "En pause : le nœud est encore en cours de synchronisation.",
|
||||
"merge_scope_failed_fmt": "Impossible de lire vos entrées : %s",
|
||||
"merge_scope_fmt": "%d entrées transparentes + %d blindées · ~%s DRGX dépensables",
|
||||
"merge_scope_loading": "Vérification de vos entrées…",
|
||||
"merge_send_failed": "Échec de la fusion : ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "Blindées",
|
||||
"merge_src_transparent": "Transparentes",
|
||||
"merge_started": "Opération de fusion démarrée",
|
||||
"merge_submit_uncertain": "Le nœud n'a pas confirmé la fusion avant l'interruption de la connexion. Elle peut tout de même aboutir — vérifiez l'Historique après la reconnexion avant de fusionner à nouveau.",
|
||||
"merge_title": "Fusionner vers une adresse",
|
||||
"merge_wait_locked": "Le portefeuille est verrouillé. Déverrouillez-le pour continuer.",
|
||||
"merge_waiting_node": "En attente du nœud…",
|
||||
"merge_where": "Où doivent aller les fonds ?",
|
||||
"mig_adopt": "Faire de ceci mon portefeuille",
|
||||
"mig_adopt_now": "Adopter le portefeuille à phrase",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "Réduire les animations",
|
||||
"refresh": "Actualiser",
|
||||
"refresh_now": "Actualiser maintenant",
|
||||
"refresh_waits_for_sync": "Le nœud est encore en cours de synchronisation. Les données du portefeuille seront actualisées une fois celle-ci terminée.",
|
||||
"remove_favorite": "Retirer des favoris",
|
||||
"repair_wallet": "Réparer le portefeuille",
|
||||
"report_bug": "Signaler un bug",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "Votre nœud a été initialisé par bootstrap, les blocs situés en dessous de l'instantané ne sont donc pas sur le disque et un rescan depuis la genèse échouerait. Effectuez un rescan à partir d'une hauteur incluse dans votre instantané pour réconcilier le solde dépensé de votre portefeuille. Votre fichier wallet.dat et vos données de chaîne ne sont pas supprimés.",
|
||||
"rescan_detecting": "Vérification des blocs présents sur le disque de votre nœud…",
|
||||
"rescan_from_height": "Rescanner à partir de la hauteur de bloc :",
|
||||
"rescan_probe_failed": "Impossible de vérifier quels blocs votre nœud a sur le disque (il est occupé ou une requête a expiré). Si ce nœud a été initialisé par bootstrap, annulez et réessayez dans une minute : le redémarrage complet avec -rescan ci-dessous échoue sur un nœud initialisé par bootstrap.",
|
||||
"reset_to_defaults": "Réinitialiser les paramètres",
|
||||
"restarting_after_encryption": "Redémarrage du daemon après chiffrement...",
|
||||
"restore_address": "Restaurer l'adresse",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "Lancer la capture d'écran",
|
||||
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
|
||||
"screenshot_sweep_full": "Balayage complet de l'interface",
|
||||
"screenshot_sweep_refused": "Balayage complet de l'interface refusé ; voir le journal de débogage",
|
||||
"search_icons": "Rechercher des icônes...",
|
||||
"search_placeholder": "Rechercher...",
|
||||
"sec_changing_passphrase": "Modification de la phrase secrète...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " Phrase secrète",
|
||||
"sec_not_connected_to_daemon": "Non connecté au démon",
|
||||
"sec_not_connected_to_daemon_pin": "Non connecté au démon",
|
||||
"sec_passphrase_changed_pin_stale": "Phrase secrète modifiée ; votre PIN est obsolète",
|
||||
"sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès",
|
||||
"sec_pin_changed_successfully": "PIN modifié avec succès",
|
||||
"sec_pin_removed": "PIN supprimé",
|
||||
"sec_pin_set_successfully": "PIN défini avec succès",
|
||||
"sec_pin_stale_msg": "PIN obsolète. Utilisez votre phrase secrète.",
|
||||
"sec_pin_unreadable": "Fichier du PIN illisible. Utilisez votre phrase secrète.",
|
||||
"sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.",
|
||||
"sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...",
|
||||
"sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds",
|
||||
"sec_unlock_button": "Déverrouiller",
|
||||
"sec_unlock_failed_prefix": "Échec du déverrouillage : ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Déverrouillage%s",
|
||||
"sec_use_passphrase_instead": "Utiliser une phrase secrète",
|
||||
"sec_use_pin_instead": "Utiliser un PIN",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "Ouvrir le dossier des journaux",
|
||||
"settings_other": "Autres",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN obsolète",
|
||||
"settings_privacy": "Confidentialité",
|
||||
"settings_quick_unlock_pin": "PIN de déverrouillage rapide",
|
||||
"settings_reduce_transparency": "Réduire la transparence",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "Plus récent d'abord",
|
||||
"sort_date_oldest": "Plus ancien d'abord",
|
||||
"start_mining": "Démarrer le minage",
|
||||
"startup_files_preparing": "Préparation des fichiers du portefeuille... %d%%",
|
||||
"startup_files_verifying": "Vérification des fichiers du portefeuille...",
|
||||
"status": "Statut",
|
||||
"stop_external": "Arrêter le daemon externe",
|
||||
"stop_mining": "Arrêter le minage",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "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.",
|
||||
"stopconfirm_rebuild_title": "Le nœud reconstruit les témoins de notes",
|
||||
"stopconfirm_stop_wait": "Arrêter le nœud et attendre",
|
||||
"stopconfirm_walletop_body": "Le nœud analyse la chaîne pour une importation de clé ou une réanalyse. L'arrêter maintenant met fin à cette analyse plus tôt, et les fonds qu'elle n'a pas encore atteints n'apparaîtront qu'après Paramètres → Re-scanner.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera l'analyse seul.",
|
||||
"stopconfirm_walletop_title": "Une importation de clé ou une réanalyse est en cours",
|
||||
"stratum_allowip_hint": "Autoriser les mineurs depuis une IP ou un CIDR (vide = cet ordinateur uniquement)",
|
||||
"stratum_expose_warn": "Ouvre un port de minage vers le réseau que vous autorisez — à utiliser uniquement sur un réseau local de confiance.",
|
||||
"stratum_host": "Héberger un pool de minage (stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "OUTILS",
|
||||
"tools_actions": "Outils & Actions...",
|
||||
"tools_actions_hdr": "OUTILS ET ACTIONS",
|
||||
"tor_never_applied_notice": "L'option « Utiliser Tor » n'a jamais fonctionné : vos connexions n'ont PAS été acheminées via Tor. Elle a été désactivée. Consultez son info-bulle dans Paramètres pour des alternatives.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Solde total",
|
||||
"transaction_id": "ID DE TRANSACTION",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration",
|
||||
"tt_language": "Langue de l'interface du portefeuille",
|
||||
"tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance",
|
||||
"tt_lite_confirm_pass": "Saisissez à nouveau la nouvelle phrase secrète ; elle doit correspondre et comporter au moins 8 caractères",
|
||||
"tt_lite_copy": "Copie le secret révélé dans le presse-papiers",
|
||||
"tt_lite_decrypt_pass": "Saisissez votre phrase de passe pour retirer le chiffrement du portefeuille",
|
||||
"tt_lite_encrypt": "Chiffre le portefeuille avec la phrase de passe ci-dessus ; il se verrouille immédiatement et requiert la phrase pour se déverrouiller",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
|
||||
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
|
||||
"tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
|
||||
"tt_pin_out_of_date": "La phrase secrète du portefeuille a changé après la définition de ce PIN ; le PIN ne peut plus le déverrouiller. Supprimez le PIN puis définissez-le à nouveau.",
|
||||
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
|
||||
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
|
||||
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "Scintillement, lueur, cycle de teinte par thème",
|
||||
"tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème",
|
||||
"tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat",
|
||||
"tt_tor_unavailable": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Pour le nœud uniquement, vous pouvez ajouter proxy=127.0.0.1:9050 à DRAGONX.conf et redémarrer le nœud. Tor doit être en cours d'exécution, sinon le nœud ne trouve aucun pair. Les requêtes de prix, de mise à jour, de bootstrap et de pool se connectent toujours directement ; pour celles-ci, utilisez un VPN/Tor à l'échelle du système.",
|
||||
"tt_tor_unavailable_lite": "Pas encore disponible : ce portefeuille n'achemine aucun trafic via Tor. Utilisez un VPN/Tor à l'échelle du système.",
|
||||
"tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs",
|
||||
"tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)",
|
||||
"tt_validate": "Vérifier si une adresse DragonX est valide",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "Actualisation des transactions en file d'attente",
|
||||
"tx_loading_refreshing_cached": "Actualisation de l'historique du portefeuille (%d en cache)",
|
||||
"tx_loading_scanning_shielded": "Analyse de l'historique blindé (%d adresses)",
|
||||
"tx_loading_waits_for_sync": "L'historique sera mis à jour une fois le nœud synchronisé",
|
||||
"tx_mined": "MINÉ",
|
||||
"tx_progress_balances": "Actualisation des soldes",
|
||||
"tx_progress_finalizing": "Finalisation de la transaction",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "Afficher le dossier",
|
||||
"wallets_scan_folder": "Analyser un autre dossier pour des portefeuilles…",
|
||||
"wallets_scanned_folders": "Dossiers analysés :",
|
||||
"wallets_scanning": "Analyse des dossiers…",
|
||||
"wallets_sort_addresses": "Adresses",
|
||||
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
|
||||
"wallets_sort_by": "Trier :",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。",
|
||||
"appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました",
|
||||
"appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "ブートストラップのチェーンデータとウォレットを照合できませんでした。ノードが処理中のため、保有しているブロックを確認できませんでした。同期が追いついたら、設定から再スキャンを実行してください。",
|
||||
"appx_cancel": "キャンセル",
|
||||
"appx_cleaning_up": "クリーンアップしています...",
|
||||
"appx_confirm_your_backup": "バックアップを確認",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "dragonxd の出力",
|
||||
"appx_encrypting_wallet": "ウォレットを暗号化しています...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません",
|
||||
"appx_import_cancelled": "キャンセルしました:ノードはまだスキャン中の可能性があります。鍵の資金が表示されない場合は、設定 → 再スキャン を実行してください。",
|
||||
"appx_import_interrupted": "インポートが中断されたため、鍵はすでにウォレットにある可能性があります。資金が表示されない場合は、設定 → 再スキャン を実行してください。",
|
||||
"appx_import_refused": "ノードが要求を拒否したため、鍵はインポートされませんでした。",
|
||||
"appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...",
|
||||
"appx_invalid_payment_uri_prefix": "無効な支払いURI: ",
|
||||
"appx_ive_written_it_down": "書き留めました",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...",
|
||||
"appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。",
|
||||
"appx_wallet_op_node_busy": "ノードは処理中で応答していません。完了してからインポートまたは再スキャンをもう一度お試しください。",
|
||||
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
|
||||
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
|
||||
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
|
||||
"auto_shield": "マイニング自動シールド",
|
||||
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "新しいパスフレーズ(確認):",
|
||||
"change_pass_current": "現在のパスフレーズ:",
|
||||
"change_pass_new": "新しいパスフレーズ:",
|
||||
"change_pass_pin_note": "このウォレットにはクイックアンロック PIN が設定されています。変更後は古くなるため、設定で PIN を削除してから再設定してください。",
|
||||
"change_pass_title": "パスフレーズを変更",
|
||||
"characters": "文字",
|
||||
"chat": "チャット",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
|
||||
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。",
|
||||
"lite_birthday_label": "バースデー",
|
||||
"lite_confirm_label": "確認",
|
||||
"lite_console_backend_commands": "バックエンドコマンド:",
|
||||
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
|
||||
"lite_copy": "コピー",
|
||||
"lite_could_not_start": "操作を開始できませんでした",
|
||||
"lite_could_not_write": "書き込めませんでした ",
|
||||
"lite_encrypt_offline_warning": "ウォレットファイルを持つ人は誰でも、オフラインでパスフレーズを試せます。先にシードフレーズをバックアップし、長いパスフレーズを使ってください。",
|
||||
"lite_encrypt_wallet": "ウォレットを暗号化",
|
||||
"lite_encryption_removed": "暗号化を解除しました",
|
||||
"lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "非表示にして消去",
|
||||
"lite_import": "インポート",
|
||||
"lite_import_key_label": "鍵をインポート",
|
||||
"lite_key_import_failed": "鍵をインポートできませんでした。鍵を確認し、ウォレットのロックを解除して(暗号化されている場合)再試行してください。",
|
||||
"lite_key_imported": "鍵をインポートしました — 履歴をスキャンするには同期を実行してください",
|
||||
"lite_lock_failed": "ロックに失敗しました",
|
||||
"lite_lock_now": "今すぐロック",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s — 失敗:%s",
|
||||
"merge_keep_each_status_not_started": "%s — 開始されませんでした:%s",
|
||||
"merge_keep_each_status_running": "%s — %d 件のノートを統合中(%d / %d 回完了)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — ステータスを確認できませんでした",
|
||||
"merge_keep_each_too_small": "少なすぎて統合できません。手数料でこの資金がなくなります。",
|
||||
"merge_max_inputs": "バッチあたりの最大入力数",
|
||||
"merge_no_spendable": "統合できる使用可能な入力がまだありません。",
|
||||
"merge_progress": "統合中… 数分かかることがあります。このウィンドウは閉じても構いません。",
|
||||
"merge_run_aborted_toast": "ノードとの接続がリセットされたため、統合を停止しました。続けるには再度実行してください。",
|
||||
"merge_run_busy": "統合を実行中です。完了を待つか、先に停止してください。",
|
||||
"merge_run_chip_fmt": "統合中 %d/%d",
|
||||
"merge_run_err_timeout": "ノードが時間内に応答しませんでした。この回はまだキューに入っている可能性があります。",
|
||||
"merge_run_halted_fmt": "失敗が続いたため統合を停止しました:%s",
|
||||
"merge_run_migration_pending": "統合する前に、シードの移行を完了するかキャンセルしてください。",
|
||||
"merge_run_progress_fmt": "統合中… %d 回目(全体で約 %d 回)、これまでに %d 件のノートを統合しました。このウィンドウを閉じてもバックグラウンドで続行します。",
|
||||
"merge_run_start_failed": "統合を開始できませんでした — ノードが接続されていません。",
|
||||
"merge_run_status_not_run": "%s — 未統合(%d 件のノート)",
|
||||
"merge_run_status_waiting": "%s — 待機中(%d 件のノート)",
|
||||
"merge_run_stop": "停止",
|
||||
"merge_run_stopped_toast": "統合を停止しました。残りを統合するには再度実行してください。",
|
||||
"merge_run_stopping": "送信済みの回が終わったら停止します…",
|
||||
"merge_run_wait_batch": "ノードの応答性を保つため、間隔を空けて実行しています…",
|
||||
"merge_run_wait_send": "保留中の取引が完了するまで一時停止しています。",
|
||||
"merge_run_wait_sync": "一時停止中:ノードはまだ同期中です。",
|
||||
"merge_scope_failed_fmt": "入力を読み取れませんでした: %s",
|
||||
"merge_scope_fmt": "透明%d件 + シールド%d件の入力 · 使用可能 約%s DRGX",
|
||||
"merge_scope_loading": "入力を確認中…",
|
||||
"merge_send_failed": "マージに失敗しました: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "シールド",
|
||||
"merge_src_transparent": "透明",
|
||||
"merge_started": "統合操作を開始しました",
|
||||
"merge_submit_uncertain": "接続が中断される前に、ノードはマージを確認しませんでした。マージは実行される可能性があります — 再接続後、もう一度マージする前に「履歴」を確認してください。",
|
||||
"merge_title": "アドレスに統合",
|
||||
"merge_wait_locked": "ウォレットがロックされています。続行するにはロックを解除してください。",
|
||||
"merge_waiting_node": "ノードを待っています…",
|
||||
"merge_where": "資金の移動先",
|
||||
"mig_adopt": "これを自分のウォレットにする",
|
||||
"mig_adopt_now": "シードウォレットを採用",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "モーションを減らす",
|
||||
"refresh": "更新",
|
||||
"refresh_now": "今すぐ更新",
|
||||
"refresh_waits_for_sync": "ノードはまだ同期中です。同期が完了するとウォレットのデータが更新されます。",
|
||||
"remove_favorite": "お気に入りを削除",
|
||||
"repair_wallet": "ウォレットを修復",
|
||||
"report_bug": "バグを報告",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "ノードはブートストラップされているため、スナップショットより下のブロックはディスク上になく、ジェネシスからの再スキャンは失敗します。ウォレットの使用済み残高を照合するには、スナップショットに含まれる高さから再スキャンしてください。wallet.dat とチェーンデータは削除されません。",
|
||||
"rescan_detecting": "ノードがディスク上に保有しているブロックを確認中…",
|
||||
"rescan_from_height": "ブロック高から再スキャン:",
|
||||
"rescan_probe_failed": "ノードがディスク上に保有しているブロックを確認できませんでした(ノードが処理中か、リクエストがタイムアウトしました)。このノードがブートストラップされている場合は、キャンセルして1分後に再試行してください。ブートストラップされたノードでは、下の -rescan による再起動は失敗します。",
|
||||
"reset_to_defaults": "デフォルトにリセット",
|
||||
"restarting_after_encryption": "暗号化後にデーモンを再起動中...",
|
||||
"restore_address": "アドレスを復元",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "スクリーンショットスイープを実行",
|
||||
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
|
||||
"screenshot_sweep_full": "UI全体スイープ",
|
||||
"screenshot_sweep_refused": "UI全体スイープは拒否されました。デバッグログを確認してください",
|
||||
"search_icons": "アイコンを検索...",
|
||||
"search_placeholder": "検索...",
|
||||
"sec_changing_passphrase": "パスフレーズを変更しています...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " パスフレーズ",
|
||||
"sec_not_connected_to_daemon": "デーモンに接続されていません",
|
||||
"sec_not_connected_to_daemon_pin": "デーモンに接続されていません",
|
||||
"sec_passphrase_changed_pin_stale": "パスフレーズを変更しました。PIN は古くなりました",
|
||||
"sec_passphrase_changed_successfully": "パスフレーズを変更しました",
|
||||
"sec_pin_changed_successfully": "PINを変更しました",
|
||||
"sec_pin_removed": "PINを削除しました",
|
||||
"sec_pin_set_successfully": "PINを設定しました",
|
||||
"sec_pin_stale_msg": "PIN が古くなっています。パスフレーズを使用してください。",
|
||||
"sec_pin_unreadable": "PIN ファイルが読めません。パスフレーズで解除してください。",
|
||||
"sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。",
|
||||
"sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...",
|
||||
"sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒",
|
||||
"sec_unlock_button": "ロック解除",
|
||||
"sec_unlock_failed_prefix": "ロック解除に失敗しました: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "ロック解除中%s",
|
||||
"sec_use_passphrase_instead": "代わりにパスフレーズを使用",
|
||||
"sec_use_pin_instead": "代わりにPINを使用",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "ログフォルダを開く",
|
||||
"settings_other": "その他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN が古くなっています",
|
||||
"settings_privacy": "プライバシー",
|
||||
"settings_quick_unlock_pin": "クイックアンロック PIN",
|
||||
"settings_reduce_transparency": "透明度を下げる",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "新しい順",
|
||||
"sort_date_oldest": "古い順",
|
||||
"start_mining": "マイニング開始",
|
||||
"startup_files_preparing": "ウォレットファイルを準備しています... %d%%",
|
||||
"startup_files_verifying": "ウォレットファイルを検証しています...",
|
||||
"status": "ステータス",
|
||||
"stop_external": "外部デーモンを停止",
|
||||
"stop_mining": "マイニング停止",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"stopconfirm_rebuild_title": "ノードはノートウィットネスを再構築しています",
|
||||
"stopconfirm_stop_wait": "ノードを停止して待つ",
|
||||
"stopconfirm_walletop_body": "ノードは鍵のインポートまたは再スキャンのためにチェーンをスキャンしています。今停止するとスキャンが途中で終了し、まだスキャンされていない資金は 設定 → 再スキャン の後にのみ表示されます。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的にスキャンを完了します。",
|
||||
"stopconfirm_walletop_title": "鍵のインポートまたは再スキャンを実行中です",
|
||||
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
|
||||
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
|
||||
"stratum_host": "マイニングプールをホストする(stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "ツール",
|
||||
"tools_actions": "ツールとアクション...",
|
||||
"tools_actions_hdr": "ツールと操作",
|
||||
"tor_never_applied_notice": "「Torを使用」オプションは一度も機能していませんでした:接続は Tor 経由でルーティングされていませんでした。このオプションはオフにしました。代替手段については、設定にあるこのオプションのツールチップをご覧ください。",
|
||||
"total": "合計",
|
||||
"total_balance_label": "総残高",
|
||||
"transaction_id": "取引ID",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します",
|
||||
"tt_language": "ウォレット UI のインターフェース言語",
|
||||
"tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え",
|
||||
"tt_lite_confirm_pass": "新しいパスフレーズをもう一度入力してください。一致し、8 文字以上である必要があります",
|
||||
"tt_lite_copy": "表示された秘密情報をクリップボードにコピーします",
|
||||
"tt_lite_decrypt_pass": "ウォレットの暗号化を解除するためにパスフレーズを入力します",
|
||||
"tt_lite_encrypt": "上のパスフレーズでウォレットを暗号化します。すぐにロックされ、解除にはパスフレーズが必要です",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
|
||||
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
|
||||
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
|
||||
"tt_pin_out_of_date": "この PIN の設定後にウォレットのパスフレーズが変更されたため、PIN ではウォレットをアンロックできません。PIN を削除して再設定してください。",
|
||||
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
|
||||
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
|
||||
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "テーマごとのシマー、グロー、色相サイクル",
|
||||
"tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え",
|
||||
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
|
||||
"tt_tor_unavailable": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。ノードに限り、DRAGONX.conf に proxy=127.0.0.1:9050 を追加してノードを再起動できます。Tor が起動している必要があり、起動していないとノードはピアを見つけられません。価格、アップデート、ブートストラップ、プールのリクエストは引き続き直接接続されます。これらにはシステム全体の VPN/Tor を使用してください。",
|
||||
"tt_tor_unavailable_lite": "まだ利用できません:このウォレットはどの通信も Tor 経由でルーティングしません。システム全体の VPN/Tor を使用してください。",
|
||||
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
|
||||
"tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
|
||||
"tt_validate": "DragonX アドレスが有効かどうかを確認",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "取引の更新を待機中",
|
||||
"tx_loading_refreshing_cached": "ウォレット履歴を更新中(%d 件キャッシュ済み)",
|
||||
"tx_loading_scanning_shielded": "シールド履歴をスキャン中 (%d アドレス)",
|
||||
"tx_loading_waits_for_sync": "ノードの同期が完了すると履歴が更新されます",
|
||||
"tx_mined": "採掘済み",
|
||||
"tx_progress_balances": "残高を更新中",
|
||||
"tx_progress_finalizing": "トランザクションを確定中",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "フォルダを開く",
|
||||
"wallets_scan_folder": "別のフォルダをスキャンしてウォレットを検索…",
|
||||
"wallets_scanned_folders": "スキャン対象フォルダー:",
|
||||
"wallets_scanning": "フォルダをスキャンしています…",
|
||||
"wallets_sort_addresses": "アドレス",
|
||||
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
|
||||
"wallets_sort_by": "並べ替え:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.",
|
||||
"appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료",
|
||||
"appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "부트스트랩 체인 데이터와 지갑을 대조하지 못했습니다. 노드가 사용 중이어서 보유한 블록을 확인할 수 없었습니다. 동기화가 따라잡으면 설정에서 재스캔을 실행하세요.",
|
||||
"appx_cancel": "취소",
|
||||
"appx_cleaning_up": "정리하는 중...",
|
||||
"appx_confirm_your_backup": "백업 확인",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "dragonxd 출력",
|
||||
"appx_encrypting_wallet": "지갑 암호화 중...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다",
|
||||
"appx_import_cancelled": "취소됨: 노드가 아직 스캔 중일 수 있습니다. 키의 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
|
||||
"appx_import_interrupted": "가져오기가 중단되어 키가 이미 지갑에 있을 수 있습니다. 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
|
||||
"appx_import_refused": "노드가 요청을 거부하여 키를 가져오지 않았습니다.",
|
||||
"appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...",
|
||||
"appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ",
|
||||
"appx_ive_written_it_down": "적어 두었습니다",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...",
|
||||
"appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.",
|
||||
"appx_wallet_op_node_busy": "노드가 바빠서 응답하지 않습니다. 끝난 뒤 가져오기 또는 재스캔을 다시 시도하세요.",
|
||||
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
|
||||
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
|
||||
"auto_shield": "채굴 자동 차폐",
|
||||
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "새 암호 확인:",
|
||||
"change_pass_current": "현재 암호:",
|
||||
"change_pass_new": "새 암호:",
|
||||
"change_pass_pin_note": "이 지갑에는 빠른 잠금 해제 PIN이 있습니다. 변경 후에는 PIN이 오래된 상태가 되므로 설정에서 PIN을 제거한 뒤 다시 설정하세요.",
|
||||
"change_pass_title": "암호 변경",
|
||||
"characters": "문자",
|
||||
"chat": "채팅",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
|
||||
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
|
||||
"lite_birthday_label": "생일 블록",
|
||||
"lite_confirm_label": "확인",
|
||||
"lite_console_backend_commands": "백엔드 명령:",
|
||||
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
|
||||
"lite_copy": "복사",
|
||||
"lite_could_not_start": "작업을 시작할 수 없습니다",
|
||||
"lite_could_not_write": "쓸 수 없습니다: ",
|
||||
"lite_encrypt_offline_warning": "지갑 파일을 가진 사람은 누구나 오프라인으로 암호를 시도할 수 있습니다. 먼저 시드 문구를 백업하고 긴 암호를 사용하세요.",
|
||||
"lite_encrypt_wallet": "지갑 암호화",
|
||||
"lite_encryption_removed": "암호화가 제거되었습니다",
|
||||
"lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "숨기고 삭제",
|
||||
"lite_import": "가져오기",
|
||||
"lite_import_key_label": "키 가져오기",
|
||||
"lite_key_import_failed": "키를 가져오지 못했습니다. 키를 확인하고 지갑 잠금을 해제한 후(암호화된 경우) 다시 시도하세요.",
|
||||
"lite_key_imported": "키를 가져왔습니다 — 동기화를 실행하여 기록을 스캔하세요",
|
||||
"lite_lock_failed": "잠금 실패",
|
||||
"lite_lock_now": "지금 잠그기",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s — 실패: %s",
|
||||
"merge_keep_each_status_not_started": "%s — 시작되지 않음: %s",
|
||||
"merge_keep_each_status_running": "%s — 노트 %d개 병합 중(%d/%d 배치 완료)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — 상태를 확인할 수 없음",
|
||||
"merge_keep_each_too_small": "너무 적어서 통합할 수 없습니다. 수수료가 이 자금을 모두 소모합니다.",
|
||||
"merge_max_inputs": "배치당 최대 입력 수",
|
||||
"merge_no_spendable": "아직 통합할 사용 가능한 입력이 없습니다.",
|
||||
"merge_progress": "통합 중… 몇 분 정도 걸릴 수 있습니다. 이 창을 닫아도 됩니다.",
|
||||
"merge_run_aborted_toast": "노드 연결이 재설정되어 통합이 중지되었습니다. 계속하려면 다시 실행하세요.",
|
||||
"merge_run_busy": "통합이 진행 중입니다. 완료될 때까지 기다리거나 먼저 중지하세요.",
|
||||
"merge_run_chip_fmt": "통합 중 %d/%d",
|
||||
"merge_run_err_timeout": "노드가 제때 응답하지 않았습니다. 배치가 아직 대기열에 있을 수 있습니다.",
|
||||
"merge_run_halted_fmt": "반복된 실패로 통합이 중지되었습니다: %s",
|
||||
"merge_run_migration_pending": "통합하기 전에 시드 마이그레이션을 완료하거나 취소하세요.",
|
||||
"merge_run_progress_fmt": "통합 중… 배치 %d(약 %d개 중), 지금까지 노트 %d개 병합됨. 이 창을 닫아도 백그라운드에서 계속 진행됩니다.",
|
||||
"merge_run_start_failed": "통합을 시작할 수 없습니다 — 노드가 연결되어 있지 않습니다.",
|
||||
"merge_run_status_not_run": "%s — 통합 안 됨(노트 %d개)",
|
||||
"merge_run_status_waiting": "%s — 대기 중(노트 %d개)",
|
||||
"merge_run_stop": "중지",
|
||||
"merge_run_stopped_toast": "통합이 중지되었습니다. 나머지를 병합하려면 다시 실행하세요.",
|
||||
"merge_run_stopping": "이미 보낸 배치가 끝나면 중지합니다…",
|
||||
"merge_run_wait_batch": "노드가 응답성을 유지하도록 배치 간격을 두는 중…",
|
||||
"merge_run_wait_send": "보류 중인 거래가 완료될 때까지 일시 중지됩니다.",
|
||||
"merge_run_wait_sync": "일시 중지됨: 노드가 아직 동기화 중입니다.",
|
||||
"merge_scope_failed_fmt": "입력을 읽을 수 없습니다: %s",
|
||||
"merge_scope_fmt": "투명 입력 %d개 + 보호 입력 %d개 · 사용 가능 약 %s DRGX",
|
||||
"merge_scope_loading": "입력을 확인하는 중…",
|
||||
"merge_send_failed": "통합 실패: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "보호",
|
||||
"merge_src_transparent": "투명",
|
||||
"merge_started": "통합 작업이 시작되었습니다",
|
||||
"merge_submit_uncertain": "연결이 끊기기 전에 노드가 통합을 확인하지 않았습니다. 그래도 처리될 수 있습니다 — 다시 연결한 후 다시 통합하기 전에 내역을 확인하세요.",
|
||||
"merge_title": "주소로 통합",
|
||||
"merge_wait_locked": "지갑이 잠겨 있습니다. 계속하려면 잠금을 해제하세요.",
|
||||
"merge_waiting_node": "노드를 기다리는 중…",
|
||||
"merge_where": "자금을 어디로 보낼까요?",
|
||||
"mig_adopt": "이 지갑을 내 지갑으로 사용",
|
||||
"mig_adopt_now": "시드 지갑 사용",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "모션 줄이기",
|
||||
"refresh": "새로고침",
|
||||
"refresh_now": "지금 새로고침",
|
||||
"refresh_waits_for_sync": "노드가 아직 동기화 중입니다. 동기화가 끝나면 지갑 데이터가 새로고침됩니다.",
|
||||
"remove_favorite": "즐겨찾기 제거",
|
||||
"repair_wallet": "지갑 복구",
|
||||
"report_bug": "버그 신고",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "노드가 부트스트랩되어 스냅샷 이전 블록은 디스크에 없으므로 제네시스부터의 재스캔은 실패합니다. 지갑의 사용된 잔액을 조정하려면 스냅샷에 포함된 높이부터 재스캔하세요. wallet.dat과 체인 데이터는 삭제되지 않습니다.",
|
||||
"rescan_detecting": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하는 중…",
|
||||
"rescan_from_height": "재스캔 시작 블록 높이:",
|
||||
"rescan_probe_failed": "노드가 디스크에 어떤 블록을 가지고 있는지 확인하지 못했습니다(노드가 사용 중이거나 요청 시간이 초과되었습니다). 이 노드가 부트스트랩된 경우 취소하고 1분 후에 다시 시도하세요. 부트스트랩된 노드에서는 아래의 -rescan 재시작이 실패합니다.",
|
||||
"reset_to_defaults": "기본값으로 재설정",
|
||||
"restarting_after_encryption": "암호화 후 데몬 재시작 중...",
|
||||
"restore_address": "주소 복원",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "스크린샷 스윕 실행",
|
||||
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
|
||||
"screenshot_sweep_full": "전체 UI 스윕",
|
||||
"screenshot_sweep_refused": "전체 UI 스윕이 거부되었습니다. 디버그 로그를 확인하세요",
|
||||
"search_icons": "아이콘 검색...",
|
||||
"search_placeholder": "검색...",
|
||||
"sec_changing_passphrase": "암호문 변경 중...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " 암호문",
|
||||
"sec_not_connected_to_daemon": "데몬에 연결되지 않음",
|
||||
"sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음",
|
||||
"sec_passphrase_changed_pin_stale": "암호문이 변경되었습니다. PIN이 오래되었습니다",
|
||||
"sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다",
|
||||
"sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다",
|
||||
"sec_pin_removed": "PIN이 제거되었습니다",
|
||||
"sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다",
|
||||
"sec_pin_stale_msg": "PIN이 오래되었습니다. 암호문을 사용하세요.",
|
||||
"sec_pin_unreadable": "PIN 파일을 읽을 수 없습니다. 암호문을 사용하세요.",
|
||||
"sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.",
|
||||
"sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...",
|
||||
"sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초",
|
||||
"sec_unlock_button": "잠금 해제",
|
||||
"sec_unlock_failed_prefix": "잠금 해제 실패: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "잠금 해제 중%s",
|
||||
"sec_use_passphrase_instead": "대신 암호문 사용",
|
||||
"sec_use_pin_instead": "대신 PIN 사용",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "로그 폴더 열기",
|
||||
"settings_other": "기타",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN이 오래됨",
|
||||
"settings_privacy": "개인 정보",
|
||||
"settings_quick_unlock_pin": "빠른 잠금 해제 PIN",
|
||||
"settings_reduce_transparency": "투명도 줄이기",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "최신순",
|
||||
"sort_date_oldest": "오래된 순",
|
||||
"start_mining": "채굴 시작",
|
||||
"startup_files_preparing": "지갑 파일 준비 중... %d%%",
|
||||
"startup_files_verifying": "지갑 파일 확인 중...",
|
||||
"status": "상태",
|
||||
"stop_external": "외부 데몬 중지",
|
||||
"stop_mining": "채굴 중지",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
|
||||
"stopconfirm_rebuild_title": "노드가 노트 위트니스를 재구성하고 있습니다",
|
||||
"stopconfirm_stop_wait": "노드 중지 후 대기",
|
||||
"stopconfirm_walletop_body": "노드가 키 가져오기 또는 재스캔을 위해 체인을 스캔하고 있습니다. 지금 중지하면 스캔이 일찍 끝나고, 아직 스캔하지 못한 자금은 설정 → 재스캔 후에만 표시됩니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 스캔을 마칩니다.",
|
||||
"stopconfirm_walletop_title": "키 가져오기 또는 재스캔이 실행 중입니다",
|
||||
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
|
||||
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
|
||||
"stratum_host": "마이닝 풀 호스팅 (stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "도구",
|
||||
"tools_actions": "도구 및 작업...",
|
||||
"tools_actions_hdr": "도구 및 작업",
|
||||
"tor_never_applied_notice": "\"Tor 사용\" 옵션은 한 번도 작동하지 않았습니다. 연결이 Tor를 통해 라우팅되지 않았습니다. 이 옵션은 꺼졌습니다. 대안은 설정에서 이 옵션의 설명을 참고하세요.",
|
||||
"total": "합계",
|
||||
"total_balance_label": "총 잔액",
|
||||
"transaction_id": "거래 ID",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다",
|
||||
"tt_language": "지갑 UI 인터페이스 언어",
|
||||
"tt_layout_hotkey": "단축키: 좌/우 화살표 키로 잔액 레이아웃 전환",
|
||||
"tt_lite_confirm_pass": "새 암호를 한 번 더 입력하세요. 일치해야 하며 8자 이상이어야 합니다",
|
||||
"tt_lite_copy": "표시된 비밀을 클립보드에 복사합니다",
|
||||
"tt_lite_decrypt_pass": "지갑에서 암호화를 제거하려면 암호를 입력하세요",
|
||||
"tt_lite_encrypt": "위 암호로 지갑을 암호화합니다; 즉시 잠기며 잠금 해제하려면 암호가 필요합니다",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
|
||||
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
|
||||
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
|
||||
"tt_pin_out_of_date": "이 PIN을 설정한 후 지갑 암호문이 변경되어 PIN으로 더 이상 지갑을 잠금 해제할 수 없습니다. PIN을 제거한 뒤 다시 설정하세요.",
|
||||
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
|
||||
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
|
||||
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "테마별 반짝임, 글로우, 색조 순환",
|
||||
"tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환",
|
||||
"tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅",
|
||||
"tt_tor_unavailable": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 노드에 한해 DRAGONX.conf에 proxy=127.0.0.1:9050을 추가하고 노드를 재시작할 수 있습니다. Tor가 실행 중이어야 하며, 그렇지 않으면 노드가 피어를 찾지 못합니다. 가격, 업데이트, 부트스트랩, 풀 요청은 계속 직접 연결됩니다. 이러한 요청에는 시스템 전체 VPN/Tor를 사용하세요.",
|
||||
"tt_tor_unavailable_lite": "아직 사용할 수 없음: 이 지갑은 어떤 통신도 Tor를 통해 라우팅하지 않습니다. 시스템 전체 VPN/Tor를 사용하세요.",
|
||||
"tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL",
|
||||
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
|
||||
"tt_validate": "DragonX 주소가 유효한지 확인",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "거래 새로고침 대기 중",
|
||||
"tx_loading_refreshing_cached": "지갑 내역을 새로고침하는 중 (%d개 캐시됨)",
|
||||
"tx_loading_scanning_shielded": "차폐 내역 스캔 중 (%d개 주소)",
|
||||
"tx_loading_waits_for_sync": "노드 동기화가 끝나면 내역이 업데이트됩니다",
|
||||
"tx_mined": "채굴됨",
|
||||
"tx_progress_balances": "잔액을 새로 고치는 중",
|
||||
"tx_progress_finalizing": "트랜잭션 마무리 중",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "폴더 열기",
|
||||
"wallets_scan_folder": "다른 폴더에서 지갑 검색…",
|
||||
"wallets_scanned_folders": "스캔한 폴더:",
|
||||
"wallets_scanning": "폴더 검색 중…",
|
||||
"wallets_sort_addresses": "주소",
|
||||
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
|
||||
"wallets_sort_by": "정렬:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.",
|
||||
"appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído",
|
||||
"appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Não foi possível reconciliar sua carteira com os dados da chain do bootstrap: o nó ficou ocupado demais para verificar quais blocos tem. Execute Reescanear nos Ajustes quando ele estiver em dia.",
|
||||
"appx_cancel": "Cancelar",
|
||||
"appx_cleaning_up": "Limpando...",
|
||||
"appx_confirm_your_backup": "Confirme seu backup",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "saída do dragonxd",
|
||||
"appx_encrypting_wallet": "Criptografando a carteira...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite",
|
||||
"appx_import_cancelled": "Cancelado: o nó ainda pode estar escaneando. Se os fundos da chave não aparecerem, execute Ajustes → Reescanear.",
|
||||
"appx_import_interrupted": "A importação foi interrompida, então a chave pode já estar na carteira. Se os fundos dela não aparecerem, execute Ajustes → Reescanear.",
|
||||
"appx_import_refused": "O nó recusou a solicitação, então a chave não foi importada.",
|
||||
"appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...",
|
||||
"appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ",
|
||||
"appx_ive_written_it_down": "Já anotei",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...",
|
||||
"appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.",
|
||||
"appx_wallet_op_node_busy": "O nó está ocupado e não responde. Tente a importação ou o reescaneamento novamente quando terminar.",
|
||||
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
|
||||
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
|
||||
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
|
||||
"auto_shield": "Auto-blindar mineração",
|
||||
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "Confirmar nova:",
|
||||
"change_pass_current": "Senha atual:",
|
||||
"change_pass_new": "Nova senha:",
|
||||
"change_pass_pin_note": "Esta carteira tem um PIN de desbloqueio rápido. Após a alteração ele ficará desatualizado: remova o PIN em Ajustes e defina-o novamente.",
|
||||
"change_pass_title": "Alterar senha",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
|
||||
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
|
||||
"lite_birthday_label": "Data de nascimento",
|
||||
"lite_confirm_label": "Confirmar",
|
||||
"lite_console_backend_commands": "Comandos do backend:",
|
||||
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "Não foi possível iniciar a operação",
|
||||
"lite_could_not_write": "Não foi possível gravar ",
|
||||
"lite_encrypt_offline_warning": "Qualquer pessoa com o arquivo da carteira pode testar frases-passe offline. Faça primeiro o backup da sua frase de recuperação e use uma frase-passe longa.",
|
||||
"lite_encrypt_wallet": "Criptografar carteira",
|
||||
"lite_encryption_removed": "Criptografia removida",
|
||||
"lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "Ocultar e apagar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar chave",
|
||||
"lite_key_import_failed": "Não foi possível importar a chave — verifique-a, desbloqueie a carteira (se estiver criptografada) e tente novamente.",
|
||||
"lite_key_imported": "Chave importada — execute uma sincronização para escanear seu histórico",
|
||||
"lite_lock_failed": "Falha ao bloquear",
|
||||
"lite_lock_now": "Bloquear agora",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s — falhou: %s",
|
||||
"merge_keep_each_status_not_started": "%s — não iniciado: %s",
|
||||
"merge_keep_each_status_running": "%s — mesclando %d notas (%d de %d lotes concluídos)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — não foi possível confirmar o status",
|
||||
"merge_keep_each_too_small": "Pequeno demais para consolidar: a taxa consumiria esses fundos.",
|
||||
"merge_max_inputs": "Máx. de entradas por lote",
|
||||
"merge_no_spendable": "Ainda não há entradas disponíveis para consolidar.",
|
||||
"merge_progress": "Consolidando… isso pode levar alguns minutos. Você pode fechar esta janela.",
|
||||
"merge_run_aborted_toast": "A conexão com o nó foi redefinida, então a consolidação parou. Execute-a novamente para continuar.",
|
||||
"merge_run_busy": "Uma consolidação está em andamento. Aguarde que termine ou interrompa-a primeiro.",
|
||||
"merge_run_chip_fmt": "Consolidando %d/%d",
|
||||
"merge_run_err_timeout": "O nó não respondeu a tempo; o lote ainda pode estar na fila.",
|
||||
"merge_run_halted_fmt": "Consolidação interrompida após falhas repetidas: %s",
|
||||
"merge_run_migration_pending": "Conclua ou cancele a migração da semente antes de consolidar.",
|
||||
"merge_run_progress_fmt": "Consolidando… lote %d de cerca de %d, %d notas mescladas até agora. Você pode fechar esta janela; a operação continua em segundo plano.",
|
||||
"merge_run_start_failed": "Não foi possível iniciar a consolidação — o nó não está conectado.",
|
||||
"merge_run_status_not_run": "%s — não consolidado (%d notas)",
|
||||
"merge_run_status_waiting": "%s — aguardando (%d notas)",
|
||||
"merge_run_stop": "Parar",
|
||||
"merge_run_stopped_toast": "Consolidação interrompida. Execute-a novamente para mesclar o restante.",
|
||||
"merge_run_stopping": "Parando após os lotes já enviados…",
|
||||
"merge_run_wait_batch": "Espaçando os lotes para que o nó continue respondendo…",
|
||||
"merge_run_wait_send": "Pausado até que sua transação pendente seja concluída.",
|
||||
"merge_run_wait_sync": "Pausado: o nó ainda está sincronizando.",
|
||||
"merge_scope_failed_fmt": "Não foi possível ler suas entradas: %s",
|
||||
"merge_scope_fmt": "%d entradas transparentes + %d blindadas · ~%s DRGX disponíveis",
|
||||
"merge_scope_loading": "Verificando suas entradas…",
|
||||
"merge_send_failed": "Falha na fusão: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "Blindado",
|
||||
"merge_src_transparent": "Transparente",
|
||||
"merge_started": "Operação de fusão iniciada",
|
||||
"merge_submit_uncertain": "O nó não confirmou a fusão antes de a conexão ser interrompida. Ela ainda pode ser concluída — verifique o Histórico após reconectar antes de fundir novamente.",
|
||||
"merge_title": "Fundir para Endereço",
|
||||
"merge_wait_locked": "A carteira está bloqueada. Desbloqueie-a para continuar.",
|
||||
"merge_waiting_node": "Aguardando o nó…",
|
||||
"merge_where": "Para onde devem ir os fundos?",
|
||||
"mig_adopt": "Tornar esta minha carteira",
|
||||
"mig_adopt_now": "Adotar carteira semente",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "Reduzir Movimento",
|
||||
"refresh": "Atualizar",
|
||||
"refresh_now": "Atualizar Agora",
|
||||
"refresh_waits_for_sync": "O nó ainda está sincronizando. Os dados da carteira serão atualizados quando terminar.",
|
||||
"remove_favorite": "Remover favorito",
|
||||
"repair_wallet": "Reparar Carteira",
|
||||
"report_bug": "Reportar Bug",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "Seu nó foi inicializado por bootstrap, então os blocos abaixo do snapshot não estão em disco e um reescaneamento a partir do gênesis falharia. Reescaneie a partir de uma altura que seu snapshot inclua para reconciliar o saldo gasto da sua carteira. Seu wallet.dat e os dados da cadeia não são excluídos.",
|
||||
"rescan_detecting": "Verificando quais blocos seu nó tem em disco…",
|
||||
"rescan_from_height": "Reescanear a partir da altura do bloco:",
|
||||
"rescan_probe_failed": "Não foi possível verificar quais blocos seu nó tem em disco (ele está ocupado ou uma solicitação expirou). Se este nó foi inicializado por bootstrap, cancele e tente novamente em um minuto: a reinicialização completa com -rescan abaixo falha em um nó inicializado por bootstrap.",
|
||||
"reset_to_defaults": "Redefinir Padrões",
|
||||
"restarting_after_encryption": "Reiniciando daemon após criptografia...",
|
||||
"restore_address": "Restaurar endereço",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "Executar varredura de capturas de tela",
|
||||
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
|
||||
"screenshot_sweep_full": "Varredura completa da interface",
|
||||
"screenshot_sweep_refused": "Varredura completa da interface recusada; veja o log de depuração",
|
||||
"search_icons": "Pesquisar ícones...",
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"sec_changing_passphrase": "Alterando a frase-senha...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " Frase-senha",
|
||||
"sec_not_connected_to_daemon": "Não conectado ao daemon",
|
||||
"sec_not_connected_to_daemon_pin": "Não conectado ao daemon",
|
||||
"sec_passphrase_changed_pin_stale": "Frase-senha alterada; seu PIN está desatualizado",
|
||||
"sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso",
|
||||
"sec_pin_changed_successfully": "PIN alterado com sucesso",
|
||||
"sec_pin_removed": "PIN removido",
|
||||
"sec_pin_set_successfully": "PIN definido com sucesso",
|
||||
"sec_pin_stale_msg": "PIN desatualizado. Use sua frase-senha.",
|
||||
"sec_pin_unreadable": "Arquivo do PIN ilegível. Use sua frase-senha.",
|
||||
"sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.",
|
||||
"sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...",
|
||||
"sec_total_elapsed_fmt": "Tempo total: %dm %02ds",
|
||||
"sec_unlock_button": "Desbloquear",
|
||||
"sec_unlock_failed_prefix": "Falha ao desbloquear: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Desbloqueando%s",
|
||||
"sec_use_passphrase_instead": "Usar frase-senha",
|
||||
"sec_use_pin_instead": "Usar PIN",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "Abrir pasta de logs",
|
||||
"settings_other": "Outros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN desatualizado",
|
||||
"settings_privacy": "Privacidade",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueio rápido",
|
||||
"settings_reduce_transparency": "Reduzir transparência",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "Mais recentes primeiro",
|
||||
"sort_date_oldest": "Mais antigas primeiro",
|
||||
"start_mining": "Iniciar Mineração",
|
||||
"startup_files_preparing": "Preparando os arquivos da carteira... %d%%",
|
||||
"startup_files_verifying": "Verificando os arquivos da carteira...",
|
||||
"status": "Status",
|
||||
"stop_external": "Parar daemon externo",
|
||||
"stop_mining": "Parar Mineração",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "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.",
|
||||
"stopconfirm_rebuild_title": "O nó está reconstruindo as testemunhas de notas",
|
||||
"stopconfirm_stop_wait": "Parar o nó e aguardar",
|
||||
"stopconfirm_walletop_body": "O nó está escaneando a cadeia para uma importação de chave ou um reescaneamento. Pará-lo agora encerra esse escaneamento antes do tempo, e os fundos que ele ainda não alcançou só aparecerão depois de Ajustes → Reescanear.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina o escaneamento sozinho.",
|
||||
"stopconfirm_walletop_title": "Uma importação de chave ou um reescaneamento está em andamento",
|
||||
"stratum_allowip_hint": "Permitir mineradores a partir de IP ou CIDR (em branco = apenas este computador)",
|
||||
"stratum_expose_warn": "Abre uma porta de mineração para a rede que você permitir — use apenas em uma LAN confiável.",
|
||||
"stratum_host": "Hospedar um pool de mineração (stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "FERRAMENTAS",
|
||||
"tools_actions": "Ferramentas e Ações...",
|
||||
"tools_actions_hdr": "FERRAMENTAS E AÇÕES",
|
||||
"tor_never_applied_notice": "A opção \"Usar Tor\" nunca funcionou: suas conexões NÃO foram roteadas pelo Tor. Ela foi desativada. Veja a dica dela em Ajustes para alternativas.",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DA TRANSAÇÃO",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração",
|
||||
"tt_language": "Idioma da interface da carteira",
|
||||
"tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo",
|
||||
"tt_lite_confirm_pass": "Digite a nova frase-passe novamente; ela deve coincidir e ter pelo menos 8 caracteres",
|
||||
"tt_lite_copy": "Copiar o segredo revelado para a área de transferência",
|
||||
"tt_lite_decrypt_pass": "Digite sua frase-senha para remover a criptografia da carteira",
|
||||
"tt_lite_encrypt": "Criptografar a carteira com a frase-senha acima; ela é bloqueada imediatamente e exige a frase-senha para desbloquear",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
|
||||
"tt_open_dir": "Clique para abrir no explorador de arquivos",
|
||||
"tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
|
||||
"tt_pin_out_of_date": "A frase-senha da carteira mudou depois que este PIN foi definido, então o PIN não consegue mais desbloqueá-la. Remova o PIN e defina-o novamente.",
|
||||
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
|
||||
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
|
||||
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "Brilho, luminescência, ciclo de matiz por tema",
|
||||
"tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas",
|
||||
"tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato",
|
||||
"tt_tor_unavailable": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Apenas para o nó, você pode adicionar proxy=127.0.0.1:9050 ao DRAGONX.conf e reiniciar o nó. O Tor precisa estar em execução, ou o nó não encontrará pares. Solicitações de preço, atualização, bootstrap e pool continuam se conectando diretamente; para elas, use uma VPN/Tor em todo o sistema.",
|
||||
"tt_tor_unavailable_lite": "Ainda não disponível: esta carteira não encaminha nenhum tráfego pelo Tor. Use uma VPN/Tor em todo o sistema.",
|
||||
"tt_tx_url": "URL base para visualizar transações em um explorador de blocos",
|
||||
"tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)",
|
||||
"tt_validate": "Verificar se um endereço DragonX é válido",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "Atualização de transação na fila",
|
||||
"tx_loading_refreshing_cached": "Atualizando histórico da carteira (%d em cache)",
|
||||
"tx_loading_scanning_shielded": "Verificando histórico blindado (%d endereços)",
|
||||
"tx_loading_waits_for_sync": "O histórico será atualizado quando o nó terminar de sincronizar",
|
||||
"tx_mined": "MINERADO",
|
||||
"tx_progress_balances": "Atualizando saldos",
|
||||
"tx_progress_finalizing": "Finalizando transação",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "Mostrar pasta",
|
||||
"wallets_scan_folder": "Escanear outra pasta em busca de carteiras…",
|
||||
"wallets_scanned_folders": "Pastas verificadas:",
|
||||
"wallets_scanning": "Procurando nas pastas…",
|
||||
"wallets_sort_addresses": "Endereços",
|
||||
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
|
||||
"wallets_sort_by": "Ordenar:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.",
|
||||
"appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено",
|
||||
"appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.",
|
||||
"appx_bootstrap_reconcile_probe_failed": "Не удалось сверить кошелёк с данными цепи из бутстрапа: узел был слишком занят, чтобы проверить свои блоки. Запустите Пересканировать в Настройках, когда узел догонит сеть.",
|
||||
"appx_cancel": "Отмена",
|
||||
"appx_cleaning_up": "Очистка...",
|
||||
"appx_confirm_your_backup": "Подтвердите резервную копию",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "вывод dragonxd",
|
||||
"appx_encrypting_wallet": "Шифрование кошелька...",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке",
|
||||
"appx_import_cancelled": "Отменено: узел, возможно, ещё сканирует. Если средства ключа не появятся, запустите Настройки → Пересканировать.",
|
||||
"appx_import_interrupted": "Импорт был прерван, поэтому ключ, возможно, уже в кошельке. Если его средства не появятся, запустите Настройки → Пересканировать.",
|
||||
"appx_import_refused": "Узел отклонил запрос, поэтому ключ не был импортирован.",
|
||||
"appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...",
|
||||
"appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ",
|
||||
"appx_ive_written_it_down": "Я записал её",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...",
|
||||
"appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.",
|
||||
"appx_wallet_op_node_busy": "Узел занят и не отвечает. Повторите импорт или повторное сканирование, когда он освободится.",
|
||||
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
|
||||
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
|
||||
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
|
||||
"auto_shield": "Авто-экранирование майнинга",
|
||||
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "Подтвердите новый:",
|
||||
"change_pass_current": "Текущий пароль:",
|
||||
"change_pass_new": "Новый пароль:",
|
||||
"change_pass_pin_note": "У этого кошелька есть PIN для быстрой разблокировки. После смены он устареет: удалите PIN в настройках и установите его заново.",
|
||||
"change_pass_title": "Сменить пароль",
|
||||
"characters": "символов",
|
||||
"chat": "Чат",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
|
||||
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
|
||||
"lite_birthday_label": "Дата рождения",
|
||||
"lite_confirm_label": "Повтор",
|
||||
"lite_console_backend_commands": "Команды бэкенда:",
|
||||
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
|
||||
"lite_copy": "Копировать",
|
||||
"lite_could_not_start": "Не удалось запустить операцию",
|
||||
"lite_could_not_write": "Не удалось записать ",
|
||||
"lite_encrypt_offline_warning": "Любой, у кого есть файл кошелька, может подбирать парольные фразы офлайн. Сначала сохраните сид-фразу и используйте длинную парольную фразу.",
|
||||
"lite_encrypt_wallet": "Зашифровать кошелёк",
|
||||
"lite_encryption_removed": "Шифрование удалено",
|
||||
"lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "Скрыть и стереть",
|
||||
"lite_import": "Импорт",
|
||||
"lite_import_key_label": "Импортировать ключ",
|
||||
"lite_key_import_failed": "Не удалось импортировать ключ — проверьте его, разблокируйте кошелёк (если зашифрован) и повторите попытку.",
|
||||
"lite_key_imported": "Ключ импортирован — выполните синхронизацию, чтобы просканировать его историю",
|
||||
"lite_lock_failed": "Не удалось заблокировать",
|
||||
"lite_lock_now": "Заблокировать сейчас",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s — ошибка: %s",
|
||||
"merge_keep_each_status_not_started": "%s — не запущено: %s",
|
||||
"merge_keep_each_status_running": "%s — объединение %d заметок (готово партий: %d из %d)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s — статус не удалось подтвердить",
|
||||
"merge_keep_each_too_small": "Слишком мало для объединения: комиссия съест эти средства.",
|
||||
"merge_max_inputs": "Макс. входов за партию",
|
||||
"merge_no_spendable": "Пока нет доступных для расходования входов для объединения.",
|
||||
"merge_progress": "Объединение… это может занять несколько минут. Вы можете закрыть это окно.",
|
||||
"merge_run_aborted_toast": "Соединение с узлом было сброшено, поэтому объединение остановлено. Запустите его снова, чтобы продолжить.",
|
||||
"merge_run_busy": "Идёт объединение. Дождитесь его завершения или сначала остановите его.",
|
||||
"merge_run_chip_fmt": "Объединение %d/%d",
|
||||
"merge_run_err_timeout": "Узел не ответил вовремя; партия, возможно, всё ещё в очереди.",
|
||||
"merge_run_halted_fmt": "Объединение остановлено после повторных сбоев: %s",
|
||||
"merge_run_migration_pending": "Завершите или отмените перенос сид-фразы перед объединением.",
|
||||
"merge_run_progress_fmt": "Объединение… партия %d из примерно %d, объединено заметок: %d. Это окно можно закрыть — операция продолжится в фоне.",
|
||||
"merge_run_start_failed": "Не удалось начать объединение — узел не подключён.",
|
||||
"merge_run_status_not_run": "%s — не объединено (%d заметок)",
|
||||
"merge_run_status_waiting": "%s — ожидание (%d заметок)",
|
||||
"merge_run_stop": "Остановить",
|
||||
"merge_run_stopped_toast": "Объединение остановлено. Запустите его снова, чтобы объединить остальное.",
|
||||
"merge_run_stopping": "Остановка после уже отправленных партий…",
|
||||
"merge_run_wait_batch": "Партии отправляются с паузами, чтобы узел оставался отзывчивым…",
|
||||
"merge_run_wait_send": "Приостановлено до завершения вашей ожидающей транзакции.",
|
||||
"merge_run_wait_sync": "Приостановлено: узел ещё синхронизируется.",
|
||||
"merge_scope_failed_fmt": "Не удалось прочитать ваши входы: %s",
|
||||
"merge_scope_fmt": "%d прозрачных + %d защищённых входов · ~%s DRGX доступно",
|
||||
"merge_scope_loading": "Проверка ваших входов…",
|
||||
"merge_send_failed": "Ошибка объединения: ",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "Защищённые",
|
||||
"merge_src_transparent": "Прозрачные",
|
||||
"merge_started": "Операция объединения начата",
|
||||
"merge_submit_uncertain": "Узел не подтвердил объединение до разрыва соединения. Оно всё ещё может выполниться — после переподключения проверьте Историю, прежде чем объединять снова.",
|
||||
"merge_title": "Объединить на адрес",
|
||||
"merge_wait_locked": "Кошелёк заблокирован. Разблокируйте его, чтобы продолжить.",
|
||||
"merge_waiting_node": "Ожидание узла…",
|
||||
"merge_where": "Куда направить средства?",
|
||||
"mig_adopt": "Сделать это моим кошельком",
|
||||
"mig_adopt_now": "Принять сид-кошелёк",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "Уменьшить анимацию",
|
||||
"refresh": "Обновить",
|
||||
"refresh_now": "Обновить сейчас",
|
||||
"refresh_waits_for_sync": "Узел ещё синхронизируется. Данные кошелька обновятся после её завершения.",
|
||||
"remove_favorite": "Удалить из избранного",
|
||||
"repair_wallet": "Восстановить кошелёк",
|
||||
"report_bug": "Сообщить об ошибке",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "Ваш узел был инициализирован из бутстрапа, поэтому блоки ниже снимка отсутствуют на диске, и пересканирование с генезиса не удастся. Пересканируйте с высоты, входящей в ваш снимок, чтобы согласовать потраченный баланс кошелька. Ваш wallet.dat и данные блокчейна не удаляются.",
|
||||
"rescan_detecting": "Проверка, какие блоки уже есть на диске вашего узла…",
|
||||
"rescan_from_height": "Пересканировать с высоты блока:",
|
||||
"rescan_probe_failed": "Не удалось проверить, какие блоки есть на диске вашего узла (он занят или истекло время ожидания запроса). Если узел был инициализирован из бутстрапа, отмените и повторите попытку через минуту: полный перезапуск с -rescan ниже на таком узле не удастся.",
|
||||
"reset_to_defaults": "Сбросить настройки",
|
||||
"restarting_after_encryption": "Перезапуск демона после шифрования...",
|
||||
"restore_address": "Восстановить адрес",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "Запустить прогон скриншотов",
|
||||
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
|
||||
"screenshot_sweep_full": "Полный обход интерфейса",
|
||||
"screenshot_sweep_refused": "Полный обход интерфейса отклонён; см. журнал отладки",
|
||||
"search_icons": "Поиск значков...",
|
||||
"search_placeholder": "Поиск...",
|
||||
"sec_changing_passphrase": "Смена парольной фразы...",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " Парольная фраза",
|
||||
"sec_not_connected_to_daemon": "Нет соединения с демоном",
|
||||
"sec_not_connected_to_daemon_pin": "Нет соединения с демоном",
|
||||
"sec_passphrase_changed_pin_stale": "Парольная фраза изменена; PIN устарел",
|
||||
"sec_passphrase_changed_successfully": "Парольная фраза успешно изменена",
|
||||
"sec_pin_changed_successfully": "PIN успешно изменён",
|
||||
"sec_pin_removed": "PIN удалён",
|
||||
"sec_pin_set_successfully": "PIN успешно задан",
|
||||
"sec_pin_stale_msg": "PIN устарел. Используйте парольную фразу.",
|
||||
"sec_pin_unreadable": "Файл PIN не читается. Используйте парольную фразу.",
|
||||
"sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.",
|
||||
"sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...",
|
||||
"sec_total_elapsed_fmt": "Всего прошло: %dм %02dс",
|
||||
"sec_unlock_button": "Разблокировать",
|
||||
"sec_unlock_failed_prefix": "Ошибка разблокировки: ",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "Разблокировка%s",
|
||||
"sec_use_passphrase_instead": "Использовать парольную фразу",
|
||||
"sec_use_pin_instead": "Использовать PIN",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "Открыть папку журналов",
|
||||
"settings_other": "Прочее",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN устарел",
|
||||
"settings_privacy": "Конфиденциальность",
|
||||
"settings_quick_unlock_pin": "Быстрый PIN-код разблокировки",
|
||||
"settings_reduce_transparency": "Уменьшить прозрачность",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "Сначала новые",
|
||||
"sort_date_oldest": "Сначала старые",
|
||||
"start_mining": "Начать майнинг",
|
||||
"startup_files_preparing": "Подготовка файлов кошелька... %d%%",
|
||||
"startup_files_verifying": "Проверка файлов кошелька...",
|
||||
"status": "Статус",
|
||||
"stop_external": "Остановить внешний daemon",
|
||||
"stop_mining": "Остановить майнинг",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"stopconfirm_rebuild_title": "Узел перестраивает свидетелей заметок",
|
||||
"stopconfirm_stop_wait": "Остановить узел и подождать",
|
||||
"stopconfirm_walletop_body": "Узел сканирует цепочку для импорта ключа или повторного сканирования. Если остановить его сейчас, это сканирование закончится раньше, а средства, до которых оно ещё не дошло, появятся только после Настройки → Пересканировать.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит сканирование сам.",
|
||||
"stopconfirm_walletop_title": "Выполняется импорт ключа или повторное сканирование",
|
||||
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
|
||||
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
|
||||
"stratum_host": "Разместить майнинг-пул (stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "УТИЛИТЫ",
|
||||
"tools_actions": "Инструменты и действия...",
|
||||
"tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ",
|
||||
"tor_never_applied_notice": "Опция «Использовать Tor» никогда не работала: ваши соединения НЕ направлялись через Tor. Она отключена. Альтернативы описаны в её подсказке в Настройках.",
|
||||
"total": "Итого",
|
||||
"total_balance_label": "Общий баланс",
|
||||
"transaction_id": "ID ТРАНЗАКЦИИ",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки",
|
||||
"tt_language": "Язык интерфейса кошелька",
|
||||
"tt_layout_hotkey": "Горячая клавиша: стрелки влево/вправо для переключения раскладок Баланса",
|
||||
"tt_lite_confirm_pass": "Введите новую парольную фразу ещё раз; она должна совпадать и содержать не менее 8 символов",
|
||||
"tt_lite_copy": "Скопировать показанный секрет в буфер обмена",
|
||||
"tt_lite_decrypt_pass": "Введите пароль, чтобы снять шифрование с кошелька",
|
||||
"tt_lite_encrypt": "Зашифровать кошелёк паролем выше; он блокируется сразу же и требует пароль для разблокировки",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
|
||||
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
|
||||
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
|
||||
"tt_pin_out_of_date": "Парольная фраза кошелька изменилась после установки этого PIN, поэтому PIN больше не разблокирует кошелёк. Удалите PIN и установите его заново.",
|
||||
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
|
||||
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
|
||||
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "Мерцание, свечение, циклическая смена оттенка по теме",
|
||||
"tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем",
|
||||
"tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности",
|
||||
"tt_tor_unavailable": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Только для узла можно добавить proxy=127.0.0.1:9050 в DRAGONX.conf и перезапустить узел. Tor должен быть запущен, иначе узел не найдёт других узлов. Запросы цен, обновлений, bootstrap и пула по-прежнему идут напрямую; для них используйте общесистемный VPN/Tor.",
|
||||
"tt_tor_unavailable_lite": "Пока недоступно: этот кошелёк не направляет никакой трафик через Tor. Используйте общесистемный VPN/Tor.",
|
||||
"tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков",
|
||||
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
|
||||
"tt_validate": "Проверить, действителен ли адрес DragonX",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "Обновление транзакций поставлено в очередь",
|
||||
"tx_loading_refreshing_cached": "Обновление истории кошелька (в кеше: %d)",
|
||||
"tx_loading_scanning_shielded": "Сканирование экранированной истории (%d адресов)",
|
||||
"tx_loading_waits_for_sync": "История обновится, когда узел завершит синхронизацию",
|
||||
"tx_mined": "ДОБЫТА",
|
||||
"tx_progress_balances": "Обновление балансов",
|
||||
"tx_progress_finalizing": "Завершение транзакции",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "Открыть папку",
|
||||
"wallets_scan_folder": "Сканировать другую папку на наличие кошельков…",
|
||||
"wallets_scanned_folders": "Просканированные папки:",
|
||||
"wallets_scanning": "Поиск в папках…",
|
||||
"wallets_sort_addresses": "Адреса",
|
||||
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
|
||||
"wallets_sort_by": "Сортировка:",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。",
|
||||
"appx_blockchain_rescan_complete": "区块链重新扫描完成",
|
||||
"appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。",
|
||||
"appx_bootstrap_reconcile_probe_failed": "无法将你的钱包与引导数据的链数据进行核对:节点一直太忙,无法检查其已有的区块。待节点同步完成后,请在设置中运行重新扫描。",
|
||||
"appx_cancel": "取消",
|
||||
"appx_cleaning_up": "正在清理…",
|
||||
"appx_confirm_your_backup": "确认你的备份",
|
||||
@@ -85,6 +86,9 @@
|
||||
"appx_dragonxd_output": "dragonxd 输出",
|
||||
"appx_encrypting_wallet": "正在加密钱包…",
|
||||
"appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用",
|
||||
"appx_import_cancelled": "已取消:节点可能仍在扫描。如果该密钥的资金没有显示,请运行“设置 → 重新扫描”。",
|
||||
"appx_import_interrupted": "导入被中断,该密钥可能已在钱包中。如果其资金没有显示,请运行“设置 → 重新扫描”。",
|
||||
"appx_import_refused": "节点拒绝了请求,因此未导入该密钥。",
|
||||
"appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…",
|
||||
"appx_invalid_payment_uri_prefix": "无效的支付 URI:",
|
||||
"appx_ive_written_it_down": "我已抄写完毕",
|
||||
@@ -133,6 +137,9 @@
|
||||
"appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试",
|
||||
"appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…",
|
||||
"appx_wallet_created_and_backed_up": "钱包已创建并完成备份。",
|
||||
"appx_wallet_op_node_busy": "节点正忙,暂时无法响应。请在完成后重新导入或重新扫描。",
|
||||
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
|
||||
"appx_wallet_op_shutting_down": "钱包正在关闭。",
|
||||
"appx_wallet_open_failed_prefix": "钱包打开失败:",
|
||||
"auto_shield": "自动屏蔽挖矿",
|
||||
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
|
||||
@@ -236,6 +243,7 @@
|
||||
"change_pass_confirm": "确认新密码:",
|
||||
"change_pass_current": "当前密码短语:",
|
||||
"change_pass_new": "新密码短语:",
|
||||
"change_pass_pin_note": "此钱包设有快速解锁 PIN。更改后它将过时:请在设置中移除 PIN 并重新设置。",
|
||||
"change_pass_title": "更改密码短语",
|
||||
"characters": "字符",
|
||||
"chat": "聊天",
|
||||
@@ -1007,11 +1015,13 @@
|
||||
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
|
||||
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。",
|
||||
"lite_birthday_label": "诞生区块",
|
||||
"lite_confirm_label": "确认",
|
||||
"lite_console_backend_commands": "后端命令:",
|
||||
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
|
||||
"lite_copy": "复制",
|
||||
"lite_could_not_start": "无法启动该操作",
|
||||
"lite_could_not_write": "无法写入 ",
|
||||
"lite_encrypt_offline_warning": "任何拿到钱包文件的人都可以离线尝试密码。请先备份助记词,并使用较长的密码短语。",
|
||||
"lite_encrypt_wallet": "加密钱包",
|
||||
"lite_encryption_removed": "已移除加密",
|
||||
"lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
|
||||
@@ -1019,6 +1029,7 @@
|
||||
"lite_hide_wipe": "隐藏并清除",
|
||||
"lite_import": "导入",
|
||||
"lite_import_key_label": "导入密钥",
|
||||
"lite_key_import_failed": "无法导入密钥——请检查密钥,解锁钱包(如已加密)后重试。",
|
||||
"lite_key_imported": "密钥已导入——运行同步以扫描其历史记录",
|
||||
"lite_lock_failed": "锁定失败",
|
||||
"lite_lock_now": "立即锁定",
|
||||
@@ -1178,10 +1189,28 @@
|
||||
"merge_keep_each_status_failed": "%s——失败:%s",
|
||||
"merge_keep_each_status_not_started": "%s——未开始:%s",
|
||||
"merge_keep_each_status_running": "%s——正在合并 %d 张票据(已完成 %d/%d 批)…",
|
||||
"merge_keep_each_status_unconfirmed": "%s——无法确认状态",
|
||||
"merge_keep_each_too_small": "金额太小,无法整合:手续费会耗尽这些资金。",
|
||||
"merge_max_inputs": "每批最大输入数",
|
||||
"merge_no_spendable": "暂无可合并的可用输入。",
|
||||
"merge_progress": "正在合并…这可能需要几分钟。您可以关闭此窗口。",
|
||||
"merge_run_aborted_toast": "节点连接已重置,合并已停止。再次运行以继续。",
|
||||
"merge_run_busy": "正在进行合并。请等待其完成,或先将其停止。",
|
||||
"merge_run_chip_fmt": "合并中 %d/%d",
|
||||
"merge_run_err_timeout": "节点未及时响应;该批次可能仍在队列中。",
|
||||
"merge_run_halted_fmt": "多次失败后合并已停止:%s",
|
||||
"merge_run_migration_pending": "请先完成或取消助记词迁移,再进行合并。",
|
||||
"merge_run_progress_fmt": "正在合并… 第 %d 批(共约 %d 批),已合并 %d 张票据。可以关闭此窗口,合并会在后台继续。",
|
||||
"merge_run_start_failed": "无法开始合并——节点未连接。",
|
||||
"merge_run_status_not_run": "%s——未合并(%d 张票据)",
|
||||
"merge_run_status_waiting": "%s——等待中(%d 张票据)",
|
||||
"merge_run_stop": "停止",
|
||||
"merge_run_stopped_toast": "合并已停止。再次运行以合并其余部分。",
|
||||
"merge_run_stopping": "已发送的批次完成后停止…",
|
||||
"merge_run_wait_batch": "正在间隔各批次,以保持节点响应…",
|
||||
"merge_run_wait_send": "已暂停,等待您的待处理交易完成。",
|
||||
"merge_run_wait_sync": "已暂停:节点仍在同步。",
|
||||
"merge_scope_failed_fmt": "无法读取您的输入:%s",
|
||||
"merge_scope_fmt": "%d 笔透明输入 + %d 笔屏蔽输入 ・ 约 %s DRGX 可用",
|
||||
"merge_scope_loading": "正在检查您的输入…",
|
||||
"merge_send_failed": "合并失败:",
|
||||
@@ -1190,7 +1219,10 @@
|
||||
"merge_src_shielded": "屏蔽",
|
||||
"merge_src_transparent": "透明",
|
||||
"merge_started": "合并操作已开始",
|
||||
"merge_submit_uncertain": "连接中断前节点未确认合并。合并仍可能完成——重新连接后,请先在“历史”中确认,再重新合并。",
|
||||
"merge_title": "合并到地址",
|
||||
"merge_wait_locked": "钱包已锁定。请解锁后继续。",
|
||||
"merge_waiting_node": "正在等待节点…",
|
||||
"merge_where": "资金应转到哪里?",
|
||||
"mig_adopt": "将其设为我的钱包",
|
||||
"mig_adopt_now": "采用助记词钱包",
|
||||
@@ -1556,6 +1588,7 @@
|
||||
"reduce_motion": "减少动画",
|
||||
"refresh": "刷新",
|
||||
"refresh_now": "立即刷新",
|
||||
"refresh_waits_for_sync": "节点仍在同步。同步完成后将刷新钱包数据。",
|
||||
"remove_favorite": "移除收藏",
|
||||
"repair_wallet": "修复钱包",
|
||||
"report_bug": "报告错误",
|
||||
@@ -1575,6 +1608,7 @@
|
||||
"rescan_bootstrapped_msg": "您的节点使用了引导程序,因此快照以下的区块不在磁盘上,从创世区块重新扫描将失败。请从快照所包含的高度开始重新扫描,以核对钱包的已花费余额。您的 wallet.dat 和区块链数据不会被删除。",
|
||||
"rescan_detecting": "正在检查您的节点在磁盘上已有哪些区块…",
|
||||
"rescan_from_height": "从区块高度重新扫描:",
|
||||
"rescan_probe_failed": "无法检查您的节点在磁盘上已有哪些区块(节点正忙或请求超时)。如果此节点使用了引导程序,请取消并在一分钟后重试:在使用引导程序的节点上,下面的 -rescan 重启将失败。",
|
||||
"reset_to_defaults": "重置为默认值",
|
||||
"restarting_after_encryption": "加密后重启守护进程...",
|
||||
"restore_address": "恢复地址",
|
||||
@@ -1639,6 +1673,7 @@
|
||||
"screenshot_sweep": "运行截图批处理",
|
||||
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
|
||||
"screenshot_sweep_full": "完整 UI 遍历",
|
||||
"screenshot_sweep_refused": "完整 UI 遍历被拒绝;请查看调试日志",
|
||||
"search_icons": "搜索图标...",
|
||||
"search_placeholder": "搜索...",
|
||||
"sec_changing_passphrase": "正在更改密码短语…",
|
||||
@@ -1660,15 +1695,19 @@
|
||||
"sec_mode_passphrase": " 密码短语",
|
||||
"sec_not_connected_to_daemon": "未连接到守护进程",
|
||||
"sec_not_connected_to_daemon_pin": "未连接到守护进程",
|
||||
"sec_passphrase_changed_pin_stale": "密码短语已更改;PIN 已过时",
|
||||
"sec_passphrase_changed_successfully": "密码短语更改成功",
|
||||
"sec_pin_changed_successfully": "PIN 更改成功",
|
||||
"sec_pin_removed": "PIN 已移除",
|
||||
"sec_pin_set_successfully": "PIN 设置成功",
|
||||
"sec_pin_stale_msg": "PIN 已过时。请使用密码短语。",
|
||||
"sec_pin_unreadable": "无法读取 PIN 文件。请使用密码短语。",
|
||||
"sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。",
|
||||
"sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…",
|
||||
"sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒",
|
||||
"sec_unlock_button": "解锁",
|
||||
"sec_unlock_failed_prefix": "解锁失败:",
|
||||
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
|
||||
"sec_unlocking_fmt": "正在解锁%s",
|
||||
"sec_use_passphrase_instead": "改用密码短语",
|
||||
"sec_use_pin_instead": "改用 PIN",
|
||||
@@ -1812,6 +1851,7 @@
|
||||
"settings_open_log_folder": "打开日志文件夹",
|
||||
"settings_other": "其他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_pin_out_of_date": "PIN 已过时",
|
||||
"settings_privacy": "隐私",
|
||||
"settings_quick_unlock_pin": "快速解锁 PIN",
|
||||
"settings_reduce_transparency": "降低透明度",
|
||||
@@ -1901,6 +1941,8 @@
|
||||
"sort_date_newest": "最新优先",
|
||||
"sort_date_oldest": "最早优先",
|
||||
"start_mining": "开始挖矿",
|
||||
"startup_files_preparing": "正在准备钱包文件... %d%%",
|
||||
"startup_files_verifying": "正在验证钱包文件...",
|
||||
"status": "状态",
|
||||
"stop_external": "停止外部守护进程",
|
||||
"stop_mining": "停止挖矿",
|
||||
@@ -1909,6 +1951,8 @@
|
||||
"stopconfirm_rebuild_body": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"stopconfirm_rebuild_title": "节点正在重建票据见证",
|
||||
"stopconfirm_stop_wait": "停止节点并等待",
|
||||
"stopconfirm_walletop_body": "节点正在为密钥导入或重新扫描扫描区块链。现在停止它会提前结束这次扫描,尚未扫描到的资金只有在“设置 → 重新扫描”之后才会显示。\n\n建议:关闭钱包并保持节点运行,它会自行完成扫描。",
|
||||
"stopconfirm_walletop_title": "密钥导入或重新扫描正在进行",
|
||||
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
|
||||
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
|
||||
"stratum_host": "托管矿池(stratum)",
|
||||
@@ -1976,6 +2020,7 @@
|
||||
"tools": "工具",
|
||||
"tools_actions": "工具与操作...",
|
||||
"tools_actions_hdr": "工具与操作",
|
||||
"tor_never_applied_notice": "“使用 Tor”选项从未生效:您的连接并未通过 Tor 路由。该选项已关闭。其他方法请参阅设置中该选项的提示。",
|
||||
"total": "合计",
|
||||
"total_balance_label": "总余额",
|
||||
"transaction_id": "交易 ID",
|
||||
@@ -2032,6 +2077,7 @@
|
||||
"tt_keep_daemon": "运行设置向导时守护进程仍会停止",
|
||||
"tt_language": "钱包界面语言",
|
||||
"tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局",
|
||||
"tt_lite_confirm_pass": "再次输入新的密码短语;两次输入必须一致,且至少 8 个字符",
|
||||
"tt_lite_copy": "将显示的机密复制到剪贴板",
|
||||
"tt_lite_decrypt_pass": "输入你的密码以移除钱包的加密",
|
||||
"tt_lite_encrypt": "用上方的密码加密钱包;加密后立即锁定,需要该密码才能解锁",
|
||||
@@ -2066,6 +2112,7 @@
|
||||
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
|
||||
"tt_open_dir": "点击在文件管理器中打开",
|
||||
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
|
||||
"tt_pin_out_of_date": "设置此 PIN 后钱包密码短语已更改,因此该 PIN 无法再解锁钱包。请移除 PIN 并重新设置。",
|
||||
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
|
||||
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
|
||||
"tt_remove_pin": "移除 PIN 并要求密码解锁",
|
||||
@@ -2101,6 +2148,8 @@
|
||||
"tt_theme_effects": "每个主题的闪烁、发光、色调循环",
|
||||
"tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题",
|
||||
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
|
||||
"tt_tor_unavailable": "暂不可用:此钱包不会通过 Tor 路由任何流量。仅就节点而言,您可以在 DRAGONX.conf 中添加 proxy=127.0.0.1:9050 并重启节点。Tor 必须正在运行,否则节点将找不到其他节点。价格、更新、引导和矿池请求仍会直接连接;如需保护这些请求,请使用系统级 VPN/Tor。",
|
||||
"tt_tor_unavailable_lite": "暂不可用:此钱包不会通过 Tor 路由任何流量。请使用系统级 VPN/Tor。",
|
||||
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
|
||||
"tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
|
||||
"tt_validate": "检查 DragonX 地址是否有效",
|
||||
@@ -2122,6 +2171,7 @@
|
||||
"tx_loading_queued": "已排队的交易刷新",
|
||||
"tx_loading_refreshing_cached": "正在刷新钱包历史(已缓存 %d 条)",
|
||||
"tx_loading_scanning_shielded": "正在扫描屏蔽历史(%d 个地址)",
|
||||
"tx_loading_waits_for_sync": "节点同步完成后将更新历史记录",
|
||||
"tx_mined": "已挖得",
|
||||
"tx_progress_balances": "正在刷新余额",
|
||||
"tx_progress_finalizing": "正在完成交易",
|
||||
@@ -2278,6 +2328,7 @@
|
||||
"wallets_reveal": "打开文件夹",
|
||||
"wallets_scan_folder": "扫描其他文件夹以查找钱包…",
|
||||
"wallets_scanned_folders": "已扫描的文件夹:",
|
||||
"wallets_scanning": "正在扫描文件夹…",
|
||||
"wallets_sort_addresses": "地址",
|
||||
"wallets_sort_asc": "升序(最早/最少/最小优先)",
|
||||
"wallets_sort_by": "排序:",
|
||||
|
||||
@@ -700,6 +700,7 @@ status-pill-bg-alpha = { size = 30 }
|
||||
status-pill-y-offset = { size = 1 }
|
||||
confirmed-threshold = { size = 10 }
|
||||
|
||||
# NOTE: [banners.*] and [layout] are parsed as flat sections (kFlatSections in ui_schema.cpp).
|
||||
# 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]
|
||||
@@ -1097,6 +1098,7 @@ fee-input = { width = 150 }
|
||||
utxo-limit-input = { width = 150 }
|
||||
shield-button = { width = 120, font = "button" }
|
||||
cancel-button = { width = 100, font = "button" }
|
||||
list-max-rows = { size = 7 } # address picker / run rows shown before the list scrolls
|
||||
|
||||
[dialogs.request-payment]
|
||||
width = 500.0
|
||||
@@ -1202,6 +1204,16 @@ connection-dot = { opacity = 1.0 }
|
||||
height = 30
|
||||
padding = [8, 0]
|
||||
|
||||
[components.startup-files-chip]
|
||||
width = { size = 340.0 }
|
||||
height = { size = 46.0 }
|
||||
bottom-margin = { size = 38.0 }
|
||||
rounding = { size = 10.0 }
|
||||
padding-x = { size = 14.0 }
|
||||
bar-height = { size = 3.0 }
|
||||
bar-bottom = { size = 10.0 }
|
||||
show-delay-seconds = { size = 0.4 }
|
||||
|
||||
[components.notifications]
|
||||
width = 350.0
|
||||
height = 60.0
|
||||
@@ -1339,6 +1351,10 @@ wallet-btn-padding = { size = 24.0 }
|
||||
rpc-label-min-width = { size = 70.0 }
|
||||
rpc-label-width = { size = 85.0 }
|
||||
security-combo-width = { size = 120.0 }
|
||||
# Wrap width of the Security band's "PIN out of date" tooltip (a long sentence in every language).
|
||||
pin-tooltip-wrap-width = { size = 420.0 }
|
||||
# Wrap width of the disabled "Use Tor" checkbox's tooltip (Settings page and the legacy Settings window).
|
||||
tor-tooltip-wrap-width = { size = 420.0 }
|
||||
node-grid-breakpoint = { size = 900.0 }
|
||||
port-input-min-width = { size = 60.0 }
|
||||
port-input-width-ratio = { size = 0.4 }
|
||||
|
||||
+1096
-420
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,11 @@
|
||||
#include <unordered_set>
|
||||
#include <deque>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/transaction_history_store_policy.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_index.h"
|
||||
#include "data/wallet_state.h"
|
||||
@@ -24,10 +27,23 @@
|
||||
#include "daemon/stop_delivery.h"
|
||||
#include "daemon/daemon_busy_detect.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "rpc/long_call_lane.h"
|
||||
#include "services/history_scan_policy.h"
|
||||
#include "services/connect_scan_gate.h"
|
||||
#include "services/scan_policy.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
#include "services/chat_scan_policy.h"
|
||||
#include "services/note_consolidation.h"
|
||||
#include "services/mining_thread_commit.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "util/async_task_manager.h"
|
||||
#include "resources/provision_state.h"
|
||||
#include "util/background_sampler.h"
|
||||
#include "util/coalescing_writer.h"
|
||||
#include "util/fetch_backoff.h"
|
||||
#include "util/idle_tracker.h"
|
||||
#include "util/pool_stats_service.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "chat/chat_service.h"
|
||||
@@ -114,6 +130,25 @@ public:
|
||||
*/
|
||||
void update();
|
||||
|
||||
/**
|
||||
* @brief Wall-clock seconds since the previous update(), capped at kMaxLogicDeltaSeconds.
|
||||
*
|
||||
* Logic timers (refresh scheduler, poll intervals, countdowns) advance by this. Visual
|
||||
* interpolation keeps io.DeltaTime, which update() clamps to 0.25 s so animations don't jump. The
|
||||
* main loop idles between frames (one frame per 2 s wake when nothing is happening), so a timer
|
||||
* fed the clamped delta would run up to 8x slow.
|
||||
*/
|
||||
float logicDeltaTime() const { return logic_dt_; }
|
||||
static constexpr float kMaxLogicDeltaSeconds = 6.0f;
|
||||
|
||||
/**
|
||||
* @brief Push the saved appearance prefs (low-spec, theme effects, blur, UI and noise opacity,
|
||||
* console scanline) to the live effects state. Low-spec overrides the rest without touching the
|
||||
* saved values. Called at startup, by the low-spec hotkey, the first-run wizard and the Settings
|
||||
* page, so every path applies the same thing.
|
||||
*/
|
||||
void applyVisualSettings();
|
||||
|
||||
/**
|
||||
* @brief Pre-frame tasks that must run BEFORE ImGui::NewFrame().
|
||||
*
|
||||
@@ -133,6 +168,12 @@ public:
|
||||
*/
|
||||
void shutdown();
|
||||
|
||||
/**
|
||||
* @brief Wait (bounded, 3 s) for queued settings.json / wallets.json writes to land on disk.
|
||||
* main() calls it around shutdown(): _Exit() skips ~App(), so nothing else would.
|
||||
*/
|
||||
void flushPendingWrites() { file_writer_.flush(std::chrono::milliseconds(3000)); }
|
||||
|
||||
/**
|
||||
* @brief Check if app should exit (render loop can stop)
|
||||
* Enforces minimum 1-second display of shutdown screen so user sees feedback.
|
||||
@@ -140,9 +181,16 @@ public:
|
||||
bool shouldQuit() const {
|
||||
if (!quit_requested_ || !shutdown_complete_) return false;
|
||||
auto elapsed = std::chrono::steady_clock::now() - shutdown_start_time_;
|
||||
return elapsed >= std::chrono::seconds(1);
|
||||
return elapsed >= std::chrono::seconds(1) && !bootstrapQuitPending();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A bootstrap cancelled by beginShutdown() is still replacing the chain data (its abort removes the
|
||||
* partial chain). The shutdown screen stays up until it is done (capped), so main()'s exit watchdog never
|
||||
* cuts it off.
|
||||
*/
|
||||
bool bootstrapQuitPending() const;
|
||||
|
||||
/**
|
||||
* @brief Request application exit — begins async shutdown
|
||||
*/
|
||||
@@ -210,6 +258,15 @@ public:
|
||||
// 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();
|
||||
// Long wallet RPCs (rescan, key imports; C61) run one at a time on long_lane_. Main thread. Decide a refusal
|
||||
// BEFORE copying a key into a closure: true when one can't start now, with the reason in English in *why
|
||||
// (the console's text) and as an i18n key in *trKey (the GUI's toast / status line).
|
||||
bool longWalletOpRefused(std::string* why, const char** trKey = nullptr) const;
|
||||
// Main thread. Runs body on the lane's own ungated client (no client timeout, aborted at quit) on an async
|
||||
// task; body must catch everything and returns the continuation, which runs on the main thread once the lane
|
||||
// is free again. false (body never runs) when the lane is already taken.
|
||||
using LongWalletBody = std::function<std::function<void()>(rpc::RPCClient&)>;
|
||||
bool runLongWalletTask(LongWalletBody body);
|
||||
// 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);
|
||||
@@ -238,6 +295,9 @@ public:
|
||||
// Request a font-atlas rebuild before the next frame (e.g. after toggling color emoji). Handled in
|
||||
// preFrame() via Typography::reload — safe to call from UI code mid-frame.
|
||||
void requestFontRebuild() { font_rebuild_requested_ = true; }
|
||||
/// Load the base ui.toml (disk, synced to the embedded copy when sizes differ; embedded fallback). Idempotent.
|
||||
/// main() calls it before the first Typography::load so the atlas is built once at the final sizes.
|
||||
static bool loadBaseUiSchema();
|
||||
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
||||
@@ -251,8 +311,9 @@ public:
|
||||
const std::string& text);
|
||||
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
||||
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
||||
// the chat feature is off.
|
||||
void seedChatDemoData();
|
||||
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
|
||||
// because none was provisioned, so the sweep can drop it again at the end.
|
||||
bool seedChatDemoData();
|
||||
// Reason the lite wallet failed to auto-open this session (empty if none / opened OK).
|
||||
const std::string& liteOpenError() const { return lite_open_error_; }
|
||||
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
|
||||
@@ -278,9 +339,19 @@ public:
|
||||
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
|
||||
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
|
||||
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
|
||||
// Empty only when there is no active wallet file. Establishes + persists the id on first call.
|
||||
// Empty only when there is no active wallet file. Establishes + persists the id on first call,
|
||||
// except during a full UI sweep (capture_mode_), where it returns the existing id or "".
|
||||
std::string activeWalletScopeId();
|
||||
|
||||
// Stable scope for the Market-tab portfolio groups: activeWalletScopeId() once this wallet's addresses are
|
||||
// loaded, else "" (so a group never shows a 0.0000 balance from an empty list, and Add stays disabled
|
||||
// until the wallet is known - the same window the old identity-hash gate covered). Never the address hash.
|
||||
// In a full UI sweep (capture_mode_) a fixed demo id so the sweep can still exercise Add.
|
||||
std::string portfolioScopeId();
|
||||
// One-time (per wallet per session) migration of legacy hash-scoped portfolio groups to the stable id.
|
||||
// Called from update(); a no-op until the wallet's addresses are loaded.
|
||||
void reattachLegacyPortfolioScopes();
|
||||
|
||||
data::WalletIndex& walletIndex() { return wallet_index_; }
|
||||
const data::WalletIndex& walletIndex() const { return wallet_index_; }
|
||||
|
||||
@@ -312,9 +383,23 @@ public:
|
||||
// Pool mining (xmrig)
|
||||
void startPoolMining(int threads);
|
||||
void stopPoolMining();
|
||||
// Change the thread count of the pool miner that is running (or queued to start). Never starts one.
|
||||
void retargetPoolMining(int threads);
|
||||
int getXmrigRequestedThreads() const {
|
||||
return xmrig_manager_ ? xmrig_manager_->getRequestedThreads() : 0;
|
||||
}
|
||||
// Thread count of the newest pool start/retarget posted and not stopped since (0 = none). Leads
|
||||
// getXmrigRequestedThreads(), which only changes once the mining-control thread runs that job.
|
||||
int poolThreadsTarget() const { return pool_threads_target_.load(std::memory_order_relaxed); }
|
||||
// A thread-count pick from the Mining tab (grid tile, -/+, Enter). The settings value changes at once;
|
||||
// the save and the miner change happen ONCE, when the input settles (drag released, or ~0.5 s after
|
||||
// the last click). Latest wins.
|
||||
void selectMiningThreads(int threads);
|
||||
void setMiningThreadDragActive(bool active) { mining_thread_drag_ = active; }
|
||||
// A pick has not reached the miner yet (the grid must not snap back to the old count meanwhile).
|
||||
bool miningThreadCommitPending() const {
|
||||
return mining_thread_commit_.pending() || mining_solo_threads_pending_ > 0;
|
||||
}
|
||||
// True while the pool miner process is live — used to refuse replacing the binary under it.
|
||||
bool isPoolMinerRunning() const {
|
||||
return xmrig_manager_ && xmrig_manager_->isRunning();
|
||||
@@ -410,18 +495,90 @@ public:
|
||||
// auto-shield) with the shared opid poller so its eventual success/failure is
|
||||
// surfaced and balances/transactions refresh on completion. z_sendmany uses the
|
||||
// richer pending-send path internally; this is for operations with no optimistic
|
||||
// transaction row of their own.
|
||||
void trackOperation(const std::string& opid);
|
||||
// transaction row of their own. The origin decides what its completion re-reads: the client
|
||||
// auto-shield is Auto (re-read, balance unforced); everything the user starts is User.
|
||||
void trackOperation(const std::string& opid,
|
||||
services::NetworkRefreshService::OperationOrigin origin = services::NetworkRefreshService::OperationOrigin::User);
|
||||
// The connected daemon rejected the opid-filtered z_getoperationstatus, so polls use the unfiltered
|
||||
// form (every retained op) for the rest of this connection.
|
||||
bool opidFilterUnsupported() const { return opid_filter_unsupported_.load(std::memory_order_relaxed); }
|
||||
|
||||
// Something changed the wallet that the scans' change signals (tip, mempool) don't show: a send or other
|
||||
// operation finished, an import, a Console command. The change-gated scans run again.
|
||||
void noteWalletMutation() { ++wallet_mutation_epoch_; }
|
||||
// The window is in the background: minimized, hidden, or covered while the app has no focus (debounced
|
||||
// by the main loop; any input ends it). The background refresh profile then holds the wallet scans and
|
||||
// the peripheral polls (mining, price), and no page counts as visible to the scan budget.
|
||||
void setWindowBackground(bool background);
|
||||
bool isWindowBackground() const { return window_background_; }
|
||||
// A balance scan is in flight (the staleness badge waits for it).
|
||||
bool isBalanceRefreshInFlight() const { return scan_governor_.inFlight(services::ScanStream::Balance); }
|
||||
// The balance is waiting for the scan budget and still within what it promises (its floor plus one scan):
|
||||
// an old value then is the budget at work, not a failing node.
|
||||
bool isBalanceHeldByScanBudget() const;
|
||||
|
||||
// RAII around one long ("maintenance") rpc_ call made on a worker thread with a timeout above the
|
||||
// default 30 s, e.g. a merge or a whole-wallet scan given 120 s. rpc_ holds its curl lock for the
|
||||
// whole transfer and disconnect() takes the same lock, so these are counted for
|
||||
// abortMaintenanceCalls().
|
||||
class MaintenanceCallScope {
|
||||
public:
|
||||
explicit MaintenanceCallScope(App& app) : app_(app)
|
||||
{
|
||||
// Count first, then read the flag; abortMaintenanceCalls() sets the flag, then reads the count.
|
||||
// Either this sees the abort and never sends, or the abort sees this call and cuts it off. (A
|
||||
// call queued behind the one disconnect() waits on could otherwise take the curl lock next
|
||||
// and hold disconnect() for its full 120 s.)
|
||||
app_.maint_calls_.fetch_add(1);
|
||||
if (app_.maint_abort_.load()) {
|
||||
app_.maint_calls_.fetch_sub(1);
|
||||
throw rpc::RpcTransportError(rpc::RpcFailureKind::Aborted, "RPC aborted: disconnecting");
|
||||
}
|
||||
}
|
||||
~MaintenanceCallScope() { app_.maint_calls_.fetch_sub(1); }
|
||||
MaintenanceCallScope(const MaintenanceCallScope&) = delete;
|
||||
MaintenanceCallScope& operator=(const MaintenanceCallScope&) = delete;
|
||||
private:
|
||||
App& app_;
|
||||
};
|
||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
||||
void abortMaintenanceCalls();
|
||||
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
||||
// full node that is past warmup, not rescanning and not busy.
|
||||
bool walletScanReady() const;
|
||||
|
||||
// C210: the "Keep in each address" consolidation run. Owned here so it keeps going when the Merge dialog
|
||||
// closes; pumped from update(). Full node only (refused in Lite). Main thread.
|
||||
const services::ConsolidationRun& consolidation() const { return consolidation_; }
|
||||
bool startConsolidation(const std::vector<services::NoteGroup>& groups, double fee, int batchLimit);
|
||||
void stopConsolidation() { consolidation_.requestStop(); }
|
||||
void dismissConsolidation() { consolidation_.dismiss(); } // no-op while running
|
||||
// A one-off wallet-wide scan the user asked for (the Merge dialog's scope read, a merge): never held back,
|
||||
// but charged to the scan budget (ScanStream::Adhoc) so the routine scans make room. Main thread.
|
||||
services::ScanTicket beginAdhocWalletScan();
|
||||
void endAdhocWalletScan(const services::ScanTicket& ticket, double costMs, services::ScanOutcome outcome);
|
||||
// A GUI wallet scan is in the daemon or queued for it: a one-off scan waits rather than stack on cs_main.
|
||||
bool walletScanInFlight() const { return scan_governor_.anyInFlight(); }
|
||||
|
||||
// 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).
|
||||
// Called on window restore: drop any daemon-output backlog (update() runs while minimized, but its
|
||||
// output parser skips while the wallet is locked), so a background witness rebuild that started and
|
||||
// finished meanwhile isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
|
||||
void skipDaemonOutputBacklog();
|
||||
void applyPendingDaemonOutputReset();
|
||||
// A real user input event (key, text, mouse, touch, pen, gamepad button) was ingested. Main thread only.
|
||||
// Called from main.cpp's noteEvent; do NOT call from an SDL event watch/filter (resizeWatcher).
|
||||
void noteUserInput() { idle_.noteActivity(); }
|
||||
void refreshMiningInfo();
|
||||
void refreshPeerInfo();
|
||||
void refreshMarketData();
|
||||
// The Market tab's refresh button: lift any failure backoff (at most once per 5 s, against click
|
||||
// spam), then refreshMarketData().
|
||||
void retryMarketDataNow();
|
||||
// Fetch the live exchange/pair list from CoinGecko once per session (venues are
|
||||
// near-static); populates state.market.exchanges. Safe to call every frame.
|
||||
void refreshExchanges();
|
||||
@@ -525,14 +682,20 @@ public:
|
||||
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
|
||||
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
|
||||
// block data), reconciling the wallet's stale spent-state without a daemon restart.
|
||||
void runtimeRescan(int startHeight);
|
||||
// Async binary-search probe for the lowest block height the node still has on disk.
|
||||
// cb(ok, lowestHeight, fullHistory): fullHistory==true when genesis is present (a normal,
|
||||
// non-bootstrapped node). Runs on the UI thread via the RPC worker callbacks.
|
||||
// Runs on the long-call lane; false when it did not start (refused, not connected, one already running).
|
||||
bool runtimeRescan(int startHeight);
|
||||
// Probe for the lowest block height the node still has on disk, on worker_ (never on the UI thread).
|
||||
// cb(ok, lowestHeight, fullHistory) runs on the UI thread: from worker_->drainResults(), or synchronously
|
||||
// when there is nothing to probe. fullHistory==true when block 1 is readable (a normal, non-bootstrapped
|
||||
// node). ok==false means "could not tell" (busy node, timeout, reconnect): never "blocks are missing".
|
||||
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
|
||||
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
|
||||
// daemon is back up (consumed in update()).
|
||||
void markPostBootstrapRescanPending() { post_bootstrap_rescan_pending_ = true; }
|
||||
void markPostBootstrapRescanPending() {
|
||||
post_bootstrap_rescan_pending_ = true;
|
||||
post_bootstrap_probe_attempts_ = 0;
|
||||
post_bootstrap_probe_retry_at_ = {};
|
||||
}
|
||||
bool runtimeRescanActive() const { return runtime_rescan_active_; }
|
||||
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
|
||||
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
|
||||
@@ -548,8 +711,8 @@ public:
|
||||
// Diagnostic string describing daemon memory detection path (for debugging)
|
||||
const std::string& getDaemonMemDiag() const { return daemon_mem_diag_; }
|
||||
|
||||
// Background gradient overlay texture
|
||||
void setGradientTexture(ImTextureID tex) { gradient_tex_ = tex; }
|
||||
// Background gradient overlay texture (a replaced texture is retired, freed at the next preFrame())
|
||||
void setGradientTexture(ImTextureID tex) { if (tex != gradient_tex_) retireTexture(gradient_tex_); gradient_tex_ = tex; }
|
||||
ImTextureID getGradientTexture() const { return gradient_tex_; }
|
||||
|
||||
// Logo texture accessor (wallet branding icon)
|
||||
@@ -635,6 +798,11 @@ public:
|
||||
void showPinChangeDialog() { show_pin_change_ = true; pin_status_.clear(); }
|
||||
void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); }
|
||||
bool hasPinVault() const;
|
||||
// The PIN vault seals a passphrase the wallet no longer accepts (C87): changed in this session, or a
|
||||
// PIN unlock got RPC -14. Session-only; the vault file is kept. Settings shows "PIN out of date".
|
||||
bool isPinVaultStale() const { return pin_vault_stale_; }
|
||||
// A wallet encrypt / passphrase change is in flight (PIN controls stay disabled meanwhile).
|
||||
bool isEncryptInProgress() const { return encrypt_in_progress_; }
|
||||
|
||||
// Debug-options gate: does revealing the debug dropdown need re-authentication (a PIN vault or
|
||||
// an encrypted wallet)? And verify the entered PIN/passphrase — cb(ok) is invoked on the main
|
||||
@@ -642,8 +810,12 @@ public:
|
||||
bool debugGateRequiresAuth() const;
|
||||
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb);
|
||||
|
||||
/// @brief Check if RPC worker has queued results waiting to be processed
|
||||
bool hasPendingRPCResults() const;
|
||||
/// @brief Something finished on a background thread and waits for update() to apply it on the main thread
|
||||
/// (the RPC worker lanes, the HTTP lane, lite wallet ops, the startup node-file job). main.cpp's idle loop
|
||||
/// polls this every <= 200 ms (hasImmediateWork) and renders a frame when it is true, so the result shows
|
||||
/// without input, even while the window is hidden. A new lane whose results are applied in update() adds
|
||||
/// its term HERE.
|
||||
bool hasPendingBackgroundResults() const;
|
||||
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
||||
std::string transactionSendProgressText() const;
|
||||
std::string transactionRefreshProgressText() const;
|
||||
@@ -715,14 +887,18 @@ private:
|
||||
// t_addresses for `fromAddress` and clamp its balance at >=0. A positive `signedAmount`
|
||||
// restores a debit; a negative one applies it. When `includeAggregates` is set, adjust the
|
||||
// private/transparent bucket (chosen by the address's leading 'z') and totalBalance with the
|
||||
// same clamp. Shared by the three pending-send delta sites so they can't drift.
|
||||
// same clamp. Shared by the three pending-send delta sites so they can't drift. `includeAddress`
|
||||
// off leaves the per-address balances alone (the Core poll refreshes only the aggregates).
|
||||
void applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
|
||||
bool includeAggregates);
|
||||
bool includeAggregates, bool includeAddress = true);
|
||||
// Deliver a deferred z_sendmany result to its waiting UI callback once the opid
|
||||
// reaches a terminal status. Returns true if a callback was registered (and fired).
|
||||
bool invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
const std::string& result);
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
|
||||
// Re-apply every pending send's debit on top of freshly fetched balances: the aggregates after a
|
||||
// Core poll that carried a balance, the per-address balances after an address scan. Only onto
|
||||
// fresh values, or a debit applied again on every tick would drain them.
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances, bool includeAddressBalances = true);
|
||||
std::string transactionHistoryCacheWalletIdentity() const;
|
||||
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
|
||||
// Record the active wallet's cached metadata (balance, address count, identity, size) into the
|
||||
@@ -735,15 +911,27 @@ private:
|
||||
// curve on every failed path (a PIN RPC failure used to skip it).
|
||||
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
||||
void applyUnlockFailure(const std::string& errorMessage);
|
||||
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
||||
void applyUnlockNotCounted(const std::string& errorMessage);
|
||||
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
||||
// rescan-completion sites so a new witness field can't be forgotten in one copy.
|
||||
void resetWitnessRescanProgress();
|
||||
void loadTransactionHistoryCacheIfAvailable();
|
||||
void storeTransactionHistoryCacheIfAvailable();
|
||||
// Persist the tx-history snapshot. Coalesce: only a change that matters, rate-limited (content <= every
|
||||
// 20 s, scan heights/confirmations/tip <= every 10 min). Now: anything not yet on disk, immediately (user
|
||||
// action, disconnect, lock, exit). The write itself runs on history_writer_; this only copies the rows.
|
||||
void storeTransactionHistoryCacheIfAvailable(
|
||||
data::HistoryStoreMode mode = data::HistoryStoreMode::Coalesce);
|
||||
void pumpTransactionHistoryStore(); // update(): release a deferred store, retry a failed write
|
||||
void wipePendingTransactionHistoryCachePassphrase();
|
||||
void resetTransactionHistoryCacheSession();
|
||||
// The wallet locked: drop the cache key, keep the history and its scan progress (an unlock used to
|
||||
// force a full re-scan of every address, every auto-lock cycle).
|
||||
void lockTransactionHistoryCacheSession();
|
||||
void pruneShieldedHistoryScanProgress();
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache);
|
||||
// Forget the receive-scan heights so every address is scanned again. keepSentScan leaves the sent
|
||||
// sweep's watermark (and the enrichment backlog), so a plain refresh doesn't cost a whole-wallet sweep.
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache, bool keepSentScan = false);
|
||||
|
||||
// Auto-balance pool selection: drive the periodic hashrate refresh and apply a
|
||||
// freshly-completed snapshot (weighted-random pick + optional miner restart).
|
||||
@@ -760,9 +948,19 @@ private:
|
||||
std::unique_ptr<rpc::RPCClient> fast_rpc_;
|
||||
std::unique_ptr<rpc::RPCWorker> fast_worker_;
|
||||
|
||||
// HTTP lane: price / tickers / market chart / exchange candle GETs on their own thread, so a slow or
|
||||
// blocked HTTP endpoint never queues in front of wallet RPCs on worker_ (and wallet scans never delay the
|
||||
// price). Not tied to the daemon connection: started in init(), stopped only at shutdown. Work functions
|
||||
// touch only http_cancel_ and their captures; results apply in MainCbs (main thread). http_cancel_ is
|
||||
// declared BEFORE http_worker_ on purpose: members die in reverse order, and the lane's thread reads the
|
||||
// flag until ~RPCWorker joins it.
|
||||
std::atomic<bool> http_cancel_{false}; // set at shutdown: queued GETs return at once, an in-flight one aborts
|
||||
std::unique_ptr<rpc::RPCWorker> http_worker_;
|
||||
|
||||
// Saved connection credentials (needed to open the fast-lane connection)
|
||||
rpc::ConnectionConfig saved_config_;
|
||||
|
||||
util::CoalescingWriter file_writer_; // background atomic writer for settings.json / wallets.json
|
||||
std::unique_ptr<config::Settings> settings_;
|
||||
std::unique_ptr<wallet::LiteWalletController> lite_wallet_; // lite builds w/ linked backend
|
||||
// Pending send_tab callback for an in-flight lite send (delivered in update() once the
|
||||
@@ -775,6 +973,13 @@ private:
|
||||
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
|
||||
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
|
||||
std::string lite_open_error_;
|
||||
// A forced lite rebuild (Network-tab server switch) deferred while a wallet op is in flight; update() retries it
|
||||
// (rebuildLiteWallet). deferred_at bounds the wait (wallet::kLiteServerSwitchMaxDeferSeconds).
|
||||
bool lite_rebuild_pending_ = false;
|
||||
std::chrono::steady_clock::time_point lite_rebuild_deferred_at_{};
|
||||
// Retire the current lite controller without blocking the UI thread (fail its pending op callbacks, stop its
|
||||
// worker, shut its client down) and destroy it on a detached thread. lite_wallet_ is null afterwards.
|
||||
void retireLiteWallet();
|
||||
|
||||
// HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat
|
||||
// service so the transaction-refresh harvest can decrypt incoming memos into threaded messages.
|
||||
@@ -801,15 +1006,15 @@ private:
|
||||
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;
|
||||
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on tx_revision),
|
||||
// not every frame. See App::render().
|
||||
std::uint64_t sb_unconf_key_rev_ = 0; // sentinel 0 never matches: tx_revision starts at 1
|
||||
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;
|
||||
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS): it runs on this sampler's own
|
||||
// thread, at most once per 3 s and only while a reader asks (Mining tab memory bar, Peers tab Memory cell).
|
||||
// See App::getDaemonMemoryUsageMB(). Mutable: that accessor is const. Probes capture values only, so any
|
||||
// member order is safe; shutdown() stops it.
|
||||
mutable util::BackgroundSampler daemon_mem_sampler_;
|
||||
|
||||
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
||||
@@ -847,12 +1052,38 @@ private:
|
||||
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
|
||||
// refill forever). Prevents runaway splitting that would drain balance into fees.
|
||||
bool chat_split_outstanding_ = false;
|
||||
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
|
||||
double chat_split_submitted_at_ = 0.0; // scanClockNow() when the outstanding split was submitted (watchdog)
|
||||
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
|
||||
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
|
||||
// balance poll discards per-note data). Set with its scan ticket; cleared only by its reply.
|
||||
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)
|
||||
double chat_note_scan_last_ = 0.0; // scanClockNow() of the last note-scan dispatch or address-scan feed
|
||||
std::int64_t chat_note_scan_tip_ = -1; // the settled tip the note view was read at (-1: look again)
|
||||
bool chat_note_rescan_requested_ = false; // a chat op finished: its notes changed
|
||||
bool chat_note_first_look_sent_ = false; // the session's one explicit look (Chat page, no model)
|
||||
services::ChatPayerMap chat_verified_funds_by_addr_; // verified notes by address (the chat payer view)
|
||||
std::int64_t chat_split_hold_tip_ = -1; // a split failed at this tip: none before the next block
|
||||
// Chat spends the payer view's producers (a note scan, an address pass) posted earlier can't see: worker_ is
|
||||
// FIFO, so a reply reflects every spend submitted before it was posted, none after. Re-applied on its reply.
|
||||
struct ChatSpendLogEntry {
|
||||
std::uint64_t seq = 0;
|
||||
std::string payer;
|
||||
services::ChatPayerSpend spend = services::ChatPayerSpend::OneNote;
|
||||
};
|
||||
std::uint64_t chat_spend_seq_ = 0;
|
||||
std::deque<ChatSpendLogEntry> chat_spend_log_;
|
||||
std::int64_t chat_note_scan_spend_seq_ = -1; // the spend seq an outstanding note scan was posted at
|
||||
std::int64_t addr_pass_spend_seq_ = -1; // ...and an outstanding address pass
|
||||
void debitChatPayerLogged(const std::string& payer, services::ChatPayerSpend spend);
|
||||
void reapplyChatSpendsAfter(std::uint64_t seq);
|
||||
// The 0-conf inbox: a txcount probe per chat tick, the inbox scan when the wallet gained a tx.
|
||||
std::int64_t chat_inbox_basis_txcount_ = -1; // txcount the last successful inbox scan is current for
|
||||
double chat_inbox_last_scan_at_ = 0.0; // scanClockNow() at that scan's dispatch (safety net)
|
||||
bool chat_inbox_scan_wanted_ = false; // the probe found a need the budget hasn't admitted yet
|
||||
std::int64_t chat_inbox_wanted_txcount_ = -1; // the count that need was found at: the next basis
|
||||
double chat_inbox_probe_at_ = 0.0; // scanClockNow() of the last probe
|
||||
double chat_inbox_probe_ms_ = 0.0; // its cost (self-throttle)
|
||||
bool chatInboxScanAllowed() const; // the inbox's gates (identity, the hard gates, the Chat page)
|
||||
void admitChatInboxScan(); // ask the scan budget; post the inbox scan when admitted
|
||||
// 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.
|
||||
@@ -861,14 +1092,21 @@ 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
|
||||
// Full node: the z_listunspent note-buffer scan, on need (services::shouldScanChatNotes), under the scan
|
||||
// budget (ChatNotes); also off the Chat page while a queued send or a split waits on it.
|
||||
void refreshChatNoteBudgetNode();
|
||||
// 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);
|
||||
// feedTip: the tip that pass may vouch for (-1 when the tip moved too recently for the wallet notifier).
|
||||
// spendSeq: the chat spend seq the pass was posted at (its reply re-applies the spends after it).
|
||||
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes,
|
||||
std::int64_t feedTip, std::uint64_t spendSeq);
|
||||
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
|
||||
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||
bool shouldSplitChatBuffer();
|
||||
// What one split can spend: on the full node the richest verified payer (z_sendmany pays from ONE address).
|
||||
std::uint64_t chatSplitAffordableZat() const;
|
||||
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
|
||||
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
|
||||
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
|
||||
@@ -917,28 +1155,28 @@ private:
|
||||
// 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 pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
||||
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)
|
||||
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
|
||||
// reply address. The reply-to in the memo stays the identity address, so paying from a different
|
||||
// funded note is transport-transparent. Empty if no z-address can cover the fee.
|
||||
std::string chatPayFromZaddr(double fee) const;
|
||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||
// the identity reply address; from the chat note view's verified notes when there is one. The reply-to in
|
||||
// the memo stays the identity address, so paying from a different funded note is transport-transparent.
|
||||
// Empty if no z-address can cover it.
|
||||
std::string chatPayFromZaddr(double need) const;
|
||||
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
|
||||
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
|
||||
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
|
||||
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
|
||||
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
|
||||
// Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle
|
||||
// so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped
|
||||
// (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity.
|
||||
// Full-node 0-conf fast path on the Chat page: a txcount probe each chat tick, and the inbox scan (the chat
|
||||
// reply address at minconf=0) when the wallet gained a tx, as the scan budget admits it (ChatFast), so
|
||||
// incoming messages surface at mempool speed. Hidden conversations are skipped (they still un-hide via the
|
||||
// normal confirmed harvest). Self-gated; no-op without a chat identity.
|
||||
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 chat_fast_scan_in_flight_ = false; // the inbox probe or scan is out; cleared only by its reply
|
||||
float chat_fast_scan_accum_ = 0.0f; // seconds since the last chat tick
|
||||
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;
|
||||
@@ -957,7 +1195,18 @@ private:
|
||||
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 signalMiningControlStop(); // signal only (no join): safe on the UI thread
|
||||
void stopMiningControlThread(); // signal + join the control thread (shutdown)
|
||||
void finishMinerShutdown(); // join control thread -> xmrig stop(3000); background only
|
||||
// Builds the pool config on the main thread and posts stop(if running)+start to the control thread.
|
||||
// retargetOnly: a thread-count change, decided on the control thread (never starts a miner).
|
||||
void postPoolMinerJob(int threads, bool retargetOnly);
|
||||
// Coalesced thread-count picks (main thread only; see services/mining_thread_commit.h).
|
||||
services::MiningThreadCommit mining_thread_commit_;
|
||||
bool mining_thread_drag_ = false; // the thread grid is mid-drag (set by the Mining tab)
|
||||
bool mining_threads_save_pending_ = false; // settings hold a picked count that is not on disk yet
|
||||
int mining_solo_threads_pending_ = 0; // settled pick waiting for an in-flight setgenerate
|
||||
void tickMiningThreadCommit(); // per frame, from update()
|
||||
// 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_;
|
||||
@@ -965,8 +1214,17 @@ private:
|
||||
long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick
|
||||
bool balance_refresh_pending_ = false; // UI asked for an immediate refresh
|
||||
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
|
||||
bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch
|
||||
bool exchanges_fetch_started_ = false; // CoinGecko tickers fetch in flight or done (once per session)
|
||||
bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker
|
||||
// Failed market fetches back off instead of retrying: the Market tab calls these every frame, so a
|
||||
// failure (offline, rate-limited) used to retry on the very next frame, ~20 requests a second.
|
||||
util::FetchBackoff exchanges_backoff_;
|
||||
util::FetchBackoff market_chart_backoff_;
|
||||
util::FetchBackoff exchange_chart_backoff_; // for exchange_chart_fail_key_ only: other pairs still load
|
||||
std::string exchange_chart_fail_key_;
|
||||
std::chrono::steady_clock::time_point market_manual_retry_at_{};
|
||||
bool price_fetch_failed_ = false; // last price fetch failed (connectivity probe for the backoffs)
|
||||
bool price_fetch_in_flight_ = false; // a price GET is on http_worker_ (main thread only)
|
||||
// Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an
|
||||
// in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart).
|
||||
std::string exchange_chart_key_; // "<identifier>:<BASE>/<QUOTE>" of the loaded series ("" = none)
|
||||
@@ -980,6 +1238,19 @@ private:
|
||||
std::chrono::steady_clock::time_point fetchedAt{};
|
||||
};
|
||||
std::unordered_map<std::string, ExchangeChartCache> exchange_chart_cache_;
|
||||
// Startup node files (Windows: extract the bundled params/node/miner; Linux/macOS: copy them from the exe
|
||||
// dir; full node: then verify the Sapling params) on the "extract-node-files" async task. Declared BEFORE
|
||||
// async_tasks_ so it outlives the task threads if the App is destroyed without shutdown().
|
||||
std::shared_ptr<resources::StartupProvision> startup_files_;
|
||||
std::thread::id ui_thread_id_{}; // set first thing in init(); startEmbeddedDaemon() never blocks it
|
||||
bool daemon_start_after_files_ = false; // UI thread only: a daemon start was deferred while the job ran
|
||||
void startStartupFilesProvision();
|
||||
void pollStartupFiles(); // UI thread, every update()
|
||||
void renderStartupFilesChip(); // UI thread, top of render()
|
||||
// The long wallet RPC lane (C61). Its task runs on async_tasks_, so it is declared BEFORE async_tasks_ and
|
||||
// outlives the task threads. Aborted at quit (beginShutdown / shutdown); never to make way for another flow:
|
||||
// those are refused while it is active (nodeBusyForStop), because an aborted client does not stop the scan.
|
||||
rpc::LongCallLane long_lane_;
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
@@ -992,6 +1263,7 @@ private:
|
||||
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
|
||||
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
|
||||
std::thread shutdown_thread_;
|
||||
std::thread miner_stop_thread_; // disconnect-only quit paths: miner teardown off the UI 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
|
||||
@@ -1183,6 +1455,8 @@ private:
|
||||
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)
|
||||
std::atomic<bool> daemon_output_reset_pending_{false}; // lifecycle thread -> UI thread
|
||||
std::atomic<size_t> daemon_output_reset_to_{0};
|
||||
// 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_;
|
||||
@@ -1212,6 +1486,11 @@ private:
|
||||
long ext_log_pid_ = 0;
|
||||
std::uintmax_t ext_log_offset_ = 0;
|
||||
double ext_log_next_at_ = 0.0;
|
||||
long ext_log_miss_pid_ = 0; // pid whose banner we failed to find
|
||||
std::uintmax_t ext_log_miss_size_ = 0; // file size at that miss
|
||||
double ext_log_miss_since_ = 0.0; // when this pid first missed (fresh-node grace)
|
||||
double ext_log_retry_at_ = 0.0; // next allowed re-scan (ImGui::GetTime)
|
||||
double ext_log_backoff_ = 0.0; // current backoff seconds (0 until grace elapses)
|
||||
void abortRpcBusyCanary();
|
||||
void feedDaemonBusy(const std::string& chunk);
|
||||
void resetDaemonBusy();
|
||||
@@ -1232,6 +1511,10 @@ private:
|
||||
bool export_in_progress_ = false; // async key fetch running (spinner + disable Export)
|
||||
bool export_error_ = false; // last export returned no key (locked wallet / failure)
|
||||
char import_key_input_[512] = {0};
|
||||
char import_clip_preview_[64] = {0}; // trimmed clipboard preview (<=48 chars + NUL); SECRET, wiped
|
||||
uint8_t import_clip_class_ = 0; // 0 none / 1 ok / 2 wrong-type / 3 unrecognized
|
||||
double import_clip_next_at_ = 0.0; // ImGui::GetTime() of next allowed clipboard read
|
||||
bool import_clip_hover_prev_ = false; // hover edge
|
||||
std::string export_address_;
|
||||
std::string import_status_;
|
||||
bool import_success_ = false;
|
||||
@@ -1263,25 +1546,69 @@ 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
|
||||
bool refresh_policy_background_ = false; // whether the background 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
|
||||
// Bumped with each forced-refresh request, so the poll that carried the forced balance clears the
|
||||
// flag only if no newer request came in meanwhile (see requestForcedBalanceRefresh()).
|
||||
std::uint32_t force_balance_seq_ = 0;
|
||||
// The exact minconf=0 total of the last balance poll: the minconf=1 call is skipped while it holds
|
||||
// still and nothing is pending (NetworkRefreshService::CoreRefreshPlan).
|
||||
std::optional<std::int64_t> last_total_balance_zat_;
|
||||
// Force the next balance poll through the throttle (the user's own send). Cleared by the Core poll
|
||||
// that actually sent the forced balance call, not at dispatch, so a dispatch the job queue refuses
|
||||
// (a Core job already in flight) or one that skips the balance (node behind or busy) keeps it.
|
||||
void requestForcedBalanceRefresh() { force_balance_refresh_ = true; ++force_balance_seq_; }
|
||||
// A wallet mutation committed (a User/Auto op succeeded, or a send returned no opid): re-read what it changed.
|
||||
void markWalletMutated(bool forceBalance);
|
||||
bool hasPendingUserOperations() const {
|
||||
return services::NetworkRefreshService::anyUserOperationPending(pending_opids_, op_origin_);
|
||||
}
|
||||
// One budget for every wallet-wide scan the GUI sends on its own (balance, addresses, history, the
|
||||
// between-blocks probe): one in flight at a time across both lanes, ~15% of wall-clock in total. They
|
||||
// used to be throttled one by one at 10% each, which added up to 30-40% with all of them due.
|
||||
// Explicit needs (dirty sets, the user's own send, a never-loaded list) bypass it but are charged.
|
||||
services::WalletScanGovernor scan_governor_;
|
||||
// The change signals the routine scans are gated on, besides the tip hash (state_.sync.best_blockhash).
|
||||
std::uint64_t wallet_mutation_epoch_ = 0; // bumped by noteWalletMutation()
|
||||
bool mempool_known_ = false; // the last Core poll's getmempoolinfo
|
||||
double tip_changed_at_ = 0.0; // scanClockNow() when a Core poll saw a new tip hash (chat settle)
|
||||
std::int64_t mempool_txs_ = 0;
|
||||
std::int64_t mempool_bytes_ = 0;
|
||||
bool window_background_ = false; // setWindowBackground()
|
||||
static constexpr float kScanRetrySec = 2.0f; // a routine scan the governor held back asks again this soon
|
||||
services::ScanSignals currentScanSignals() const;
|
||||
services::ScanVisibility currentScanVisibility() const;
|
||||
static double scanClockNow(); // the governor's clock: steady-clock seconds
|
||||
// C44: holds the wallet-wide scans for a moment after the node starts answering (connect, warmup end),
|
||||
// until the lock state is known and the node knows its tip or the wallet is small (20 s at most).
|
||||
services::ConnectScanGate scan_gate_;
|
||||
// After an address enumeration failed: no address refresh before this (the previous lists stay).
|
||||
std::chrono::steady_clock::time_point address_retry_not_before_{};
|
||||
// A full address pass (the per-address balances) has run this connection. Until then the first one runs
|
||||
// even while the node catches up.
|
||||
bool address_balances_loaded_ = false;
|
||||
// The session's first balance read was sent while the node was catching up (once per connection).
|
||||
bool first_balance_read_tried_ = false;
|
||||
// Paces the address passes the held profiles' timers can't bring (a stale list, the first balances).
|
||||
std::chrono::steady_clock::time_point address_owed_next_try_{};
|
||||
bool scanGateOpen() const;
|
||||
// The address list was never loaded this session, or the address set may have changed (import, sweep,
|
||||
// decrypt-import): it loads even while the wallet scans are held (list-only).
|
||||
bool addressListStale() const;
|
||||
// What the scan entry points check before sending a wallet-wide scan (services::scan_policy.h).
|
||||
services::ScanContext scanContext() const;
|
||||
// The full address pass could be sent now (the hard gates, and no retry hold).
|
||||
bool addressScanCanRunNow() const;
|
||||
void armScanGate(const services::NetworkRefreshService::ConnectionInfoResult& info);
|
||||
// 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;
|
||||
std::chrono::steady_clock::time_point clipboard_clear_deadline_{};
|
||||
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
|
||||
@@ -1324,9 +1651,14 @@ private:
|
||||
// RPC, no auto-lock, no async pump. New async paths that could run mid-sweep must guard on it.
|
||||
bool capture_mode_ = false;
|
||||
void startSweepImpl(bool full);
|
||||
// Why a full sweep must not start now (a real wallet workflow is in flight), or nullptr.
|
||||
const char* fullSweepBlockedReason() const;
|
||||
void buildSweepCatalog();
|
||||
void installDemoWalletData();
|
||||
void clearDemoWalletData();
|
||||
// The full sweep's private temp dir (demo wallet files and folders); "" when none.
|
||||
std::string sweep_sandbox_;
|
||||
bool sweep_chat_identity_fabricated_ = false; // seedChatDemoData() made the demo chat identity
|
||||
void applyHealthyDemoState(); // reset the connection/encryption flags to the healthy demo values
|
||||
void writeSweepManifest() const;
|
||||
// Snapshot of the state_ fields the demo installer mutates, restored at sweep end. Kept as a
|
||||
@@ -1342,6 +1674,7 @@ private:
|
||||
std::vector<TransactionInfo> transactions;
|
||||
double market_price_usd=0;
|
||||
double market_change_24h=0;
|
||||
WizardPhase wizard_phase = WizardPhase::None; // the demo state dismisses the wizard
|
||||
} sweep_state_snapshot_;
|
||||
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
|
||||
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)
|
||||
@@ -1350,6 +1683,10 @@ private:
|
||||
// Background gradient overlay
|
||||
ImTextureID gradient_tex_ = 0;
|
||||
|
||||
// Textures retired mid-frame (draw lists may still reference them); freed at the next preFrame().
|
||||
std::vector<ImTextureID> pending_texture_destroy_;
|
||||
void retireTexture(ImTextureID& t) { if (t) { pending_texture_destroy_.push_back(t); t = 0; } }
|
||||
|
||||
// Logo texture (reload on skin change / dark↔light switch)
|
||||
ImTextureID logo_tex_ = 0;
|
||||
int logo_w_ = 0;
|
||||
@@ -1388,6 +1725,16 @@ private:
|
||||
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
|
||||
std::atomic<bool> pool_starting_{false};
|
||||
|
||||
// Thread count of the newest pool start/retarget job posted (0 = none / stopped). A hint for the main
|
||||
// thread: set when a job is posted, cleared by stopPoolMining(), by a start that fails and by a retarget
|
||||
// that finds no miner. The control thread re-checks the real state, so a stale value can only skip a
|
||||
// job the newest posted one already covers, or post a no-op one.
|
||||
std::atomic<int> pool_threads_target_{0};
|
||||
void clearPoolThreadsTarget(int mine) { // only if it still holds this job's value
|
||||
int expected = mine;
|
||||
pool_threads_target_.compare_exchange_strong(expected, 0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1405,12 +1752,35 @@ private:
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
|
||||
std::size_t shielded_history_scan_cursor_ = 0;
|
||||
bool shielded_history_scan_pending_ = false;
|
||||
// False until the first full shielded-history scan finishes. Drives the History tab's
|
||||
// "Loading older history…" progress so the user knows transactions are still streaming in
|
||||
// after the first batch appears; goes quiet for the routine per-block re-scans afterward.
|
||||
bool initial_history_scan_complete_ = false;
|
||||
std::unordered_map<std::string, int> shielded_history_scan_heights_;
|
||||
// The receive-scan pass over every z-address (see services::ShieldedHistoryPass).
|
||||
services::ShieldedHistoryPass history_pass_;
|
||||
std::uint64_t history_scan_epoch_ = 0; // bumped when the scan heights are invalidated
|
||||
std::uint64_t history_sent_epoch_ = 0; // bumped when the sent sweep's watermark is reset
|
||||
int history_sent_scan_height_ = -1; // tip of the last successful z_listsentbyaddress; -1 = none
|
||||
int history_full_sweep_failures_ = 0; // whole-wallet sweeps failed in a row (backoff)
|
||||
std::time_t history_full_sweep_retry_at_ = 0;
|
||||
std::unordered_set<std::string> history_enrich_backlog_; // txids found, not enriched yet
|
||||
double history_addr_scan_ms_ = 0.0; // per-address z_listreceivedbyaddress cost (estimate)
|
||||
std::size_t history_transparent_rows_ = 0; // rows the last listtransactions chain returned
|
||||
int history_transparent_tip_ = -1; // ...and the tip it ran at (same tip: rows can be reused)
|
||||
std::uint64_t tx_dirty_generation_ = 0; // bumped by markTransactionsDirty()
|
||||
std::uint64_t addr_dirty_generation_ = 0; // bumped by markAddressesDirty()
|
||||
// Addresses holding notes the history doesn't show yet (from the address scan's z_listunspent): scanned
|
||||
// first in the next wallet-wide cycle, so a receive or payout shows within one cycle on a large wallet
|
||||
// instead of when the routine pass reaches its address. Each txid hints once per session after it is
|
||||
// mined (a mempool hint doesn't use that up, so the row also catches its confirmation).
|
||||
std::unordered_map<std::string, int> history_hint_floors_;
|
||||
std::unordered_set<std::string> history_hinted_txids_;
|
||||
// The between-blocks probe's progress through the z-addresses since what it watches last changed.
|
||||
services::ProbeRotation recent_probe_rotation_;
|
||||
// The z-addresses of the last successful address enumeration this session: a never-scanned address new
|
||||
// to it (an imported key) resets the sent sweep's watermark, even if the list in between came back blank.
|
||||
std::unordered_set<std::string> history_listed_z_;
|
||||
|
||||
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
|
||||
// txids we've already enriched. Keyed by txid.
|
||||
@@ -1428,6 +1798,7 @@ private:
|
||||
bool addresses_dirty_ = true; // true → refreshAddresses() will run
|
||||
bool address_validation_cache_dirty_ = true;
|
||||
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
|
||||
int mining_tx_poll_block_ = -1; // C119: tip of the last dispatched Mining-tab poll
|
||||
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
|
||||
bool rescan_status_poll_in_progress_ = false;
|
||||
// True once we've actually observed the rescan running (daemon restarted into -rescan warmup).
|
||||
@@ -1446,6 +1817,10 @@ private:
|
||||
// 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;
|
||||
std::string portfolio_scope_migrated_for_; // scope id the legacy-portfolio-scope migration last ran for
|
||||
bool post_bootstrap_probe_in_flight_ = false; // its detectLowestAvailableBlockHeight() is on worker_
|
||||
int post_bootstrap_probe_attempts_ = 0; // probes that could not tell; gives up after 6
|
||||
std::chrono::steady_clock::time_point post_bootstrap_probe_retry_at_{};
|
||||
// Largest "blocks remaining" seen during the current witness-rebuild phase. The daemon's
|
||||
// "Building Witnesses for block" fraction resets every call (it's re-invoked per connected
|
||||
// block, each walking from its own start height to the tip), so we derive a stable, monotonic
|
||||
@@ -1459,6 +1834,16 @@ private:
|
||||
std::unordered_set<std::string> witness_seen_txids_;
|
||||
int witness_total_txs_ = 0;
|
||||
bool opid_poll_in_progress_ = false;
|
||||
std::atomic<bool> opid_filter_unsupported_{false}; // see opidFilterUnsupported(); set on the worker
|
||||
std::atomic<int> maint_calls_{0}; // maintenance calls in flight (MaintenanceCallScope)
|
||||
std::atomic<bool> maint_abort_{false}; // set by abortMaintenanceCalls(), cleared on connect
|
||||
services::ConsolidationRun consolidation_; // C210 (see consolidation())
|
||||
std::uint64_t rpc_session_ = 0; // ++ in onConnected/onDisconnected: an orphan merge is tracked only within one
|
||||
double run_lock_resync_at_ = 0.0; // steadySeconds() before which the run asks for no lock-state re-read
|
||||
void pumpConsolidation();
|
||||
void postConsolidationCall(int addrIdx);
|
||||
void postConsolidationPoll();
|
||||
void finishConsolidation();
|
||||
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
|
||||
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
|
||||
int consecutive_core_failures_ = 0;
|
||||
@@ -1467,6 +1852,8 @@ private:
|
||||
bool send_progress_active_ = false;
|
||||
int send_submissions_in_flight_ = 0;
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
// Non-User ops only (absent = User): what each op's completion re-reads.
|
||||
std::unordered_map<std::string, services::NetworkRefreshService::OperationOrigin> op_origin_;
|
||||
struct PendingSendInfo {
|
||||
std::string from;
|
||||
std::string to;
|
||||
@@ -1488,6 +1875,10 @@ private:
|
||||
// even when they don't appear in listtransactions or
|
||||
// z_listreceivedbyaddress.
|
||||
std::unordered_set<std::string> send_txids_;
|
||||
// Tip height when each tracked txid was added (TTL backstop, see pruneTrackedSendTxids()).
|
||||
std::unordered_map<std::string, int> send_txid_heights_;
|
||||
static constexpr int kTrackedSendTtlBlocks = 20;
|
||||
void pruneTrackedSendTxids();
|
||||
|
||||
// First-run wizard state
|
||||
WizardPhase wizard_phase_ = WizardPhase::None;
|
||||
@@ -1511,16 +1902,32 @@ private:
|
||||
data::WalletIndex wallet_index_; // per-wallet metadata cache (wallets.json); populated after each load
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
// Background writer for the history snapshot. Declared AFTER transaction_history_cache_ (destroyed first,
|
||||
// so a destructor-only teardown joins it before the cache goes); its jobs reference only that cache.
|
||||
// App::shutdown() drains it (bounded) and joins it once idle; that is the real join (main.cpp _Exit()s past
|
||||
// ~App()).
|
||||
util::CoalescingWriter history_writer_;
|
||||
struct HistoryStoreState {
|
||||
bool have = false; // a snapshot of this session is on disk (submitted or loaded)
|
||||
data::HistorySnapshotSig sig; // its signature
|
||||
std::chrono::steady_clock::time_point lastSubmit{};
|
||||
} history_store_; // UI thread only
|
||||
bool history_store_pending_ = false; // a content change waits out kHistoryMinStoreIntervalSec
|
||||
|
||||
// Lock screen state
|
||||
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
|
||||
bool lock_use_pin_ = true; // true = PIN input, false = passphrase input
|
||||
// The active wallet's PIN vault holds an outdated passphrase (see isPinVaultStale()). Cleared when a
|
||||
// PIN is set (which also re-offers PIN entry) or removed and on a wallet switch; a Change PIN keeps it
|
||||
// (same sealed passphrase).
|
||||
bool pin_vault_stale_ = false;
|
||||
char lock_pin_buf_[16] = {};
|
||||
char lock_passphrase_buf_[256] = {};
|
||||
std::string lock_error_msg_;
|
||||
float lock_error_timer_ = 0.0f;
|
||||
int lock_attempts_ = 0;
|
||||
float lock_lockout_timer_ = 0.0f;
|
||||
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
|
||||
bool lock_unlock_in_progress_ = false;
|
||||
|
||||
// Encrypt wallet dialog state
|
||||
@@ -1559,11 +1966,21 @@ private:
|
||||
char wizard_pin_confirm_buf_[16] = {};
|
||||
std::string wizard_pin_status_;
|
||||
|
||||
// Auto-lock on idle
|
||||
std::chrono::steady_clock::time_point last_interaction_ = std::chrono::steady_clock::now();
|
||||
// Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by
|
||||
// widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21).
|
||||
util::IdleTracker idle_;
|
||||
// C90: back-off clock for the daemon-unlock-expiry reconcile (steady clock, wall-clock independent).
|
||||
std::chrono::steady_clock::time_point unlock_reconcile_retry_at_{};
|
||||
|
||||
// Wall-clock delta for logic timers (see logicDeltaTime()).
|
||||
std::chrono::steady_clock::time_point last_update_time_{};
|
||||
float logic_dt_ = 0.0f;
|
||||
|
||||
// Mine-when-idle: auto-start/stop mining based on system idle state
|
||||
bool idle_mining_active_ = false; // true when mining was auto-started by idle detection
|
||||
std::chrono::steady_clock::time_point idle_mining_next_check_{}; // checkIdleMining runs at 1 Hz
|
||||
std::chrono::steady_clock::time_point idle_gpu_next_sample_{}; // GPU probe at most every 5 s
|
||||
bool idle_gpu_busy_ = false; // last GPU-aware verdict (with hysteresis)
|
||||
bool idle_scaled_to_idle_ = false; // true when threads have been scaled up for idle
|
||||
|
||||
// Private methods - rendering
|
||||
@@ -1633,28 +2050,40 @@ private:
|
||||
|
||||
// Private methods - data refresh
|
||||
void refreshData(); // Orchestrator: dispatches per-category refreshes
|
||||
void refreshCoreData(); // Balance + blockchain info (can use fast_worker_)
|
||||
void refreshAddressData(); // Address lists + balances
|
||||
void refreshTransactionData(); // Transaction list + z_viewtransaction enrichment
|
||||
void refreshRecentTransactionData(); // Lightweight recent/unconfirmed tx poll
|
||||
void refreshCoreData(bool allowBalance = true); // Balance + blockchain info (can use fast_worker_)
|
||||
// Address lists + balances. With no ticket it is an explicit refresh (charged to the scan budget).
|
||||
void refreshAddressData(services::ScanTicket ticket = {});
|
||||
// Transaction history: decides the cycle (services::decideTxCycle) and starts it. Safe to call any
|
||||
// time: it refuses when the node can't be scanned (not connected, locked, warming up, rescanning, busy).
|
||||
// Returns true when a routine scan was wanted but the scan budget held it back (ask again soon).
|
||||
bool refreshTransactionData();
|
||||
void startTransactionCycle(bool full, bool routineAllowed, services::ScanTicket ticket); // Full or Light
|
||||
void startRecentTransactionPoll(bool probe, services::ScanTicket ticket); // between blocks
|
||||
void markTransactionsDirty() { transactions_dirty_ = true; ++tx_dirty_generation_; }
|
||||
// A pass sent before this can't clear it (refreshAddressData compares the generation).
|
||||
void markAddressesDirty() { addresses_dirty_ = true; ++addr_dirty_generation_; }
|
||||
std::vector<std::string> shieldedAddressList() const;
|
||||
services::ShieldedHistoryPass::Work historyScanWork() const;
|
||||
// From the address scan's z_listunspent: addresses holding received notes the history doesn't show
|
||||
// yet (no row, or rows still unconfirmed while the note is mined) become priority scans.
|
||||
void noteHistoryHintsFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& notes);
|
||||
bool refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshBalance(); // Legacy: balance-only refresh (used by specific callers)
|
||||
void refreshAddresses(); // Legacy: standalone address refresh
|
||||
void refreshPrice();
|
||||
void refreshWalletEncryptionState();
|
||||
// C88: reconcile the persisted "encryption requested" flag against one parsed getwalletinfo result
|
||||
// (connect-time ConnectionInit and every Encryption job). Main thread only.
|
||||
void reconcileEncryptionPending(const services::NetworkRefreshService::WalletEncryptionResult& enc);
|
||||
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;
|
||||
bool shouldRefreshRecentTransactions() const;
|
||||
void checkAutoLock();
|
||||
// C90: the daemon's absolute unlock window ran out while the GUI still says "unlocked": re-read getwalletinfo
|
||||
// (once per expiry, backed off) so the lock screen / tx-cache lock follow the daemon.
|
||||
void reconcileUnlockExpiry();
|
||||
void checkIdleMining();
|
||||
};
|
||||
|
||||
|
||||
+1936
-537
File diff suppressed because it is too large
Load Diff
+340
-91
@@ -6,6 +6,8 @@
|
||||
// Split from app.cpp for maintainability.
|
||||
|
||||
#include "app.h"
|
||||
#include "data/pin_vault_policy.h"
|
||||
#include "data/wallet_security_reconcile.h"
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -238,22 +240,13 @@ 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)
|
||||
// Best-effort secure delete (overwrite in place, fsync, unlink) of a plaintext key export, so a full
|
||||
// cleartext dump of every private key is not 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);
|
||||
util::Platform::secureWipeAndRemove(path);
|
||||
}
|
||||
|
||||
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||
@@ -274,6 +267,20 @@ void App::sweepStaleDecryptExports()
|
||||
}
|
||||
|
||||
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
||||
// Both callers have just had encryptwallet succeed. Record it here: the first-run wizard's
|
||||
// deferred path never did, and the reconnect after the restart usually lands in RPC warmup, where
|
||||
// the connect-time getwalletinfo fails and can't correct it.
|
||||
state_.encrypted = true;
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
// encryptwallet succeeded: the wizard's persisted request is satisfied. Clear it HERE (not only at the next
|
||||
// connect) so a quit before the reconnect can't leave a stale flag (C88).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
if (announceRestartStatus) {
|
||||
// Update connection_status_ so the loading overlay explains why
|
||||
@@ -283,6 +290,17 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
|
||||
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
|
||||
daemon_restarting_ = true;
|
||||
// Mark the main client disconnected so the update loop's reconnect path (tryConnect, held back
|
||||
// by daemon_restarting_) picks the restarted node up. Connected-but-locked no longer re-runs
|
||||
// the connect sequence on its own. Not onDisconnected(): its state_.clear() would drop the
|
||||
// encrypted/locked flags just set for the lock screen. Abort any in-flight call first:
|
||||
// disconnect() takes the curl lock, which a worker call (say a balance scan queued behind
|
||||
// encryptwallet) holds for its whole transfer, up to the 30 s timeout. open() clears the abort
|
||||
// when tryConnect reconnects.
|
||||
if (rpc_) {
|
||||
rpc_->requestAbort();
|
||||
rpc_->disconnect();
|
||||
}
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
|
||||
@@ -417,6 +435,8 @@ void App::processDeferredEncryption() {
|
||||
// Finalize PIN settings on main thread
|
||||
if (result.pinProvided) {
|
||||
if (result.pinStored) {
|
||||
pin_vault_stale_ = false;
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
|
||||
@@ -457,7 +477,7 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
|
||||
lock_error_msg_.clear();
|
||||
lock_attempts_ = 0;
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
last_interaction_ = std::chrono::steady_clock::now();
|
||||
idle_.noteActivity();
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
@@ -472,6 +492,7 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = errorMessage;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
|
||||
const int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
|
||||
@@ -481,6 +502,16 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
|
||||
}
|
||||
|
||||
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
|
||||
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
|
||||
void App::applyUnlockNotCounted(const std::string& msg) {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_ = msg;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
}
|
||||
|
||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
lock_unlock_in_progress_ = true;
|
||||
@@ -490,17 +521,39 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
rpc::UnlockFailure cls = rpc::UnlockFailure::Other;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Unlock");
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
cls = rpc::classifyUnlockFailure(e);
|
||||
}
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
return [this, ok, cls, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
if (ok) {
|
||||
applyUnlockSuccess(passphrase, timeout);
|
||||
} else {
|
||||
DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str());
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
switch (cls) {
|
||||
case rpc::UnlockFailure::WrongPassphrase:
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NodeBusy:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NotEncrypted:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
refreshWalletEncryptionState();
|
||||
break;
|
||||
case rpc::UnlockFailure::Other:
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + err_msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
@@ -527,7 +580,7 @@ void App::lockWallet() {
|
||||
if (ok) {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
lockTransactionHistoryCacheSession(); // keeps the history's scan progress
|
||||
lock_failure_warned_ = false;
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
} else {
|
||||
@@ -548,13 +601,19 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = TR("sec_changing_passphrase");
|
||||
// The vault this change applies to, and whether it changes the passphrase at all. The success
|
||||
// callback decides about the vault with the state at THAT time (C87).
|
||||
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
|
||||
const bool samePass = (oldPass == newPass);
|
||||
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this,
|
||||
r,
|
||||
oldPass = std::string(oldPass),
|
||||
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
newPass = std::string(newPass),
|
||||
vaultPath,
|
||||
samePass]() mutable -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
@@ -570,7 +629,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
return [this,
|
||||
ok,
|
||||
err_msg,
|
||||
newPass = std::move(cacheNewPass)]() mutable {
|
||||
newPass = std::move(cacheNewPass),
|
||||
vaultPath,
|
||||
samePass]() mutable {
|
||||
encrypt_in_progress_ = false;
|
||||
if (ok) {
|
||||
encrypt_status_.clear();
|
||||
@@ -579,8 +640,21 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
|
||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||
// New key: the old ciphertext is unreadable with it. Write now unless the unlock just did
|
||||
// (keyId() is part of the content signature, so Now skips only if the same key is submitted).
|
||||
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
|
||||
const bool vaultNow = vault_ && vault_->hasVault();
|
||||
if (data::decideVaultAfterPassphraseChange(true, vaultNow, samePass, vaultPath,
|
||||
vault_ ? vault_->getVaultPath() : std::string()) == data::VaultAfterChange::MarkStale) {
|
||||
// The PIN vault still seals the OLD passphrase. Keep the file (the user removes the PIN
|
||||
// and sets it again), but stop offering the PIN this session and say so.
|
||||
lock_use_pin_ = false;
|
||||
pin_vault_stale_ = true;
|
||||
DEBUG_LOGF("[App] Passphrase changed; the PIN vault is kept but marked out of date\n");
|
||||
ui::Notifications::instance().warning(TR("sec_passphrase_changed_pin_stale"), 10.0f);
|
||||
} else {
|
||||
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||
}
|
||||
} else {
|
||||
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
|
||||
}
|
||||
@@ -615,14 +689,8 @@ 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();
|
||||
lockTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -633,18 +701,6 @@ 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 {
|
||||
@@ -668,6 +724,29 @@ void App::refreshWalletEncryptionState() {
|
||||
});
|
||||
}
|
||||
|
||||
void App::reconcileEncryptionPending(const services::NetworkRefreshService::WalletEncryptionResult& enc)
|
||||
{
|
||||
if (!settings_) return;
|
||||
using Act = data::EncryptionPendingAction;
|
||||
switch (data::decideEncryptionPending(settings_->getEncryptionPending(), enc.ok, enc.encrypted,
|
||||
wallet_security_.hasDeferredEncryption(), encrypt_in_progress_,
|
||||
encryption_incomplete_warned_)) {
|
||||
case Act::Clear:
|
||||
// The wallet is encrypted, however it got there (deferred, Settings, another client).
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
break;
|
||||
case Act::Warn:
|
||||
// Requested but NOT encrypted, and nothing pending/in flight: lost to a quit/crash or failed connect.
|
||||
// The flag stays set, so this recurs each launch until the wallet is actually encrypted (W2-2).
|
||||
encryption_incomplete_warned_ = true;
|
||||
ui::Notifications::instance().warning(TR("sec_encryption_did_not_complete"), 30.0f);
|
||||
break;
|
||||
case Act::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Auto-lock on idle
|
||||
// ===========================================================================
|
||||
@@ -682,17 +761,16 @@ void App::checkAutoLock() {
|
||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
||||
if (miningActive) {
|
||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
||||
last_interaction_ = std::chrono::steady_clock::now();
|
||||
idle_.noteActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
int timeout = settings_ ? settings_->getAutoLockTimeout() : 300;
|
||||
if (timeout <= 0) return; // disabled
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
float elapsed = std::chrono::duration<float>(now - last_interaction_).count();
|
||||
const double elapsed = idle_.idleSeconds();
|
||||
|
||||
if (elapsed >= (float)timeout) {
|
||||
if (elapsed >= (double)timeout) {
|
||||
// Lite has no daemon `walletlock` — App::lockWallet() early-returns without rpc_, so route
|
||||
// lite through lockLiteWallet() (locks the backend + tears down the chat session so no
|
||||
// decrypted store / unlocked DB key survives the idle lock). In full-node builds
|
||||
@@ -703,6 +781,23 @@ void App::checkAutoLock() {
|
||||
}
|
||||
}
|
||||
|
||||
void App::reconcileUnlockExpiry()
|
||||
{
|
||||
// Full node only: Lite has no daemon timer (unlocked_until stays 0), and its lock is the backend's.
|
||||
if (lite_wallet_ || !state_.connected || !rpc_ || !rpc_->isConnected()) return;
|
||||
if (!data::unlockWindowExpired(state_.encrypted, state_.locked, state_.unlocked_until,
|
||||
static_cast<std::int64_t>(std::time(nullptr)))) return;
|
||||
if (lock_unlock_in_progress_ || encrypt_in_progress_ || pin_in_progress_) return; // an unlock/lock/PIN change decides it
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < unlock_reconcile_retry_at_) return;
|
||||
// Back-off, wall-clock independent. refreshEncryptionState() coalesces via Job::Encryption and its apply path
|
||||
// (applyWalletEncryptionResult) is authoritative: unlocked_until == 0 => locked (lock screen + tx-cache
|
||||
// lock); a newer deadline (e.g. a console walletpassphrase, or the lock-screen re-verify) is adopted. If the
|
||||
// daemon clock is skewed and still reports "unlocked", this retries every 30 s, one cheap-ish getwalletinfo.
|
||||
unlock_reconcile_retry_at_ = now + std::chrono::seconds(30);
|
||||
refreshEncryptionState();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mine when idle — auto-start/stop mining based on system idle state
|
||||
// Supports two modes:
|
||||
@@ -732,6 +827,17 @@ void App::checkIdleMining() {
|
||||
// Skip idle mining adjustments while thread benchmark is running
|
||||
if (ui::IsMiningBenchmarkActive()) return;
|
||||
|
||||
// The decisions below work on a seconds scale (the idle delay is tens of seconds or more), so check
|
||||
// at 1 Hz. This used to run every frame: an X11 idle query and, with GPU-aware idle, an nvidia-smi
|
||||
// launch on the UI thread per frame. Steady clock rather than ImGui::GetTime(), whose clock stops
|
||||
// while minimized (update() still runs then).
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
// ...except while a miner this feature started (or scaled up) is running: it must yield on the first
|
||||
// frame that sees the user back, and frames can be seconds apart when the app idles, so a gated frame
|
||||
// may be the only one carrying that input. The idle query is cheap; the GPU probe has its own cache.
|
||||
if (!(idle_mining_active_ || idle_scaled_to_idle_) && now < idle_mining_next_check_) return;
|
||||
idle_mining_next_check_ = now + std::chrono::seconds(1);
|
||||
|
||||
int idleSec = util::Platform::getSystemIdleSeconds();
|
||||
int delay = settings_->getMineIdleDelay();
|
||||
bool isPool = settings_->getPoolMode() || !supportsSoloMining();
|
||||
@@ -739,26 +845,40 @@ void App::checkIdleMining() {
|
||||
bool threadScaling = settings_->getIdleThreadScaling();
|
||||
int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||
|
||||
// GPU-aware idle detection: if enabled, treat GPU utilization >= 10%
|
||||
// as "user active" (e.g. watching a video). Disabled = unrestricted
|
||||
// mode that only looks at keyboard/mouse input.
|
||||
bool gpuBusy = false;
|
||||
if (settings_->getIdleGpuAware()) {
|
||||
int gpuUtil = util::Platform::getGpuUtilization();
|
||||
gpuBusy = (gpuUtil >= 10);
|
||||
}
|
||||
bool systemIdle = (idleSec >= delay) && !gpuBusy;
|
||||
|
||||
// Check if mining is already running (manually started by user)
|
||||
bool miningActive = isPool
|
||||
? (xmrig_manager_ && xmrig_manager_->isRunning())
|
||||
: state_.mining.generate;
|
||||
|
||||
// Thread scaling only adjusts a miner the user started: with none running there is nothing to
|
||||
// decide, or to probe for.
|
||||
if (threadScaling && (!miningActive || mining_toggle_in_progress_.load())) return;
|
||||
|
||||
// GPU-aware idle detection: if enabled, treat GPU utilization >= 10% as "user active" (e.g. watching
|
||||
// a video). Disabled = unrestricted mode that only looks at keyboard/mouse input. The probe can only
|
||||
// matter once input is idle and a decision can change (start/stop mode leaves a miner the user
|
||||
// started alone), so it runs only then, at most every 5 s, with hysteresis (busy at >= 10%, clear
|
||||
// below 5%) so a GPU hovering near 10% can't keep restarting the miner. Unknown (-1) reads as not busy.
|
||||
const bool inputIdle = idleSec >= delay;
|
||||
const bool decisionPossible = threadScaling || !miningActive || idle_mining_active_;
|
||||
bool gpuBusy = false;
|
||||
if (inputIdle && decisionPossible && settings_->getIdleGpuAware()) {
|
||||
if (now >= idle_gpu_next_sample_) {
|
||||
const int gpuUtil = util::Platform::getGpuUtilization();
|
||||
if (gpuUtil >= 10) idle_gpu_busy_ = true;
|
||||
else if (gpuUtil < 5) idle_gpu_busy_ = false;
|
||||
idle_gpu_next_sample_ = now + std::chrono::seconds(5);
|
||||
}
|
||||
gpuBusy = idle_gpu_busy_;
|
||||
} else if (!inputIdle) {
|
||||
idle_gpu_busy_ = false; // input activity decides now; sample afresh once idle again
|
||||
idle_gpu_next_sample_ = {};
|
||||
}
|
||||
bool systemIdle = inputIdle && !gpuBusy;
|
||||
|
||||
if (threadScaling) {
|
||||
// --- Thread scaling mode ---
|
||||
// Mining must already be running (started by user). We just adjust threads.
|
||||
if (!miningActive || mining_toggle_in_progress_.load()) return;
|
||||
|
||||
// Mining is already running (started by user; checked above). We just adjust threads.
|
||||
int activeThreads = settings_->getIdleThreadsActive();
|
||||
int idleThreads = settings_->getIdleThreadsIdle();
|
||||
// Resolve auto values: active defaults to half, idle defaults to all
|
||||
@@ -774,8 +894,7 @@ void App::checkIdleMining() {
|
||||
if (!idle_scaled_to_idle_) {
|
||||
idle_scaled_to_idle_ = true;
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(idleThreads);
|
||||
retargetPoolMining(idleThreads);
|
||||
} else {
|
||||
startMining(idleThreads);
|
||||
}
|
||||
@@ -786,8 +905,7 @@ void App::checkIdleMining() {
|
||||
if (idle_scaled_to_idle_) {
|
||||
idle_scaled_to_idle_ = false;
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(activeThreads);
|
||||
retargetPoolMining(activeThreads);
|
||||
} else {
|
||||
startMining(activeThreads);
|
||||
}
|
||||
@@ -795,13 +913,16 @@ void App::checkIdleMining() {
|
||||
} else {
|
||||
// Mining just started while user is active — ensure active
|
||||
// thread count is applied (grid selection may differ).
|
||||
// Pool: compare with the count last REQUESTED (a restart still queued on the control thread
|
||||
// included), not the xmrig API's last report, which lags a restart by seconds.
|
||||
int currentThreads = isPool
|
||||
? xmrig_manager_->getStats().threads_active
|
||||
? services::effectivePoolThreads(pool_threads_target_.load(std::memory_order_relaxed),
|
||||
xmrig_manager_->getRequestedThreads(),
|
||||
xmrig_manager_->getStats().threads_active)
|
||||
: state_.mining.genproclimit;
|
||||
if (currentThreads > 0 && currentThreads != activeThreads) {
|
||||
if (isPool) {
|
||||
stopPoolMining();
|
||||
startPoolMining(activeThreads);
|
||||
retargetPoolMining(activeThreads);
|
||||
} else {
|
||||
startMining(activeThreads);
|
||||
}
|
||||
@@ -844,6 +965,68 @@ void App::checkIdleMining() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Mining thread-count picks — coalesced (one save + at most one miner change per gesture)
|
||||
// ===========================================================================
|
||||
|
||||
namespace {
|
||||
double miningSteadySeconds()
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void App::selectMiningThreads(int threads)
|
||||
{
|
||||
if (!settings_) return;
|
||||
threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));
|
||||
if (settings_->getPoolThreads() != threads) { // in memory now; saved once when the pick settles
|
||||
settings_->setPoolThreads(threads);
|
||||
mining_threads_save_pending_ = true;
|
||||
}
|
||||
mining_thread_commit_.select(threads, miningSteadySeconds());
|
||||
}
|
||||
|
||||
void App::tickMiningThreadCommit()
|
||||
{
|
||||
if (shutting_down_ || !settings_) return; // beginShutdown() saves settings itself
|
||||
const double now = miningSteadySeconds();
|
||||
|
||||
// The grid holds the commit while the button is down. The release is noticed HERE, not by the tab, so
|
||||
// a drag that ends while the Mining tab isn't drawn (page switch, focus loss) still commits.
|
||||
bool held = false;
|
||||
if (mining_thread_drag_) {
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) held = true;
|
||||
else { mining_thread_drag_ = false; mining_thread_commit_.release(now); }
|
||||
}
|
||||
|
||||
// The benchmark owns the miner's thread count while it runs (its cancel/finish restore it): keep the
|
||||
// pick in settings, drop the miner change.
|
||||
if (ui::IsMiningBenchmarkActive()) {
|
||||
mining_thread_commit_.cancel();
|
||||
mining_solo_threads_pending_ = 0;
|
||||
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
|
||||
return;
|
||||
}
|
||||
|
||||
int threads = 0;
|
||||
if (mining_thread_commit_.take(now, held, threads)) {
|
||||
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
|
||||
retargetPoolMining(threads); // restarts xmrig once, only if it is running
|
||||
mining_solo_threads_pending_ = threads; // setgenerate, once no other toggle is in flight
|
||||
} else if (mining_thread_commit_.pending() && !held) {
|
||||
ui::material::RequestProgressFrame(); // keep frames coming until the debounce deadline
|
||||
}
|
||||
|
||||
// Solo: startMining() drops a call while another setgenerate is in flight, so wait for it (its result
|
||||
// wakes the loop). Applies only while solo mining is on, as before.
|
||||
if (mining_solo_threads_pending_ > 0 && !mining_toggle_in_progress_.load(std::memory_order_relaxed)) {
|
||||
const int t = mining_solo_threads_pending_;
|
||||
mining_solo_threads_pending_ = 0;
|
||||
if (state_.mining.generate) startMining(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Restart Setup Wizard (from Settings)
|
||||
// ===========================================================================
|
||||
@@ -857,6 +1040,12 @@ void App::checkIdleMining() {
|
||||
void App::renderLockScreen() {
|
||||
using namespace ui::material;
|
||||
|
||||
// Keep frames coming while something on the lock screen is moving without input: the unlock in
|
||||
// flight (its dots), the lockout countdown, an error message about to clear.
|
||||
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) {
|
||||
RequestProgressFrame();
|
||||
}
|
||||
|
||||
// Full-window lock overlay: cover the ENTIRE viewport (sidebar included) so the whole screen is
|
||||
// obscured while locked, not just the tab-content area. A borderless, transparent, focused window
|
||||
// over the main viewport (same pattern as the modal overlays; opened from within ##ContentArea,
|
||||
@@ -963,9 +1152,14 @@ void App::renderLockScreen() {
|
||||
cy += ts.y + 20.0f * dp;
|
||||
}
|
||||
|
||||
// Count down only the time since the timers were armed: a failed unlock arms them from an RPC result,
|
||||
// which can land on a frame that ends an idle gap the countdown would otherwise lose at once.
|
||||
const float lockDt = std::min(logic_dt_, std::chrono::duration<float>(
|
||||
std::chrono::steady_clock::now() - lock_timer_armed_at_).count());
|
||||
|
||||
// Lockout timer
|
||||
if (lock_lockout_timer_ > 0.0f) {
|
||||
lock_lockout_timer_ -= ImGui::GetIO().DeltaTime;
|
||||
lock_lockout_timer_ -= lockDt;
|
||||
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
|
||||
|
||||
char msg[128];
|
||||
@@ -1072,7 +1266,7 @@ void App::renderLockScreen() {
|
||||
|
||||
// Error message
|
||||
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
|
||||
lock_error_timer_ -= ImGui::GetIO().DeltaTime;
|
||||
lock_error_timer_ -= lockDt;
|
||||
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
||||
dl->AddText(captionFont, captionFont->LegacySize,
|
||||
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
||||
@@ -1135,22 +1329,30 @@ void App::renderLockScreen() {
|
||||
// Use fast-lane worker for priority unlock.
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
// The vault this attempt opens: a wallet switch before the answer must not mark the new
|
||||
// wallet's PIN out of date.
|
||||
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
|
||||
if (w) {
|
||||
w->post([this, r, pin, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this, r, pin, timeout, vaultPath]() -> rpc::RPCWorker::MainCb {
|
||||
// Heavy Argon2id derivation runs here (worker thread)
|
||||
std::string passphrase;
|
||||
bool vaultOk = vault_ && vault_->retrieve(pin, passphrase);
|
||||
|
||||
if (!vaultOk) {
|
||||
bool noVault = !vault_ || !vault_->hasVault();
|
||||
return [this, noVault]() {
|
||||
if (noVault) {
|
||||
// Vault file missing — switch to passphrase mode. Not a failed
|
||||
// attempt, so no counter bump / lockout escalation.
|
||||
// A file no PIN can open (zero-filled by a removeVault() whose unlink failed,
|
||||
// truncated, unknown version) counts as no vault: failing it is not a wrong PIN.
|
||||
bool malformed = !noVault && !vault_->isWellFormed();
|
||||
return [this, noVault, malformed]() {
|
||||
if (noVault || malformed) {
|
||||
// Vault file missing or unreadable — switch to passphrase mode. Not a
|
||||
// failed attempt, so no counter bump / lockout escalation.
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_ = TR("pin_not_set");
|
||||
lock_error_msg_ = noVault ? TR("pin_not_set") : TR("sec_pin_unreadable");
|
||||
lock_use_pin_ = false;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_error_timer_ = noVault ? 3.0f : 6.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
if (malformed) DEBUG_LOGF("[App] PIN unlock: the vault file is malformed; no penalty\n");
|
||||
} else {
|
||||
applyUnlockFailure(TR("incorrect_pin"));
|
||||
}
|
||||
@@ -1160,6 +1362,7 @@ void App::renderLockScreen() {
|
||||
// Vault decrypted — now unlock wallet via RPC (also on worker thread)
|
||||
bool rpcOk = false;
|
||||
std::string rpcErr;
|
||||
int rpcCode = 0;
|
||||
try {
|
||||
if (r && r->isConnected()) {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
@@ -1167,6 +1370,8 @@ void App::renderLockScreen() {
|
||||
} else {
|
||||
rpcErr = TR("sec_not_connected_to_daemon");
|
||||
}
|
||||
} catch (const rpc::RpcError& e) {
|
||||
rpcErr = e.what(); rpcCode = e.code;
|
||||
} catch (const std::exception& e) {
|
||||
rpcErr = e.what();
|
||||
}
|
||||
@@ -1176,12 +1381,28 @@ void App::renderLockScreen() {
|
||||
applyUnlockSuccess(passphrase, timeout);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
} else if (data::isStaleVaultUnlockFailure(/*vaultDecrypted=*/true, rpcCode)) {
|
||||
// The PIN was right (the vault opened) but the wallet refuses the passphrase it
|
||||
// seals: it was changed after the PIN was set. Not a failed attempt (no lockout)
|
||||
// and the vault is kept; use the passphrase and mark the PIN out of date.
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
return [this, vaultPath]() {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_use_pin_ = false;
|
||||
if (vault_ && vault_->getVaultPath() == vaultPath) pin_vault_stale_ = true;
|
||||
lock_error_msg_ = TR("sec_pin_stale_msg");
|
||||
lock_error_timer_ = 6.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
DEBUG_LOGF("[App] PIN unlock: the wallet refused the vault's passphrase (-14); "
|
||||
"PIN marked out of date, no penalty\n");
|
||||
};
|
||||
} else {
|
||||
// Vault decrypt succeeded but the RPC failed — still a failed unlock,
|
||||
// so route through applyUnlockFailure so the lockout curve applies
|
||||
// (this path previously bumped the counter but skipped the lockout math).
|
||||
// Vault decrypt succeeded (the PIN was correct) and the RPC failed for a
|
||||
// reason other than -14 (busy node, warmup, transport error). That is never
|
||||
// a wrong credential, so it must not count toward the lockout.
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
@@ -1397,6 +1618,8 @@ void App::renderEncryptWalletDialog() {
|
||||
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
pin_vault_stale_ = false;
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
pin_in_progress_ = false;
|
||||
@@ -1460,6 +1683,14 @@ void App::renderEncryptWalletDialog() {
|
||||
DialogWarningHeader(TR("wiz_encrypt_warning"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// This wallet has a PIN: it keeps the old passphrase and goes out of date (C87).
|
||||
if (hasPinVault()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
|
||||
ImGui::TextWrapped("%s", TR("change_pass_pin_note"));
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted(TR("change_pass_current"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
||||
@@ -1489,7 +1720,8 @@ void App::renderEncryptWalletDialog() {
|
||||
|
||||
// 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_);
|
||||
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
||||
changePassphrase(std::string(change_old_pass_buf_),
|
||||
std::string(change_new_pass_buf_));
|
||||
@@ -1692,6 +1924,12 @@ void App::renderDecryptWalletDialog() {
|
||||
state_.sync.rescanning = true;
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
|
||||
// The user chose to remove encryption: any wizard-era "encryption pending" request is moot
|
||||
// (else the next connect would warn that encryption "did not complete") (C88).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
// Clear encryption state early — vault/PIN removed now,
|
||||
// wallet file is already unencrypted
|
||||
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
|
||||
@@ -1740,9 +1978,8 @@ void App::renderDecryptWalletDialog() {
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
transactions_dirty_ = true;
|
||||
invalidateAddressValidationCache(); // marks the addresses dirty
|
||||
markTransactionsDirty();
|
||||
last_tx_block_height_ = -1;
|
||||
|
||||
refreshWalletEncryptionState();
|
||||
@@ -1972,7 +2209,8 @@ void App::renderPinDialogs() {
|
||||
|
||||
// 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_);
|
||||
// Not while a passphrase change runs: it could land after the old passphrase is verified.
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = TR("sec_verifying_passphrase");
|
||||
@@ -1986,11 +2224,15 @@ void App::renderPinDialogs() {
|
||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||
|
||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// C90: decided on the main thread (state_ is main-thread data). An unlocked wallet is verified
|
||||
// with the time it has left and stays unlocked, so the GUI and the daemon keep agreeing.
|
||||
const data::SetPinVerifyPlan verifyPlan = data::planSetPinVerify(
|
||||
state_.isUnlocked(), state_.unlocked_until, static_cast<std::int64_t>(std::time(nullptr)));
|
||||
worker_->post([this, passphrase, pin, verifyPlan]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
rpc_->call("walletpassphrase", {passphrase, verifyPlan.timeoutSecs});
|
||||
} 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());
|
||||
@@ -2006,14 +2248,20 @@ void App::renderPinDialogs() {
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
// Lock wallet back — only if it was locked before the check (C90).
|
||||
if (verifyPlan.relockAfter) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
return [this, storeOk]() {
|
||||
// Re-read the daemon's lock state either way: it is the truth and self-heals any drift.
|
||||
refreshEncryptionState();
|
||||
if (storeOk) {
|
||||
pin_vault_stale_ = false; // the vault now seals the verified passphrase
|
||||
lock_use_pin_ = true;
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
pin_status_.clear();
|
||||
@@ -2172,6 +2420,7 @@ void App::renderPinDialogs() {
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
vault_->removeVault();
|
||||
pin_vault_stale_ = false;
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
pin_status_.clear();
|
||||
|
||||
+164
-26
@@ -34,8 +34,13 @@
|
||||
#include "ui/windows/address_transfer_dialog.h"
|
||||
#include "ui/windows/key_export_dialog.h"
|
||||
#include "ui/windows/contacts_tab.h"
|
||||
#include "ui/windows/chat_tab.h"
|
||||
#include "ui/windows/folder_picker.h"
|
||||
#include "ui/pages/settings_page.h"
|
||||
#include "chat/chat_protocol.h"
|
||||
#include "util/i18n.h"
|
||||
#include "util/platform.h"
|
||||
#include "util/private_temp_dir.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
|
||||
#include "imgui.h"
|
||||
@@ -95,12 +100,11 @@ const char* kDemoZAddr =
|
||||
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
|
||||
|
||||
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
|
||||
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
|
||||
// so the user's persisted address book is never touched even if the sweep is interrupted.
|
||||
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
|
||||
// modes render with data, and restore the real book afterward. sweepBegin() snapshots the real book on
|
||||
// its first call only (this setup re-runs every frame) and latches save()/load(), so the user's
|
||||
// persisted address book is never touched, even if the sweep is interrupted.
|
||||
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("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
|
||||
@@ -109,14 +113,17 @@ void seedSweepContacts(App& a)
|
||||
e2.scope = a.activeWalletIdentityHash();
|
||||
data::AddressBookEntry e3("cold savings",
|
||||
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
|
||||
a.addressBook().sweepSetEntries({ e1, e2, e3 });
|
||||
a.addressBook().sweepBegin({ e1, e2, e3 });
|
||||
}
|
||||
// The contacts view mode is restored by Settings::sweepEnd() at sweep end.
|
||||
void restoreSweepContacts(App& a)
|
||||
{
|
||||
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
|
||||
s_contactsSweepBackup.clear();
|
||||
if (a.settings()) a.settings()->setContactsViewMode(0);
|
||||
a.addressBook().sweepEnd();
|
||||
}
|
||||
|
||||
// The full sweep's sandbox: a fresh owner-only dir under the temp dir. Its demo wallet files live in
|
||||
// <sandbox>/DRAGONX (and DRAGONX-many, extra-wallets, picker-demo), never in the real datadir or config dir.
|
||||
const char* kSweepSandboxPrefix = "obsidiandragon-sweep-";
|
||||
} // namespace
|
||||
|
||||
std::string App::screenshotDir() const
|
||||
@@ -132,6 +139,11 @@ std::string App::screenshotFullDir() const
|
||||
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
|
||||
void App::installDemoWalletData()
|
||||
{
|
||||
// Persistence latches first: until clearDemoWalletData(), nothing the demo surfaces put into
|
||||
// Settings or the wallet index (or, via seedSweepContacts, the address book) can reach disk.
|
||||
if (settings_) settings_->sweepBegin();
|
||||
wallet_index_.sweepBegin();
|
||||
|
||||
// Snapshot the state_ fields we mutate (WalletState isn't copy-assignable — reference aliases).
|
||||
auto& s = sweep_state_snapshot_;
|
||||
s.connected = state_.connected; s.warming_up = state_.warming_up;
|
||||
@@ -146,6 +158,7 @@ void App::installDemoWalletData()
|
||||
s.transactions = state_.transactions;
|
||||
s.market_price_usd = state_.market.price_usd;
|
||||
s.market_change_24h = state_.market.change_24h;
|
||||
s.wizard_phase = wizard_phase_;
|
||||
s.valid = true;
|
||||
|
||||
applyHealthyDemoState();
|
||||
@@ -201,6 +214,7 @@ void App::installDemoWalletData()
|
||||
tx("deadbeef000000000000000000000000000000000000000000000000000000000000", "receive", 0.50000000,
|
||||
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
|
||||
};
|
||||
state_.touchTransactions();
|
||||
|
||||
// Demo portfolio groups so the Market tab's row styles render with real-looking data. Snapshot
|
||||
// the user's real portfolio first; setPortfolio* only mutate memory (save() is never called
|
||||
@@ -228,7 +242,7 @@ void App::installDemoWalletData()
|
||||
settings_->setPortfolioStyle(0);
|
||||
}
|
||||
|
||||
seedChatDemoData();
|
||||
sweep_chat_identity_fabricated_ = seedChatDemoData();
|
||||
}
|
||||
|
||||
void App::applyHealthyDemoState()
|
||||
@@ -246,6 +260,32 @@ void App::applyHealthyDemoState()
|
||||
wizard_phase_ = WizardPhase::None;
|
||||
}
|
||||
|
||||
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
|
||||
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
|
||||
const char* App::fullSweepBlockedReason() const
|
||||
{
|
||||
if (settings_ && (settings_->getSeedMigrationPending() || settings_->getSeedMigrationSweepSubmitted() ||
|
||||
!settings_->getSeedMigrationSweepTxid().empty() ||
|
||||
!settings_->getSeedMigrationSweepOpid().empty()))
|
||||
return "a migrate-to-seed is pending (seed_migration_* settings)";
|
||||
if (show_seed_migration_ || seed_migration_step_ != SeedMigrationStep::Intro || seed_migration_in_flight_)
|
||||
return "the migrate-to-seed dialog is open";
|
||||
if (wallet_switch_phase_.load() != 0 || wallet_switch_dialog_open_.load() ||
|
||||
!pending_switch_wallet_file_.empty())
|
||||
return "a wallet switch is in progress";
|
||||
if (wallet_security_workflow_.inProgress() || wallet_security_workflow_.importActive())
|
||||
return "an encryption workflow is in progress";
|
||||
if (encrypt_in_progress_ || pin_in_progress_ || lock_unlock_in_progress_)
|
||||
return "an encrypt, PIN or lock/unlock operation is in progress";
|
||||
if (show_seed_backup_ || !seed_backup_phrase_.empty())
|
||||
return "the seed-phrase backup dialog is open";
|
||||
if (bootstrap_ || bootstrap_downloading_)
|
||||
return "a bootstrap is in progress";
|
||||
if (long_lane_.active() || import_in_progress_ || runtime_rescan_active_)
|
||||
return "a key import or rescan is in progress";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void App::clearDemoWalletData()
|
||||
{
|
||||
auto& s = sweep_state_snapshot_;
|
||||
@@ -261,12 +301,68 @@ void App::clearDemoWalletData()
|
||||
state_.totalBalance = s.totalBalance; state_.unconfirmedBalance = s.unconfirmedBalance;
|
||||
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
|
||||
state_.transactions = s.transactions;
|
||||
state_.touchTransactions();
|
||||
state_.market.price_usd = s.market_price_usd;
|
||||
state_.market.change_24h = s.market_change_24h;
|
||||
state_.market.price_history = g_marketHistorySnapshot;
|
||||
g_marketHistorySnapshot.clear();
|
||||
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
|
||||
s = SweepStateSnapshot{}; // invalidate
|
||||
|
||||
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
|
||||
// e.g. a file a Windows probe thread still holds).
|
||||
ui::WalletsDialog::hide();
|
||||
ui::FolderPicker::close();
|
||||
ui::WalletsDialog::setSweepDataDir({});
|
||||
if (!sweep_sandbox_.empty()) {
|
||||
if (!util::removePrivateTempDir(sweep_sandbox_, kSweepSandboxPrefix))
|
||||
DEBUG_LOGF("[Sweep] could not fully remove the sandbox %s\n", sweep_sandbox_.c_str());
|
||||
sweep_sandbox_.clear();
|
||||
}
|
||||
|
||||
// Migrate-to-seed state the mig() surfaces faked. Safe to reset: the preflight refused the sweep
|
||||
// while a real migration was pending or its dialog was open.
|
||||
show_seed_migration_ = false;
|
||||
seed_migration_step_ = SeedMigrationStep::Intro;
|
||||
if (!seed_migration_seed_.empty())
|
||||
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||
seed_migration_seed_.clear();
|
||||
seed_migration_dest_.clear();
|
||||
seed_migration_sweep_txid_.clear();
|
||||
seed_migration_status_.clear();
|
||||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||||
seed_migration_precheck_started_ = false;
|
||||
seed_migration_backed_up_ = false;
|
||||
seed_migration_balance_ = 0.0;
|
||||
seed_migration_balance_loaded_ = false;
|
||||
seed_migration_nofunds_confirmed_ = false;
|
||||
seed_migration_sweep_confs_ = 0;
|
||||
seed_migration_legacy_remaining_ = -1.0;
|
||||
|
||||
// Chat: drop the demo conversations (in-memory only). If the sweep had to fabricate the demo
|
||||
// identity, drop it too and re-arm provisioning, or chat would run on that throwaway key for the rest
|
||||
// of the session. No real identity existed, so the DB was never unlocked under it.
|
||||
if (chat::hushChatFeatureEnabledAtBuild()) {
|
||||
for (const char* cid : {"demo-1", "demo-2", "demo-3"})
|
||||
chat_service_.store().eraseConversation(cid);
|
||||
if (sweep_chat_identity_fabricated_) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_seen_watermark_.clear();
|
||||
ui::ResetChatTab();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
++chat_session_generation_; // discard any identity fetch that was in flight
|
||||
}
|
||||
}
|
||||
sweep_chat_identity_fabricated_ = false;
|
||||
|
||||
// Address book and wallet index: back to the real data (no disk write).
|
||||
addressBook().sweepEnd();
|
||||
wallet_index_.sweepEnd();
|
||||
|
||||
// Restore the user's real portfolio (demo groups were in-memory only).
|
||||
if (g_pfSnapshotValid && settings_) {
|
||||
settings_->setPortfolioEntries(g_pfSnapshot);
|
||||
@@ -274,6 +370,10 @@ void App::clearDemoWalletData()
|
||||
}
|
||||
g_pfSnapshot.clear();
|
||||
g_pfSnapshotValid = false;
|
||||
|
||||
// LAST: restore the latched settings and flush once if a save was suppressed. Anything restored
|
||||
// after this point would be flushed with its demo value.
|
||||
if (settings_) settings_->sweepEnd();
|
||||
}
|
||||
|
||||
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -455,7 +555,13 @@ void App::buildSweepCatalog()
|
||||
a.show_seed_backup_ = true; a.seed_backup_fetch_started_ = true;
|
||||
a.seed_backup_loading_ = false; a.seed_backup_no_mnemonic_ = false;
|
||||
a.seed_backup_status_.clear();
|
||||
if (a.seed_backup_phrase_.empty()) a.seed_backup_phrase_ = kDemoMnemonic;
|
||||
// Always the demo phrase: anything else here could be a real mnemonic, and it would be
|
||||
// written into this surface's screenshots for every skin.
|
||||
if (a.seed_backup_phrase_ != kDemoMnemonic) {
|
||||
if (!a.seed_backup_phrase_.empty())
|
||||
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
|
||||
a.seed_backup_phrase_ = kDemoMnemonic;
|
||||
}
|
||||
},
|
||||
[](App& a) {
|
||||
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
|
||||
@@ -509,12 +615,13 @@ void App::buildSweepCatalog()
|
||||
mig("modal-migrate-error", SeedMigrationStep::Error,
|
||||
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
|
||||
|
||||
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway)
|
||||
// datadir + cache their metadata so the list renders populated.
|
||||
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the sandbox's
|
||||
// datadir + cache their metadata (latched, never saved) so the list renders populated.
|
||||
add("modal-wallets", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
const std::string dd = a.sweep_sandbox_ + "/DRAGONX";
|
||||
fs::create_directories(dd, ec);
|
||||
if (!fs::exists(dd + "/wallet.dat", ec))
|
||||
std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0');
|
||||
@@ -529,26 +636,26 @@ void App::buildSweepCatalog()
|
||||
a.walletIndex().upsert(e1);
|
||||
a.walletIndex().upsert(e2);
|
||||
// An out-of-datadir wallet (in an extra folder) so the Import action shows too.
|
||||
const std::string extra = util::Platform::getConfigDir() + "extra-wallets";
|
||||
const std::string extra = a.sweep_sandbox_ + "/extra-wallets";
|
||||
fs::create_directories(extra, ec);
|
||||
if (!fs::exists(extra + "/my-wallet.dat", ec))
|
||||
std::ofstream(extra + "/my-wallet.dat", std::ios::binary) << std::string(80000, '\0');
|
||||
a.walletIndex().addExtraFolder(extra);
|
||||
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
|
||||
ui::WalletsDialog::setSweepDataDir(dd);
|
||||
ui::WalletsDialog::show(&a);
|
||||
},
|
||||
[](App&) { ui::WalletsDialog::hide(); });
|
||||
|
||||
// Many wallets — exercises the viewport-cap path: the card can't fit all rows, so the list
|
||||
// must scroll internally while the create/scan/footer controls stay pinned (esp. at 150%).
|
||||
// Teardown removes the extra files it dropped so the plain modal-wallets surface stays a
|
||||
// clean 3-wallet reference (both surfaces share the one throwaway datadir).
|
||||
static const char* kManyExtra[] = {"wallet-cold.dat", "wallet-trading.dat", "wallet-mining.dat",
|
||||
"wallet-donations.dat", "wallet-2023.dat", "wallet-payroll.dat"};
|
||||
// Its own sandbox datadir keeps the plain modal-wallets surface a clean 3-wallet reference;
|
||||
// nothing is ever deleted here (the whole sandbox goes at sweep end).
|
||||
add("modal-wallets-many", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
const std::string dd = a.sweep_sandbox_ + "/DRAGONX-many";
|
||||
fs::create_directories(dd, ec);
|
||||
const char* names[] = {"wallet.dat", "wallet-savings.dat", "wallet-cold.dat",
|
||||
"wallet-trading.dat", "wallet-mining.dat", "wallet-donations.dat",
|
||||
@@ -562,14 +669,10 @@ void App::buildSweepCatalog()
|
||||
a.walletIndex().upsert(e);
|
||||
}
|
||||
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
|
||||
ui::WalletsDialog::setSweepDataDir(dd);
|
||||
ui::WalletsDialog::show(&a);
|
||||
},
|
||||
[](App& a) {
|
||||
ui::WalletsDialog::hide();
|
||||
std::error_code ec;
|
||||
const std::string dd = util::Platform::getDragonXDataDir();
|
||||
for (const char* n : kManyExtra) fs::remove(dd + "/" + n, ec);
|
||||
});
|
||||
[](App&) { ui::WalletsDialog::hide(); });
|
||||
}
|
||||
|
||||
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
|
||||
@@ -700,13 +803,15 @@ void App::buildSweepCatalog()
|
||||
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
|
||||
add("modal-folder-picker", ui::NavPage::Settings,
|
||||
[](App& a) {
|
||||
if (a.sweep_sandbox_.empty()) return;
|
||||
std::error_code ec;
|
||||
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
|
||||
const std::string demo = a.sweep_sandbox_ + "/picker-demo";
|
||||
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
|
||||
fs::create_directories(demo + "/" + sub, ec);
|
||||
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
|
||||
if (!fs::exists(demo + "/" + f, ec))
|
||||
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
|
||||
ui::WalletsDialog::setSweepDataDir(a.sweep_sandbox_ + "/DRAGONX");
|
||||
ui::WalletsDialog::show(&a);
|
||||
ui::FolderPicker::open(demo, [](const std::string&) {});
|
||||
},
|
||||
@@ -716,7 +821,24 @@ void App::buildSweepCatalog()
|
||||
// ── State machine ───────────────────────────────────────────────────────────────────────────
|
||||
void App::startSweepImpl(bool full)
|
||||
{
|
||||
#if !DRAGONX_ENABLE_UI_SWEEP
|
||||
// Dev tool only (CMake DRAGONX_ENABLE_UI_SWEEP, OFF in every release); an undefined macro counts as 0.
|
||||
(void)full;
|
||||
DEBUG_LOGF("[Sweep] disabled in this build\n");
|
||||
return;
|
||||
#else
|
||||
if (screenshot_sweep_active_) return;
|
||||
// Sentinel: a release binary must not contain this literal (strings <bin> | grep -c ... == 0).
|
||||
DEBUG_LOGF("[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN\n");
|
||||
|
||||
// The full sweep fakes wallet, migration, switch and security state: never over a real workflow.
|
||||
if (full) {
|
||||
if (const char* why = fullSweepBlockedReason()) {
|
||||
DEBUG_LOGF("[Sweep] full sweep refused: %s\n", why);
|
||||
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sweep_skins_.clear();
|
||||
for (const auto& sk : ui::schema::SkinManager::instance().available())
|
||||
@@ -734,6 +856,21 @@ void App::startSweepImpl(bool full)
|
||||
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
|
||||
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
|
||||
|
||||
if (full) {
|
||||
// A fresh private sandbox for the demo wallet files: never the real datadir or config dir.
|
||||
std::error_code tec;
|
||||
const fs::path tmp = fs::temp_directory_path(tec);
|
||||
sweep_sandbox_ = tec ? std::string() : util::makePrivateTempDir(tmp, kSweepSandboxPrefix);
|
||||
if (sweep_sandbox_.empty()) {
|
||||
DEBUG_LOGF("[Sweep] full sweep refused: could not create a private sandbox under %s\n",
|
||||
tmp.string().c_str());
|
||||
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
|
||||
return;
|
||||
}
|
||||
ui::WalletsDialog::setSweepDataDir(sweep_sandbox_ + "/DRAGONX");
|
||||
DEBUG_LOGF("[Sweep] sandbox %s\n", sweep_sandbox_.c_str());
|
||||
}
|
||||
|
||||
sweep_full_ = full;
|
||||
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
||||
buildSweepCatalog();
|
||||
@@ -759,6 +896,7 @@ void App::startSweepImpl(bool full)
|
||||
ui::Notifications::instance().info(full ? "Full UI sweep running…" : "Screenshot sweep running…");
|
||||
DEBUG_LOGF("[Sweep] %s -> %s (%d skins x %d surfaces)\n", full ? "FULL" : "tabs",
|
||||
sweep_dir_.c_str(), (int)sweep_skins_.size(), (int)sweep_targets_.size());
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::startScreenshotSweep() { startSweepImpl(false); }
|
||||
|
||||
+12
-9
@@ -87,6 +87,7 @@ void App::restartWizard()
|
||||
|
||||
// Disconnect RPC
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Wizard restart");
|
||||
@@ -357,13 +358,9 @@ void App::renderFirstRunWizard() {
|
||||
if (lang.first == wiz_cur_lang) { wiz_language_index = idx; break; }
|
||||
idx++;
|
||||
}
|
||||
// Apply loaded settings to runtime so visuals match slider values
|
||||
ui::effects::setLowSpecMode(wiz_low_spec);
|
||||
ui::effects::ImGuiAcrylic::ApplyBlurAmount(wiz_blur_amount);
|
||||
ui::effects::ImGuiAcrylic::SetUIOpacity(wiz_ui_opacity);
|
||||
ui::effects::ThemeEffects::instance().setEnabled(wiz_theme_effects);
|
||||
ui::effects::ThemeEffects::instance().setReducedTransparency(!wiz_theme_effects);
|
||||
ui::ConsoleTab::s_scanline_enabled = wiz_scanline;
|
||||
// Apply the loaded settings to the runtime (same low-spec-aware path as startup) so the
|
||||
// visuals match the slider values
|
||||
applyVisualSettings();
|
||||
wiz_appearance_init = true;
|
||||
}
|
||||
|
||||
@@ -538,6 +535,9 @@ void App::renderFirstRunWizard() {
|
||||
settings_->setScanlineEnabled(wiz_scanline);
|
||||
settings_->setLowSpecMode(wiz_low_spec);
|
||||
settings_->save();
|
||||
// Make the runtime match what was just saved, both ways: unticking a low-spec that was
|
||||
// loaded rather than ticked here has no snapshot, so the branch above pushes nothing.
|
||||
applyVisualSettings();
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::SameLine();
|
||||
@@ -826,9 +826,12 @@ void App::renderFirstRunWizard() {
|
||||
float cancelBX = rightX + (colW - cancelW) * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cancelBX, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
// Disabled while the old chain data is being replaced (not cancellable).
|
||||
ImGui::BeginDisabled(!prog.cancellable);
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(cancelW, cancelH))) {
|
||||
bootstrap_->cancel();
|
||||
bootstrap_->requestCancel(); // a no-op once the old chain data is being replaced
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
cy += cancelH;
|
||||
|
||||
@@ -1398,7 +1401,7 @@ void App::renderFirstRunWizard() {
|
||||
(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.
|
||||
// connect in App::reconcileEncryptionPending (W2-2). Saved with the wizard state below.
|
||||
settings_->setEncryptionPending(true);
|
||||
|
||||
// Clear sensitive buffers
|
||||
|
||||
@@ -132,6 +132,55 @@ bool ChatDatabase::append(const ChatMessage& message)
|
||||
return sqlite3_changes(db_) > 0;
|
||||
}
|
||||
|
||||
int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return 0;
|
||||
if (messages.empty()) return 0;
|
||||
|
||||
struct Row { std::string dedup; std::vector<unsigned char> nonce, cipher; };
|
||||
std::vector<Row> rows;
|
||||
rows.reserve(messages.size());
|
||||
for (const auto& m : messages) {
|
||||
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
||||
try {
|
||||
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
||||
std::vector<unsigned char> nonce, cipher;
|
||||
const bool ok = encrypt(plain, nonce, cipher);
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
if (!ok) continue;
|
||||
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
||||
} catch (const std::exception&) {
|
||||
continue; // skip only this message; never open a txn with bad data
|
||||
}
|
||||
}
|
||||
if (rows.empty()) return 0;
|
||||
|
||||
if (!exec("BEGIN")) return 0; // match deleteMessages() in-file style
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
exec("ROLLBACK");
|
||||
return 0;
|
||||
}
|
||||
int inserted = 0;
|
||||
bool ok = true;
|
||||
for (const auto& r : rows) {
|
||||
sqlite3_reset(stmt);
|
||||
sqlite3_clear_bindings(stmt);
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, r.dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 3, r.nonce.data(), static_cast<int>(r.nonce.size()), SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 4, r.cipher.data(), static_cast<int>(r.cipher.size()), SQLITE_TRANSIENT);
|
||||
if (sqlite3_step(stmt) != SQLITE_DONE) { ok = false; break; }
|
||||
if (sqlite3_changes(db_) > 0) ++inserted;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (!exec(ok ? "COMMIT" : "ROLLBACK")) return 0;
|
||||
return ok ? inserted : 0;
|
||||
}
|
||||
|
||||
bool ChatDatabase::upsert(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
|
||||
@@ -44,6 +44,12 @@ public:
|
||||
// Returns true if newly inserted; false on duplicate or while locked.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Persist many messages in ONE transaction (INSERT OR IGNORE, same dedup key as append()).
|
||||
// Serializes+encrypts every message BEFORE opening the transaction, so a non-UTF-8 body is
|
||||
// skipped without leaving a transaction open. Preserves input order (load() relies on rowid).
|
||||
// Returns the number of rows actually inserted. No-op (returns 0) while locked or on empty input.
|
||||
int appendBatch(const std::vector<ChatMessage>& messages);
|
||||
|
||||
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
|
||||
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
|
||||
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
|
||||
|
||||
@@ -16,11 +16,13 @@ ChatService::~ChatService() {
|
||||
void ChatService::setIdentity(const ChatKeyPair& keys) {
|
||||
identity_ = keys;
|
||||
has_identity_ = true;
|
||||
undecryptable_.clear(); // another identity may decrypt them
|
||||
}
|
||||
|
||||
void ChatService::clearIdentity() {
|
||||
wipeChatKeyPair(identity_);
|
||||
has_identity_ = false;
|
||||
undecryptable_.clear();
|
||||
}
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
@@ -36,7 +38,11 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
int added = 0;
|
||||
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
||||
for (const auto& meta : metadata) {
|
||||
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||
// decrypt for all but the new messages.
|
||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
||||
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
||||
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
|
||||
@@ -78,11 +84,23 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
} else {
|
||||
message.kind = ChatMessageKind::Message;
|
||||
// Keyed by the decrypt's inputs, not only (txid, position): another scan path that pairs a
|
||||
// different header with this payload gets its own try.
|
||||
const std::string failKey = ChatStore::messageKey(meta.txid, meta.payload_position) + "|" +
|
||||
meta.sender_public_key_hex + "|" + meta.secretstream_header_hex;
|
||||
if (undecryptable_.count(failKey)) continue;
|
||||
++decrypt_attempts_;
|
||||
std::string plaintext;
|
||||
const ChatCryptoStatus status = decryptIncoming(
|
||||
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
|
||||
meta.payload_memo, plaintext);
|
||||
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
|
||||
if (status != ChatCryptoStatus::Ok) { // drop undecryptable silently, and don't try it again
|
||||
if (status != ChatCryptoStatus::SodiumInitFailed) { // not a property of the message
|
||||
if (undecryptable_.size() >= 4096) undecryptable_.clear();
|
||||
undecryptable_.insert(failKey);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
message.body = std::move(plaintext);
|
||||
}
|
||||
|
||||
@@ -90,23 +108,26 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
// session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but
|
||||
// the store dedup prevents a duplicate write.
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
fresh.push_back(message);
|
||||
++added;
|
||||
// Every ingested message is incoming — report its cid so the caller can notify without
|
||||
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
|
||||
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
|
||||
}
|
||||
}
|
||||
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
||||
return added;
|
||||
}
|
||||
|
||||
void ChatService::loadFromDatabase() {
|
||||
if (!db_) return;
|
||||
for (const auto& message : db_->load()) {
|
||||
auto rows = db_->load();
|
||||
store_.reserve(store_.size() + rows.size());
|
||||
for (auto& message : rows) {
|
||||
// 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);
|
||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; // evaluate before the move
|
||||
store_.append(std::move(message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
@@ -99,9 +100,15 @@ public:
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
std::uint64_t decryptAttempts() const { return decrypt_attempts_; } // tests / diagnostics
|
||||
|
||||
private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
// Decrypt failures, keyed by what the decrypt reads (bounded). A rescan re-sends every message ever
|
||||
// received, so without it each poll re-ran the crypto for every undecryptable memo.
|
||||
std::unordered_set<std::string> undecryptable_;
|
||||
std::uint64_t decrypt_attempts_ = 0;
|
||||
ChatStore store_;
|
||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||
|
||||
+28
-1
@@ -6,8 +6,12 @@
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
std::string ChatStore::messageKey(const std::string& txid, std::size_t payloadPosition) {
|
||||
return txid + ":" + std::to_string(payloadPosition);
|
||||
}
|
||||
|
||||
std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
return message.txid + ":" + std::to_string(message.payload_position);
|
||||
return messageKey(message.txid, message.payload_position);
|
||||
}
|
||||
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
@@ -17,6 +21,28 @@ bool ChatStore::append(const ChatMessage& message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ChatStore::append(ChatMessage&& message) {
|
||||
if (!seen_.insert(dedupKey(message)).second) return false; // reads txid/payload_position before the move
|
||||
messages_.push_back(std::move(message));
|
||||
++revision_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ChatStore::reserve(std::size_t n) {
|
||||
messages_.reserve(n);
|
||||
seen_.reserve(n);
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::int64_t> ChatStore::latestTimestampByConversation() const {
|
||||
std::unordered_map<std::string, std::int64_t> out;
|
||||
for (const auto& message : messages_) {
|
||||
auto it = out.find(message.conversation_id);
|
||||
if (it == out.end()) out.emplace(message.conversation_id, static_cast<std::int64_t>(message.timestamp));
|
||||
else if (static_cast<std::int64_t>(message.timestamp) > it->second) it->second = static_cast<std::int64_t>(message.timestamp);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
|
||||
std::vector<ChatMessage> out;
|
||||
for (const auto& message : messages_) {
|
||||
@@ -86,6 +112,7 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
||||
void ChatStore::clear() {
|
||||
messages_.clear();
|
||||
seen_.clear();
|
||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||
++revision_;
|
||||
}
|
||||
|
||||
|
||||
+37
-1
@@ -8,6 +8,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
@@ -19,6 +20,20 @@ class ChatStore {
|
||||
public:
|
||||
// Returns true if newly inserted, false if a duplicate was ignored.
|
||||
bool append(const ChatMessage& message);
|
||||
// Same, but moves the message into the store (dedup key is computed before the move).
|
||||
bool append(ChatMessage&& message);
|
||||
// Pre-size the message vector + dedup set for a bulk load of n additional messages.
|
||||
void reserve(std::size_t n);
|
||||
// Max timestamp per conversation in ONE pass over messages_. Semantically equal to
|
||||
// conversation(cid).back().timestamp (conversation() sorts ascending) without the per-cid copy+sort.
|
||||
std::unordered_map<std::string, std::int64_t> latestTimestampByConversation() const;
|
||||
|
||||
// The dedup key of a message: "<txid>:<payload_position>".
|
||||
static std::string messageKey(const std::string& txid, std::size_t payloadPosition);
|
||||
// A message with this (txid, position) is in the store (append would refuse it).
|
||||
bool contains(const std::string& txid, std::size_t payloadPosition) const {
|
||||
return seen_.count(messageKey(txid, payloadPosition)) != 0;
|
||||
}
|
||||
|
||||
// Messages in a conversation, in insertion order.
|
||||
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
|
||||
@@ -31,7 +46,7 @@ public:
|
||||
std::vector<std::string> conversationIds() const;
|
||||
|
||||
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
|
||||
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
|
||||
// requests). Builds a fresh COPY every call (O(messages) allocations) — prefer hasChatTxid() on hot paths.
|
||||
std::unordered_set<std::string> chatTxids() const {
|
||||
std::unordered_set<std::string> out;
|
||||
out.reserve(messages_.size());
|
||||
@@ -40,6 +55,15 @@ public:
|
||||
return out;
|
||||
}
|
||||
|
||||
// True if an on-chain txid carried a chat message (History tab "Message" badge / Chat filter). Backed by a
|
||||
// txid index rebuilt lazily when revision() moved (once per store mutation, O(messages)); O(1) on an empty
|
||||
// store and per lookup. One txid may back several messages; a from-scratch rebuild handles erase correctly.
|
||||
bool hasChatTxid(const std::string& txid) const {
|
||||
if (messages_.empty() || txid.empty()) return false;
|
||||
if (txid_index_rev_ != revision_) rebuildTxidIndex();
|
||||
return txid_index_.count(txid) != 0;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -63,9 +87,21 @@ public:
|
||||
private:
|
||||
static std::string dedupKey(const ChatMessage& message);
|
||||
|
||||
void rebuildTxidIndex() const {
|
||||
txid_index_.clear();
|
||||
txid_index_.reserve(messages_.size());
|
||||
for (const auto& m : messages_)
|
||||
if (!m.txid.empty()) txid_index_.insert(m.txid);
|
||||
txid_index_rev_ = revision_;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> messages_;
|
||||
std::unordered_set<std::string> seen_;
|
||||
std::uint64_t revision_ = 0;
|
||||
// Lazy memo for hasChatTxid(): valid iff txid_index_rev_ == revision_. revision_ is monotonic and never reset,
|
||||
// so append/updateDelivery/eraseConversation/clear all invalidate it (~0 = never built). Main thread only.
|
||||
mutable std::unordered_set<std::string> txid_index_;
|
||||
mutable std::uint64_t txid_index_rev_ = ~std::uint64_t{0};
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
+39
-8
@@ -15,6 +15,7 @@
|
||||
#include <algorithm>
|
||||
#include <type_traits>
|
||||
|
||||
#include "../util/coalescing_writer.h"
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
@@ -176,7 +177,8 @@ bool Settings::load(const std::string& path)
|
||||
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())});
|
||||
(c.contains("name") && c["name"].is_string())
|
||||
? c["name"].get<std::string>() : 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_);
|
||||
@@ -193,10 +195,12 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "blur_multiplier", blur_multiplier_);
|
||||
loadScalar(j, "noise_opacity", noise_opacity_);
|
||||
loadScalar(j, "gradient_background", gradient_background_);
|
||||
// Migrate legacy reduced_transparency bool -> ui_opacity float
|
||||
if (j.contains("ui_opacity")) {
|
||||
ui_opacity_ = j["ui_opacity"].get<float>();
|
||||
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].get<bool>()) {
|
||||
// Migrate legacy reduced_transparency bool -> ui_opacity float (type-tolerant: a hand-edited or
|
||||
// inf->null re-saved value must not quarantine the whole settings file).
|
||||
if (j.contains("ui_opacity") && j["ui_opacity"].is_number()) {
|
||||
ui_opacity_ = std::clamp(j["ui_opacity"].get<float>(), 0.3f, 1.0f); // matches setUIOpacity()
|
||||
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].is_boolean()
|
||||
&& j["reduced_transparency"].get<bool>()) {
|
||||
ui_opacity_ = 1.0f; // legacy: reduced = fully opaque
|
||||
}
|
||||
loadScalar(j, "window_opacity", window_opacity_);
|
||||
@@ -420,7 +424,7 @@ bool Settings::load(const std::string& path)
|
||||
entry.showSparkline = e["show_sparkline"].get<bool>();
|
||||
if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer())
|
||||
entry.sparklineInterval = e["sparkline_interval"].get<int>();
|
||||
entry.scope = e.value("scope", "");
|
||||
if (e.contains("scope") && e["scope"].is_string()) entry.scope = e["scope"].get<std::string>();
|
||||
if (e.contains("grid_col") && e["grid_col"].is_number_integer())
|
||||
entry.gridCol = e["grid_col"].get<int>();
|
||||
if (e.contains("grid_row") && e["grid_row"].is_number_integer())
|
||||
@@ -463,6 +467,26 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
}
|
||||
|
||||
int Settings::reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
|
||||
const std::set<std::string>& walletAddresses, bool singleWallet)
|
||||
{
|
||||
if (scopeId.empty()) return 0;
|
||||
int moved = 0;
|
||||
for (auto& e : portfolio_entries_) {
|
||||
if (!e.isLegacyScope()) continue; // global stays global; "w:" ids are already stable
|
||||
bool mine = singleWallet || (!identityHash.empty() && e.scope == identityHash);
|
||||
if (!mine) {
|
||||
for (const auto& a : e.addresses) {
|
||||
if (walletAddresses.count(a)) { mine = true; break; }
|
||||
}
|
||||
}
|
||||
if (!mine) continue; // ambiguous: leave legacy (fail-open visible)
|
||||
e.scope = scopeId;
|
||||
++moved;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
bool Settings::save()
|
||||
{
|
||||
if (settings_path_.empty()) {
|
||||
@@ -473,6 +497,8 @@ bool Settings::save()
|
||||
|
||||
bool Settings::save(const std::string& path)
|
||||
{
|
||||
// UI sweep: nothing is written until sweepEnd(), which restores the real values and saves once.
|
||||
if (sweep_active_) { sweep_suppressed_save_ = true; return true; }
|
||||
json j;
|
||||
|
||||
j["theme"] = theme_;
|
||||
@@ -657,8 +683,13 @@ bool Settings::save(const std::string& path)
|
||||
// Atomic + durable: write to a temp file, fsync, then rename over the real file.
|
||||
// A crash mid-write can no longer truncate settings.json (which would silently
|
||||
// reset every preference on the next launch). Owner-only (0600) — it carries the
|
||||
// lite-server list and address metadata.
|
||||
return util::Platform::writeFileAtomically(path, j.dump(4), /*restrictPermissions=*/true);
|
||||
// lite-server list and address metadata. With a writer attached the same atomic write
|
||||
// runs on its background thread (queued, latest-wins); after a critical setter
|
||||
// (durable_next_save_) this waits until the file is on disk, as the old save did.
|
||||
const bool ok = util::persistFileAtomically(writer_, durable_next_save_, path, j.dump(4),
|
||||
/*restrictPermissions=*/true);
|
||||
if (ok) durable_next_save_ = false;
|
||||
return ok;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to save settings: %s\n", e.what());
|
||||
return false;
|
||||
|
||||
+87
-14
@@ -12,6 +12,7 @@
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util { class CoalescingWriter; }
|
||||
namespace config {
|
||||
|
||||
/**
|
||||
@@ -39,10 +40,16 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Save settings to default location
|
||||
* @return true if saved successfully
|
||||
* With a file writer attached (the App's), the JSON snapshot is taken here, on the caller's thread,
|
||||
* and the atomic write runs on the writer's thread: returns true once queued. After a fund/security-
|
||||
* critical setter (see durable_next_save_) it waits until the file is on disk, like the old save().
|
||||
* @return true if saved (or queued) successfully
|
||||
*/
|
||||
bool save();
|
||||
|
||||
/** @brief Route saves through a background writer (not owned; must outlive this). Null = synchronous. */
|
||||
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
|
||||
|
||||
/**
|
||||
* @brief Save settings to specific path
|
||||
* @param path Path to settings file
|
||||
@@ -93,10 +100,21 @@ public:
|
||||
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
|
||||
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
|
||||
// from the daily series, vs the young in-session minute buffer)
|
||||
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
|
||||
// hash (shown only when that wallet is active). New entries are tagged with the current
|
||||
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
|
||||
// Per-wallet visibility: "" (shown in every wallet - legacy/global) or the active wallet's STABLE scope
|
||||
// id ("w:"+hex, App::activeWalletScopeId(), persisted in wallets.json, never derived from the address
|
||||
// set). Older builds stored the address-set identity hash here, which drifted whenever an address was
|
||||
// added; such a legacy scope fails open (stays visible) until reattachLegacyPortfolioScopes() moves it
|
||||
// to the stable id. New entries are tagged with the current wallet so a portfolio built for wallet A
|
||||
// doesn't clutter wallet B.
|
||||
std::string scope;
|
||||
bool isGlobal() const { return scope.empty(); }
|
||||
bool isLegacyScope() const { return !scope.empty() && scope.rfind("w:", 0) != 0; }
|
||||
bool visibleInWallet(const std::string& scopeId) const {
|
||||
if (isGlobal()) return true;
|
||||
if (scopeId.empty()) return false; // wallet not resolved yet: never another wallet's groups
|
||||
if (isLegacyScope()) return true; // fail open until migrated
|
||||
return scope == scopeId;
|
||||
}
|
||||
// Legacy dashboard-grid placement (deprecated by the row layout; kept for back-compat so an
|
||||
// older build's saved positions aren't dropped, but no longer used by the renderer).
|
||||
int gridCol = -1;
|
||||
@@ -194,6 +212,10 @@ public:
|
||||
|
||||
bool getUseTor() const { return use_tor_; }
|
||||
void setUseTor(bool tor) { use_tor_ = tor; }
|
||||
// "Use Tor" was never implemented: no build routed any traffic through Tor. Clears a stored opt-in
|
||||
// and reports whether there was one, so App::init can tell that user once (the cleared key is the
|
||||
// "already told" flag).
|
||||
bool consumeLegacyUseTor() { const bool was = use_tor_; use_tor_ = false; return was; }
|
||||
|
||||
// Fees
|
||||
bool getAllowCustomFees() const { return allow_custom_fees_; }
|
||||
@@ -344,7 +366,7 @@ public:
|
||||
|
||||
// First-run wizard
|
||||
bool getWizardCompleted() const { return wizard_completed_; }
|
||||
void setWizardCompleted(bool v) { wizard_completed_ = v; }
|
||||
void setWizardCompleted(bool v) { wizard_completed_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||
@@ -366,7 +388,7 @@ public:
|
||||
// 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; }
|
||||
void setEncryptionPending(bool v) { encryption_pending_ = v; durable_next_save_ = true; }
|
||||
|
||||
// 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.
|
||||
@@ -377,31 +399,31 @@ public:
|
||||
// the datadir; defaults to the daemon's own default. Used at launch to know which wallet we're
|
||||
// on before connect + to scope per-wallet data.
|
||||
std::string getActiveWalletFile() const { return active_wallet_file_; }
|
||||
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; }
|
||||
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt
|
||||
// step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir.
|
||||
bool getSeedMigrationPending() const { return seed_migration_pending_; }
|
||||
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; }
|
||||
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; durable_next_save_ = true; }
|
||||
std::string getSeedMigrationDest() const { return seed_migration_dest_; }
|
||||
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
|
||||
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; durable_next_save_ = true; }
|
||||
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
|
||||
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; }
|
||||
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; durable_next_save_ = true; }
|
||||
// The sweep transaction id, persisted once the sweep is submitted — non-empty means the
|
||||
// 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; }
|
||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; durable_next_save_ = true; }
|
||||
// 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; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; durable_next_save_ = true; }
|
||||
// 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; }
|
||||
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -413,7 +435,7 @@ public:
|
||||
|
||||
// Security — PIN unlock enabled
|
||||
bool getPinEnabled() const { return pin_enabled_; }
|
||||
void setPinEnabled(bool v) { pin_enabled_ = v; }
|
||||
void setPinEnabled(bool v) { pin_enabled_ = v; durable_next_save_ = true; }
|
||||
|
||||
// Daemon — keep running in background when closing the app
|
||||
bool getKeepDaemonRunning() const { return keep_daemon_running_; }
|
||||
@@ -452,6 +474,14 @@ public:
|
||||
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
||||
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
||||
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
|
||||
// Re-attach groups stuck on a legacy (address-set-hash) scope to the stable wallet id `scopeId`.
|
||||
// A legacy group is claimed when ANY of: its scope == identityHash (the current address-set hash, exact
|
||||
// match); at least one of its own addresses is in `walletAddresses` (groups carry their addresses, so
|
||||
// this attributes correctly in multi-wallet installs); or `singleWallet` (only one known wallet, so
|
||||
// attribution is unambiguous - same rule contacts use). Anything else stays legacy (fail-open visible).
|
||||
// Idempotent. Does NOT save: the caller saves once if the return value is > 0. Returns #entries moved.
|
||||
int reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
|
||||
const std::set<std::string>& walletAddresses, bool singleWallet);
|
||||
|
||||
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
|
||||
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
|
||||
@@ -579,8 +609,51 @@ public:
|
||||
bool needsUpgradeSave() const { return needs_upgrade_save_; }
|
||||
void clearUpgradeSave() { needs_upgrade_save_ = false; }
|
||||
|
||||
// UI-sweep ONLY. sweepBegin() snapshots the values the full sweep's demo surfaces change: the active
|
||||
// wallet file, the contacts view mode, and the pool URL/worker (the Mining tab defaults an empty
|
||||
// worker to the first DEMO z-address). Until sweepEnd(), save() returns true without writing, so
|
||||
// neither those nor the demo portfolio can reach disk, even through the shutdown save if the app
|
||||
// quits mid-sweep. sweepEnd() restores the snapshot and, only if a save was suppressed, saves once
|
||||
// so a real change made meanwhile still lands. The caller restores everything else it changed
|
||||
// (the portfolio) BEFORE sweepEnd(). Both are idempotent.
|
||||
void sweepBegin() {
|
||||
if (sweep_active_) return;
|
||||
sweep_active_wallet_file_ = active_wallet_file_;
|
||||
sweep_contacts_view_mode_ = contacts_view_mode_;
|
||||
sweep_pool_url_ = pool_url_;
|
||||
sweep_pool_worker_ = pool_worker_;
|
||||
sweep_suppressed_save_ = false;
|
||||
sweep_active_ = true;
|
||||
}
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
sweep_active_ = false;
|
||||
active_wallet_file_ = sweep_active_wallet_file_;
|
||||
contacts_view_mode_ = sweep_contacts_view_mode_;
|
||||
pool_url_ = sweep_pool_url_;
|
||||
pool_worker_ = sweep_pool_worker_;
|
||||
if (sweep_suppressed_save_) {
|
||||
sweep_suppressed_save_ = false;
|
||||
save();
|
||||
}
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
private:
|
||||
// UI-sweep latch (see sweepBegin()).
|
||||
bool sweep_active_ = false;
|
||||
bool sweep_suppressed_save_ = false;
|
||||
std::string sweep_active_wallet_file_;
|
||||
int sweep_contacts_view_mode_ = 0;
|
||||
std::string sweep_pool_url_, sweep_pool_worker_;
|
||||
|
||||
std::string settings_path_;
|
||||
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous saves (tests, tools, early startup)
|
||||
// Armed by the fund/security-critical setters (seed-migration stage, encryption_pending, pin_enabled,
|
||||
// active_wallet_file, wizard_completed): the next save() waits until the file is on disk before it
|
||||
// returns, so e.g. the sweep-submitted marker is durable before the sweep is broadcast. Cleared by a
|
||||
// successful save. A NEW setter for crash-critical state must arm it too.
|
||||
bool durable_next_save_ = false;
|
||||
|
||||
// Settings values
|
||||
std::string theme_ = "dragonx";
|
||||
|
||||
@@ -141,6 +141,17 @@ inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker
|
||||
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
|
||||
}
|
||||
|
||||
// Retry schedule after failing to find a pid's banner in debug.log: re-scan fast during the fresh-node grace
|
||||
// window, then back off 4 s doubling to a 60 s cap. `backoff` is the caller's running state (0 until grace ends).
|
||||
inline double nextExternalLogRetryDelay(double ageSinceMiss, double& backoff,
|
||||
double graceWindow = 15.0, double fast = 2.0,
|
||||
double base = 4.0, double cap = 60.0)
|
||||
{
|
||||
if (ageSinceMiss < graceWindow) return fast;
|
||||
backoff = (backoff < base) ? base : (backoff * 2.0 < cap ? backoff * 2.0 : cap);
|
||||
return backoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -41,10 +41,10 @@ void DaemonController::syncSettings(const config::Settings* settings)
|
||||
daemon_->setWalletFile(walletFile);
|
||||
}
|
||||
|
||||
bool DaemonController::start(const config::Settings* settings)
|
||||
bool DaemonController::start(const config::Settings* settings, int lockWaitPolls)
|
||||
{
|
||||
syncSettings(settings);
|
||||
return daemon_->start();
|
||||
return daemon_->start("", lockWaitPolls);
|
||||
}
|
||||
|
||||
void DaemonController::stop(int waitMs)
|
||||
@@ -99,11 +99,18 @@ double DaemonController::memoryUsageMB() const
|
||||
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
|
||||
}
|
||||
|
||||
std::function<double()> DaemonController::memoryProbe() const
|
||||
{
|
||||
return daemon_ ? daemon_->memoryProbe() : std::function<double()>{};
|
||||
}
|
||||
|
||||
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
|
||||
{
|
||||
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
|
||||
}
|
||||
|
||||
std::size_t DaemonController::outputEnd() const { return daemon_ ? daemon_->getOutputEnd() : 0; }
|
||||
|
||||
std::string DaemonController::outputSince(std::size_t& offset) const
|
||||
{
|
||||
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -94,7 +95,7 @@ public:
|
||||
void setStateCallback(StateCallback callback);
|
||||
void syncSettings(const config::Settings* settings);
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
bool start(const config::Settings* settings, int lockWaitPolls = 3);
|
||||
void stop(int waitMs);
|
||||
void markStopRequested();
|
||||
|
||||
@@ -106,8 +107,10 @@ public:
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
std::function<double()> memoryProbe() const; // EmbeddedDaemon::memoryProbe(); empty without a daemon
|
||||
std::vector<std::string> recentLines(std::size_t count) const;
|
||||
std::string outputSince(std::size_t& offset) const;
|
||||
std::size_t outputEnd() const;
|
||||
|
||||
void resetCrashCount();
|
||||
void setRescanOnNextStart(bool enabled);
|
||||
|
||||
+168
-59
@@ -64,7 +64,7 @@ EmbeddedDaemon::~EmbeddedDaemon()
|
||||
// process (seed migration's isolated node) could take down the user's main node. Owners call stop()
|
||||
// explicitly (the app's own daemon is never destroyed: main() ends in _Exit). Just stop watching and
|
||||
// release what we hold; a process still running is left to finish on its own.
|
||||
should_stop_ = true;
|
||||
requestMonitorStop();
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
@@ -254,13 +254,33 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
||||
};
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::requestMonitorStop()
|
||||
{
|
||||
// Take monitor_mutex_ so the flag cannot flip between the monitor's predicate check and its wait
|
||||
// (lost wakeup). Lock order lifecycle_mutex_ -> monitor_mutex_ is deadlock-free: the monitor thread
|
||||
// never takes lifecycle_mutex_.
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(monitor_mutex_);
|
||||
should_stop_ = true;
|
||||
}
|
||||
monitor_cv_.notify_all();
|
||||
}
|
||||
|
||||
size_t EmbeddedDaemon::outputTotalSnapshot() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
return output_total_;
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::setState(State s, const std::string& message)
|
||||
{
|
||||
state_ = s;
|
||||
// Message first, state second: a reader that sees Error must also see that error's message, not the
|
||||
// previous one (the console prints the message when it sees the state change).
|
||||
if (!message.empty() && s == State::Error) {
|
||||
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
|
||||
last_error_ = message;
|
||||
}
|
||||
state_ = s;
|
||||
|
||||
if (state_callback_) {
|
||||
state_callback_(s, message);
|
||||
@@ -274,18 +294,7 @@ static constexpr size_t MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MB
|
||||
void EmbeddedDaemon::appendOutput(const char* data, size_t len)
|
||||
{
|
||||
// Caller must hold output_mutex_
|
||||
process_output_.append(data, len);
|
||||
output_total_ += len;
|
||||
if (process_output_.size() > MAX_OUTPUT_BYTES + MAX_OUTPUT_BYTES / 4) {
|
||||
// Trim to MAX_OUTPUT_BYTES, cutting at a newline boundary
|
||||
size_t trim_to = process_output_.size() - MAX_OUTPUT_BYTES;
|
||||
size_t nl = process_output_.find('\n', trim_to);
|
||||
if (nl != std::string::npos && nl < process_output_.size() - 1) {
|
||||
process_output_.erase(0, nl + 1);
|
||||
} else {
|
||||
process_output_.erase(0, trim_to);
|
||||
}
|
||||
}
|
||||
appendBounded(process_output_, output_total_, data, len, MAX_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
std::vector<std::string> EmbeddedDaemon::getRecentLines(int maxLines) const
|
||||
@@ -496,8 +505,10 @@ static bool isPortInUse(int port)
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
|
||||
// Don't start if already running or starting
|
||||
if (state_ == State::Starting || state_ == State::Running) {
|
||||
DEBUG_LOGF("[INFO] Daemon already %s\n", state_ == State::Starting ? "starting" : "running");
|
||||
@@ -509,6 +520,27 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
return true;
|
||||
}
|
||||
|
||||
// A stop session owns this object until endStop() sets Stopped; never spawn under it (its endStop would
|
||||
// adopt and abandon our new pid, and it still uses stdout_fd_/process_handle_).
|
||||
if (state_ == State::Stopping) {
|
||||
DEBUG_LOGF("[INFO] start(): a stop is still in progress, not starting\n");
|
||||
return false;
|
||||
}
|
||||
// A monitor that returned on its own (unexpected exit, or a requested stop that landed before stop()) is
|
||||
// still joinable. Join it, then release what its run left behind. Only in this case: if a stop session
|
||||
// already joined it, the fd/handle belong to that session and endStop() closes them.
|
||||
if (monitor_thread_.joinable()) {
|
||||
requestMonitorStop(); // a monitor somehow still looping wakes immediately via monitor_cv_
|
||||
monitor_thread_.join(); // normally immediate (it already returned)
|
||||
#ifdef _WIN32
|
||||
if (process_handle_ != nullptr) { CloseHandle(process_handle_); process_handle_ = nullptr; }
|
||||
if (stdout_read_ != nullptr) { CloseHandle(stdout_read_); stdout_read_ = nullptr; }
|
||||
#else
|
||||
if (stdout_fd_ >= 0) { close(stdout_fd_); stdout_fd_ = -1; } // also stops it leaking into the next fork
|
||||
#endif
|
||||
}
|
||||
should_stop_ = false;
|
||||
|
||||
// Check if something is already listening on the RPC port. An isolated instance (migrate-to-
|
||||
// seed) runs on its own non-default port alongside the main daemon, so it skips this bail.
|
||||
int rpc_port = std::atoi(DRAGONX_DEFAULT_RPC_PORT);
|
||||
@@ -532,11 +564,10 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
|
||||
{
|
||||
constexpr int kDatadirLockWaitPollMs = 100;
|
||||
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||
bool stillRunning = false;
|
||||
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||
stillRunning = isDaemonProcessRunning();
|
||||
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
|
||||
for (int i = 0; stillRunning && i < lockWaitPolls; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||
stillRunning = isDaemonProcessRunning();
|
||||
}
|
||||
@@ -577,16 +608,23 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
||||
}
|
||||
|
||||
// The user's own DRAGONX.conf wins over our flags (a command-line flag would override it): read the
|
||||
// conf once, for a normal start only (an isolated start has its own datadir + conf and keeps the pin).
|
||||
std::string conf;
|
||||
if (override_datadir_.empty()) {
|
||||
std::ifstream in((fs::path(util::Platform::getDragonXDataDir()) / "DRAGONX.conf").string(), std::ios::binary);
|
||||
if (in) conf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
if (confSetsKey(conf, "dbcache")) {
|
||||
DEBUG_LOGF("[INFO] DRAGONX.conf sets dbcache; not pinning -dbcache\n");
|
||||
}
|
||||
dropPinnedDbcacheIfConfSets(args, conf);
|
||||
}
|
||||
|
||||
// More RPC workers for a node we launch: dragonxd's default 8 workers + 16-deep queue fill up with
|
||||
// requests waiting behind a long cs_main holder (a witness rebuild), after which even "stop" is refused.
|
||||
// The GUI itself no longer floods it; this is headroom for dragonx-cli and other clients — a delay,
|
||||
// not a fix. Not for an isolated start, and DRAGONX.conf wins (a flag would override the user's own).
|
||||
if (override_datadir_.empty()) {
|
||||
std::string conf;
|
||||
{
|
||||
std::ifstream in((fs::path(util::Platform::getDragonXDataDir()) / "DRAGONX.conf").string(), std::ios::binary);
|
||||
if (in) conf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
if (!confSetsKey(conf, "rpcthreads")) args.push_back("-rpcthreads=16");
|
||||
}
|
||||
|
||||
@@ -689,11 +727,12 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
if (pid > 0 && !rec.startToken.empty()) writeOwnershipRecord(ownershipRecordPath(), rec);
|
||||
}
|
||||
|
||||
// Start monitor thread (if not already running)
|
||||
// Supervise the process just launched. A monitor left over from an earlier run was joined above.
|
||||
should_stop_ = false;
|
||||
if (!monitor_thread_.joinable()) {
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
if (monitor_thread_.joinable()) { // unreachable under lifecycle_mutex_; never join a live monitor unsignalled
|
||||
requestMonitorStop(); monitor_thread_.join(); should_stop_ = false;
|
||||
}
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -837,7 +876,7 @@ static DWORD findProcessByName(const char* name)
|
||||
double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
{
|
||||
if (process_handle_ == nullptr) return 0.0;
|
||||
|
||||
|
||||
PROCESS_MEMORY_COUNTERS pmc;
|
||||
ZeroMemory(&pmc, sizeof(pmc));
|
||||
pmc.cb = sizeof(pmc);
|
||||
@@ -847,6 +886,16 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
std::function<double()> EmbeddedDaemon::memoryProbe() const
|
||||
{
|
||||
if (process_handle_ == nullptr) return {};
|
||||
// GetProcessMemoryInfo on the handle we own is one cheap call: read it here and hand back the value, so the
|
||||
// callable never touches process_handle_ (the monitor can close it). The expensive part on Windows, the
|
||||
// Toolhelp process scan App falls back to when this reads 0, runs on the sampler thread.
|
||||
const double mb = getMemoryUsageMB();
|
||||
return [mb]() { return mb; };
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::drainOutput()
|
||||
{
|
||||
// Read new content from debug.log (tail-follow approach)
|
||||
@@ -912,8 +961,11 @@ void EmbeddedDaemon::drainOutput()
|
||||
|
||||
bool EmbeddedDaemon::stop(int wait_ms)
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
stop_requested_ = true;
|
||||
requestMonitorStop();
|
||||
}
|
||||
setState(State::Stopping, "Stopping dragonxd...");
|
||||
|
||||
// Check if our tracked process handle is still alive
|
||||
@@ -971,8 +1023,11 @@ bool EmbeddedDaemon::stop(int wait_ms)
|
||||
// Final drain of debug.log
|
||||
drainOutput();
|
||||
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
@@ -981,8 +1036,9 @@ bool EmbeddedDaemon::stop(int wait_ms)
|
||||
|
||||
bool EmbeddedDaemon::beginStop()
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_); // before the flags: a racing start() would reset should_stop_
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
requestMonitorStop();
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
@@ -1030,8 +1086,12 @@ EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
|
||||
|
||||
void EmbeddedDaemon::endStop(bool keepWatching)
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
if (keepWatching && isRunning()) {
|
||||
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
|
||||
if (monitor_thread_.joinable()) { // unreachable after beginStop()'s join; never join a live monitor unsignalled
|
||||
requestMonitorStop(); monitor_thread_.join();
|
||||
}
|
||||
should_stop_ = false;
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
return;
|
||||
@@ -1073,10 +1133,23 @@ static std::string translateWindowsExitCode(DWORD code) {
|
||||
|
||||
void EmbeddedDaemon::monitorProcess()
|
||||
{
|
||||
const auto fast = std::chrono::milliseconds(250);
|
||||
const auto cap = std::chrono::milliseconds(500);
|
||||
const auto startTime = std::chrono::steady_clock::now();
|
||||
auto lastData = startTime;
|
||||
auto interval = fast;
|
||||
while (!should_stop_ && isRunning()) {
|
||||
// Tail debug.log for new output
|
||||
const size_t before = outputTotalSnapshot();
|
||||
drainOutput();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool got = outputTotalSnapshot() != before;
|
||||
if (got) lastData = now;
|
||||
interval = nextMonitorInterval(interval, got,
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastData), fast, cap);
|
||||
std::unique_lock<std::mutex> lk(monitor_mutex_);
|
||||
monitor_cv_.wait_for(lk, interval, [this] { return should_stop_.load(); });
|
||||
}
|
||||
|
||||
// Read any remaining output after process exits
|
||||
@@ -1098,6 +1171,7 @@ void EmbeddedDaemon::monitorProcess()
|
||||
}
|
||||
|
||||
crash_count_++;
|
||||
unexpected_exits_++; // before setState(Error): a reader that sees Error sees the new count
|
||||
|
||||
// Build informative error message with full diagnostic details
|
||||
std::string error_msg = "dragonxd exited unexpectedly (" + translateWindowsExitCode(exit_code) + ")";
|
||||
@@ -1318,15 +1392,17 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
return true;
|
||||
}
|
||||
|
||||
double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
namespace {
|
||||
// Sum of the RSS (MB) of every process in process group `pgid`. The tracked pid is often a bash wrapper and the
|
||||
// real dragonxd a child in the same group (setpgid in startProcess). Runs on a sampler thread
|
||||
// (util::BackgroundSampler), so the /proc scan / `ps` fork no longer costs the UI anything.
|
||||
double sampleProcessGroupRssMB(pid_t pgid)
|
||||
{
|
||||
if (process_pid_ <= 0) return 0.0;
|
||||
|
||||
#ifdef __APPLE__
|
||||
// macOS: use ps to read RSS for the daemon process and its children
|
||||
// in the same process group.
|
||||
char cmd[128];
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -g %d 2>/dev/null", process_pid_);
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -g %d 2>/dev/null", static_cast<int>(pgid));
|
||||
FILE* fp = popen(cmd, "r");
|
||||
if (!fp) return 0.0;
|
||||
double total_rss_mb = 0.0;
|
||||
@@ -1338,20 +1414,17 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
pclose(fp);
|
||||
return total_rss_mb;
|
||||
#else
|
||||
// Linux: The tracked PID is often a bash wrapper script; the real daemon
|
||||
// (dragonxd) is a child in the same process group. Sum VmRSS for every
|
||||
// process whose PGID matches our tracked PID.
|
||||
// Linux: sum VmRSS for every /proc/<pid> whose process group is pgid. The error_code overloads: an
|
||||
// unreadable /proc ends the scan instead of throwing (the old range-for constructor threw).
|
||||
double total_rss_mb = 0.0;
|
||||
|
||||
// Iterate /proc/<pid>/stat for all numeric entries
|
||||
for (const auto& entry : fs::directory_iterator("/proc")) {
|
||||
if (!entry.is_directory()) continue;
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.empty() || !std::isdigit(name[0])) continue;
|
||||
std::error_code ec;
|
||||
for (fs::directory_iterator it("/proc", ec), end; !ec && it != end; it.increment(ec)) {
|
||||
const std::string name = it->path().filename().string();
|
||||
if (name.empty() || !std::isdigit(static_cast<unsigned char>(name[0]))) continue;
|
||||
|
||||
// Read process group from /proc/<pid>/stat (field 5 is pgrp)
|
||||
std::string statPath = entry.path().string() + "/stat";
|
||||
FILE* sf = fopen(statPath.c_str(), "r");
|
||||
const std::string base = it->path().string();
|
||||
FILE* sf = fopen((base + "/stat").c_str(), "r");
|
||||
if (!sf) continue;
|
||||
|
||||
char statLine[512];
|
||||
@@ -1369,11 +1442,10 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
char state;
|
||||
int ppid;
|
||||
if (sscanf(p, " %c %d %d", &state, &ppid, &pgrp) != 3) continue;
|
||||
if (pgrp != process_pid_) continue;
|
||||
if (pgrp != pgid) continue;
|
||||
|
||||
// This process belongs to our group — read its VmRSS
|
||||
std::string statusPath = entry.path().string() + "/status";
|
||||
FILE* f = fopen(statusPath.c_str(), "r");
|
||||
FILE* f = fopen((base + "/status").c_str(), "r");
|
||||
if (!f) continue;
|
||||
char line[256];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
@@ -1386,10 +1458,23 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
return total_rss_mb;
|
||||
#endif // __APPLE__ / Linux
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::function<double()> EmbeddedDaemon::memoryProbe() const
|
||||
{
|
||||
const pid_t pid = process_pid_; // snapshot on the calling thread; the callable carries only this
|
||||
if (pid <= 0) return {};
|
||||
return [pid]() { return sampleProcessGroupRssMB(pid); };
|
||||
}
|
||||
|
||||
double EmbeddedDaemon::getMemoryUsageMB() const
|
||||
{
|
||||
const auto probe = memoryProbe();
|
||||
return probe ? probe() : 0.0;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::isRunning() const
|
||||
{
|
||||
@@ -1480,16 +1565,20 @@ bool EmbeddedDaemon::reapWithin(pid_t pid, int timeout_ms, int& status)
|
||||
|
||||
bool EmbeddedDaemon::stop(int wait_ms)
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
bool exitedOnItsOwn = true;
|
||||
|
||||
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
|
||||
// Join the monitor FIRST (it wakes immediately via monitor_cv_) so that from here on this is
|
||||
// the only reaper. isRunning() is state-based and stays true until we set Stopped below, so it can't
|
||||
// tell us the child exited: poll waitpid() on a cached pid instead. If the child exited inside that
|
||||
// tick, the monitor already reaped it and, seeing stop_requested_, recorded a clean stop (pid now 0).
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
// lifecycle_mutex_ covers the flags and the join only, never the reap waits below.
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
stop_requested_ = true;
|
||||
requestMonitorStop();
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
const pid_t pid = process_pid_;
|
||||
@@ -1533,9 +1622,10 @@ bool EmbeddedDaemon::stop(int wait_ms)
|
||||
|
||||
bool EmbeddedDaemon::beginStop()
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_); // before the flags: a racing start() would reset should_stop_
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
// The monitor sees should_stop_ within one 100ms tick. If the child exited in that window the
|
||||
requestMonitorStop();
|
||||
// The monitor wakes immediately via monitor_cv_. If the child exited in that window the
|
||||
// monitor reaped it (as a clean stop: stop_requested_ is set) and the pid is now 0.
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
@@ -1625,9 +1715,13 @@ EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
|
||||
|
||||
void EmbeddedDaemon::endStop(bool keepWatching)
|
||||
{
|
||||
std::lock_guard<std::mutex> life(lifecycle_mutex_);
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid > 0 && keepWatching) {
|
||||
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
|
||||
if (monitor_thread_.joinable()) { // unreachable after beginStop()'s join; never join a live monitor unsignalled
|
||||
requestMonitorStop(); monitor_thread_.join();
|
||||
}
|
||||
should_stop_ = false;
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
return;
|
||||
@@ -1652,6 +1746,11 @@ void EmbeddedDaemon::endStop(bool keepWatching)
|
||||
|
||||
void EmbeddedDaemon::monitorProcess()
|
||||
{
|
||||
const auto fast = std::chrono::milliseconds(100);
|
||||
const auto cap = std::chrono::milliseconds(500);
|
||||
const auto startTime = std::chrono::steady_clock::now();
|
||||
auto lastData = startTime;
|
||||
auto interval = fast;
|
||||
while (!should_stop_) {
|
||||
// Check if process exited (non-blocking waitpid captures exit status)
|
||||
if (process_pid_ > 0) {
|
||||
@@ -1671,15 +1770,25 @@ void EmbeddedDaemon::monitorProcess()
|
||||
return;
|
||||
}
|
||||
crash_count_++;
|
||||
unexpected_exits_++; // before setState(Error): a reader that sees Error sees the new count
|
||||
setState(State::Error, "dragonxd exited unexpectedly (" + exitInfo + ")");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Read process output (non-blocking): the stdout/stderr pipe and the node's debug.log
|
||||
const size_t before = outputTotalSnapshot();
|
||||
drainOutput();
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool got = outputTotalSnapshot() != before;
|
||||
if (got) lastData = now;
|
||||
interval = nextMonitorInterval(interval, got,
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - startTime),
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastData), fast, cap);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
// Wakes immediately on requestMonitorStop(); idle polling backs off to `cap`.
|
||||
std::unique_lock<std::mutex> lk(monitor_mutex_);
|
||||
monitor_cv_.wait_for(lk, interval, [this] { return should_stop_.load(); });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
@@ -13,9 +14,13 @@
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include "output_window.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
@@ -53,9 +58,13 @@ public:
|
||||
/**
|
||||
* @brief Start the embedded dragonxd daemon
|
||||
* @param binary_path Path to dragonxd binary (auto-detect if empty)
|
||||
* @return true if process started successfully
|
||||
* @return true if process started successfully (or is already running); false while a stop
|
||||
* session (beginStop() .. endStop()) is still in progress
|
||||
*/
|
||||
bool start(const std::string& binary_path = "");
|
||||
static constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||
|
||||
/** @param lockWaitPolls max 100ms datadir-lock waits (0 = single check, never sleeps; for the UI thread) */
|
||||
bool start(const std::string& binary_path = "", int lockWaitPolls = kDatadirLockWaitMaxPolls);
|
||||
|
||||
/**
|
||||
* @brief Stop the daemon process this object launched (never any other dragonxd)
|
||||
@@ -128,8 +137,18 @@ public:
|
||||
/**
|
||||
* @brief Get daemon process memory usage (RSS) in MB
|
||||
* @return Memory usage in MB, or 0 if unavailable
|
||||
*
|
||||
* Synchronous (Linux scans /proc, macOS forks `ps`): UI code uses memoryProbe() through a
|
||||
* util::BackgroundSampler instead.
|
||||
*/
|
||||
double getMemoryUsageMB() const;
|
||||
|
||||
/**
|
||||
* @brief A self-contained RSS probe (MB) for another thread. Snapshots what it needs on the calling
|
||||
* thread (the pid; on Windows the value itself: one cheap call on the owned handle); the callable
|
||||
* touches no EmbeddedDaemon state. Empty when there is no process.
|
||||
*/
|
||||
std::function<double()> memoryProbe() const;
|
||||
|
||||
/**
|
||||
* @brief Get last error message
|
||||
@@ -160,15 +179,13 @@ public:
|
||||
/** 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 = buffer.substr(offset - base);
|
||||
offset = total;
|
||||
return result;
|
||||
return outputWindowSince(buffer, total, offset);
|
||||
}
|
||||
|
||||
/** Absolute end of everything ever appended (a valid getOutputSince() offset). */
|
||||
size_t getOutputEnd() const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
return output_total_;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,6 +430,18 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the wallet's own -dbcache= pin from `args` when the user's DRAGONX.conf sets dbcache (a
|
||||
* command-line flag overrides the conf, so the user's own value must win). Pure; no logging.
|
||||
*/
|
||||
static void dropPinnedDbcacheIfConfSets(std::vector<std::string>& args, const std::string& conf)
|
||||
{
|
||||
if (!confSetsKey(conf, "dbcache")) return;
|
||||
args.erase(std::remove_if(args.begin(), args.end(),
|
||||
[](const std::string& a) { return a.rfind("-dbcache=", 0) == 0; }),
|
||||
args.end());
|
||||
}
|
||||
|
||||
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||
struct StartLockGateDecision {
|
||||
bool proceed = true; // false => bail before spawning
|
||||
@@ -446,10 +475,27 @@ public:
|
||||
int getCrashCount() const { return crash_count_.load(); }
|
||||
/** Reset crash counter (call on successful connection or manual restart) */
|
||||
void resetCrashCount() { crash_count_ = 0; }
|
||||
/** Unexpected exits since this object was created; never reset (unlike the crash count), so a
|
||||
* reader can tell a new crash from an error it has already reported. */
|
||||
std::uint64_t getUnexpectedExitCount() const { return unexpected_exits_.load(); }
|
||||
|
||||
/** Monitor poll interval: stay `fast` for the first 30 s after the thread starts and for 3 s after any
|
||||
* output; otherwise double per idle tick up to `cap`. Any output resets to `fast`. Pure. */
|
||||
static std::chrono::milliseconds nextMonitorInterval(std::chrono::milliseconds cur, bool bytesRead,
|
||||
std::chrono::milliseconds sinceStart, std::chrono::milliseconds sinceLastData,
|
||||
std::chrono::milliseconds fast, std::chrono::milliseconds cap)
|
||||
{
|
||||
using ms = std::chrono::milliseconds;
|
||||
if (bytesRead || sinceStart < ms(30000) || sinceLastData < ms(3000)) return fast;
|
||||
const ms next = cur * 2;
|
||||
return next > cap ? cap : (next < fast ? fast : next);
|
||||
}
|
||||
|
||||
private:
|
||||
void setState(State s, const std::string& message = "");
|
||||
void monitorProcess();
|
||||
void requestMonitorStop(); // set should_stop_ under monitor_mutex_ and wake the monitor
|
||||
size_t outputTotalSnapshot() const; // output_total_ under output_mutex_ (monitor idle detection)
|
||||
void drainOutput(); // read any pending stdout into process_output_
|
||||
void appendOutput(const char* data, size_t len); // append + trim (caller holds output_mutex_)
|
||||
bool startProcess(const std::string& binary_path, const std::vector<std::string>& args);
|
||||
@@ -487,13 +533,19 @@ private:
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
std::mutex monitor_mutex_; // pairs with monitor_cv_ so stop wakes the monitor at once
|
||||
std::condition_variable monitor_cv_;
|
||||
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
|
||||
// Serializes start/beginStop/stop/endStop over monitor_thread_, should_stop_ and stop_requested_ (two
|
||||
// threads joining one std::thread is undefined). Never taken by the monitor thread or the state callback.
|
||||
std::mutex lifecycle_mutex_;
|
||||
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<std::uint64_t> unexpected_exits_{0}; // all unexpected exits (see getUnexpectedExitCount)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Shared absolute-offset output ring helpers (daemon debug.log feed, xmrig console feed).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// getOutputSince() over a buffer holding the LAST buffer.size() of `total` bytes ever appended.
|
||||
inline std::string outputWindowSince(const std::string& buffer, std::size_t total, std::size_t& offset)
|
||||
{
|
||||
const std::size_t base = total - buffer.size(); // absolute position of buffer[0]
|
||||
if (offset < base) offset = base; // trimmed before read: resume at what's left
|
||||
if (offset >= total) {
|
||||
offset = total;
|
||||
return {};
|
||||
}
|
||||
std::string result = buffer.substr(offset - base);
|
||||
offset = total;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Append `n` bytes, advance absolute `total`, and trim to ~cap at a newline once size exceeds 1.25x cap.
|
||||
// The `nl < size-1` guard keeps a lone trailing newline from erasing the whole buffer.
|
||||
inline void appendBounded(std::string& buffer, std::size_t& total, const char* data, std::size_t n, std::size_t cap)
|
||||
{
|
||||
buffer.append(data, n);
|
||||
total += n;
|
||||
if (buffer.size() > cap + cap / 4) {
|
||||
const std::size_t trim_to = buffer.size() - cap;
|
||||
const std::size_t nl = buffer.find('\n', trim_to);
|
||||
if (nl != std::string::npos && nl < buffer.size() - 1) buffer.erase(0, nl + 1);
|
||||
else buffer.erase(0, trim_to);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -173,9 +173,10 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
int hw = (int)std::thread::hardware_concurrency();
|
||||
if (hw < 1) hw = 1;
|
||||
// Use explicit thread count (not just a hint)
|
||||
threads_ = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
|
||||
if (threads_ > hw) threads_ = hw;
|
||||
// Use explicit thread count (not just a hint). One store: the UI thread reads it (getRequestedThreads).
|
||||
int t = (cfg.threads > 0) ? cfg.threads : std::max(1, hw / 2);
|
||||
if (t > hw) t = hw;
|
||||
threads_ = t;
|
||||
|
||||
json j;
|
||||
j["autosave"] = false;
|
||||
@@ -324,7 +325,7 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
return false;
|
||||
}
|
||||
DEBUG_LOGF("[INFO] XmrigManager: config written to %s (API port %d, threads %d)\n",
|
||||
cfgPath.c_str(), api_port_, threads_);
|
||||
cfgPath.c_str(), api_port_, threads_.load());
|
||||
|
||||
// Spawn process
|
||||
if (!startProcess(binary, cfgPath, threads_)) {
|
||||
@@ -355,12 +356,6 @@ void XmrigManager::stop(int wait_ms) {
|
||||
// Try graceful termination first
|
||||
TerminateProcess(process_handle_, 0);
|
||||
WaitForSingleObject(process_handle_, wait_ms);
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
#else
|
||||
if (process_pid_ > 0) {
|
||||
@@ -385,15 +380,29 @@ void XmrigManager::stop(int wait_ms) {
|
||||
}
|
||||
process_pid_ = 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
|
||||
// Close the pipe only after the monitor thread has finished with it (it
|
||||
// drains stdout); closing earlier let read() hit a closed/reused fd.
|
||||
#ifdef _WIN32
|
||||
if (process_handle_) {
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
#else
|
||||
if (stdout_fd_ >= 0) {
|
||||
close(stdout_fd_);
|
||||
stdout_fd_ = -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
|
||||
state_ = State::Stopped;
|
||||
DEBUG_LOGF("[INFO] XmrigManager: stopped\n");
|
||||
}
|
||||
@@ -474,6 +483,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
}
|
||||
|
||||
void XmrigManager::drainOutput() {
|
||||
std::lock_guard<std::mutex> io_lk(io_mutex_);
|
||||
if (!stdout_read_) return;
|
||||
char buf[4096];
|
||||
DWORD avail = 0;
|
||||
@@ -564,7 +574,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
#ifdef __APPLE__
|
||||
// macOS: use ps to read RSS for xmrig process
|
||||
char cmd[128];
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", process_pid_);
|
||||
snprintf(cmd, sizeof(cmd), "ps -o rss= -p %d 2>/dev/null", (int)process_pid_.load(std::memory_order_relaxed));
|
||||
FILE* fp = popen(cmd, "r");
|
||||
if (!fp) return 0.0;
|
||||
char line[64];
|
||||
@@ -577,7 +587,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
return mb;
|
||||
#else
|
||||
char path[64];
|
||||
snprintf(path, sizeof(path), "/proc/%d/statm", process_pid_);
|
||||
snprintf(path, sizeof(path), "/proc/%d/statm", (int)process_pid_.load(std::memory_order_relaxed));
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) return 0.0;
|
||||
long dummy = 0, pages = 0;
|
||||
@@ -591,6 +601,7 @@ double XmrigManager::getMemoryUsageMB() const {
|
||||
}
|
||||
|
||||
void XmrigManager::drainOutput() {
|
||||
std::lock_guard<std::mutex> io_lk(io_mutex_);
|
||||
if (stdout_fd_ < 0) return;
|
||||
char buf[4096];
|
||||
while (true) {
|
||||
@@ -612,17 +623,7 @@ void XmrigManager::drainOutput() {
|
||||
|
||||
void XmrigManager::appendOutput(const char* data, size_t len) {
|
||||
// Caller must hold output_mutex_
|
||||
static constexpr size_t MAX_OUTPUT = 1024 * 1024; // 1 MB cap
|
||||
process_output_.append(data, len);
|
||||
if (process_output_.size() > MAX_OUTPUT) {
|
||||
// Trim from the front at a newline boundary
|
||||
size_t cut = process_output_.size() - MAX_OUTPUT;
|
||||
auto pos = process_output_.find('\n', cut);
|
||||
if (pos != std::string::npos)
|
||||
process_output_.erase(0, pos + 1);
|
||||
else
|
||||
process_output_.erase(0, cut);
|
||||
}
|
||||
appendBounded(process_output_, output_total_, data, len, 1024 * 1024); // 1 MB cap (1.25x hysteresis)
|
||||
}
|
||||
|
||||
std::vector<std::string> XmrigManager::getRecentLines(int maxLines) const {
|
||||
@@ -711,9 +712,8 @@ void XmrigManager::monitorProcess() {
|
||||
// ============================================================================
|
||||
|
||||
void XmrigManager::pollStats() {
|
||||
// No-op on UI thread — stats are fetched by the monitor thread.
|
||||
// Just drain stdout so log lines stay fresh.
|
||||
drainOutput();
|
||||
// No-op on UI thread — stats and the stdout drain both run on the monitor
|
||||
// thread (a second reader here raced stop() closing the fd).
|
||||
}
|
||||
|
||||
void XmrigManager::fetchStatsHttp() {
|
||||
@@ -739,18 +739,23 @@ void XmrigManager::fetchStatsHttp() {
|
||||
headers = curl_slist_append(headers, authHeader.c_str());
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROXY, "*"); // 127.0.0.1 miner API: never via an env proxy
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curlWriteCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseData);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 2000L); // 2s timeout — generous for loaded system
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 1000L); // 1s connect timeout
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // Avoid signal issues in threads
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
if (should_stop_.load()) return; // aborted by stop(): not a failure
|
||||
static int s_fail_count = 0;
|
||||
if (++s_fail_count <= 5 || s_fail_count % 30 == 0) {
|
||||
DEBUG_LOGF("[WARN] XmrigManager::pollStats: curl failed (%d): %s url=%s\n",
|
||||
@@ -845,6 +850,9 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents.
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; ObsidianDragon)");
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, &XmrigManager::abortCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
+13
-12
@@ -11,6 +11,8 @@
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
#include "output_window.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <windows.h>
|
||||
@@ -106,13 +108,7 @@ public:
|
||||
*/
|
||||
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 {};
|
||||
}
|
||||
std::string result = process_output_.substr(offset);
|
||||
offset = process_output_.size();
|
||||
return result;
|
||||
return outputWindowSince(process_output_, output_total_, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +157,9 @@ private:
|
||||
// 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
|
||||
// curl xfer-info callback: abort an in-flight stats fetch once stop() is requested.
|
||||
static int abortCb(void* p, int64_t, int64_t, int64_t, int64_t)
|
||||
{ return static_cast<const XmrigManager*>(p)->should_stop_.load() ? 1 : 0; }
|
||||
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
||||
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
@@ -169,23 +168,25 @@ private:
|
||||
|
||||
mutable std::mutex output_mutex_;
|
||||
std::string process_output_;
|
||||
std::size_t output_total_ = 0; // bytes ever appended (absolute end; guarded by output_mutex_, kept across restarts)
|
||||
|
||||
// xmrig HTTP API credentials (random per session)
|
||||
int api_port_ = 0;
|
||||
std::string api_token_;
|
||||
int threads_ = 0; // Thread count for mining
|
||||
std::atomic<int> threads_{0}; // Thread count for mining (written on the mining-control thread, read by the UI)
|
||||
std::string pool_host_; // Pool hostname for stats API
|
||||
PoolStats stats_;
|
||||
mutable std::mutex stats_mutex_;
|
||||
|
||||
// Process handles
|
||||
#ifdef _WIN32
|
||||
HANDLE process_handle_ = nullptr;
|
||||
HANDLE stdout_read_ = nullptr;
|
||||
std::atomic<HANDLE> process_handle_{nullptr};
|
||||
std::atomic<HANDLE> stdout_read_{nullptr};
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1;
|
||||
std::atomic<pid_t> process_pid_{0};
|
||||
std::atomic<int> stdout_fd_{-1};
|
||||
#endif
|
||||
std::mutex io_mutex_; // serializes drainOutput(); lock order io_mutex_ -> output_mutex_
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
|
||||
@@ -31,6 +31,7 @@ std::string AddressBook::getDefaultPath()
|
||||
|
||||
bool AddressBook::load()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: keep the demo list and the real-book snapshot
|
||||
file_path_ = getDefaultPath();
|
||||
|
||||
std::ifstream file(file_path_);
|
||||
@@ -79,6 +80,7 @@ bool AddressBook::load()
|
||||
|
||||
bool AddressBook::save()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: demo contacts never reach disk
|
||||
if (file_path_.empty()) {
|
||||
file_path_ = getDefaultPath();
|
||||
}
|
||||
|
||||
+20
-3
@@ -130,10 +130,25 @@ public:
|
||||
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.
|
||||
* @brief UI-sweep ONLY: show `demo` instead of the real book. The first call snapshots the real
|
||||
* entries; later calls (the sweep re-runs its setup every frame) only swap the demo list. While
|
||||
* active, save() and load() return true without touching disk or the snapshot, so nothing (an
|
||||
* add/update/remove, reattachLegacyScopes) can persist demo contacts. Do not use outside the sweep.
|
||||
*/
|
||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
|
||||
void sweepBegin(std::vector<AddressBookEntry> demo) {
|
||||
if (!sweep_active_) { sweep_backup_ = std::move(entries_); sweep_active_ = true; }
|
||||
entries_ = std::move(demo);
|
||||
++revision_;
|
||||
}
|
||||
/** @brief UI-sweep ONLY: put the real book back (no disk write). Idempotent. */
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
entries_ = std::move(sweep_backup_);
|
||||
sweep_backup_.clear();
|
||||
sweep_active_ = false;
|
||||
++revision_;
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
/**
|
||||
* @brief Check if empty
|
||||
@@ -144,6 +159,8 @@ private:
|
||||
std::vector<AddressBookEntry> entries_;
|
||||
std::uint64_t revision_ = 0;
|
||||
std::string file_path_;
|
||||
std::vector<AddressBookEntry> sweep_backup_; // the real book while a UI sweep shows demo contacts
|
||||
bool sweep_active_ = false;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -38,6 +39,25 @@ struct ExchangeInfo {
|
||||
*/
|
||||
const std::vector<ExchangeInfo>& getExchangeRegistry();
|
||||
|
||||
/**
|
||||
* @brief Index of the selected exchange/pair in `reg`, resolved by saved NAME.
|
||||
* Matches the exchange name (any exchange when selEx is empty) THEN the pair display name, like the chart
|
||||
* fetch always has. Not found => (0,0), found=false (the registry's first pair - the app's default).
|
||||
* Single source of truth for the Market tab chips and App::refreshExchangeChart so they cannot diverge.
|
||||
*/
|
||||
struct ExchangeSelection { int exchangeIdx = 0; int pairIdx = 0; bool found = false; };
|
||||
inline ExchangeSelection resolveExchangeSelection(const std::vector<ExchangeInfo>& reg,
|
||||
const std::string& selEx, const std::string& selPair)
|
||||
{
|
||||
for (std::size_t ei = 0; ei < reg.size(); ++ei) {
|
||||
if (!selEx.empty() && reg[ei].name != selEx) continue;
|
||||
for (std::size_t pi = 0; pi < reg[ei].pairs.size(); ++pi)
|
||||
if (reg[ei].pairs[pi].displayName == selPair)
|
||||
return {static_cast<int>(ei), static_cast<int>(pi), true};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Pure decisions for the PIN quick-unlock vault (C87), kept free of App/UI/RPC state so they are
|
||||
// unit-testable. The vault seals the wallet passphrase under the PIN. None of these paths deletes
|
||||
// it: a vault whose passphrase the wallet no longer accepts is only marked out of date for the
|
||||
// session (no lockout penalty), and the user removes it and sets the PIN again. The vault may hold
|
||||
// the only copy of a passphrase that still opens an older wallet backup.
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// What a completed walletpassphrasechange means for the active wallet's PIN vault.
|
||||
enum class VaultAfterChange {
|
||||
Keep, // no vault, the change failed or kept the same passphrase, or the vault is another wallet's
|
||||
MarkStale, // the vault seals the OLD passphrase: stop offering the PIN this session and warn (keep the file)
|
||||
};
|
||||
|
||||
// Decided on the main thread when the change completes, not when it is sent, so a vault created
|
||||
// while the change was in flight is covered too. `pathAtDispatch` is the vault path captured when
|
||||
// the change was sent and `pathNow` the active vault path now: a wallet switch in between makes
|
||||
// them differ, and the other wallet's vault is left alone.
|
||||
inline VaultAfterChange decideVaultAfterPassphraseChange(bool changeSucceeded,
|
||||
bool vaultExistsNow,
|
||||
bool samePassphrase,
|
||||
const std::string& pathAtDispatch,
|
||||
const std::string& pathNow) {
|
||||
if (!changeSucceeded || !vaultExistsNow || samePassphrase) return VaultAfterChange::Keep;
|
||||
if (pathAtDispatch.empty() || pathAtDispatch != pathNow) return VaultAfterChange::Keep;
|
||||
return VaultAfterChange::MarkStale;
|
||||
}
|
||||
|
||||
// dragonxd's RPC_WALLET_PASSPHRASE_INCORRECT ("The wallet passphrase entered was incorrect.").
|
||||
constexpr int kRpcWalletPassphraseIncorrect = -14;
|
||||
|
||||
// A PIN unlock whose vault opened (so the PIN was right) but whose walletpassphrase call the daemon
|
||||
// refused with -14: the vault holds a passphrase the wallet no longer accepts (it was changed after
|
||||
// the PIN was set, e.g. with dragonx-cli walletpassphrasechange). Not a wrong-PIN attempt, so no
|
||||
// lockout penalty; the PIN is marked out of date and the vault is kept.
|
||||
inline bool isStaleVaultUnlockFailure(bool vaultDecrypted, int rpcCode) {
|
||||
return vaultDecrypted && rpcCode == kRpcWalletPassphraseIncorrect;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,69 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transaction_csv.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
std::string escapeCsvField(const std::string& field)
|
||||
{
|
||||
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
|
||||
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
|
||||
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
|
||||
std::string safe = field;
|
||||
if (!safe.empty()) {
|
||||
const char c0 = safe.front();
|
||||
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
|
||||
safe.insert(safe.begin(), '\'');
|
||||
}
|
||||
if (safe.find(',') != std::string::npos ||
|
||||
safe.find('"') != std::string::npos ||
|
||||
safe.find('\n') != std::string::npos) {
|
||||
std::string escaped;
|
||||
escaped.reserve(safe.size() + 4);
|
||||
escaped += '"';
|
||||
for (char c : safe) {
|
||||
if (c == '"') escaped += "\"\"";
|
||||
else escaped += c;
|
||||
}
|
||||
escaped += '"';
|
||||
return escaped;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs)
|
||||
{
|
||||
std::string out;
|
||||
out.reserve(64 + txs.size() * 200);
|
||||
out += "Date,Type,Amount,Address,TXID,Confirmations,Memo\n";
|
||||
for (const auto& tx : txs) {
|
||||
std::time_t t = static_cast<std::time_t>(tx.timestamp);
|
||||
std::tm tmv{};
|
||||
#ifdef _WIN32
|
||||
const bool okTm = localtime_s(&tmv, &t) == 0;
|
||||
#else
|
||||
const bool okTm = localtime_r(&t, &tmv) != nullptr;
|
||||
#endif
|
||||
char datebuf[32] = "";
|
||||
if (okTm) std::strftime(datebuf, sizeof(datebuf), "%Y-%m-%d %H:%M:%S", &tmv);
|
||||
char amt[48];
|
||||
std::snprintf(amt, sizeof(amt), "%.8f", tx.amount);
|
||||
out += datebuf; out += ',';
|
||||
out += escapeCsvField(tx.type); out += ',';
|
||||
out += amt; out += ',';
|
||||
out += escapeCsvField(tx.address); out += ',';
|
||||
out += escapeCsvField(tx.txid); out += ',';
|
||||
out += std::to_string(tx.confirmations); out += ',';
|
||||
out += escapeCsvField(tx.memo); out += '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,23 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// CSV-escape one field: neutralises spreadsheet formula injection (leading = + - @ \t \r gets a
|
||||
// ' prefix) and quotes fields containing , " or \n.
|
||||
std::string escapeCsvField(const std::string& field);
|
||||
|
||||
// "Date,Type,Amount,Address,TXID,Confirmations,Memo\n" + one line per transaction
|
||||
// (local time, 8-decimals amount).
|
||||
std::string formatTransactionsCsv(const std::vector<TransactionInfo>& txs);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
@@ -110,8 +111,25 @@ std::string associatedDataForWallet(const std::string& walletHash)
|
||||
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
|
||||
}
|
||||
|
||||
// Non-reversible 64-bit tag of a derived key (BLAKE2b keyed by the key, over a fixed label). Only used to
|
||||
// notice that the key changed; nothing about the key can be recovered from it, and it never leaves memory.
|
||||
std::uint64_t computeKeyId(const std::array<unsigned char, 32>& key)
|
||||
{
|
||||
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-keyid";
|
||||
unsigned char out[crypto_generichash_BYTES_MIN]; // 16, libsodium's minimum; the first 8 are used
|
||||
crypto_generichash(out, sizeof(out), kLabel, sizeof(kLabel) - 1, key.data(), key.size());
|
||||
std::uint64_t id = 0;
|
||||
std::memcpy(&id, out, sizeof(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransactionHistoryCache::SnapshotJob::~SnapshotJob()
|
||||
{
|
||||
sodium_memzero(key.data(), key.size());
|
||||
}
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache()
|
||||
: TransactionHistoryCache(defaultDatabasePath())
|
||||
{
|
||||
@@ -170,6 +188,7 @@ std::string TransactionHistoryCache::walletIdentityHash(const std::string& walle
|
||||
|
||||
bool TransactionHistoryCache::ensureOpen()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
@@ -212,29 +231,91 @@ bool TransactionHistoryCache::ensureOpen()
|
||||
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
|
||||
const std::string& passphrase)
|
||||
{
|
||||
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
|
||||
if (walletIdentity.empty() || passphrase.empty()) return false;
|
||||
|
||||
// Held across the KDF on purpose: "this wallet's salt row exists" and "this wallet is the unlocked one"
|
||||
// must be atomic with respect to a writer job's prune (writeSnapshot keeps the unlocked wallet), or a job
|
||||
// for the previous wallet could delete the salt this key is being derived from. The UI thread only waits
|
||||
// here for a write already in flight, never for the queue.
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
|
||||
if (!deriveKey(passphrase, salt)) return false;
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
std::array<unsigned char, kKeyBytes> derived{};
|
||||
if (!deriveKey(passphrase, salt, derived)) return false;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
key_ = derived;
|
||||
key_id_ = computeKeyId(derived);
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
}
|
||||
sodium_memzero(derived.data(), derived.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::unlockUnencrypted(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
std::array<unsigned char, kKeyBytes> derived{};
|
||||
if (!deriveKeyFast(walletHash, salt, derived)) return false;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
key_ = derived;
|
||||
key_id_ = computeKeyId(derived);
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
}
|
||||
sodium_memzero(derived.data(), derived.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::lockKey()
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (key_ready_) sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
unlocked_wallet_hash_.clear();
|
||||
// key_id_ is kept on purpose: re-unlocking with the same passphrase yields the same id.
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::hasKey() const
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_ready_;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
|
||||
{
|
||||
return key_ready_ && !walletIdentity.empty() &&
|
||||
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
|
||||
if (walletIdentity.empty()) return false;
|
||||
const std::string hash = walletIdentityHash(walletIdentity); // outside the lock
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_ready_ && unlocked_wallet_hash_ == hash;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::captureKey(const std::string& walletIdentity,
|
||||
std::array<unsigned char, 32>& keyOut) const
|
||||
{
|
||||
if (walletIdentity.empty()) return false;
|
||||
const std::string hash = walletIdentityHash(walletIdentity);
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (!key_ready_ || unlocked_wallet_hash_ != hash) return false;
|
||||
keyOut = key_;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t TransactionHistoryCache::keyId() const
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
return key_id_;
|
||||
}
|
||||
|
||||
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
@@ -243,6 +324,7 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
const std::string& currentTipHash)
|
||||
{
|
||||
LoadResult result;
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_); // serialised against the writer's INSERT/prune
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
@@ -303,21 +385,42 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights)
|
||||
{
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
|
||||
// The synchronous form (tests, tools): the same write the background writer does.
|
||||
SnapshotJob job;
|
||||
if (!captureKey(walletIdentity, job.key)) return false;
|
||||
job.walletIdentity = walletIdentity;
|
||||
job.tipHeight = tipHeight;
|
||||
job.tipHash = tipHash;
|
||||
job.transactions = transactions;
|
||||
job.updatedAt = updatedAt;
|
||||
job.shieldedScanHeights = shieldedScanHeights;
|
||||
return writeSnapshot(std::move(job));
|
||||
}
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
|
||||
{
|
||||
struct WipeKey {
|
||||
std::array<unsigned char, 32>& key;
|
||||
~WipeKey() { sodium_memzero(key.data(), key.size()); }
|
||||
} wipe{job.key};
|
||||
|
||||
// A job without a key would replace the good snapshot with one nobody can read.
|
||||
if (job.walletIdentity.empty() || job.key == std::array<unsigned char, 32>{}) return false;
|
||||
const std::string walletHash = walletIdentityHash(job.walletIdentity);
|
||||
|
||||
// ---- the expensive part (JSON DOM, dump, AEAD), with no lock held ----
|
||||
json payload;
|
||||
payload["schema_version"] = kSchemaVersion;
|
||||
payload["wallet_hash"] = walletHash;
|
||||
payload["tip_height"] = tipHeight;
|
||||
payload["tip_hash"] = tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
|
||||
payload["tip_height"] = job.tipHeight;
|
||||
payload["tip_hash"] = job.tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(job.updatedAt);
|
||||
payload["transactions"] = json::array();
|
||||
for (const auto& transaction : transactions) {
|
||||
for (const auto& transaction : job.transactions) {
|
||||
payload["transactions"].push_back(transactionToJson(transaction));
|
||||
}
|
||||
payload["shielded_scan_heights"] = json::object();
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
for (const auto& [address, height] : job.shieldedScanHeights) {
|
||||
if (!address.empty() && height >= 0) {
|
||||
payload["shielded_scan_heights"][address] = height;
|
||||
}
|
||||
@@ -326,10 +429,14 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
std::string plainText = payload.dump();
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
|
||||
const bool encrypted = encryptPayload(job.key, walletHash, plainText, nonce, cipherText);
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
// ---- one autocommit INSERT OR REPLACE (WAL: a crash leaves the previous snapshot), then the prune ----
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_snapshots "
|
||||
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
|
||||
@@ -338,14 +445,22 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
|
||||
if (!bindText(statement.handle, 4, tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight));
|
||||
if (!bindText(statement.handle, 4, job.tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(job.updatedAt));
|
||||
if (!bindBlob(statement.handle, 6, nonce)) return false;
|
||||
if (!bindBlob(statement.handle, 7, cipherText)) return false;
|
||||
|
||||
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false;
|
||||
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts
|
||||
if (sqlite3_step(statement.handle) != SQLITE_DONE) {
|
||||
DEBUG_LOGF("Transaction history cache write failed: %s\n", sqlite3_errmsg(db_));
|
||||
return false;
|
||||
}
|
||||
std::string liveHash;
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
liveHash = unlocked_wallet_hash_;
|
||||
}
|
||||
pruneOtherWallets(walletHash, liveHash); // bound DB growth — drop stale-hash snapshots/salts
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -357,6 +472,7 @@ void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
|
||||
|
||||
int TransactionHistoryCache::snapshotCount()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
@@ -417,8 +533,23 @@ std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::s
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-unenc-v1";
|
||||
// keyed BLAKE2b over label||walletHash, keyed by the per-wallet salt -> 32-byte key.
|
||||
std::string msg(reinterpret_cast<const char*>(kLabel), sizeof(kLabel) - 1);
|
||||
msg += walletHash;
|
||||
return crypto_generichash(keyOut.data(), keyOut.size(),
|
||||
reinterpret_cast<const unsigned char*>(msg.data()), msg.size(),
|
||||
salt.data(), salt.size()) == 0;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt)
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
unsigned char derived[kKeyBytes];
|
||||
@@ -429,17 +560,17 @@ bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (result != 0) return false;
|
||||
std::copy(derived, derived + sizeof(derived), key_.begin());
|
||||
std::copy(derived, derived + sizeof(derived), keyOut.begin());
|
||||
sodium_memzero(derived, sizeof(derived));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
bool TransactionHistoryCache::encryptPayload(const std::array<unsigned char, 32>& key,
|
||||
const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const
|
||||
std::vector<unsigned char>& cipherText)
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
@@ -450,7 +581,7 @@ bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
cipherText.data(), &cipherLength,
|
||||
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nullptr, nonce.data(), key_.data());
|
||||
nullptr, nonce.data(), key.data());
|
||||
if (result != 0) return false;
|
||||
cipherText.resize(static_cast<std::size_t>(cipherLength));
|
||||
return true;
|
||||
@@ -461,10 +592,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const
|
||||
{
|
||||
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
|
||||
return false;
|
||||
}
|
||||
std::array<unsigned char, kKeyBytes> key{};
|
||||
{
|
||||
std::lock_guard<std::mutex> keyLock(key_mutex_);
|
||||
if (!key_ready_) return false;
|
||||
key = key_;
|
||||
}
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
@@ -473,7 +610,8 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
plain.data(), &plainLength, nullptr,
|
||||
cipherText.data(), cipherText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nonce.data(), key_.data());
|
||||
nonce.data(), key.data());
|
||||
sodium_memzero(key.data(), key.size());
|
||||
if (result != 0) return false;
|
||||
|
||||
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
|
||||
@@ -506,6 +644,7 @@ bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
|
||||
|
||||
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen()) return;
|
||||
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return;
|
||||
@@ -513,22 +652,28 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash)
|
||||
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash,
|
||||
const std::string& alsoKeepWalletHash)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!ensureOpen() || keepWalletHash.empty()) return;
|
||||
const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash;
|
||||
// Table names are hardcoded literals (no injection surface). Prune both the snapshot
|
||||
// blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext.
|
||||
for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) {
|
||||
const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ?";
|
||||
const std::string sql = std::string("DELETE FROM ") + table +
|
||||
" WHERE wallet_hash <> ? AND wallet_hash <> ?";
|
||||
Statement statement(db_, sql.c_str());
|
||||
if (!statement.handle) continue;
|
||||
if (!bindText(statement.handle, 1, keepWalletHash)) continue;
|
||||
if (!bindText(statement.handle, 2, alsoKeep)) continue;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::close()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -26,6 +27,26 @@ public:
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
};
|
||||
|
||||
// Everything one snapshot write needs, BY VALUE, so it can run on the history writer thread after the UI
|
||||
// state it came from has moved on (and after lockKey() zeroed the live key). Move-only; the private key
|
||||
// copy is wiped by writeSnapshot() and, for a job that never ran (superseded), by the destructor.
|
||||
struct SnapshotJob {
|
||||
std::string walletIdentity;
|
||||
std::array<unsigned char, 32> key{};
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::time_t updatedAt = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
|
||||
SnapshotJob() = default;
|
||||
SnapshotJob(const SnapshotJob&) = delete;
|
||||
SnapshotJob& operator=(const SnapshotJob&) = delete;
|
||||
SnapshotJob(SnapshotJob&&) = default;
|
||||
SnapshotJob& operator=(SnapshotJob&&) = default;
|
||||
~SnapshotJob();
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
explicit TransactionHistoryCache(std::string databasePath);
|
||||
~TransactionHistoryCache();
|
||||
@@ -40,9 +61,17 @@ public:
|
||||
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
// Unlock an UNENCRYPTED wallet's cache without Argon2id: the "secret" is derivable from the DB, so a
|
||||
// cheap domain-separated BLAKE2b KDF is security-equivalent and avoids a ~50-100 ms UI-thread stall.
|
||||
bool unlockUnencrypted(const std::string& walletIdentity);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool hasKey() const;
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
// Copy of the live key if it is unlocked for `walletIdentity` (keyOut untouched otherwise). Caller wipes it.
|
||||
bool captureKey(const std::string& walletIdentity, std::array<unsigned char, 32>& keyOut) const;
|
||||
// Non-reversible id of the key last installed. Survives lockKey(), so re-unlocking with the same
|
||||
// passphrase gives the same id and a passphrase change gives another. 0 = never unlocked.
|
||||
std::uint64_t keyId() const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
@@ -53,6 +82,9 @@ public:
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
// Thread-safe snapshot write (the history writer thread; replace() wraps it): JSON + AEAD with no lock
|
||||
// held, then INSERT OR REPLACE + prune under the DB lock. Always wipes job.key. False for a keyless job.
|
||||
bool writeSnapshot(SnapshotJob&& job);
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
@@ -60,12 +92,17 @@ private:
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt);
|
||||
bool encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const;
|
||||
static bool deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut); // BLAKE2b, label-separated
|
||||
static bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut);
|
||||
static bool encryptPayload(const std::array<unsigned char, 32>& key,
|
||||
const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
@@ -79,14 +116,21 @@ private:
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the
|
||||
// DB so generating a new address (which changes the hash) doesn't orphan history forever.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash);
|
||||
// Also keeps `alsoKeepWalletHash` (the wallet unlocked now, if any): a delayed writer job for the previous
|
||||
// wallet must not delete the salt of the wallet just switched to.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash, const std::string& alsoKeepWalletHash);
|
||||
void close();
|
||||
|
||||
// Lock order: db_mutex_ then key_mutex_. key_mutex_ is never held across I/O or the KDF. Only the UI
|
||||
// thread changes the key fields; the writer thread reads unlocked_wallet_hash_ (prune) under both locks.
|
||||
mutable std::recursive_mutex db_mutex_; // db_ and every SQL statement
|
||||
mutable std::mutex key_mutex_; // key_, key_ready_, unlocked_wallet_hash_, key_id_
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
std::uint64_t key_id_ = 0;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transaction_history_store_policy.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::uint64_t kOffset = 0xcbf29ce484222325ULL;
|
||||
constexpr std::uint64_t kMul = 0x9E3779B97F4A7C15ULL; // odd
|
||||
|
||||
static_assert(sizeof(double) == sizeof(std::uint64_t), "amount bit pattern is hashed as 64 bits");
|
||||
|
||||
// 8 bytes per step: a 5k-row wallet (~1.5 MB of row text) hashes in about a millisecond. Endianness-dependent
|
||||
// on purpose: the value never leaves the process.
|
||||
inline void mixBytes(std::uint64_t& h, const void* data, std::size_t n)
|
||||
{
|
||||
const auto* b = static_cast<const unsigned char*>(data);
|
||||
while (n >= 8) {
|
||||
std::uint64_t w;
|
||||
std::memcpy(&w, b, 8);
|
||||
h = (h ^ w) * kMul;
|
||||
h ^= h >> 32;
|
||||
b += 8;
|
||||
n -= 8;
|
||||
}
|
||||
if (n) {
|
||||
std::uint64_t w = 0;
|
||||
std::memcpy(&w, b, n);
|
||||
h = (h ^ w ^ (static_cast<std::uint64_t>(n) << 56)) * kMul;
|
||||
h ^= h >> 32;
|
||||
}
|
||||
}
|
||||
inline void mixU64(std::uint64_t& h, std::uint64_t v) { mixBytes(h, &v, sizeof(v)); }
|
||||
inline void mixStr(std::uint64_t& h, const std::string& s)
|
||||
{
|
||||
mixU64(h, s.size()); // length first: ("ab","c") != ("a","bc")
|
||||
mixBytes(h, s.data(), s.size());
|
||||
}
|
||||
inline std::uint64_t finalize(std::uint64_t x) // splitmix64 finalizer
|
||||
{
|
||||
x += 0x9e3779b97f4a7c15ULL;
|
||||
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
|
||||
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
|
||||
return x ^ (x >> 31);
|
||||
}
|
||||
// The thresholds a stored row is read at: transactions_tab.cpp:788-793 / transaction_details_dialog.cpp:80-85
|
||||
// (0 pending, < 10 counted, >= 100 mature for mined), mining_earnings.cpp:102 (>= 100), the confirmed-tx cache
|
||||
// (>= 10, app_network.cpp:1915) and conflicted (< 0, network_refresh_service.cpp:1470).
|
||||
inline std::uint64_t confirmationBucket(int c)
|
||||
{
|
||||
return c < 0 ? 0 : c == 0 ? 1 : c < 10 ? 2 : c < 100 ? 3 : 4;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
|
||||
const std::unordered_set<std::string>& skipTxids,
|
||||
const std::string& walletHash,
|
||||
std::uint64_t keyId,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
||||
int tipHeight,
|
||||
const std::string& tipHash)
|
||||
{
|
||||
HistorySnapshotSig sig;
|
||||
std::uint64_t rowSum = 0; // sums: commutative, so row order does not matter; duplicates still count
|
||||
std::uint64_t exactSum = 0;
|
||||
for (const auto& tx : transactions) {
|
||||
if (!skipTxids.empty() && skipTxids.count(tx.txid)) continue;
|
||||
std::uint64_t h = kOffset;
|
||||
mixStr(h, tx.txid);
|
||||
mixStr(h, tx.type);
|
||||
std::uint64_t amountBits = 0;
|
||||
std::memcpy(&amountBits, &tx.amount, sizeof(amountBits));
|
||||
mixU64(h, amountBits);
|
||||
mixU64(h, static_cast<std::uint64_t>(tx.timestamp));
|
||||
mixStr(h, tx.address);
|
||||
mixStr(h, tx.from_address);
|
||||
mixStr(h, tx.memo);
|
||||
std::uint64_t e = h;
|
||||
mixU64(h, confirmationBucket(tx.confirmations));
|
||||
mixU64(e, static_cast<std::uint64_t>(static_cast<std::int64_t>(tx.confirmations)));
|
||||
rowSum += finalize(h);
|
||||
exactSum += finalize(e);
|
||||
++sig.rows;
|
||||
}
|
||||
std::uint64_t c = kOffset;
|
||||
mixU64(c, rowSum);
|
||||
mixU64(c, sig.rows);
|
||||
mixStr(c, walletHash);
|
||||
mixU64(c, keyId);
|
||||
sig.content = finalize(c);
|
||||
|
||||
std::uint64_t heightSum = 0;
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
if (address.empty() || height < 0) continue; // exactly what replace() persists
|
||||
std::uint64_t h = kOffset;
|
||||
mixStr(h, address);
|
||||
mixU64(h, static_cast<std::uint64_t>(height));
|
||||
heightSum += finalize(h);
|
||||
}
|
||||
std::uint64_t s = kOffset;
|
||||
mixU64(s, heightSum);
|
||||
mixU64(s, exactSum);
|
||||
mixU64(s, static_cast<std::uint64_t>(static_cast<std::int64_t>(tipHeight)));
|
||||
mixStr(s, tipHash);
|
||||
sig.slow = finalize(s);
|
||||
return sig;
|
||||
}
|
||||
|
||||
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& in)
|
||||
{
|
||||
const bool contentDirty = !in.haveStored || in.stored.content != in.current.content;
|
||||
const bool slowDirty = in.haveStored && in.stored.slow != in.current.slow;
|
||||
if (in.mode == HistoryStoreMode::Now)
|
||||
return (contentDirty || slowDirty) ? HistoryStoreAction::Store : HistoryStoreAction::Skip;
|
||||
if (contentDirty)
|
||||
return in.sinceLastSubmitSec >= kHistoryMinStoreIntervalSec ? HistoryStoreAction::Store
|
||||
: HistoryStoreAction::Defer;
|
||||
if (slowDirty && in.sinceLastSubmitSec >= kHistorySlowStoreIntervalSec) return HistoryStoreAction::Store;
|
||||
return HistoryStoreAction::Skip;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,64 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure decision logic for persisting the tx-history snapshot: what counts as "changed", and when a write is
|
||||
// due. No I/O, no clock, no sodium: unit-tested directly.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// 64-bit fingerprints of what a snapshot would contain.
|
||||
struct HistorySnapshotSig {
|
||||
std::uint64_t content = 0; // rows (confirmations by UI bucket) + wallet hash + key id
|
||||
std::uint64_t slow = 0; // shielded scan heights + raw confirmations + tip: drift every block
|
||||
std::size_t rows = 0; // rows that would be persisted (pending sends excluded)
|
||||
};
|
||||
|
||||
// Order-independent over rows. `content` covers the persisted row fields (txid, type, amount bit pattern,
|
||||
// timestamp, address, from_address, memo) with confirmations bucketed at the thresholds the UI and the refresh
|
||||
// read off a stored row (< 0 conflicted, 0 pending, 1-9 counted, >= 10 confirmed, >= 100 mature), plus the
|
||||
// wallet hash and `keyId` (a passphrase change must rewrite: the old ciphertext is unreadable with the new key).
|
||||
// `slow` covers what changes every block: the per-address scan heights (entries replace() would persist), each
|
||||
// row's raw confirmations, and the tip.
|
||||
HistorySnapshotSig historySnapshotSignature(const std::vector<TransactionInfo>& transactions,
|
||||
const std::unordered_set<std::string>& skipTxids,
|
||||
const std::string& walletHash,
|
||||
std::uint64_t keyId,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights,
|
||||
int tipHeight,
|
||||
const std::string& tipHash);
|
||||
|
||||
enum class HistoryStoreMode {
|
||||
Coalesce, // routine refresh: content changes at most every kHistoryMinStoreIntervalSec, slow drift every
|
||||
// kHistorySlowStoreIntervalSec
|
||||
Now // user action / disconnect / lock / exit: store now if anything differs from the last submit
|
||||
};
|
||||
enum class HistoryStoreAction { Skip, Defer, Store };
|
||||
|
||||
constexpr double kHistoryMinStoreIntervalSec = 20.0;
|
||||
constexpr double kHistorySlowStoreIntervalSec = 600.0;
|
||||
|
||||
struct HistoryStoreInput {
|
||||
HistoryStoreMode mode = HistoryStoreMode::Coalesce;
|
||||
bool haveStored = false; // a snapshot of this session (submitted, or the loaded one) is on disk
|
||||
HistorySnapshotSig stored; // its signature
|
||||
HistorySnapshotSig current;
|
||||
double sinceLastSubmitSec = 1e9; // 1e9 = never submitted this session
|
||||
};
|
||||
|
||||
HistoryStoreAction decideHistoryStore(const HistoryStoreInput& input);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
#include "../util/coalescing_writer.h"
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
@@ -88,6 +89,7 @@ bool WalletIndex::load()
|
||||
|
||||
bool WalletIndex::save()
|
||||
{
|
||||
if (sweep_active_) return true; // UI sweep: demo entries never reach disk
|
||||
if (file_path_.empty()) file_path_ = getDefaultPath();
|
||||
|
||||
try {
|
||||
@@ -109,7 +111,8 @@ bool WalletIndex::save()
|
||||
j["extraFolders"] = extra_folders_;
|
||||
|
||||
// No secrets here (file names + coarse metadata) but keep it owner-only for consistency.
|
||||
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
|
||||
if (!util::persistFileAtomically(writer_, /*waitForDisk=*/false, file_path_, j.dump(2),
|
||||
/*restrictPermissions=*/true)) {
|
||||
DEBUG_LOGF("Could not write wallet index: %s\n", file_path_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
+32
-1
@@ -5,9 +5,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util { class CoalescingWriter; }
|
||||
namespace data {
|
||||
|
||||
/**
|
||||
@@ -41,9 +43,12 @@ struct WalletIndexEntry {
|
||||
class WalletIndex {
|
||||
public:
|
||||
bool load();
|
||||
bool save();
|
||||
bool save(); // queued on the writer when one is attached (returns true once queued)
|
||||
static std::string getDefaultPath();
|
||||
|
||||
/** @brief Route save() through a background writer (not owned; must outlive this). Null = synchronous. */
|
||||
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
|
||||
|
||||
const std::vector<WalletIndexEntry>& entries() const { return entries_; }
|
||||
const std::vector<std::string>& extraFolders() const { return extra_folders_; }
|
||||
|
||||
@@ -60,10 +65,36 @@ public:
|
||||
bool addExtraFolder(const std::string& dir);
|
||||
bool removeExtraFolder(const std::string& dir);
|
||||
|
||||
/**
|
||||
* @brief UI-sweep ONLY: snapshot the entries and extra folders; until sweepEnd(), save() returns
|
||||
* true without writing or queueing. The sweep's demo upserts drop the real entry's scopeId, and
|
||||
* activeWalletScopeId() would then mint and save a new one, hiding the user's "w:"-scoped contacts.
|
||||
*/
|
||||
void sweepBegin() {
|
||||
if (sweep_active_) return;
|
||||
sweep_entries_ = entries_;
|
||||
sweep_extra_folders_ = extra_folders_;
|
||||
sweep_active_ = true;
|
||||
}
|
||||
/** @brief UI-sweep ONLY: restore the snapshot (no disk write). Idempotent. */
|
||||
void sweepEnd() {
|
||||
if (!sweep_active_) return;
|
||||
entries_ = std::move(sweep_entries_);
|
||||
extra_folders_ = std::move(sweep_extra_folders_);
|
||||
sweep_entries_.clear();
|
||||
sweep_extra_folders_.clear();
|
||||
sweep_active_ = false;
|
||||
}
|
||||
bool sweepActive() const { return sweep_active_; }
|
||||
|
||||
private:
|
||||
std::vector<WalletIndexEntry> entries_;
|
||||
std::vector<std::string> extra_folders_;
|
||||
std::string file_path_;
|
||||
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous writes
|
||||
std::vector<WalletIndexEntry> sweep_entries_; // the real index while a UI sweep runs
|
||||
std::vector<std::string> sweep_extra_folders_;
|
||||
bool sweep_active_ = false;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
enum class EncryptionPendingAction { None, Clear, Warn };
|
||||
|
||||
// What to do with the persisted "encryption requested in the wizard" flag, given ONE parsed getwalletinfo
|
||||
// result. rpcOk must come from the parsed reply (a -28 warm-up failure or a throw is rpcOk=false => None);
|
||||
// never derive it from WalletState (a 15 s timeout forces encrypted=false there).
|
||||
inline EncryptionPendingAction decideEncryptionPending(bool flagSet, bool rpcOk, bool walletEncrypted,
|
||||
bool deferredPending, bool encryptInProgress,
|
||||
bool alreadyWarned)
|
||||
{
|
||||
if (!flagSet || !rpcOk) return EncryptionPendingAction::None;
|
||||
if (walletEncrypted) return EncryptionPendingAction::Clear;
|
||||
if (deferredPending || encryptInProgress || alreadyWarned) return EncryptionPendingAction::None;
|
||||
return EncryptionPendingAction::Warn;
|
||||
}
|
||||
|
||||
// ---- C90 ----
|
||||
constexpr int kSetPinProbeSecs = 5; // the verification unlock used when the wallet is locked
|
||||
|
||||
struct SetPinVerifyPlan {
|
||||
int timeoutSecs; // walletpassphrase timeout for the passphrase check
|
||||
bool relockAfter; // call walletlock afterwards (only if the wallet was locked before)
|
||||
};
|
||||
|
||||
// Set PIN verifies the passphrase with walletpassphrase. If the wallet is unlocked now, re-unlock with the time
|
||||
// it has left (replaces the daemon's timer with the same deadline) and DO NOT relock; otherwise a 5 s probe + relock.
|
||||
inline SetPinVerifyPlan planSetPinVerify(bool guiUnlocked, std::int64_t unlockedUntil, std::int64_t now)
|
||||
{
|
||||
const std::int64_t left = unlockedUntil - now;
|
||||
if (guiUnlocked && left > kSetPinProbeSecs) {
|
||||
const std::int64_t capped = left > 86400 * 7 ? 86400 * 7 : left;
|
||||
return {static_cast<int>(capped), false};
|
||||
}
|
||||
return {kSetPinProbeSecs, true};
|
||||
}
|
||||
|
||||
// The daemon's absolute unlock window has run out but the GUI still says "unlocked". Wall clock, because the
|
||||
// deadline is an absolute unix time. unlockedUntil == 0 means "no deadline known" => never expired here.
|
||||
inline bool unlockWindowExpired(bool encrypted, bool locked, std::int64_t unlockedUntil, std::int64_t now)
|
||||
{
|
||||
return encrypted && !locked && unlockedUntil > 0 && now >= unlockedUntil;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -43,6 +43,32 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address)
|
||||
{
|
||||
if (address.empty()) return -1;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].address == address) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
|
||||
bool autoLatched, bool connected)
|
||||
{
|
||||
SourceSelection r;
|
||||
r.address = current;
|
||||
r.autoLatched = autoLatched;
|
||||
r.index = addressIndexByString(addresses, current);
|
||||
if (!autoLatched && connected && !addresses.empty()) {
|
||||
if (current.empty()) { // only ever fill an empty source
|
||||
const int best = bestSpendableAddressIndex(addresses);
|
||||
if (best >= 0) { r.address = addresses[best].address; r.index = best; }
|
||||
}
|
||||
r.autoLatched = true; // one shot, whether or not it picked
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string TransactionInfo::getTimeString() const
|
||||
{
|
||||
if (timestamp == 0) return "Unknown";
|
||||
|
||||
+62
-2
@@ -9,6 +9,7 @@
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
#include "exchange_info.h"
|
||||
@@ -41,6 +42,20 @@ struct AddressInfo {
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance = true);
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
||||
// Index of the address whose string equals `address` (exact match), or -1 (empty string / not listed).
|
||||
int addressIndexByString(const std::vector<AddressInfo>& addresses, const std::string& address);
|
||||
|
||||
// The Send tab's source-address resolution, pure. `current` (s_from_address) is the single source of truth:
|
||||
// the one-shot auto-pick (highest confirmed balance) only fills an EMPTY source, and never replaces one the
|
||||
// user - or another tab's "Send from this address" - already set. A source that is not in the list (the list
|
||||
// can be transiently short) is kept as is with index -1; it must NOT trigger a re-pick.
|
||||
struct SourceSelection {
|
||||
std::string address; // what s_from_address must hold afterwards
|
||||
int index = -1; // its row in `addresses`, -1 if not listed
|
||||
bool autoLatched = false; // new value of the one-shot latch
|
||||
};
|
||||
SourceSelection resolveSourceSelection(const std::vector<AddressInfo>& addresses, const std::string& current,
|
||||
bool autoLatched, bool connected);
|
||||
|
||||
/**
|
||||
* @brief Represents a wallet transaction
|
||||
@@ -62,6 +77,22 @@ struct TransactionInfo {
|
||||
bool isMature() const { return confirmations >= 100; }
|
||||
};
|
||||
|
||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||
// BIT-exactly (like the old History fingerprint): -0.0 and +0.0 render differently ("+-0.0" vs "+0.0").
|
||||
// Must list EVERY TransactionInfo field (tests mutate each one).
|
||||
inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo& b) {
|
||||
return a.timestamp == b.timestamp && a.confirmations == b.confirmations &&
|
||||
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
|
||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||
a.from_address == b.from_address && a.memo == b.memo;
|
||||
}
|
||||
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i)
|
||||
if (!sameTransactionInfo(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Represents a connected peer
|
||||
*/
|
||||
@@ -115,7 +146,8 @@ struct MiningInfo {
|
||||
|
||||
// History for chart
|
||||
std::vector<double> hashrate_history; // Last N samples
|
||||
static constexpr int MAX_HISTORY = 300; // 5 minutes at 1s intervals
|
||||
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
|
||||
static constexpr int MAX_HISTORY = 300; // 5 minutes at ~1s intervals
|
||||
|
||||
// Recent daemon log lines for the mining log panel
|
||||
std::vector<std::string> log_lines;
|
||||
@@ -130,6 +162,7 @@ struct SyncInfo {
|
||||
double verification_progress = 0.0;
|
||||
bool syncing = false;
|
||||
std::string best_blockhash;
|
||||
std::string lite_error; // lite: last sync failure text, "" = none
|
||||
|
||||
// Rescan state (detected from daemon output)
|
||||
bool rescanning = false;
|
||||
@@ -231,7 +264,8 @@ struct PoolMiningState {
|
||||
|
||||
// Hashrate history for chart (mirrors MiningInfo::hashrate_history)
|
||||
std::vector<double> hashrate_history;
|
||||
static constexpr int MAX_HISTORY = 60; // 5 minutes at ~5s intervals
|
||||
std::vector<std::time_t> hashrate_history_t; // wall-clock stamp per sample (lockstep with hashrate_history)
|
||||
static constexpr int MAX_HISTORY = 60; // ~2 min at the ~2s pool poll
|
||||
|
||||
// Recent log lines for the log panel
|
||||
std::vector<std::string> log_lines;
|
||||
@@ -318,8 +352,30 @@ struct WalletState {
|
||||
|
||||
// Timestamps for refresh logic
|
||||
int64_t last_balance_update = 0;
|
||||
// A Core poll that skipped the balance confirmed it is still current (nothing it depends on changed):
|
||||
// the staleness badge counts from the later of the two.
|
||||
int64_t balance_verified_at = 0;
|
||||
// The last balance call that was sent failed (or timed out): the staleness badge then shows at its usual
|
||||
// thresholds even while the scan budget is holding the next one.
|
||||
bool balance_last_attempt_failed = false;
|
||||
int wallet_tx_count = -1; // getwalletinfo's txcount; -1 = unknown
|
||||
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
|
||||
int64_t last_tx_update = 0;
|
||||
|
||||
// Change counter for `transactions` (main thread only). Starts at 1 so a UI cache's 0 sentinel never matches;
|
||||
// only ever increases (clear() bumps, never resets) so a wallet switch can't alias an old cache key. EVERY
|
||||
// write to `transactions` (replace, insert/erase, in-place field edit) must use the helpers below or call
|
||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||
std::uint64_t tx_revision = 1;
|
||||
void touchTransactions() { ++tx_revision; }
|
||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||
if (sameTransactionList(transactions, next)) return false;
|
||||
replaceTransactions(std::move(next));
|
||||
return true;
|
||||
}
|
||||
int64_t last_peer_update = 0;
|
||||
int64_t last_mining_update = 0;
|
||||
|
||||
@@ -351,6 +407,7 @@ struct WalletState {
|
||||
z_addresses.clear();
|
||||
t_addresses.clear();
|
||||
transactions.clear();
|
||||
touchTransactions(); // never reset: a post-switch revision must differ from every cached key
|
||||
peers.clear();
|
||||
bannedPeers.clear();
|
||||
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
|
||||
@@ -362,6 +419,9 @@ struct WalletState {
|
||||
// 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;
|
||||
balance_verified_at = 0;
|
||||
balance_last_attempt_failed = false;
|
||||
wallet_tx_count = -1;
|
||||
}
|
||||
|
||||
// Rebuild combined addresses list from z/t lists
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// DragonX Wallet - INCBIN text-resource embedding (CMake-generated). Do NOT edit the generated copy.
|
||||
#include "incbin.h"
|
||||
INCBIN(ui_toml, "@CMAKE_SOURCE_DIR@/res/themes/ui.toml");
|
||||
INCBIN(default_banlist, "@CMAKE_SOURCE_DIR@/res/default_banlist.txt");
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
// Defined in generated/embedded_text.cpp via INCBIN (.incbin). Data is NOT NUL-terminated;
|
||||
// every consumer passes an explicit size. Size is `unsigned int`.
|
||||
extern "C" {
|
||||
extern const unsigned char g_ui_toml_data[];
|
||||
extern const unsigned int g_ui_toml_size;
|
||||
extern const unsigned char g_default_banlist_data[];
|
||||
extern const unsigned int g_default_banlist_size;
|
||||
}
|
||||
+380
-101
@@ -23,6 +23,7 @@
|
||||
#include "imgui_internal.h"
|
||||
#include "imgui_impl_sdl3.h"
|
||||
#include "ui/effects/imgui_acrylic.h"
|
||||
#include "ui/effects/capture_extent.h"
|
||||
#include "ui/effects/theme_effects.h"
|
||||
#include "util/texture_loader.h"
|
||||
#include "util/platform.h"
|
||||
@@ -48,6 +49,7 @@
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows_backdrop.h"
|
||||
#include <windows.h>
|
||||
#include <io.h>
|
||||
#include <dwmapi.h>
|
||||
#include <shlobj.h>
|
||||
#include <propkey.h>
|
||||
@@ -71,11 +73,13 @@ extern "C" HRESULT __stdcall SHGetPropertyStoreForWindow(HWND hwnd, REFIID riid,
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstdlib>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
@@ -330,6 +334,11 @@ static LRESULT CALLBACK BorderlessWndProc(HWND hwnd, UINT msg,
|
||||
return CallWindowProcW(g_sdlWndProc, hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
// Caption-button window ops are deferred out of the open ImGui frame and run
|
||||
// after the frame closes (SDL pushes resize events synchronously). (C146)
|
||||
enum class CaptionOp { None, Minimize, Maximize, Restore };
|
||||
static CaptionOp g_pendingCaptionOp = CaptionOp::None;
|
||||
|
||||
// Draw custom window-control buttons (minimize / maximize-restore / close)
|
||||
// on the foreground draw list, and handle title-bar dragging.
|
||||
static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
@@ -376,7 +385,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
float cy = oy + btnH * 0.5f, lx = minX + btnW * 0.5f - iconHalf;
|
||||
fg->AddLine(ImVec2(lx, cy), ImVec2(lx + iconHalf * 2.0f, cy),
|
||||
wcIcon, lineThk);
|
||||
if (hov && click) SDL_MinimizeWindow(window);
|
||||
if (hov && click) g_pendingCaptionOp = CaptionOp::Minimize;
|
||||
}
|
||||
|
||||
// --- Maximize / Restore button ---
|
||||
@@ -398,10 +407,7 @@ static void renderBorderlessControls(SDL_Window* window, HWND /*hwnd*/,
|
||||
ImVec2(cx + iconHalf, cy + iconHalf - dp),
|
||||
wcIcon, 0, 0, lineThk);
|
||||
}
|
||||
if (hov && click) {
|
||||
if (maximized) SDL_RestoreWindow(window);
|
||||
else SDL_MaximizeWindow(window);
|
||||
}
|
||||
if (hov && click) g_pendingCaptionOp = maximized ? CaptionOp::Restore : CaptionOp::Maximize;
|
||||
}
|
||||
|
||||
// --- Close button ---
|
||||
@@ -550,7 +556,7 @@ static void drawWindowBackdrop(dragonx::App& app, ImDrawList* bgDL, ImVec2 p0, I
|
||||
}
|
||||
|
||||
// ── Window-dimension diagnostic tracer ──────────────────────────────────────────────────────
|
||||
// Always-on (writes to stderr → dragonx-debug.log on Windows) so it's visible in release builds,
|
||||
// Always-on (writes to stderr → dragonx-stdout.log on Windows) so it's visible in release builds,
|
||||
// unlike DRAGONX_DEBUG-gated DEBUG_LOGF. Grep the log for "[WINLOG]" to trace how the window size
|
||||
// changes when dragging between monitors of different DPI. Low volume — only fires on window
|
||||
// resize / move / DPI-scale events. TODO: remove once the multi-monitor sizing bug is resolved.
|
||||
@@ -778,6 +784,102 @@ static void installPosixCrashHandler(const std::string& crashLogPath)
|
||||
}
|
||||
#endif // !_WIN32
|
||||
|
||||
// ============================================================================
|
||||
// Frame pacing (P6)
|
||||
// ============================================================================
|
||||
// The main loop renders a frame only when something needs one:
|
||||
// - full rate: the user is interacting (and for kRecentInputMs after the last input event), smooth
|
||||
// scrolling, the screenshot sweep, and a few settle frames after a window-state change or a window
|
||||
// appearing;
|
||||
// - ambient: continuous motion that isn't tied to input (theme-effect animation, the translucent
|
||||
// backdrop, animated avatars, toast fades) at kAmbientFrameMs while the app has focus and
|
||||
// kAmbientFrameUnfocusedMs while it doesn't;
|
||||
// - progress: things that only need to keep visibly moving (spinners and progress bars for work on
|
||||
// other threads, countdowns, a focused text field's caret, the send/refresh/switch/shutdown
|
||||
// states) at kProgressFrameMs / kProgressFrameUnfocusedMs;
|
||||
// - idle: no frames until input, RPC results, or the periodic housekeeping wake.
|
||||
// Hidden or occluded windows get neither ambient nor progress frames.
|
||||
// frameBudgetNs() caps every rate at the display refresh where swap/present doesn't block on vsync
|
||||
// (WSLg, VMs, vsync forced off in the driver), which otherwise spins a core at hundreds of fps.
|
||||
namespace {
|
||||
constexpr Uint32 kAmbientFrameMs = 33; // ~30 fps while the app has focus
|
||||
constexpr Uint32 kAmbientFrameUnfocusedMs = 100; // ~10 fps while another app has focus
|
||||
constexpr Uint32 kProgressFrameMs = 100; // ~10 fps: spinners, progress, caret blink
|
||||
constexpr Uint32 kProgressFrameUnfocusedMs = 250; // ~4 fps
|
||||
constexpr Uint64 kRecentInputMs = 750; // stay at full rate this long after input stops
|
||||
constexpr int kSettleFrames = 3; // full-rate frames after a window-state change
|
||||
|
||||
// Minimum spacing between frame starts: 90% of the display's refresh interval, so a working vsync
|
||||
// never waits here and a non-blocking swap can't outrun the display.
|
||||
Uint64 frameBudgetNs(SDL_Window* window)
|
||||
{
|
||||
float hz = 60.0f;
|
||||
if (const SDL_DisplayMode* dm = SDL_GetCurrentDisplayMode(SDL_GetDisplayForWindow(window))) {
|
||||
if (dm->refresh_rate > 1.0f) hz = dm->refresh_rate;
|
||||
}
|
||||
hz = std::clamp(hz, 30.0f, 240.0f);
|
||||
return static_cast<Uint64>(0.9e9 / hz);
|
||||
}
|
||||
|
||||
bool isInputEvent(Uint32 type)
|
||||
{
|
||||
switch (type) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
case SDL_EVENT_KEY_UP:
|
||||
case SDL_EVENT_TEXT_INPUT:
|
||||
case SDL_EVENT_TEXT_EDITING:
|
||||
case SDL_EVENT_TEXT_EDITING_CANDIDATES:
|
||||
case SDL_EVENT_MOUSE_MOTION:
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
case SDL_EVENT_MOUSE_BUTTON_UP:
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
case SDL_EVENT_FINGER_DOWN:
|
||||
case SDL_EVENT_FINGER_UP:
|
||||
case SDL_EVENT_FINGER_MOTION:
|
||||
case SDL_EVENT_PEN_DOWN:
|
||||
case SDL_EVENT_PEN_UP:
|
||||
case SDL_EVENT_PEN_MOTION:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_UP:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Window-state changes after which the compositor or the acrylic capture may need a few fresh frames.
|
||||
bool isWindowStateEvent(Uint32 type)
|
||||
{
|
||||
switch (type) {
|
||||
case SDL_EVENT_WINDOW_SHOWN:
|
||||
case SDL_EVENT_WINDOW_EXPOSED:
|
||||
case SDL_EVENT_WINDOW_RESTORED:
|
||||
case SDL_EVENT_WINDOW_MAXIMIZED:
|
||||
case SDL_EVENT_WINDOW_RESIZED:
|
||||
case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:
|
||||
case SDL_EVENT_WINDOW_DISPLAY_CHANGED:
|
||||
case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED:
|
||||
case SDL_EVENT_WINDOW_FOCUS_GAINED:
|
||||
case SDL_EVENT_WINDOW_FOCUS_LOST:
|
||||
case SDL_EVENT_WINDOW_OCCLUDED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A held keyboard key keeps ImGui's own key repeat running even where the OS sends no repeat events.
|
||||
// Keyboard only: gamepad keys come from polled analog state, so a drifting stick or a trigger resting
|
||||
// past ImGui's 0.1 threshold would read as held forever (gamepad presses still count as input events).
|
||||
bool anyKeyDown()
|
||||
{
|
||||
for (int k = ImGuiKey_Keyboard_BEGIN; k < ImGuiKey_Keyboard_END; ++k) {
|
||||
if (ImGui::IsKeyDown(static_cast<ImGuiKey>(k))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||
@@ -820,8 +922,10 @@ int main(int argc, char* argv[])
|
||||
// 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);
|
||||
freopen(logPath.c_str(), "w", stdout); // per-run truncation
|
||||
freopen("NUL", "w", stderr); // GUI subsystem may have no valid fd 2 — give it one first
|
||||
_dup2(_fileno(stdout), _fileno(stderr)); // stderr now shares stdout's handle + file offset
|
||||
setvbuf(stderr, nullptr, _IONBF, 0); // keep stderr unbuffered (crash diagnostics)
|
||||
}
|
||||
// Install crash handler for diagnostics
|
||||
SetUnhandledExceptionFilter(CrashHandler);
|
||||
@@ -880,9 +984,16 @@ int main(int argc, char* argv[])
|
||||
// Load saved window size early (before window creation, before App exists).
|
||||
// Settings are loaded again later in app.init(), this is just for the size.
|
||||
int savedWinW = 1200, savedWinH = 720;
|
||||
float earlyFontScale = 1.0f; // consumed below, before the first (and only) font-atlas build
|
||||
bool earlyEmojiColor = false;
|
||||
bool earlyOpaque = false; // opaque start: skip DWM blur/acrylic backdrop (Windows)
|
||||
{
|
||||
dragonx::config::Settings earlySettings;
|
||||
if (earlySettings.load()) {
|
||||
const bool earlyLoaded = earlySettings.load();
|
||||
earlyFontScale = earlySettings.getFontScale(); // defaults when load failed, same as App::init sees
|
||||
earlyEmojiColor = earlySettings.getChatEmojiColor();
|
||||
earlyOpaque = earlySettings.getLowSpecMode() || earlySettings.getWindowOpacity() >= 0.99f;
|
||||
if (earlyLoaded) {
|
||||
int sw = earlySettings.getWindowWidth();
|
||||
int sh = earlySettings.getWindowHeight();
|
||||
if (sw >= 1024 && sh >= 720) {
|
||||
@@ -896,6 +1007,12 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// Everything the first Typography::load() reads must be final BEFORE InitImGui (DX11 and GL paths below):
|
||||
// ui.toml sizes, the saved font scale (non-flagging setter so the first preFrame does not reload), emoji style.
|
||||
dragonx::App::loadBaseUiSchema();
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(earlyFontScale);
|
||||
dragonx::ui::material::Typography::instance().setColorEmoji(earlyEmojiColor);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Platform-specific window creation and graphics context
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1048,14 +1165,17 @@ int main(int argc, char* argv[])
|
||||
if (hwnd_blur) {
|
||||
// Override SDL's zero-area blur region with a full-window blur.
|
||||
// This gives DWM the signal to blur everything behind our window.
|
||||
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
|
||||
bb.fEnable = TRUE;
|
||||
bb.hRgnBlur = blurRgn;
|
||||
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
|
||||
DeleteObject(blurRgn);
|
||||
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
|
||||
// Skipped when the window starts opaque (SDL's zero-area region stays).
|
||||
if (!earlyOpaque) {
|
||||
HRGN blurRgn = CreateRectRgn(0, 0, -1, -1); // full window
|
||||
DWM_BLURBEHIND bb = {};
|
||||
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
|
||||
bb.fEnable = TRUE;
|
||||
bb.hRgnBlur = blurRgn;
|
||||
HRESULT hr_blur = DwmEnableBlurBehindWindow(hwnd_blur, &bb);
|
||||
DeleteObject(blurRgn);
|
||||
DEBUG_LOGF("DwmEnableBlurBehindWindow (full region): 0x%08lx\n", hr_blur);
|
||||
}
|
||||
|
||||
// On Windows 11 22H2+, also enable Acrylic for nicer modern blur
|
||||
auto ver = dragonx::platform::getWindowsVersion();
|
||||
@@ -1073,7 +1193,7 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Acrylic = blurred, semi-transparent desktop view
|
||||
// (NOT Mica which is opaque)
|
||||
DWORD backdrop = 3; // DWMSBT_TRANSIENTWINDOW = Acrylic
|
||||
DWORD backdrop = earlyOpaque ? 1 /*DWMSBT_NONE*/ : 3 /*DWMSBT_TRANSIENTWINDOW = Acrylic*/;
|
||||
HRESULT hr_acr = DwmSetWindowAttribute(hwnd_blur, 38,
|
||||
&backdrop, sizeof(backdrop));
|
||||
DEBUG_LOGF("DWM Acrylic (DWMSBT_TRANSIENTWINDOW): 0x%08lx\n", hr_acr);
|
||||
@@ -1124,8 +1244,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Create window with graphics context
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
// Nothing in the app uses depth/stencil (ImGui + acrylic are color-only): request none.
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
|
||||
|
||||
SDL_WindowFlags window_flags = (SDL_WindowFlags)(SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY | SDL_WINDOW_TRANSPARENT);
|
||||
@@ -1159,6 +1280,11 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
SDL_GL_MakeCurrent(window, gl_context);
|
||||
{ int d = -1, s = -1;
|
||||
const bool okd = SDL_GL_GetAttribute(SDL_GL_DEPTH_SIZE, &d);
|
||||
const bool oks = SDL_GL_GetAttribute(SDL_GL_STENCIL_SIZE, &s);
|
||||
DEBUG_LOGF("GL default framebuffer: depth=%d%s stencil=%d%s\n",
|
||||
d, okd ? "" : " (no attachment)", s, oks ? "" : " (no attachment)"); }
|
||||
SDL_GL_SetSwapInterval(1); // Enable vsync
|
||||
|
||||
bool backdrop_active = false;
|
||||
@@ -1350,8 +1476,10 @@ int main(int argc, char* argv[])
|
||||
dragonx::ui::schema::UISchema::instance().reapplyColorsToImGui();
|
||||
}
|
||||
|
||||
// Initialize performance profiler — writes to <ObsidianDragonDir>/perf.log
|
||||
{
|
||||
// Performance profiler — writes to <ObsidianDragonDir>/perf.log. Opt-in (DRAGONX_PERFLOG=1): always
|
||||
// on, it appended a block every 300 frames and was never rotated (tens of MB a day for any wallet
|
||||
// left open).
|
||||
if (const char* perfEnv = std::getenv("DRAGONX_PERFLOG"); perfEnv && *perfEnv && std::strcmp(perfEnv, "0") != 0) {
|
||||
std::string perfPath = (std::filesystem::path(
|
||||
dragonx::util::Platform::getObsidianDragonDir()) / "perf.log").string();
|
||||
dragonx::util::PerfLog::instance().init(perfPath);
|
||||
@@ -1406,6 +1534,20 @@ int main(int argc, char* argv[])
|
||||
if (event->window.windowID != SDL_GetWindowID(ctx->window))
|
||||
return true;
|
||||
|
||||
// Never nest NewFrame() inside an already-open frame — Windows min/max/restore buttons
|
||||
// (renderBorderlessControls) push SDL window events synchronously from inside app.render(). (C146)
|
||||
ImGuiContext* gctx = ImGui::GetCurrentContext();
|
||||
if (!gctx || gctx->WithinFrameScope) return true;
|
||||
|
||||
// Dedupe the RESIZED + PIXEL_SIZE_CHANGED pair: both report the same final pixel size, so render
|
||||
// once. SDL_GetWindowSizeInPixels is a consistent key across both event types (their data1/data2
|
||||
// differ — points vs pixels — and cannot be compared directly).
|
||||
int pw = 0, ph = 0;
|
||||
SDL_GetWindowSizeInPixels(ctx->window, &pw, &ph);
|
||||
static int lastW = 0, lastH = 0;
|
||||
if (pw == lastW && ph == lastH) return true;
|
||||
lastW = pw; lastH = ph;
|
||||
|
||||
// Avoid re-entrancy
|
||||
static bool in_resize_render = false;
|
||||
if (in_resize_render) return true;
|
||||
@@ -1421,6 +1563,8 @@ int main(int argc, char* argv[])
|
||||
SDL_GL_MakeCurrent(ctx->window, ctx->gl_context);
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
#endif
|
||||
// Free/rebuild acrylic targets at the frame boundary (after MakeCurrent on GL: FBOs are per-context).
|
||||
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
|
||||
ImGui_ImplSDL3_NewFrame();
|
||||
ImGui::NewFrame();
|
||||
|
||||
@@ -1438,7 +1582,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
|
||||
ctx->app->update();
|
||||
ctx->app->render();
|
||||
@@ -1480,6 +1626,50 @@ int main(int argc, char* argv[])
|
||||
bool needsRedraw = true; // P6: Start with a full redraw
|
||||
auto lastPeriodicFrame = std::chrono::steady_clock::now();
|
||||
|
||||
// Frame pacing state (P6; see kAmbientFrameMs and friends above main()).
|
||||
Uint64 lastInputMs = 0; // SDL_GetTicks() of the last input-class event
|
||||
Uint64 nextAmbientFrameMs = 0; // when the next ambient frame is due; 0 = none scheduled
|
||||
Uint64 lastFrameStartNs = 0; // software frame cap bookkeeping (SDL_GetTicksNS)
|
||||
int settleFrames = kSettleFrames;
|
||||
auto noteEvent = [&](const SDL_Event& e) {
|
||||
if (isInputEvent(e.type)) {
|
||||
lastInputMs = SDL_GetTicks();
|
||||
app.noteUserInput(); // auto-lock idle clock (C21): real input only, never widget state
|
||||
}
|
||||
else if (isWindowStateEvent(e.type)) settleFrames = kSettleFrames;
|
||||
};
|
||||
|
||||
// Deferred font-scale commit: smooth visual update during Alt+scroll,
|
||||
// atlas rebuild after scrolling stops (~200ms idle).
|
||||
Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
|
||||
// Alt + scroll wheel -> font scale. Returns true if the event was consumed.
|
||||
auto handleAltWheel = [&](const SDL_Event& e) -> bool {
|
||||
if (e.type != SDL_EVENT_MOUSE_WHEEL) return false;
|
||||
if (!(SDL_GetModState() & SDL_KMOD_ALT)) return false;
|
||||
float step = 0.05f * e.wheel.y;
|
||||
float cur = dragonx::ui::Layout::userFontScale();
|
||||
float next = std::max(1.0f, std::min(1.5f, cur + step));
|
||||
if (next != cur) {
|
||||
// Smooth preview — no atlas rebuild yet
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(next);
|
||||
// Schedule atlas rebuild after scrolling stops
|
||||
fontScaleCommitTick = SDL_GetTicks() + 200;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Headless verification hook (DRAGONX_FULL_SWEEP=1, handled after present), hoisted so the pacing
|
||||
// decision can keep its warm-up frames coming.
|
||||
#if DRAGONX_ENABLE_UI_SWEEP
|
||||
const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
|
||||
#else
|
||||
// The env hook is dead in shipped builds. It must be: with startSweepImpl a no-op, an enabled
|
||||
// autoSweep would quit the app as soon as the warm-up frames ran out.
|
||||
const bool autoSweep = false;
|
||||
#endif
|
||||
int autoSweepFrames = 0;
|
||||
bool autoSweepStarted = false;
|
||||
|
||||
// Per-scale saved window sizes for DPI round-trips.
|
||||
// Each entry maps scale-percentage (100, 125, 150, 200, …) to the
|
||||
// physical pixel size the user had at that scale. When transitioning
|
||||
@@ -1506,6 +1696,7 @@ int main(int argc, char* argv[])
|
||||
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
|
||||
Uint64 backgroundSinceMs = 0; // when the window started to look backgrounded (0 = it doesn't)
|
||||
{
|
||||
float s = dragonx::ui::material::Typography::instance().getDpiScale();
|
||||
int w = 0, h = 0;
|
||||
@@ -1543,12 +1734,19 @@ int main(int argc, char* argv[])
|
||||
// above forces redraws while active, so this drains promptly even if the app is otherwise idle.
|
||||
if (dpiSettleFrames > 0) --dpiSettleFrames;
|
||||
|
||||
// P6: Idle rendering — sleep if nothing needs redrawing
|
||||
// P6: Idle rendering — sleep until input, the next ambient frame, or a 200 ms housekeeping tick
|
||||
if (!needsRedraw) {
|
||||
// Wait up to 200ms for user input or wake event
|
||||
int waitMs = 200;
|
||||
if (nextAmbientFrameMs != 0) {
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
waitMs = nextAmbientFrameMs > nowMs
|
||||
? static_cast<int>(std::min<Uint64>(nextAmbientFrameMs - nowMs, 200)) : 0;
|
||||
}
|
||||
SDL_Event waitEvent;
|
||||
if (SDL_WaitEventTimeout(&waitEvent, 200)) {
|
||||
ImGui_ImplSDL3_ProcessEvent(&waitEvent);
|
||||
if (waitMs > 0 && SDL_WaitEventTimeout(&waitEvent, waitMs)) {
|
||||
noteEvent(waitEvent);
|
||||
if (handleAltWheel(waitEvent)) needsRedraw = true;
|
||||
else ImGui_ImplSDL3_ProcessEvent(&waitEvent);
|
||||
if (waitEvent.type == SDL_EVENT_QUIT) {
|
||||
app.beginShutdown();
|
||||
}
|
||||
@@ -1586,11 +1784,12 @@ int main(int argc, char* argv[])
|
||||
lastKnownH = waitEvent.window.data2;
|
||||
}
|
||||
}
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
// Window restored. No forced refresh: update() keeps running while minimized, so the data
|
||||
// is current, and refreshNow() wiped the shielded-scan progress and re-scanned every
|
||||
// z-address (SDL also sends RESTORED on un-maximize, so a title-bar double-click did it).
|
||||
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)
|
||||
if (waitEvent.type == SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED &&
|
||||
@@ -1613,17 +1812,22 @@ int main(int argc, char* argv[])
|
||||
auto sinceLast = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - lastPeriodicFrame).count();
|
||||
|
||||
// Immediate triggers: async RPC results or visible notifications
|
||||
bool hasImmediateWork = app.hasPendingRPCResults()
|
||||
// Immediate triggers: background results, visible notifications, work in progress
|
||||
// (also what keeps a hidden or occluded window responsive: ~5 fps while it lasts)
|
||||
bool hasImmediateWork = app.hasPendingBackgroundResults()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| app.isShuttingDown()
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Ambient frame due (scheduled by the pacing decision at the end of the last frame)
|
||||
const bool ambientDue = nextAmbientFrameMs != 0 && SDL_GetTicks() >= nextAmbientFrameMs;
|
||||
|
||||
// Periodic maintenance: fire refresh timers in app.update()
|
||||
// Low-spec: every 5s; normal: every 2s
|
||||
int periodMs = dragonx::ui::effects::isLowSpecMode() ? 5000 : 2000;
|
||||
|
||||
if (hasImmediateWork || sinceLast >= periodMs) {
|
||||
if (ambientDue || hasImmediateWork || sinceLast >= periodMs) {
|
||||
needsRedraw = true;
|
||||
} else {
|
||||
continue; // Nothing to do — go back to sleep
|
||||
@@ -1631,30 +1835,23 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// Software frame cap: never start frames faster than the display refreshes. A no-op where
|
||||
// swap/present blocks on vsync; elsewhere it stops the loop spinning a core. The screenshot
|
||||
// sweep switches vsync off on purpose and is exempt. Before the event poll, so the frame still
|
||||
// renders with the freshest input.
|
||||
if (!app.isScreenshotSweeping() && lastFrameStartNs != 0) {
|
||||
const Uint64 budgetNs = frameBudgetNs(window);
|
||||
const Uint64 sinceStartNs = SDL_GetTicksNS() - lastFrameStartNs;
|
||||
if (sinceStartNs < budgetNs) SDL_DelayNS(budgetNs - sinceStartNs);
|
||||
}
|
||||
lastFrameStartNs = SDL_GetTicksNS();
|
||||
|
||||
// Poll remaining events
|
||||
SDL_Event event;
|
||||
// Deferred font-scale commit: smooth visual update during Alt+scroll,
|
||||
// atlas rebuild after scrolling stops (~200ms idle).
|
||||
static Uint64 fontScaleCommitTick = 0; // 0 = nothing pending
|
||||
|
||||
while (SDL_PollEvent(&event)) {
|
||||
// Alt + scroll wheel: smooth font scale adjustment
|
||||
if (event.type == SDL_EVENT_MOUSE_WHEEL) {
|
||||
SDL_Keymod mods = SDL_GetModState();
|
||||
if (mods & SDL_KMOD_ALT) {
|
||||
float step = 0.05f * event.wheel.y;
|
||||
float cur = dragonx::ui::Layout::userFontScale();
|
||||
float next = std::max(1.0f, std::min(1.5f, cur + step));
|
||||
if (next != cur) {
|
||||
// Smooth preview — no atlas rebuild yet
|
||||
dragonx::ui::Layout::setUserFontScaleVisual(next);
|
||||
// Schedule atlas rebuild after scrolling stops
|
||||
fontScaleCommitTick = SDL_GetTicks() + 200;
|
||||
}
|
||||
// Don't pass Alt+scroll to ImGui (would scroll windows)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
noteEvent(event);
|
||||
// Alt + scroll wheel: smooth font scale adjustment (don't pass to ImGui: would scroll windows)
|
||||
if (handleAltWheel(event)) continue;
|
||||
ImGui_ImplSDL3_ProcessEvent(&event);
|
||||
|
||||
if (event.type == SDL_EVENT_QUIT) {
|
||||
@@ -1716,11 +1913,10 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
}
|
||||
// Window restored from minimized — trigger immediate data refresh
|
||||
// Window restored: no forced refresh (see the wait branch above).
|
||||
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
|
||||
// different-DPI monitor, or user changes Windows scaling)
|
||||
@@ -1738,6 +1934,22 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// C134: the window is in the background when it is minimized, hidden, or covered while none of our
|
||||
// windows has focus, for a few seconds with no input meanwhile. Focus loss alone never counts. The
|
||||
// app then polls only the chain state; coming back is immediate.
|
||||
{
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
const SDL_WindowFlags wf = SDL_GetWindowFlags(window);
|
||||
const bool flagged = dragonx::services::windowLooksBackground(
|
||||
(wf & SDL_WINDOW_MINIMIZED) != 0, (wf & SDL_WINDOW_HIDDEN) != 0,
|
||||
(wf & SDL_WINDOW_OCCLUDED) != 0, SDL_GetKeyboardFocus() != nullptr);
|
||||
if (!flagged) backgroundSinceMs = 0;
|
||||
else if (backgroundSinceMs == 0) backgroundSinceMs = nowMs;
|
||||
app.setWindowBackground(dragonx::services::isBackgroundWindow(
|
||||
flagged, backgroundSinceMs != 0 ? (nowMs - backgroundSinceMs) / 1000.0 : 0.0,
|
||||
lastInputMs != 0 ? (nowMs - lastInputMs) / 1000.0 : 1.0e9));
|
||||
}
|
||||
|
||||
// Check if window is minimized
|
||||
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
|
||||
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
|
||||
@@ -1778,6 +1990,8 @@ int main(int argc, char* argv[])
|
||||
// Pre-frame: font atlas rebuilds and schema hot-reload must
|
||||
// happen BEFORE NewFrame() because NewFrame() caches font ptrs.
|
||||
app.preFrame();
|
||||
// Acrylic targets are freed/rebuilt here, between frames: nothing recorded can reference them.
|
||||
dragonx::ui::effects::ImGuiAcrylic::ReleaseIdleBuffers();
|
||||
|
||||
// Smooth font-scale: compensate visual font size via FontScaleMain
|
||||
// when the atlas hasn't been rebuilt yet (during slider drag).
|
||||
@@ -1863,10 +2077,14 @@ int main(int argc, char* argv[])
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!dragonx::ui::effects::isLowSpecMode()) {
|
||||
dragonx::ui::effects::ImGuiAcrylic::BeginFrame(
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
(int)io.DisplaySize.x, (int)io.DisplaySize.y,
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
}
|
||||
|
||||
// Update theme visual effects timing
|
||||
// Update theme visual effects timing (held still under Reduce Motion)
|
||||
dragonx::ui::effects::ThemeEffects::instance().setMotionFrozen(
|
||||
app.settings() && app.settings()->getReduceMotion());
|
||||
dragonx::ui::effects::ThemeEffects::instance().beginFrame();
|
||||
// Propagate window opacity to theme effects so they fade with the
|
||||
// backdrop while UI elements (cards, text, buttons) stay opaque.
|
||||
@@ -2025,7 +2243,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Debug screenshot sweep — grab the finished frame before swap.
|
||||
if (app.wantsScreenshotThisFrame()) {
|
||||
captureFrameToPng(app.screenshotSweepPath(), (int)io.DisplaySize.x, (int)io.DisplaySize.y);
|
||||
captureFrameToPng(app.screenshotSweepPath(),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.x, io.DisplayFramebufferScale.x),
|
||||
dragonx::ui::effects::framebufferExtent(io.DisplaySize.y, io.DisplayFramebufferScale.y));
|
||||
app.onScreenshotCaptured();
|
||||
}
|
||||
|
||||
@@ -2058,16 +2278,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Headless verification hook: DRAGONX_FULL_SWEEP=1 runs the full UI sweep once the app has
|
||||
// settled, then quits when it finishes. Used to run the sweep without clicking the button.
|
||||
{
|
||||
static const bool autoSweep = std::getenv("DRAGONX_FULL_SWEEP") != nullptr;
|
||||
static int autoFrames = 0;
|
||||
static bool autoStarted = false;
|
||||
if (autoSweep) {
|
||||
if (!autoStarted) {
|
||||
if (++autoFrames >= 90) { app.startFullUiSweep(); autoStarted = true; }
|
||||
} else if (!app.isScreenshotSweeping()) {
|
||||
running = false; // sweep complete
|
||||
}
|
||||
if (autoSweep) {
|
||||
if (!autoSweepStarted) {
|
||||
if (++autoSweepFrames >= 90) { app.startFullUiSweep(); autoSweepStarted = true; }
|
||||
} else if (!app.isScreenshotSweeping()) {
|
||||
running = false; // sweep complete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,41 +2305,99 @@ int main(int argc, char* argv[])
|
||||
// Track last render for idle periodic timer
|
||||
lastPeriodicFrame = std::chrono::steady_clock::now();
|
||||
|
||||
// P6: Determine if next frame needs redraw or can idle
|
||||
// P6: Decide when the next frame is needed: right away (full rate), at the ambient rate, or not
|
||||
// until the next event / housekeeping wake (idle). See the frame-pacing notes above main().
|
||||
{
|
||||
ImGuiContext& g = *GImGui;
|
||||
const Uint64 nowMs = SDL_GetTicks();
|
||||
|
||||
// Detect actual user interaction — NOT WantCaptureMouse/Keyboard
|
||||
// which are just "ImGui has windows that accept input" (always true).
|
||||
bool mouseMoving = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f;
|
||||
bool mouseClicked = false;
|
||||
// Hidden or occluded windows get no ambient frames (SDL reports occlusion on macOS and
|
||||
// Wayland; elsewhere a covered window usually also lacks focus). Focus is per application:
|
||||
// any of our windows, multi-viewport platform windows included, not just the main one.
|
||||
const SDL_WindowFlags wflags = SDL_GetWindowFlags(window);
|
||||
const bool visible =
|
||||
(wflags & (SDL_WINDOW_HIDDEN | SDL_WINDOW_OCCLUDED | SDL_WINDOW_MINIMIZED)) == 0;
|
||||
const bool focused = SDL_GetKeyboardFocus() != nullptr;
|
||||
|
||||
// Interaction — not WantCaptureMouse/Keyboard, which only mean "ImGui has windows that
|
||||
// accept input" (always true). A focused text field by itself (only the caret moves: the
|
||||
// lock screen's passphrase box, the console input) is ambient below; it used to hold the
|
||||
// loop at the display rate for as long as the field kept focus.
|
||||
const bool textEditing = g.ActiveId != 0 && g.ActiveId == g.InputTextState.ID;
|
||||
bool mouseDown = false;
|
||||
for (int i = 0; i < IM_ARRAYSIZE(g.IO.MouseDown); i++)
|
||||
mouseClicked |= g.IO.MouseDown[i];
|
||||
bool scrolling = g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f;
|
||||
bool typing = g.IO.InputQueueCharacters.Size > 0;
|
||||
bool widgetActive = g.ActiveId != 0 || g.MovingWindow != nullptr;
|
||||
mouseDown |= g.IO.MouseDown[i];
|
||||
const bool uiActive = g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f
|
||||
|| mouseDown
|
||||
|| g.IO.MouseWheel != 0.0f || g.IO.MouseWheelH != 0.0f
|
||||
|| (g.ActiveId != 0 && !textEditing) || g.MovingWindow != nullptr
|
||||
|| g.InputEventsQueue.Size > 0 // input still trickling in
|
||||
|| (focused && anyKeyDown()) // ImGui key repeat
|
||||
|| (lastInputMs != 0 && nowMs - lastInputMs < kRecentInputMs);
|
||||
|
||||
bool uiActive = mouseMoving || mouseClicked || scrolling
|
||||
|| typing || widgetActive;
|
||||
// A window that appeared this frame (a popup hidden for its auto-fit frame, an overlay
|
||||
// dialog's scrim while its card measures and captures its blur) needs a few more frames
|
||||
// to show, even when it opened from an RPC result or a timer rather than input.
|
||||
for (ImGuiWindow* w : g.Windows) {
|
||||
if (w->Active && w->Appearing) { settleFrames = kSettleFrames; break; }
|
||||
}
|
||||
|
||||
// When the background is fully opaque, backdrop doesn't
|
||||
// need continuous frames — let the app idle and save power.
|
||||
float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
|
||||
bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
|
||||
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||
&& !opaqueBackground;
|
||||
bool animating = app.isShuttingDown()
|
||||
|| app.isWalletSwitchInProgress()
|
||||
|| backdropNeedsFrames
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
|| dragonx::ui::Notifications::instance().hasActive()
|
||||
|| dragonx::ui::material::SmoothScrollAnimating()
|
||||
|| dragonx::ui::ConsumeContactsAvatarAnimation();
|
||||
// If nothing is happening, allow the next iteration to idle
|
||||
needsRedraw = uiActive || animating;
|
||||
const bool fullRate = uiActive
|
||||
|| settleFrames > 0
|
||||
|| app.isScreenshotSweeping()
|
||||
|| (autoSweep && !autoSweepStarted)
|
||||
|| dragonx::ui::material::SmoothScrollAnimating();
|
||||
if (settleFrames > 0) --settleFrames;
|
||||
|
||||
// Ambient: continuous motion without input. Theme effects stop requesting frames under
|
||||
// Reduce Motion (their clock is frozen above). The backdrop itself is static; the
|
||||
// translucent window stays on this tier as the conservative choice for compositors.
|
||||
const bool avatarAnimating = dragonx::ui::ConsumeContactsAvatarAnimation(); // consume every frame
|
||||
const bool progressRequested = dragonx::ui::material::ConsumeProgressFrameRequest(); // ditto
|
||||
const float wo2 = app.settings() ? app.settings()->getWindowOpacity() : 1.0f;
|
||||
const bool opaqueBackground = dragonx::ui::effects::isLowSpecMode() || wo2 >= 0.99f;
|
||||
const bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||
&& !opaqueBackground;
|
||||
const bool reduceMotion = app.settings() && app.settings()->getReduceMotion();
|
||||
// C128: suppress backdrop theme-effect frame requests while a blur overlay (a modal dialog or
|
||||
// the lock screen) covers them -- they aren't visible, and the overlay's own moving elements
|
||||
// request frames via RequestProgressFrame(), so this never freezes anything on screen.
|
||||
const bool effectsAnimating = !reduceMotion
|
||||
&& dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
&& !dragonx::ui::material::AnyBlurOverlayActiveLastFrame();
|
||||
const bool ambient = effectsAnimating
|
||||
|| backdropNeedsFrames
|
||||
|| avatarAnimating
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Progress: only needs to keep visibly moving (a spinner, a percentage, dots, a caret).
|
||||
const bool progress = progressRequested
|
||||
|| (textEditing && focused)
|
||||
|| app.isShuttingDown()
|
||||
|| app.isWalletSwitchInProgress()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress();
|
||||
|
||||
needsRedraw = fullRate;
|
||||
nextAmbientFrameMs = 0;
|
||||
if (!fullRate && visible && (ambient || progress)) {
|
||||
const Uint32 interval = ambient ? (focused ? kAmbientFrameMs : kAmbientFrameUnfocusedMs)
|
||||
: (focused ? kProgressFrameMs : kProgressFrameUnfocusedMs);
|
||||
nextAmbientFrameMs = lastFrameStartNs / 1000000 + interval;
|
||||
}
|
||||
}
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
if (g_pendingCaptionOp != CaptionOp::None) {
|
||||
switch (g_pendingCaptionOp) {
|
||||
case CaptionOp::Minimize: SDL_MinimizeWindow(window); break;
|
||||
case CaptionOp::Maximize: SDL_MaximizeWindow(window); break;
|
||||
case CaptionOp::Restore: SDL_RestoreWindow(window); break;
|
||||
default: break;
|
||||
}
|
||||
g_pendingCaptionOp = CaptionOp::None;
|
||||
needsRedraw = true; // after the pacing block, so it is not overwritten
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// Remove resize event watcher
|
||||
@@ -2162,8 +2435,14 @@ int main(int argc, char* argv[])
|
||||
_Exit(0);
|
||||
}).detach();
|
||||
|
||||
// Land the queued settings/wallets.json writes (incl. the window size saved above) before the joins
|
||||
// in shutdown(); bounded (3 s) so a wedged disk can't eat the watchdog's budget.
|
||||
app.flushPendingWrites();
|
||||
// Final cleanup (daemon already stopped by beginShutdown)
|
||||
app.shutdown();
|
||||
// shutdown()'s fallback branch saves settings again; land that too, BEFORE the single-instance lock
|
||||
// is released below so a relaunch can't read a stale settings.json. _Exit() skips ~App().
|
||||
app.flushPendingWrites();
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
Shutdown(window, dx);
|
||||
#else
|
||||
@@ -2190,7 +2469,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
static bool InitSDL()
|
||||
{
|
||||
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD)) {
|
||||
// SDL3 otherwise holds timeBeginPeriod(1) for the process lifetime on Windows. Nothing here needs
|
||||
// sub-15 ms waits (vsync pacing, the 200 ms idle wait, >=50 ms thread sleeps), so let the system
|
||||
// timer relax. Ignored on non-Windows platforms; a false return (env override) is harmless.
|
||||
SDL_SetHint(SDL_HINT_TIMER_RESOLUTION, "0");
|
||||
if (!SDL_Init(SDL_INIT_VIDEO)) {
|
||||
fprintf(stderr, "Error: SDL_Init(): %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
@@ -2215,8 +2498,6 @@ static bool InitImGui(SDL_Window* window, dragonx::platform::DX11Context& dx)
|
||||
|
||||
// Enable keyboard navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
// Enable gamepad navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
// Enable multi-viewport (windows can float outside main window)
|
||||
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
|
||||
|
||||
@@ -2277,8 +2558,6 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context)
|
||||
|
||||
// Enable keyboard navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
// Enable gamepad navigation
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
// Enable multi-viewport (windows can float outside main window)
|
||||
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
|
||||
|
||||
|
||||
@@ -252,16 +252,6 @@ void DX11Context::resize(int width, int height)
|
||||
(void)height;
|
||||
if (!swapChain_ || !deviceContext_) return;
|
||||
|
||||
cleanupRenderTarget();
|
||||
|
||||
// Unbind the render target from the pipeline — ResizeBuffers requires
|
||||
// ALL outstanding references to back-buffer resources to be released.
|
||||
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
|
||||
// growing the window (shrinking appears fine because the old larger
|
||||
// buffer simply gets cropped by DWM).
|
||||
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
|
||||
deviceContext_->Flush();
|
||||
|
||||
// For composition swap chains (CreateSwapChainForComposition),
|
||||
// passing 0,0 means "keep current size" — NOT auto-detect from HWND.
|
||||
// We must pass the actual pixel dimensions from GetClientRect.
|
||||
@@ -272,7 +262,22 @@ void DX11Context::resize(int width, int height)
|
||||
w = static_cast<UINT>(rc.right - rc.left);
|
||||
h = static_cast<UINT>(rc.bottom - rc.top);
|
||||
}
|
||||
if (w == 0 || h == 0) return;
|
||||
if (w == 0 || h == 0) return; // minimized / zero-area: keep the current RTV, do nothing
|
||||
|
||||
// Already this size with a live RTV: nothing to do (skips the destroy/recreate churn).
|
||||
DXGI_SWAP_CHAIN_DESC1 d;
|
||||
if (SUCCEEDED(swapChain_->GetDesc1(&d)) && d.Width == w && d.Height == h && renderTargetView_)
|
||||
return;
|
||||
|
||||
cleanupRenderTarget();
|
||||
|
||||
// Unbind the render target from the pipeline — ResizeBuffers requires
|
||||
// ALL outstanding references to back-buffer resources to be released.
|
||||
// Without this, ResizeBuffers fails with DXGI_ERROR_INVALID_CALL when
|
||||
// growing the window (shrinking appears fine because the old larger
|
||||
// buffer simply gets cropped by DWM).
|
||||
deviceContext_->OMSetRenderTargets(0, nullptr, nullptr);
|
||||
deviceContext_->Flush();
|
||||
|
||||
HRESULT hr = swapChain_->ResizeBuffers(0, w, h, DXGI_FORMAT_UNKNOWN, 0);
|
||||
if (FAILED(hr)) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#include "embedded_resources.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/logger.h"
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
|
||||
@@ -12,6 +17,7 @@
|
||||
#include <windows.h>
|
||||
#include <shlobj.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <pwd.h>
|
||||
#include <sys/types.h>
|
||||
@@ -83,8 +89,8 @@ bool hasEmbeddedResources()
|
||||
const EmbeddedResource* getEmbeddedResource(const std::string& name)
|
||||
{
|
||||
// Search static resource table (params, daemon binaries, etc.)
|
||||
for (const auto* res = s_resources; res->data != nullptr; ++res) {
|
||||
if (name == res->filename) {
|
||||
for (const auto* res = s_resources; res->filename != nullptr; ++res) {
|
||||
if (res->data && res->size && name == res->filename) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -116,6 +122,27 @@ const EmbeddedTheme* getEmbeddedThemes()
|
||||
return themes.data();
|
||||
}
|
||||
|
||||
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size)
|
||||
{
|
||||
{
|
||||
std::ifstream cur(path, std::ios::binary | std::ios::ate);
|
||||
if (cur.is_open()) {
|
||||
const std::streamoff len = cur.tellg();
|
||||
if (len >= 0 && static_cast<size_t>(len) == size) {
|
||||
std::string buf(size, '\0');
|
||||
cur.seekg(0);
|
||||
if (size == 0 || (cur.read(&buf[0], static_cast<std::streamsize>(size)) &&
|
||||
std::memcmp(buf.data(), data, size) == 0))
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} // reader closed before the writer opens (Windows sharing)
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (!f.is_open()) return -1;
|
||||
f.write(reinterpret_cast<const char*>(data), static_cast<std::streamsize>(size));
|
||||
return f.good() ? 1 : -1;
|
||||
}
|
||||
|
||||
int extractBundledThemes(const std::string& destDir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
@@ -134,14 +161,12 @@ int extractBundledThemes(const std::string& destDir)
|
||||
|
||||
for (const auto* t = themes; t->data != nullptr; ++t) {
|
||||
fs::path dest = fs::path(destDir) / t->filename;
|
||||
// Always overwrite — bundled themes should match the binary version
|
||||
std::ofstream f(dest, std::ios::binary);
|
||||
if (f.is_open()) {
|
||||
f.write(reinterpret_cast<const char*>(t->data), t->size);
|
||||
f.close();
|
||||
DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n",
|
||||
t->filename, t->size);
|
||||
count++;
|
||||
// Bundled themes must match the binary version: rewrite only when the bytes differ (not size-only:
|
||||
// a same-length palette edit must still update).
|
||||
const int w = writeFileIfDifferent(dest.string(), t->data, t->size);
|
||||
if (w >= 0) {
|
||||
if (w == 1) DEBUG_LOGF("[INFO] EmbeddedResources: Extracted theme: %s (%zu bytes)\n", t->filename, t->size);
|
||||
count++; // present and current (caller only checks > 0)
|
||||
} else {
|
||||
DEBUG_LOGF("[ERROR] EmbeddedResources: Failed to write theme: %s\n", dest.string().c_str());
|
||||
}
|
||||
@@ -205,272 +230,461 @@ std::string getParamsDirectory()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Forward declaration — defined below extractResource()
|
||||
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath);
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Writing into the daemon directory: one lock, atomic (temp + flush + rename) files.
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
bool needsParamsExtraction()
|
||||
// Every writer of the daemon directory (startup job, getDaemonPath(), getXmrigPath(), forceExtractXmrig(),
|
||||
// reextractBundledDaemon(), ensureWalletRebuildHelperExtracted(), copyBundledFiles()) holds this for its whole
|
||||
// write; whoever comes second finds the files complete and does nothing. Never taken re-entrantly. Lock order:
|
||||
// provisionMutex -> Logger mutex (DEBUG_LOGF) only. The in-app updaters do not take it (see residual risks).
|
||||
std::mutex& provisionMutex() { static std::mutex m; return m; }
|
||||
|
||||
struct ProvisionEntry {
|
||||
const char* name;
|
||||
bool refreshOnSizeMismatch; // re-extract when the bundled size differs (params, asmap)
|
||||
bool executable;
|
||||
bool fullNodeOnly; // not written by Lite builds (the lite backend bakes in its own params)
|
||||
bool startupTrigger; // makes needsParamsExtraction() true
|
||||
};
|
||||
|
||||
// Params and asmap follow the bundled size; the node binaries, the rebuild helper and the miner are placed only
|
||||
// when MISSING (user-updatable via the updaters: the user may run a specific dragonxd or an "Update miner..."
|
||||
// build; replacing the node is the explicit Settings action). asmap/cli/tx/rebuild are written along, never a trigger.
|
||||
const ProvisionEntry kProvisionEntries[] = {
|
||||
{ RESOURCE_SAPLING_SPEND, true, false, true, true },
|
||||
{ RESOURCE_SAPLING_OUTPUT, true, false, true, true },
|
||||
{ RESOURCE_ASMAP, true, false, true, false },
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
{ RESOURCE_DRAGONXD, false, true, true, true },
|
||||
{ RESOURCE_DRAGONX_CLI, false, true, true, false },
|
||||
{ RESOURCE_DRAGONX_TX, false, true, true, false },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
{ RESOURCE_XMRIG, kXmrigRefreshOnSizeMismatch, true, false, true },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
{ RESOURCE_DRAGONX_WALLET_REBUILD, false, true, true, false },
|
||||
#endif
|
||||
};
|
||||
|
||||
bool entryActive(const ProvisionEntry& e)
|
||||
{
|
||||
if (!hasEmbeddedResources()) {
|
||||
#if defined(DRAGONX_LITE_BUILD) && DRAGONX_LITE_BUILD
|
||||
return !e.fullNodeOnly;
|
||||
#else
|
||||
(void)e;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string joinDir(const std::string& dir, const char* name)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return dir + "\\" + name;
|
||||
#else
|
||||
return dir + "/" + name;
|
||||
#endif
|
||||
}
|
||||
|
||||
constexpr std::size_t kCopyChunk = 4u * 1024u * 1024u;
|
||||
|
||||
bool prepareParentDir(const std::string& destPath)
|
||||
{
|
||||
const std::filesystem::path dest(destPath);
|
||||
if (!dest.has_parent_path()) return true;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dest.parent_path(), ec);
|
||||
if (ec) {
|
||||
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", dest.parent_path().string().c_str(),
|
||||
ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check daemon directory (dragonx/) — the only extraction target
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Temp-file sink for writeFileAtomic / copyFileAtomic. Windows: ONE CreateFileA handle with no SECURITY_ATTRIBUTES,
|
||||
// i.e. NOT inheritable: std::ofstream's CRT handle is, and a child created with bInheritHandles=TRUE meanwhile
|
||||
// (xmrig, runHiddenCapture) would hold "<name>.partial" without FILE_SHARE_DELETE, so the MoveFileEx in
|
||||
// replaceFromTemp fails while it runs (same fix as Platform::writeFileAtomically). commit() flushes on the same
|
||||
// handle (FlushFileBuffers / fsync) before close, so the bytes are on disk before the rename. POSIX: O_CLOEXEC.
|
||||
class TempSink {
|
||||
public:
|
||||
explicit TempSink(const std::string& path)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
h_ = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
#else
|
||||
const char pathSep = '/';
|
||||
#endif
|
||||
std::string spendPath = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
|
||||
std::string outputPath = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
|
||||
|
||||
// Check if params are missing or stale (size mismatch → updated in newer build)
|
||||
const auto* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
|
||||
const auto* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
|
||||
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
|
||||
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
|
||||
|
||||
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
|
||||
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
|
||||
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
|
||||
fd_ = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
#endif
|
||||
}
|
||||
~TempSink() { (void)close(); }
|
||||
TempSink(const TempSink&) = delete;
|
||||
TempSink& operator=(const TempSink&) = delete;
|
||||
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
const auto* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
std::string xmrigPath = daemonDir + pathSep + RESOURCE_XMRIG;
|
||||
if (xmrigRes && resourceNeedsUpdate(xmrigRes, xmrigPath)) return true;
|
||||
bool ok() const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return h_ != INVALID_HANDLE_VALUE;
|
||||
#else
|
||||
return fd_ >= 0;
|
||||
#endif
|
||||
}
|
||||
bool write(const void* data, std::size_t n)
|
||||
{
|
||||
const char* p = static_cast<const char*>(data);
|
||||
while (n > 0) {
|
||||
#ifdef _WIN32
|
||||
const DWORD chunk = static_cast<DWORD>(n > (1u << 30) ? (1u << 30) : n);
|
||||
DWORD w = 0;
|
||||
if (!WriteFile(h_, p, chunk, &w, nullptr) || w == 0) return false;
|
||||
#else
|
||||
const ssize_t w = ::write(fd_, p, n);
|
||||
if (w < 0) { if (errno == EINTR) continue; return false; }
|
||||
if (w == 0) return false;
|
||||
#endif
|
||||
p += w;
|
||||
n -= static_cast<std::size_t>(w);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Flush to stable storage, then close. False on any error.
|
||||
bool commit()
|
||||
{
|
||||
bool good = ok();
|
||||
#ifdef _WIN32
|
||||
if (good && !FlushFileBuffers(h_)) good = false;
|
||||
#else
|
||||
if (good && ::fsync(fd_) != 0) good = false;
|
||||
#endif
|
||||
if (!close()) good = false;
|
||||
return good;
|
||||
}
|
||||
|
||||
private:
|
||||
bool close()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
if (h_ == INVALID_HANDLE_VALUE) return true;
|
||||
const bool r = CloseHandle(h_) != 0;
|
||||
h_ = INVALID_HANDLE_VALUE;
|
||||
return r;
|
||||
#else
|
||||
if (fd_ < 0) return true;
|
||||
const bool r = ::close(fd_) == 0;
|
||||
fd_ = -1;
|
||||
return r;
|
||||
#endif
|
||||
}
|
||||
#ifdef _WIN32
|
||||
HANDLE h_ = INVALID_HANDLE_VALUE;
|
||||
#else
|
||||
int fd_ = -1;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Move `tmp` over `dest`. POSIX: rename() replaces atomically (even a running binary) + best-effort dir fsync.
|
||||
// Windows (same primitive as Platform::writeFileAtomically): MoveFileEx REPLACE_EXISTING|WRITE_THROUGH. A
|
||||
// running .exe cannot be replaced but can be renamed, so it is moved aside to ".old" once (the running process
|
||||
// keeps it; cleaned on a later run). A just-closed file is often held for a moment by antivirus (sharing
|
||||
// violation), so retry for up to ~2 s, but stop at once if the temp vanished (quarantined). On failure the
|
||||
// temp is removed and a moved-aside original is put back.
|
||||
bool replaceFromTemp(const std::string& tmp, const std::string& dest)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
const std::string aside = dest + ".old";
|
||||
bool movedAside = false;
|
||||
DWORD err = 0;
|
||||
for (int attempt = 0; attempt < 20; ++attempt) {
|
||||
if (MoveFileExA(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
return true;
|
||||
err = GetLastError();
|
||||
if (GetFileAttributesA(tmp.c_str()) == INVALID_FILE_ATTRIBUTES) break; // temp gone (AV quarantine)
|
||||
if (!movedAside && GetFileAttributesA(dest.c_str()) != INVALID_FILE_ATTRIBUTES) {
|
||||
DeleteFileA(aside.c_str());
|
||||
if (MoveFileExA(dest.c_str(), aside.c_str(), MOVEFILE_REPLACE_EXISTING)) {
|
||||
movedAside = true;
|
||||
DEBUG_LOGF("[INFO] %s is in use - moved the old copy to .old\n", dest.c_str());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Sleep(100);
|
||||
}
|
||||
DEBUG_LOGF("[ERROR] Could not move %s into place (Win32 error %lu)\n", dest.c_str(), (unsigned long)err);
|
||||
DeleteFileA(tmp.c_str());
|
||||
if (movedAside) MoveFileExA(aside.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING);
|
||||
return false;
|
||||
#else
|
||||
if (::rename(tmp.c_str(), dest.c_str()) != 0) {
|
||||
DEBUG_LOGF("[ERROR] Could not move %s into place: %s\n", dest.c_str(), std::strerror(errno));
|
||||
::remove(tmp.c_str());
|
||||
return false;
|
||||
}
|
||||
const std::string dir = std::filesystem::path(dest).parent_path().string();
|
||||
const int dfd = ::open(dir.empty() ? "." : dir.c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (dfd >= 0) { ::fsync(dfd); ::close(dfd); }
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath)
|
||||
{
|
||||
if (embeddedSize == 0) return false;
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(destPath, ec)) return true;
|
||||
if (!refreshOnSizeMismatch) return false;
|
||||
const auto diskSize = std::filesystem::file_size(destPath, ec);
|
||||
if (ec) return true; // can't stat -> rewrite
|
||||
return static_cast<std::uint64_t>(diskSize) != embeddedSize;
|
||||
}
|
||||
|
||||
// Check if an on-disk file is missing or differs in size from the embedded resource.
|
||||
// A size mismatch means the binary was updated in a newer wallet build.
|
||||
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
|
||||
[[maybe_unused]] static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) return false;
|
||||
if (!std::filesystem::exists(destPath)) return true;
|
||||
std::error_code ec;
|
||||
auto diskSize = std::filesystem::file_size(destPath, ec);
|
||||
if (ec) return true; // can't stat → re-extract
|
||||
return diskSize != static_cast<std::uintmax_t>(res->size);
|
||||
return resourceNeedsWrite(res->size, /*refreshOnSizeMismatch=*/true, destPath);
|
||||
}
|
||||
|
||||
static bool extractResource(const EmbeddedResource* res, const std::string& destPath)
|
||||
bool needsParamsExtraction()
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) {
|
||||
return false;
|
||||
if (!hasEmbeddedResources()) return false;
|
||||
const std::string dir = getDaemonDirectory(); // the only extraction target
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
if (!entryActive(e) || !e.startupTrigger) continue;
|
||||
const EmbeddedResource* res = getEmbeddedResource(e.name);
|
||||
if (res && resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, joinDir(dir, e.name))) return true;
|
||||
}
|
||||
|
||||
// Create parent directories
|
||||
std::filesystem::path path(destPath);
|
||||
if (path.has_parent_path()) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
if (ec) {
|
||||
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n",
|
||||
path.parent_path().string().c_str(), ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Write file
|
||||
std::ofstream file(destPath, std::ios::binary);
|
||||
if (!file) {
|
||||
// The destination may be locked because the previous daemon is still using the binary:
|
||||
// Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms
|
||||
// DO allow renaming/moving such a file — the running process keeps the moved copy — so move
|
||||
// the stale binary aside and write a fresh one at the original path.
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(destPath)) {
|
||||
std::string sidelined = destPath + ".old";
|
||||
std::filesystem::remove(sidelined, ec); // clear any leftover from a prior swap
|
||||
ec.clear();
|
||||
std::filesystem::rename(destPath, sidelined, ec);
|
||||
if (!ec) {
|
||||
file.clear();
|
||||
file.open(destPath, std::ios::binary);
|
||||
if (file)
|
||||
DEBUG_LOGF("[INFO] Replaced in-use %s (old copy moved to .old)\n", destPath.c_str());
|
||||
} else {
|
||||
DEBUG_LOGF("[WARN] Could not move stale %s aside: %s\n",
|
||||
destPath.c_str(), ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
if (!data || size == 0) return false;
|
||||
if (!prepareParentDir(destPath)) return false;
|
||||
const std::string tmp = destPath + kPartialFileSuffix;
|
||||
std::error_code ec;
|
||||
bool good = true;
|
||||
{
|
||||
TempSink f(tmp);
|
||||
if (!f.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
|
||||
for (std::size_t off = 0; off < size; off += kCopyChunk) {
|
||||
if (cancelled && cancelled()) { good = false; break; }
|
||||
const std::size_t n = std::min(kCopyChunk, size - off);
|
||||
if (!f.write(data + off, n)) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", n, tmp.c_str());
|
||||
good = false; break;
|
||||
}
|
||||
if (progress) progress->bytesDone.fetch_add(n, std::memory_order_relaxed);
|
||||
}
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
file.write(reinterpret_cast<const char*>(res->data), res->size);
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", res->size, destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), res->size);
|
||||
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
|
||||
if (good && !f.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
|
||||
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
|
||||
if (!good) { std::filesystem::remove(tmp, ec); return false; }
|
||||
#ifndef _WIN32
|
||||
if (executable) ::chmod(tmp.c_str(), 0755); // before it becomes visible under the final name
|
||||
#else
|
||||
(void)executable;
|
||||
#endif
|
||||
if (!replaceFromTemp(tmp, destPath)) return false;
|
||||
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), size);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool extractEmbeddedResources()
|
||||
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
if (!prepareParentDir(destPath)) return false;
|
||||
const std::string tmp = destPath + kPartialFileSuffix;
|
||||
std::error_code ec;
|
||||
bool good = true;
|
||||
{
|
||||
std::ifstream in(srcPath, std::ios::binary); // a read handle on the source is harmless if inherited
|
||||
if (!in) { DEBUG_LOGF("[ERROR] Failed to open %s\n", srcPath.c_str()); return false; }
|
||||
TempSink out(tmp);
|
||||
if (!out.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
|
||||
std::vector<char> buf(kCopyChunk);
|
||||
for (;;) {
|
||||
if (cancelled && cancelled()) { good = false; break; }
|
||||
in.read(buf.data(), static_cast<std::streamsize>(buf.size()));
|
||||
const std::streamsize got = in.gcount();
|
||||
if (got > 0) {
|
||||
if (!out.write(buf.data(), static_cast<std::size_t>(got))) {
|
||||
DEBUG_LOGF("[ERROR] Failed to write %s\n", tmp.c_str());
|
||||
good = false; break;
|
||||
}
|
||||
if (progress) progress->bytesDone.fetch_add(static_cast<std::uint64_t>(got), std::memory_order_relaxed);
|
||||
}
|
||||
if (!in) break; // EOF (eof|fail) or a read error (bad, checked below)
|
||||
}
|
||||
if (good && in.bad()) {
|
||||
DEBUG_LOGF("[ERROR] Failed to read %s\n", srcPath.c_str());
|
||||
good = false;
|
||||
}
|
||||
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
|
||||
if (good && !out.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
|
||||
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
|
||||
if (!good) { fs::remove(tmp, ec); return false; }
|
||||
const auto st = fs::status(srcPath, ec);
|
||||
if (!ec) fs::permissions(tmp, st.permissions(), ec); // keeps dragonxd/cli/tx executable (as copy_file did)
|
||||
return replaceFromTemp(tmp, destPath);
|
||||
}
|
||||
|
||||
// Requires provisionMutex() held.
|
||||
static bool extractResourceLocked(const EmbeddedResource* res, const std::string& destPath, bool executable,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {})
|
||||
{
|
||||
if (!res || !res->data || res->size == 0) return false;
|
||||
return writeFileAtomic(destPath, res->data, res->size, executable, progress, cancelled);
|
||||
}
|
||||
|
||||
bool extractEmbeddedResources(ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
if (!hasEmbeddedResources()) {
|
||||
DEBUG_LOGF("[ERROR] No embedded resources available\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
#else
|
||||
const char pathSep = '/';
|
||||
#endif
|
||||
|
||||
// All files go to <ObsidianDragonDir>/dragonx/
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
|
||||
// Extract Sapling params to daemon directory alongside dragonxd
|
||||
const EmbeddedResource* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
|
||||
if (spendRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
|
||||
if (resourceNeedsUpdate(spendRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting sapling-spend.params (%zu MB)...\n", spendRes->size / (1024*1024));
|
||||
if (!extractResource(spendRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EmbeddedResource* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
|
||||
if (outputRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
|
||||
if (resourceNeedsUpdate(outputRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting sapling-output.params (%zu MB)...\n", outputRes->size / (1024*1024));
|
||||
if (!extractResource(outputRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract asmap.dat to daemon directory
|
||||
const EmbeddedResource* asmapRes = getEmbeddedResource(RESOURCE_ASMAP);
|
||||
if (asmapRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_ASMAP;
|
||||
if (resourceNeedsUpdate(asmapRes, dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting asmap.dat...\n");
|
||||
if (!extractResource(asmapRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract daemon binaries — NOT the data directory.
|
||||
// Running dragonxd.exe from inside the data directory (where it writes blockchain
|
||||
// data, debug.log, lock files, etc.) causes crashes on some Windows machines.
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
// All files go to <ObsidianDragonDir>/dragonx/ — NOT the data directory (running dragonxd.exe from inside
|
||||
// the data directory, where it writes chain data / debug.log / lock files, crashes on some Windows machines).
|
||||
const std::string daemonDir = getDaemonDirectory();
|
||||
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
|
||||
|
||||
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch
|
||||
// (the user may run a specific dragonxd; replacing it is an explicit Settings action).
|
||||
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
if (daemonRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024));
|
||||
if (!extractResource(daemonRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(daemonDir, ec);
|
||||
ec.clear();
|
||||
|
||||
// Temp files left by an interrupted run (killed process, power cut). Only OUR suffix.
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
std::filesystem::remove(joinDir(daemonDir, e.name) + kPartialFileSuffix, ec);
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
struct Todo { const ProvisionEntry* entry; const EmbeddedResource* res; std::string dest; };
|
||||
std::vector<Todo> todo;
|
||||
std::uint64_t total = 0;
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
if (!entryActive(e)) continue;
|
||||
const EmbeddedResource* res = getEmbeddedResource(e.name);
|
||||
if (!res) continue;
|
||||
std::string dest = joinDir(daemonDir, e.name);
|
||||
if (!resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, dest)) continue;
|
||||
total += res->size;
|
||||
todo.push_back({ &e, res, std::move(dest) });
|
||||
}
|
||||
if (progress) {
|
||||
progress->bytesDone.store(0, std::memory_order_relaxed);
|
||||
progress->bytesTotal.store(total, std::memory_order_relaxed);
|
||||
progress->setPhase(ProvisionPhase::Extracting);
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
for (const auto& t : todo) {
|
||||
if (cancelled && cancelled()) return false;
|
||||
DEBUG_LOGF("[INFO] Extracting %s (%zu MB)...\n", t.entry->name, t.res->size / (1024 * 1024));
|
||||
if (!extractResourceLocked(t.res, t.dest, t.entry->executable, progress, cancelled)) {
|
||||
if (cancelled && cancelled()) return false;
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI);
|
||||
if (cliRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024));
|
||||
if (!extractResource(cliRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
// Best-effort cleanup of ".old" copies left by an in-use replacement. Once the old process has exited the
|
||||
// file is no longer locked and removes cleanly; if it still runs, the remove fails and we retry next run.
|
||||
for (const auto& e : kProvisionEntries) {
|
||||
std::filesystem::remove(joinDir(daemonDir, e.name) + std::string(".old"), ec);
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
|
||||
if (txRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
|
||||
if (!extractResource(txRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
const EmbeddedResource* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
if (xmrigRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_XMRIG;
|
||||
if (resourceNeedsUpdate(xmrigRes, dest)) {
|
||||
if (std::filesystem::exists(dest))
|
||||
DEBUG_LOGF("[INFO] Updating stale xmrig (size mismatch)...\n");
|
||||
DEBUG_LOGF("[INFO] Extracting xmrig (%zu MB)...\n", xmrigRes->size / (1024*1024));
|
||||
if (!extractResource(xmrigRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#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.
|
||||
{
|
||||
std::error_code ec;
|
||||
for (const char* name : { RESOURCE_DRAGONXD, RESOURCE_XMRIG }) {
|
||||
std::filesystem::remove(daemonDir + pathSep + name + std::string(".old"), ec);
|
||||
ec.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
std::vector<std::string> bundleSearchDirs()
|
||||
{
|
||||
std::vector<std::string> dirs;
|
||||
const std::string exeDir = util::Platform::getExecutableDirectory();
|
||||
if (exeDir.empty()) return dirs;
|
||||
#ifdef __APPLE__
|
||||
// Inside a .app the params live in Contents/Resources while the executable is in Contents/MacOS.
|
||||
std::error_code ec;
|
||||
const std::filesystem::path res = std::filesystem::path(exeDir).parent_path() / "Resources";
|
||||
if (std::filesystem::is_directory(res, ec)) dirs.push_back(res.string());
|
||||
#endif
|
||||
dirs.push_back(exeDir);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& saplingParamFileNames()
|
||||
{
|
||||
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params" };
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::vector<std::string>& nodeBundleFileNames()
|
||||
{
|
||||
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params", "asmap.dat",
|
||||
"dragonxd", "dragonx-cli", "dragonx-tx" };
|
||||
return v;
|
||||
}
|
||||
|
||||
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
std::vector<BundleCopyItem> plan;
|
||||
std::error_code ec;
|
||||
for (const auto& name : names) {
|
||||
const fs::path dst = fs::path(destDir) / name;
|
||||
if (!replaceExisting && fs::exists(dst, ec)) continue;
|
||||
for (const auto& dir : searchDirs) {
|
||||
const fs::path src = fs::path(dir) / name;
|
||||
if (fs::exists(src, ec)) { plan.push_back({ name, src.string(), dst.string() }); break; }
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting,
|
||||
ProvisionProgress* progress, const CancelFn& cancelled)
|
||||
{
|
||||
BundleCopyResult r;
|
||||
// Stat-only check first: the steady-state call (every daemon start) never touches the lock.
|
||||
if (planBundleCopy(searchDirs, destDir, names, replaceExisting).empty()) return r;
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
const auto plan = planBundleCopy(searchDirs, destDir, names, replaceExisting); // re-check under the lock
|
||||
std::uint64_t total = 0;
|
||||
std::error_code ec;
|
||||
for (const auto& it : plan) {
|
||||
std::filesystem::remove(it.dst + kPartialFileSuffix, ec); // stale temp from an interrupted copy
|
||||
ec.clear();
|
||||
const auto sz = std::filesystem::file_size(it.src, ec);
|
||||
if (!ec) total += sz;
|
||||
ec.clear();
|
||||
}
|
||||
if (progress) {
|
||||
progress->bytesDone.store(0, std::memory_order_relaxed);
|
||||
progress->bytesTotal.store(total, std::memory_order_relaxed);
|
||||
progress->setPhase(ProvisionPhase::Copying);
|
||||
}
|
||||
for (const auto& it : plan) {
|
||||
if (cancelled && cancelled()) { r.ok = false; return r; }
|
||||
DEBUG_LOGF("Copying bundled %s to %s\n", it.name.c_str(), destDir.c_str());
|
||||
if (copyFileAtomic(it.src, it.dst, progress, cancelled)) {
|
||||
++r.copied;
|
||||
} else {
|
||||
if (cancelled && cancelled()) { r.ok = false; return r; }
|
||||
DEBUG_LOGF("[ERROR] Failed to copy %s\n", it.name.c_str());
|
||||
r.ok = false;
|
||||
if (r.firstFailure.empty()) r.firstFailure = it.name;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string ensureWalletRebuildHelperExtracted()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
@@ -484,12 +698,11 @@ std::string ensureWalletRebuildHelperExtracted()
|
||||
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
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted (atomic write: exists == complete)
|
||||
std::lock_guard<std::mutex> lock(provisionMutex()); // serialise with the startup job / other writers
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // written meanwhile by the startup job
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (!extractResource(res, dest)) return {};
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
if (!extractResourceLocked(res, dest, /*executable=*/true)) return {};
|
||||
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||
return dest;
|
||||
#else
|
||||
@@ -577,22 +790,6 @@ std::string getDaemonPath()
|
||||
return "";
|
||||
}
|
||||
|
||||
bool needsXmrigExtraction()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
std::string xmrigPath = daemonDir + "\\xmrig.exe";
|
||||
#else
|
||||
std::string xmrigPath = daemonDir + "/xmrig";
|
||||
#endif
|
||||
const auto* res = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
return resourceNeedsUpdate(res, xmrigPath);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool hasXmrigAvailable()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
@@ -623,14 +820,11 @@ bool forceExtractXmrig()
|
||||
}
|
||||
DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n",
|
||||
xmrigRes->size / (1024*1024), dest.c_str());
|
||||
if (!extractResource(xmrigRes, dest)) {
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
if (!extractResourceLocked(xmrigRes, dest, /*executable=*/true)) {
|
||||
DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n");
|
||||
return false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Set executable permission on Linux
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
@@ -639,12 +833,13 @@ bool forceExtractXmrig()
|
||||
|
||||
// Scan a binary blob for the daemon's version stamp: 'v' <maj>.<min>.<rev> optionally followed by
|
||||
// '-' <commit hash (>=6 hex)>, e.g. "v1.0.2-ddd851dc1". Returns the first match, or "" if none.
|
||||
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
|
||||
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size,
|
||||
std::size_t lastStart = static_cast<std::size_t>(-1))
|
||||
{
|
||||
if (!data || size < 6) return "";
|
||||
auto isdig = [](uint8_t c) { return std::isdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
auto isxd = [](uint8_t c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
for (std::size_t i = 0; i + 5 < size; ++i) {
|
||||
for (std::size_t i = 0; i + 5 < size && i <= lastStart; ++i) {
|
||||
if (data[i] != 'v') continue;
|
||||
std::size_t k = i + 1, s;
|
||||
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // major
|
||||
@@ -663,14 +858,38 @@ static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
|
||||
return "";
|
||||
}
|
||||
|
||||
DaemonBinaryInfo getInstalledDaemonInfo()
|
||||
// Stream the file in 4 MiB chunks (bounded memory, stops at the first stamp). A non-final chunk only starts
|
||||
// matches where a whole stamp (<= kStampMax bytes) still fits, and the last kStampMax-1 bytes are carried into the
|
||||
// next chunk, so a stamp straddling a boundary is neither cut short nor missed.
|
||||
static std::string scanFileVersion(const std::string& path)
|
||||
{
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) return "";
|
||||
constexpr std::size_t kChunk = 4u << 20, kStampMax = 64;
|
||||
std::vector<uint8_t> buf(kChunk + kStampMax);
|
||||
std::size_t carry = 0;
|
||||
for (;;) {
|
||||
f.read(reinterpret_cast<char*>(buf.data() + carry), static_cast<std::streamsize>(kChunk));
|
||||
const std::size_t got = static_cast<std::size_t>(f.gcount());
|
||||
const std::size_t total = carry + got;
|
||||
const bool last = got < kChunk; // short read => EOF
|
||||
const std::size_t lastStart = (last || total < kStampMax) ? static_cast<std::size_t>(-1) : total - kStampMax;
|
||||
std::string v = scanBinaryVersion(buf.data(), total, lastStart);
|
||||
if (!v.empty()) return v;
|
||||
if (last) return "";
|
||||
carry = kStampMax - 1;
|
||||
std::memmove(buf.data(), buf.data() + total - carry, carry);
|
||||
}
|
||||
}
|
||||
|
||||
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion)
|
||||
{
|
||||
DaemonBinaryInfo info;
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
info.path = daemonDir + "\\" + RESOURCE_DRAGONXD;
|
||||
info.path = daemonDir + "\\" + DAEMON_BINARY_NAME;
|
||||
#else
|
||||
info.path = daemonDir + "/" + RESOURCE_DRAGONXD;
|
||||
info.path = daemonDir + "/" + DAEMON_BINARY_NAME; // was RESOURCE_DRAGONXD ("dragonxd.exe"): never existed on POSIX (C123)
|
||||
#endif
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(info.path, ec)) return info; // exists stays false
|
||||
@@ -687,17 +906,24 @@ DaemonBinaryInfo getInstalledDaemonInfo()
|
||||
static_cast<std::int64_t>(std::chrono::system_clock::to_time_t(sysTime));
|
||||
}
|
||||
|
||||
// Read the binary and scan for its version stamp (one-off; caller caches the result).
|
||||
std::ifstream f(info.path, std::ios::binary);
|
||||
if (f) {
|
||||
f.seekg(0, std::ios::end);
|
||||
std::streamoff len = f.tellg();
|
||||
f.seekg(0, std::ios::beg);
|
||||
if (len > 0) {
|
||||
std::vector<uint8_t> buf(static_cast<std::size_t>(len));
|
||||
f.read(reinterpret_cast<char*>(buf.data()), len);
|
||||
info.version = scanBinaryVersion(buf.data(), static_cast<std::size_t>(f.gcount()));
|
||||
// Scan for the version stamp (streamed; call off the UI thread). Cached by (path, size, mtime): Refresh and the
|
||||
// post-install reload only rescan when the file actually changed.
|
||||
if (!scanVersion) return info;
|
||||
{
|
||||
static std::mutex cacheMutex;
|
||||
static std::string cPath, cVersion;
|
||||
static std::uintmax_t cSize = 0;
|
||||
static std::int64_t cMtime = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(cacheMutex);
|
||||
if (cPath == info.path && cSize == info.size && cMtime == info.modifiedEpoch && !cVersion.empty()) {
|
||||
info.version = cVersion;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
info.version = scanFileVersion(info.path);
|
||||
std::lock_guard<std::mutex> lock(cacheMutex);
|
||||
cPath = info.path; cSize = info.size; cMtime = info.modifiedEpoch; cVersion = info.version;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -721,6 +947,7 @@ BundledDaemonInfo getBundledDaemonInfo()
|
||||
bool reextractBundledDaemon()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
@@ -735,15 +962,12 @@ bool reextractBundledDaemon()
|
||||
if (!res) continue;
|
||||
std::string dest = daemonDir + pathSep + name;
|
||||
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
|
||||
if (!extractResource(res, dest)) {
|
||||
if (!extractResourceLocked(res, dest, /*executable=*/true)) {
|
||||
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
wroteAny = true;
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
}
|
||||
return ok && wroteAny;
|
||||
#else
|
||||
@@ -769,8 +993,13 @@ std::string getXmrigPath()
|
||||
std::string embeddedPath = daemonDir + pathSep + xmrigName;
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str());
|
||||
if (!std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
|
||||
forceExtractXmrig();
|
||||
// Serialise with the startup job: if it just wrote xmrig, the re-check finds it complete.
|
||||
std::lock_guard<std::mutex> lock(provisionMutex());
|
||||
if (!std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
|
||||
const EmbeddedResource* xr = getEmbeddedResource(RESOURCE_XMRIG);
|
||||
if (xr) extractResourceLocked(xr, embeddedPath, /*executable=*/true);
|
||||
}
|
||||
}
|
||||
if (std::filesystem::exists(embeddedPath)) {
|
||||
DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str());
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "provision_state.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace resources {
|
||||
@@ -20,9 +25,50 @@ bool hasEmbeddedResources();
|
||||
// Returns nullptr if not found or not embedded
|
||||
const EmbeddedResource* getEmbeddedResource(const std::string& name);
|
||||
|
||||
// Extract all embedded resources to appropriate locations
|
||||
// Returns true if all resources extracted successfully
|
||||
bool extractEmbeddedResources();
|
||||
using CancelFn = std::function<bool()>; // polled between 4 MB chunks; true = stop now
|
||||
|
||||
// Suffix of our own temp files in the daemon dir ("<name>.partial"). NOT ".tmp": the in-app miner/node
|
||||
// updaters stage "<name>.tmp" in the same directory and must never have it deleted under them.
|
||||
inline constexpr const char* kPartialFileSuffix = ".partial";
|
||||
|
||||
// Extract all embedded resources to the daemon directory. Serialised with every other writer of that
|
||||
// directory by an internal mutex; atomic per file (temp + flush + rename), so a destination is either the old
|
||||
// complete file or the new one. `progress` (optional) receives bytes written; `cancelled` (optional) aborts
|
||||
// between chunks leaving no partial destination. Returns true if every needed file was written (false also on
|
||||
// cancel). Safe on any thread.
|
||||
bool extractEmbeddedResources(ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Does `destPath` need (re)writing? Missing -> yes. Present: only when `refreshOnSizeMismatch` and the size
|
||||
// differs. embeddedSize == 0 -> never. (Pure apart from the stat; exposed for tests.)
|
||||
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath);
|
||||
// The bundled miner is placed only when MISSING: a present xmrig is never replaced by the bundled copy, even when
|
||||
// its size differs, so an "Update miner..." install survives a wallet restart or upgrade.
|
||||
inline constexpr bool kXmrigRefreshOnSizeMismatch = false; // the in-app updater owns the miner once placed
|
||||
// Write `data` to `destPath` via `destPath + kPartialFileSuffix`, flush it to disk, then move it into place
|
||||
// (POSIX rename; Windows MoveFileEx with retry and move-aside of a running .exe to ".old"). POSIX: chmod 0755
|
||||
// first when `executable`. Returns false (temp removed, original kept) on any failure or on cancel.
|
||||
// Takes no lock itself (extractEmbeddedResources / copyBundledFiles / the on-demand extractors do).
|
||||
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
// Same contract, streaming from a source file in 4 MB chunks; keeps the source's permissions.
|
||||
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Node files shipped NEXT TO the executable (zip / AppImage / .app; no embedded_data.h).
|
||||
struct BundleCopyItem { std::string name, src, dst; };
|
||||
std::vector<std::string> bundleSearchDirs(); // macOS: .app Contents/Resources first; then exe dir
|
||||
const std::vector<std::string>& saplingParamFileNames(); // sapling-spend.params, sapling-output.params
|
||||
const std::vector<std::string>& nodeBundleFileNames(); // params + asmap.dat + dragonxd + dragonx-cli + dragonx-tx
|
||||
// First search dir holding a name wins; names with no source are skipped (optional); names whose destination
|
||||
// exists are skipped unless replaceExisting. Stat-only.
|
||||
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting);
|
||||
struct BundleCopyResult { bool ok = true; int copied = 0; std::string firstFailure; };
|
||||
// Executes planBundleCopy under the provisioning lock (checks the plan lock-free first: the common
|
||||
// "nothing to copy" call never touches the lock).
|
||||
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
|
||||
const std::vector<std::string>& names, bool replaceExisting,
|
||||
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
|
||||
|
||||
// Check if params need to be extracted (not already present)
|
||||
bool needsParamsExtraction();
|
||||
@@ -48,7 +94,8 @@ struct BundledDaemonInfo {
|
||||
};
|
||||
|
||||
// Read + scan the installed dragonxd (reads the file; call off the UI thread or cache the result).
|
||||
DaemonBinaryInfo getInstalledDaemonInfo();
|
||||
// scanVersion=false: stat only (exists / size / modifiedEpoch), no 16 MB read.
|
||||
DaemonBinaryInfo getInstalledDaemonInfo(bool scanVersion = true);
|
||||
// Info about the bundled daemon (scans the embedded bytes once, cached).
|
||||
BundledDaemonInfo getBundledDaemonInfo();
|
||||
// Force-overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. The
|
||||
@@ -65,7 +112,13 @@ std::string ensureWalletRebuildHelperExtracted();
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe"; // embedded-resource table key (Windows builds only)
|
||||
// The daemon binary's file name on THIS platform. Use it to build paths; RESOURCE_DRAGONXD is only the table key.
|
||||
#ifdef _WIN32
|
||||
constexpr const char* DAEMON_BINARY_NAME = "dragonxd.exe";
|
||||
#else
|
||||
constexpr const char* DAEMON_BINARY_NAME = "dragonxd";
|
||||
#endif
|
||||
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";
|
||||
@@ -87,6 +140,10 @@ const EmbeddedTheme* getEmbeddedThemes();
|
||||
// Returns number of themes extracted
|
||||
int extractBundledThemes(const std::string& destDir);
|
||||
|
||||
// Write `size` bytes to `path` unless the file already holds exactly those bytes.
|
||||
// Returns 1 = written, 0 = already identical (untouched), -1 = write failed.
|
||||
int writeFileIfDifferent(const std::string& path, const uint8_t* data, size_t size);
|
||||
|
||||
// Update stale bundled theme files in the given directory.
|
||||
// Compares on-disk file size to embedded size; overwrites on mismatch.
|
||||
// Returns number of themes updated.
|
||||
@@ -104,9 +161,6 @@ bool hasDaemonAvailable();
|
||||
// Get daemon executable path (extracts if needed)
|
||||
std::string getDaemonPath();
|
||||
|
||||
// Check if xmrig needs to be extracted
|
||||
bool needsXmrigExtraction();
|
||||
|
||||
// Check if xmrig is available (embedded or in app directory)
|
||||
bool hasXmrigAvailable();
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// provision_state.h - state of the startup "node files" job (App::startStartupFilesProvision): on Windows it
|
||||
// extracts the bundled Sapling params / asmap / node / miner from the .exe, on Linux/macOS it copies them
|
||||
// from the executable's directory, and on full-node builds it then verifies the Sapling params. The job runs
|
||||
// on a worker thread; the UI thread only polls this. No app dependencies, so tests include it directly.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace dragonx {
|
||||
namespace resources {
|
||||
|
||||
enum class ProvisionPhase : int { Idle = 0, Extracting, Copying, Verifying };
|
||||
|
||||
// Lock-free progress: written by the worker, read by the UI every frame.
|
||||
struct ProvisionProgress {
|
||||
std::atomic<std::uint64_t> bytesDone{0};
|
||||
std::atomic<std::uint64_t> bytesTotal{0};
|
||||
std::atomic<int> phase{static_cast<int>(ProvisionPhase::Idle)};
|
||||
void setPhase(ProvisionPhase p) { phase.store(static_cast<int>(p), std::memory_order_relaxed); }
|
||||
ProvisionPhase getPhase() const { return static_cast<ProvisionPhase>(phase.load(std::memory_order_relaxed)); }
|
||||
};
|
||||
|
||||
// 0..100; 0 while the total is unknown.
|
||||
inline int provisionPercent(std::uint64_t done, std::uint64_t total)
|
||||
{
|
||||
if (total == 0) return 0;
|
||||
if (done >= total) return 100;
|
||||
return static_cast<int>((done * 100u) / total);
|
||||
}
|
||||
|
||||
// The chip stays hidden for a short grace period so a job that ends in a few ms never flashes.
|
||||
inline bool provisionChipVisible(bool running, double elapsedSeconds, double delaySeconds)
|
||||
{
|
||||
return running && elapsedSeconds >= delaySeconds;
|
||||
}
|
||||
|
||||
// What App::startEmbeddedDaemon() does while the job may still be writing the node files: never launch the
|
||||
// node over half-written files, never block the UI thread (defer + replay), workers simply wait.
|
||||
enum class DaemonStartGate { Proceed, Defer, Wait };
|
||||
inline DaemonStartGate daemonStartGate(bool jobRunning, bool onUiThread)
|
||||
{
|
||||
if (!jobRunning) return DaemonStartGate::Proceed;
|
||||
return onUiThread ? DaemonStartGate::Defer : DaemonStartGate::Wait;
|
||||
}
|
||||
|
||||
class StartupProvision {
|
||||
public:
|
||||
struct Result {
|
||||
bool extractOk = true; // every needed file written/copied (false: disk full / permissions / AV)
|
||||
bool verifyOk = true; // Sapling SHA-256 check (full node only)
|
||||
bool cancelled = false; // shutdown stopped it; nothing is wrong
|
||||
};
|
||||
enum class State : int { Idle, Running, Finished, Reaped };
|
||||
|
||||
ProvisionProgress progress;
|
||||
// The Sapling param repair copy (replace the daemon-dir params from the bundle, ~51.5 MB) ran this session:
|
||||
// set by the job and by App::startEmbeddedDaemon(), so a still-failing verify never repeats it on every
|
||||
// connect retry (the UI thread).
|
||||
std::atomic<bool> paramRepairTried{false};
|
||||
|
||||
void begin() {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
state_ = State::Running;
|
||||
startedAt_ = std::chrono::steady_clock::now();
|
||||
}
|
||||
// Nothing needed doing (the steady-state launch): no thread was started.
|
||||
void markNothingToDo() { std::lock_guard<std::mutex> lk(m_); state_ = State::Reaped; }
|
||||
void finish(const Result& r) {
|
||||
{ std::lock_guard<std::mutex> lk(m_); result_ = r; state_ = State::Finished; }
|
||||
cv_.notify_all();
|
||||
}
|
||||
bool isRunning() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Running; }
|
||||
// Non-reaping peek: the job has ended and shutdown cancelled it (synchronized with finish() through m_).
|
||||
bool endedCancelled() const {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
return state_ != State::Running && result_.cancelled;
|
||||
}
|
||||
// App::hasPendingBackgroundResults(): finished and not yet reaped (the reap needs one update() tick).
|
||||
// NOT while Running: nothing is reapable then, and the chip already requests progress frames when visible.
|
||||
bool hasCompletion() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Finished; }
|
||||
// UI thread: true exactly once, after the job finished.
|
||||
bool takeCompletion(Result* out) {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
if (state_ != State::Finished) return false;
|
||||
if (out) *out = result_;
|
||||
state_ = State::Reaped;
|
||||
return true;
|
||||
}
|
||||
// Worker threads: true when the job is no longer running.
|
||||
bool waitUntilDone(int timeoutMs) {
|
||||
std::unique_lock<std::mutex> lk(m_);
|
||||
cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return state_ != State::Running; });
|
||||
return state_ != State::Running;
|
||||
}
|
||||
double elapsedSeconds() const {
|
||||
std::lock_guard<std::mutex> lk(m_);
|
||||
if (state_ != State::Running) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - startedAt_).count();
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex m_;
|
||||
std::condition_variable cv_;
|
||||
State state_ = State::Idle;
|
||||
Result result_;
|
||||
std::chrono::steady_clock::time_point startedAt_{};
|
||||
};
|
||||
|
||||
} // namespace resources
|
||||
} // namespace dragonx
|
||||
@@ -241,6 +241,30 @@ bool Connection::verifySaplingParams()
|
||||
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
Connection::SaplingParamState Connection::saplingParamStateIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||
{
|
||||
if (dir.empty() || digests.empty()) return SaplingParamState::Missing;
|
||||
std::vector<std::string> current;
|
||||
current.reserve(digests.size());
|
||||
for (const auto& d : digests) {
|
||||
const std::string p = joinParamPath(dir, d.first);
|
||||
std::error_code ec;
|
||||
if (!fs::exists(p, ec)) return SaplingParamState::Missing;
|
||||
current.push_back(paramStatLine(p));
|
||||
}
|
||||
return saplingMarkerMatches(joinParamPath(dir, ".sapling_verified"), current)
|
||||
? SaplingParamState::Verified : SaplingParamState::NeedsHash;
|
||||
}
|
||||
|
||||
Connection::SaplingParamState Connection::saplingParamState()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::string>> digests;
|
||||
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||
return saplingParamStateIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
{
|
||||
ConnectionConfig config;
|
||||
|
||||
@@ -83,6 +83,14 @@ public:
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
// Stat-only (never reads or hashes the params): Missing = a param is absent, Verified = the
|
||||
// .sapling_verified marker matches every param's size:mtime, NeedsHash = present but unverified.
|
||||
enum class SaplingParamState { Missing, NeedsHash, Verified };
|
||||
static SaplingParamState saplingParamState();
|
||||
static SaplingParamState saplingParamStateIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
/**
|
||||
* @brief Get the Sapling params directory
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Long wallet RPCs (C61). A rescan or a key import holds the daemon's cs_main for as long as the wallet scan
|
||||
// takes: minutes to hours on a big wallet. On the shared clients they used to hit the 30 s curl timeout, report
|
||||
// a failure that wasn't one and flip the app into "node busy" (a reply timeout is recorded by the busy gate).
|
||||
// They now run one at a time on a dedicated, ungated client with no client timeout, abortable from any thread.
|
||||
|
||||
#include "connection.h" // ConnectionConfig
|
||||
#include "rpc_client.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// The wallet RPCs that scan the chain inside the call: they run on the long-call lane, never on the shared
|
||||
// clients (rpc_ / the console's fast lane), whose 30 s timeout they outlast.
|
||||
inline bool isLongWalletMethod(const std::string& method)
|
||||
{
|
||||
return method == "rescan" || method == "importprivkey" || method == "importaddress" ||
|
||||
method == "importwallet" || method == "z_importkey" || method == "z_importviewingkey" ||
|
||||
method == "z_importwallet";
|
||||
}
|
||||
|
||||
// How a long wallet call that threw an RpcTransportError ended, which picks the wording shown to the user.
|
||||
enum class LongCallCutOff {
|
||||
None, // the request never reached the node: its error is the whole story
|
||||
Cancelled, // our own abort cut the transfer; the node may still be scanning
|
||||
Interrupted, // the request was sent but no answer came back (a dropped connection, or a proxy's 502/504
|
||||
// while the node still runs it): the key may already be in the wallet
|
||||
Refused, // the node's HTTP layer turned it away before the method ran (401, 503, queue full,
|
||||
// unhandled), so nothing was imported
|
||||
};
|
||||
|
||||
inline LongCallCutOff classifyLongCallCutOff(RpcFailureKind kind, bool requestSent)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::Aborted:
|
||||
return LongCallCutOff::Cancelled;
|
||||
case RpcFailureKind::Auth:
|
||||
case RpcFailureKind::ShuttingDown:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Unhandled:
|
||||
return LongCallCutOff::Refused;
|
||||
default:
|
||||
// Any other HTTP status (a proxy's 502/504) or a lost reply proves nothing about the node's side.
|
||||
return requestSent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
|
||||
}
|
||||
}
|
||||
|
||||
// One long wallet RPC at a time on its own ungated client, with no client timeout, abortable from any thread.
|
||||
class LongCallLane {
|
||||
public:
|
||||
// Main thread: claim the lane. false when a long call is already running. A new call starts un-aborted.
|
||||
bool tryBegin()
|
||||
{
|
||||
bool idle = false;
|
||||
if (!active_.compare_exchange_strong(idle, true)) return false;
|
||||
abort_ = false;
|
||||
return true;
|
||||
}
|
||||
// The call and its continuation are done: the lane is free again.
|
||||
void end() { active_ = false; }
|
||||
bool active() const { return active_.load(); }
|
||||
bool aborted() const { return abort_.load(); }
|
||||
|
||||
// Any thread. Sticky until the next tryBegin(): a call that has not opened its client yet is aborted as soon
|
||||
// as it does, and an in-flight transfer is cut through the client's progress callback (about a second).
|
||||
void abort()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
abort_ = true;
|
||||
if (client_) client_->requestAbort();
|
||||
}
|
||||
|
||||
// Lane thread. open() FIRST (it clears the client's abort flag), THEN publish the client, THEN re-check the
|
||||
// sticky flag under the same mutex, so an abort that landed before or during open() is never erased.
|
||||
// An open() failure is not handled here: the body's first call then throws "Not connected".
|
||||
template <class Body>
|
||||
void run(RPCClient& c, const ConnectionConfig& cfg, Body&& body)
|
||||
{
|
||||
c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls);
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
client_ = &c;
|
||||
if (abort_) c.requestAbort();
|
||||
}
|
||||
struct Unpublish {
|
||||
LongCallLane& lane;
|
||||
~Unpublish()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(lane.mx_);
|
||||
lane.client_ = nullptr;
|
||||
}
|
||||
} unpublish{*this};
|
||||
body(c);
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mx_;
|
||||
RPCClient* client_ = nullptr; // guarded by mx_; set only while run() executes the body
|
||||
std::atomic<bool> abort_{false};
|
||||
std::atomic<bool> active_{false};
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -47,7 +47,8 @@ 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";
|
||||
// getmempoolinfo takes only mempool.cs (rpc/blockchain.cpp), and the dispatcher no global lock.
|
||||
return method == "stop" || method == "help" || method == "getrescaninfo" || method == "getmempoolinfo";
|
||||
}
|
||||
|
||||
// May this request be sent now? Lock-free methods always; the rest only while the node isn't busy.
|
||||
|
||||
+29
-31
@@ -225,6 +225,10 @@ bool RPCClient::open(const std::string& host, const std::string& port,
|
||||
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
||||
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
||||
// Loopback RPC carries basic-auth creds + wallet data: never route it through an env proxy
|
||||
// (http_proxy / all_proxy). Remote daemons keep proxy support. NOPROXY also overrides no_proxy.
|
||||
if (Connection::isLocalHost(host))
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROXY, "*");
|
||||
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
|
||||
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
@@ -447,8 +451,8 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
|
||||
}
|
||||
|
||||
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
|
||||
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
|
||||
json result = std::move(response["result"]); // move the subtree out: the secret now lives only here
|
||||
if (scrubSource) scrubJsonSecrets(response); // response["result"] is now moved-from (null); harmless (B7)
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -585,6 +589,28 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string RPCClient::callRaw(const std::string& method, const json& params, long timeoutSec)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!impl_->curl) {
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
// Temporarily override the timeout, as call(method, params, timeoutSec) does
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
|
||||
try {
|
||||
std::string out = callRaw(method, params);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
return out;
|
||||
} catch (...) {
|
||||
// Ensure timeout is always restored
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
||||
{
|
||||
try {
|
||||
@@ -658,7 +684,7 @@ void RPCClient::z_getNewAddress(Callback cb, ErrorCallback err)
|
||||
|
||||
void RPCClient::listTransactions(int count, Callback cb, ErrorCallback err)
|
||||
{
|
||||
doRPC("listtransactions", {"", count}, cb, err);
|
||||
doRPC("listtransactions", {"*", count}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_viewTransaction(const std::string& txid, Callback cb, ErrorCallback err)
|
||||
@@ -832,24 +858,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
|
||||
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
doRPC("z_getoperationstatus", {ids}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
doRPC("z_getoperationresult", {ids}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err)
|
||||
{
|
||||
doRPC("z_listreceivedbyaddress", {address, minconf}, cb, err);
|
||||
@@ -902,16 +910,6 @@ void RPCClient::z_mergeToAddress(const std::vector<std::string>& fromAddrs, cons
|
||||
doRPC("z_mergetoaddress", {addrs, toAddr, fee, 0, limit}, handlers.first, handlers.second);
|
||||
}
|
||||
|
||||
void RPCClient::z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) {
|
||||
ids.push_back(id);
|
||||
}
|
||||
auto handlers = splitUnified(std::move(cb));
|
||||
doRPC("z_getoperationstatus", {ids}, handlers.first, handlers.second);
|
||||
}
|
||||
|
||||
void RPCClient::getBlock(int height, UnifiedCallback cb)
|
||||
{
|
||||
// First get block hash, then get block
|
||||
|
||||
+11
-10
@@ -184,6 +184,12 @@ public:
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief callRaw() with a custom timeout, restored to the default afterwards (like the 3-arg call())
|
||||
* @param timeoutSec Timeout in seconds (0 = no timeout)
|
||||
*/
|
||||
std::string callRaw(const std::string& method, const json& params, long timeoutSec);
|
||||
|
||||
// High-level API methods - mirror the Qt version
|
||||
|
||||
// Info methods
|
||||
@@ -257,10 +263,6 @@ public:
|
||||
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
|
||||
double fee, int limit, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
// Operation status monitoring
|
||||
void z_getOperationStatus(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
|
||||
void z_getOperationResult(const std::vector<std::string>& opids, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
// Received transactions
|
||||
void z_listReceivedByAddress(const std::string& address, int minconf, Callback cb, ErrorCallback err = nullptr);
|
||||
|
||||
@@ -272,7 +274,6 @@ public:
|
||||
double fee, int limit, UnifiedCallback cb);
|
||||
void z_mergeToAddress(const std::vector<std::string>& fromAddrs, const std::string& toAddr,
|
||||
double fee, int limit, UnifiedCallback cb);
|
||||
void z_getOperationStatus(const std::vector<std::string>& opids, UnifiedCallback cb);
|
||||
void getBlock(int height, UnifiedCallback cb);
|
||||
|
||||
private:
|
||||
@@ -284,9 +285,9 @@ private:
|
||||
// hold curl_mutex_ and have verified impl_->curl.
|
||||
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
|
||||
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
|
||||
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
|
||||
// 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).
|
||||
// response["result"] on success. The result subtree is moved out of the parse tree, so a secret lives
|
||||
// only in the returned node (which callSecret/callSecretString callers wipe). When scrubSource is true
|
||||
// (secret-bearing calls), any remaining parse-tree string values are zeroed before it is discarded (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).
|
||||
@@ -300,9 +301,9 @@ private:
|
||||
std::string host_;
|
||||
std::string port_;
|
||||
std::string auth_; // Base64 encoded "user:password"
|
||||
bool connected_ = false;
|
||||
std::atomic<bool> connected_{false};
|
||||
std::atomic<bool> abort_{false}; // set cross-thread to abort an in-flight transfer
|
||||
bool warming_up_ = false;
|
||||
std::atomic<bool> warming_up_{false};
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
|
||||
|
||||
@@ -152,5 +152,30 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||
|
||||
inline UnlockFailure classifyUnlockFailure(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) {
|
||||
if (r->code == -14) return UnlockFailure::WrongPassphrase; // RPC_WALLET_PASSPHRASE_INCORRECT
|
||||
if (r->code == -15) return UnlockFailure::NotEncrypted; // RPC_WALLET_WRONG_ENC_STATE
|
||||
if (r->code == -28) return UnlockFailure::NodeBusy; // RPC_IN_WARMUP
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
switch (rpcFailureKindOf(e)) {
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::ConnectTimeout:
|
||||
case RpcFailureKind::NotListening:
|
||||
return UnlockFailure::NodeBusy;
|
||||
default:
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -142,6 +142,10 @@ void RPCWorker::run()
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[RPCWorker] Task threw: %s\n", e.what());
|
||||
} catch (...) {
|
||||
// A raw post() job that throws still loses its MainCb (no worse than the
|
||||
// std::exception path); this only keeps the worker thread alive.
|
||||
DEBUG_LOGF("[RPCWorker] Task threw unknown exception\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the HushChat scans are worth sending. Both are wallet-wide (they hold dragonxd's cs_main): the inbox
|
||||
// scan (z_listreceivedbyaddress of the chat address, every note ever) and the note-buffer scan
|
||||
// (z_listunspent). They used to run on a timer whether or not anything had changed. Here they run when
|
||||
// something they depend on can have changed, and the WalletScanGovernor decides when (the scan budget).
|
||||
//
|
||||
// Pure, header-only.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
#include "services/wallet_scan_governor.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr double kChatTipSettleSec = 3.0; // the wallet notifier stores a block's txs ~1 s after it connects
|
||||
constexpr double kChatNoteScanFloorSec = 4.0; // spacing between note scans (a failing need can't spin), not a duty rule
|
||||
constexpr double kChatProbeMaxDuty = 0.02;
|
||||
constexpr double kChatProbeMaxSpacingSec = 60.0; // the probe is the inbox safety net's only path
|
||||
|
||||
// getwalletinfo's txcount (mapWallet.size(), read under cs_wallet): it changes only after the daemon's wallet
|
||||
// notifier has stored a tx and its notes. A mempool or tip change can run ahead of that (the notifier runs
|
||||
// about once a second), so the inbox is gated on this instead. -1 when absent, malformed or negative.
|
||||
inline std::int64_t parseWalletTxCount(const nlohmann::json& reply)
|
||||
{
|
||||
if (!reply.is_object() || !reply.contains("txcount") || !reply["txcount"].is_number_integer()) return -1;
|
||||
const auto v = reply["txcount"].get<std::int64_t>();
|
||||
return v < 0 ? -1 : v;
|
||||
}
|
||||
|
||||
// The inbox probe (getwalletinfo: three passes over the wallet under cs_main) isn't a governed scan: at most 2%
|
||||
// of wall-clock, and at least once a minute (it is the only way the inbox safety net is checked).
|
||||
inline bool chatProbeDue(double lastProbeMs, double lastProbeAtSec, double nowSec)
|
||||
{
|
||||
return nowSec - lastProbeAtSec >= std::min((std::max(0.0, lastProbeMs) / 1000.0) / kChatProbeMaxDuty,
|
||||
kChatProbeMaxSpacingSec);
|
||||
}
|
||||
|
||||
// Safety nets when no change signal fired: a minute, stretched with the scan's own cost (the inbox is gated on
|
||||
// txcount, so its net is only a backstop; the note fallback refreshes the buffer status and the payer view).
|
||||
inline double chatInboxSafetyNetSec(double emaCostMs) { return std::max(60.0, 50.0 * emaCostMs / 1000.0); }
|
||||
inline double chatNoteFallbackSec(double emaCostMs) { return std::max(60.0, 20.0 * emaCostMs / 1000.0); }
|
||||
|
||||
struct ChatInboxScanInputs {
|
||||
std::int64_t basisTxCount = -1; // the count the last successful scan is current for (-1: none)
|
||||
std::int64_t txCount = -1; // the probe's read
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double safetyNetSec = 60.0;
|
||||
};
|
||||
|
||||
// The inbox needs a scan: the count is unknown (no basis yet, or a daemon without txcount: as the budget
|
||||
// allows), the wallet gained or lost a transaction, or the safety net is due.
|
||||
inline bool chatInboxScanNeeded(const ChatInboxScanInputs& in)
|
||||
{
|
||||
if (in.txCount < 0 || in.basisTxCount < 0) return true;
|
||||
if (in.txCount != in.basisTxCount) return true;
|
||||
return in.nowSec - in.lastScanAtSec >= in.safetyNetSec;
|
||||
}
|
||||
|
||||
struct ChatNoteScanInputs {
|
||||
bool onChatPage = false; // the Chat page is shown (and the window isn't in the background)
|
||||
bool queueWaiting = false; // a chat send waits for a verified note
|
||||
bool splitOutstanding = false; // a self-split is confirming
|
||||
bool rescanRequested = false; // a chat operation finished
|
||||
bool modelSeen = false; // a scan (or the address scan's feed) succeeded this session
|
||||
int verifiedBudget = 0;
|
||||
int pipelineNotes = 0;
|
||||
std::int64_t tipHeight = 0;
|
||||
std::int64_t lastScanTip = -1;
|
||||
double nowSec = 0.0;
|
||||
double lastScanAtSec = 0.0;
|
||||
double tipChangedAtSec = 0.0;
|
||||
double fallbackSec = 60.0;
|
||||
double floorSec = kChatNoteScanFloorSec;
|
||||
double tipSettleSec = kChatTipSettleSec;
|
||||
};
|
||||
|
||||
// The note view is worth refreshing: something can use it (the Chat page, a waiting send, a split) and it
|
||||
// can have changed (first look, a chat op finished, a block that matters once the notifier caught up, or the
|
||||
// fallback). How often it actually runs is the scan budget's call.
|
||||
inline bool shouldScanChatNotes(const ChatNoteScanInputs& in)
|
||||
{
|
||||
if (!in.onChatPage && !in.queueWaiting && !in.splitOutstanding) return false; // nothing can use it
|
||||
const bool ever = in.lastScanAtSec > 0.0;
|
||||
if (ever && in.nowSec - in.lastScanAtSec < in.floorSec) return false;
|
||||
if (!in.modelSeen || in.rescanRequested) return true;
|
||||
const bool tipMoved = in.tipHeight != in.lastScanTip && in.nowSec - in.tipChangedAtSec >= in.tipSettleSec;
|
||||
const bool starved = in.queueWaiting && in.verifiedBudget < 1;
|
||||
const bool maturing = in.onChatPage && in.verifiedBudget < in.pipelineNotes; // notes still confirming
|
||||
if (tipMoved && (starved || in.splitOutstanding || maturing)) return true;
|
||||
return !ever || in.nowSec - in.lastScanAtSec >= in.fallbackSec;
|
||||
}
|
||||
|
||||
// Verified notes (at least a fee, unlocked, with the confirmations a chat send needs) by z-address.
|
||||
// z_sendmany pays from ONE address and spends its largest notes first.
|
||||
struct ChatPayerFunds {
|
||||
std::uint64_t zat = 0;
|
||||
int notes = 0;
|
||||
};
|
||||
using ChatPayerMap = std::unordered_map<std::string, ChatPayerFunds>;
|
||||
|
||||
// `preferred` if it has a verified note and covers needZat, else the richest that does (ties: the smallest
|
||||
// address). "" if none does.
|
||||
inline std::string chooseChatPayer(const ChatPayerMap& funds, const std::string& preferred, std::uint64_t needZat)
|
||||
{
|
||||
auto eligible = [&](const ChatPayerFunds& f) { return f.notes > 0 && f.zat >= needZat; };
|
||||
if (!preferred.empty()) {
|
||||
const auto it = funds.find(preferred);
|
||||
if (it != funds.end() && eligible(it->second)) return preferred;
|
||||
}
|
||||
std::string best;
|
||||
std::uint64_t bestZat = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (address.empty() || !eligible(f)) continue;
|
||||
if (best.empty() || f.zat > bestZat || (f.zat == bestZat && address < best)) {
|
||||
best = address;
|
||||
bestZat = f.zat;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
inline std::uint64_t richestChatPayerZat(const ChatPayerMap& funds)
|
||||
{
|
||||
std::uint64_t best = 0;
|
||||
for (const auto& [address, f] : funds) {
|
||||
if (!address.empty() && f.notes > 0) best = std::max(best, f.zat);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
enum class ChatPayerSpend { OneNote, All };
|
||||
|
||||
// Until the next scan restores the truth: a chat send spends one whole note (its change is unverified for a
|
||||
// block); a split may spend all of the payer's verified notes.
|
||||
inline void debitChatPayer(ChatPayerMap& funds, const std::string& payer, ChatPayerSpend spend)
|
||||
{
|
||||
const auto it = funds.find(payer);
|
||||
if (it == funds.end()) return;
|
||||
auto& f = it->second;
|
||||
if (spend == ChatPayerSpend::All || f.notes <= 1) {
|
||||
f = {};
|
||||
return;
|
||||
}
|
||||
f.zat -= f.zat / static_cast<std::uint64_t>(f.notes);
|
||||
--f.notes;
|
||||
}
|
||||
|
||||
// How a chat scan's result is charged to the scan budget.
|
||||
inline ScanOutcome chatScanOutcome(bool ok, rpc::RpcFailureKind kind)
|
||||
{
|
||||
if (ok) return ScanOutcome::Ok;
|
||||
if (kind == rpc::RpcFailureKind::ReplyTimeout) return ScanOutcome::TimedOut; // the daemon still runs it
|
||||
if (kind == rpc::RpcFailureKind::Deferred || kind == rpc::RpcFailureKind::QueueFull)
|
||||
return ScanOutcome::Skipped; // never ran
|
||||
return ScanOutcome::Failed;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Holds the wallet-wide scans (the balance, the address scan, the history) for a moment after the node
|
||||
// starts answering: until the lock state is known and either the node knows the chain tip or the wallet is
|
||||
// small. Right after warmup a node that is behind doesn't know it yet (longestchain stays 0 until its first
|
||||
// UpdateTip), so the first Core poll would read "synced" and a large wallet's scans would start, holding
|
||||
// cs_main ahead of the catch-up. Opens after kGraceSeconds whatever happens.
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class ConnectScanGate {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static constexpr int kGraceSeconds = 20;
|
||||
static constexpr int kMinWalletTxs = 500; // below this the scans are cheap: don't hold them for the tip
|
||||
|
||||
void arm(Clock::time_point now, bool tipKnown)
|
||||
{
|
||||
armed_ = true;
|
||||
edgePending_ = true;
|
||||
tipKnown_ = tipKnown;
|
||||
armedAt_ = now;
|
||||
}
|
||||
void noteTipKnown() { tipKnown_ = true; }
|
||||
void disarm()
|
||||
{
|
||||
armed_ = false;
|
||||
edgePending_ = false;
|
||||
}
|
||||
bool armed() const { return armed_; }
|
||||
|
||||
// walletTxCount: getwalletinfo's txcount, -1 when unknown.
|
||||
bool open(Clock::time_point now, bool lockStateKnown, int walletTxCount) const
|
||||
{
|
||||
if (!armed_) return true;
|
||||
const bool smallWallet = walletTxCount >= 0 && walletTxCount < kMinWalletTxs;
|
||||
if (lockStateKnown && (tipKnown_ || smallWallet)) return true;
|
||||
return now - armedAt_ >= std::chrono::seconds(kGraceSeconds);
|
||||
}
|
||||
// True once per arm(), the first time open() is: what the gate held is owed now.
|
||||
bool consumeOpenEdge(Clock::time_point now, bool lockStateKnown, int walletTxCount)
|
||||
{
|
||||
if (!armed_ || !edgePending_ || !open(now, lockStateKnown, walletTxCount)) return false;
|
||||
edgePending_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool armed_ = false;
|
||||
bool edgePending_ = false;
|
||||
bool tipKnown_ = false;
|
||||
Clock::time_point armedAt_{};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,212 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "history_scan_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs)
|
||||
{
|
||||
if (addressCount == 0 || !(perAddressScanMs > 0.0)) return kHistoryMinStaleBlocks;
|
||||
const double blocks = std::ceil((static_cast<double>(addressCount) * perAddressScanMs / 1000.0) /
|
||||
(kHistoryBlockSeconds * kHistoryScanDuty));
|
||||
if (!(blocks < kHistoryMaxStaleBlocks)) return kHistoryMaxStaleBlocks; // also NaN / inf
|
||||
return std::max(kHistoryMinStaleBlocks, static_cast<int>(blocks));
|
||||
}
|
||||
|
||||
double updateScanCostEstimateMs(double previousMs, double sampleMs)
|
||||
{
|
||||
if (!(sampleMs > 0.0)) return previousMs;
|
||||
if (!(previousMs > 0.0) || sampleMs > previousMs) return sampleMs;
|
||||
return 0.75 * previousMs + 0.25 * sampleMs;
|
||||
}
|
||||
|
||||
int transparentPageSize(std::size_t previousRowCount)
|
||||
{
|
||||
const std::size_t want = previousRowCount + std::max<std::size_t>(200, previousRowCount / 10);
|
||||
return static_cast<int>(std::clamp<std::size_t>(want, kTransparentPageMin, kTransparentPageMax));
|
||||
}
|
||||
|
||||
bool sentSweepIsFull(int tip, int fromHeight)
|
||||
{
|
||||
return tip <= 0 || fromHeight < 0 || fromHeight > tip;
|
||||
}
|
||||
|
||||
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight)
|
||||
{
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
params.push_back(anyZAddress);
|
||||
params.push_back(0);
|
||||
if (sentSweepIsFull(tip, fromHeight)) return params;
|
||||
const int maxDepth = (tip - fromHeight) + kSentSweepReorgMarginBlocks;
|
||||
params.push_back(2); // filter type 2: depth
|
||||
params.push_back(maxDepth); // at most this deep (mempool txs, depth 0, always pass)
|
||||
return params;
|
||||
}
|
||||
|
||||
long fullSentSweepRetryDelaySec(int consecutiveFailures)
|
||||
{
|
||||
if (consecutiveFailures <= 0) return 0;
|
||||
if (consecutiveFailures == 1) return 600;
|
||||
if (consecutiveFailures == 2) return 3600;
|
||||
return 21600;
|
||||
}
|
||||
|
||||
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
return kind == K::ReplyTimeout || kind == K::AppError || kind == K::Other;
|
||||
}
|
||||
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights)
|
||||
{
|
||||
std::size_t best = 0;
|
||||
int bestHeight = 0;
|
||||
bool haveBest = false;
|
||||
for (std::size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto it = heights.find(addresses[i]);
|
||||
if (it == heights.end()) return i; // never scanned: first
|
||||
if (!haveBest || it->second < bestHeight) {
|
||||
best = i;
|
||||
bestHeight = it->second;
|
||||
haveBest = true;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string ProbeRotation::next(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights)
|
||||
{
|
||||
std::unordered_set<std::string> listed(addresses.begin(), addresses.end());
|
||||
for (auto it = left_.begin(); it != left_.end();) {
|
||||
if (listed.count(*it)) ++it;
|
||||
else it = left_.erase(it);
|
||||
}
|
||||
if (left_.empty()) left_ = std::move(listed);
|
||||
std::vector<std::string> left;
|
||||
for (const auto& address : addresses) {
|
||||
if (left_.count(address)) left.push_back(address);
|
||||
}
|
||||
if (left.empty()) return {};
|
||||
return left[oldestScannedAddressIndex(left, heights)];
|
||||
}
|
||||
|
||||
int ShieldedHistoryPass::floorAt(int tip) const
|
||||
{
|
||||
return tip > 0 ? std::min(startHeight_, tip) : startHeight_;
|
||||
}
|
||||
|
||||
ShieldedHistoryPass::Work ShieldedHistoryPass::evaluate(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights,
|
||||
int staleAfter, bool initialComplete) const
|
||||
{
|
||||
Work work;
|
||||
work.total = addresses.size();
|
||||
const int floor = active_ ? floorAt(tip) : -1;
|
||||
std::size_t below = 0;
|
||||
bool staleDue = false;
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it == heights.end()) {
|
||||
++work.missing;
|
||||
continue;
|
||||
}
|
||||
if (active_) {
|
||||
if (it->second < floor) ++below;
|
||||
} else if (tip > 0 && it->second < tip - staleAfter) {
|
||||
staleDue = true;
|
||||
}
|
||||
}
|
||||
work.pending = work.missing + (active_ ? below : (staleDue ? work.total - work.missing : 0));
|
||||
work.explicitWork = !stalled() &&
|
||||
(work.missing > 0 || (active_ ? (explicit_ && below > 0) : (staleDue && !initialComplete)));
|
||||
work.routineWork = !work.explicitWork && work.pending > 0;
|
||||
return work;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::begin(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, int staleAfter,
|
||||
bool initialComplete, bool routineAllowed)
|
||||
{
|
||||
if (active_) {
|
||||
if (tip > 0 && startHeight_ > tip) startHeight_ = tip; // the chain reorganised below the floor
|
||||
return;
|
||||
}
|
||||
if (tip <= 0 || !(routineAllowed || !initialComplete)) return;
|
||||
bool staleDue = false;
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it != heights.end() && it->second < tip - staleAfter) {
|
||||
staleDue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!staleDue) return;
|
||||
active_ = true;
|
||||
explicit_ = !initialComplete;
|
||||
startHeight_ = tip;
|
||||
}
|
||||
|
||||
int ShieldedHistoryPass::scanFloor(int tip, bool routineAllowed) const
|
||||
{
|
||||
return (active_ && (explicit_ || routineAllowed)) ? floorAt(tip) : -1;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::finish(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, std::size_t attempted,
|
||||
std::size_t succeeded)
|
||||
{
|
||||
if (attempted > 0) stalledCycles_ = succeeded > 0 ? 0 : stalledCycles_ + 1;
|
||||
// An empty list proves nothing (a failed enumeration blanks it); a real wallet change resets the pass.
|
||||
if (!active_ || addresses.empty()) return;
|
||||
const int floor = floorAt(tip);
|
||||
for (const auto& address : addresses) {
|
||||
const auto it = heights.find(address);
|
||||
if (it == heights.end() || it->second < floor) return; // still work left in this pass
|
||||
}
|
||||
active_ = false;
|
||||
explicit_ = false;
|
||||
}
|
||||
|
||||
void ShieldedHistoryPass::reset()
|
||||
{
|
||||
*this = ShieldedHistoryPass{};
|
||||
}
|
||||
|
||||
TxCycle decideTxCycle(const TxCycleInputs& in)
|
||||
{
|
||||
const bool wanted = in.neverFetched || in.newTip || in.dirty || in.explicitScanWork || in.routineScanWork;
|
||||
const bool scansAllowed = !in.syncing && in.heightKnown;
|
||||
if (wanted) {
|
||||
if (scansAllowed && (in.explicitScanWork || in.gateOpen)) return TxCycle::Full;
|
||||
// Rows now (a mutation, a tracked send at a new tip); wallet-wide scans wait for the throttle.
|
||||
if (in.neverFetched || in.dirty || (in.sendTracking && in.newTip)) return TxCycle::Light;
|
||||
}
|
||||
if (in.recentDue) return (scansAllowed && in.gateOpen) ? TxCycle::RecentWithProbe : TxCycle::Recent;
|
||||
return TxCycle::None;
|
||||
}
|
||||
|
||||
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in)
|
||||
{
|
||||
if (!in.sendTracking) { // tracked sends need their Light cycle at a new tip, wherever the user is
|
||||
in.newTip = false;
|
||||
in.routineScanWork = false;
|
||||
}
|
||||
return in;
|
||||
}
|
||||
|
||||
bool miningTxPollDue(const MiningTxPollInputs& in)
|
||||
{
|
||||
return in.historyLoaded && in.tip > 0 && in.tip != in.lastPolledTip && in.txAgeDue && !in.dirty &&
|
||||
!in.addressesDirty;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,170 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// When the transaction history refresh may scan what. The history comes from three wallet-wide RPCs, each
|
||||
// holding the daemon's cs_main for its whole run (seconds per call on a large wallet): listtransactions,
|
||||
// z_listreceivedbyaddress (once per z-address) and z_listsentbyaddress. This decides, per refresh cycle,
|
||||
// which of them run, so the cost stays bounded and every pass over the z-addresses finishes. Pure and
|
||||
// main-thread only; App::refreshTransactionData() feeds it and the test target drives it directly.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
constexpr int kHistoryBlockSeconds = 36; // -ac_blocktime=36
|
||||
constexpr double kHistoryScanDuty = 0.10; // the same 10% as the other scan throttles
|
||||
constexpr int kHistoryMinStaleBlocks = 8; // re-scan an address at most every 8 blocks...
|
||||
constexpr int kHistoryMaxStaleBlocks = 500; // ...and at least every 500 (a safety net)
|
||||
constexpr int kSentSweepReorgMarginBlocks = 100; // > MAX_REORG_LENGTH (99), plus tip drift
|
||||
constexpr long kFullSentSweepTimeoutSec = 120; // a whole-wallet sweep must be able to finish
|
||||
constexpr int kTransparentPageMin = 1000;
|
||||
constexpr int kTransparentPageMax = 5000;
|
||||
constexpr int kStallCyclesBeforeGating = 1;
|
||||
|
||||
// Blocks after which an address's last receive scan counts as stale and a new routine pass is due: the
|
||||
// pass then costs about kHistoryScanDuty of wall-clock (one z_listreceivedbyaddress of perAddressScanMs per
|
||||
// address, spread over that many blocks), within [kHistoryMinStaleBlocks, kHistoryMaxStaleBlocks].
|
||||
int historyStaleAfterBlocks(std::size_t addressCount, double perAddressScanMs);
|
||||
|
||||
// Running cost estimate: a higher sample replaces it at once, a lower one pulls it down 25%. A sample of 0
|
||||
// or less (nothing measured) leaves it as it is.
|
||||
double updateScanCostEstimateMs(double previousMs, double sampleMs);
|
||||
|
||||
// listtransactions rows to ask for in the first page: last time's count plus headroom, so most wallets
|
||||
// get every row in one call instead of re-paging the whole history in 1000-row pages.
|
||||
int transparentPageSize(std::size_t previousRowCount);
|
||||
|
||||
// z_listsentbyaddress: the whole wallet when there's no usable watermark (none yet, or it is above the
|
||||
// tip after a reorg), else only txs at most (tip - fromHeight) + kSentSweepReorgMarginBlocks deep.
|
||||
bool sentSweepIsFull(int tip, int fromHeight);
|
||||
// [z, 0] for the full sweep, [z, 0, 2, maxDepth] for the window: JSON integers, and never 3 params (the
|
||||
// daemon rejects that count).
|
||||
nlohmann::json sentSweepParams(const std::string& anyZAddress, int tip, int fromHeight);
|
||||
// Backoff after full sweeps that failed in a way that cost the daemon the work: 0, 10 min, 1 h, then 6 h.
|
||||
long fullSentSweepRetryDelaySec(int consecutiveFailures);
|
||||
// Whether a failed full sweep counts toward that backoff: only when the daemon did the work (reply timeout,
|
||||
// an error answer, anything unclassified), not when the call never ran (held back, aborted, not listening).
|
||||
bool fullSentSweepFailureCounts(rpc::RpcFailureKind kind);
|
||||
|
||||
// The address scanned longest ago (never-scanned first), for the between-blocks probe.
|
||||
std::size_t oldestScannedAddressIndex(const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights);
|
||||
|
||||
// The between-blocks probe scans one address per run. After what it watches changed (the mempool, a wallet
|
||||
// mutation) it isn't done until it has probed every z-address once: the change may be any of them. Tracks
|
||||
// which are left, by address (heights can't tell: a rotation at one tip leaves them all equal).
|
||||
class ProbeRotation {
|
||||
public:
|
||||
// The next address to probe: the oldest-scanned of those left. Drops addresses no longer listed, and
|
||||
// starts a new rotation when none are left. Empty when there are no addresses.
|
||||
std::string next(const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights);
|
||||
void probed(const std::string& address) { left_.erase(address); }
|
||||
bool complete() const { return left_.empty(); }
|
||||
void reset() { left_.clear(); }
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> left_;
|
||||
};
|
||||
|
||||
// One pass of receive scans over every z-address. An address is done once it was scanned at or above the
|
||||
// pass's start height (its floor), so a pass ends after ceil(A / budget) cycles however the tip moves. An
|
||||
// explicit pass (the first load of a wallet, or after an invalidation) may run back to back; a routine
|
||||
// re-pass (addresses gone stale) runs only as the scan throttle allows.
|
||||
class ShieldedHistoryPass {
|
||||
public:
|
||||
struct Work {
|
||||
std::size_t total = 0; // z-addresses
|
||||
std::size_t missing = 0; // never scanned
|
||||
std::size_t pending = 0; // still to scan in this pass (or in the pass that is due)
|
||||
bool explicitWork = false; // may skip the scan throttle
|
||||
bool routineWork = false; // waits for the scan throttle
|
||||
};
|
||||
|
||||
Work evaluate(int tip, const std::vector<std::string>& addresses,
|
||||
const std::unordered_map<std::string, int>& heights, int staleAfter, bool initialComplete) const;
|
||||
// Start a pass if one is due and allowed (routine passes need routineAllowed, unless the first load
|
||||
// isn't complete yet). Clamps an active pass's floor after a reorg below it.
|
||||
void begin(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
int staleAfter, bool initialComplete, bool routineAllowed);
|
||||
// The floor a cycle scans up to: -1 means only never-scanned (and priority) addresses, which is all an
|
||||
// explicit-only cycle may do while a routine pass is active.
|
||||
int scanFloor(int tip, bool routineAllowed) const;
|
||||
// After a cycle's heights are merged: count stalls (scans attempted, none succeeded), end the pass once
|
||||
// no address is missing or below its floor.
|
||||
void finish(int tip, const std::vector<std::string>& addresses, const std::unordered_map<std::string, int>& heights,
|
||||
std::size_t attempted, std::size_t succeeded);
|
||||
void reset();
|
||||
|
||||
bool active() const { return active_; }
|
||||
bool explicitPass() const { return explicit_; }
|
||||
int startHeight() const { return startHeight_; }
|
||||
// Scans keep failing: explicit work is treated as routine (gated) until one succeeds again.
|
||||
bool stalled() const { return stalledCycles_ >= kStallCyclesBeforeGating; }
|
||||
|
||||
private:
|
||||
int floorAt(int tip) const;
|
||||
|
||||
bool active_ = false;
|
||||
bool explicit_ = false;
|
||||
int startHeight_ = -1;
|
||||
int stalledCycles_ = 0;
|
||||
};
|
||||
|
||||
enum class TxCycle {
|
||||
None,
|
||||
Full, // listtransactions, receive scans, the sent sweep, enrichment
|
||||
Light, // listtransactions and enrichment only: no wallet-wide z-scans
|
||||
Recent, // the between-blocks poll: recent listtransactions rows
|
||||
RecentWithProbe, // ...plus one receive scan of the address scanned longest ago
|
||||
};
|
||||
|
||||
struct TxCycleInputs {
|
||||
bool neverFetched = false; // no history loaded yet this session
|
||||
bool newTip = false; // the tip moved since the last history apply
|
||||
bool dirty = false; // something changed the wallet (a send, an import, ...)
|
||||
bool sendTracking = false; // tracked sends still waiting for their view
|
||||
bool explicitScanWork = false; // ShieldedHistoryPass::Work::explicitWork
|
||||
bool routineScanWork = false; // ShieldedHistoryPass::Work::routineWork
|
||||
bool gateOpen = false; // the scan throttle allows a wallet-wide scan now
|
||||
bool syncing = false; // the node is catching up (or settling after it)
|
||||
bool heightKnown = false; // the tip height is known
|
||||
bool recentDue = false; // the between-blocks poll is due on a wallet page
|
||||
};
|
||||
|
||||
// Only explicit work skips the throttle with scans; sends and other mutations get rows now through Light
|
||||
// cycles; nothing scans while syncing or before the tip is known. Tracking alone needs a new tip for a
|
||||
// Light cycle, so it can't loop them at one tip.
|
||||
TxCycle decideTxCycle(const TxCycleInputs& in);
|
||||
|
||||
// The Mining tab shows history-derived earnings but is not a wallet-data page: it gets the cheap between-blocks
|
||||
// poll (recent listtransactions rows) and nothing routine. Explicit work (first load, dirty, new addresses) and
|
||||
// tracked sends still go through the normal cycles on any page.
|
||||
TxCycleInputs shapeTxCycleForMiningPage(TxCycleInputs in);
|
||||
|
||||
struct MiningTxPollInputs {
|
||||
int tip = 0; // current chain height
|
||||
int lastPolledTip = -1; // the tip the last Mining poll was dispatched at (-1 = none)
|
||||
bool txAgeDue = false; // RefreshTimer::TxAge due (15 s since the last Recent poll / full cycle)
|
||||
bool historyLoaded = false; // last_tx_block_height_ >= 0
|
||||
bool dirty = false; // transactions_dirty_
|
||||
bool addressesDirty = false; // addresses_dirty_
|
||||
};
|
||||
// One poll per new block (confirmation counts and new coinbase rows only change with a block), after the history
|
||||
// has loaded, never while a dirty cycle is already owed. Unlike the wallet pages, an EMPTY history may poll (a fresh
|
||||
// miner's first block must appear).
|
||||
bool miningTxPollDue(const MiningTxPollInputs& in);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,90 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure search for the lowest block height whose data the node can still read (a node restored from a
|
||||
// bootstrap snapshot lacks everything below the snapshot base). No I/O: the caller supplies the probe.
|
||||
// Header-only so the test target needs no new source.
|
||||
#pragma once
|
||||
|
||||
#include "../rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class BlockProbe { Present, Missing, Unknown };
|
||||
|
||||
struct LowestBlockResult {
|
||||
bool ok = false; // false: could not tell (never read as "missing")
|
||||
int lowest = 0; // lowest readable height (valid when ok)
|
||||
bool fullHistory = false; // block 1 readable: not a bootstrapped / pruned node
|
||||
};
|
||||
|
||||
// What a failed getblockhash / getblock means. Only "the daemon says it cannot read the block data"
|
||||
// (RPC_INTERNAL_ERROR -32603: "Can't read block from disk" / "Block not available (pruned data)",
|
||||
// external/dragonx/src/rpc/blockchain.cpp) is Missing. Every transport problem (timeout, busy gate
|
||||
// "RPC deferred", queue full, abort, not listening), warmup (-28), out of range (-8), not found (-5) and
|
||||
// anything unrecognised is Unknown, which stops the search.
|
||||
inline BlockProbe classifyBlockProbeError(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const rpc::RpcError*>(&e)) {
|
||||
if (r->code == -32603) return BlockProbe::Missing;
|
||||
const std::string m = r->what();
|
||||
if (m.find("from disk") != std::string::npos || m.find("pruned") != std::string::npos)
|
||||
return BlockProbe::Missing;
|
||||
return BlockProbe::Unknown;
|
||||
}
|
||||
return BlockProbe::Unknown; // RpcTransportError, runtime_error("Not connected"), json errors
|
||||
}
|
||||
|
||||
// call(method, params) -> json, throwing like RPCClient::call. Present = both calls answered.
|
||||
template <typename Call>
|
||||
BlockProbe probeBlockPresence(Call&& call, int height)
|
||||
{
|
||||
try {
|
||||
nlohmann::json h = call("getblockhash", nlohmann::json::array({height}));
|
||||
if (!h.is_string()) return BlockProbe::Unknown;
|
||||
nlohmann::json b = call("getblock", nlohmann::json::array({h.get<std::string>()}));
|
||||
return b.is_null() ? BlockProbe::Unknown : BlockProbe::Present;
|
||||
} catch (const std::exception& e) {
|
||||
return classifyBlockProbeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Assumes block data is present from some base height upward (as the old search did). tip = a height known
|
||||
// to exist (the caller's synced tip). Probe order: 1 (a normal full-history node answers in ONE probe), then
|
||||
// the tip (the search's premise), then a binary search over (1, tip]. Any Unknown ends it with ok=false:
|
||||
// nothing is guessed.
|
||||
inline LowestBlockResult findLowestAvailableBlock(int tip, const std::function<BlockProbe(int)>& probe)
|
||||
{
|
||||
LowestBlockResult out;
|
||||
if (tip <= 1) return out;
|
||||
switch (probe(1)) {
|
||||
case BlockProbe::Present: out.ok = true; out.lowest = 1; out.fullHistory = true; return out;
|
||||
case BlockProbe::Unknown: return out;
|
||||
case BlockProbe::Missing: break;
|
||||
}
|
||||
if (probe(tip) != BlockProbe::Present) return out; // an unreadable tip is as unusable as Unknown
|
||||
int lo = 1, hi = tip; // invariant: lo Missing, hi Present
|
||||
while (hi - lo > 1) {
|
||||
const int mid = lo + (hi - lo) / 2;
|
||||
switch (probe(mid)) {
|
||||
case BlockProbe::Present: hi = mid; break;
|
||||
case BlockProbe::Missing: lo = mid; break;
|
||||
case BlockProbe::Unknown: return out;
|
||||
}
|
||||
}
|
||||
out.ok = true;
|
||||
out.lowest = hi;
|
||||
out.fullHistory = false;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Coalesces mining thread-count picks. The Mining tab reports every intermediate value of a gesture (a tile
|
||||
// drag crosses up to ~24 tiles; the -/+ stepper is clicked repeatedly); only the LAST one should reach the
|
||||
// settings file and the miner, once, when the input settles: on drag release, or kDebounceSec after the
|
||||
// last click. Pure: time is passed in; nothing here touches settings, the miner or ImGui.
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class MiningThreadCommit {
|
||||
public:
|
||||
static constexpr double kDebounceSec = 0.5;
|
||||
|
||||
// The user picked `threads` at `now` (seconds, any monotonic clock). Latest wins.
|
||||
void select(int threads, double now)
|
||||
{
|
||||
target_ = threads;
|
||||
pending_ = true;
|
||||
due_ = now + kDebounceSec;
|
||||
}
|
||||
|
||||
// The drag that produced the pending pick ended: it is due now, not after the debounce.
|
||||
void release(double now)
|
||||
{
|
||||
if (pending_ && due_ > now) due_ = now;
|
||||
}
|
||||
|
||||
void cancel() { pending_ = false; }
|
||||
bool pending() const { return pending_; }
|
||||
|
||||
// Once per frame. `held`: a drag is still in progress (nothing commits until it ends). Returns true
|
||||
// exactly once per settled pick and sets `out` to it.
|
||||
bool take(double now, bool held, int& out)
|
||||
{
|
||||
if (!pending_ || held || now < due_) return false;
|
||||
pending_ = false;
|
||||
out = target_;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
int target_ = 0;
|
||||
bool pending_ = false;
|
||||
double due_ = 0.0;
|
||||
};
|
||||
|
||||
// The pool thread count a wanted count is compared with: the newest start/retarget posted (it includes a
|
||||
// restart still queued on the mining-control thread), else the count the running xmrig was started with,
|
||||
// else what its HTTP API last reported (that lags a restart by seconds, so it is only a last resort).
|
||||
inline int effectivePoolThreads(int queuedTarget, int requested, int apiActive)
|
||||
{
|
||||
if (queuedTarget > 0) return queuedTarget;
|
||||
if (requested > 0) return requested;
|
||||
return apiActive;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "network_refresh_service.h"
|
||||
#include "history_scan_policy.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
@@ -7,9 +8,11 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
@@ -133,20 +136,43 @@ void appendTrackedSendPlaceholder(std::vector<TransactionInfo>& transactions,
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
|
||||
// A row the shielded scans (or z_viewtransaction) produce rather than listtransactions.
|
||||
bool isShieldedRelatedRow(const TransactionInfo& transaction)
|
||||
{
|
||||
return (!transaction.address.empty() && transaction.address[0] == 'z') ||
|
||||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
|
||||
!transaction.memo.empty();
|
||||
}
|
||||
|
||||
void appendMissingPreviousTransactions(std::vector<TransactionInfo>& transactions,
|
||||
const std::vector<TransactionInfo>& previousTransactions,
|
||||
bool includeAll,
|
||||
const std::unordered_set<std::string>& pinnedTxids)
|
||||
{
|
||||
// Presence is decided against the rows built this cycle, before any is carried: deduping against rows
|
||||
// just appended kept only the first leg of a multi-recipient send.
|
||||
std::set<std::pair<std::string, std::string>> present;
|
||||
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
|
||||
for (const auto& transaction : previousTransactions) {
|
||||
bool pinned = pinnedTxids.find(transaction.txid) != pinnedTxids.end();
|
||||
if (!includeAll && !pinned) {
|
||||
bool shieldedRelated = (!transaction.address.empty() && transaction.address[0] == 'z') ||
|
||||
(!transaction.from_address.empty() && transaction.from_address[0] == 'z') ||
|
||||
!transaction.memo.empty();
|
||||
if (!shieldedRelated) continue;
|
||||
}
|
||||
if (hasTransactionType(transactions, transaction.txid, transaction.type)) continue;
|
||||
if (!includeAll && !pinned && !isShieldedRelatedRow(transaction)) continue;
|
||||
if (present.count({transaction.txid, transaction.type})) continue;
|
||||
transactions.push_back(transaction);
|
||||
}
|
||||
}
|
||||
|
||||
// On a full rebuild, keep the stored rows of sends still waiting to be re-enriched. Presence is decided
|
||||
// against the rows built this cycle, before any is carried, so every row of a multi-recipient send is kept
|
||||
// (deduping against rows just appended would keep only the first).
|
||||
void appendPinnedPreviousTransactions(std::vector<TransactionInfo>& transactions,
|
||||
const std::vector<TransactionInfo>& previousTransactions,
|
||||
const std::unordered_set<std::string>& pinnedTxids)
|
||||
{
|
||||
std::set<std::pair<std::string, std::string>> present;
|
||||
for (const auto& transaction : transactions) present.emplace(transaction.txid, transaction.type);
|
||||
for (const auto& transaction : previousTransactions) {
|
||||
if (!pinnedTxids.count(transaction.txid)) continue;
|
||||
if (present.count({transaction.txid, transaction.type})) continue;
|
||||
transactions.push_back(transaction);
|
||||
}
|
||||
}
|
||||
@@ -222,9 +248,21 @@ NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWallet
|
||||
result.ok = false;
|
||||
}
|
||||
}
|
||||
if (walletInfo.contains("txcount") && walletInfo["txcount"].is_number_integer())
|
||||
result.txCount = walletInfo["txcount"].get<int>();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::connectionInfoTipKnown(const ConnectionInfoResult& info)
|
||||
{
|
||||
return info.ok && info.longestChain && *info.longestChain > 0;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::connectionInfoShowsBehind(const ConnectionInfoResult& info)
|
||||
{
|
||||
return connectionInfoTipKnown(info) && info.blocks && *info.blocks < *info.longestChain - 2;
|
||||
}
|
||||
|
||||
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
WarmupPollResult result;
|
||||
@@ -234,6 +272,7 @@ NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPoll
|
||||
result.info = parseConnectionInfoResult(info);
|
||||
} catch (const std::exception& e) {
|
||||
result.errorMessage = e.what();
|
||||
result.errorKind = rpc::rpcFailureKindOf(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -270,6 +309,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
result.shieldedBalance = readBalanceString(totalBalance, "private");
|
||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||
const auto total = totalBalance.find("total");
|
||||
if (total != totalBalance.end() && total->is_string())
|
||||
result.totalBalanceZat = parseMoneyZat(total->get<std::string>());
|
||||
}
|
||||
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
|
||||
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
|
||||
@@ -289,7 +331,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc,
|
||||
const CoreRefreshPlan& plan)
|
||||
{
|
||||
json totalBalance;
|
||||
json spendableBalance;
|
||||
@@ -313,6 +356,25 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
// The mempool's size: with the tip hash and the wallet's mutation epoch, what tells the caller whether a
|
||||
// wallet scan could see anything new. getmempoolinfo takes no cs_main. Failing leaves it unknown, which
|
||||
// counts as changed.
|
||||
bool mempoolFetched = false, mempoolOk = false;
|
||||
std::int64_t mempoolTxs = 0, mempoolBytes = 0;
|
||||
if (plan.fetchMempool && blockOk) {
|
||||
mempoolFetched = true;
|
||||
try {
|
||||
json pool = rpc.call("getmempoolinfo", json::array());
|
||||
if (pool.is_object()) {
|
||||
mempoolOk = true;
|
||||
if (pool.contains("size") && pool["size"].is_number_integer()) mempoolTxs = pool["size"].get<std::int64_t>();
|
||||
if (pool.contains("bytes") && pool["bytes"].is_number_integer()) mempoolBytes = pool["bytes"].get<std::int64_t>();
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getmempoolinfo error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -327,7 +389,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
|
||||
// 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)) {
|
||||
bool balanceAttempted = false;
|
||||
bool spendableQueried = false;
|
||||
if (plan.includeBalance && (!behind || plan.evenIfBehind) && !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.
|
||||
@@ -339,9 +403,27 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
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 (...) {}
|
||||
// A Deferred call was held back by the busy gate and never reached the daemon.
|
||||
balanceAttempted = balanceErrKind != rpc::RpcFailureKind::Deferred;
|
||||
// SPENDABLE total: minconf=1 (confirmed), a second whole-wallet scan. Only after the display call
|
||||
// succeeded (its result is dropped otherwise), and only when it can differ from the display total:
|
||||
// something is unconfirmed or pending (needSpendable), or the exact total moved since the last
|
||||
// poll, which is how a new incoming 0-conf payment first shows. Skipped, spendable equals display
|
||||
// in applyCoreRefreshResult and nothing reads as unconfirmed.
|
||||
if (balanceOk) {
|
||||
std::optional<std::int64_t> totalZat;
|
||||
const auto total = totalBalance.is_object() ? totalBalance.find("total") : totalBalance.end();
|
||||
if (total != totalBalance.end() && total->is_string()) totalZat = parseMoneyZat(total->get<std::string>());
|
||||
const bool totalMoved = !totalZat || !plan.prevTotalZat || *totalZat != *plan.prevTotalZat;
|
||||
// Not on the session's first read while the node is behind: nothing can be sent until it caught
|
||||
// up anyway, and it is a second whole-wallet scan into the catch-up.
|
||||
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
|
||||
spendableQueried = true;
|
||||
try { // If it fails, 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();
|
||||
}
|
||||
@@ -350,9 +432,69 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
result.balanceScanMs = balanceScanMs;
|
||||
result.blockchainErrKind = blockErrKind;
|
||||
result.balanceErrKind = balanceErrKind;
|
||||
result.balanceAttempted = balanceAttempted;
|
||||
result.spendableQueried = spendableQueried;
|
||||
result.mempoolFetched = mempoolFetched;
|
||||
result.mempoolOk = mempoolOk;
|
||||
result.mempoolTxs = mempoolTxs;
|
||||
result.mempoolBytes = mempoolBytes;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<std::int64_t> NetworkRefreshService::parseMoneyZat(const std::string& text)
|
||||
{
|
||||
constexpr std::int64_t kCoin = 100000000;
|
||||
// Leaves room for up to 99999999 zatoshis of fraction on top of whole * 1e8.
|
||||
constexpr std::int64_t kMaxWhole = std::numeric_limits<std::int64_t>::max() / kCoin - 1;
|
||||
std::size_t i = 0;
|
||||
const bool negative = !text.empty() && text[0] == '-';
|
||||
if (negative) ++i;
|
||||
std::int64_t whole = 0;
|
||||
std::size_t wholeDigits = 0;
|
||||
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++wholeDigits) {
|
||||
const int digit = text[i] - '0';
|
||||
if (whole > (kMaxWhole - digit) / 10) return std::nullopt; // the amount would overflow
|
||||
whole = whole * 10 + digit;
|
||||
}
|
||||
if (wholeDigits == 0) return std::nullopt;
|
||||
std::int64_t fraction = 0;
|
||||
if (i < text.size() && text[i] == '.') {
|
||||
++i;
|
||||
std::size_t fractionDigits = 0;
|
||||
for (; i < text.size() && text[i] >= '0' && text[i] <= '9'; ++i, ++fractionDigits) {
|
||||
if (fractionDigits == 8) return std::nullopt; // finer than a zatoshi: not a daemon amount
|
||||
fraction = fraction * 10 + (text[i] - '0');
|
||||
}
|
||||
if (fractionDigits == 0) return std::nullopt;
|
||||
for (; fractionDigits < 8; ++fractionDigits) fraction *= 10;
|
||||
}
|
||||
if (i != text.size()) return std::nullopt;
|
||||
const std::int64_t zat = whole * kCoin + fraction;
|
||||
return negative ? -zat : zat;
|
||||
}
|
||||
|
||||
std::optional<std::int64_t> NetworkRefreshService::nextTotalBaseline(const CoreRefreshResult& result,
|
||||
std::optional<std::int64_t> previous)
|
||||
{
|
||||
if (!result.balanceOk) return previous; // no fresh total
|
||||
if (result.spendableQueried && !result.spendableTotalBalance) return std::nullopt; // re-measure next poll
|
||||
return result.totalBalanceZat;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced)
|
||||
{
|
||||
if (forced || pendingSendWork) return true;
|
||||
if (state.last_balance_update == 0) return true;
|
||||
if (state.unconfirmedBalance > 1e-9) return true;
|
||||
// The address scan saw unconfirmed funds (z_listunspent/listunspent at minconf=0 with confirmations).
|
||||
auto anyUnconfirmed = [](const std::vector<AddressInfo>& addresses) {
|
||||
return std::any_of(addresses.begin(), addresses.end(), [](const AddressInfo& address) {
|
||||
return address.balance - address.spendableBalance > 1e-9;
|
||||
});
|
||||
};
|
||||
return anyUnconfirmed(state.z_addresses) || anyUnconfirmed(state.t_addresses);
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
const json& miningInfo, bool miningOk, const json& localHashrate, bool hashrateOk, double daemonMemoryMb)
|
||||
{
|
||||
@@ -626,9 +768,12 @@ NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddres
|
||||
|
||||
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot)
|
||||
const AddressRefreshSnapshot& snapshot,
|
||||
bool listOnly)
|
||||
{
|
||||
AddressRefreshResult result;
|
||||
result.listOnly = listOnly;
|
||||
|
||||
// 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.
|
||||
@@ -669,9 +814,24 @@ 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
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
|
||||
try {
|
||||
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
|
||||
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
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
|
||||
// The balances need the lists they belong to: none in a list-only pass, and none when an enumeration
|
||||
// failed (the caller discards the whole pass and keeps the previous lists).
|
||||
const bool scanBalances = !listOnly && result.addressListOk;
|
||||
if (scanBalances) try {
|
||||
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
|
||||
@@ -687,36 +847,49 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
|
||||
? nz["rawconfirmations"].get<int>()
|
||||
: nz.value("confirmations", 0);
|
||||
if (nz.contains("address") && nz["address"].is_string()) lite.address = nz["address"].get<std::string>();
|
||||
if (nz.contains("txid") && nz["txid"].is_string()) lite.txid = nz["txid"].get<std::string>();
|
||||
if (nz.contains("change") && nz["change"].is_boolean()) lite.change = nz["change"].get<bool>();
|
||||
result.unspentNotes.push_back(lite);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
const auto kind = rpc::rpcFailureKindOf(e);
|
||||
result.replyTimedOut |= kind == rpc::RpcFailureKind::ReplyTimeout;
|
||||
if (rpc::rpcKindMeansBusy(kind)) {
|
||||
// The node is busy: every z_getbalance would be held back and read 0. Nothing more this pass.
|
||||
DEBUG_LOGF("z_listunspent: node busy (%s)\n", e.what());
|
||||
result.balancesOk = false;
|
||||
result.nodeBusy = true;
|
||||
} else {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
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 (...) {}
|
||||
} catch (...) { result.balancesOk = false; }
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
|
||||
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 {
|
||||
if (scanBalances && !result.nodeBusy) try {
|
||||
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.balancesOk = false;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
// A balance that wasn't read reads 0: never overwrite known balances with that. The pass degrades to
|
||||
// list-only (apply carries the previous balances), and its notes aren't a full view either.
|
||||
if (scanBalances && !result.balancesOk) {
|
||||
result.listOnly = true;
|
||||
result.unspentNotes.clear();
|
||||
}
|
||||
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
@@ -760,6 +933,9 @@ void NetworkRefreshService::appendTransparentTransactions(std::vector<Transactio
|
||||
for (const auto& transactionJson : result) {
|
||||
TransactionInfo info;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "txid")) info.txid = *value;
|
||||
// C266: listtransactions("*") also returns "move" accounting rows (no txid/address/vout) for
|
||||
// every account. They are not wallet transactions - skip so they never become blank History rows.
|
||||
if (info.txid.empty()) continue;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "category")) info.type = *value;
|
||||
if (auto value = readOptional<double>(transactionJson, "amount")) info.amount = *value;
|
||||
if (auto value = readOptional<std::int64_t>(transactionJson, "time")) {
|
||||
@@ -964,10 +1140,13 @@ 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.
|
||||
// Time the whole cycle: listtransactions, the per-address z_listreceivedbyaddress scans and
|
||||
// z_listsentbyaddress are each O(mapWallet) and hold the daemon's cs_main. The measured cost feeds the
|
||||
// caller's throttle (txRefreshDue()) so routine scans can't starve block connection on a large wallet.
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
auto msSince = [](std::chrono::steady_clock::time_point from) {
|
||||
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - from).count();
|
||||
};
|
||||
|
||||
std::set<std::string> knownTxids;
|
||||
HushChatMemoOutputMap hushChatReceivedOutputs;
|
||||
@@ -976,47 +1155,85 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
: nullptr;
|
||||
|
||||
bool transactionRpcError = false;
|
||||
try {
|
||||
constexpr int pageSize = 1000;
|
||||
int skip = 0;
|
||||
while (true) {
|
||||
json transactions = rpc.call("listtransactions", json::array({"", pageSize, skip}));
|
||||
appendTransparentTransactions(result.transactions, knownTxids, transactions, snapshot.miningAddresses);
|
||||
if (!transactions.is_array() || transactions.size() < static_cast<std::size_t>(pageSize)) break;
|
||||
skip += pageSize;
|
||||
|
||||
// ── Transparent rows (listtransactions) ─────────────────────────────────────────────────────────
|
||||
if (snapshot.reuseTransparentRows && !snapshot.previousTransactions.empty()) {
|
||||
// Same tip and nothing changed since the rows on screen were fetched: keep their transparent rows
|
||||
// instead of paging the whole history again. Shielded rows come from the scans below, and rows of
|
||||
// tracked sends and pending ops are rebuilt by enrichment.
|
||||
for (const auto& transaction : snapshot.previousTransactions) {
|
||||
if (isShieldedRelatedRow(transaction)) continue;
|
||||
if (snapshot.sendTxids.count(transaction.txid) || snapshot.pendingOpids.count(transaction.txid)) continue;
|
||||
result.transactions.push_back(transaction);
|
||||
if (!transaction.txid.empty()) knownTxids.insert(transaction.txid);
|
||||
}
|
||||
result.transparentRowsReused = true;
|
||||
} else {
|
||||
try {
|
||||
// The first page holds last time's count plus headroom, so most wallets get every row in one
|
||||
// call (re-paging 1000 rows at a time formats ~rows^2/2000 rows); skip-paging stays the fallback.
|
||||
const int pageSize = transparentPageSize(snapshot.transparentRowHint);
|
||||
std::unordered_set<std::string> seenPagedRows;
|
||||
for (int skip = 0;; skip += pageSize) {
|
||||
json page = rpc.call("listtransactions", json::array({"*", pageSize, skip}));
|
||||
const std::size_t got = page.is_array() ? page.size() : 0;
|
||||
// C267: skip-paging walks a list that can shift between RPCs (a new tx pushes the
|
||||
// previous page's tail into this page's head). De-dup on the daemon's stable per-output
|
||||
// identity (txid, send/non-send class, vout). Key on the send/non-send CLASS, not the
|
||||
// exact category, so a coinbase maturing (immature->generate) between the two fetches is
|
||||
// still recognised as the same row; keep vout so two equal-value outputs of one tx stay
|
||||
// distinct. Rows without txid or vout ("move") are never keyed and pass through
|
||||
// (appendTransparentTransactions drops them anyway).
|
||||
json uniq = json::array();
|
||||
if (page.is_array()) {
|
||||
for (const auto& row : page) {
|
||||
auto txid = readOptional<std::string>(row, "txid");
|
||||
auto vout = readOptional<int>(row, "vout");
|
||||
if (txid && !txid->empty() && vout) {
|
||||
const bool isSend =
|
||||
readOptional<std::string>(row, "category").value_or("") == "send";
|
||||
std::string key = *txid + '|' + (isSend ? 's' : 'r') + '|' + std::to_string(*vout);
|
||||
if (!seenPagedRows.insert(std::move(key)).second) continue; // true duplicate
|
||||
}
|
||||
uniq.push_back(row);
|
||||
}
|
||||
}
|
||||
appendTransparentTransactions(result.transactions, knownTxids, uniq, snapshot.miningAddresses);
|
||||
result.transparentRowCount += got; // RAW page size: drives the stop test + next row hint
|
||||
if (got < static_cast<std::size_t>(pageSize)) break;
|
||||
}
|
||||
result.transparentChainOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
// An address counts as "scanned at tip" if its last scan is within shieldedScanTipTolerance
|
||||
// blocks of the current tip. Tolerance 0 means strict (exact tip); a small tolerance lets a
|
||||
// multi-cycle pass over many addresses complete even as the tip advances during it.
|
||||
const int scanStaleThreshold = currentBlockHeight - std::max(0, snapshot.shieldedScanTipTolerance);
|
||||
auto scannedAtTip = [&](const std::string& address) {
|
||||
if (currentBlockHeight < 0) return false;
|
||||
auto it = result.shieldedScanHeights.find(address);
|
||||
return it != result.shieldedScanHeights.end() && it->second >= scanStaleThreshold;
|
||||
// ── Shielded receives (z_listreceivedbyaddress, one per z-address) ──────────────────────────────
|
||||
// A pass re-scans each address once: one scanned below the pass floor (or never) needs a scan, so a
|
||||
// pass over many addresses completes however the tip moves meanwhile. Priority addresses go first.
|
||||
const int scanFloor = snapshot.shieldedScanFloorHeight;
|
||||
auto needsPassScan = [&](const std::string& address) {
|
||||
const auto it = result.shieldedScanHeights.find(address);
|
||||
if (it == result.shieldedScanHeights.end()) return true;
|
||||
return scanFloor >= 0 && it->second < scanFloor;
|
||||
};
|
||||
|
||||
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
|
||||
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
|
||||
std::size_t nextSearchIndex = shieldedStart;
|
||||
|
||||
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
|
||||
const auto& address = snapshot.shieldedAddresses[index];
|
||||
if (scannedAtTip(address)) {
|
||||
nextSearchIndex = index + 1;
|
||||
continue;
|
||||
}
|
||||
if (snapshot.maxShieldedReceiveScans > 0 &&
|
||||
result.shieldedAddressesScanned >= snapshot.maxShieldedReceiveScans) {
|
||||
nextSearchIndex = index;
|
||||
break;
|
||||
}
|
||||
auto needsPriorityScan = [&](const std::string& address) {
|
||||
const auto priority = snapshot.priorityScanFloors.find(address);
|
||||
if (priority == snapshot.priorityScanFloors.end()) return false;
|
||||
const auto it = result.shieldedScanHeights.find(address);
|
||||
return it == result.shieldedScanHeights.end() || it->second < priority->second;
|
||||
};
|
||||
auto budgetLeft = [&]() {
|
||||
return snapshot.maxShieldedReceiveScans == 0 ||
|
||||
result.shieldedAddressesScanned < snapshot.maxShieldedReceiveScans;
|
||||
};
|
||||
std::unordered_set<std::string> scannedNow;
|
||||
auto scanAddress = [&](const std::string& address) {
|
||||
++result.shieldedAddressesScanned;
|
||||
nextSearchIndex = index + 1;
|
||||
scannedNow.insert(address);
|
||||
const auto callStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
|
||||
appendShieldedReceivedTransactions(result.transactions,
|
||||
@@ -1025,11 +1242,37 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
received,
|
||||
snapshot.miningAddresses,
|
||||
hushChatReceivedOutputsPtr);
|
||||
++result.shieldedScansSucceeded;
|
||||
result.receiveScanOkMs += msSince(callStart);
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
};
|
||||
|
||||
std::size_t shieldedStart = snapshot.shieldedScanStartIndex;
|
||||
if (shieldedStart >= snapshot.shieldedAddresses.size()) shieldedStart = 0;
|
||||
std::size_t nextSearchIndex = shieldedStart;
|
||||
if (!snapshot.skipShieldedScans) {
|
||||
for (const auto& address : snapshot.shieldedAddresses) {
|
||||
if (!budgetLeft()) break;
|
||||
if (needsPriorityScan(address)) scanAddress(address);
|
||||
}
|
||||
for (std::size_t index = shieldedStart; index < snapshot.shieldedAddresses.size(); ++index) {
|
||||
const auto& address = snapshot.shieldedAddresses[index];
|
||||
if (!needsPassScan(address) || scannedNow.count(address)) {
|
||||
nextSearchIndex = index + 1;
|
||||
continue;
|
||||
}
|
||||
if (!budgetLeft()) {
|
||||
nextSearchIndex = index;
|
||||
break;
|
||||
}
|
||||
nextSearchIndex = index + 1;
|
||||
scanAddress(address);
|
||||
}
|
||||
}
|
||||
|
||||
auto findPendingShieldedIndex = [&]() -> std::optional<std::size_t> {
|
||||
@@ -1037,7 +1280,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
std::size_t start = nextSearchIndex < snapshot.shieldedAddresses.size() ? nextSearchIndex : 0;
|
||||
for (std::size_t offset = 0; offset < snapshot.shieldedAddresses.size(); ++offset) {
|
||||
std::size_t index = (start + offset) % snapshot.shieldedAddresses.size();
|
||||
if (!scannedAtTip(snapshot.shieldedAddresses[index])) return index;
|
||||
if (needsPassScan(snapshot.shieldedAddresses[index])) return index;
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
@@ -1046,11 +1289,11 @@ 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).
|
||||
// ── Outgoing shielded sends (z_listsentbyaddress) ───────────────────────────────────────────────
|
||||
// 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 only reaches `knownTxids`, and so the
|
||||
// z_viewtransaction enrichment below, through this call.
|
||||
//
|
||||
// `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
|
||||
@@ -1064,13 +1307,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
// 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()) {
|
||||
// It trial-decrypts every send it returns under cs_main, the heaviest call here on a wallet with many
|
||||
// sends. So it runs once a pass is complete and, past a successful sweep (the caller's watermark), only
|
||||
// over the txs at most a little deeper than the blocks since then (filter type 2). The whole-wallet
|
||||
// sweep, needed when there's no watermark, gets a long timeout so it can finish on a large wallet.
|
||||
const bool sweepIsFull = sentSweepIsFull(currentBlockHeight, snapshot.sentScanFromHeight);
|
||||
if (!snapshot.shieldedAddresses.empty() && !snapshot.skipSentSweep &&
|
||||
(result.shieldedScanComplete || (snapshot.allowMidPassSentSweep && !sweepIsFull))) {
|
||||
result.sentSweepRan = true;
|
||||
result.sentSweepFull = sweepIsFull;
|
||||
const json params = sentSweepParams(snapshot.shieldedAddresses.front(), currentBlockHeight,
|
||||
snapshot.sentScanFromHeight);
|
||||
const auto sweepStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
json sent = rpc.call("z_listsentbyaddress",
|
||||
json::array({snapshot.shieldedAddresses.front(), 0}));
|
||||
json sent = result.sentSweepFull
|
||||
? rpc.callLong("z_listsentbyaddress", params, kFullSentSweepTimeoutSec)
|
||||
: rpc.call("z_listsentbyaddress", params);
|
||||
if (sent.is_array()) {
|
||||
for (const auto& sentTx : sent) {
|
||||
auto txid = readOptional<std::string>(sentTx, "txid");
|
||||
@@ -1078,11 +1330,16 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
|
||||
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
|
||||
}
|
||||
result.sentSweepOk = true;
|
||||
result.sentSweepHeight = currentBlockHeight;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
result.sentSweepErrKind = rpc::rpcFailureKindOf(e);
|
||||
result.replyTimedOut |= result.sentSweepErrKind == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||
}
|
||||
result.sentSweepMs = msSince(sweepStart);
|
||||
}
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
@@ -1093,8 +1350,45 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
if (!cachedView.outgoing_outputs.empty()) knownTxids.insert(txid);
|
||||
}
|
||||
|
||||
int viewTxCount = 0;
|
||||
// Shielded send rows from an earlier session (the history cache) whose view isn't cached: they count as
|
||||
// enriched by their age, so nothing would rebuild them, and a windowed sweep never rediscovers an old
|
||||
// send. Re-enrich them, and keep their stored rows ("pinned") until the daemon has answered.
|
||||
std::unordered_set<std::string> rowBacklog;
|
||||
// A settled stored row's block time and confirmations (>= 10, the History's "confirmed"): reused for
|
||||
// its re-enrichment instead of a gettransaction per row. An unsettled one still asks, or its stale
|
||||
// count would stick for the session (a cached view keeps the metadata it was built with).
|
||||
std::unordered_map<std::string, std::pair<std::int64_t, int>> settledStoredMeta;
|
||||
for (const auto& transaction : snapshot.previousTransactions) {
|
||||
if (transaction.type != "send" || transaction.txid.empty() || !isShieldedRelatedRow(transaction)) continue;
|
||||
if (snapshot.viewTxCache.count(transaction.txid) || snapshot.sendTxids.count(transaction.txid) ||
|
||||
snapshot.pendingOpids.count(transaction.txid)) continue;
|
||||
rowBacklog.insert(transaction.txid);
|
||||
if (transaction.timestamp != 0 && transaction.confirmations >= 10)
|
||||
settledStoredMeta.emplace(transaction.txid, std::make_pair(transaction.timestamp, transaction.confirmations));
|
||||
}
|
||||
std::set<std::string> backlog(snapshot.enrichBacklogTxids.begin(), snapshot.enrichBacklogTxids.end());
|
||||
backlog.insert(rowBacklog.begin(), rowBacklog.end());
|
||||
std::unordered_set<std::string> pinnedRows = rowBacklog;
|
||||
|
||||
// Tracked sends go first so they get the per-cycle z_viewtransaction budget ahead of routine
|
||||
// enrichment, then this cycle's discoveries, then the backlog. A spent budget only skips further
|
||||
// RPCs: cached rows later in the order are still appended (stopping the loop at the cap dropped them
|
||||
// from History), and what was skipped is handed back as unenrichedTxids.
|
||||
std::vector<const std::string*> enrichOrder;
|
||||
enrichOrder.reserve(knownTxids.size() + backlog.size());
|
||||
for (const auto& txid : knownTxids) {
|
||||
if (snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
|
||||
}
|
||||
for (const auto& txid : knownTxids) {
|
||||
if (!snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
|
||||
}
|
||||
for (const auto& txid : backlog) {
|
||||
if (!knownTxids.count(txid) && !snapshot.sendTxids.count(txid)) enrichOrder.push_back(&txid);
|
||||
}
|
||||
|
||||
int viewTxCount = 0;
|
||||
for (const std::string* txidPtr : enrichOrder) {
|
||||
const std::string& txid = *txidPtr;
|
||||
bool trackedSend = snapshot.sendTxids.count(txid) > 0;
|
||||
|
||||
auto cached = snapshot.viewTxCache.find(txid);
|
||||
@@ -1109,9 +1403,12 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
}
|
||||
}
|
||||
|
||||
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid)) continue;
|
||||
if (!trackedSend && snapshot.fullyEnrichedTxids.count(txid) && !rowBacklog.count(txid)) continue;
|
||||
|
||||
if (viewTxCount >= maxViewTransactionsPerCycle) break;
|
||||
if (viewTxCount >= maxViewTransactionsPerCycle) {
|
||||
if (!trackedSend) result.unenrichedTxids.insert(txid);
|
||||
continue;
|
||||
}
|
||||
++viewTxCount;
|
||||
|
||||
try {
|
||||
@@ -1123,11 +1420,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
appendTrackedSendPlaceholder(result.transactions,
|
||||
txid,
|
||||
hasRawTransaction ? &rawTransaction : nullptr);
|
||||
} else {
|
||||
result.unenrichedTxids.insert(txid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||
if (storedRow && entry.timestamp == 0) {
|
||||
const auto settled = settledStoredMeta.find(txid);
|
||||
if (settled != settledStoredMeta.end()) {
|
||||
entry.timestamp = settled->second.first;
|
||||
entry.confirmations = settled->second.second;
|
||||
}
|
||||
}
|
||||
const std::size_t rowsBeforeView = result.transactions.size();
|
||||
appendViewTransactionOutputs(result.transactions, txid, entry);
|
||||
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
|
||||
// the cached-view branch above.)
|
||||
@@ -1153,6 +1461,21 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
}
|
||||
}
|
||||
|
||||
if (storedRow) {
|
||||
// A stored send rebuilt without a date (gettransaction failed): keep its stored rows (still
|
||||
// pinned) and retry next cycle, rather than cache an undated view for the session.
|
||||
bool undated = false;
|
||||
for (std::size_t i = rowsBeforeView; i < result.transactions.size(); ++i) {
|
||||
if (result.transactions[i].txid == txid && result.transactions[i].timestamp == 0) undated = true;
|
||||
}
|
||||
if (undated) {
|
||||
result.transactions.resize(rowsBeforeView);
|
||||
result.unenrichedTxids.insert(txid);
|
||||
continue;
|
||||
}
|
||||
pinnedRows.erase(txid);
|
||||
}
|
||||
|
||||
if (trackedSend && !hasTransactionType(result.transactions, txid, "send")) {
|
||||
if (!hasRawTransaction) hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
|
||||
appendTrackedSendPlaceholder(result.transactions,
|
||||
@@ -1162,15 +1485,40 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
|
||||
if (!trackedSend || !entry.outgoing_outputs.empty()) {
|
||||
result.newViewTxEntries[txid] = std::move(entry);
|
||||
} else {
|
||||
// A tracked op that only paid the wallet itself never shows an outgoing output, so the
|
||||
// check above kept it tracked (and forcing history refreshes past the scan throttle on
|
||||
// every tab) for the rest of the session. While it's unconfirmed the empty result stays
|
||||
// retryable; once it is mined, or conflicted, the view result is final: cache it and
|
||||
// stop tracking. Confirmations come from this txid's rows (listtransactions, or the
|
||||
// gettransaction-backed placeholder) and any raw metadata applied to the entry.
|
||||
bool mined = entry.confirmations >= 1;
|
||||
bool conflicted = entry.confirmations < 0;
|
||||
for (const auto& info : result.transactions) {
|
||||
if (info.txid != txid) continue;
|
||||
if (info.confirmations >= 1) mined = true;
|
||||
else if (info.confirmations < 0) conflicted = true;
|
||||
}
|
||||
if (mined || conflicted) {
|
||||
result.newViewTxEntries[txid] = std::move(entry);
|
||||
result.resolvedSendTxids.insert(txid);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
(void)e;
|
||||
if (trackedSend) {
|
||||
json rawTransaction;
|
||||
bool hasRawTransaction = tryFetchRawTransaction(rpc, txid, rawTransaction);
|
||||
appendTrackedSendPlaceholder(result.transactions,
|
||||
txid,
|
||||
hasRawTransaction ? &rawTransaction : nullptr);
|
||||
} else if (const auto* rpcError = dynamic_cast<const rpc::RpcError*>(&e);
|
||||
rpcError && rpcError->code == -5) {
|
||||
// "Invalid or non-wallet transaction id": definitive. Cache an empty view so it isn't
|
||||
// asked again, and let a stored row for it go.
|
||||
result.newViewTxEntries[txid] = TransactionViewCacheEntry{};
|
||||
pinnedRows.erase(txid);
|
||||
} else {
|
||||
result.unenrichedTxids.insert(txid); // anything else may be transient: retry
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1178,17 +1526,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
|
||||
|
||||
if (!snapshot.previousTransactions.empty()) {
|
||||
std::unordered_set<std::string> pinned = snapshot.pendingOpids;
|
||||
pinned.insert(pinnedRows.begin(), pinnedRows.end());
|
||||
if (transactionRpcError) {
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, snapshot.pendingOpids);
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, true, pinned);
|
||||
} else if (snapshot.shieldedAddresses.empty() || !result.shieldedScanComplete ||
|
||||
result.shieldedAddressesScanned < result.shieldedAddressCount) {
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, snapshot.pendingOpids);
|
||||
appendMissingPreviousTransactions(result.transactions, snapshot.previousTransactions, false, pinned);
|
||||
} else if (!pinnedRows.empty()) {
|
||||
// A full rebuild, except the stored rows of sends still waiting to be re-enriched.
|
||||
appendPinnedPreviousTransactions(result.transactions, snapshot.previousTransactions, pinnedRows);
|
||||
}
|
||||
}
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - scanStart).count();
|
||||
result.scanMs = msSince(scanStart);
|
||||
result.rpcError = transactionRpcError;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1199,6 +1552,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
int pageSize)
|
||||
{
|
||||
TransactionRefreshResult result;
|
||||
const auto scanStart = std::chrono::steady_clock::now();
|
||||
result.blockHeight = currentBlockHeight;
|
||||
result.transactions = snapshot.previousTransactions;
|
||||
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
||||
@@ -1227,9 +1581,37 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
try {
|
||||
std::set<std::string> recentTxids;
|
||||
std::vector<TransactionInfo> recentTransactions;
|
||||
json transactions = rpc.call("listtransactions", json::array({"", pageSize, 0}));
|
||||
json transactions = rpc.call("listtransactions", json::array({"*", pageSize, 0}));
|
||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||
|
||||
// A coinbase changes category in place (immature -> generate at maturity, or -> orphan on a reorg): drop
|
||||
// the previous row of the OTHER category before merging, or both stay and the earnings double count
|
||||
// (C119). Note "mined" (a transparent receive remapped to a mining address) is NOT a coinbase category
|
||||
// and never transitions, so it is intentionally excluded.
|
||||
{
|
||||
const auto coinbaseType = [](const std::string& t) {
|
||||
return t == "generate" || t == "immature" || t == "orphan";
|
||||
};
|
||||
std::unordered_map<std::string, std::string> freshCoinbase; // txid -> its fresh category
|
||||
for (const auto& recent : recentTransactions)
|
||||
if (coinbaseType(recent.type)) freshCoinbase[recent.txid] = recent.type;
|
||||
if (!freshCoinbase.empty()) {
|
||||
const auto before = result.transactions.size();
|
||||
result.transactions.erase(
|
||||
std::remove_if(result.transactions.begin(), result.transactions.end(),
|
||||
[&](const TransactionInfo& t) {
|
||||
if (!coinbaseType(t.type)) return false;
|
||||
const auto it = freshCoinbase.find(t.txid);
|
||||
return it != freshCoinbase.end() && it->second != t.type;
|
||||
}),
|
||||
result.transactions.end());
|
||||
if (result.transactions.size() != before) {
|
||||
byKey.clear();
|
||||
for (std::size_t i = 0; i < result.transactions.size(); ++i)
|
||||
byKey.emplace(txKey(result.transactions[i]), i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto& recent : recentTransactions) {
|
||||
auto it = byKey.find(txKey(recent));
|
||||
if (it != byKey.end()) {
|
||||
@@ -1240,6 +1622,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
result.rpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
@@ -1249,9 +1633,12 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
if (snapshot.maxShieldedReceiveScans > 0) {
|
||||
shieldedLimit = std::min(shieldedLimit, snapshot.maxShieldedReceiveScans);
|
||||
}
|
||||
// Without the probe (the scan throttle is closed) the poll is listtransactions only.
|
||||
if (snapshot.skipShieldedScans) shieldedLimit = 0;
|
||||
|
||||
for (std::size_t index = shieldedStart; index < shieldedStart + shieldedLimit; ++index) {
|
||||
const auto& address = snapshot.shieldedAddresses[index];
|
||||
const auto callStart = std::chrono::steady_clock::now();
|
||||
try {
|
||||
std::vector<TransactionInfo> scannedTransactions;
|
||||
std::set<std::string> scannedTxids;
|
||||
@@ -1273,18 +1660,29 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
||||
}
|
||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||
++result.shieldedAddressesScanned;
|
||||
++result.shieldedScansSucceeded;
|
||||
result.receiveScanOkMs += std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - callStart).count();
|
||||
} catch (const std::exception& e) {
|
||||
++result.shieldedAddressesScanned;
|
||||
result.rpcError = true;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
DEBUG_LOGF("recent z_listreceivedbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
result.nextShieldedScanStartIndex =
|
||||
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
|
||||
if (snapshot.skipShieldedScans) {
|
||||
result.nextShieldedScanStartIndex = shieldedStart;
|
||||
} else {
|
||||
result.nextShieldedScanStartIndex =
|
||||
(shieldedStart + shieldedLimit >= snapshot.shieldedAddresses.size()) ? 0 : shieldedStart + shieldedLimit;
|
||||
}
|
||||
result.shieldedScanComplete = true;
|
||||
|
||||
appendExtractedHushChatMetadata(result.hushChatMetadata, hushChatReceivedOutputs);
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - scanStart).count();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1295,9 +1693,10 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
||||
OperationStatusPollResult parsed;
|
||||
if (!result.is_array()) return parsed;
|
||||
|
||||
// We poll z_getoperationstatus with no filter (this daemon rejects the per-opid filtered form),
|
||||
// so the result lists ALL operations. Only act on the ones we're tracking — otherwise an
|
||||
// unrelated failed/old operation would fire a spurious error toast or pollute send state.
|
||||
// The poll is filtered to our opids (buildOperationFilterParams), but falls back to the unfiltered
|
||||
// form, which lists ALL operations, if a daemon rejects the filter. Only act on the ones we're
|
||||
// tracking — otherwise an unrelated failed/old operation would fire a spurious error toast or
|
||||
// pollute send state.
|
||||
const std::set<std::string> requested(requestedOpids.begin(), requestedOpids.end());
|
||||
|
||||
std::set<std::string> reported;
|
||||
@@ -1347,6 +1746,51 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
||||
return parsed;
|
||||
}
|
||||
|
||||
std::optional<json> NetworkRefreshService::buildOperationFilterParams(const std::vector<std::string>& opids)
|
||||
{
|
||||
json ids = json::array();
|
||||
for (const auto& opid : opids) {
|
||||
if (!opid.empty()) ids.push_back(opid);
|
||||
}
|
||||
if (ids.empty()) return std::nullopt;
|
||||
json params = json::array();
|
||||
params.push_back(std::move(ids));
|
||||
return params;
|
||||
}
|
||||
|
||||
NetworkRefreshService::OperationBatchPlan NetworkRefreshService::planOperationBatch(
|
||||
const OperationStatusPollResult& parsed, const std::unordered_map<std::string, OperationOrigin>& origins)
|
||||
{
|
||||
auto originOf = [&](const std::string& id) {
|
||||
const auto it = origins.find(id);
|
||||
return it == origins.end() ? OperationOrigin::User : it->second;
|
||||
};
|
||||
OperationBatchPlan plan;
|
||||
auto terminal = [&](const std::string& id) {
|
||||
if (originOf(id) == OperationOrigin::Chat) plan.chatTerminal = true;
|
||||
else plan.walletTerminal = true;
|
||||
};
|
||||
for (const auto& id : parsed.doneOpids) { // success, failed or cancelled
|
||||
terminal(id);
|
||||
if (parsed.failureByOpid.count(id)) continue; // failed: nothing committed
|
||||
const auto origin = originOf(id);
|
||||
if (origin == OperationOrigin::User) plan.userSucceeded = true; // a success without a txid too
|
||||
else if (origin == OperationOrigin::Auto) plan.autoSucceeded = true;
|
||||
}
|
||||
for (const auto& id : parsed.staleOpids) terminal(id);
|
||||
return plan;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::anyUserOperationPending(const std::vector<std::string>& pendingOpids,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins)
|
||||
{
|
||||
for (const auto& id : pendingOpids) {
|
||||
const auto it = origins.find(id);
|
||||
if (it == origins.end() || it->second == OperationOrigin::User) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
|
||||
{
|
||||
if (!result.ok) return;
|
||||
@@ -1374,6 +1818,7 @@ void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, cons
|
||||
state.unlocked_until = 0;
|
||||
}
|
||||
state.encryption_state_known = true;
|
||||
if (result.txCount) state.wallet_tx_count = *result.txCount;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
|
||||
@@ -1425,8 +1870,10 @@ void NetworkRefreshService::applyMiningRefreshResult(WalletState& state,
|
||||
if (result.localHashrate) {
|
||||
state.mining.localHashrate = *result.localHashrate;
|
||||
state.mining.hashrate_history.push_back(state.mining.localHashrate);
|
||||
state.mining.hashrate_history_t.push_back(updatedAt);
|
||||
if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
|
||||
state.mining.hashrate_history.erase(state.mining.hashrate_history.begin());
|
||||
state.mining.hashrate_history_t.erase(state.mining.hashrate_history_t.begin());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1490,6 +1937,22 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
|
||||
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result)
|
||||
{
|
||||
if (result.listOnly) {
|
||||
// No balance scan ran: keep each address's last known balances (a new address shows 0 until the
|
||||
// next full pass).
|
||||
auto carry = [](std::vector<AddressInfo>& fresh, const std::vector<AddressInfo>& previous) {
|
||||
std::unordered_map<std::string, const AddressInfo*> known;
|
||||
for (const auto& info : previous) known[info.address] = &info;
|
||||
for (auto& info : fresh) {
|
||||
const auto it = known.find(info.address);
|
||||
if (it == known.end()) continue;
|
||||
info.balance = it->second->balance;
|
||||
info.spendableBalance = it->second->spendableBalance;
|
||||
}
|
||||
};
|
||||
carry(result.shieldedAddresses, state.z_addresses);
|
||||
carry(result.transparentAddresses, state.t_addresses);
|
||||
}
|
||||
state.z_addresses = std::move(result.shieldedAddresses);
|
||||
state.t_addresses = std::move(result.transparentAddresses);
|
||||
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
|
||||
@@ -1504,14 +1967,14 @@ void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
|
||||
TransactionRefreshResult&& result,
|
||||
std::time_t updatedAt)
|
||||
{
|
||||
state.transactions = std::move(result.transactions);
|
||||
state.setTransactionsIfChanged(std::move(result.transactions)); // bumps tx_revision only on a real change
|
||||
state.last_tx_update = updatedAt;
|
||||
cacheUpdate.lastTxBlockHeight = result.blockHeight;
|
||||
|
||||
for (auto& [txid, entry] : result.newViewTxEntries) {
|
||||
bool hasOutgoingOutputs = !entry.outgoing_outputs.empty();
|
||||
cacheUpdate.viewTxCache[txid] = std::move(entry);
|
||||
if (hasOutgoingOutputs) cacheUpdate.sendTxids.erase(txid);
|
||||
if (hasOutgoingOutputs || result.resolvedSendTxids.count(txid)) cacheUpdate.sendTxids.erase(txid);
|
||||
}
|
||||
|
||||
cacheUpdate.confirmedTxCache.clear();
|
||||
|
||||
@@ -35,6 +35,13 @@ public:
|
||||
virtual ~RefreshRpcGateway() = default;
|
||||
virtual nlohmann::json call(const std::string& method,
|
||||
const nlohmann::json& params) = 0;
|
||||
// A call allowed to outlast the default timeout (a whole-wallet sweep on a large wallet).
|
||||
// Gateways without a separate timeout just call().
|
||||
virtual nlohmann::json callLong(const std::string& method, const nlohmann::json& params, long timeoutSec)
|
||||
{
|
||||
(void)timeoutSec;
|
||||
return call(method, params);
|
||||
}
|
||||
};
|
||||
|
||||
enum class Job {
|
||||
@@ -84,12 +91,16 @@ public:
|
||||
bool ok = false;
|
||||
bool encrypted = false;
|
||||
std::int64_t unlockedUntil = 0;
|
||||
std::optional<int> txCount; // getwalletinfo's txcount (the wallet's transactions)
|
||||
};
|
||||
|
||||
struct WarmupPollResult {
|
||||
bool ready = false;
|
||||
ConnectionInfoResult info;
|
||||
std::string errorMessage;
|
||||
// Why the poll failed: a -28 warmup reply means the node is there; a refused/reset connection
|
||||
// means it's gone (the caller counts those toward its lost-connection check).
|
||||
rpc::RpcFailureKind errorKind = rpc::RpcFailureKind::None;
|
||||
};
|
||||
|
||||
struct ConnectionInitResult {
|
||||
@@ -117,6 +128,27 @@ public:
|
||||
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
|
||||
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
// The minconf=0 call reached the daemon (it may still have failed). False when the balance was
|
||||
// skipped (behind, busy chain read) or held back by the busy gate (Deferred) before it was sent.
|
||||
bool balanceAttempted = false;
|
||||
bool spendableQueried = false; // the minconf=1 call was made
|
||||
std::optional<std::int64_t> totalBalanceZat; // the minconf=0 "total" in zatoshis, exact
|
||||
// getmempoolinfo (CoreRefreshPlan::fetchMempool): a change signal that takes no cs_main.
|
||||
bool mempoolFetched = false;
|
||||
bool mempoolOk = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
};
|
||||
|
||||
// What one Core refresh fetches. The minconf=1 (spendable) z_gettotalbalance is a second whole-wallet
|
||||
// scan under cs_main; it only tells something the minconf=0 call doesn't while funds are unconfirmed
|
||||
// or pending, so it runs only when needSpendable or the exact total moved since prevTotalZat.
|
||||
struct CoreRefreshPlan {
|
||||
bool includeBalance = true;
|
||||
bool needSpendable = true;
|
||||
std::optional<std::int64_t> prevTotalZat;
|
||||
bool fetchMempool = false; // add getmempoolinfo after a successful getblockchaininfo
|
||||
bool evenIfBehind = false; // the session's first balance read: also while the node is behind
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -158,6 +190,9 @@ public:
|
||||
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)
|
||||
std::string address; // the z-address holding it
|
||||
std::string txid; // the transaction that created it
|
||||
bool change = false; // change from the wallet's own spend (not a receive)
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
@@ -173,6 +208,12 @@ public:
|
||||
// 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;
|
||||
bool balancesOk = true; // false: a balance call failed, the per-address balances may be stale
|
||||
bool replyTimedOut = false; // a call hit the reply timeout (the daemon kept running it)
|
||||
// The lists only: no z_listunspent / listunspent, or a balance call failed (the pass then degrades to
|
||||
// list-only). Applying it carries the previous balances over.
|
||||
bool listOnly = false;
|
||||
bool nodeBusy = false; // a call failed because the node was busy (timeout, full queue, held back)
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
@@ -204,14 +245,25 @@ public:
|
||||
std::set<std::string> miningAddresses;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
std::size_t shieldedScanStartIndex = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0;
|
||||
// How many blocks the tip may advance past an address's last scan before it counts as stale
|
||||
// and needs re-scanning. 0 = strict (must be scanned at the exact current tip). A small
|
||||
// tolerance lets a multi-cycle pass over many shielded addresses COMPLETE even though new
|
||||
// blocks arrive mid-pass — otherwise the "scanned at tip" bar moves every block and the scan
|
||||
// (and the transactions_dirty_ flag it drives) never finishes. It also naturally throttles
|
||||
// full rescans to roughly once per `tolerance` blocks.
|
||||
int shieldedScanTipTolerance = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0; // receive scans this cycle; 0 = no limit
|
||||
// The pass's floor (services::ShieldedHistoryPass): an address scanned below it is scanned again.
|
||||
// -1: only never-scanned addresses (and priority ones) are.
|
||||
int shieldedScanFloorHeight = -1;
|
||||
// Scanned first, whatever the cursor, when never scanned or scanned below their floor: new
|
||||
// addresses, the chat reply address, addresses holding notes the history doesn't show yet.
|
||||
std::unordered_map<std::string, int> priorityScanFloors;
|
||||
bool skipShieldedScans = false; // a Light cycle (or a probe-less recent poll): no receive scans
|
||||
bool skipSentSweep = false;
|
||||
int sentScanFromHeight = -1; // the sweep's watermark; -1 = sweep the whole wallet
|
||||
std::size_t transparentRowHint = 0; // listtransactions rows last time: sizes the first page
|
||||
// Same tip and nothing changed since the rows on screen were fetched: reuse their transparent rows
|
||||
// instead of paging listtransactions again.
|
||||
bool reuseTransparentRows = false;
|
||||
std::unordered_set<std::string> enrichBacklogTxids; // discovered earlier, not enriched yet
|
||||
// Run a windowed sent sweep even though the receive pass isn't complete (the caller rate-limits
|
||||
// this), so untracked sends (from the Console, or another wallet on the same seed) don't wait for a
|
||||
// whole pass. The whole-wallet sweep stays tied to a completed pass.
|
||||
bool allowMidPassSentSweep = false;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
@@ -219,14 +271,34 @@ public:
|
||||
std::vector<chat::HushChatTransactionMetadata> hushChatMetadata;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
// Tracked sends whose view result is final although it shows no outgoing output: the op only
|
||||
// paid the wallet itself (shield, merge/consolidate, auto-shield, chat note split, a transfer
|
||||
// between own addresses) and is now mined or conflicted. Applying the result stops tracking them.
|
||||
std::unordered_set<std::string> resolvedSendTxids;
|
||||
std::size_t nextShieldedScanStartIndex = 0;
|
||||
std::size_t shieldedAddressesScanned = 0;
|
||||
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()).
|
||||
// Wall-clock spent in the whole cycle: listtransactions, the receive scans and the sent sweep each
|
||||
// hold cs_main. Lets the caller throttle wallet-wide scans by their measured cost (txRefreshDue()).
|
||||
double scanMs = 0.0;
|
||||
std::size_t shieldedScansSucceeded = 0; // shieldedAddressesScanned counts attempts
|
||||
double receiveScanOkMs = 0.0; // time in the receive scans that succeeded
|
||||
std::size_t transparentRowCount = 0; // rows the listtransactions chain returned
|
||||
bool transparentChainOk = false;
|
||||
bool transparentRowsReused = false;
|
||||
bool sentSweepRan = false;
|
||||
bool sentSweepOk = false;
|
||||
bool sentSweepFull = false;
|
||||
int sentSweepHeight = -1; // the tip a successful sweep covered
|
||||
double sentSweepMs = 0.0; // time in the sweep (part of scanMs)
|
||||
rpc::RpcFailureKind sentSweepErrKind = rpc::RpcFailureKind::None;
|
||||
// Txids found but not enriched (over the per-cycle cap, or the view failed transiently): the caller
|
||||
// keeps them and feeds them back as enrichBacklogTxids.
|
||||
std::unordered_set<std::string> unenrichedTxids;
|
||||
bool rpcError = false; // a wallet-wide call failed
|
||||
bool replyTimedOut = false; // ...with a reply timeout (the daemon kept running it)
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
@@ -239,6 +311,16 @@ public:
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
// Who started a tracked async operation, i.e. what its completion re-reads (App::markWalletMutated).
|
||||
// Absent from the App's origin map = User.
|
||||
enum class OperationOrigin : std::uint8_t { User, Auto, Chat };
|
||||
struct OperationBatchPlan {
|
||||
bool userSucceeded = false; // >= 1 User op succeeded: re-read the wallet, force the balance
|
||||
bool autoSucceeded = false; // >= 1 Auto op (client auto-shield) succeeded: re-read, unforced
|
||||
bool walletTerminal = false; // >= 1 non-chat op finished (any outcome) or went stale
|
||||
bool chatTerminal = false; // >= 1 chat op finished or went stale: refresh the chat note view
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
@@ -251,6 +333,10 @@ public:
|
||||
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
|
||||
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
// The node knows the chain tip: longestchain stays 0 after a start until its first UpdateTip.
|
||||
static bool connectionInfoTipKnown(const ConnectionInfoResult& info);
|
||||
// The tip is known and the node is more than 2 blocks behind it (the sync tolerance).
|
||||
static bool connectionInfoShowsBehind(const ConnectionInfoResult& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(
|
||||
@@ -264,7 +350,24 @@ public:
|
||||
// includeBalance=false skips z_gettotalbalance (which takes the wallet lock + cs_main) and only
|
||||
// fetches getblockchaininfo — used while syncing, where the balance is incomplete anyway and the
|
||||
// wallet should minimise lock contention with block connection.
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, const CoreRefreshPlan& plan);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true)
|
||||
{
|
||||
CoreRefreshPlan plan;
|
||||
plan.includeBalance = includeBalance;
|
||||
return collectCoreRefreshResult(rpc, plan);
|
||||
}
|
||||
// An amount as the daemon formats it ("[-]digits[.up to 8 digits]") in zatoshis; nullopt for anything
|
||||
// else or anything that would overflow. Exact, so "did the total move" never trips on rounding.
|
||||
static std::optional<std::int64_t> parseMoneyZat(const std::string& text);
|
||||
// Whether the next balance poll needs the minconf=1 (spendable) call whatever the total does: never
|
||||
// fetched, unconfirmed or pending funds, or a forced refresh after the user's own send.
|
||||
static bool spendableBalanceNeeded(const WalletState& state, bool pendingSendWork, bool forced);
|
||||
// The total the next poll compares against (CoreRefreshPlan::prevTotalZat). None after a poll whose
|
||||
// minconf=1 call was made but brought back no total (timeout, held back, error): that left spendable
|
||||
// degraded to display, so the next poll must count the total as moved and measure it again.
|
||||
static std::optional<std::int64_t> nextTotalBaseline(const CoreRefreshResult& result,
|
||||
std::optional<std::int64_t> previous);
|
||||
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
|
||||
bool miningOk,
|
||||
const nlohmann::json& localHashrate,
|
||||
@@ -295,9 +398,13 @@ public:
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
|
||||
// listOnly: enumerate the addresses only (cheap), without the wallet-wide balance scans; for while the
|
||||
// scans are held (the node catching up, the connect gate). Both lists are enumerated first, and the
|
||||
// balances are scanned only when both enumerations succeeded (a failed pass is discarded whole).
|
||||
static AddressRefreshResult collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot = {});
|
||||
const AddressRefreshSnapshot& snapshot = {},
|
||||
bool listOnly = false);
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
@@ -333,6 +440,17 @@ public:
|
||||
int pageSize = 100);
|
||||
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
|
||||
const std::vector<std::string>& requestedOpids);
|
||||
// Params for z_getoperationstatus/result filtered to these opids: ONE argument, an array of ids, so
|
||||
// [[id, ...]]. nullopt when no non-empty id remains: an empty filter means every op the node has
|
||||
// retained. Built explicitly, because a one-element braced list of a json ({ids}) collapses to the
|
||||
// inner array, which the daemon rejects ("JSON value is not an array as expected").
|
||||
static std::optional<nlohmann::json> buildOperationFilterParams(const std::vector<std::string>& opids);
|
||||
// Classify a poll's terminal ops by origin (absent from the map = User).
|
||||
static OperationBatchPlan planOperationBatch(const OperationStatusPollResult& parsed,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
// Any pending id is a User op (absent from the map = User).
|
||||
static bool anyUserOperationPending(const std::vector<std::string>& pendingOpids,
|
||||
const std::unordered_map<std::string, OperationOrigin>& origins);
|
||||
|
||||
struct DaemonRescanScan {
|
||||
bool foundRescan = false;
|
||||
@@ -387,6 +505,7 @@ public:
|
||||
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); }
|
||||
void retrySoon(Timer timer, float seconds) { scheduler_.retrySoon(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
#include "note_consolidation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <climits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
@@ -20,6 +23,7 @@ std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
||||
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
|
||||
if (noteIsLocked(n)) continue; // held by an operation in flight
|
||||
NoteGroup& g = byAddress[address];
|
||||
g.address = address;
|
||||
++g.notes;
|
||||
@@ -36,6 +40,13 @@ std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent)
|
||||
return out;
|
||||
}
|
||||
|
||||
bool noteIsLocked(const nlohmann::json& zListUnspentEntry)
|
||||
{
|
||||
if (!zListUnspentEntry.is_object()) return false;
|
||||
const auto it = zListUnspentEntry.find("locked");
|
||||
return it != zListUnspentEntry.end() && it->is_boolean() && it->get<bool>();
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -49,23 +60,590 @@ int estimateBatches(int notes, int limit)
|
||||
return notes / limit + ((notes % limit) >= 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches)
|
||||
{
|
||||
if (!r.error.empty()) {
|
||||
run.error = r.error; // earlier batches (if any) are already submitted and stand
|
||||
return false;
|
||||
}
|
||||
run.opids.push_back(r.opid);
|
||||
run.notesMerged += r.merging;
|
||||
run.remaining = r.remaining;
|
||||
// One leftover note can't be merged into its own address.
|
||||
return r.remaining >= 2 && static_cast<int>(run.opids.size()) < maxBatches;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (maxBatches <= 0) return run;
|
||||
while (recordMergeResult(run, call(address), maxBatches)) {}
|
||||
return run;
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::string lower(std::string s)
|
||||
{
|
||||
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
bool has(const std::string& haystack, const char* needle) { return haystack.find(needle) != std::string::npos; }
|
||||
|
||||
int clampedCount(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
if (it == obj.end()) return 0;
|
||||
if (it->is_number_unsigned()) {
|
||||
const auto v = it->get<std::uint64_t>();
|
||||
return v > static_cast<std::uint64_t>(INT_MAX) ? INT_MAX : static_cast<int>(v);
|
||||
}
|
||||
if (it->is_number_integer()) {
|
||||
const auto v = it->get<std::int64_t>();
|
||||
return v < 0 ? 0 : (v > INT_MAX ? INT_MAX : static_cast<int>(v));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string stringField(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
const auto it = obj.find(key);
|
||||
return (it != obj.end() && it->is_string()) ? it->get<std::string>() : std::string();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out)
|
||||
{
|
||||
out = MergeCallResult{};
|
||||
if (res.is_object()) {
|
||||
out.opid = stringField(res, "opid");
|
||||
out.merging = clampedCount(res, "mergingNotes");
|
||||
out.remaining = clampedCount(res, "remainingNotes");
|
||||
}
|
||||
if (out.opid.empty()) {
|
||||
out = MergeCallResult{};
|
||||
out.error = "no operation id";
|
||||
}
|
||||
}
|
||||
|
||||
OpState opStateFromStatus(const std::string& status)
|
||||
{
|
||||
if (status == "queued") return OpState::Queued;
|
||||
if (status == "executing") return OpState::Executing;
|
||||
if (status == "success") return OpState::Success;
|
||||
if (status == "failed" || status == "cancelled" || status == "canceled") return OpState::Failed;
|
||||
return OpState::Unknown;
|
||||
}
|
||||
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted)
|
||||
{
|
||||
std::vector<OpReport> out;
|
||||
if (!statusArray.is_array()) return out;
|
||||
for (const auto& op : statusArray) {
|
||||
if (!op.is_object()) continue;
|
||||
OpReport rep;
|
||||
rep.id = stringField(op, "id");
|
||||
if (rep.id.empty()) continue;
|
||||
if (!wanted.empty() && !wanted.count(rep.id)) continue;
|
||||
rep.status = stringField(op, "status");
|
||||
const auto err = op.find("error");
|
||||
if (err != op.end() && err->is_object()) rep.error = stringField(*err, "message");
|
||||
const auto res = op.find("result");
|
||||
if (res != op.end() && res->is_object()) rep.txid = stringField(*res, "txid");
|
||||
out.push_back(std::move(rep));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return MergeFailure::Uncertain; // answered without an opid: it may have queued the merge
|
||||
case K::Deferred:
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout:
|
||||
case K::Warmup:
|
||||
case K::QueueFull: return MergeFailure::NotSent; // turned away before z_mergetoaddress ran
|
||||
case K::ReplyTimeout: // the daemon keeps running it and may queue the merge
|
||||
case K::Aborted: // our abort: if sent it may have queued; if not, the session is ending (result stale)
|
||||
return MergeFailure::Uncertain;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return MergeFailure::Transient; // the HTTP layer dropped it
|
||||
case K::Auth: return MergeFailure::Fatal;
|
||||
default: break; // AppError, Other: by message
|
||||
}
|
||||
const std::string m = lower(message);
|
||||
if (has(m, "not connected")) return MergeFailure::NotSent; // RPCClient throws before sending
|
||||
if (has(m, "still syncing") || has(m, "fully synced")) return MergeFailure::Syncing; // THROW_IF_SYNCING (cheap)
|
||||
if (has(m, "spending key") || has(m, "wallet passphrase") || has(m, "walletpassphrase")) return MergeFailure::NeedsUnlock;
|
||||
if (has(m, "queue is shutting down")) return MergeFailure::Transient; // "...; the operation was not queued"
|
||||
if (has(m, "could not find any funds") || has(m, "already merged") || has(m, "insufficient funds") ||
|
||||
has(m, "greater than the net amount"))
|
||||
return MergeFailure::NothingLeft;
|
||||
return MergeFailure::Fatal;
|
||||
}
|
||||
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e)
|
||||
{
|
||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||
if (kind == rpc::RpcFailureKind::Other) {
|
||||
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e); t && t->requestSent && t->httpStatus == 0)
|
||||
return rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
return kind;
|
||||
}
|
||||
|
||||
RunVerdict runVerdict(const RunSummary& s)
|
||||
{
|
||||
switch (s.end) {
|
||||
case RunEnd::None: return RunVerdict::None;
|
||||
case RunEnd::Stopped: return RunVerdict::Stopped;
|
||||
case RunEnd::Halted: return RunVerdict::Halted;
|
||||
case RunEnd::Aborted: return RunVerdict::Aborted;
|
||||
case RunEnd::Completed: break;
|
||||
}
|
||||
if (s.batchesSubmitted == 0 && s.addressesUnconfirmed == 0) return RunVerdict::NothingSubmitted;
|
||||
if (s.addressesFailed > 0) return RunVerdict::SomeFailed;
|
||||
if (s.addressesUnconfirmed > 0) return RunVerdict::Unconfirmed;
|
||||
return RunVerdict::Clean;
|
||||
}
|
||||
|
||||
const char* runWaitKey(RunWait wait)
|
||||
{
|
||||
switch (wait) {
|
||||
case RunWait::Node:
|
||||
case RunWait::Busy: return "merge_waiting_node";
|
||||
case RunWait::Syncing: return "merge_run_wait_sync";
|
||||
case RunWait::Locked: return "merge_wait_locked";
|
||||
case RunWait::UserSend: return "merge_run_wait_send";
|
||||
case RunWait::Scan:
|
||||
case RunWait::Batch: return "merge_run_wait_batch";
|
||||
case RunWait::None:
|
||||
case RunWait::InFlight: break;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now)
|
||||
{
|
||||
if (phase_ == Phase::Running) return false;
|
||||
cfg.limit = std::max(2, cfg.limit);
|
||||
cfg.maxBatches = std::max(1, cfg.maxBatches);
|
||||
std::vector<RunAddress> addrs;
|
||||
for (const auto& g : groups) {
|
||||
if (g.address.empty() || !worthConsolidating(g, cfg.fee)) continue;
|
||||
RunAddress a;
|
||||
a.group = g;
|
||||
a.run.address = g.address;
|
||||
addrs.push_back(std::move(a));
|
||||
}
|
||||
if (addrs.empty()) return false;
|
||||
const std::uint64_t gen = gen_ + 1;
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
cfg_ = cfg;
|
||||
addrs_ = std::move(addrs);
|
||||
phase_ = Phase::Running;
|
||||
nextCallAt_ = nextPollAt_ = now;
|
||||
lastPumpAt_ = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::requestStop()
|
||||
{
|
||||
if (phase_ == Phase::Running) stopping_ = true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::dismiss()
|
||||
{
|
||||
if (phase_ == Phase::Running) return;
|
||||
const std::uint64_t gen = gen_; // a stale result can never match a later run
|
||||
*this = ConsolidationRun();
|
||||
gen_ = gen;
|
||||
}
|
||||
|
||||
bool ConsolidationRun::abort()
|
||||
{
|
||||
if (phase_ != Phase::Running) return false;
|
||||
++gen_;
|
||||
if (callInFlight_ && callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size())) {
|
||||
auto& a = addrs_[callAddr_];
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
}
|
||||
callInFlight_ = pollInFlight_ = false;
|
||||
for (auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) applyOpState(b, OpState::Lost, nullptr); // "could not be confirmed"
|
||||
}
|
||||
phase_ = Phase::Finished;
|
||||
end_ = RunEnd::Aborted;
|
||||
lastWait_ = RunWait::None;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConsolidationRun::endAddress(RunAddress& a, AddrOutcome o, const std::string& msg)
|
||||
{
|
||||
a.done = true;
|
||||
worsen(a, o);
|
||||
if (a.run.error.empty()) a.run.error = msg;
|
||||
if (a.run.opids.empty() && ++deadStreak_ >= kRunMaxFailStreak) halt(msg);
|
||||
}
|
||||
|
||||
void ConsolidationRun::applyOpState(RunBatch& b, OpState st, const OpReport* rep)
|
||||
{
|
||||
b.state = st;
|
||||
if (b.addrIdx < 0 || b.addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
RunAddress& a = addrs_[b.addrIdx];
|
||||
switch (st) {
|
||||
case OpState::Success:
|
||||
if (rep) b.txid = rep->txid;
|
||||
++a.opsDone;
|
||||
opFailStreak_ = 0;
|
||||
break;
|
||||
case OpState::Failed:
|
||||
if (rep) b.error = rep->error;
|
||||
if (a.failure.empty()) a.failure = b.error;
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Failed);
|
||||
if (++opFailStreak_ >= kRunMaxFailStreak) halt(b.error);
|
||||
break;
|
||||
case OpState::Lost:
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Uncertain);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
RunStep ConsolidationRun::next(const RunEnv& env, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return {};
|
||||
if (callInFlight_ && now - callPostedAt_ > kRunWatchdogSec) { // its result never came: a late reply is an orphan
|
||||
callInFlight_ = false;
|
||||
++gen_;
|
||||
if (callAddr_ >= 0 && callAddr_ < static_cast<int>(addrs_.size()))
|
||||
endAddress(addrs_[callAddr_], AddrOutcome::Uncertain, "");
|
||||
nextCallAt_ = std::max(nextCallAt_, now + kRunMaxGapSec);
|
||||
}
|
||||
if (pollInFlight_ && now - pollPostedAt_ > kRunWatchdogSec) {
|
||||
pollInFlight_ = false;
|
||||
++gen_;
|
||||
}
|
||||
// An op the daemon never reports done is presumed lost after an hour in which it could have been polled
|
||||
// (not while the node was gone or busy: it may simply not have been asked), or after 4 h whatever happened.
|
||||
const double dt = std::max(0.0, now - lastPumpAt_);
|
||||
lastPumpAt_ = now;
|
||||
const bool pollable = env.nodeUsable && !env.busy;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
if (pollable) b.pollableSec += std::min(dt, std::max(0.0, now - b.acceptedAt));
|
||||
if (b.pollableSec > kRunOpMaxSec || now - b.acceptedAt > kRunOpHardMaxSec) applyOpState(b, OpState::Lost, nullptr);
|
||||
}
|
||||
while (cur_ < addrs_.size() && addrs_[cur_].done) ++cur_;
|
||||
const bool moreCalls = !stopping_ && !halted_ && cur_ < addrs_.size();
|
||||
const int outstanding = static_cast<int>(std::count_if(batches_.begin(), batches_.end(),
|
||||
[](const RunBatch& b) { return !opStateTerminal(b.state); }));
|
||||
auto wait = [&](RunWait w) {
|
||||
lastWait_ = w;
|
||||
return RunStep{};
|
||||
};
|
||||
if (!callInFlight_ && !pollInFlight_ && !moreCalls && outstanding == 0) {
|
||||
phase_ = Phase::Finished;
|
||||
end_ = halted_ ? RunEnd::Halted : (stopping_ && cur_ < addrs_.size()) ? RunEnd::Stopped : RunEnd::Completed;
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Finish, -1}; // exactly once
|
||||
}
|
||||
if (callInFlight_ || pollInFlight_) return wait(RunWait::InFlight); // one run RPC at a time
|
||||
if (!env.nodeUsable) return wait(RunWait::Node); // blocks polls too
|
||||
if (env.busy) return wait(RunWait::Busy); // z_getoperationstatus needs cs_main
|
||||
if (outstanding > 0 && now >= nextPollAt_) {
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Poll, -1};
|
||||
}
|
||||
if (!moreCalls) return wait(RunWait::None); // waiting for submitted batches
|
||||
if (!env.callsAllowed || now < syncHoldUntil_) return wait(RunWait::Syncing);
|
||||
if (env.locked || now < unlockHoldUntil_) return wait(RunWait::Locked);
|
||||
if (env.userSendBusy) return wait(RunWait::UserSend);
|
||||
if (now < nextCallAt_) return wait(RunWait::Batch);
|
||||
if (!batches_.empty() && (batches_.back().state == OpState::Unknown || batches_.back().state == OpState::Queued))
|
||||
return wait(RunWait::Batch); // at most 2 unfinished ops, at most 2 x limit notes locked
|
||||
if (env.scanBusy) return wait(RunWait::Scan); // another GUI wallet scan is in the daemon
|
||||
lastWait_ = RunWait::None;
|
||||
return {RunAction::Call, static_cast<int>(cur_)};
|
||||
}
|
||||
|
||||
void ConsolidationRun::markCallPosted(int addrIdx, double now)
|
||||
{
|
||||
if (phase_ != Phase::Running || addrIdx < 0 || addrIdx >= static_cast<int>(addrs_.size())) return;
|
||||
callInFlight_ = true;
|
||||
callAddr_ = addrIdx;
|
||||
callPostedAt_ = now;
|
||||
worsen(addrs_[addrIdx], AddrOutcome::Running);
|
||||
}
|
||||
|
||||
void ConsolidationRun::markPollPosted(double now)
|
||||
{
|
||||
if (phase_ != Phase::Running) return;
|
||||
pollInFlight_ = true;
|
||||
pollPostedAt_ = now;
|
||||
}
|
||||
|
||||
std::string ConsolidationRun::onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r,
|
||||
rpc::RpcFailureKind kind, double callSec, double now)
|
||||
{
|
||||
const bool accepted = r.error.empty() && !r.opid.empty();
|
||||
if (gen != gen_ || phase_ != Phase::Running || !callInFlight_ || addrIdx != callAddr_)
|
||||
return accepted ? r.opid : std::string();
|
||||
callInFlight_ = false;
|
||||
const double gapAt = now + std::clamp(callSec, kRunMinGapSec, kRunMaxGapSec);
|
||||
nextCallAt_ = gapAt;
|
||||
RunAddress& a = addrs_[addrIdx];
|
||||
if (accepted) {
|
||||
a.transient = 0;
|
||||
deadStreak_ = 0;
|
||||
const bool more = recordMergeResult(a.run, r, cfg_.maxBatches);
|
||||
RunBatch b;
|
||||
b.addrIdx = addrIdx;
|
||||
b.opid = r.opid;
|
||||
b.merging = r.merging;
|
||||
b.acceptedAt = now;
|
||||
batches_.push_back(std::move(b));
|
||||
nextPollAt_ = now; // learn its state before the next call
|
||||
if (!more) {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const std::string msg = r.error.empty() ? std::string("no operation id") : r.error;
|
||||
switch (classifyMergeFailure(kind, msg)) {
|
||||
case MergeFailure::NotSent: // the same batch again, no retry budget
|
||||
nextCallAt_ = std::max(gapAt, now + kRunNotSentRetrySec);
|
||||
break;
|
||||
case MergeFailure::Syncing: // no retry budget
|
||||
syncHoldUntil_ = now + kRunSyncHoldSec;
|
||||
break;
|
||||
case MergeFailure::NeedsUnlock:
|
||||
if (++a.unlockRefusals >= kRunMaxUnlockRefusals) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
unlockHoldUntil_ = now + kRunUnlockHoldSec;
|
||||
lockResync_ = true;
|
||||
}
|
||||
break;
|
||||
case MergeFailure::NothingLeft:
|
||||
if (a.run.opids.empty()) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
a.done = true;
|
||||
worsen(a, AddrOutcome::Done);
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Uncertain: // never resent
|
||||
endAddress(a, AddrOutcome::Uncertain, msg);
|
||||
break;
|
||||
case MergeFailure::Transient:
|
||||
if (++a.transient > kRunMaxTransientRetries) {
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
} else {
|
||||
nextCallAt_ = std::max(gapAt, now + std::min(60.0, 5.0 * static_cast<double>(1 << (a.transient - 1))));
|
||||
}
|
||||
break;
|
||||
case MergeFailure::Fatal:
|
||||
endAddress(a, AddrOutcome::Failed, msg);
|
||||
break;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ConsolidationRun::onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now)
|
||||
{
|
||||
if (gen != gen_ || phase_ != Phase::Running || !pollInFlight_) return;
|
||||
pollInFlight_ = false;
|
||||
nextPollAt_ = now + kRunPollIntervalSec;
|
||||
if (!ok) return;
|
||||
// No call can be in flight during a poll, so the non-terminal batches now are exactly the ids it asked about.
|
||||
std::map<std::string, const OpReport*> byId;
|
||||
for (const auto& rep : reports) byId[rep.id] = &rep;
|
||||
for (auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) continue;
|
||||
const auto it = byId.find(b.opid);
|
||||
if (it == byId.end()) {
|
||||
if (++b.misses >= kRunMaxPollMisses) applyOpState(b, OpState::Lost, nullptr);
|
||||
continue;
|
||||
}
|
||||
b.misses = 0;
|
||||
const OpState st = opStateFromStatus(it->second->status);
|
||||
if (st != OpState::Unknown) applyOpState(b, st, it->second);
|
||||
}
|
||||
}
|
||||
|
||||
bool ConsolidationRun::consumeLockResyncRequest()
|
||||
{
|
||||
const bool r = lockResync_;
|
||||
lockResync_ = false;
|
||||
return r;
|
||||
}
|
||||
|
||||
std::vector<std::string> ConsolidationRun::outstandingOpids() const
|
||||
{
|
||||
std::vector<std::string> ids;
|
||||
for (const auto& b : batches_) {
|
||||
if (!opStateTerminal(b.state)) ids.push_back(b.opid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
RunProgress ConsolidationRun::progress() const
|
||||
{
|
||||
RunProgress p;
|
||||
p.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.done) ++p.addressesDone;
|
||||
const int submitted = static_cast<int>(a.run.opids.size());
|
||||
p.batchesEstimated += a.done ? submitted
|
||||
: std::max(std::min(estimateBatches(a.group.notes, cfg_.limit), cfg_.maxBatches), submitted);
|
||||
}
|
||||
p.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (opStateTerminal(b.state)) ++p.batchesDone;
|
||||
if (b.state == OpState::Success) p.notesMerged += b.merging;
|
||||
}
|
||||
p.stopping = stopping_;
|
||||
p.wait = lastWait_;
|
||||
return p;
|
||||
}
|
||||
|
||||
RunSummary ConsolidationRun::summary() const
|
||||
{
|
||||
RunSummary s;
|
||||
s.end = end_;
|
||||
s.addresses = static_cast<int>(addrs_.size());
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome == AddrOutcome::Failed) ++s.addressesFailed;
|
||||
if (a.outcome == AddrOutcome::Uncertain) ++s.addressesUnconfirmed;
|
||||
}
|
||||
s.batchesSubmitted = static_cast<int>(batches_.size());
|
||||
for (const auto& b : batches_) {
|
||||
if (b.state == OpState::Success) {
|
||||
++s.batchesSucceeded;
|
||||
s.notesMerged += b.merging;
|
||||
}
|
||||
}
|
||||
s.firstError = haltError_;
|
||||
if (s.firstError.empty()) {
|
||||
for (const auto& a : addrs_) {
|
||||
if (a.outcome != AddrOutcome::Failed) continue;
|
||||
s.firstError = !a.failure.empty() ? a.failure : a.run.error;
|
||||
if (!s.firstError.empty()) break;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in)
|
||||
{
|
||||
if (!in.mergeMode || scan.phase == ScopePhase::Loaded) return ScopeWant::Nothing;
|
||||
if (scan.inFlight || in.busyWithOp) return ScopeWant::Nothing;
|
||||
if (scan.phase == ScopePhase::Failed && !scan.autoRetry) return ScopeWant::Nothing; // waits for Retry
|
||||
if (in.now < scan.retryAt) return ScopeWant::WaitBackoff;
|
||||
if (!in.nodeReady) return ScopeWant::WaitNode;
|
||||
return ScopeWant::Post;
|
||||
}
|
||||
|
||||
std::uint32_t scopeScanBegin(ScopeScan& scan, double now)
|
||||
{
|
||||
scan.phase = ScopePhase::Loading;
|
||||
scan.inFlight = true;
|
||||
scan.inFlightGen = scan.gen;
|
||||
scan.startedAt = now;
|
||||
return scan.gen;
|
||||
}
|
||||
|
||||
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
|
||||
double now)
|
||||
{
|
||||
if (scan.inFlight && gen == scan.inFlightGen) scan.inFlight = false;
|
||||
if (gen != scan.gen) return false; // a scan from an earlier session: its data is not this one's
|
||||
if (error.empty()) {
|
||||
scan.phase = ScopePhase::Loaded;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
return true;
|
||||
}
|
||||
scan.phase = ScopePhase::Failed;
|
||||
++scan.failures;
|
||||
scan.error = error;
|
||||
scan.autoRetry = scopeFailureRetries(kind, error) && scan.failures <= kScopeMaxAutoRetries;
|
||||
scan.retryAt = scan.autoRetry ? now + scopeRetryDelaySec(scan.failures) : 0.0;
|
||||
return false;
|
||||
}
|
||||
|
||||
void scopeScanOpen(ScopeScan& scan, double now)
|
||||
{
|
||||
if (scan.inFlight && now - scan.startedAt > kScopeInFlightMaxSec) scan.inFlight = false; // lost
|
||||
if (scan.inFlight && scan.phase == ScopePhase::Loading) return; // still loading for the last session
|
||||
++scan.gen;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
void scopeScanInvalidate(ScopeScan& scan)
|
||||
{
|
||||
++scan.gen;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
void scopeScanRetry(ScopeScan& scan)
|
||||
{
|
||||
if (scan.phase != ScopePhase::Failed) return;
|
||||
scan.phase = ScopePhase::Idle;
|
||||
scan.failures = 0;
|
||||
scan.autoRetry = false;
|
||||
scan.retryAt = 0.0;
|
||||
scan.error.clear();
|
||||
}
|
||||
|
||||
double scopeRetryDelaySec(int failures)
|
||||
{
|
||||
if (failures <= 1) return 10.0;
|
||||
if (failures == 2) return 30.0;
|
||||
return 60.0;
|
||||
}
|
||||
|
||||
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout:
|
||||
case K::Warmup:
|
||||
case K::Deferred:
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled:
|
||||
case K::Aborted:
|
||||
return true;
|
||||
case K::Other: {
|
||||
std::string lower = message;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return lower.find("not connected") != std::string::npos;
|
||||
}
|
||||
default:
|
||||
return false; // ReplyTimeout, QueueFull, AppError, Auth, parse/type errors
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
// 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 "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -26,10 +31,14 @@ struct NoteGroup {
|
||||
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).
|
||||
// Spendable shielded notes grouped by address, most notes first. Transparent UTXOs and unconfirmed,
|
||||
// watch-only or locked notes are left out (z_mergetoaddress only spends confirmed, unlocked notes it has
|
||||
// keys for).
|
||||
std::vector<NoteGroup> groupSpendableNotes(const nlohmann::json& zListUnspent);
|
||||
|
||||
// A z_listunspent entry the wallet has locked, e.g. for an operation in flight ("locked": true).
|
||||
bool noteIsLocked(const nlohmann::json& zListUnspentEntry);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -59,5 +68,221 @@ struct AddressRun {
|
||||
// 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);
|
||||
|
||||
// ── Merge-call stepper ──
|
||||
// One call's result into the address's run (r.error set: run.error = r.error, false). True while another call
|
||||
// for this address is worth making: at least 2 unlocked notes left and fewer than maxBatches submitted.
|
||||
bool recordMergeResult(AddressRun& run, const MergeCallResult& r, int maxBatches = 20);
|
||||
// z_mergetoaddress's reply. Never throws. A non-empty string "opid" = accepted; mergingNotes/remainingNotes only
|
||||
// when integral (clamped to [0, INT_MAX]), else 0; no opid: error "no operation id".
|
||||
void readMergeCallResult(const nlohmann::json& res, MergeCallResult& out);
|
||||
|
||||
// ── Async op status ──
|
||||
enum class OpState : std::uint8_t { Unknown, Queued, Executing, Success, Failed, Lost };
|
||||
inline bool opStateTerminal(OpState s) { return s == OpState::Success || s == OpState::Failed || s == OpState::Lost; }
|
||||
struct OpReport {
|
||||
std::string id, status, error, txid; // error = error.message, txid = result.txid
|
||||
};
|
||||
OpState opStateFromStatus(const std::string& status); // queued/executing/success; failed|cancelled|canceled: Failed
|
||||
// Type-checked, never throws. Skips non-objects and missing/non-string/empty ids; keeps only `wanted` (all if empty).
|
||||
std::vector<OpReport> parseOpReports(const nlohmann::json& statusArray, const std::set<std::string>& wanted = {});
|
||||
|
||||
// ── Merge-call failures ──
|
||||
enum class MergeFailure : std::uint8_t {
|
||||
NotSent, // turned away before z_mergetoaddress ran: the same batch again, later
|
||||
Syncing, // THROW_IF_SYNCING: hold the run while the node catches up
|
||||
NeedsUnlock, // a locked keystore ("could not find spending key")
|
||||
NothingLeft, // no funds / already merged / too small for the fee
|
||||
Uncertain, // it may have queued the merge (a reply timeout, an answer without an opid): never resent
|
||||
Transient, // the HTTP layer dropped it, or the async queue is shutting down: retry with backoff
|
||||
Fatal, // anything else: this address is done
|
||||
};
|
||||
MergeFailure classifyMergeFailure(rpc::RpcFailureKind kind, const std::string& message);
|
||||
// The failure kind of a z_mergetoaddress call that threw. Sent, then cut off before any HTTP status
|
||||
// (connection reset, an empty or partial reply): the daemon may have queued the merge, exactly as after a
|
||||
// reply timeout, so it reads as one. Any other failure keeps its kind.
|
||||
rpc::RpcFailureKind mergeCallFailureKind(const std::exception& e);
|
||||
|
||||
// ── Paced per-address consolidation run (owned by App, pumped from App::update(); main thread) ──
|
||||
// The "Keep in each address" mode used to post one task that called z_mergetoaddress for every batch of every
|
||||
// address back to back. Each call is a whole-wallet scan under cs_main, and the daemon runs the ops one at a
|
||||
// time anyway. Here: one run RPC at a time, the next call only after the previous batch left the queue and a
|
||||
// gap as long as the last call took, failures classified (nothing that may have queued is ever resent), and a
|
||||
// breaker after three dead addresses or three failed ops in a row.
|
||||
inline constexpr double kRunMinGapSec = 2.0;
|
||||
inline constexpr double kRunMaxGapSec = 120.0;
|
||||
inline constexpr double kRunPollIntervalSec = 3.0;
|
||||
inline constexpr long kRunCallTimeoutSec = 120;
|
||||
inline constexpr double kRunWatchdogSec = 1800.0; // a call or poll outstanding this long is presumed lost
|
||||
inline constexpr double kRunOpMaxSec = 3600.0; // an op not terminal after this much pollable time is presumed lost
|
||||
inline constexpr double kRunOpHardMaxSec = 4 * kRunOpMaxSec; // ...and after this long whatever happened
|
||||
inline constexpr double kRunNotSentRetrySec = 5.0;
|
||||
inline constexpr double kRunSyncHoldSec = 30.0;
|
||||
inline constexpr double kRunUnlockHoldSec = 30.0;
|
||||
inline constexpr int kRunMaxTransientRetries = 5;
|
||||
inline constexpr int kRunMaxUnlockRefusals = 2;
|
||||
inline constexpr int kRunMaxFailStreak = 3;
|
||||
inline constexpr int kRunMaxPollMisses = 2;
|
||||
|
||||
struct RunConfig {
|
||||
double fee = 0.0001;
|
||||
int limit = 50;
|
||||
int maxBatches = 20;
|
||||
};
|
||||
struct RunEnv {
|
||||
bool nodeUsable = false; // connected, not warming up or rescanning
|
||||
bool busy = false; // the busy gate holds RPCs that need cs_main
|
||||
bool callsAllowed = true; // not syncing / settling
|
||||
bool locked = false;
|
||||
bool userSendBusy = false; // a send / shield / auto-shield / chat op of ours is in progress
|
||||
bool scanBusy = false; // another GUI wallet scan is in the daemon
|
||||
};
|
||||
enum class RunAction : std::uint8_t { None, Call, Poll, Finish };
|
||||
enum class RunWait : std::uint8_t { None, InFlight, Node, Busy, Syncing, Locked, UserSend, Scan, Batch };
|
||||
enum class RunEnd : std::uint8_t { None, Completed, Stopped, Halted, Aborted };
|
||||
enum class AddrOutcome : std::uint8_t { Pending, Running, Done, Uncertain, Failed }; // ordered: worse wins
|
||||
enum class RunVerdict : std::uint8_t { None, Clean, NothingSubmitted, SomeFailed, Unconfirmed, Stopped, Halted, Aborted };
|
||||
struct RunStep {
|
||||
RunAction action = RunAction::None;
|
||||
int addrIdx = -1;
|
||||
};
|
||||
struct RunAddress {
|
||||
NoteGroup group;
|
||||
AddressRun run;
|
||||
AddrOutcome outcome = AddrOutcome::Pending;
|
||||
bool done = false;
|
||||
int opsDone = 0;
|
||||
int transient = 0;
|
||||
int unlockRefusals = 0;
|
||||
std::string failure;
|
||||
};
|
||||
struct RunBatch {
|
||||
int addrIdx = 0;
|
||||
std::string opid;
|
||||
int merging = 0;
|
||||
OpState state = OpState::Unknown;
|
||||
std::string error, txid;
|
||||
int misses = 0;
|
||||
double acceptedAt = 0.0;
|
||||
double pollableSec = 0.0; // time since accepted in which a status poll could have gone out
|
||||
};
|
||||
struct RunProgress {
|
||||
int addresses = 0, addressesDone = 0, batchesSubmitted = 0, batchesEstimated = 0, batchesDone = 0, notesMerged = 0;
|
||||
bool stopping = false;
|
||||
RunWait wait = RunWait::None;
|
||||
};
|
||||
struct RunSummary {
|
||||
RunEnd end = RunEnd::None;
|
||||
int addresses = 0, addressesFailed = 0, addressesUnconfirmed = 0, batchesSubmitted = 0, batchesSucceeded = 0,
|
||||
notesMerged = 0;
|
||||
std::string firstError;
|
||||
};
|
||||
RunVerdict runVerdict(const RunSummary& s);
|
||||
const char* runWaitKey(RunWait wait); // i18n key of the wait line, nullptr when there is nothing to say
|
||||
|
||||
class ConsolidationRun {
|
||||
public:
|
||||
bool start(const std::vector<NoteGroup>& groups, RunConfig cfg, double now);
|
||||
void requestStop();
|
||||
bool abort();
|
||||
RunStep next(const RunEnv& env, double now);
|
||||
void markCallPosted(int addrIdx, double now);
|
||||
void markPollPosted(double now);
|
||||
// Returns an orphan opid (an accepted merge whose result is stale) for the caller to track, else "".
|
||||
std::string onCallResult(std::uint64_t gen, int addrIdx, const MergeCallResult& r, rpc::RpcFailureKind kind,
|
||||
double callSec, double now);
|
||||
void onPollResult(std::uint64_t gen, bool ok, const std::vector<OpReport>& reports, double now);
|
||||
bool consumeLockResyncRequest();
|
||||
std::vector<std::string> outstandingOpids() const;
|
||||
void dismiss();
|
||||
bool active() const { return phase_ == Phase::Running; }
|
||||
bool hasRun() const { return phase_ != Phase::Idle; }
|
||||
bool stopping() const { return stopping_; }
|
||||
std::uint64_t gen() const { return gen_; }
|
||||
RunWait lastWait() const { return lastWait_; }
|
||||
RunEnd end() const { return end_; }
|
||||
RunProgress progress() const;
|
||||
RunSummary summary() const;
|
||||
const std::vector<RunAddress>& addresses() const { return addrs_; }
|
||||
const std::vector<RunBatch>& batches() const { return batches_; }
|
||||
const RunConfig& config() const { return cfg_; }
|
||||
|
||||
private:
|
||||
enum class Phase : std::uint8_t { Idle, Running, Finished };
|
||||
static void worsen(RunAddress& a, AddrOutcome o) { if (o > a.outcome) a.outcome = o; }
|
||||
void endAddress(RunAddress& a, AddrOutcome o, const std::string& msg);
|
||||
void applyOpState(RunBatch& b, OpState st, const OpReport* rep);
|
||||
void halt(const std::string& msg) { halted_ = true; if (haltError_.empty()) haltError_ = msg; }
|
||||
|
||||
Phase phase_ = Phase::Idle;
|
||||
RunEnd end_ = RunEnd::None;
|
||||
RunConfig cfg_;
|
||||
std::vector<RunAddress> addrs_;
|
||||
std::vector<RunBatch> batches_;
|
||||
std::uint64_t gen_ = 0;
|
||||
std::size_t cur_ = 0;
|
||||
int callAddr_ = -1;
|
||||
bool callInFlight_ = false, pollInFlight_ = false, stopping_ = false, halted_ = false, lockResync_ = false;
|
||||
double callPostedAt_ = 0, pollPostedAt_ = 0, nextCallAt_ = 0, nextPollAt_ = 0, syncHoldUntil_ = 0,
|
||||
unlockHoldUntil_ = 0;
|
||||
int deadStreak_ = 0, opFailStreak_ = 0;
|
||||
std::string haltError_;
|
||||
RunWait lastWait_ = RunWait::None;
|
||||
double lastPumpAt_ = 0.0;
|
||||
};
|
||||
|
||||
// ── Scope scan (the Merge/Consolidate dialog's listunspent + z_listunspent) ─────────────────────────
|
||||
// Both calls are whole-wallet scans under the daemon's cs_main. The dialog used to post the pair again on
|
||||
// the frame after any failure, every frame for as long as it stayed open (and forever on Lite, which has
|
||||
// no node). This decides when a scan may be posted; the dialog owns one ScopeScan.
|
||||
|
||||
inline double steadySeconds() // ImGui::GetTime() stands still while the window is minimized
|
||||
{
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
inline constexpr long kScopeCallTimeoutSec = 120; // each call; a large wallet can take minutes
|
||||
inline constexpr double kScopeInFlightMaxSec = 1800; // a scan outstanding this long is presumed lost
|
||||
inline constexpr int kScopeMaxAutoRetries = 3;
|
||||
|
||||
enum class ScopePhase { Idle, Loading, Loaded, Failed };
|
||||
|
||||
struct ScopeScan {
|
||||
ScopePhase phase = ScopePhase::Idle;
|
||||
std::uint32_t gen = 0; // bumped per dialog session; a result from another session is dropped
|
||||
bool inFlight = false; // a scan is posted and its result not yet in (survives close/reopen)
|
||||
std::uint32_t inFlightGen = 0; // the session that posted it: only its result clears inFlight
|
||||
double startedAt = 0.0;
|
||||
int failures = 0;
|
||||
bool autoRetry = false; // the last failure was "node not ready yet": retry by itself
|
||||
double retryAt = 0.0;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
struct ScopeInputs {
|
||||
bool mergeMode = false;
|
||||
bool busyWithOp = false; // a merge was submitted or is in flight
|
||||
bool nodeReady = false; // App::walletScanReady()
|
||||
double now = 0.0;
|
||||
};
|
||||
|
||||
enum class ScopeWant { Nothing, Post, WaitNode, WaitBackoff };
|
||||
|
||||
ScopeWant scopeScanWant(const ScopeScan& scan, const ScopeInputs& in);
|
||||
// Mark a scan posted: Loading, in flight for this session. Returns the generation to finish it with.
|
||||
std::uint32_t scopeScanBegin(ScopeScan& scan, double now);
|
||||
// Record a scan's result. True when it belongs to the current session and succeeded: apply its data.
|
||||
bool scopeScanFinish(ScopeScan& scan, std::uint32_t gen, const std::string& error, rpc::RpcFailureKind kind,
|
||||
double now);
|
||||
// The dialog opened: start a new session, unless a scan of the last one is still loading (adopt it).
|
||||
void scopeScanOpen(ScopeScan& scan, double now);
|
||||
// The data went stale (reconnect): the next want is a fresh scan. An in-flight scan stays guarded.
|
||||
void scopeScanInvalidate(ScopeScan& scan);
|
||||
// The user pressed Retry after a failure.
|
||||
void scopeScanRetry(ScopeScan& scan);
|
||||
double scopeRetryDelaySec(int failures); // 10, 30, then 60 s
|
||||
// Retry by itself only when the node wasn't ready (refused, warming up, held back as busy, aborted):
|
||||
// not after a reply timeout or a real error, each of which would cost another pair of full scans.
|
||||
bool scopeFailureRetries(rpc::RpcFailureKind kind, const std::string& message);
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -26,6 +26,18 @@ RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
|
||||
}
|
||||
}
|
||||
|
||||
RefreshScheduler::Intervals RefreshScheduler::selectIntervals(ui::NavPage page, bool syncing, bool background)
|
||||
{
|
||||
if (background) return kBackgroundProfile;
|
||||
if (syncing) return kSyncProfile;
|
||||
return intervalsForPage(page);
|
||||
}
|
||||
|
||||
static_assert(RefreshScheduler::kSyncProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
|
||||
"the sync settle window must outlast two Core polls");
|
||||
static_assert(RefreshScheduler::kBackgroundProfile.core * 2.0f <= RefreshScheduler::kSyncSettleSeconds,
|
||||
"the sync settle window must outlast two Core polls");
|
||||
|
||||
void RefreshScheduler::applyPage(ui::NavPage page)
|
||||
{
|
||||
setIntervals(intervalsForPage(page));
|
||||
@@ -92,6 +104,12 @@ void RefreshScheduler::deferFor(Timer timer, float seconds)
|
||||
holdRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::retrySoon(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, interval(timer) - std::max(0.0f, seconds));
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
|
||||
@@ -43,8 +43,17 @@ public:
|
||||
// the lightweight Console tab syncs faster than the Peers tab. Reverts to the per-tab profile
|
||||
// once sync completes. (Tx/address/balance data is incomplete mid-sync anyway.)
|
||||
static constexpr Intervals kSyncProfile{10.0f, 0.0f, 0.0f, 0.0f};
|
||||
// While the window is in the background (minimized, hidden, or covered without focus): only the chain
|
||||
// state, slower. It keeps sync detection, the lost-connection check and the balance's own rare poll
|
||||
// (auto-shield) alive; the wallet scans and peers wait for the window to come back.
|
||||
static constexpr Intervals kBackgroundProfile{15.0f, 0.0f, 0.0f, 0.0f};
|
||||
// How long the sync profile holds after the node last read "behind" (see the Core callback). Must
|
||||
// outlast two Core polls in every held profile, or it could lapse between two "behind" readings.
|
||||
static constexpr float kSyncSettleSeconds = 30.0f;
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page);
|
||||
// The profile in force: background, else syncing, else the page's.
|
||||
static Intervals selectIntervals(ui::NavPage page, bool syncing, bool background);
|
||||
|
||||
void applyPage(ui::NavPage page);
|
||||
void setIntervals(Intervals intervals);
|
||||
@@ -62,6 +71,9 @@ public:
|
||||
// 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);
|
||||
// Due again `seconds` from now (a scan the governor held back asks again soon). Unlike deferFor, never
|
||||
// due on a 0-interval timer (the sync profile's), so a held-back scan can't fire mid-sync.
|
||||
void retrySoon(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// The hard gates in front of the wallet-wide scans: whether the node can take one at all right now. The
|
||||
// WalletScanGovernor decides on top of them how often. The scan entry points (refreshAddressData,
|
||||
// refreshTransactionData) check these themselves, so no caller (tab entry, refreshData, an import, the
|
||||
// history's re-route through the address scan) can send a scan into a node that is syncing, warming up,
|
||||
// rescanning, busy or locked, or while the window is in the background. The cheap address listing (no
|
||||
// wallet scan) still runs for a stale list, and the session's first balances are read once even while the
|
||||
// node catches up (otherwise every balance would read 0 for the whole catch-up).
|
||||
//
|
||||
// Pure, header-only.
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class ScanTier {
|
||||
None, // nothing
|
||||
ListOnly, // the address lists only (z_listaddresses, getaddressesbyaccount): cheap, no wallet scan
|
||||
Full, // the lists and the per-address balances (z_listunspent, listunspent)
|
||||
};
|
||||
|
||||
struct ScanContext {
|
||||
bool connected = false;
|
||||
bool locked = false;
|
||||
bool warmingUp = false;
|
||||
bool runtimeRescan = false;
|
||||
bool rpcBusy = false;
|
||||
bool syncing = false; // effectivelySyncing(): behind, or settling after catching up
|
||||
bool gateOpen = true; // the ConnectScanGate
|
||||
bool background = false; // the window is minimized, hidden, or covered without focus
|
||||
};
|
||||
|
||||
// The node answers RPCs that take cs_main without queueing behind something long.
|
||||
inline bool nodeCanAnswerScans(const ScanContext& c)
|
||||
{
|
||||
return c.connected && !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
|
||||
}
|
||||
|
||||
// A wallet-wide scan (O(wallet), holds cs_main) may be sent.
|
||||
inline bool wholeWalletScanAllowed(const ScanContext& c)
|
||||
{
|
||||
return nodeCanAnswerScans(c) && !c.background && !c.locked && !c.syncing && c.gateOpen;
|
||||
}
|
||||
|
||||
// The session's first read of a wallet value (the balance, the per-address balances): like
|
||||
// wholeWalletScanAllowed, but also while the node catches up. Once, so the catch-up shows the wallet's last
|
||||
// known values instead of 0.
|
||||
inline bool firstReadAllowed(const ScanContext& c)
|
||||
{
|
||||
return nodeCanAnswerScans(c) && !c.background && !c.locked && c.gateOpen;
|
||||
}
|
||||
|
||||
// listStale: never loaded this connection, or the address set may have changed (import, sweep,
|
||||
// decrypt-import). A stale list still loads while the scans are held, without the balances.
|
||||
// balancesOwed: no full pass has read the per-address balances yet this session.
|
||||
inline ScanTier planAddressScan(const ScanContext& c, bool listStale, bool balancesOwed = false)
|
||||
{
|
||||
if (wholeWalletScanAllowed(c)) return ScanTier::Full;
|
||||
if (balancesOwed && firstReadAllowed(c)) return ScanTier::Full;
|
||||
return (nodeCanAnswerScans(c) && listStale) ? ScanTier::ListOnly : ScanTier::None;
|
||||
}
|
||||
|
||||
// What entering a page does. Wallet data isn't fetched on entry any more (the periodic refresh and the
|
||||
// scan governor decide), except what the page can't show without: a stale address list, a never-loaded
|
||||
// history.
|
||||
struct PageEntryPlan {
|
||||
bool core = false;
|
||||
bool peers = false;
|
||||
bool mining = false;
|
||||
ScanTier addressesNow = ScanTier::None;
|
||||
bool transactionsNow = false;
|
||||
bool markAddresses = false;
|
||||
bool markTransactions = false;
|
||||
};
|
||||
|
||||
inline PageEntryPlan planPageEntry(ui::NavPage page, const ScanContext& c, bool addressListStale,
|
||||
bool transactionsNeverLoaded)
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
PageEntryPlan p;
|
||||
if (!c.connected) return p;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
p.core = !c.runtimeRescan && !c.rpcBusy;
|
||||
break;
|
||||
case NP::Peers:
|
||||
p.peers = !c.rpcBusy;
|
||||
break;
|
||||
case NP::Mining:
|
||||
p.mining = !c.warmingUp && !c.runtimeRescan && !c.rpcBusy;
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
case NP::History: {
|
||||
const ScanTier tier = planAddressScan(c, addressListStale);
|
||||
const bool loadNow = addressListStale && tier != ScanTier::None;
|
||||
if (loadNow) p.addressesNow = tier;
|
||||
else if (page != NP::History) p.markAddresses = true; // History needs the addresses only when stale
|
||||
if (page == NP::History) {
|
||||
if (!loadNow && transactionsNeverLoaded && wholeWalletScanAllowed(c)) p.transactionsNow = true;
|
||||
else p.markTransactions = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// The window counts as in the background when it is minimized or hidden, or covered while none of the
|
||||
// app's windows has focus (SDL reports occlusion on X11, Wayland and macOS, never on Windows).
|
||||
inline bool windowLooksBackground(bool minimized, bool hidden, bool occluded, bool appHasFocus)
|
||||
{
|
||||
return minimized || hidden || (occluded && !appHasFocus);
|
||||
}
|
||||
|
||||
constexpr double kBackgroundDebounceSeconds = 3.0;
|
||||
constexpr double kBackgroundInputGraceSeconds = 2.0;
|
||||
|
||||
// Background once it has looked so for a few seconds with no input meanwhile (a restore is immediate).
|
||||
inline bool isBackgroundWindow(bool flagged, double flaggedForSeconds, double secondsSinceInput)
|
||||
{
|
||||
return flagged && flaggedForSeconds >= kBackgroundDebounceSeconds &&
|
||||
secondsSinceInput >= kBackgroundInputGraceSeconds;
|
||||
}
|
||||
|
||||
// After a failed address enumeration, hold address refreshes for max(10 s, 10x what that pass cost). A pass
|
||||
// that failed because the node was busy (a reply timeout, a full queue, held back by the busy gate) gets the
|
||||
// floor: its wall time was the wait for cs_main, not its cost, and the busy gate already paces a busy node.
|
||||
inline double addressRetryDelaySeconds(double failedScanMs, bool nodeBusy = false)
|
||||
{
|
||||
if (nodeBusy) return 10.0;
|
||||
return std::max(10.0, (std::max(0.0, failedScanMs) / 1000.0) / 0.10);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,20 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
// The expensive z_gettotalbalance only changes the seed-migration adopt gate once the sweep is mined and
|
||||
// we have no post-mine figure yet (priorRemaining < 0). A manual Refresh forces it; an active sync
|
||||
// suppresses the automatic scan (the Refresh button still overrides).
|
||||
inline bool migrationWantsBalanceScan(bool forceBalance, bool syncing, int confs, double priorRemaining)
|
||||
{
|
||||
if (forceBalance) return true;
|
||||
return !syncing && confs >= 1 && priorRemaining < 0.0;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,319 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_scan_governor.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground)
|
||||
{
|
||||
ScanVisibility v;
|
||||
if (windowBackground) return v;
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview:
|
||||
v.balance = v.addresses = v.history = true; // totals, per-address balances, recent activity
|
||||
break;
|
||||
case NP::Send:
|
||||
case NP::Receive:
|
||||
v.addresses = v.history = true; // per-address balances, recent sends / receives
|
||||
break;
|
||||
case NP::History:
|
||||
v.history = true;
|
||||
break;
|
||||
case NP::Market:
|
||||
v.balance = true; // portfolio value
|
||||
break;
|
||||
case NP::Chat:
|
||||
v.chat = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed)
|
||||
{
|
||||
const double floorSec = autoShieldArmed ? 120.0 : 600.0;
|
||||
return std::max(floorSec, 50.0 * std::max(0.0, emaCostSec));
|
||||
}
|
||||
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in)
|
||||
{
|
||||
if (!in.allowBalance || !in.gateOpen) return BalancePollDecision::Skip;
|
||||
// The session's first read runs even while the node catches up, once per connection and only when
|
||||
// someone can see it: otherwise the total reads 0 for the whole catch-up. After that (or if that one
|
||||
// attempt failed) the balance waits for the sync to finish, a forced one too.
|
||||
if (in.neverFetched && !(in.syncing && (in.background || in.firstReadTried))) return BalancePollDecision::Explicit;
|
||||
if (in.syncing) return BalancePollDecision::Skip;
|
||||
// The user's own send, now committed. In the background it waits for the window (the routine rules below
|
||||
// still apply, e.g. the auto-shield poll); coming back forces it.
|
||||
if (in.forced && !in.background) return BalancePollDecision::Explicit;
|
||||
if (!in.changed) return BalancePollDecision::Skip;
|
||||
if (in.visible) {
|
||||
// Just came into view with an old value: fetch it now rather than queue behind the other streams.
|
||||
if (!in.waiting && in.shownAgeSec >= in.visibleFloorSec && in.sinceLastScanSec >= in.visibleFloorSec)
|
||||
return BalancePollDecision::Explicit;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
if (in.sinceLastScanSec < hiddenBalanceIntervalSec(in.emaCostSec, in.autoShieldArmed))
|
||||
return BalancePollDecision::Skip;
|
||||
return BalancePollDecision::AskGovernor;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::budgetForTipLag(double base, int blocksBehind)
|
||||
{
|
||||
if (blocksBehind <= 0) return base;
|
||||
if (blocksBehind == 1) return base * 2.0 / 3.0;
|
||||
return base / 3.0;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::signalsMatch(ScanStream stream, const ScanSignals& current) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return false;
|
||||
const unsigned mask = cfg_.streams[static_cast<std::size_t>(stream)].signalMask;
|
||||
if (mask & kScanSignalTip) {
|
||||
if (current.tipHash.empty() || st.basis.tipHash.empty() || current.tipHash != st.basis.tipHash) return false;
|
||||
}
|
||||
if (mask & kScanSignalMempool) {
|
||||
if (!current.mempoolKnown || !st.basis.mempoolKnown) return false;
|
||||
if (current.mempoolTxs != st.basis.mempoolTxs || current.mempoolBytes != st.basis.mempoolBytes) return false;
|
||||
}
|
||||
if (mask & kScanSignalEpoch) {
|
||||
if (current.walletEpoch != st.basis.walletEpoch) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::needed(ScanStream stream, const ScanSignals& current, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
if (!st.basisValid) return true;
|
||||
if (!signalsMatch(stream, current)) return true;
|
||||
const double maxAge = cfg_.streams[static_cast<std::size_t>(stream)].maxAgeSec;
|
||||
return maxAge > 0.0 && now - st.basisAt >= maxAge;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::floorSec(const Stream& stream) const
|
||||
{
|
||||
return std::max(stream.askVisible ? cfg_.visibleFloorSec : cfg_.hiddenFloorSec,
|
||||
cfg_.floorCostMultiple * stream.emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
ScanStream WalletScanGovernor::bestWaiter(double now) const
|
||||
{
|
||||
const bool quiet = now < quietUntil_;
|
||||
ScanStream best = ScanStream::Count;
|
||||
double bestKey = std::numeric_limits<double>::infinity();
|
||||
for (std::size_t i = 0; i < streams_.size(); ++i) {
|
||||
const Stream& st = streams_[i];
|
||||
if (st.waitingSince < 0.0) continue;
|
||||
if (now - st.lastAskAt > cfg_.waiterTtlSec) continue; // stopped asking
|
||||
if (quiet && now - st.waitingSince < floorSec(st)) continue; // during quiet, only via its floor
|
||||
const double key = st.waitingSince - (st.askVisible ? cfg_.visibleHeadStartSec : 0.0);
|
||||
if (key < bestKey) {
|
||||
bestKey = key;
|
||||
best = static_cast<ScanStream>(i);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = stream;
|
||||
t.kind = kind;
|
||||
t.id = ++nextId_;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = now;
|
||||
t.servesRoutine = servesRoutine;
|
||||
t.basis = current;
|
||||
live_.push_back(Live{t.id, stream, kind, now, servesRoutine});
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::ticketFor(const Live& live) const
|
||||
{
|
||||
ScanTicket t;
|
||||
t.stream = live.stream;
|
||||
t.kind = live.kind;
|
||||
t.id = live.id;
|
||||
t.generation = generation_;
|
||||
t.dispatchedAt = live.dispatchedAt;
|
||||
t.servesRoutine = live.servesRoutine;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
if (st.waitingSince < 0.0 || now - st.lastAskAt > cfg_.waitLapseSec) st.waitingSince = now; // a new wait
|
||||
st.lastAskAt = now;
|
||||
st.askVisible = visible;
|
||||
pruneLeases(now);
|
||||
if (!live_.empty()) return {}; // one wallet scan in the daemon at a time
|
||||
if (bestWaiter(now) != stream) return {};
|
||||
ScanTicket t = issue(stream, ScanKind::Routine, current, now, true);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0;
|
||||
return t;
|
||||
}
|
||||
|
||||
ScanTicket WalletScanGovernor::beginExplicit(ScanStream stream, const ScanSignals& current, double now,
|
||||
bool servesRoutine)
|
||||
{
|
||||
ScanTicket t = issue(stream, ScanKind::Explicit, current, now, servesRoutine);
|
||||
if (servesRoutine) {
|
||||
Stream& st = at(stream);
|
||||
t.waitedSince = st.waitingSince;
|
||||
st.waitingSince = -1.0; // served
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::eraseLive(std::uint64_t id)
|
||||
{
|
||||
live_.erase(std::remove_if(live_.begin(), live_.end(), [id](const Live& l) { return l.id == id; }), live_.end());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// A ticket that sent nothing gives back the wait it ended (keeping a wait begun since, if older).
|
||||
void restoreWait(double& waitingSince, double waitedSince)
|
||||
{
|
||||
if (waitedSince < 0.0) return;
|
||||
waitingSince = waitingSince < 0.0 ? waitedSince : std::min(waitingSince, waitedSince);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void WalletScanGovernor::cancel(const ScanTicket& ticket)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return;
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return;
|
||||
eraseLive(ticket.id);
|
||||
restoreWait(at(ticket.stream).waitingSince, ticket.waitedSince);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now)
|
||||
{
|
||||
if (!ticket || ticket.generation != generation_) return; // from before a reset()
|
||||
const bool wasLive = std::any_of(live_.begin(), live_.end(), [&](const Live& l) { return l.id == ticket.id; });
|
||||
if (!wasLive) return; // already completed (or dropped as an orphan)
|
||||
eraseLive(ticket.id);
|
||||
Stream& st = at(ticket.stream);
|
||||
if (outcome == ScanOutcome::Skipped) { // nothing was sent: no charge, and it keeps its place in line
|
||||
restoreWait(st.waitingSince, ticket.waitedSince);
|
||||
return;
|
||||
}
|
||||
st.lastScanAt = std::max(st.lastScanAt, ticket.dispatchedAt);
|
||||
double ms = std::max(0.0, costMs);
|
||||
if (outcome == ScanOutcome::TimedOut && ticket.servesRoutine) ms = std::max(ms, st.emaCostMs);
|
||||
if (ms > 0.0) {
|
||||
// Quiet long enough that this scan was `budget` of the time since it started.
|
||||
const double debt = ms / 1000.0 * (1.0 / budgetForTipLag(cfg_.budget, tipLag_) - 1.0);
|
||||
const double cap = now + (ticket.kind == ScanKind::Explicit ? cfg_.explicitDebtCapSec : cfg_.maxQuietSec);
|
||||
quietUntil_ = std::max(quietUntil_, std::min(std::max(quietUntil_, now) + debt, cap));
|
||||
}
|
||||
if (!ticket.servesRoutine) return; // charged; the routine scans' cost and basis are theirs
|
||||
if (outcome == ScanOutcome::Ok || outcome == ScanOutcome::Partial || outcome == ScanOutcome::TimedOut) {
|
||||
// Rises at once, decays slowly: a scan that got expensive is spaced out right away.
|
||||
st.emaCostMs = (st.emaCostMs <= 0.0 || ms > st.emaCostMs) ? ms : 0.7 * st.emaCostMs + 0.3 * ms;
|
||||
}
|
||||
if (outcome == ScanOutcome::Failed || outcome == ScanOutcome::TimedOut) {
|
||||
if (st.pendingValid) st.pendingPoisoned = true;
|
||||
} else if (outcome == ScanOutcome::Partial) {
|
||||
if (!st.pendingValid) {
|
||||
st.pending = ticket.basis;
|
||||
st.pendingAt = ticket.dispatchedAt;
|
||||
st.pendingValid = true;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
} else if (outcome == ScanOutcome::Ok) {
|
||||
// A pass that started at the pending basis is current for that basis (the earliest), unless one of
|
||||
// its dispatches failed.
|
||||
if (!(st.pendingValid && st.pendingPoisoned)) {
|
||||
st.basis = st.pendingValid ? st.pending : ticket.basis;
|
||||
st.basisAt = st.pendingValid ? st.pendingAt : ticket.dispatchedAt;
|
||||
st.basisValid = true;
|
||||
}
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
}
|
||||
|
||||
void WalletScanGovernor::dropOrphans(ScanStream stream, double now)
|
||||
{
|
||||
std::vector<Live> orphans;
|
||||
for (const auto& l : live_) {
|
||||
if (l.stream == stream) orphans.push_back(l);
|
||||
}
|
||||
for (const auto& l : orphans) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::pruneLeases(double now)
|
||||
{
|
||||
std::vector<Live> expired;
|
||||
for (const auto& l : live_) {
|
||||
const double lease = std::max(cfg_.minLeaseSec, 4.0 * at(l.stream).emaCostMs / 1000.0);
|
||||
if (now - l.dispatchedAt > lease) expired.push_back(l);
|
||||
}
|
||||
for (const auto& l : expired) complete(ticketFor(l), 0.0, ScanOutcome::TimedOut, now);
|
||||
}
|
||||
|
||||
void WalletScanGovernor::invalidate(ScanStream stream)
|
||||
{
|
||||
Stream& st = at(stream);
|
||||
st.basisValid = false;
|
||||
st.pendingValid = false;
|
||||
st.pendingPoisoned = false;
|
||||
}
|
||||
|
||||
void WalletScanGovernor::reset()
|
||||
{
|
||||
const std::uint64_t nextGeneration = generation_ + 1;
|
||||
const std::uint64_t nextId = nextId_;
|
||||
*this = WalletScanGovernor(cfg_);
|
||||
generation_ = nextGeneration;
|
||||
nextId_ = nextId;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::inFlight(ScanStream stream) const
|
||||
{
|
||||
return std::any_of(live_.begin(), live_.end(), [stream](const Live& l) { return l.stream == stream; });
|
||||
}
|
||||
|
||||
double WalletScanGovernor::secondsSinceScan(ScanStream stream, double now) const
|
||||
{
|
||||
const double at_ = at(stream).lastScanAt;
|
||||
return at_ < 0.0 ? 1e9 : now - at_;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::waiting(ScanStream stream, double now) const
|
||||
{
|
||||
const Stream& st = at(stream);
|
||||
return st.waitingSince >= 0.0 && now - st.lastAskAt <= cfg_.waitLapseSec;
|
||||
}
|
||||
|
||||
bool WalletScanGovernor::heldWithinFloor(ScanStream stream, double now) const
|
||||
{
|
||||
if (!waiting(stream, now)) return false;
|
||||
const Stream& st = at(stream);
|
||||
return now - st.waitingSince < floorSec(st) + st.emaCostMs / 1000.0;
|
||||
}
|
||||
|
||||
double WalletScanGovernor::visibleFloorSecFor(ScanStream stream) const
|
||||
{
|
||||
return std::max(cfg_.visibleFloorSec, cfg_.floorCostMultiple * at(stream).emaCostMs / 1000.0);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,219 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// One budget for every wallet-wide scan the GUI sends on its own: the balance (z_gettotalbalance), the
|
||||
// address scan (z_listunspent + listunspent), the history (z_listreceivedbyaddress, z_listsentbyaddress) and
|
||||
// the between-blocks probe. Each holds dragonxd's cs_main for its whole run, seconds per call on a large
|
||||
// wallet, which starves block connection. They used to be throttled one by one at 10% each, which adds up
|
||||
// to 30-40% with all of them due. Here: one scan in flight at a time across both RPC lanes, and one shared
|
||||
// quiet clock that keeps the total near `budget` of wall-clock. A stream that keeps asking is served FIFO
|
||||
// (visible ones a little first), and a floor scaled to its own cost keeps any stream from starving.
|
||||
// Explicit needs (the user's own send, a dirty set, a never-loaded list) are never refused, but are charged.
|
||||
//
|
||||
// Each stream also keeps the signals it last scanned at (tip hash, mempool size, a wallet-mutation epoch):
|
||||
// a routine scan is only needed when they changed, or after the stream's safety sweep.
|
||||
//
|
||||
// An explicit ticket that doesn't do the stream's routine work (a history cycle that only lists rows) is
|
||||
// charged to the quiet clock but leaves the stream's wait, cost estimate and basis alone (servesRoutine).
|
||||
//
|
||||
// Pure and main-thread only: every call takes `now` in seconds of a steady clock.
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
enum class ScanStream : std::uint8_t { Balance, Addresses, History, RecentTx, ChatNotes, ChatFast, Adhoc, Count };
|
||||
enum class ScanKind : std::uint8_t { Routine, Explicit };
|
||||
enum class ScanOutcome : std::uint8_t {
|
||||
Ok, // applied: the stream's signals basis advances
|
||||
Partial, // applied, more dispatches needed before the basis may advance (a multi-cycle pass)
|
||||
Failed, // charged; the basis stays (and a pending pass is poisoned)
|
||||
TimedOut, // a reply timeout: charged at least the stream's usual cost (the daemon still runs it)
|
||||
Skipped, // admitted, but no scan was sent (node behind, busy, held back): not charged
|
||||
};
|
||||
enum : unsigned { kScanSignalTip = 1, kScanSignalMempool = 2, kScanSignalEpoch = 4, kScanSignalAll = 7 };
|
||||
|
||||
// What a scan's result depends on, read without cs_main: the tip (bestblockhash, fetched each Core cycle),
|
||||
// the mempool (getmempoolinfo takes only mempool.cs) and the App's wallet-mutation epoch.
|
||||
struct ScanSignals {
|
||||
std::string tipHash;
|
||||
bool mempoolKnown = false;
|
||||
std::int64_t mempoolTxs = 0;
|
||||
std::int64_t mempoolBytes = 0;
|
||||
std::uint64_t walletEpoch = 0;
|
||||
};
|
||||
|
||||
struct ScanTicket {
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t generation = 0;
|
||||
double dispatchedAt = 0.0;
|
||||
double waitedSince = -1.0; // when its wait began, if it ended one (cancel() / Skipped restore it)
|
||||
bool servesRoutine = true; // does the stream's routine work: ends its wait, feeds its cost and basis
|
||||
ScanSignals basis; // the signals at dispatch: what an Ok result is current for
|
||||
explicit operator bool() const { return id != 0; }
|
||||
};
|
||||
|
||||
// Which streams the current page shows (visible streams get a head start and a shorter starvation floor).
|
||||
struct ScanVisibility {
|
||||
bool balance = false;
|
||||
bool addresses = false;
|
||||
bool history = false;
|
||||
bool chat = false;
|
||||
};
|
||||
ScanVisibility scanVisibilityForPage(ui::NavPage page, bool windowBackground);
|
||||
|
||||
// The balance part of a Core poll.
|
||||
enum class BalancePollDecision { Skip, AskGovernor, Explicit };
|
||||
struct BalancePollInputs {
|
||||
bool allowBalance = true; // false for the locked wallet's chain-state poll
|
||||
bool syncing = false; // the node is catching up (or settling after it): only a first read runs
|
||||
bool background = false; // the window is in the background: no first read while syncing
|
||||
bool firstReadTried = false; // the first read while syncing was already sent this connection
|
||||
bool gateOpen = true; // the connect gate (C44): closed right after a connect, at most ~20 s
|
||||
bool forced = false; // the user's own send (waits while the window is in the background)
|
||||
bool neverFetched = false; // no balance yet this session
|
||||
bool changed = true; // the governor's change gate
|
||||
bool visible = true; // the page shows the aggregate balance
|
||||
bool autoShieldArmed = false; // client-side auto-shield runs from balance results
|
||||
double sinceLastScanSec = 1e9; // since the last balance scan that was sent
|
||||
double emaCostSec = 0.0;
|
||||
// Coming into view with an old balance (back to Overview, the window restored): one explicit poll
|
||||
// instead of a fresh wait behind the other streams. Only when the stream isn't already waiting and
|
||||
// both the value on screen and the last scan are older than the stream's visible floor.
|
||||
bool waiting = false; // the Balance stream is waiting for admission
|
||||
double shownAgeSec = 0.0; // age of the value on screen (last fetch, or last poll that confirmed it)
|
||||
double visibleFloorSec = 30.0; // WalletScanGovernor::visibleFloorSecFor(Balance)
|
||||
};
|
||||
// How often a page that doesn't show the balance still polls it: max(10 min, 50x its cost), or every 2 min
|
||||
// while client-side auto-shield needs it.
|
||||
double hiddenBalanceIntervalSec(double emaCostSec, bool autoShieldArmed);
|
||||
BalancePollDecision decideBalancePoll(const BalancePollInputs& in);
|
||||
|
||||
class WalletScanGovernor {
|
||||
public:
|
||||
struct StreamConfig {
|
||||
unsigned signalMask = kScanSignalAll; // which signals the change gate compares
|
||||
double maxAgeSec = 0.0; // safety sweep: needed this long after its basis (0 = never)
|
||||
};
|
||||
struct Config {
|
||||
double budget = 0.15; // share of wall-clock for all routine scans together
|
||||
double explicitDebtCapSec = 120; // an explicit scan never pushes the quiet clock past now + this
|
||||
double maxQuietSec = 900; // no scan pushes it past now + this (no runaway debt)
|
||||
double visibleFloorSec = 30; // a waiting visible stream is admitted after this, quiet or not...
|
||||
double hiddenFloorSec = 900; // ...a hidden one after this...
|
||||
double floorCostMultiple = 15; // ...or after 15x its own cost if longer (<= ~6% each from floors)
|
||||
double visibleHeadStartSec = 60; // visible waiters rank as if they had waited this much longer
|
||||
double waiterTtlSec = 15; // a stream that hasn't asked for this long isn't waiting
|
||||
double waitLapseSec = 60; // asking again after this long starts a new wait
|
||||
double minLeaseSec = 600; // a ticket older than max(this, 4x its cost) is presumed lost
|
||||
std::array<StreamConfig, static_cast<std::size_t>(ScanStream::Count)> streams{{
|
||||
{kScanSignalAll, 300}, // Balance
|
||||
{kScanSignalAll, 300}, // Addresses
|
||||
{kScanSignalTip | kScanSignalEpoch, 0}, // History
|
||||
{kScanSignalMempool | kScanSignalEpoch, 0}, // RecentTx (between blocks: the tip is History's)
|
||||
{0, 0}, // ChatNotes: admission only; the need is services::shouldScanChatNotes (tip settle, chat ops)
|
||||
{0, 0}, // ChatFast: admission only; the need is getwalletinfo's txcount (chatInboxScanNeeded). Not
|
||||
// the tip or mempool: they move before the wallet notifier stores the tx, so a scan fired
|
||||
// on them can read the old wallet and consume the change.
|
||||
{0, 0}, // Adhoc
|
||||
}};
|
||||
};
|
||||
|
||||
WalletScanGovernor() = default;
|
||||
explicit WalletScanGovernor(const Config& config) : cfg_(config) {}
|
||||
|
||||
// The budget while the node is behind: full at the tip, 2/3 one block behind, 1/3 two or more.
|
||||
static double budgetForTipLag(double base, int blocksBehind);
|
||||
|
||||
// A routine scan of this stream is needed: no basis yet, the signals changed, or the sweep is due.
|
||||
bool needed(ScanStream stream, const ScanSignals& current, double now) const;
|
||||
// The stream's basis matches these signals (masked). An unknown tip or mempool never matches.
|
||||
bool signalsMatch(ScanStream stream, const ScanSignals& current) const;
|
||||
|
||||
// Ask to run a routine scan now: a ticket when admitted, else an empty one (the stream keeps waiting
|
||||
// while it keeps asking). Admitted only when no scan is in flight and this stream is the best waiter.
|
||||
ScanTicket tryAdmit(ScanStream stream, bool visible, const ScanSignals& current, double now);
|
||||
// An explicit need: never refused, charged (with capped debt). servesRoutine = false for one that
|
||||
// doesn't do the stream's routine work: it is charged, but the stream keeps waiting for that work and
|
||||
// its cost estimate and basis stay those of the routine scans.
|
||||
ScanTicket beginExplicit(ScanStream stream, const ScanSignals& current, double now, bool servesRoutine = true);
|
||||
// The scan wasn't sent after all (the job queue refused it): drop the ticket with no charge.
|
||||
void cancel(const ScanTicket& ticket);
|
||||
// The scan's result is in. `basis` may differ from the ticket's (e.g. what the worker saw).
|
||||
void complete(const ScanTicket& ticket, double costMs, ScanOutcome outcome, double now);
|
||||
// Tickets of this stream whose job is gone without a result: completed as timed out.
|
||||
void dropOrphans(ScanStream stream, double now);
|
||||
void setTipLag(int blocksBehind) { tipLag_ = blocksBehind > 0 ? blocksBehind : 0; }
|
||||
void invalidate(ScanStream stream); // forget its basis: the next routine scan is needed
|
||||
void reset(); // a new connection: forget everything; old tickets complete as no-ops
|
||||
|
||||
bool inFlight(ScanStream stream) const;
|
||||
bool anyInFlight() const { return !live_.empty(); }
|
||||
double quietUntil() const { return quietUntil_; }
|
||||
double emaCostMs(ScanStream stream) const { return at(stream).emaCostMs; }
|
||||
// Since the dispatch of the last scan that was sent (cancelled and Skipped tickets don't count); 1e9 if none.
|
||||
double secondsSinceScan(ScanStream stream, double now) const;
|
||||
double currentBudget() const { return budgetForTipLag(cfg_.budget, tipLag_); }
|
||||
// The stream asked within the lapse window and hasn't been admitted yet.
|
||||
bool waiting(ScanStream stream, double now) const;
|
||||
// Waiting, and still within what the budget promises it: its floor plus one scan of its own. Past that,
|
||||
// or not asking, a stale value means something else is wrong.
|
||||
bool heldWithinFloor(ScanStream stream, double now) const;
|
||||
// The floor it would get as a visible waiter: max(visibleFloorSec, floorCostMultiple x its cost).
|
||||
double visibleFloorSecFor(ScanStream stream) const;
|
||||
|
||||
private:
|
||||
struct Stream {
|
||||
double waitingSince = -1.0;
|
||||
double lastAskAt = -1e18;
|
||||
bool askVisible = false;
|
||||
double emaCostMs = 0.0;
|
||||
double lastScanAt = -1.0; // dispatch time of the last scan that was sent
|
||||
bool basisValid = false;
|
||||
ScanSignals basis;
|
||||
double basisAt = 0.0;
|
||||
bool pendingValid = false;
|
||||
bool pendingPoisoned = false;
|
||||
ScanSignals pending;
|
||||
double pendingAt = 0.0;
|
||||
};
|
||||
struct Live {
|
||||
std::uint64_t id = 0;
|
||||
ScanStream stream = ScanStream::Balance;
|
||||
ScanKind kind = ScanKind::Routine;
|
||||
double dispatchedAt = 0.0;
|
||||
bool servesRoutine = true;
|
||||
};
|
||||
|
||||
Stream& at(ScanStream stream) { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
const Stream& at(ScanStream stream) const { return streams_[static_cast<std::size_t>(stream)]; }
|
||||
double floorSec(const Stream& stream) const;
|
||||
ScanStream bestWaiter(double now) const;
|
||||
ScanTicket issue(ScanStream stream, ScanKind kind, const ScanSignals& current, double now, bool servesRoutine);
|
||||
ScanTicket ticketFor(const Live& live) const;
|
||||
void eraseLive(std::uint64_t id);
|
||||
void pruneLeases(double now);
|
||||
|
||||
Config cfg_;
|
||||
std::array<Stream, static_cast<std::size_t>(ScanStream::Count)> streams_{};
|
||||
std::vector<Live> live_;
|
||||
double quietUntil_ = 0.0;
|
||||
int tipLag_ = 0;
|
||||
std::uint64_t nextId_ = 0;
|
||||
std::uint64_t generation_ = 1;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
+72
-162
@@ -33,109 +33,6 @@ namespace dragonx {
|
||||
namespace ui {
|
||||
namespace effects {
|
||||
|
||||
// ============================================================================
|
||||
// Composite Shader Source (GLEW Available)
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
|
||||
const char* COMPOSITE_VERTEX = R"glsl(
|
||||
#version 330 core
|
||||
|
||||
layout (location = 0) in vec2 aPos;
|
||||
layout (location = 1) in vec2 aTexCoord;
|
||||
|
||||
out vec2 vTexCoord;
|
||||
out vec2 vNoiseCoord;
|
||||
|
||||
uniform vec2 uTexScale; // For noise tiling
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
vTexCoord = aTexCoord;
|
||||
vNoiseCoord = aTexCoord * uTexScale; // Tile noise
|
||||
}
|
||||
)glsl";
|
||||
|
||||
const char* COMPOSITE_FRAGMENT = R"glsl(
|
||||
#version 330 core
|
||||
|
||||
in vec2 vTexCoord;
|
||||
in vec2 vNoiseCoord;
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform sampler2D uBlurTex;
|
||||
uniform sampler2D uNoiseTex;
|
||||
|
||||
uniform vec4 uTintColor;
|
||||
uniform float uTintOpacity;
|
||||
uniform float uLuminosity;
|
||||
uniform float uNoiseOpacity;
|
||||
|
||||
void main()
|
||||
{
|
||||
// Sample blurred background
|
||||
vec3 blur = texture(uBlurTex, vTexCoord).rgb;
|
||||
|
||||
// Apply luminosity (desaturation effect)
|
||||
float lum = dot(blur, vec3(0.299, 0.587, 0.114));
|
||||
blur = mix(vec3(lum), blur, uLuminosity);
|
||||
|
||||
// Apply tint color
|
||||
vec3 tinted = mix(blur, uTintColor.rgb, uTintOpacity);
|
||||
|
||||
// Sample and apply noise
|
||||
float noise = texture(uNoiseTex, vNoiseCoord).r;
|
||||
// Noise is centered at 0.5, so we subtract 0.5 and scale
|
||||
float noiseOffset = (noise - 0.5) * 2.0 * uNoiseOpacity;
|
||||
tinted += vec3(noiseOffset);
|
||||
|
||||
// Output with slight transparency
|
||||
FragColor = vec4(tinted, uTintColor.a);
|
||||
}
|
||||
)glsl";
|
||||
|
||||
bool compileShader(GLuint shader, const char* source)
|
||||
{
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
|
||||
GLint success;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
|
||||
|
||||
if (!success) {
|
||||
GLint logLength;
|
||||
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &logLength);
|
||||
std::vector<char> log(logLength + 1);
|
||||
glGetShaderInfoLog(shader, logLength, nullptr, log.data());
|
||||
DEBUG_LOGF("Shader compile error:\n%s\n", log.data());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool linkProgram(GLuint program)
|
||||
{
|
||||
glLinkProgram(program);
|
||||
|
||||
GLint success;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &success);
|
||||
|
||||
if (!success) {
|
||||
GLint logLength;
|
||||
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &logLength);
|
||||
std::vector<char> log(logLength + 1);
|
||||
glGetProgramInfoLog(program, logLength, nullptr, log.data());
|
||||
DEBUG_LOGF("Program link error:\n%s\n", log.data());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
// ============================================================================
|
||||
// AcrylicMaterial Implementation (GLEW Available)
|
||||
// ============================================================================
|
||||
@@ -172,52 +69,6 @@ bool AcrylicMaterial::init()
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create composite shader
|
||||
GLuint vs = glCreateShader(GL_VERTEX_SHADER);
|
||||
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
|
||||
|
||||
if (!compileShader(vs, COMPOSITE_VERTEX)) {
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!compileShader(fs, COMPOSITE_FRAGMENT)) {
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
return false;
|
||||
}
|
||||
|
||||
compositeShader_ = glCreateProgram();
|
||||
glAttachShader(compositeShader_, vs);
|
||||
glAttachShader(compositeShader_, fs);
|
||||
|
||||
if (!linkProgram(compositeShader_)) {
|
||||
glDeleteProgram(compositeShader_);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
compositeShader_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
|
||||
// Get uniform locations
|
||||
uCompositeTintColor_ = glGetUniformLocation(compositeShader_, "uTintColor");
|
||||
uCompositeTintOpacity_ = glGetUniformLocation(compositeShader_, "uTintOpacity");
|
||||
uCompositeLuminosity_ = glGetUniformLocation(compositeShader_, "uLuminosity");
|
||||
uCompositeNoiseOpacity_ = glGetUniformLocation(compositeShader_, "uNoiseOpacity");
|
||||
uCompositeBlurTex_ = glGetUniformLocation(compositeShader_, "uBlurTex");
|
||||
uCompositeNoiseTex_ = glGetUniformLocation(compositeShader_, "uNoiseTex");
|
||||
uCompositeTexScale_ = glGetUniformLocation(compositeShader_, "uTexScale");
|
||||
|
||||
DEBUG_LOGF("AcrylicMaterial: Composite shader uniforms:\n");
|
||||
DEBUG_LOGF(" uTintColor=%d, uTintOpacity=%d, uLuminosity=%d\n",
|
||||
uCompositeTintColor_, uCompositeTintOpacity_, uCompositeLuminosity_);
|
||||
DEBUG_LOGF(" uNoiseOpacity=%d, uBlurTex=%d, uNoiseTex=%d, uTexScale=%d\n",
|
||||
uCompositeNoiseOpacity_, uCompositeBlurTex_, uCompositeNoiseTex_, uCompositeTexScale_);
|
||||
|
||||
// Detect system capabilities
|
||||
refreshCapabilities();
|
||||
currentFallback_ = detectFallback();
|
||||
@@ -239,11 +90,6 @@ void AcrylicMaterial::shutdown()
|
||||
quad_.destroy();
|
||||
noiseTexture_.destroy();
|
||||
|
||||
if (compositeShader_) {
|
||||
glDeleteProgram(compositeShader_);
|
||||
compositeShader_ = 0;
|
||||
}
|
||||
|
||||
initialized_ = false;
|
||||
viewportWidth_ = 0;
|
||||
viewportHeight_ = 0;
|
||||
@@ -260,6 +106,7 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
viewportWidth_ = width;
|
||||
viewportHeight_ = height;
|
||||
blurCacheValid_ = false; // Invalidate cache on resize
|
||||
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
|
||||
|
||||
// Determine blur buffer size based on quality
|
||||
int blurWidth = width;
|
||||
@@ -301,16 +148,46 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
|
||||
DEBUG_LOGF("AcrylicMaterial: Resized to %dx%d (blur: %dx%d)\n",
|
||||
width, height, blurWidth, blurHeight);
|
||||
needsRealloc_ = false;
|
||||
dirtyFrames_ = 2; // recapture after resize
|
||||
}
|
||||
|
||||
void AcrylicMaterial::releaseBuffers()
|
||||
{
|
||||
captureBuffer_.destroy();
|
||||
blurBuffers_.destroy();
|
||||
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
|
||||
blurCacheValid_ = false;
|
||||
needsRealloc_ = true;
|
||||
DEBUG_LOGF("AcrylicMaterial: released targets (effect skipped)\n");
|
||||
}
|
||||
|
||||
void AcrylicMaterial::syncBufferLifetime()
|
||||
{
|
||||
if (!initialized_) return;
|
||||
if (shouldSkipEffect()) {
|
||||
// Test the names, not isValid(): a half-allocated ping-pong pair must be freed too.
|
||||
if (captureBuffer_.getFbo() || blurBuffers_.getSourceTexture() || blurBuffers_.getDestTexture())
|
||||
releaseBuffers();
|
||||
return;
|
||||
}
|
||||
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
|
||||
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
|
||||
int w = viewportWidth_, h = viewportHeight_;
|
||||
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
|
||||
resize(w, h);
|
||||
hasValidCapture_ = false;
|
||||
markBackgroundDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void AcrylicMaterial::captureBackground()
|
||||
{
|
||||
if (!initialized_ || shouldSkipEffect()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -321,7 +198,7 @@ void AcrylicMaterial::captureBackground()
|
||||
--dirtyFrames_;
|
||||
|
||||
// Capture current screen content
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // New capture invalidates blur cache
|
||||
}
|
||||
@@ -332,7 +209,7 @@ void AcrylicMaterial::captureBackgroundDirect()
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) {
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,7 +226,7 @@ void AcrylicMaterial::captureBackgroundDirect()
|
||||
// draw framebuffer, and ImGui has scissor enabled during rendering.
|
||||
// The subsequent ImDrawCallback_ResetRenderState re-enables it.
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // force re-blur with fresh capture
|
||||
@@ -362,9 +239,9 @@ void AcrylicMaterial::captureLiveFramebuffer()
|
||||
// inserted at the overlay window's draw-list start), so FB 0 already holds the app UI drawn
|
||||
// below the overlay. A paired ImDrawCallback_ResetRenderState restores ImGui's GL state.
|
||||
if (!initialized_ || shouldSkipEffect()) return;
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0) return;
|
||||
if (viewportWidth_ <= 0 || viewportHeight_ <= 0 || !captureBuffer_.isValid()) return;
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
captureBuffer_.captureScreen(viewportWidth_, viewportHeight_);
|
||||
captureBuffer_.captureScreen(captureSrcWidth(), captureSrcHeight());
|
||||
hasValidCapture_ = true;
|
||||
blurCacheValid_ = false; // force re-blur with the fresh live capture
|
||||
}
|
||||
@@ -390,7 +267,7 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
|
||||
|
||||
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
|
||||
{
|
||||
if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
|
||||
if (!blurBuffers_.isValid() || !blurShader_.isValid() || !captureBuffer_.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1188,7 +1065,7 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
viewportHeight_ = height;
|
||||
blurCacheValid_ = false;
|
||||
|
||||
if (settings_.quality == AcrylicQuality::Off) return;
|
||||
if (shouldSkipEffect()) { needsRealloc_ = true; return; } // targets freed/rebuilt by syncBufferLifetime()
|
||||
|
||||
// (Re)create capture buffer at full resolution — must be B8G8R8A8 to match swap chain for CopyResource
|
||||
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
|
||||
@@ -1215,10 +1092,43 @@ void AcrylicMaterial::resize(int width, int height)
|
||||
dx_blurWidth_ = bw;
|
||||
dx_blurHeight_ = bh;
|
||||
dx_blurCurrent_ = 0;
|
||||
needsRealloc_ = false;
|
||||
dirtyFrames_ = 2;
|
||||
VERBOSE_LOGF("[Acrylic DX11] Resized %dx%d (blur %dx%d)\n", width, height, bw, bh);
|
||||
}
|
||||
|
||||
void AcrylicMaterial::releaseBuffers()
|
||||
{
|
||||
dx_releaseRenderTarget(dx_captureTex_, dx_captureSRV_, dx_captureRTV_);
|
||||
for (int i = 0; i < 2; ++i)
|
||||
dx_releaseRenderTarget(dx_blurTex_[i], dx_blurSRV_[i], dx_blurRTV_[i]);
|
||||
dx_blurWidth_ = dx_blurHeight_ = 0;
|
||||
dx_blurCurrent_ = 0;
|
||||
hasValidCapture_ = false; // drawRect -> drawTintedRect until a fresh capture
|
||||
blurCacheValid_ = false;
|
||||
needsRealloc_ = true;
|
||||
DEBUG_LOGF("[Acrylic DX11] released targets (effect skipped)\n");
|
||||
}
|
||||
|
||||
void AcrylicMaterial::syncBufferLifetime()
|
||||
{
|
||||
if (!initialized_) return;
|
||||
if (shouldSkipEffect()) {
|
||||
// Per texture: each dx_createRenderTarget can fail on its own.
|
||||
if (dx_captureTex_ || dx_blurTex_[0] || dx_blurTex_[1])
|
||||
releaseBuffers();
|
||||
return;
|
||||
}
|
||||
if (needsRealloc_ && viewportWidth_ > 0 && viewportHeight_ > 0) {
|
||||
needsRealloc_ = false; // one attempt; no per-frame retry on alloc failure
|
||||
int w = viewportWidth_, h = viewportHeight_;
|
||||
viewportWidth_ = viewportHeight_ = 0; // force resize() past its early-out
|
||||
resize(w, h);
|
||||
hasValidCapture_ = false;
|
||||
markBackgroundDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void AcrylicMaterial::captureBackground()
|
||||
{
|
||||
// Unused on DX11 — capture is done via captureBackgroundDirect()
|
||||
|
||||
+17
-16
@@ -147,6 +147,13 @@ public:
|
||||
* @param height Viewport height
|
||||
*/
|
||||
void resize(int width, int height);
|
||||
|
||||
/// Size of FB 0 in pixels (differs from the logical viewport on HiDPI GL). Used only as the capture
|
||||
/// blit source. Kept apart from viewportWidth_/Height_ so setQuality()/syncBufferLifetime() resets
|
||||
/// of those never lose it. DX11 ignores it (CopyResource needs identical dimensions, scale is 1).
|
||||
void setFramebufferSize(int w, int h) {
|
||||
if (w != fbWidth_ || h != fbHeight_) { fbWidth_ = w; fbHeight_ = h; markBackgroundDirty(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Capture current screen content for blur source
|
||||
@@ -194,6 +201,10 @@ public:
|
||||
/// has actually been rendered (theme switch happens mid-frame, before the
|
||||
/// BackgroundDrawList is rebuilt with new colors/images).
|
||||
void markBackgroundDirty() { dirtyFrames_ = 2; }
|
||||
|
||||
/// Frame-boundary maintenance. Frees the capture/blur targets while the effect is skipped
|
||||
/// (Off / disabled / reduced transparency) and rebuilds them when wanted again.
|
||||
void syncBufferLifetime();
|
||||
|
||||
/**
|
||||
* @brief Render an acrylic-filled rectangle
|
||||
@@ -348,12 +359,6 @@ private:
|
||||
*/
|
||||
void applyBlur(float radius, bool ignoreMultiplier = false);
|
||||
|
||||
/**
|
||||
* @brief Composite final acrylic appearance
|
||||
*/
|
||||
void compositeAcrylic(const ImVec2& pMin, const ImVec2& pMax,
|
||||
const AcrylicParams& params);
|
||||
|
||||
/**
|
||||
* @brief Check if effect should be skipped (settings/quality)
|
||||
*/
|
||||
@@ -377,6 +382,10 @@ private:
|
||||
// Screen dimensions
|
||||
int viewportWidth_ = 0;
|
||||
int viewportHeight_ = 0;
|
||||
int fbWidth_ = 0; // FB 0 pixel size (0 = unknown -> use the viewport size)
|
||||
int fbHeight_ = 0;
|
||||
int captureSrcWidth() const { return fbWidth_ > 0 ? fbWidth_ : viewportWidth_; }
|
||||
int captureSrcHeight() const { return fbHeight_ > 0 ? fbHeight_ : viewportHeight_; }
|
||||
|
||||
// Noise texture config
|
||||
int noiseTextureSize_ = 256; // Texture resolution (larger = less repetition)
|
||||
@@ -386,6 +395,8 @@ private:
|
||||
bool blurCacheValid_ = false;
|
||||
int dirtyFrames_ = 2; // >0 means recapture needed; counts down each frame
|
||||
bool hasValidCapture_ = false; // true once a clean BG capture exists
|
||||
void releaseBuffers();
|
||||
bool needsRealloc_ = false; // targets were freed or left stale while the effect was skipped
|
||||
|
||||
// Capture and blur framebuffers
|
||||
Framebuffer captureBuffer_;
|
||||
@@ -400,16 +411,6 @@ private:
|
||||
// Noise texture
|
||||
NoiseTexture noiseTexture_;
|
||||
|
||||
// Composite shader (tint + noise overlay)
|
||||
GLuint compositeShader_ = 0;
|
||||
GLint uCompositeTintColor_ = -1;
|
||||
GLint uCompositeTintOpacity_ = -1;
|
||||
GLint uCompositeLuminosity_ = -1;
|
||||
GLint uCompositeNoiseOpacity_ = -1;
|
||||
GLint uCompositeBlurTex_ = -1;
|
||||
GLint uCompositeNoiseTex_ = -1;
|
||||
GLint uCompositeTexScale_ = -1;
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
// ---- DX11 acrylic resources (used instead of GL objects above) ----
|
||||
ID3D11Device* dx_device_ = nullptr;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx { namespace ui { namespace effects {
|
||||
|
||||
/// Splits a rounded-rect border into line segments and writes the ascending perimeter fractions
|
||||
/// out[0]=0 .. out[n]=1 (same parametrisation and piece order as ThemeEffects::perimeterPoint:
|
||||
/// top, TR arc, right, BR arc, bottom, BL arc, left, TL arc). Straight edges get one segment per
|
||||
/// `edgeStepPx` (<= 24 per edge); each corner arc gets clamp(ceil(r*0.4),3,8) chords, i.e. a
|
||||
/// worst-case sag below 0.2 px. Returns n (>=1); needs maxSegs >= 128 + 1 entries in `out`.
|
||||
inline int BorderSampleFractions(float w, float h, float r, float edgeStepPx,
|
||||
float* out, int maxOut /* capacity of out, >= 129 */) {
|
||||
constexpr float kPi = 3.14159265f;
|
||||
out[0] = 0.0f;
|
||||
if (w <= 0.0f || h <= 0.0f || maxOut < 129) { out[1] = 1.0f; return 1; }
|
||||
r = std::min(r, std::min(w, h) * 0.5f);
|
||||
if (r < 0.5f) r = 0.0f; // perimeterPoint treats tiny rounding as none
|
||||
const float step = std::max(1.0f, edgeStepPx);
|
||||
float len[8]; int segs[8]; int np;
|
||||
auto edge = [&](float l) { return l <= 0.0f ? 0 : std::clamp((int)std::ceil(l / step), 1, 24); };
|
||||
if (r <= 0.0f) {
|
||||
np = 4;
|
||||
len[0] = w; len[1] = h; len[2] = w; len[3] = h;
|
||||
for (int i = 0; i < 4; ++i) segs[i] = edge(len[i]);
|
||||
} else {
|
||||
np = 8;
|
||||
const float arc = kPi * r * 0.5f, ew = w - 2.0f * r, eh = h - 2.0f * r;
|
||||
const int arcSegs = std::clamp((int)std::ceil(r * 0.4f), 3, 8);
|
||||
const float L[8] = { ew, arc, eh, arc, ew, arc, eh, arc };
|
||||
for (int i = 0; i < 8; ++i) { len[i] = L[i]; segs[i] = (i & 1) ? arcSegs : edge(L[i]); }
|
||||
}
|
||||
float perim = 0.0f; for (int i = 0; i < np; ++i) perim += len[i];
|
||||
int n = 0; float d0 = 0.0f;
|
||||
for (int i = 0; i < np; ++i) {
|
||||
for (int s = 1; s <= segs[i]; ++s) {
|
||||
out[++n] = (d0 + len[i] * (float)s / (float)segs[i]) / perim;
|
||||
}
|
||||
d0 += len[i];
|
||||
}
|
||||
if (n < 1) { out[1] = 1.0f; return 1; }
|
||||
out[n] = 1.0f;
|
||||
return n; // worst case 4*24 + 4*8 = 128
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
namespace dragonx { namespace ui { namespace effects {
|
||||
/// Framebuffer pixel extent for a window-unit extent and ImGui's DisplayFramebufferScale. Same truncation as
|
||||
/// imgui_impl_opengl3.cpp (`(int)(DisplaySize.x * DisplayFramebufferScale.x)`), so the blit source matches FB 0.
|
||||
inline int framebufferExtent(float logical, float scale) {
|
||||
if (!(scale > 0.0f) || !(logical > 0.0f)) return (int)logical;
|
||||
return (int)(logical * scale);
|
||||
}
|
||||
/// Source extent for the capture blit: the framebuffer size when known, else the logical size.
|
||||
inline int captureSourceExtent(int fbExtent, int logicalExtent) { return fbExtent > 0 ? fbExtent : logicalExtent; }
|
||||
}}}
|
||||
@@ -0,0 +1,34 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx { namespace ui { namespace effects {
|
||||
|
||||
constexpr double kEffectTwoPi = 6.283185307179586476925286766559;
|
||||
|
||||
/// Fractional part in [0,1) of t*rate + offset, evaluated in double so the result stays
|
||||
/// smooth after days of uptime (a float clock steps at one-frame granularity from ~3 days).
|
||||
inline double cyclePhaseD(double t, double rate, double offset = 0.0) {
|
||||
double p = std::fmod(t * rate + offset, 1.0);
|
||||
if (p < 0.0) p += 1.0;
|
||||
if (p >= 1.0) p = 0.0; // p was -tiny: p + 1.0 rounded up to 1.0
|
||||
return p;
|
||||
}
|
||||
inline float cyclePhase(double t, double rate, double offset = 0.0) {
|
||||
return static_cast<float>(cyclePhaseD(t, rate, offset));
|
||||
}
|
||||
/// sin(2*pi*(t*cycles + offset)) - oscillator whose speed is in cycles per second.
|
||||
inline float cycleSin(double t, double cycles, double offset = 0.0) {
|
||||
return static_cast<float>(std::sin(kEffectTwoPi * cyclePhaseD(t, cycles, offset)));
|
||||
}
|
||||
/// sin/cos(t*omega + offset), omega in rad/s; argument reduced in double.
|
||||
inline float radSin(double t, double omega, double offset = 0.0) {
|
||||
return static_cast<float>(std::sin(std::fmod(t * omega + offset, kEffectTwoPi)));
|
||||
}
|
||||
inline float radCos(double t, double omega, double offset = 0.0) {
|
||||
return static_cast<float>(std::cos(std::fmod(t * omega + offset, kEffectTwoPi)));
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
@@ -35,14 +35,12 @@ Framebuffer::~Framebuffer()
|
||||
Framebuffer::Framebuffer(Framebuffer&& other) noexcept
|
||||
: fbo_(other.fbo_)
|
||||
, colorTexture_(other.colorTexture_)
|
||||
, depthRbo_(other.depthRbo_)
|
||||
, width_(other.width_)
|
||||
, height_(other.height_)
|
||||
, isComplete_(other.isComplete_)
|
||||
{
|
||||
other.fbo_ = 0;
|
||||
other.colorTexture_ = 0;
|
||||
other.depthRbo_ = 0;
|
||||
other.width_ = 0;
|
||||
other.height_ = 0;
|
||||
other.isComplete_ = false;
|
||||
@@ -55,14 +53,12 @@ Framebuffer& Framebuffer::operator=(Framebuffer&& other) noexcept
|
||||
|
||||
fbo_ = other.fbo_;
|
||||
colorTexture_ = other.colorTexture_;
|
||||
depthRbo_ = other.depthRbo_;
|
||||
width_ = other.width_;
|
||||
height_ = other.height_;
|
||||
isComplete_ = other.isComplete_;
|
||||
|
||||
other.fbo_ = 0;
|
||||
other.colorTexture_ = 0;
|
||||
other.depthRbo_ = 0;
|
||||
other.width_ = 0;
|
||||
other.height_ = 0;
|
||||
other.isComplete_ = false;
|
||||
@@ -104,19 +100,12 @@ bool Framebuffer::init(int width, int height, bool useFloat16)
|
||||
// Attach color texture to framebuffer
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture_, 0);
|
||||
|
||||
// Create depth renderbuffer (optional, but good for completeness)
|
||||
glGenRenderbuffers(1, &depthRbo_);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, depthRbo_);
|
||||
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height);
|
||||
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, depthRbo_);
|
||||
|
||||
// Check completeness
|
||||
isComplete_ = checkComplete();
|
||||
|
||||
// Unbind
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||
|
||||
if (!isComplete_) {
|
||||
DEBUG_LOGF("Framebuffer::init - Framebuffer is not complete!\n");
|
||||
@@ -146,10 +135,6 @@ void Framebuffer::cleanup()
|
||||
glDeleteTextures(1, &colorTexture_);
|
||||
colorTexture_ = 0;
|
||||
}
|
||||
if (depthRbo_) {
|
||||
glDeleteRenderbuffers(1, &depthRbo_);
|
||||
depthRbo_ = 0;
|
||||
}
|
||||
if (fbo_) {
|
||||
glDeleteFramebuffers(1, &fbo_);
|
||||
fbo_ = 0;
|
||||
@@ -194,7 +179,7 @@ void Framebuffer::unbind()
|
||||
void Framebuffer::clear(float r, float g, float b, float a)
|
||||
{
|
||||
glClearColor(r, g, b, a);
|
||||
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
void Framebuffer::blitFrom(GLuint srcFbo, int srcX, int srcY, int srcWidth, int srcHeight,
|
||||
@@ -213,14 +198,14 @@ void Framebuffer::blitFrom(GLuint srcFbo, int srcX, int srcY, int srcWidth, int
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
}
|
||||
|
||||
void Framebuffer::captureScreen(int viewportWidth, int viewportHeight)
|
||||
void Framebuffer::captureScreen(int srcWidth, int srcHeight)
|
||||
{
|
||||
// Blit from default framebuffer (0) to this framebuffer
|
||||
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
|
||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo_);
|
||||
|
||||
glBlitFramebuffer(
|
||||
0, 0, viewportWidth, viewportHeight, // Source (screen)
|
||||
0, 0, srcWidth, srcHeight, // Source (screen)
|
||||
0, 0, width_, height_, // Dest (this FBO)
|
||||
GL_COLOR_BUFFER_BIT,
|
||||
GL_LINEAR
|
||||
|
||||
@@ -117,7 +117,7 @@ public:
|
||||
* @param viewportWidth Current viewport width
|
||||
* @param viewportHeight Current viewport height
|
||||
*/
|
||||
void captureScreen(int viewportWidth, int viewportHeight);
|
||||
void captureScreen(int srcWidth, int srcHeight);
|
||||
|
||||
private:
|
||||
void cleanup();
|
||||
@@ -125,7 +125,6 @@ private:
|
||||
|
||||
GLuint fbo_ = 0;
|
||||
GLuint colorTexture_ = 0;
|
||||
GLuint depthRbo_ = 0; // Renderbuffer for depth (optional)
|
||||
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
|
||||
@@ -125,6 +125,8 @@ namespace ImGuiAcrylic {
|
||||
static bool s_initialized = false;
|
||||
static int s_viewportWidth = 0;
|
||||
static int s_viewportHeight = 0;
|
||||
static int s_fbWidth = 0;
|
||||
static int s_fbHeight = 0;
|
||||
static bool s_backgroundCaptured = false;
|
||||
|
||||
// Track window state for acrylic rendering
|
||||
@@ -179,11 +181,24 @@ bool IsAvailable()
|
||||
// ============================================================================
|
||||
|
||||
void BeginFrame(int width, int height)
|
||||
{
|
||||
BeginFrame(width, height, width, height);
|
||||
}
|
||||
|
||||
void BeginFrame(int width, int height, int fbWidth, int fbHeight)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Moving the window between a Retina and a non-Retina display changes the framebuffer size while the
|
||||
// logical size stays put: the capture must be redone against the new pixel grid.
|
||||
if (fbWidth != s_fbWidth || fbHeight != s_fbHeight) {
|
||||
s_fbWidth = fbWidth;
|
||||
s_fbHeight = fbHeight;
|
||||
getAcrylicMaterial().setFramebufferSize(fbWidth, fbHeight);
|
||||
}
|
||||
|
||||
// Update viewport dimensions if changed
|
||||
if (width != s_viewportWidth || height != s_viewportHeight) {
|
||||
s_viewportWidth = width;
|
||||
@@ -221,6 +236,11 @@ void InvalidateCapture()
|
||||
getAcrylicMaterial().markBackgroundDirty();
|
||||
}
|
||||
|
||||
void ReleaseIdleBuffers()
|
||||
{
|
||||
if (s_initialized) getAcrylicMaterial().syncBufferLifetime();
|
||||
}
|
||||
|
||||
// Draw callback: captures framebuffer 0 (which at this point during
|
||||
// RenderDrawData() contains only the rasterized BackgroundDrawList —
|
||||
// no UI windows yet) into the acrylic capture buffer.
|
||||
@@ -665,8 +685,10 @@ void Shutdown() {}
|
||||
bool IsAvailable() { return false; }
|
||||
|
||||
void BeginFrame(int, int) {}
|
||||
void BeginFrame(int, int, int, int) {}
|
||||
void CaptureBackground() {}
|
||||
void InvalidateCapture() {}
|
||||
void ReleaseIdleBuffers() {}
|
||||
ImDrawCallback GetBackgroundCaptureCallback() { return nullptr; }
|
||||
ImDrawCallback GetLiveCaptureCallback() { return nullptr; }
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ namespace dragonx {
|
||||
namespace ui {
|
||||
namespace effects {
|
||||
|
||||
// Top of the Acrylic slider's range (the blur-radius multiplier at 100%). The panels frost at full
|
||||
// alpha now, so this only controls how BLURRY the max is — kept gentle (the old max of 4.0 was way
|
||||
// too strong). Saved values from the old 0–4 range are clamped to it wherever they're applied.
|
||||
inline constexpr float kAcrylicMaxBlur = 1.25f;
|
||||
|
||||
/**
|
||||
* @brief ImGui integration for acrylic effects
|
||||
*
|
||||
@@ -56,7 +61,10 @@ bool IsAvailable();
|
||||
* @param width Viewport width
|
||||
* @param height Viewport height
|
||||
*/
|
||||
void BeginFrame(int width, int height);
|
||||
void BeginFrame(int width, int height); ///< framebuffer == window size
|
||||
/// HiDPI: fbWidth/fbHeight are FB 0's pixel size (DisplaySize * DisplayFramebufferScale). The capture
|
||||
/// and blur targets stay logical-size; only the capture blit source rectangle uses the pixel size.
|
||||
void BeginFrame(int width, int height, int fbWidth, int fbHeight);
|
||||
|
||||
/**
|
||||
* @brief End the acrylic frame
|
||||
@@ -73,6 +81,16 @@ void CaptureBackground();
|
||||
*/
|
||||
void InvalidateCapture();
|
||||
|
||||
/**
|
||||
* @brief Frame-boundary maintenance: free the acrylic capture/blur targets
|
||||
* while the effect is skipped (blur off, low-spec, reduced
|
||||
* transparency) and rebuild them when it is wanted again.
|
||||
*
|
||||
* Call once per frame right after App::preFrame(), before NewFrame(), so
|
||||
* nothing recorded in a draw list can still reference a freed target.
|
||||
*/
|
||||
void ReleaseIdleBuffers();
|
||||
|
||||
/**
|
||||
* @brief Get a draw callback for background capture.
|
||||
*
|
||||
|
||||
@@ -49,6 +49,7 @@ struct ScrollFadeShader {
|
||||
|
||||
// ---- Internal state ----
|
||||
bool ready = false;
|
||||
bool failed = false; // a failed init is final until destroy(): no per-frame recompiles on a rejecting driver
|
||||
|
||||
#ifdef DRAGONX_HAS_GLAD
|
||||
GLuint program_ = 0;
|
||||
@@ -72,11 +73,16 @@ struct ScrollFadeShader {
|
||||
|
||||
bool init() {
|
||||
if (ready) return true;
|
||||
if (failed) return false;
|
||||
#ifdef DRAGONX_HAS_GLAD
|
||||
ready = initGL();
|
||||
#elif defined(DRAGONX_USE_DX11)
|
||||
ready = initDX11();
|
||||
#endif
|
||||
if (!ready) {
|
||||
failed = true;
|
||||
LOGF("ScrollFadeShader: init failed, scroll-edge fades disabled\n"); // once; release builds record it
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
@@ -87,6 +93,7 @@ struct ScrollFadeShader {
|
||||
destroyDX11();
|
||||
#endif
|
||||
ready = false;
|
||||
failed = false;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include "theme_effects.h"
|
||||
#include "low_spec.h"
|
||||
#include "border_sampling.h"
|
||||
#include "effect_clock.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../layout.h"
|
||||
#include <cmath>
|
||||
@@ -47,7 +49,7 @@ ThemeEffects& ThemeEffects::instance() {
|
||||
// ============================================================================
|
||||
|
||||
void ThemeEffects::beginFrame() {
|
||||
time_ = (float)ImGui::GetTime();
|
||||
if (!motion_frozen_) time_ = ImGui::GetTime();
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
vpMinY_ = vp->WorkPos.y;
|
||||
vpMaxY_ = vp->WorkPos.y + vp->WorkSize.y;
|
||||
@@ -248,7 +250,7 @@ void ThemeEffects::loadFromTheme() {
|
||||
ImU32 ThemeEffects::getAccentColor(float phaseOffset) const {
|
||||
if (!enabled_ || !hue_cycle_.enabled) return IM_COL32(255, 218, 0, 255);
|
||||
|
||||
float hue = std::fmod(hue_cycle_.offset + time_ * hue_cycle_.speed + phaseOffset, 1.0f);
|
||||
float hue = cyclePhase(time_, hue_cycle_.speed, (double)hue_cycle_.offset + phaseOffset);
|
||||
if (hue < 0.0f) hue += 1.0f;
|
||||
// Clamp to configured range
|
||||
hue = hue_cycle_.offset + std::fmod(hue, std::max(0.01f, hue_cycle_.range));
|
||||
@@ -293,31 +295,25 @@ void ThemeEffects::drawRainbowBorder(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding, float thickness) const {
|
||||
if (!enabled_ || !rainbow_border_.enabled || rainbow_border_.stops.empty()) return;
|
||||
|
||||
float t = time_ * rainbow_border_.speed;
|
||||
// Final line alpha is rainbow_border_.alpha * bgOpacity_ (sampleGradient); under 1/255 nothing shows.
|
||||
if (rainbow_border_.alpha * bgOpacity_ * 255.0f < 1.0f) return;
|
||||
|
||||
// Draw the rainbow border as line segments around the rounded perimeter.
|
||||
// Each segment gets a color sampled from the gradient at its position,
|
||||
// giving a smoothly rotating hue-shift that follows rounded corners.
|
||||
//
|
||||
// We need enough segments so that the curved corners are smooth.
|
||||
// Each corner arc ≈ π*r/2 pixels. We want ~1 segment per 2-3px of arc
|
||||
// to look smooth, plus the straight edges.
|
||||
float t = cyclePhase(time_, rainbow_border_.speed);
|
||||
|
||||
// The border is drawn as line segments whose colour is sampled from the gradient at the segment
|
||||
// midpoint. Straight edges change colour slowly (stops span a third of the perimeter at alpha
|
||||
// ~0.08), so they get one segment per ~32 dp; corner arcs get a few chords each so they stay round.
|
||||
const float dp = Layout::dpiScale();
|
||||
float w = pMax.x - pMin.x;
|
||||
float h = pMax.y - pMin.y;
|
||||
float r = std::min(rounding, std::min(w, h) * 0.5f);
|
||||
float cornerSegs = (r > 0.5f) ? std::ceil(r * 1.5f) : 0.0f; // segments per corner arc
|
||||
int segments = std::max(48, (int)(4 * cornerSegs + 2 * (w + h) / 8.0f));
|
||||
segments = std::min(segments, 256); // cap for perf
|
||||
float fr[129];
|
||||
const int segments = BorderSampleFractions(w, h, std::min(rounding, std::min(w, h) * 0.5f),
|
||||
32.0f * dp, fr, 129);
|
||||
|
||||
ImVec2 prev = perimeterPoint(pMin, pMax, 0.0f, rounding);
|
||||
for (int i = 1; i <= segments; i++) {
|
||||
float frac = (float)i / (float)segments;
|
||||
ImVec2 pt = perimeterPoint(pMin, pMax, frac, rounding);
|
||||
|
||||
// Sample gradient at the midpoint of this segment for smooth color
|
||||
float midFrac = (frac - 0.5f / segments);
|
||||
ImU32 c = sampleGradient(t + midFrac);
|
||||
|
||||
ImVec2 pt = perimeterPoint(pMin, pMax, fr[i], rounding);
|
||||
ImU32 c = sampleGradient(t + 0.5f * (fr[i - 1] + fr[i])); // colour at the segment midpoint
|
||||
dl->AddLine(prev, pt, c, thickness);
|
||||
prev = pt;
|
||||
}
|
||||
@@ -338,7 +334,7 @@ void ThemeEffects::drawShimmer(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
// Sweep position: band moves left to right across the panel
|
||||
float period = 1.0f / std::max(0.01f, shimmer_.speed);
|
||||
float totalTravel = w + shimmer_.width * 2.0f; // band enters and exits
|
||||
float pos = std::fmod(time_ / period, 1.0f) * totalTravel - shimmer_.width;
|
||||
float pos = cyclePhase(time_, 1.0 / period) * totalTravel - shimmer_.width;
|
||||
|
||||
// Band bounds (horizontal, ignoring angle for simplicity in this version)
|
||||
float bandLeft = pMin.x + pos;
|
||||
@@ -408,10 +404,10 @@ void ThemeEffects::drawSpecularGlare(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float phaseSeed = seed + s * 3.14159265f;
|
||||
|
||||
// Slow Lissajous drift — each spot follows a unique orbit
|
||||
float freqX = 2.0f * 3.14159265f * specular_glare_.speed;
|
||||
float freqY = freqX * 1.618f; // golden ratio offset → non-repeating path
|
||||
float driftX = std::sin(time_ * freqX + phaseSeed) * 0.3f + 0.5f;
|
||||
float driftY = std::cos(time_ * freqY + phaseSeed * 1.3f) * 0.25f + 0.4f;
|
||||
const double freqX = kEffectTwoPi * specular_glare_.speed;
|
||||
const double freqY = freqX * 1.618; // golden ratio offset → non-repeating path
|
||||
float driftX = radSin(time_, freqX, phaseSeed) * 0.3f + 0.5f;
|
||||
float driftY = radCos(time_, freqY, phaseSeed * 1.3) * 0.25f + 0.4f;
|
||||
|
||||
float cx = pMin.x + w * driftX;
|
||||
float cy = pMin.y + h * driftY;
|
||||
@@ -484,7 +480,7 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
if (!enabled_ || !glow_pulse_.enabled) return;
|
||||
|
||||
// Sinusoidal oscillation
|
||||
float phase = std::sin(time_ * glow_pulse_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||
float phase = cycleSin(time_, glow_pulse_.speed) * 0.5f + 0.5f;
|
||||
float alpha = glow_pulse_.minAlpha + phase * (glow_pulse_.maxAlpha - glow_pulse_.minAlpha);
|
||||
if (alpha <= 0.001f) return;
|
||||
|
||||
@@ -525,8 +521,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p
|
||||
// Smooth sinusoidal oscillation between color A and color B.
|
||||
// phaseOffset shifts where in the cycle this element sits so a wall of
|
||||
// panels reads like veins at different depths rather than one pulse.
|
||||
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
|
||||
* 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||
float phase = cycleSin(time_, gradient_border_.speed, phaseOffset) * 0.5f + 0.5f;
|
||||
|
||||
// Extract RGBA from both colors and lerp
|
||||
RGB ca = unpackRGB(gradient_border_.colorA);
|
||||
@@ -649,7 +644,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
float rounding) const {
|
||||
if (!enabled_ || !edge_trace_.enabled) return;
|
||||
|
||||
float headPos = std::fmod(time_ * edge_trace_.speed, 1.0f);
|
||||
float headPos = cyclePhase(time_, edge_trace_.speed);
|
||||
float traceLen = edge_trace_.length;
|
||||
|
||||
RGB c = unpackRGB(edge_trace_.color);
|
||||
@@ -705,12 +700,12 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
|
||||
|
||||
for (int i = 0; i < ember_rise_.count; i++) {
|
||||
// Golden-ratio spacing gives evenly distributed phases
|
||||
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f, 1.0f);
|
||||
float phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988);
|
||||
|
||||
// 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 * dp; // gentle sway
|
||||
float xDrift = radSin(time_, 0.7, i * 2.4) * 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
|
||||
@@ -763,13 +758,13 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
||||
float vpAlpha = ember_rise_.alpha * 0.4f * bgOpacity_; // softer than panel embers
|
||||
|
||||
for (int i = 0; i < vpCount; i++) {
|
||||
float phase = std::fmod(time_ * ember_rise_.speed * 0.6f + i * 0.618033988f, 1.0f);
|
||||
float phase = cyclePhase(time_, ember_rise_.speed * 0.6, i * 0.618033988);
|
||||
|
||||
// Deterministic x position spread across viewport
|
||||
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 * dp;
|
||||
float xDrift = radSin(time_, 0.5, i * 1.7) * 8.0f * dp;
|
||||
|
||||
float x = vpX + vpW * xHash + xDrift;
|
||||
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
|
||||
@@ -815,8 +810,7 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const {
|
||||
float windDy = std::sin(windRad); // vertical component (positive = downward)
|
||||
|
||||
// Global gust modulation: smoothly varies wind speed
|
||||
float gust = 1.0f + sandstorm_.gustStrength *
|
||||
std::sin(time_ * sandstorm_.gustSpeed * 2.0f * 3.14159265f);
|
||||
float gust = 1.0f + sandstorm_.gustStrength * cycleSin(time_, sandstorm_.gustSpeed);
|
||||
|
||||
// Diagonal traversal distance (how far a particle travels across viewport)
|
||||
float traversal = vpW + vpH * std::abs(windDy / std::max(0.01f, windDx));
|
||||
@@ -844,14 +838,13 @@ void ThemeEffects::drawSandstorm(ImDrawList* dl) const {
|
||||
|
||||
// Phase: horizontal progress across viewport (0→1)
|
||||
// Different particles have staggered starts via golden ratio
|
||||
float phase = std::fmod(
|
||||
time_ * sandstorm_.speed * speedMul * gust + i * 0.618033988f, 1.0f);
|
||||
float phase = cyclePhase(time_, (double)sandstorm_.speed * speedMul * gust, i * 0.618033988);
|
||||
|
||||
// Y position: distributed across viewport with turbulent drift
|
||||
float yBase = vpY + vpH * hash2;
|
||||
float yTurb = std::sin(time_ * 0.8f + i * 3.7f) * vpH * 0.04f;
|
||||
float yTurb = radSin(time_, 0.8, i * 3.7) * vpH * 0.04f;
|
||||
// Additional gust-correlated vertical shift
|
||||
float yGust = std::sin(time_ * sandstorm_.gustSpeed * 6.28318f + i * 1.3f) * vpH * 0.02f;
|
||||
float yGust = radSin(time_, sandstorm_.gustSpeed * 6.28318, i * 1.3) * vpH * 0.02f;
|
||||
|
||||
// Position: particle blows right-to-left with downward drift
|
||||
float x = vpX + vpW * (1.0f - phase) + phase * traversal * (1.0f - windDx) * 0.1f;
|
||||
@@ -935,7 +928,7 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
posKey = posKey - (int)posKey; // fractional 0..1
|
||||
if (posKey < 0) posKey += 1.0f;
|
||||
|
||||
float headPos = std::fmod(time_ * edge_trace_.speed + posKey, 1.0f);
|
||||
float headPos = cyclePhase(time_, edge_trace_.speed, posKey);
|
||||
float traceLen = edge_trace_.length;
|
||||
|
||||
RGB c = unpackRGB(edge_trace_.color);
|
||||
@@ -988,9 +981,9 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||
ImVec2(pMax.x + 2, pMax.y + 2), true);
|
||||
|
||||
for (int i = 0; i < panelCount; i++) {
|
||||
float phase = std::fmod(time_ * ember_rise_.speed + i * 0.618033988f + seed, 1.0f);
|
||||
float phase = cyclePhase(time_, ember_rise_.speed, i * 0.618033988 + seed);
|
||||
float xHash = std::sin((float)(i + 1) * 127.1f + seed * 100.0f) * 0.5f + 0.5f;
|
||||
float xDrift = std::sin(time_ * 0.7f + i * 2.4f + seed) * 3.0f;
|
||||
float xDrift = radSin(time_, 0.7, i * 2.4 + seed) * 3.0f;
|
||||
|
||||
float x = pMin.x + w * xHash + xDrift;
|
||||
float y = pMax.y - phase * (h + 6.0f);
|
||||
@@ -1038,7 +1031,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const {
|
||||
|
||||
// Rotation: smoothly shift which color appears at which corner
|
||||
if (viewport_overlay_.washRotateSpeed > 0.001f) {
|
||||
float shift = std::fmod(time_ * viewport_overlay_.washRotateSpeed, 1.0f);
|
||||
float shift = cyclePhase(time_, viewport_overlay_.washRotateSpeed);
|
||||
ImU32 originals[4] = { corners[0], corners[1], corners[2], corners[3] };
|
||||
|
||||
for (int c = 0; c < 4; c++) {
|
||||
@@ -1068,7 +1061,7 @@ void ThemeEffects::drawViewportOverlay(ImDrawList* dl) const {
|
||||
// Smooth breathing via colour modulation (not alpha)
|
||||
float colorMul = 1.0f;
|
||||
if (viewport_overlay_.washPulseSpeed > 0.001f) {
|
||||
float pulse = std::sin(time_ * viewport_overlay_.washPulseSpeed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||
float pulse = cycleSin(time_, viewport_overlay_.washPulseSpeed) * 0.5f + 0.5f;
|
||||
colorMul = 1.0f - viewport_overlay_.washPulseDepth * (1.0f - pulse);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ public:
|
||||
void setEnabled(bool enabled) { enabled_ = enabled; }
|
||||
bool isEnabled() const { return enabled_; }
|
||||
|
||||
/// Hold the effect clock still (Reduce Motion): effects keep their colors and layout but stop
|
||||
/// moving, so the main loop doesn't need ambient frames for them.
|
||||
void setMotionFrozen(bool frozen) { motion_frozen_ = frozen; }
|
||||
|
||||
/// Background opacity (from window opacity slider). Multiplies
|
||||
/// all effect alphas so they fade with the backdrop while UI stays opaque.
|
||||
void setBackgroundOpacity(float o) { bgOpacity_ = std::max(0.0f, std::min(1.0f, o)); }
|
||||
@@ -129,7 +133,8 @@ private:
|
||||
|
||||
bool enabled_ = true;
|
||||
bool reduced_transparency_ = false;
|
||||
float time_ = 0.0f;
|
||||
double time_ = 0.0; ///< ImGui clock, kept double: float steps coarsely after days of uptime
|
||||
bool motion_frozen_ = false; ///< Reduce Motion: beginFrame() leaves time_ alone
|
||||
float bgOpacity_ = 1.0f; ///< window-opacity multiplier for effect alphas
|
||||
|
||||
// Viewport bounds (set each frame)
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
@@ -88,37 +91,52 @@ std::string ExplorerBlockCache::defaultLegacyJsonPath()
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::failOpen(const char* why)
|
||||
{
|
||||
retry_not_before_ = std::chrono::steady_clock::now() + open_retry_interval_;
|
||||
DEBUG_LOGF("Explorer block cache unavailable (%s); retrying in %lld ms\n", why,
|
||||
static_cast<long long>(open_retry_interval_.count()));
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
// Negative cache: ~6 cache calls per frame used to each redo create_directories + open + PRAGMAs + CREATE
|
||||
// TABLE (and a 2 s busy wait on the UI thread) when the file was unusable.
|
||||
if (retry_not_before_ != std::chrono::steady_clock::time_point{} &&
|
||||
std::chrono::steady_clock::now() < retry_not_before_) return false;
|
||||
++open_attempts_;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to create explorer cache directory: %s\n", e.what());
|
||||
return false;
|
||||
return failOpen(e.what());
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open explorer block cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
const bool failed = failOpen(openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
return failed;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
||||
// freezing the frame for 2 s.
|
||||
sqlite3_busy_timeout(db_, 250);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
return failOpen("schema");
|
||||
}
|
||||
|
||||
retry_not_before_ = {};
|
||||
migrateLegacyJsonIfNeeded();
|
||||
pruneTo(kMaxCachedBlocks); // once per open, never per frame
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -126,55 +144,67 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
||||
{
|
||||
std::map<int, ExplorerBlockSummary> blocks;
|
||||
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
|
||||
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) return blocks;
|
||||
if (minHeight < 1 || maxHeight < 1) return blocks;
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
|
||||
if (!statement.handle) return blocks;
|
||||
if (ensureOpen()) {
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
|
||||
if (statement.handle) {
|
||||
sqlite3_bind_int(statement.handle, 1, minHeight);
|
||||
sqlite3_bind_int(statement.handle, 2, maxHeight);
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, minHeight);
|
||||
sqlite3_bind_int(statement.handle, 2, maxHeight);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) {
|
||||
blocks[block.height] = std::move(block);
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) {
|
||||
blocks[block.height] = std::move(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heights the DB could not give (closed, or refused the insert) come from the in-memory spill.
|
||||
for (auto it = spill_.lower_bound(minHeight); it != spill_.end() && it->first <= maxHeight; ++it)
|
||||
blocks.emplace(it->first, it->second); // emplace never overwrites a DB row
|
||||
return blocks;
|
||||
}
|
||||
|
||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||
{
|
||||
std::vector<ExplorerBlockSummary> results;
|
||||
if (query.empty() || limit < 1 || !ensureOpen()) return results;
|
||||
if (query.empty() || limit < 1) return results;
|
||||
|
||||
// Escape LIKE wildcards in the user input so '%' / '_' are matched literally.
|
||||
std::string escaped;
|
||||
escaped.reserve(query.size());
|
||||
for (char c : query) {
|
||||
if (c == '%' || c == '_' || c == '\\') escaped.push_back('\\');
|
||||
escaped.push_back(c);
|
||||
// instr() is a plain substring test (no wildcards to escape). Daemon hashes are lowercase hex, so lowercase
|
||||
// the needle (LIKE was ASCII case-insensitive). A non-numeric needle can never occur inside a decimal
|
||||
// height, so only an all-digit query also checks the height text.
|
||||
std::string needle = query;
|
||||
for (char& c : needle) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const bool digitsOnly = std::all_of(needle.begin(), needle.end(),
|
||||
[](char ch) { return ch >= '0' && ch <= '9'; });
|
||||
|
||||
if (!ensureOpen()) { // DB down: search the in-memory spill instead of returning nothing
|
||||
for (auto it = spill_.rbegin(); it != spill_.rend() && (int)results.size() < limit; ++it)
|
||||
if (it->second.hash.find(needle) != std::string::npos ||
|
||||
(digitsOnly && std::to_string(it->first).find(needle) != std::string::npos))
|
||||
results.push_back(it->second);
|
||||
return results;
|
||||
}
|
||||
std::string pattern = "%" + escaped + "%";
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks "
|
||||
"WHERE CAST(height AS TEXT) LIKE ?1 ESCAPE '\\' OR hash LIKE ?1 ESCAPE '\\' "
|
||||
"ORDER BY height DESC LIMIT ?2");
|
||||
Statement statement(db_, digitsOnly
|
||||
? "SELECT height, hash, tx_count, size, time, difficulty FROM explorer_blocks "
|
||||
"WHERE instr(CAST(height AS TEXT), ?1) > 0 OR instr(hash, ?1) > 0 ORDER BY height DESC LIMIT ?2"
|
||||
: "SELECT height, hash, tx_count, size, time, difficulty FROM explorer_blocks "
|
||||
"WHERE instr(hash, ?1) > 0 ORDER BY height DESC LIMIT ?2");
|
||||
if (!statement.handle) return results;
|
||||
|
||||
sqlite3_bind_text(statement.handle, 1, pattern.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(statement.handle, 1, needle.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(statement.handle, 2, limit);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
@@ -189,10 +219,22 @@ std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::st
|
||||
if (block.height > 0 && !block.hash.empty()) results.push_back(std::move(block));
|
||||
}
|
||||
|
||||
// A block the DB refused (transient busy) is held in spill_ and shows in the list via loadRange, so
|
||||
// keep search consistent: merge any matching spill entry the DB query did not already return.
|
||||
for (auto it = spill_.rbegin(); it != spill_.rend() && (int)results.size() < limit; ++it) {
|
||||
if (!(it->second.hash.find(needle) != std::string::npos ||
|
||||
(digitsOnly && std::to_string(it->first).find(needle) != std::string::npos)))
|
||||
continue;
|
||||
if (std::any_of(results.begin(), results.end(),
|
||||
[&](const ExplorerBlockSummary& b) { return b.height == it->first; }))
|
||||
continue;
|
||||
results.push_back(it->second);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
|
||||
|
||||
@@ -211,6 +253,40 @@ bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty()) return false;
|
||||
const bool stored = storeBlockRow(block);
|
||||
if (stored) {
|
||||
spill_.erase(block.height);
|
||||
if (++stores_since_prune_ >= 1000) { stores_since_prune_ = 0; pruneTo(kMaxCachedBlocks); }
|
||||
} else {
|
||||
spillBlock(block);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlocks(const std::vector<ExplorerBlockSummary>& blocks)
|
||||
{
|
||||
if (blocks.empty()) return true;
|
||||
// A savepoint, not BEGIN: nests inside a caller's transaction (same reason as updateTip).
|
||||
const bool grouped = ensureOpen() && exec("SAVEPOINT explorer_batch");
|
||||
bool all = true;
|
||||
for (const auto& b : blocks) all = storeBlock(b) && all;
|
||||
if (grouped) exec("RELEASE explorer_batch");
|
||||
return all;
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::spillBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
spill_[block.height] = block;
|
||||
while (spill_.size() > kMaxSpillBlocks) {
|
||||
const int farFirst = std::abs(spill_.begin()->first - block.height);
|
||||
const int farLast = std::abs(spill_.rbegin()->first - block.height);
|
||||
spill_.erase(farFirst >= farLast ? spill_.begin() : std::prev(spill_.end()));
|
||||
}
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::cachedBlockCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
@@ -221,15 +297,47 @@ int ExplorerBlockCache::cachedBlockCount()
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
spill_.clear();
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||
{
|
||||
if (maxRows < 1 || !db_) return;
|
||||
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
||||
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
||||
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
||||
Statement statement(db_,
|
||||
"DELETE FROM explorer_blocks WHERE height <= "
|
||||
"(SELECT height FROM explorer_blocks ORDER BY height DESC LIMIT 1 OFFSET ?1)");
|
||||
if (!statement.handle) return;
|
||||
sqlite3_bind_int(statement.handle, 1, maxRows);
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
int currentHeight, const std::string& currentBestHash)
|
||||
{
|
||||
SavedTipValidation validation;
|
||||
if (currentHeight <= 0 || !ensureOpen()) return validation;
|
||||
if (currentHeight <= 0) return validation;
|
||||
|
||||
// Steady state: the tip hasn't moved since it was saved. This runs every frame the Explorer is
|
||||
// open, and used to read both meta rows and rewrite them each time (two autocommit WAL writes per
|
||||
// frame). Only an exact match takes the shortcut; anything else goes through the checks below.
|
||||
if (tip_known_ && !currentBestHash.empty() &&
|
||||
currentHeight == tip_height_ && currentBestHash == tip_hash_) {
|
||||
return validation;
|
||||
}
|
||||
if (!ensureOpen()) {
|
||||
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||
(currentHeight < spill_tip_height_ ||
|
||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
||||
spill_.clear();
|
||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||
return validation;
|
||||
}
|
||||
|
||||
int savedHeight = getMetaInt("tip_height", 0);
|
||||
std::string savedHash = getMetaValue("tip_hash");
|
||||
@@ -248,8 +356,15 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
|
||||
if (currentHeight == savedHeight) {
|
||||
if (currentBestHash.empty()) return validation;
|
||||
if (currentBestHash != savedHash) clearBlocks();
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
if (currentBestHash != savedHash) {
|
||||
clearBlocks(); // same height, different block: a reorg
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
} else {
|
||||
// Already saved: remember it so later frames skip SQLite (nothing to write).
|
||||
tip_known_ = true;
|
||||
tip_height_ = savedHeight;
|
||||
tip_hash_ = savedHash;
|
||||
}
|
||||
return validation;
|
||||
}
|
||||
|
||||
@@ -280,9 +395,26 @@ void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& valid
|
||||
|
||||
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
|
||||
{
|
||||
tip_known_ = false;
|
||||
if (!ensureOpen()) return;
|
||||
setMetaValue("tip_height", std::to_string(std::max(0, height)));
|
||||
setMetaValue("tip_hash", hash);
|
||||
// Both rows in one transaction, so a crash between them can't save a new height with the old hash
|
||||
// (which would wipe the cache for nothing on the next run). A savepoint, not BEGIN, so it nests
|
||||
// inside a caller's transaction instead of failing.
|
||||
if (!exec("SAVEPOINT explorer_tip")) return;
|
||||
const bool written = setMetaValue("tip_height", std::to_string(std::max(0, height))) &&
|
||||
setMetaValue("tip_hash", hash);
|
||||
if (!written) exec("ROLLBACK TO explorer_tip");
|
||||
const bool released = exec("RELEASE explorer_tip");
|
||||
if (written && released && height > 0 && !hash.empty()) {
|
||||
tip_known_ = true;
|
||||
tip_height_ = height;
|
||||
tip_hash_ = hash;
|
||||
}
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::totalChanges() const
|
||||
{
|
||||
return db_ ? sqlite3_total_changes(db_) : 0;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::exec(const char* sql)
|
||||
@@ -322,16 +454,16 @@ int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
bool ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
if (!ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, key)) return;
|
||||
if (!bindText(statement.handle, 2, value)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
if (!statement.handle) return false;
|
||||
if (!bindText(statement.handle, 1, key)) return false;
|
||||
if (!bindText(statement.handle, 2, value)) return false;
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::createSchema()
|
||||
@@ -392,6 +524,7 @@ void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
|
||||
|
||||
void ExplorerBlockCache::close()
|
||||
{
|
||||
tip_known_ = false;
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
@@ -45,9 +47,21 @@ public:
|
||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||
// Always returns the DB result; a block the DB refuses (unopenable file, failed insert) is kept in a small
|
||||
// in-memory spill so the Explorer still shows it instead of refetching it forever.
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
// storeBlock for a batch inside one SAVEPOINT (one WAL commit instead of one per block). True if every block hit the DB.
|
||||
bool storeBlocks(const std::vector<ExplorerBlockSummary>& blocks);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
// Keep only the `maxRows` highest blocks (data-safe: the table is a re-fetchable RPC cache).
|
||||
void pruneTo(int maxRows);
|
||||
static constexpr int kMaxCachedBlocks = 20000;
|
||||
|
||||
// A failed open is not retried until this interval has passed (default 30 s). openAttempts() counts real attempts.
|
||||
void setOpenRetryInterval(std::chrono::milliseconds interval) { open_retry_interval_ = interval; }
|
||||
int openAttempts() const { return open_attempts_; }
|
||||
std::size_t spillCount() const { return spill_.size(); }
|
||||
|
||||
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
|
||||
void applySavedTipValidation(const SavedTipValidation& validation,
|
||||
@@ -56,18 +70,44 @@ public:
|
||||
const std::string& currentBestHash);
|
||||
void updateTip(int height, const std::string& hash);
|
||||
|
||||
// Rows changed through this connection since it opened (sqlite3_total_changes); 0 when closed.
|
||||
// Lets tests check that a steady tip writes nothing.
|
||||
int totalChanges() const;
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
std::string getMetaValue(const std::string& key);
|
||||
int getMetaInt(const std::string& key, int fallback);
|
||||
void setMetaValue(const std::string& key, const std::string& value);
|
||||
bool setMetaValue(const std::string& key, const std::string& value);
|
||||
bool createSchema();
|
||||
void migrateLegacyJsonIfNeeded();
|
||||
void close();
|
||||
bool storeBlockRow(const ExplorerBlockSummary& block); // DB write only
|
||||
void spillBlock(const ExplorerBlockSummary& block);
|
||||
bool failOpen(const char* why);
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::string legacy_json_path_;
|
||||
|
||||
// The saved tip as last written or read, so the per-frame check can skip SQLite while the chain
|
||||
// tip hasn't moved. The database stays the source of truth; this is only a copy of it.
|
||||
bool tip_known_ = false;
|
||||
int tip_height_ = 0;
|
||||
std::string tip_hash_;
|
||||
|
||||
// Failure latch for ensureOpen(): after a failed open nothing retries until retry_not_before_.
|
||||
std::chrono::steady_clock::time_point retry_not_before_{};
|
||||
std::chrono::milliseconds open_retry_interval_{30000};
|
||||
int open_attempts_ = 0;
|
||||
int stores_since_prune_ = 0;
|
||||
|
||||
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||
static constexpr std::size_t kMaxSpillBlocks = 512;
|
||||
std::map<int, ExplorerBlockSummary> spill_;
|
||||
int spill_tip_height_ = 0;
|
||||
std::string spill_tip_hash_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include "colors.h"
|
||||
#include "type.h"
|
||||
#include "tooltip_style.h"
|
||||
#include "scroll_edge_fade.h"
|
||||
#include "rect_cull.h"
|
||||
#include "fill_strip.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/element_styles.h"
|
||||
#include "../schema/color_var_resolver.h"
|
||||
@@ -16,6 +19,7 @@
|
||||
#include "../effects/imgui_acrylic.h"
|
||||
#include "../theme.h"
|
||||
#include "../../util/noise_texture.h"
|
||||
#include "../../util/text_fit.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
@@ -59,17 +63,14 @@ inline ImU32 ReadableError() {
|
||||
|
||||
// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
|
||||
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
|
||||
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
|
||||
// fits (or maxWidth is non-positive). Cuts land on UTF-8 code-point boundaries; single-line text
|
||||
// costs O(log n) measures (util::textfit::middleEllipsis). Display-only — never mutate the
|
||||
// underlying value with this.
|
||||
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
|
||||
if (text.empty() || !font || maxWidth <= 0.0f) return text;
|
||||
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
|
||||
const int n = static_cast<int>(text.size());
|
||||
for (int f = n / 2; f >= 3; --f) {
|
||||
const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal
|
||||
std::string t = text.substr(0, f) + "..." + text.substr(n - b);
|
||||
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
|
||||
}
|
||||
return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
|
||||
return util::textfit::middleEllipsis(text, maxWidth, [&](const char* b, const char* e) {
|
||||
return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, b, e).x;
|
||||
});
|
||||
}
|
||||
|
||||
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
|
||||
@@ -695,10 +696,22 @@ inline void DrawCardDropShadow(ImDrawList* dl, const ImVec2& pMin, const ImVec2&
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
// True when a card lies wholly outside the draw list's current clip rect (a scrolled-out panel):
|
||||
// everything it would draw is scissored away, except the theme effects, which go to the unclipped
|
||||
// foreground list. The margin covers the light-theme drop shadow (reach 6 dp) and the border.
|
||||
inline bool CardOutsideClip(const ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax)
|
||||
{
|
||||
if (dl->_ClipRectStack.Size == 0) return false;
|
||||
return RectOutsideRect(pMin, pMax, dl->GetClipRectMin(), dl->GetClipRectMax(),
|
||||
16.0f * Layout::dpiScale());
|
||||
}
|
||||
|
||||
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
||||
const ImVec2& pMax,
|
||||
const GlassPanelSpec& spec = GlassPanelSpec())
|
||||
{
|
||||
if (CardOutsideClip(dl, pMin, pMax)) return; // scrolled out of view: nothing visible to draw
|
||||
|
||||
// Subtle drop shadow behind the card (light themes only). Drawn first so the card fill below
|
||||
// covers the interior, leaving only the outer soft blur — the card body is never contaminated.
|
||||
if (CardDropShadowWanted())
|
||||
@@ -729,9 +742,11 @@ inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
||||
// (skip the blur pass at 100% for performance)
|
||||
if (uiOp < 0.99f) {
|
||||
if (useAcrylic) {
|
||||
const auto& acrylicTheme = GetCurrentAcrylicTheme();
|
||||
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax,
|
||||
acrylicTheme.card, spec.rounding);
|
||||
// The card draws its own grain layer below (noise B, over the surface overlay), so skip
|
||||
// the acrylic primitive's grain here: it sits under the overlay at < 1 grey level.
|
||||
effects::AcrylicParams cardParams = GetCurrentAcrylicTheme().card;
|
||||
cardParams.noiseOpacity = 0.0f;
|
||||
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, cardParams, spec.rounding);
|
||||
} else {
|
||||
// Lightweight fake-glass: translucent fill
|
||||
ImU32 fill = (spec.fillAlpha == 18) ? s_glassFill : ScaleAlpha(s_glassFill, spec.fillAlpha / 18.0f);
|
||||
@@ -1149,23 +1164,15 @@ inline void ApplyScrollEdgeMask(ImDrawList* parentDL, int parentVtxStart,
|
||||
float fadeZone,
|
||||
float scrollY, float scrollMaxY)
|
||||
{
|
||||
// Neither edge fades (not scrolled down and nothing below / not scrollable): every alpha would stay 1.0
|
||||
// and no vertex would be written, so skip the whole vertex walk.
|
||||
const ScrollEdgeFade fade = ComputeScrollEdgeFade(scrollY, scrollMaxY);
|
||||
if (!fade.any()) return;
|
||||
|
||||
auto mask = [&](ImDrawList* dl, int startIdx) {
|
||||
for (int vi = startIdx; vi < dl->VtxBuffer.Size; vi++) {
|
||||
ImDrawVert& v = dl->VtxBuffer[vi];
|
||||
float alpha = 1.0f;
|
||||
|
||||
// Top fade — only when scrolled down
|
||||
if (scrollY > 1.0f) {
|
||||
float d = v.pos.y - topEdge;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
// Bottom fade — only when not at scroll bottom
|
||||
if (scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f) {
|
||||
float d = bottomEdge - v.pos.y;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
const float alpha = ScrollEdgeAlpha(fade, v.pos.y, topEdge, bottomEdge, fadeZone);
|
||||
|
||||
if (alpha < 1.0f) {
|
||||
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
|
||||
@@ -1201,6 +1208,22 @@ inline bool& SmoothScrollAnimating() {
|
||||
return sAnimating;
|
||||
}
|
||||
|
||||
/// Ask main.cpp's frame pacing for progress-rate frames (~10 fps while focused) for the next frame.
|
||||
/// For UI that keeps changing without input or RPC results: spinners and progress for work on other
|
||||
/// threads, countdowns. Call it every frame the animation runs; main.cpp consumes it once per frame.
|
||||
/// Without it, an app with theme effects off or Reduce Motion idles and such UI only moves on the
|
||||
/// 2 s housekeeping wake.
|
||||
inline bool& ProgressFrameRequested() {
|
||||
static bool sRequested = false;
|
||||
return sRequested;
|
||||
}
|
||||
inline void RequestProgressFrame() { ProgressFrameRequested() = true; }
|
||||
inline bool ConsumeProgressFrameRequest() {
|
||||
const bool v = ProgressFrameRequested();
|
||||
ProgressFrameRequested() = false;
|
||||
return v;
|
||||
}
|
||||
|
||||
inline void ApplySmoothScroll(float speed = 12.0f)
|
||||
{
|
||||
struct ScrollState {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx { namespace ui { namespace material {
|
||||
|
||||
inline int FillStripVertexCount(int n) { return n < 2 ? 0 : 2 * n; }
|
||||
inline int FillStripIndexCount(int n) { return n < 2 ? 0 : 6 * (n - 1); }
|
||||
|
||||
/// Index list for the strip: vertices come in (top_i, bottom_i) pairs, 2 triangles per column.
|
||||
/// `base` is the first vertex index; `out` must hold FillStripIndexCount(n) entries.
|
||||
inline void BuildFillStripIndices(int n, unsigned base, unsigned* out) {
|
||||
for (int i = 0; i + 1 < n; ++i) {
|
||||
const unsigned a = base + 2u * (unsigned)i, b = a + 1, c = a + 2, d = a + 3;
|
||||
*out++ = a; *out++ = b; *out++ = d;
|
||||
*out++ = a; *out++ = d; *out++ = c;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the area between an x-monotone polyline and the horizontal line y = baseY with a flat colour,
|
||||
/// in O(n). Non-anti-aliased: draw the AA polyline on top (it hides the stepped top edge; the side/bottom
|
||||
/// edges are imperceptible at the ~10% alpha these fills use). Not for concave/self-overlapping shapes.
|
||||
inline void FillUnderPolyline(ImDrawList* dl, const ImVec2* pts, int n, float baseY, ImU32 col) {
|
||||
if (n < 2 || (col & IM_COL32_A_MASK) == 0) return;
|
||||
const ImVec2 uv = ImGui::GetFontTexUvWhitePixel();
|
||||
dl->PrimReserve(FillStripIndexCount(n), FillStripVertexCount(n));
|
||||
const unsigned base = dl->_VtxCurrentIdx; // read AFTER PrimReserve (it may roll the vertex offset)
|
||||
for (int i = 0; i < n; ++i) {
|
||||
dl->PrimWriteVtx(pts[i], uv, col);
|
||||
dl->PrimWriteVtx(ImVec2(pts[i].x, baseY), uv, col);
|
||||
}
|
||||
for (int i = 0; i + 1 < n; ++i) {
|
||||
const unsigned a = base + 2u * (unsigned)i;
|
||||
dl->PrimWriteIdx((ImDrawIdx)a); dl->PrimWriteIdx((ImDrawIdx)(a + 1)); dl->PrimWriteIdx((ImDrawIdx)(a + 3));
|
||||
dl->PrimWriteIdx((ImDrawIdx)a); dl->PrimWriteIdx((ImDrawIdx)(a + 3)); dl->PrimWriteIdx((ImDrawIdx)(a + 2));
|
||||
}
|
||||
}
|
||||
|
||||
}}} // namespace
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include "imgui.h"
|
||||
namespace dragonx { namespace ui { namespace material {
|
||||
/// True when [aMin,aMax] grown by `margin` does not intersect [bMin,bMax].
|
||||
inline bool RectOutsideRect(const ImVec2& aMin, const ImVec2& aMax,
|
||||
const ImVec2& bMin, const ImVec2& bMax, float margin) {
|
||||
return aMax.x + margin < bMin.x || aMin.x - margin > bMax.x ||
|
||||
aMax.y + margin < bMin.y || aMin.y - margin > bMax.y;
|
||||
}
|
||||
}}}
|
||||
@@ -0,0 +1,47 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure maths of ApplyScrollEdgeMask (draw_helpers.h), split out so it is unit-testable without ImGui.
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
struct ScrollEdgeFade {
|
||||
bool top = false; // content scrolled off the top edge
|
||||
bool bottom = false; // more content below the bottom edge
|
||||
bool any() const { return top || bottom; }
|
||||
};
|
||||
|
||||
inline ScrollEdgeFade ComputeScrollEdgeFade(float scrollY, float scrollMaxY)
|
||||
{
|
||||
ScrollEdgeFade f;
|
||||
f.top = scrollY > 1.0f; // only when scrolled down
|
||||
f.bottom = scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f; // only when not at the scroll bottom
|
||||
return f;
|
||||
}
|
||||
|
||||
// Alpha multiplier (0..1) for a vertex at screen-y `posY`. Same maths as the former inline loop.
|
||||
inline float ScrollEdgeAlpha(const ScrollEdgeFade& f, float posY, float topEdge, float bottomEdge, float fadeZone)
|
||||
{
|
||||
float alpha = 1.0f;
|
||||
if (f.top) {
|
||||
float d = posY - topEdge;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
if (f.bottom) {
|
||||
float d = bottomEdge - posY;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
return alpha;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -34,13 +34,21 @@ namespace material {
|
||||
|
||||
const TypeSpec* Typography::getTypeSpecs()
|
||||
{
|
||||
// Rebuild each call to pick up hot-reloaded values
|
||||
// Memoized on the schema generation. Every input (typography.* and fonts.*) is read via
|
||||
// schema::UI().drawElement, and UISchema bumps generation() on each loadFromFile / loadFromString /
|
||||
// mergeOverlayFromFile (startup, hot-reload, skin/theme switch). ~0u sentinel: generation_ starts at 0,
|
||||
// so the first call always builds (pre-load defaults), then the first load rebuilds.
|
||||
// DPI / font scale / language are NOT inputs (kFont*() are raw schema px). UI thread only, as before.
|
||||
static TypeSpec specs[kNumStyles];
|
||||
static uint32_t s_builtGen = ~0u;
|
||||
|
||||
// Helper: read typography entry from UISchema
|
||||
auto& S = schema::UI();
|
||||
const uint32_t gen = S.generation();
|
||||
if (gen == s_builtGen) return specs;
|
||||
s_builtGen = gen;
|
||||
auto tspec = [&](const char* key, float defLS, float defLH, bool defUpper) -> TypeSpec {
|
||||
auto e = S.drawElement("typography", key);
|
||||
const auto& e = S.drawElement("typography", key); // ref into styleCache_: never let it escape this lambda
|
||||
float ls = e.getFloat("letter-spacing", defLS);
|
||||
float lh = e.getFloat("line-height", defLH);
|
||||
// text-transform stored as extraColors (string map)
|
||||
@@ -110,6 +118,38 @@ bool Typography::reload(ImGuiIO& io, float dpiScale)
|
||||
return load(io, dpiScale);
|
||||
}
|
||||
|
||||
// The 15 schema font sizes (TypeStyle order) that load() builds from.
|
||||
static void gatherFontSizes(float (&o)[15])
|
||||
{
|
||||
o[0] = Layout::kFontH1(); o[1] = Layout::kFontH2(); o[2] = Layout::kFontH3();
|
||||
o[3] = Layout::kFontH4(); o[4] = Layout::kFontH5(); o[5] = Layout::kFontH6();
|
||||
o[6] = Layout::kFontSubtitle1(); o[7] = Layout::kFontSubtitle2(); o[8] = Layout::kFontBody1();
|
||||
o[9] = Layout::kFontBody2(); o[10] = Layout::kFontButton(); o[11] = Layout::kFontCaption();
|
||||
o[12] = Layout::kFontOverline(); o[13] = Layout::kFontButtonSm(); o[14] = Layout::kFontButtonLg();
|
||||
}
|
||||
|
||||
bool Typography::effectiveEmoji() const
|
||||
{
|
||||
#ifdef DRAGONX_HAVE_FREETYPE
|
||||
return color_emoji_;
|
||||
#else
|
||||
return false; // no FreeType (osxcross): the flag changes nothing, never rebuild for it
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Typography::needsRebuild()
|
||||
{
|
||||
if (!loaded_) return false;
|
||||
const uint32_t gen = schema::UI().generation();
|
||||
if (gen == built_.gen && effectiveEmoji() == built_.emoji) return false; // O(1) per frame in steady state
|
||||
built_.gen = gen;
|
||||
float cur[15];
|
||||
gatherFontSizes(cur);
|
||||
bool diff = effectiveEmoji() != built_.emoji || Layout::kFontScale() != built_.fontScale;
|
||||
for (int i = 0; i < 15 && !diff; ++i) diff = cur[i] != built_.sizes[i];
|
||||
return diff;
|
||||
}
|
||||
|
||||
bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
{
|
||||
if (loaded_) {
|
||||
@@ -143,64 +183,40 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
// For ImGui, we need to load fonts at specific pixel sizes.
|
||||
// Font sizes come from Layout:: accessors (backed by UISchema JSON)
|
||||
|
||||
// Load fonts in order of TypeStyle enum
|
||||
|
||||
// H1: Light
|
||||
fonts_[0] = loadFont(io, kWeightLight, Layout::kFontH1() * scale, "H1");
|
||||
|
||||
// H2: Light
|
||||
fonts_[1] = loadFont(io, kWeightLight, Layout::kFontH2() * scale, "H2");
|
||||
|
||||
// H3: Regular
|
||||
fonts_[2] = loadFont(io, kWeightRegular, Layout::kFontH3() * scale, "H3");
|
||||
|
||||
// H4: Regular
|
||||
fonts_[3] = loadFont(io, kWeightRegular, Layout::kFontH4() * scale, "H4");
|
||||
|
||||
// H5: Regular
|
||||
fonts_[4] = loadFont(io, kWeightRegular, Layout::kFontH5() * scale, "H5");
|
||||
|
||||
// H6: Medium
|
||||
fonts_[5] = loadFont(io, kWeightMedium, Layout::kFontH6() * scale, "H6");
|
||||
|
||||
// Subtitle1: Regular
|
||||
fonts_[6] = loadFont(io, kWeightRegular, Layout::kFontSubtitle1() * scale, "Subtitle1");
|
||||
|
||||
// Subtitle2: Medium
|
||||
fonts_[7] = loadFont(io, kWeightMedium, Layout::kFontSubtitle2() * scale, "Subtitle2");
|
||||
|
||||
// Body1: Regular (shares with Subtitle1 if same size)
|
||||
if (Layout::kFontBody1() == Layout::kFontSubtitle1()) {
|
||||
fonts_[8] = fonts_[6]; // Reuse Subtitle1 font
|
||||
} else {
|
||||
fonts_[8] = loadFont(io, kWeightRegular, Layout::kFontBody1() * scale, "Body1");
|
||||
// Style table (order == TypeStyle enum). `emoji` = the styles that have always received the emoji merge.
|
||||
struct StyleDef { int weight; float px; const char* name; bool emoji; };
|
||||
const StyleDef defs[kNumStyles] = {
|
||||
{ kWeightLight, Layout::kFontH1(), "H1", false },
|
||||
{ kWeightLight, Layout::kFontH2(), "H2", false },
|
||||
{ kWeightRegular, Layout::kFontH3(), "H3", false },
|
||||
{ kWeightRegular, Layout::kFontH4(), "H4", false },
|
||||
{ kWeightRegular, Layout::kFontH5(), "H5", false },
|
||||
{ kWeightMedium, Layout::kFontH6(), "H6", false },
|
||||
{ kWeightRegular, Layout::kFontSubtitle1(), "Subtitle1", true },
|
||||
{ kWeightMedium, Layout::kFontSubtitle2(), "Subtitle2", true },
|
||||
{ kWeightRegular, Layout::kFontBody1(), "Body1", true },
|
||||
{ kWeightRegular, Layout::kFontBody2(), "Body2", true },
|
||||
{ kWeightMedium, Layout::kFontButton(), "Button", true },
|
||||
{ kWeightRegular, Layout::kFontCaption(), "Caption", true },
|
||||
{ kWeightRegular, Layout::kFontOverline(), "Overline", true },
|
||||
{ kWeightMedium, Layout::kFontButtonSm(), "ButtonSm", true },
|
||||
{ kWeightMedium, Layout::kFontButtonLg(), "ButtonLg", false },
|
||||
};
|
||||
static_assert(sizeof(fonts_) / sizeof(fonts_[0]) == kNumStyles, "style table must cover every TypeStyle");
|
||||
// Styles with the same (weight, scaled size, emoji) would build byte-identical ImFonts: load once and share
|
||||
// the pointer. emoji is part of the key so no style gains or loses the emoji fallback (identical rendering).
|
||||
// loadFont() adds the base face and its merges back-to-back (MergeMode attaches to Fonts.back()).
|
||||
for (int i = 0; i < kNumStyles; ++i) {
|
||||
const float px = defs[i].px * scale;
|
||||
int first = -1;
|
||||
for (int j = 0; j < i; ++j)
|
||||
if (defs[j].weight == defs[i].weight && defs[j].px * scale == px && defs[j].emoji == defs[i].emoji) { first = j; break; }
|
||||
fonts_[i] = (first >= 0) ? fonts_[first] : loadFont(io, defs[i].weight, px, defs[i].name, defs[i].emoji);
|
||||
}
|
||||
|
||||
// Body2: Regular
|
||||
fonts_[9] = loadFont(io, kWeightRegular, Layout::kFontBody2() * scale, "Body2");
|
||||
|
||||
// Button: Medium (shares with Subtitle2 if same size)
|
||||
if (Layout::kFontButton() == Layout::kFontSubtitle2()) {
|
||||
fonts_[10] = fonts_[7]; // Reuse Subtitle2 font
|
||||
} else {
|
||||
fonts_[10] = loadFont(io, kWeightMedium, Layout::kFontButton() * scale, "Button");
|
||||
}
|
||||
|
||||
// Caption: Regular
|
||||
fonts_[11] = loadFont(io, kWeightRegular, Layout::kFontCaption() * scale, "Caption");
|
||||
|
||||
// Overline: Regular
|
||||
fonts_[12] = loadFont(io, kWeightRegular, Layout::kFontOverline() * scale, "Overline");
|
||||
|
||||
// ButtonSm: Medium
|
||||
fonts_[13] = loadFont(io, kWeightMedium, Layout::kFontButtonSm() * scale, "ButtonSm");
|
||||
|
||||
// ButtonLg: Medium
|
||||
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
|
||||
|
||||
// Monospace (Ubuntu Mono) at caption size — used by the console so terminal text +
|
||||
// pretty-printed JSON columns align. Same size as Caption to keep line heights matched.
|
||||
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono");
|
||||
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono", false);
|
||||
|
||||
// --- Icon fonts at multiple sizes ---
|
||||
// These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering.
|
||||
@@ -231,6 +247,10 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
// instead of H1 (the first font loaded).
|
||||
io.FontDefault = fonts_[static_cast<int>(TypeStyle::Body1)];
|
||||
|
||||
gatherFontSizes(built_.sizes);
|
||||
built_.fontScale = Layout::kFontScale();
|
||||
built_.emoji = effectiveEmoji();
|
||||
built_.gen = schema::UI().generation();
|
||||
loaded_ = true;
|
||||
Layout::setFontAtlasScale(Layout::userFontScale());
|
||||
DEBUG_LOGF("Typography: Loaded %d font styles (default=Body1, atlasScale=%.2f)\n",
|
||||
@@ -239,7 +259,7 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
return allLoaded;
|
||||
}
|
||||
|
||||
ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* name)
|
||||
ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* name, bool wantEmoji)
|
||||
{
|
||||
// Select font data based on weight
|
||||
const unsigned char* fontData = nullptr;
|
||||
@@ -265,12 +285,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
break;
|
||||
}
|
||||
|
||||
// ImGui needs to own a copy of the font data
|
||||
void* fontDataCopy = IM_ALLOC(fontDataLen);
|
||||
memcpy(fontDataCopy, fontData, fontDataLen);
|
||||
|
||||
// Zero-copy: the INCBIN blob is immutable .rodata that outlives the atlas (ImGui 1.92 requires the data to
|
||||
// persist for the atlas lifetime); FontDataOwnedByAtlas=false means ImGui never frees or writes it.
|
||||
ImFontConfig cfg;
|
||||
cfg.FontDataOwnedByAtlas = true; // ImGui will free this
|
||||
cfg.FontDataOwnedByAtlas = false;
|
||||
cfg.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
@@ -298,18 +316,15 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
weight == kWeightMono ? "Mono" : "Regular";
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg);
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(fontData), static_cast<int>(fontDataLen), size, &cfg);
|
||||
|
||||
if (font) {
|
||||
DEBUG_LOGF("Typography: Loaded %s (%.0fpx) as '%s'\n", name, size, cfg.Name);
|
||||
|
||||
// Merge CJK fallback glyphs (Chinese/Japanese/Korean) from subset font
|
||||
if (g_noto_cjk_subset_size > 0) {
|
||||
void* cjkCopy = IM_ALLOC(g_noto_cjk_subset_size);
|
||||
memcpy(cjkCopy, g_noto_cjk_subset_data, g_noto_cjk_subset_size);
|
||||
|
||||
ImFontConfig cjkCfg;
|
||||
cjkCfg.FontDataOwnedByAtlas = true;
|
||||
cjkCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
|
||||
cjkCfg.MergeMode = true; // merge into the font we just loaded
|
||||
cjkCfg.OversampleH = 1;
|
||||
cjkCfg.OversampleV = 1;
|
||||
@@ -330,7 +345,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
cjkCfg.GlyphRanges = cjkRanges;
|
||||
snprintf(cjkCfg.Name, sizeof(cjkCfg.Name), "NotoSansCJK %.0fpx (merge)", size);
|
||||
|
||||
ImFont* mergeResult = io.Fonts->AddFontFromMemoryTTF(cjkCopy, g_noto_cjk_subset_size, size, &cjkCfg);
|
||||
ImFont* mergeResult = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_noto_cjk_subset_data),
|
||||
static_cast<int>(g_noto_cjk_subset_size), size, &cjkCfg);
|
||||
if (mergeResult) {
|
||||
DEBUG_LOGF("Typography: Merged CJK (%u bytes) into %s OK\n",
|
||||
g_noto_cjk_subset_size, name);
|
||||
@@ -340,15 +356,9 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
}
|
||||
}
|
||||
|
||||
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking
|
||||
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
|
||||
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
|
||||
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
|
||||
static const char* const kEmojiFonts[] = {
|
||||
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
|
||||
};
|
||||
bool wantEmoji = false;
|
||||
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
|
||||
// Emoji (monochrome subset, or COLR color via FreeType) is merged only into the small text styles; load()
|
||||
// passes `wantEmoji` from its style table (dedup keys on it). ImGui does no shaping (no ZWJ/flags); the
|
||||
// >0xFFFF ranges need IMGUI_USE_WCHAR32.
|
||||
|
||||
// Emoji blob: the COLR/CPAL color font (FreeType-rendered) when color emoji is enabled and this
|
||||
// is a FreeType build, else the monochrome subset (default / non-FreeType path).
|
||||
@@ -363,11 +373,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
}
|
||||
#endif
|
||||
if (wantEmoji && emojiSize > 0) {
|
||||
void* emojiCopy = IM_ALLOC(emojiSize);
|
||||
memcpy(emojiCopy, emojiData, emojiSize);
|
||||
|
||||
ImFontConfig emojiCfg;
|
||||
emojiCfg.FontDataOwnedByAtlas = true;
|
||||
emojiCfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
|
||||
emojiCfg.MergeMode = true; // merge into the text font just loaded
|
||||
emojiCfg.OversampleH = 1;
|
||||
emojiCfg.OversampleV = 1;
|
||||
@@ -388,17 +395,17 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "%s %.0fpx (merge)",
|
||||
colorGlyphs ? "Twemoji" : "NotoEmoji", size);
|
||||
|
||||
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, emojiSize, size, &emojiCfg);
|
||||
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(emojiData),
|
||||
static_cast<int>(emojiSize), size, &emojiCfg);
|
||||
if (emojiMerge) {
|
||||
DEBUG_LOGF("Typography: Merged %s emoji (%u bytes) into %s OK\n",
|
||||
colorGlyphs ? "color" : "mono", emojiSize, name);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size);
|
||||
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, emojiSize);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
|
||||
return font;
|
||||
@@ -406,12 +413,8 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
|
||||
ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
|
||||
{
|
||||
// ImGui needs to own a copy of the font data
|
||||
void* fontDataCopy = IM_ALLOC(g_material_icons_size);
|
||||
memcpy(fontDataCopy, g_material_icons_data, g_material_icons_size);
|
||||
|
||||
ImFontConfig cfg;
|
||||
cfg.FontDataOwnedByAtlas = true;
|
||||
cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
|
||||
cfg.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
@@ -423,12 +426,11 @@ ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
|
||||
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "MDIcons %.0fpx", size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_material_icons_size, size, &cfg);
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_material_icons_data), static_cast<int>(g_material_icons_size), size, &cfg);
|
||||
if (font) {
|
||||
DEBUG_LOGF("Typography: Loaded icon font %s (%.0fpx)\n", name, size);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load icon font %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
@@ -440,11 +442,8 @@ ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void* fontDataCopy = IM_ALLOC(g_mdi_pickaxe_subset_size);
|
||||
memcpy(fontDataCopy, g_mdi_pickaxe_subset_data, g_mdi_pickaxe_subset_size);
|
||||
|
||||
ImFontConfig cfg;
|
||||
cfg.FontDataOwnedByAtlas = true;
|
||||
cfg.FontDataOwnedByAtlas = false; // zero-copy: static INCBIN blob
|
||||
cfg.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
@@ -460,12 +459,11 @@ ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
|
||||
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "MDIPickaxe %.0fpx", size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_mdi_pickaxe_subset_size, size, &cfg);
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(const_cast<unsigned char*>(g_mdi_pickaxe_subset_data), static_cast<int>(g_mdi_pickaxe_subset_size), size, &cfg);
|
||||
if (font) {
|
||||
DEBUG_LOGF("Typography: Loaded pickaxe font %s (%.0fpx)\n", name, size);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load pickaxe font %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "imgui.h"
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -120,6 +121,12 @@ public:
|
||||
*/
|
||||
void setColorEmoji(bool enabled) { color_emoji_ = enabled; }
|
||||
bool colorEmoji() const { return color_emoji_; }
|
||||
|
||||
/**
|
||||
* @brief True when the schema font sizes / kFontScale / effective emoji style differ from the last build.
|
||||
* O(1) per frame while the schema generation and emoji style are unchanged (App::preFrame safety net).
|
||||
*/
|
||||
bool needsRebuild();
|
||||
|
||||
/**
|
||||
* @brief Get font for a type style
|
||||
@@ -259,7 +266,7 @@ private:
|
||||
Typography& operator=(const Typography&) = delete;
|
||||
|
||||
// Load fonts at a specific size with a specific weight
|
||||
ImFont* loadFont(ImGuiIO& io, int weight, float size, const char* name);
|
||||
ImFont* loadFont(ImGuiIO& io, int weight, float size, const char* name, bool wantEmoji);
|
||||
|
||||
// Font weight constants
|
||||
static constexpr int kWeightLight = 300;
|
||||
@@ -271,6 +278,11 @@ private:
|
||||
float dpiScale_ = 1.0f;
|
||||
bool color_emoji_ = false; // when true + FreeType present, merge the COLR color-emoji font
|
||||
|
||||
// Inputs the current atlas was built from (recorded at the end of load(); read by needsRebuild()).
|
||||
// userFontScale and DPI are deliberately not inputs: they have their own explicit reload paths.
|
||||
struct BuiltInputs { float sizes[15]; float fontScale; bool emoji; uint32_t gen; } built_{};
|
||||
bool effectiveEmoji() const;
|
||||
|
||||
// Fonts for each type style
|
||||
ImFont* fonts_[15] = {};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user