Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229373e937 | ||
|
|
973c390df5 | ||
|
|
d684db446e | ||
|
|
9e1b1397ad | ||
|
|
55a36e0d06 | ||
|
|
7937aad4fb | ||
|
|
077f9a7403 | ||
|
|
9f23b2781c | ||
|
|
79d8f0d809 | ||
|
|
dc4426810f | ||
|
|
915c1b4d23 | ||
|
|
28b9e0dffb | ||
|
|
6be0a58c26 | ||
|
|
fbdba1a001 | ||
|
|
821c54ba2b | ||
|
|
3ff62ca248 | ||
|
|
bbf53a130c | ||
|
|
1f9e43d7b2 | ||
|
|
5ebccceffc | ||
|
|
9d2d581474 | ||
|
|
096f8ee90e | ||
|
|
ca199ef195 | ||
|
|
97bd2f8168 | ||
|
|
09f287fbc5 | ||
|
|
b3d43ba0ad | ||
|
|
430290f97a | ||
|
|
30fc5da520 | ||
|
|
f02c965929 | ||
|
|
f0b7b88ef2 | ||
|
|
53d08de639 | ||
|
|
8645a82e4f | ||
|
|
9e94952e0a | ||
|
|
4a841fd032 | ||
|
|
f0c87e4092 | ||
|
|
c5ef4899bb | ||
|
|
36b67e69d0 | ||
|
|
06c80ef51c | ||
|
|
6bd5341507 | ||
|
|
5284c0dbb6 | ||
|
|
cf520fdf40 | ||
|
|
96c27bb949 | ||
|
|
cc617dd5be | ||
|
|
653a90de62 | ||
|
|
4b16a2a2c4 | ||
|
|
c51d3dafff | ||
|
|
68c2a59d09 | ||
|
|
45a2ccd9f3 | ||
|
|
0ca1caf148 | ||
|
|
7fb1f1de9d | ||
|
|
386cc857b0 | ||
|
|
3e6136983a | ||
|
|
2c1862aed3 | ||
|
|
4b815fc9d1 |
+147
-10
@@ -37,6 +37,8 @@ endif()
|
||||
option(DRAGONX_USE_SYSTEM_SDL3 "Use system SDL3 instead of fetching" ON)
|
||||
option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
|
||||
include(CTest)
|
||||
|
||||
# Output directories
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
|
||||
@@ -108,6 +110,32 @@ FetchContent_Declare(
|
||||
)
|
||||
FetchContent_MakeAvailable(tomlplusplus)
|
||||
|
||||
# SQLite amalgamation - local Explorer block-summary cache
|
||||
FetchContent_Declare(
|
||||
sqlite3
|
||||
URL https://www.sqlite.org/2024/sqlite-amalgamation-3450300.zip
|
||||
URL_HASH SHA256=ea170e73e447703e8359308ca2e4366a3ae0c4304a8665896f068c736781c651
|
||||
)
|
||||
FetchContent_GetProperties(sqlite3)
|
||||
if(NOT sqlite3_POPULATED)
|
||||
FetchContent_Populate(sqlite3)
|
||||
endif()
|
||||
file(GLOB SQLITE3_AMALGAMATION_C CONFIGURE_DEPENDS
|
||||
${sqlite3_SOURCE_DIR}/sqlite3.c
|
||||
${sqlite3_SOURCE_DIR}/*/sqlite3.c
|
||||
)
|
||||
if(NOT SQLITE3_AMALGAMATION_C)
|
||||
message(FATAL_ERROR "SQLite amalgamation source not found")
|
||||
endif()
|
||||
list(GET SQLITE3_AMALGAMATION_C 0 SQLITE3_SOURCE_FILE)
|
||||
get_filename_component(SQLITE3_INCLUDE_DIR ${SQLITE3_SOURCE_FILE} DIRECTORY)
|
||||
add_library(sqlite3_amalgamation STATIC ${SQLITE3_SOURCE_FILE})
|
||||
target_include_directories(sqlite3_amalgamation PUBLIC ${SQLITE3_INCLUDE_DIR})
|
||||
target_compile_definitions(sqlite3_amalgamation PRIVATE
|
||||
SQLITE_THREADSAFE=1
|
||||
SQLITE_OMIT_LOAD_EXTENSION
|
||||
)
|
||||
|
||||
# libcurl for HTTPS RPC connections (more reliable than cpp-httplib with OpenSSL 3.x)
|
||||
if(WIN32)
|
||||
# For Windows cross-compilation, fetch and build libcurl statically
|
||||
@@ -247,22 +275,39 @@ set(APP_SOURCES
|
||||
src/app_network.cpp
|
||||
src/app_security.cpp
|
||||
src/app_wizard.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/ui/theme.cpp
|
||||
src/ui/theme_loader.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
src/ui/material/color_theme.cpp
|
||||
src/ui/material/typography.cpp
|
||||
src/ui/notifications.cpp
|
||||
src/ui/windows/main_window.cpp
|
||||
src/ui/windows/balance_tab.cpp
|
||||
src/ui/windows/balance_address_list.cpp
|
||||
src/ui/windows/balance_recent_tx.cpp
|
||||
src/ui/windows/balance_tab_helpers.cpp
|
||||
src/ui/windows/send_tab.cpp
|
||||
src/ui/windows/receive_tab.cpp
|
||||
src/ui/windows/transactions_tab.cpp
|
||||
src/ui/windows/mining_tab.cpp
|
||||
src/ui/windows/mining_benchmark.cpp
|
||||
src/ui/windows/mining_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
src/ui/windows/peers_tab.cpp
|
||||
src/ui/windows/explorer_tab.cpp
|
||||
src/ui/windows/market_tab.cpp
|
||||
src/ui/windows/console_tab.cpp
|
||||
src/ui/windows/console_command_reference.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.cpp
|
||||
src/ui/windows/about_dialog.cpp
|
||||
@@ -286,6 +331,8 @@ set(APP_SOURCES
|
||||
src/data/address_book.cpp
|
||||
src/data/exchange_info.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/base64.cpp
|
||||
src/util/single_instance.cpp
|
||||
src/util/i18n.cpp
|
||||
@@ -294,6 +341,8 @@ set(APP_SOURCES
|
||||
src/util/texture_loader.cpp
|
||||
src/util/noise_texture.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/daemon/daemon_controller.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/daemon/xmrig_manager.cpp
|
||||
src/util/bootstrap.cpp
|
||||
src/util/secure_vault.cpp
|
||||
@@ -326,20 +375,38 @@ endif()
|
||||
|
||||
set(APP_HEADERS
|
||||
src/app.h
|
||||
src/services/network_refresh_service.h
|
||||
src/services/refresh_scheduler.h
|
||||
src/services/wallet_security_controller.h
|
||||
src/services/wallet_security_workflow.h
|
||||
src/services/wallet_security_workflow_executor.h
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
src/ui/theme.h
|
||||
src/ui/theme_loader.h
|
||||
src/ui/explorer/explorer_block_cache.h
|
||||
src/ui/notifications.h
|
||||
src/ui/windows/main_window.h
|
||||
src/ui/windows/balance_tab.h
|
||||
src/ui/windows/balance_address_list.h
|
||||
src/ui/windows/balance_recent_tx.h
|
||||
src/ui/windows/balance_tab_helpers.h
|
||||
src/ui/windows/send_tab.h
|
||||
src/ui/windows/receive_tab.h
|
||||
src/ui/windows/transactions_tab.h
|
||||
src/ui/windows/mining_tab.h
|
||||
src/ui/windows/mining_benchmark.h
|
||||
src/ui/windows/mining_pool_panel.h
|
||||
src/ui/windows/mining_tab_helpers.h
|
||||
src/ui/windows/peers_tab.h
|
||||
src/ui/windows/explorer_tab.h
|
||||
src/ui/windows/market_tab.h
|
||||
src/ui/windows/console_command_reference.h
|
||||
src/ui/windows/console_input_model.h
|
||||
src/ui/windows/console_output_model.h
|
||||
src/ui/windows/console_tab.h
|
||||
src/ui/windows/console_tab_helpers.h
|
||||
src/ui/windows/settings_window.h
|
||||
src/ui/windows/about_dialog.h
|
||||
src/ui/windows/key_export_dialog.h
|
||||
@@ -363,6 +430,8 @@ set(APP_HEADERS
|
||||
src/data/address_book.h
|
||||
src/data/exchange_info.h
|
||||
src/util/logger.h
|
||||
src/util/async_task_manager.h
|
||||
src/util/amount_format.h
|
||||
src/util/base64.h
|
||||
src/util/single_instance.h
|
||||
src/util/i18n.h
|
||||
@@ -370,6 +439,8 @@ set(APP_HEADERS
|
||||
src/util/payment_uri.h
|
||||
src/util/secure_vault.h
|
||||
src/daemon/embedded_daemon.h
|
||||
src/daemon/daemon_controller.h
|
||||
src/daemon/lifecycle_adapters.h
|
||||
src/daemon/xmrig_manager.h
|
||||
src/ui/effects/framebuffer.h
|
||||
src/ui/effects/blur_shader.h
|
||||
@@ -433,6 +504,7 @@ set_source_files_properties(
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"
|
||||
)
|
||||
|
||||
@@ -465,6 +537,7 @@ target_link_libraries(ObsidianDragon PRIVATE
|
||||
SDL3::SDL3
|
||||
nlohmann_json::nlohmann_json
|
||||
tomlplusplus::tomlplusplus
|
||||
sqlite3_amalgamation
|
||||
${CURL_LIBRARIES}
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
@@ -527,6 +600,12 @@ 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")
|
||||
endif()
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded)
|
||||
|
||||
foreach(LANG_FILE ${LANG_FILES})
|
||||
get_filename_component(LANG_FILENAME ${LANG_FILE} NAME)
|
||||
add_custom_command(
|
||||
@@ -540,16 +619,18 @@ 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
|
||||
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
|
||||
set(LANG_HEADER ${CMAKE_SOURCE_DIR}/src/embedded/lang_${LANG_CODE}.h)
|
||||
add_custom_command(
|
||||
OUTPUT ${LANG_HEADER}
|
||||
COMMAND xxd -i "res/lang/${LANG_FILENAME}" > "src/embedded/lang_${LANG_CODE}.h"
|
||||
DEPENDS ${LANG_FILE}
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMENT "Embedding lang_${LANG_CODE}.h"
|
||||
)
|
||||
list(APPEND LANG_OUTPUTS ${LANG_HEADER})
|
||||
if(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(
|
||||
OUTPUT ${LANG_HEADER}
|
||||
COMMAND ${XXD_EXECUTABLE} -i "res/lang/${LANG_FILENAME}" > "${LANG_HEADER}"
|
||||
DEPENDS ${LANG_FILE}
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMENT "Embedding lang_${LANG_CODE}.h"
|
||||
)
|
||||
list(APPEND LANG_OUTPUTS ${LANG_HEADER})
|
||||
endif()
|
||||
endforeach()
|
||||
add_custom_target(copy_langs ALL DEPENDS ${LANG_OUTPUTS})
|
||||
add_dependencies(ObsidianDragon copy_langs)
|
||||
@@ -596,6 +677,7 @@ if(THEME_FILES AND Python3_EXECUTABLE)
|
||||
message(STATUS " Theme files: ${THEME_FILES} (build-time expansion via Python)")
|
||||
elseif(THEME_FILES)
|
||||
# Fallback: plain copy if Python is not available
|
||||
message(WARNING "Python3 not found; copying theme files without expand_themes.py layout merge")
|
||||
foreach(THEME_FILE ${THEME_FILES})
|
||||
get_filename_component(THEME_FILENAME ${THEME_FILE} NAME)
|
||||
add_custom_command(
|
||||
@@ -649,6 +731,61 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(ObsidianDragonTests
|
||||
tests/test_phase4.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
src/ui/windows/balance_address_list.cpp
|
||||
src/ui/windows/balance_recent_tx.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/mining_benchmark.cpp
|
||||
src/ui/windows/mining_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
src/util/secure_vault.cpp
|
||||
src/util/platform.cpp
|
||||
src/util/logger.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ObsidianDragonTests PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/src/resources
|
||||
${CMAKE_SOURCE_DIR}/libs
|
||||
${CMAKE_BINARY_DIR}/generated
|
||||
${IMGUI_DIR}
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
sqlite3_amalgamation
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
|
||||
if(UNIX)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
|
||||
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# DragonX Wallet - ImGui Edition
|
||||
# ObsidianDragon - DragonX Wallet
|
||||
|
||||
A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui.
|
||||
A lightweight, portable full-node cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui.
|
||||
|
||||
Current pre-release: **1.2.0-rc1**.
|
||||
|
||||

|
||||

|
||||
@@ -9,10 +11,13 @@ A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dea
|
||||
|
||||
- **Full Node Support**: Connects to dragonxd for complete blockchain verification
|
||||
- **Shielded Transactions**: Full z-address support with encrypted memos
|
||||
- **Integrated Mining**: CPU mining controls with hashrate monitoring
|
||||
- **Address Management**: Labels, icons, favorites, hidden addresses, and address-to-address transfers
|
||||
- **Integrated Mining**: Solo CPU mining plus pool mining through xmrig, with idle-mining controls
|
||||
- **Explorer Tools**: Block/transaction lookup and bootstrap snapshot download
|
||||
- **Market Data**: Real-time price charts from CoinGecko
|
||||
- **QR Codes**: Generate and display QR codes for receiving addresses
|
||||
- **Multi-language**: i18n support (English, Spanish, more coming)
|
||||
- **Multi-language**: i18n support for English, German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese
|
||||
- **CJK Fonts**: Bundled CJK subset font for translated interfaces
|
||||
- **Lightweight**: ~5-10MB binary vs ~50MB+ for Qt version
|
||||
- **Fast Builds**: Compiles in seconds, not minutes
|
||||
|
||||
@@ -116,7 +121,8 @@ cd ObsidianDragon/
|
||||
./ObsidianDragon
|
||||
```
|
||||
|
||||
The wallet will automatically connect to the daemon using credentials from \`~/.hush/DRAGONX/DRAGONX.conf\`.
|
||||
The wallet will automatically connect to the daemon using credentials from `~/.hush/DRAGONX/DRAGONX.conf`.
|
||||
|
||||
### Using Custom Node Binaries
|
||||
|
||||
The wallet checks its **own directory first** when looking for DragonX node binaries. This means you can test new or different branch builds of `hush-arrakis-chain`/`hushd` without waiting for a new wallet release:
|
||||
@@ -131,9 +137,10 @@ The wallet checks its **own directory first** when looking for DragonX node bina
|
||||
3. System-wide locations (`/usr/local/bin`, `~/dragonx/src`, etc.)
|
||||
|
||||
This is useful for testing new branches or hotfixes to the node software before they are bundled into a wallet release.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in \`~/.hush/DRAGONX/DRAGONX.conf\`:
|
||||
Configuration is stored in `~/.hush/DRAGONX/DRAGONX.conf`:
|
||||
|
||||
```
|
||||
rpcuser=your_rpc_user
|
||||
@@ -148,44 +155,46 @@ ObsidianDragon/
|
||||
├── src/
|
||||
│ ├── main.cpp # Entry point, SDL/ImGui setup
|
||||
│ ├── app.cpp/h # Main application class
|
||||
│ ├── wallet_state.h # Wallet data structures
|
||||
│ ├── version.h # Version definitions
|
||||
│ ├── data/ # WalletState, address book, exchange info
|
||||
│ ├── config/ # Settings persistence and committed/generated version.h
|
||||
│ ├── ui/
|
||||
│ │ ├── theme.cpp/h # DragonX theme
|
||||
│ │ └── windows/ # UI tabs and dialogs
|
||||
│ │ ├── schema/ # TOML UI schema and skin manager
|
||||
│ │ ├── material/ # Material components, typography, layout
|
||||
│ │ ├── windows/ # Tabs and dialogs
|
||||
│ │ └── pages/ # Multi-page screens such as Settings
|
||||
│ ├── rpc/
|
||||
│ │ ├── rpc_client.cpp # JSON-RPC client
|
||||
│ │ └── connection.cpp # Daemon connection
|
||||
│ ├── config/
|
||||
│ │ └── settings.cpp # Settings persistence
|
||||
│ ├── resources/ # Embedded resource extraction
|
||||
│ ├── platform/ # Windows DX11/backdrop helpers
|
||||
│ ├── util/
|
||||
│ │ ├── i18n.cpp # Internationalization
|
||||
│ │ └── ...
|
||||
│ └── daemon/
|
||||
│ └── embedded_daemon.cpp
|
||||
├── res/
|
||||
│ ├── fonts/ # Ubuntu font
|
||||
│ ├── fonts/ # Ubuntu, icon, and CJK fonts
|
||||
│ └── lang/ # Translation files
|
||||
├── libs/
|
||||
│ └── qrcode/ # QR code generation
|
||||
├── CMakeLists.txt
|
||||
├── build-release.sh # Build script
|
||||
└── create-appimage.sh # AppImage packaging
|
||||
├── build.sh # Release/cross-platform build script
|
||||
└── scripts/create-appimage.sh # AppImage packaging
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Fetched automatically by CMake (no manual install needed):
|
||||
Fetched or discovered by CMake:
|
||||
|
||||
- **[SDL3](https://github.com/libsdl-org/SDL)** — Cross-platform windowing/input
|
||||
- **[nlohmann/json](https://github.com/nlohmann/json)** — JSON parsing
|
||||
- **[toml++](https://github.com/marzer/tomlplusplus)** — TOML parsing (UI schema/themes)
|
||||
- **[libcurl](https://curl.se/libcurl/)** — HTTPS RPC transport (system on Linux, fetched on Windows)
|
||||
- **[libcurl](https://curl.se/libcurl/)** — HTTP/HTTPS transport for daemon RPC and network calls (system on Linux/macOS, fetched on Windows)
|
||||
|
||||
Bundled in `libs/`:
|
||||
|
||||
- **[Dear ImGui](https://github.com/ocornut/imgui)** — Immediate mode GUI
|
||||
- **[libsodium](https://libsodium.org)** — Cryptographic operations (fetched by `scripts/fetch-libsodium.sh`)
|
||||
- **[libsodium](https://libsodium.org)** — Cryptographic operations (system on Linux or fetched by `scripts/fetch-libsodium.sh`)
|
||||
- **[QR-Code-generator](https://github.com/nayuki/QR-Code-generator)** — QR code rendering
|
||||
- **[miniz](https://github.com/richgel999/miniz)** — ZIP compression
|
||||
- **[GLAD](https://glad.dav1d.de/)** — OpenGL loader (Linux/macOS)
|
||||
@@ -202,9 +211,11 @@ Bundled in `libs/`:
|
||||
|
||||
## Translation
|
||||
|
||||
Current language files live in `res/lang/` as `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, and `zh` JSON files, with built-in English fallbacks.
|
||||
|
||||
To add a new language:
|
||||
|
||||
1. Copy \`res/lang/es.json\` to \`res/lang/<code>.json\`
|
||||
1. Copy `res/lang/es.json` to `res/lang/<code>.json`
|
||||
2. Translate all strings
|
||||
3. The language will appear in Settings automatically
|
||||
|
||||
|
||||
+17
-2
@@ -361,7 +361,22 @@ https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
---
|
||||
|
||||
## 13. IconFontCppHeaders
|
||||
## 13. Material Design Icons Pickaxe Subset Font
|
||||
|
||||
- **Location:** `res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf`
|
||||
- **Source:** https://github.com/Templarian/MaterialDesign-Webfont
|
||||
- **Derived from:** Pictogrammers Material Design Icons webfont (`materialdesignicons-webfont.ttf`)
|
||||
- **Copyright:** Pictogrammers contributors
|
||||
- **License:** Apache License 2.0
|
||||
|
||||
This bundled font is a local one-glyph subset containing only the MDI pickaxe
|
||||
icon, remapped onto a BMP private-use codepoint for Dear ImGui compatibility.
|
||||
The full text of the Apache License 2.0 is available at:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
---
|
||||
|
||||
## 14. IconFontCppHeaders
|
||||
|
||||
- **Location:** `src/embedded/IconsMaterialDesign.h`
|
||||
- **Source:** https://github.com/juliettef/IconFontCppHeaders
|
||||
@@ -390,7 +405,7 @@ freely, subject to the following restrictions:
|
||||
|
||||
---
|
||||
|
||||
## 14. Ubuntu Font Family
|
||||
## 15. Ubuntu Font Family
|
||||
|
||||
- **Location:** `res/fonts/Ubuntu-Light.ttf`, `Ubuntu-Medium.ttf`, `Ubuntu-R.ttf`
|
||||
- **Source:** https://design.ubuntu.com/font
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
# Codebase Cleanup Audit
|
||||
|
||||
Current as of 2026-04-27 for ObsidianDragon `1.2.0-rc1`.
|
||||
|
||||
## Scope
|
||||
|
||||
This audit covers architecture, threading, UI/layout code, build/resource integration, RPC/daemon behavior, and general cleanup opportunities. It is a static maintainability audit, not a runtime performance profile or security penetration test.
|
||||
|
||||
Evidence was gathered from source-tree scans, largest-file metrics, targeted code inspection, and focused architecture/UI/build review passes.
|
||||
|
||||
## Snapshot
|
||||
|
||||
- `src/` contains about 77,639 handwritten C++ source/header lines, excluding generated language outputs.
|
||||
- Generated language headers previously accounted for about 34,675 source-tree lines under `src/embedded/lang_*.h`; Phase 4 moved those outputs to `build/generated/embedded/`.
|
||||
- Largest handwritten files are concentrated in UI and application orchestration:
|
||||
- `src/ui/windows/balance_tab.cpp`: 3,434 lines
|
||||
- `src/app.cpp`: 3,097 lines
|
||||
- `src/ui/windows/mining_tab.cpp`: 2,544 lines
|
||||
- `src/ui/pages/settings_page.cpp`: 2,092 lines
|
||||
- `src/main.cpp`: 2,014 lines
|
||||
- `src/app_security.cpp`: 1,918 lines
|
||||
- `src/app_network.cpp`: 1,912 lines
|
||||
- `src/ui/windows/console_tab.cpp`: 1,572 lines
|
||||
- `src/app_wizard.cpp`: 1,378 lines
|
||||
- `src/daemon/embedded_daemon.cpp`: 1,213 lines
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The codebase has a solid functional foundation: RPC work is already separated from UI-thread callbacks, the schema-driven UI system is useful, and embedded resource handling is well established. The largest opportunities are mostly structural rather than feature bugs.
|
||||
|
||||
The main cleanup theme is ownership. `App` currently coordinates lifecycle, rendering, daemon state, RPC refreshes, wallet security, dialogs, shutdown, mining, and first-run flow. That centralization makes features easy to wire in the short term, but it also pushes thread lifetime, error propagation, UI state, and background polling into a few very large files.
|
||||
|
||||
The second theme is consistency. Dialogs mostly use Material overlay helpers, but some still use raw ImGui modals. Many UI dimensions are schema-driven, but dialog/button/icon sizes still have scattered literals. RPC auth exists in more than one place, generated resources live partly in source and partly in build output, and error handling ranges from user-visible reporting to silent `catch (...)` blocks.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Phase 1 Completed On 2026-04-27
|
||||
|
||||
- Replaced time-seeded `std::rand()` RPC credential generation in `src/rpc/connection.cpp` with libsodium-backed random generation.
|
||||
- Replaced direct UI `system()` explorer/about launch calls with `util::Platform::openUrl()`.
|
||||
- Reworked `util::Platform::openUrl()` and `openFolder()` so macOS/Linux launchers use `posix_spawnp()` arguments instead of shell-built command strings, and folder creation uses `std::filesystem::create_directories()`.
|
||||
- Added URL scheme validation for platform URL opens.
|
||||
- Added CMake visibility for missing `xxd` and Python theme expansion dependencies.
|
||||
- Added `xxd` and Python checks/package coverage to `setup.sh`.
|
||||
- Added shared `App::sendStopCommandSafely()` logging helper and routed repeated daemon `stop` RPC calls through it.
|
||||
- Confirmed `libs/incbin.h` is already represented in `THIRD_PARTY_LICENSES`, so no additional license entry was needed.
|
||||
- Verified with staged builds after logical batches, ending with `cd build && make -j$(nproc)` successfully linking `bin/ObsidianDragon`.
|
||||
|
||||
Remaining high-priority follow-ups from this audit include the larger `App`/security/refresh service boundaries and tests around the newly centralized runtime behavior.
|
||||
|
||||
### Phase 2 Completed On 2026-04-27
|
||||
|
||||
- Added schema-backed dialog layout tokens in `res/themes/ui.toml` and central accessors in `src/ui/layout.h` for common dialog widths, form width, action width/gap, max height ratio, and compact bottom alignment.
|
||||
- Registered the `dialog` UI schema section in `src/ui/schema/ui_schema.cpp` so those tokens are available through the existing schema cache.
|
||||
- Extended `material::BeginOverlayDialog()` with an optional ID suffix and added shared overlay action/footer helpers in `src/ui/material/draw_helpers.h`.
|
||||
- Migrated address book Add/Edit address dialogs in `src/ui/windows/address_book_dialog.cpp` from raw `ImGui::BeginPopupModal()` usage to `material::BeginOverlayDialog()` with one shared form/action renderer.
|
||||
- Added `src/ui/material/project_icons.h` as the wallet icon registry and moved pickaxe-specific font rendering behind that helper.
|
||||
- Kept `AddressLabelDialog::drawIconByName()` and `iconGlyphForName()` as compatibility wrappers for existing call sites.
|
||||
- Verified with `cd build && make -j$(nproc)` successfully linking `bin/ObsidianDragon`; diagnostics were clean on Phase 2 touched files, `git diff --check` passed, and scans found no remaining Add/Edit raw modal usage or local address-label icon arrays.
|
||||
|
||||
### Phase 3 Completed On 2026-04-27
|
||||
|
||||
- Added `src/util/async_task_manager.h/.cpp` as a named task owner with cancellation tokens, completed-task reaping, and join-on-shutdown behavior.
|
||||
- Routed App-owned daemon maintenance, wizard daemon stop/check, encryption daemon restart, and decrypt restart/import background work through `AsyncTaskManager` instead of detached App threads.
|
||||
- Kept `Bootstrap`'s worker joinable so its existing destructor cancellation can join the download/extract thread instead of losing ownership through `detach()`.
|
||||
- Added `src/daemon/daemon_controller.h/.cpp` as the first daemon ownership boundary; it now owns `EmbeddedDaemon`, syncs settings into the daemon, and centralizes start/stop calls while `App` keeps a non-owning bridge pointer for low-churn follow-up migration.
|
||||
- Extended `rpc::ConnectionConfig` with auth-source tracking and `use_tls`, parsed `rpctls`/`rpcssl`-style config flags, and centralized `.cookie` auth retry construction in `Connection::buildCookieAuthConfig()`.
|
||||
- Updated main, fast-lane, temporary stop, wizard stop, and decrypt-import RPC clients to pass the TLS flag to `RPCClient`.
|
||||
- Added a one-per-session runtime warning and Settings-page warning when a non-localhost RPC host is configured without TLS.
|
||||
- Verified after each logical batch with `cmake .. && make -j$(nproc)` or `make -j$(nproc)` successfully linking `bin/ObsidianDragon`; diagnostics were clean on Phase 3 touched files, `git diff --check` passed, and scans found no remaining App-owned detached background tasks or App-level manual `.cookie` fallback.
|
||||
|
||||
### Phase 4 Completed On 2026-04-27
|
||||
|
||||
- Added `src/services/refresh_scheduler.h/.cpp` and moved refresh interval/timer policy out of `App`, while leaving RPC refresh bodies behavior-preserving in the existing app/network methods.
|
||||
- Replaced App refresh timer fields with `services::RefreshScheduler`, including page-specific refresh policy, wallet mutation refresh marking, transaction-age throttling, OPID polling cadence, price refresh cadence, and fast mining/rescan ticks.
|
||||
- Moved generated language headers from tracked `src/embedded/lang_*.h` files into `${CMAKE_BINARY_DIR}/generated/embedded/`, keeping the existing `embedded/lang_*.h` include strings working through the generated include directory.
|
||||
- Added CTest infrastructure and `ObsidianDragonTests` with focused coverage for connection config parsing, cookie fallback and plaintext/TLS checks, payment URI parsing, fixed amount formatting, spendable wallet address filtering, and scheduler behavior.
|
||||
- Added `src/util/amount_format.h/.cpp` for shared fixed-decimal amount formatting and used it in transaction send payload construction.
|
||||
- Added pure spendability helpers in `src/data/wallet_state.h/.cpp` and routed Send-tab source address selection through them.
|
||||
- Split low-risk helpers out of large UI tabs: balance helper formatting/drawing code, mining formatting/estimate/thread helpers, and the console RPC command reference registry.
|
||||
- Verified focused tests with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`, then verified the UI split batch with `cd build && cmake .. && make -j$(nproc)` successfully linking `bin/ObsidianDragon`.
|
||||
|
||||
### Phase 5 Completed On 2026-04-27
|
||||
|
||||
- Added `src/services/wallet_security_controller.h/.cpp` and moved deferred wizard encryption/PIN state, connection retry throttling, PIN validation, and secure clearing behind a wallet-security boundary while keeping the existing RPC/UI behavior in `App`.
|
||||
- Added `src/services/network_refresh_service.h/.cpp` as the fuller refresh boundary around `RefreshScheduler`, named refresh jobs, in-flight job guards, and explicit queue-pressure skips for core, address, transaction, mining, and peer refreshes.
|
||||
- Extended `DaemonController` with shutdown/external-daemon policy decisions and routed `App::beginShutdown()` through that boundary.
|
||||
- Grouped Settings-page static UI state into `SettingsPageState` and first-run wizard static UI state into `WizardUiState`, preserving existing file-local behavior while making state ownership explicit.
|
||||
- Continued low-risk renderer splitting with console layout helpers, balance recent-transaction visual/amount helpers, and mining active-state/thread clamp helpers.
|
||||
- Expanded focused tests for daemon shutdown policy, wallet security transitions, network refresh job guards, renderer helpers, and generated resource fallback behavior.
|
||||
- Documented the `src/config/version.h` policy: it remains committed source for editor/release tooling compatibility, and CMake regenerates it from `src/config/version.h.in` during configure.
|
||||
- Verified Phase 5 batches with repeated `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure` runs.
|
||||
|
||||
### Phase 6 Completed On 2026-04-27
|
||||
|
||||
- Expanded `WalletSecurityController` with mockable RPC and secure-vault gateways for deferred encryption, unlock, export, import, key classification, import messaging, decrypt export naming, and secure handoff behavior.
|
||||
- Routed wallet encryption, deferred encryption retry, unlock, and key import/export classification through the wallet-security service boundary while preserving existing App-visible behavior.
|
||||
- Extended `DaemonController` with lifecycle decisions for manual restart, rescan, blockchain-data deletion, and bootstrap daemon stop sequencing, then routed the corresponding App flows through those decisions.
|
||||
- Promoted `NetworkRefreshService` to dispatch-ticket ownership with queue-depth telemetry, queue-pressure skips, in-flight skips, completion stats, cancellation, and stale-callback detection for named refresh jobs.
|
||||
- Continued renderer splitting with stable modules for recent transaction presentation, pool-worker default selection, and console output filtering.
|
||||
- Removed Settings-page compatibility aliases so the page now uses `SettingsPageState` fields directly.
|
||||
- Expanded focused integration-style tests with mock wallet-security RPC/vault collaborators, daemon lifecycle policy checks, refresh dispatch telemetry/stale-callback edges, and UI helper coverage.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 7 completed on 2026-04-27:
|
||||
- `DaemonController` now owns lifecycle execution ordering for restart, rescan, blockchain-data deletion, and bootstrap stops through mockable `LifecycleRuntime` and `LifecycleTaskContext` collaborators.
|
||||
- App removed the temporary non-owning `embedded_daemon_` bridge; daemon state/output reads now route through `DaemonController` wrappers or explicit controller access.
|
||||
- Added `WalletSecurityWorkflow` for decrypt/export/import dialog phase, step, import-active state, and wallet file planning, so the dialog state can be tested without constructing `App`.
|
||||
- `NetworkRefreshService` now owns enqueue/callback wrapping for named refresh jobs, including queue-depth sampling, queue-pressure skips, stale callback suppression, and UI-thread callback handoff.
|
||||
- Core, address, transaction, mining, peer, and encryption refresh jobs now use the service enqueue wrapper instead of app-local dispatch-ticket boilerplate.
|
||||
- Split additional UI helpers into `balance_address_list`, `mining_benchmark`, and `console_input_model`, with focused tests for filtering/sorting, benchmark state estimates, history navigation, and autocomplete.
|
||||
- Expanded `ObsidianDragonTests` with mock daemon lifecycle execution tests, wallet workflow tests, refresh enqueue/stale-callback tests, and the new UI module coverage.
|
||||
- Verified after each logical batch with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 8 completed on 2026-04-27:
|
||||
- Added `WalletSecurityWorkflowExecutor` with mockable RPC, import, file, daemon, and secure-vault cleanup gateways; decrypt wallet unlock/export/backup/restart/import/cleanup sequencing now runs through that executor instead of nested App-local orchestration.
|
||||
- Completed named refresh enqueue coverage by routing price refresh through `NetworkRefreshService::enqueue(Job::Price)` and connect-time `getinfo`/`getwalletinfo` prefetch through `Job::ConnectionInit`.
|
||||
- Added daemon lifecycle collaborators for async/immediate task contexts and blockchain-data cleanup, reducing App-specific lifecycle runtime code where the extracted pieces are directly testable.
|
||||
- Continued large-view reduction with balance address-row layout/USD helpers, mining benchmark transition and pool saved/default helpers, and console command parse/result classification helpers.
|
||||
- Expanded `ObsidianDragonTests` for workflow executor edges, ConnectionInit enqueue coverage, daemon lifecycle adapters, and the new UI helper/model behavior.
|
||||
- Verified each logical Phase 8 batch with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 9 completed on 2026-04-27:
|
||||
- Added typed refresh result models, JSON parsers, and service-owned apply contracts on `NetworkRefreshService` for connection info, wallet encryption, core balance/sync, mining, peers, and price refreshes.
|
||||
- Routed `App` refresh callbacks through those contracts for connect-time prefetch, warmup info application, core refresh, encryption refresh, mining refresh, peer refresh, and price refresh while preserving the existing RPC call ordering.
|
||||
- Expanded `ObsidianDragonTests` with focused refresh result parsing/application coverage for balance/sync state, connection metadata, wallet lock state, mining history, peer lists, banned peers, and price history.
|
||||
- Documented remaining intentional process-wide ImGui state and legacy compatibility wrappers in `docs/ui-static-state.md`, and linked that policy from `docs/codebase-overview.md`.
|
||||
- Verified the Phase 9 implementation batch with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 10 completed on 2026-04-27:
|
||||
- Added `NetworkRefreshService::AddressRefreshResult` and routed address snapshot application through `applyAddressRefreshResult()` while preserving App-owned RPC collection and dirty-flag behavior.
|
||||
- Added service-owned transaction view-cache models plus `TransactionRefreshResult`/`TransactionCacheUpdate` so transaction refresh callbacks now apply `WalletState::transactions`, `last_tx_update`, `last_tx_block_height_`, `viewtx_cache_`, `send_txids_`, and confirmed transaction caches through one cache-update contract.
|
||||
- Moved z-viewtransaction output parsing and outgoing-send enrichment into `NetworkRefreshService` helpers, leaving only the RPC call order and per-cycle throttling in `App`.
|
||||
- Evaluated the remaining decrypt workflow orchestration and kept the nested UI step handoffs App-owned because those boundaries still carry progress updates, worker-thread callback ordering, and shutdown/token cancellation semantics.
|
||||
- Did not touch high-churn tab/dialog statics in this phase; `docs/ui-static-state.md` remains the policy for future behavior-adjacent UI state changes.
|
||||
- Expanded `ObsidianDragonTests` with address/transaction applicator coverage, view-transaction enrichment/cache-update tests, stale callback cancellation coverage, additional remote/TLS config parsing, and shutdown-cancellation lifecycle coverage.
|
||||
- Verified the Phase 10 implementation batch with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 11 completed on 2026-04-27:
|
||||
- Revisited the decrypt restart/import flow and kept the remaining UI handoff choreography App-owned because each boundary still corresponds to an explicit progress step, cancellation check, or background import transition. No partial executor move was made.
|
||||
- Moved additional address refresh construction into `NetworkRefreshService` helpers for shielded-address validation results, transparent address parsing, and unspent-output balance application while preserving the existing daemon RPC call order in `App::refreshAddressData()`.
|
||||
- Moved transaction pre-refresh snapshot construction and list parsing helpers into `NetworkRefreshService`, including shielded address snapshots, fully enriched txid snapshots, transparent transaction parsing, shielded receive parsing, and final transaction sorting. `App::refreshTransactionData()` still owns RPC call order and per-cycle `z_viewtransaction` throttling.
|
||||
- Expanded `ObsidianDragonTests` with focused coverage for the new address/transaction snapshot helpers, worker callback ordering across independent refresh jobs, and reconnect-style stale transaction callbacks.
|
||||
- Did not migrate additional tab/dialog statics because Phase 11 did not touch those views for behavior changes.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 12 completed on 2026-04-27:
|
||||
- Added a shared ordered mock refresh RPC fixture in `ObsidianDragonTests` so service-level refresh collectors can assert exact daemon method ordering and parameters.
|
||||
- Introduced `NetworkRefreshService::RefreshRpcGateway` plus `collectAddressRefreshResult()` and `collectTransactionRefreshResult()`; address and transaction refresh RPC collection now lives behind the service boundary while preserving the same daemon call order and per-cycle `z_viewtransaction` cap.
|
||||
- Routed `App::refreshAddressData()` and `App::refreshTransactionData()` through a small `RPCClient` adapter for those collectors, keeping App focused on enqueueing and applying the returned results.
|
||||
- Expanded refresh lifecycle coverage for ordered callbacks, reconnect-style stale transaction callbacks, and collector ordering around address validation, z-balance fallback, shielded receive polling, cached viewtransaction entries, fresh `z_viewtransaction`, and `gettransaction` enrichment.
|
||||
- Revisited decrypt restart/import orchestration again and made no extra move because the remaining boundaries are still the explicit progress/cancellation/import handoff points.
|
||||
- Did not migrate additional tab/dialog statics because Phase 12 did not touch those views for behavior changes.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 13 completed on 2026-04-27:
|
||||
- Added service-owned core and peer refresh collectors behind `NetworkRefreshService::RefreshRpcGateway`, moving the fixed `z_gettotalbalance`/`getblockchaininfo` and `getpeerinfo`/`listbanned` RPC bodies out of `App`.
|
||||
- Extended the ordered mock RPC fixture tests to assert the exact core and peer daemon call order, empty parameter arrays, parsed result values, and partial-failure behavior where the second RPC still runs after the first fails.
|
||||
- Kept warmup `getinfo`, mining refresh, connection init, and price fetch orchestration App-owned because those paths either have UI status handoffs, cadence-specific behavior, or non-RPC HTTP/callback details that were not part of this small collector move.
|
||||
- Did not add new reconnect/shutdown lifecycle tests because Phase 13 did not change async cancellation, daemon restart, or worker lifecycle ownership.
|
||||
- Revisited decrypt restart/import orchestration and high-churn UI state migrations by scope only; no behavior work touched those complete progress/cancellation or tab/dialog seams.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 14 completed on 2026-04-27:
|
||||
- Added `NetworkRefreshService::collectMiningRefreshResult()` behind `RefreshRpcGateway`; the service now owns the ordered `getlocalsolps` plus optional `getmininginfo` RPC collection while `App` still owns the fast/slow cadence decision and daemon-memory snapshot.
|
||||
- Added `NetworkRefreshService::collectConnectionInitResult()` so the initial `getinfo` then `getwalletinfo` prefetch is service-owned while `App::onConnected()` keeps the high-priority enqueue and `encryption_state_prefetched_` lock-screen timing flag.
|
||||
- Extended ordered mock RPC tests for mining slow ticks, mining fast-only ticks, mining partial failure, connection-init success, and connection-init wallet-info prefetch after `getinfo` failure.
|
||||
- Did not add reconnect/shutdown lifecycle tests because Phase 14 did not change worker callback ownership, shutdown, reconnect, daemon restart, or cancellation behavior.
|
||||
- Did not touch decrypt orchestration or high-churn UI state because no complete behavior-preserving step in those areas was part of the batch.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 15 completed on 2026-04-27:
|
||||
- Added `NetworkRefreshService::WarmupPollResult` and `collectWarmupPollResult()` behind `RefreshRpcGateway`; the service now owns the single warmup `getinfo` call and preserves either parsed connection info or the raw RPC error string.
|
||||
- Routed the warmup branch of `App::refreshCoreData()` through the new collector while keeping UI status translation, daemon block-height decoration, `connection_status_`, and the `refreshData()` transition App-owned.
|
||||
- Extended ordered mock RPC tests for warmup success and warmup failure so the `getinfo` call, empty params, parsed ready state, and error propagation are directly covered.
|
||||
- Reviewed price HTTP fetching and kept it App-owned because the current worker callback, libcurl setup, HTTP-status handling, logging, and parse/apply handoff are clearer in one place; only JSON response parsing remains service-owned.
|
||||
- Left command-style RPC actions App-owned because Phase 15 did not include behavior changes for send, address creation, mining toggles, ban operations, or import/export commands.
|
||||
- Did not add reconnect/shutdown lifecycle tests because worker callback ownership, shutdown, reconnect, daemon restart, and cancellation behavior did not change.
|
||||
- Did not touch decrypt orchestration or high-churn UI state because no complete behavior-preserving step in those areas was part of the batch.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 16 completed on 2026-04-27:
|
||||
- Added `NetworkRefreshService::PriceHttpResponse`, `PriceHttpResult`, and `parsePriceHttpResponse()` so libcurl transport status, HTTP status, parse success, and failure messages are represented as a focused service-owned boundary.
|
||||
- Kept libcurl initialization, request execution, worker callback ownership, successful price logging, and UI-thread price application in `App::refreshPrice()`.
|
||||
- Expanded focused tests for successful price HTTP parsing, HTTP non-200 failures, transport failures, and unrecognized response bodies.
|
||||
- Kept command-style RPC actions App-owned because Phase 16 did not include a complete behavior change for send, address creation, mining toggles, ban operations, or import/export commands.
|
||||
- Did not add reconnect/shutdown lifecycle tests because callback ownership, cancellation, shutdown, reconnect, and daemon restart behavior did not change.
|
||||
- Did not touch decrypt orchestration or high-churn UI state because no behavior work touched those workflows.
|
||||
- Verified with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 17 completed on 2026-04-27:
|
||||
- Reviewed command-style RPC actions for send, address creation, mining toggles, ban operations, and key import/export; kept them App-owned because no complete behavior change created a focused extraction boundary with tests.
|
||||
- Reviewed lifecycle, reconnect, shutdown, and daemon restart ownership; no new lifecycle tests were added because callback ownership, cancellation, shutdown, reconnect, and daemon restart behavior did not change.
|
||||
- Reviewed decrypt and high-churn UI-state seams by scope; no changes were made because Phase 17 did not directly touch those workflows for behavior.
|
||||
- Kept refresh and price boundaries stable because the current service collectors/result helpers already cover the testable seams introduced in prior phases, and no new smaller behavior seam appeared.
|
||||
- Verified the stable-boundary decision with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 18 completed on 2026-04-27:
|
||||
- Re-reviewed the remaining command-style RPC actions (`setgenerate`, `setban`, new address creation, key import, send) and kept them App-owned because Phase 18 did not include a real behavior change that supplied a focused extraction boundary and tests.
|
||||
- Re-reviewed reconnect, shutdown, daemon restart, and callback ownership; no lifecycle tests were added because those ownership and cancellation behaviors did not change.
|
||||
- Re-reviewed decrypt and high-churn UI-state seams by scope; no code changes were made because those workflows were not directly touched for behavior.
|
||||
- Preserved the stable refresh and price boundaries introduced in prior phases because no new behavior made a smaller tested seam useful.
|
||||
- Verified the feature-driven cleanup decision with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 19 completed on 2026-04-27:
|
||||
- Treated the audit as feature-scoped maintenance rather than an extraction-only phase and made no source changes because no real behavior change created a new focused test seam.
|
||||
- Re-reviewed command-style RPC actions (`setgenerate`, `setban`, new address creation, key import, send) and kept them App-owned because their current boundaries are command-specific and tied to immediate UI/app-state updates.
|
||||
- Re-reviewed lifecycle, reconnect, shutdown, daemon restart, decrypt, and high-churn UI-state seams; no tests or migrations were added because those behaviors did not change directly.
|
||||
- Preserved the stable refresh and price service boundaries because the existing collectors/result helpers already cover the testable seams created by earlier work.
|
||||
- Verified the maintenance decision with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 20 completed on 2026-04-27:
|
||||
- Moved the cleanup audit into maintenance mode and avoided additional extraction-only work because no concrete behavior change created a smaller tested seam.
|
||||
- Kept command-style RPC actions App-owned until a complete command workflow change supplies focused tests.
|
||||
- Added no lifecycle, decrypt, or UI-state tests because callback ownership, cancellation, daemon restart, decrypt flow, and UI state behavior did not change directly.
|
||||
- Preserved stable refresh and price service boundaries because no new behavior exposed a better tested boundary.
|
||||
- Verified maintenance mode with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 21 completed on 2026-04-27:
|
||||
- Sustained maintenance mode and avoided extraction-only work because no concrete feature change created a focused tested seam.
|
||||
- Kept command-style RPC actions App-owned until a complete command workflow change supplies focused tests.
|
||||
- Added no lifecycle, decrypt, or UI-state coverage because callback ownership, cancellation, daemon restart, decrypt flow, and UI state behavior did not change directly.
|
||||
- Preserved stable refresh and price service boundaries because no new behavior exposed a smaller tested seam.
|
||||
- Verified sustained maintenance mode with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Phase 22 completed on 2026-04-27:
|
||||
- Continued sustained maintenance mode and avoided extraction-only work because no concrete feature change created a focused tested seam.
|
||||
- Kept command-style RPC actions App-owned until a complete command workflow change supplies focused tests.
|
||||
- Added no lifecycle, decrypt, or UI-state coverage because callback ownership, cancellation, daemon restart, decrypt flow, and UI state behavior did not change directly.
|
||||
- Preserved stable refresh and price service boundaries because no new behavior exposed a smaller tested seam.
|
||||
- Verified the maintenance checkpoint with `cd build && cmake .. && make -j$(nproc) && ctest --output-on-failure`.
|
||||
|
||||
Remaining Phase 23 follow-ups:
|
||||
- Continue sustained maintenance mode and avoid extraction-only phases unless concrete feature work creates a focused tested seam.
|
||||
- Keep command-style RPC actions App-owned until a complete command workflow change supplies focused tests.
|
||||
- Add lifecycle, decrypt, or UI-state coverage only when directly changing callback ownership, cancellation, daemon restart, decrypt flow, or UI state behavior.
|
||||
- Preserve stable refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
## High-Priority Findings
|
||||
|
||||
### 1. `App` Is Carrying Too Many Responsibilities
|
||||
|
||||
Evidence:
|
||||
- `src/app.h`, `src/app.cpp`, `src/app_network.cpp`, `src/app_security.cpp`, and `src/app_wizard.cpp` form a broad partial-class style application layer.
|
||||
- `src/app.cpp` is 3,141 lines, with lifecycle, rendering, daemon/RPC startup, shutdown, modal rendering, and restart flows.
|
||||
- `src/app_network.cpp` is 1,926 lines and contains many refresh, polling, send, mining, market, peer, and address metadata paths.
|
||||
- `src/app_security.cpp` is 1,819 lines and combines wallet encryption, PIN handling, secure vault, import/export, and restart flows.
|
||||
|
||||
Why it matters:
|
||||
- Changes in one workflow can easily affect unrelated lifecycle or UI behavior.
|
||||
- It is hard to unit-test isolated flows without instantiating most of the app.
|
||||
- Shutdown and async callback ownership are difficult to reason about because many subsystems capture or mutate `App` state directly.
|
||||
|
||||
Recommended cleanup:
|
||||
- Extract a `DaemonController` for embedded/external daemon ownership, restart, port ownership, and shutdown sequencing.
|
||||
- Extract a `WalletSecurityController` for PIN, encryption, secure vault, and key import/export flow state.
|
||||
- Extract a `NetworkRefreshService` or `WalletRefreshScheduler` for balance/address/transaction/mining/market polling.
|
||||
- Keep `App` focused on initialization, top-level navigation, frame dispatch, and service composition.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 3: add the first `DaemonController` boundary around `EmbeddedDaemon` ownership, settings sync, and start/stop behavior. Follow-up work should move more restart/shutdown sequencing and external-daemon policy into the controller.
|
||||
|
||||
### 2. Background Thread Lifetime Is Not Centrally Owned
|
||||
|
||||
Evidence:
|
||||
- Detached threads appear in `src/app.cpp`, `src/app_security.cpp`, `src/app_wizard.cpp`, `src/util/bootstrap.cpp`, and `src/main.cpp`.
|
||||
- Several lambdas capture `this` and then call back after waits or daemon operations.
|
||||
- Managed thread members also exist (`shutdown_thread_`, `daemon_restart_thread_`, `wizard_stop_thread_`, monitor threads), but ownership patterns vary by subsystem.
|
||||
|
||||
Why it matters:
|
||||
- Detached work can outlive the `App` object or UI state it touches.
|
||||
- Shutdown order is harder to guarantee.
|
||||
- Cancellation is inconsistent, especially across daemon restart, wallet import, bootstrap download, and PIN unlock flows.
|
||||
|
||||
Recommended cleanup:
|
||||
- Introduce an `AsyncTaskManager` owned by `App`, with named tasks, cancellation flags/tokens, and join-on-shutdown behavior.
|
||||
- Replace new detached threads with submitted tasks and explicit cancellation.
|
||||
- Require background callbacks to hop through the existing UI callback queue before touching UI state.
|
||||
- Add shutdown assertions or logging for tasks that fail to stop promptly.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 3: convert App-owned daemon restart/import/wizard background work to `AsyncTaskManager` and keep `Bootstrap`'s worker joinable. Follow-up work should move remaining subsystem monitor/watchdog policies behind explicit owners where useful.
|
||||
|
||||
### 3. RPC Credential Generation And Remote RPC Handling Need Hardening
|
||||
|
||||
Evidence:
|
||||
- Before Phase 1, `src/rpc/connection.cpp` generated default `rpcuser`/`rpcpassword` with `std::srand(std::time(nullptr))` and `std::rand()`.
|
||||
- Phase 1 replaced that path with libsodium-backed random generation.
|
||||
- Before Phase 3, `src/rpc/rpc_client.cpp` always constructed an `http://host:port/` URL.
|
||||
- Before Phase 3, `.cookie` auth handling appeared in `src/rpc/connection.cpp` and also as a 401 fallback in `src/app_network.cpp`.
|
||||
|
||||
Why it matters:
|
||||
- `std::rand()` is not appropriate for credentials.
|
||||
- HTTP is acceptable for strict localhost daemon RPC, but remote hosts can expose credentials unless the UI explicitly warns or supports TLS.
|
||||
- Duplicate auth fallback paths make it harder to reason about precedence and failures.
|
||||
|
||||
Recommended cleanup:
|
||||
- Completed in Phase 1: generate RPC credentials with libsodium-backed random generation.
|
||||
- Completed in Phase 3: centralize `.cookie` retry construction in `Connection` after config-password failure.
|
||||
- Completed in Phase 3: add a `use_tls` transport flag to `ConnectionConfig` and pass it through `RPCClient` connections.
|
||||
- Completed in Phase 3: warn at runtime and in Settings when a non-localhost RPC host uses plaintext HTTP.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 1 and Phase 3: replace `std::rand()` credential generation, centralize cookie fallback, and add remote plaintext/TLS handling.
|
||||
|
||||
### 4. Shell Launching Uses `system()` In UI And Platform Paths
|
||||
|
||||
Evidence:
|
||||
- Before Phase 1, `src/util/platform.cpp` used `system()` for URL/folder opening and directory creation on some platforms.
|
||||
- Before Phase 1, `src/ui/windows/transactions_tab.cpp`, `src/ui/windows/transaction_details_dialog.cpp`, and `src/ui/windows/about_dialog.cpp` called `system()` directly.
|
||||
- Phase 1 routed these UI calls through `util::Platform::openUrl()` and removed shell-built launcher strings from `Platform` on macOS/Linux.
|
||||
|
||||
Why it matters:
|
||||
- Shell invocation increases quoting and command-injection risk.
|
||||
- Direct calls bypass the existing platform abstraction.
|
||||
- Behavior varies by shell and desktop environment.
|
||||
|
||||
Recommended cleanup:
|
||||
- Route all URL/file/folder launch behavior through `Platform` helpers.
|
||||
- Replace shell strings with platform APIs where possible: `ShellExecute` on Windows, `open` or `xdg-open` via `fork/exec` or `posix_spawn` on Unix-like systems.
|
||||
- Validate URL schemes before opening external links.
|
||||
- Remove direct `system()` calls from UI windows.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 1: update About, transaction details, and transaction tab actions to use `Platform::openUrl()` and safer platform launcher behavior.
|
||||
|
||||
## Medium-Priority Findings
|
||||
|
||||
### 5. Silent And Broad Exception Handling Is Common
|
||||
|
||||
Evidence:
|
||||
- Empty or near-empty `catch (...)` blocks appear in `src/app.cpp`, `src/app_network.cpp`, `src/app_security.cpp`, `src/app_wizard.cpp`, `src/rpc/rpc_worker.cpp`, `src/rpc/rpc_client.cpp`, `src/main.cpp`, and several UI files.
|
||||
- Repeated `try { rpc_->call("stop"); } catch (...) {}` patterns appear in daemon stop flows.
|
||||
|
||||
Why it matters:
|
||||
- Failures vanish from logs and UI, making support and diagnosis difficult.
|
||||
- Some swallowed failures are acceptable during best-effort shutdown, but the code does not consistently explain that intent.
|
||||
- Repeated patterns invite copy-paste drift.
|
||||
|
||||
Recommended cleanup:
|
||||
- Add small helpers such as `stopDaemonSafely(context)` and `parseFloatOrDefault(value, fallback, context)`.
|
||||
- Log best-effort failures with enough context to debug without spamming normal shutdown.
|
||||
- Prefer typed catches where the thrown type is known.
|
||||
- For RPC work, propagate categorized failures to the notification system when they affect user-visible state.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 1: replace repeated daemon stop catch blocks with a single helper that logs at debug/verbose level.
|
||||
|
||||
### 6. UI Dialogs Have Mixed Modal Systems And Repeated Layout Literals
|
||||
|
||||
Evidence:
|
||||
- Most dialogs use `Material::BeginOverlayDialog()`, but `src/ui/windows/address_book_dialog.cpp` still uses raw `ImGui::BeginPopupModal()` for Add/Edit address modals.
|
||||
- Dialog widths such as `480.0f`, `500.0f`, `620.0f`, and `660.0f` appear across UI files.
|
||||
- Button widths and padding literals such as `140.0f`, `160.0f`, and `24.0f` are repeated.
|
||||
- Icon font sizes are hardcoded in `src/ui/material/typography.cpp` as 14, 18, 24, and 40 px families.
|
||||
|
||||
Why it matters:
|
||||
- The schema system already solves this class of problem, but not all dimensions use it yet.
|
||||
- Modal behavior diverges when some dialogs bypass overlay scrim/input blocking helpers.
|
||||
- Theme and density changes require hunting through many files.
|
||||
|
||||
Recommended cleanup:
|
||||
- Add schema-backed dialog tokens under `res/themes/ui.toml`, such as `globals.dialog.width-default`, `width-lg`, `min-width`, and `max-height-ratio`.
|
||||
- Add schema-backed action widths under a shared `button` or `globals.button-sizes` section.
|
||||
- Move icon size selection into schema or a single icon token helper.
|
||||
- Migrate address book Add/Edit dialogs to `BeginOverlayDialog()` and extract one shared form renderer.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 2: refactor the address book Add/Edit dialog because it had duplicated modal form code and was still outside the overlay helper path.
|
||||
|
||||
### 7. UI State Uses Many Static Locals And File-Scoped Globals
|
||||
|
||||
Evidence:
|
||||
- `src/ui/pages/settings_page.cpp` has many `sp_*` static variables for page state.
|
||||
- `src/app_wizard.cpp` uses local statics for first-run appearance and daemon checks.
|
||||
- Theme/color/effect modules use static state for current theme and effect initialization.
|
||||
|
||||
Why it matters:
|
||||
- Static state is hard to reset in tests.
|
||||
- It makes multi-window or future session-reset behavior harder.
|
||||
- Initialization order and stale state can become subtle bugs after hot reloads or account switching.
|
||||
|
||||
Recommended cleanup:
|
||||
- Introduce `SettingsPageState` and `WizardState` structures owned by `App` or a UI state container.
|
||||
- Keep global/static state only for immutable constants or explicit process-wide singletons.
|
||||
- Add reset/apply methods for state structures to support theme reload and test setup.
|
||||
|
||||
Suggested first slice:
|
||||
- Move settings-page statics into a single struct without changing behavior. This is mostly mechanical and improves readability.
|
||||
|
||||
### 8. Build And Generated Resource Lifecycle Is Split Across Source, Build, And Scripts
|
||||
|
||||
Evidence:
|
||||
- `src/config/version.h` is generated from `src/config/version.h.in` but the generated file is present in source control.
|
||||
- CMake generates `src/embedded/lang_*.h` from `res/lang/*.json` with `xxd`.
|
||||
- `setup.sh` does not appear to check for `xxd`.
|
||||
- `find_package(Python3 QUIET COMPONENTS Interpreter)` is used for theme expansion; CMake falls back when Python is unavailable.
|
||||
- Font `OBJECT_DEPENDS` are listed manually in `CMakeLists.txt`.
|
||||
- `src/resources/embedded_resources.cpp` conditionally includes `embedded_data.h`, which is produced by release/build scripts for some platforms.
|
||||
|
||||
Why it matters:
|
||||
- Generated files under `src/` make the source tree noisier and can cause stale diffs.
|
||||
- Missing tools fail late or silently reduce build output quality.
|
||||
- Resource embedding behavior differs by platform without one obvious matrix.
|
||||
|
||||
Recommended cleanup:
|
||||
- Generate language headers under `build/generated/embedded/` and include that directory instead of writing under `src/embedded/`.
|
||||
- Decide whether `src/config/version.h` should be generated-only or committed, then enforce that decision with `.gitignore` and documentation.
|
||||
- Add explicit `xxd` checks to `setup.sh` and CMake configure output.
|
||||
- Make the Python theme-expansion fallback a warning, not a quiet behavior change.
|
||||
- Replace manual font `OBJECT_DEPENDS` with a generated list or CMake glob configured for the font directory.
|
||||
- Document the embedded resource matrix by platform and build type.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 1: add `xxd` detection and make missing Python theme expansion noisy.
|
||||
|
||||
### 9. Polling And Refresh Flow Would Benefit From A Scheduler Boundary
|
||||
|
||||
Evidence:
|
||||
- `src/app_network.cpp` coordinates many refresh paths and timings.
|
||||
- `src/app.cpp` also contains timed daemon/mining/rescan update logic.
|
||||
- `src/rpc/rpc_worker.cpp` already provides a worker queue, but refresh orchestration is still spread through app-level methods.
|
||||
|
||||
Why it matters:
|
||||
- Polling order, throttle behavior, and cancellation are difficult to audit.
|
||||
- Adding one more refresh path can accidentally affect responsiveness or RPC queue pressure.
|
||||
- Console fast-lane RPC is a good pattern, but regular refresh batches still need a clear scheduler owner.
|
||||
|
||||
Recommended cleanup:
|
||||
- Add a `PollingScheduler` or `RefreshScheduler` that owns timer intervals, dependencies, cancellation, and rate limiting.
|
||||
- Model refreshes as named jobs with last-run time, minimum interval, and in-flight state.
|
||||
- Keep `RPCWorker` as execution plumbing while moving scheduling decisions out of `App`.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 4: added `RefreshScheduler` for refresh timers, page intervals, transaction age throttling, OPID cadence, price refresh, and fast tick behavior.
|
||||
- Completed in Phase 7 and Phase 8: `NetworkRefreshService` now owns enqueue/callback wrapping for core, address, transaction, mining, peer, encryption, price, and connection-init jobs, including queue pressure and stale callback suppression.
|
||||
|
||||
### 10. Large UI Tabs Need Feature-Sliced Components
|
||||
|
||||
Evidence:
|
||||
- `src/ui/windows/balance_tab.cpp` is 3,562 lines.
|
||||
- `src/ui/windows/mining_tab.cpp` is 2,842 lines.
|
||||
- `src/ui/windows/console_tab.cpp` is 1,861 lines.
|
||||
- Several of these files combine state management, drawing, filtering, formatting, and modal coordination.
|
||||
|
||||
Why it matters:
|
||||
- UI changes become difficult to review because unrelated drawing and state logic share one file.
|
||||
- Reusable patterns stay local and get reimplemented elsewhere.
|
||||
- Testing smaller formatting/state helpers is harder when they are buried in render functions.
|
||||
|
||||
Recommended cleanup:
|
||||
- Split large tabs by feature area: panels, table/list renderers, local state structs, formatting helpers, and action handlers.
|
||||
- Keep ImGui draw calls close to the view but move data preparation and command decisions into smaller helpers.
|
||||
- Avoid introducing inheritance-heavy UI abstractions; simple namespaces and structs should be enough.
|
||||
|
||||
Suggested first slice:
|
||||
- Completed in Phase 4 through Phase 8: extracted low-risk balance helpers, balance address-list/recent-transaction models, mining benchmark/pool helpers, console command/input/output models, and additional address-row/mining/console execution helpers. Remaining work should continue moving draw-heavy action glue only when it lowers review/test cost.
|
||||
|
||||
## Low-Priority And Quick-Win Findings
|
||||
|
||||
### 11. Test Infrastructure Is Missing Or Not Obvious
|
||||
|
||||
Before Phase 4, no test target was visible in the workspace snapshot. Phase 4 added a small CTest executable covering connection config parsing, `.cookie` fallback priority, plaintext/TLS detection, wallet state filtering, amount formatting, payment URI parsing, and scheduler behavior.
|
||||
|
||||
Recommended cleanup:
|
||||
- Completed in Phase 4: add a small CTest target with a lightweight assertion harness for pure utility/RPC/scheduler helpers.
|
||||
- Consider migrating to Catch2 or GoogleTest if the test suite grows beyond a few focused files.
|
||||
- Add mocks for `RPCClient` and daemon process state once service boundaries exist.
|
||||
|
||||
### 12. Translation Key Usage Is Inconsistent
|
||||
|
||||
`TR()` and `TrId()` coexist in places such as Settings. This is workable, but it increases typo risk and makes missing translations harder to audit.
|
||||
|
||||
Recommended cleanup:
|
||||
- Define constants for high-traffic translation keys.
|
||||
- Prefer one translation call style inside each module.
|
||||
- Add a script that compares referenced keys against `res/lang/*.json`.
|
||||
|
||||
### 13. Project Icon Handling Needs One Registry
|
||||
|
||||
Material icons are referenced directly in many files, while the pickaxe icon uses a special one-glyph font path. The special case is now documented in `docs/codebase-overview.md`, but a code-level registry would make future icon work cleaner.
|
||||
|
||||
Recommended cleanup:
|
||||
- Completed in Phase 2: add `src/ui/material/project_icons.h` for app-specific icon names.
|
||||
- Completed in Phase 2: encapsulate pickaxe rendering behind one helper, so feature code does not need to know which font supplies it.
|
||||
|
||||
### 14. Release/Portability Docs Can Be Closer To The Real Build
|
||||
|
||||
`build.sh` contains much more platform-specific behavior than the README currently explains. AppImage dependency bundling, macOS universal build defaults, Windows MinGW assumptions, embedded daemon/resource behavior, and third-party license sync deserve their own build notes.
|
||||
|
||||
Recommended cleanup:
|
||||
- Add a focused `docs/build-and-release.md`.
|
||||
- Document development build vs release packaging per platform.
|
||||
- Add a checklist for third-party license updates, including `libs/incbin.h`.
|
||||
|
||||
## Suggested Roadmap
|
||||
|
||||
### Phase 1: Small High-Value Fixes
|
||||
|
||||
- [x] Replace weak RPC credential generation in `src/rpc/connection.cpp`.
|
||||
- [x] Route direct URL/open actions away from raw `system()` calls.
|
||||
- [x] Add `xxd` checks and noisy Python theme-expansion warnings.
|
||||
- [x] Confirm missing third-party license entry for INCBIN is not needed because `THIRD_PARTY_LICENSES` already includes it.
|
||||
- [x] Extract repeated daemon stop try/catch blocks into a helper with contextual logging.
|
||||
|
||||
### Phase 2: UI Consistency Pass
|
||||
|
||||
- [x] Move common dialog sizes and action button widths into `res/themes/ui.toml` or existing layout helpers.
|
||||
- [x] Migrate address book Add/Edit dialogs to `BeginOverlayDialog()`.
|
||||
- [x] Add a shared dialog content/footer helper.
|
||||
- [x] Add an icon registry and hide the pickaxe font special case behind it.
|
||||
|
||||
### Phase 3: Ownership And Runtime Boundaries
|
||||
|
||||
- [x] Introduce `AsyncTaskManager` for background work.
|
||||
- [x] Extract `DaemonController` from `App`.
|
||||
- [x] Consolidate RPC auth and connection fallback behavior.
|
||||
- [x] Add warning/TLS configuration for non-localhost RPC.
|
||||
|
||||
### Phase 4: Larger Maintainability Work
|
||||
|
||||
- [x] Extract `NetworkRefreshService` or `RefreshScheduler`.
|
||||
- [x] Split `balance_tab.cpp`, `mining_tab.cpp`, and `console_tab.cpp` into smaller feature files where safe.
|
||||
- [x] Move generated language headers into `build/generated`.
|
||||
- [x] Add focused unit tests for connection config, URI parsing, amount formatting, wallet address spendability filtering, and scheduler behavior.
|
||||
|
||||
### Phase 5: Service Boundaries And Test Depth
|
||||
|
||||
- [x] Move wallet encryption, PIN, secure vault, key import/export, and restart flow state toward a `WalletSecurityController`.
|
||||
- [x] Move more daemon restart/shutdown policy and external-daemon behavior from `App` into `DaemonController`.
|
||||
- [x] Evolve `RefreshScheduler` into a fuller network refresh service by modeling named refresh jobs, in-flight state, and RPC queue pressure explicitly.
|
||||
- [x] Split larger UI feature renderers after the low-risk helper extractions, starting with balance address/recent transaction panels and mining benchmark/pool panels.
|
||||
- [x] Move settings/wizard static UI state into explicit state structs.
|
||||
- [x] Add deeper tests around daemon/RPC service boundaries, scheduler edge cases, wallet security state transitions, and generated resource behavior.
|
||||
- [x] Decide and document whether `src/config/version.h` is committed source or generated-only output.
|
||||
|
||||
### Phase 6: Deeper Service Extraction And Integration Tests
|
||||
|
||||
- [x] Move wallet-security RPC orchestration, secure-vault handoffs, key import/export state, and daemon restart sequencing behind service interfaces that can be exercised without constructing the full UI `App`.
|
||||
- [x] Move daemon restart/rescan/bootstrap shutdown sequencing and external-daemon ownership policy into `DaemonController` with mockable collaborators.
|
||||
- [x] Promote `NetworkRefreshService` from refresh policy/guard owner to dispatcher owner for named RPC jobs, queue-pressure telemetry, and stale-callback handling.
|
||||
- [x] Split large UI files into stable feature modules, especially balance address-list/recent-transaction rendering, mining benchmark/pool rendering, and console output/input rendering.
|
||||
- [x] Replace remaining compatibility aliases around extracted UI state with direct state-struct use.
|
||||
- [x] Add integration tests with mock RPC/daemon collaborators for wallet security, daemon lifecycle, and refresh dispatch edges.
|
||||
|
||||
### Phase 7: Service-Owned Execution And UI Module Finish
|
||||
|
||||
- [x] Move async daemon lifecycle execution, restart delays, rescan flags, blockchain-data deletion, bootstrap stops, and external-daemon ownership enforcement deeper into `DaemonController` with mock daemon/filesystem/task collaborators.
|
||||
- [x] Extract wallet decrypt/export/import dialog workflow state and secure-vault restart handoffs into a wallet-security workflow service that can be tested without constructing `App`.
|
||||
- [x] Promote `NetworkRefreshService` from dispatch-ticket/telemetry owner to the enqueue/callback wrapper for named RPC refresh jobs, including stale callback suppression and queue-pressure reporting at the service boundary.
|
||||
- [x] Continue splitting balance address-list rendering, mining benchmark controls, and console input/history/autocomplete into stable modules with tests.
|
||||
- [x] Remove temporary App compatibility bridges around daemon ownership and any remaining extracted UI state once direct service/state use is complete.
|
||||
|
||||
### Phase 8: Workflow Executors And Large-View Reduction
|
||||
|
||||
- [x] Move wallet decrypt import/restart RPC orchestration out of `App`'s nested lambdas into a workflow executor with mock RPC/daemon/vault/file collaborators.
|
||||
- [x] Split concrete daemon lifecycle runtime responsibilities into smaller adapters only where it lowers App coupling without adding indirection for its own sake.
|
||||
- [x] Complete named refresh enqueue coverage for price/market refresh and connect-time one-off polling.
|
||||
- [x] Continue feature-slicing large ImGui views by extracting address-row, mining benchmark/pool, and console command execution helper/model seams.
|
||||
- [x] Add focused tests around the new workflow, refresh, daemon lifecycle, and UI helper seams.
|
||||
|
||||
### Phase 9: Typed Refresh Results And State Cleanup
|
||||
|
||||
- [x] Evolve refresh jobs toward typed result models and service-owned result application contracts for refresh paths that still require complex app-local snapshots.
|
||||
- [x] Review remaining static UI state and compatibility glue, then move it to explicit state structs or document what is intentionally process-wide.
|
||||
- [x] Reviewed draw-heavy ImGui bodies and deferred additional slicing to Phase 10 unless adjacent to a required behavior-preserving change.
|
||||
- [x] Add focused tests around refresh result application, reconnect/stale-callback edges, and any state-struct migrations.
|
||||
|
||||
### Phase 10: Transaction Refresh And State Follow-Through
|
||||
|
||||
- [x] Evaluate typed address/transaction refresh result models and cache-update applicators for the refresh paths that still build large App-local snapshots.
|
||||
- [x] Reduce decrypt workflow orchestration in `App` further only where executor-owned operations can preserve progress reporting and cancellation behavior; Phase 10 evaluated the remaining chain and deferred extra movement because the current App handoffs are the progress/cancellation boundary.
|
||||
- [x] Convert remaining high-churn tab/dialog statics to explicit state structs as those views are touched, following `docs/ui-static-state.md`; Phase 10 did not touch those views for behavior changes.
|
||||
- [x] Add stronger reconnect/stale-callback, remote/TLS RPC, and shutdown-cancellation tests.
|
||||
|
||||
### Phase 11: Workflow And UI State Hardening
|
||||
|
||||
- [x] Revisit decrypt restart/import orchestration for one complete executor-owned step if progress and cancellation can stay explicit.
|
||||
- [x] Continue high-churn tab/dialog state-struct migrations only when those views are touched for real behavior changes.
|
||||
- [x] Move more address/transaction RPC snapshot construction into testable helpers if it reduces App coupling without duplicating daemon call ordering.
|
||||
- [x] Keep broadening refresh/RPC lifecycle tests around realistic worker callback ordering and reconnect races.
|
||||
|
||||
### Phase 12: Integration Fixtures And Remaining Orchestration Edges
|
||||
|
||||
- [x] Add shared mock RPC fixtures only if they let tests assert daemon call ordering directly instead of duplicating production sequencing in test setup.
|
||||
- [x] Move more refresh body construction out of `App` only where call ordering remains obvious and covered by tests.
|
||||
- [x] Revisit decrypt restart/import orchestration only if a complete progress/cancellation step can be executor-owned end to end.
|
||||
- [x] Continue UI state-struct migrations opportunistically when behavior work touches those tabs/dialogs.
|
||||
|
||||
### Phase 13: Ordered Collectors And Lifecycle Coverage
|
||||
|
||||
- [x] Extend ordered mock RPC coverage to additional refresh collectors only when those collectors move beyond App-owned call bodies.
|
||||
- [x] Keep refresh call ordering explicit in tests whenever daemon call sequencing moves into a service.
|
||||
- [x] Broaden reconnect/shutdown lifecycle coverage around async refresh cancellation only when those flows are touched.
|
||||
- [x] Continue decrypt and UI-state hardening opportunistically at complete, behavior-preserving seams.
|
||||
|
||||
### Phase 14: Mining And Connection Edge Review
|
||||
|
||||
- [x] Move mining refresh collection only if the fast/slow polling cadence remains explicit and covered by ordered mock RPC tests.
|
||||
- [x] Move connection-init prefetch only if lock-screen timing and `getinfo`/`getwalletinfo` ordering remain obvious and tested.
|
||||
- [x] Add lifecycle coverage only when changing worker callback, shutdown, reconnect, or daemon restart ownership.
|
||||
- [x] Continue decrypt and UI-state work only when a complete behavior-preserving step is touched.
|
||||
|
||||
### Phase 15: Remaining App-Owned Edges
|
||||
|
||||
- [x] Review warmup status polling only if the UI status/progress handoff can stay explicit and covered.
|
||||
- [x] Review price HTTP fetching only if network parsing, callback behavior, and failure handling become easier to test without hiding libcurl details.
|
||||
- [x] Leave command-style RPC actions App-owned unless a complete behavior change creates a focused extraction boundary.
|
||||
- [x] Add lifecycle, decrypt, or UI-state tests only when touching those behaviors directly.
|
||||
|
||||
### Phase 16: Price And Command Boundary Review
|
||||
|
||||
- [x] Revisit price HTTP fetching only if libcurl status/error behavior can be represented as a focused testable boundary.
|
||||
- [x] Keep send, address creation, mining toggle, ban, and import/export command RPC actions App-owned unless a complete behavior change justifies extraction.
|
||||
- [x] Add reconnect/shutdown lifecycle coverage only when callback ownership or cancellation behavior changes.
|
||||
- [x] Continue decrypt and UI-state work only when directly touching those workflows for behavior.
|
||||
|
||||
### Phase 17: Command And Lifecycle Guardrails
|
||||
|
||||
- [x] Review command-style RPC actions only when a complete behavior change creates a focused extraction boundary.
|
||||
- [x] Add reconnect/shutdown lifecycle coverage only when callback ownership, cancellation, shutdown, reconnect, or daemon restart behavior changes.
|
||||
- [x] Continue decrypt and UI-state work only when directly touching those workflows for behavior.
|
||||
- [x] Keep existing refresh and price boundaries stable unless new behavior creates a smaller tested seam.
|
||||
|
||||
### Phase 18: Feature-Driven Cleanup Only
|
||||
|
||||
- [x] Move command-style RPC actions only when a real behavior change supplies a focused extraction boundary and tests.
|
||||
- [x] Add lifecycle tests only when callback ownership, cancellation, shutdown, reconnect, or daemon restart behavior changes.
|
||||
- [x] Continue decrypt and UI-state cleanup only when those workflows are touched for behavior.
|
||||
- [x] Preserve stable refresh and price boundaries unless new behavior makes a smaller tested seam useful.
|
||||
|
||||
### Phase 19: Feature-Scoped Maintenance
|
||||
|
||||
- [x] Prefer feature-scoped maintenance over additional extraction-only phases.
|
||||
- [x] Move command-style RPC actions only with a complete behavior change and focused tests.
|
||||
- [x] Add lifecycle/decrypt/UI-state coverage only when those behaviors change directly.
|
||||
- [x] Preserve existing refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
### Phase 20: Maintenance Mode
|
||||
|
||||
- [x] Convert the cleanup roadmap from extraction phases to maintenance-mode guidance.
|
||||
- [x] Keep command-style RPC actions App-owned unless a complete feature change supplies focused tests.
|
||||
- [x] Add lifecycle/decrypt/UI-state coverage only when those behaviors change directly.
|
||||
- [x] Preserve stable refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
### Phase 21: Sustained Maintenance Mode
|
||||
|
||||
- [x] Continue maintenance-mode cleanup only when concrete feature work creates a focused tested seam.
|
||||
- [x] Keep command-style RPC actions App-owned unless a complete command workflow change supplies focused tests.
|
||||
- [x] Add lifecycle/decrypt/UI-state coverage only when those behaviors change directly.
|
||||
- [x] Preserve stable refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
### Phase 22: Maintenance Checkpoint
|
||||
|
||||
- [x] Continue sustained maintenance mode and avoid extraction-only phases unless concrete feature work creates a focused tested seam.
|
||||
- [x] Keep command-style RPC actions App-owned unless a complete command workflow change supplies focused tests.
|
||||
- [x] Add lifecycle/decrypt/UI-state coverage only when those behaviors change directly.
|
||||
- [x] Preserve stable refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
### Phase 23: Maintenance Checkpoint
|
||||
|
||||
- [ ] Continue sustained maintenance mode and avoid extraction-only phases unless concrete feature work creates a focused tested seam.
|
||||
- [ ] Keep command-style RPC actions App-owned unless a complete command workflow change supplies focused tests.
|
||||
- [ ] Add lifecycle/decrypt/UI-state coverage only when those behaviors change directly.
|
||||
- [ ] Preserve stable refresh and price service boundaries unless new behavior exposes a smaller tested seam.
|
||||
|
||||
## Residual Risk
|
||||
|
||||
Several findings are maintainability risks rather than confirmed user-facing bugs. Remote/plaintext RPC handling now warns and has a TLS config path, and Phase 10 added focused parsing tests, but real daemon TLS setups still deserve manual validation.
|
||||
|
||||
The safest next step is Phase 23: continue sustained maintenance mode, preserving tested boundaries and tying future cleanup to concrete feature work.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Codebase Overview
|
||||
|
||||
Current as of 2026-04-27 for ObsidianDragon `1.2.0-rc1`.
|
||||
|
||||
## Purpose
|
||||
|
||||
ObsidianDragon is a Dear ImGui full-node wallet for DragonX (DRGX). It manages an embedded or external `dragonxd`, renders a schema-driven desktop UI, and provides shielded transactions, mining, market data, address management, explorer tools, and bootstrap download support.
|
||||
|
||||
## Runtime Architecture
|
||||
|
||||
- `src/main.cpp` initializes SDL3, graphics backends, ImGui, and the main loop.
|
||||
- `src/app.cpp` owns application lifecycle, navigation, rendering, dialogs, and daemon/RPC startup.
|
||||
- `src/app_network.cpp` contains refresh and transaction flows, including balance, address, transaction, mining, market, peer, and address metadata updates.
|
||||
- `src/app_security.cpp` covers wallet encryption, PIN unlock, auto-lock, and secure vault integration.
|
||||
- `src/app_wizard.cpp` handles first-run setup and bootstrap/encryption flow.
|
||||
|
||||
The UI thread renders ImGui and drains callback queues. RPC work runs through `RPCWorker`, which posts worker-thread RPC calls and returns UI-thread callbacks. Console commands can use a separate fast-lane RPC client/worker so they do not queue behind regular refresh batches.
|
||||
|
||||
`NetworkRefreshService` owns refresh timing, named job enqueue/callback guards, typed refresh result models, and service-owned result applicators for connection, core balance/sync, encryption, mining, peers, price, address snapshots, and transaction cache updates. Warmup polling, connection-init, core, mining, peer, address, and transaction refresh collection now run through a testable `RefreshRpcGateway`, and price HTTP response evaluation has a service-owned status/error/result model. `App` keeps enqueueing, UI timing decisions, cadence decisions, libcurl execution, and application handoff code for those paths.
|
||||
|
||||
## Source Map
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `src/config/` | Settings JSON persistence and generated `version.h` |
|
||||
| `src/data/` | Wallet state, address book, exchange info |
|
||||
| `src/rpc/` | libcurl JSON-RPC client, connection config, worker queue |
|
||||
| `src/daemon/` | Embedded `dragonxd` manager and xmrig pool miner manager |
|
||||
| `src/ui/windows/` | Main tabs and modal dialogs |
|
||||
| `src/ui/pages/` | Page-style screens such as Settings |
|
||||
| `src/ui/schema/` | TOML schema loader, skin manager, color resolver |
|
||||
| `src/ui/material/` | Material-style typography, layout, drawing, components |
|
||||
| `src/ui/effects/` | Acrylic, blur, noise, theme effects, low-spec fallback |
|
||||
| `src/resources/` | Embedded resource extraction for params, daemon assets, themes, images |
|
||||
| `src/platform/` | Windows DX11 and backdrop helpers |
|
||||
| `src/util/` | i18n, logging, platform paths, bootstrap, vault, URI/base64/texture utilities |
|
||||
|
||||
## Build And Resources
|
||||
|
||||
- CMake uses C++17 and outputs `build/bin/ObsidianDragon`.
|
||||
- Version comes from `project(... VERSION 1.2.0)` plus `DRAGONX_VERSION_SUFFIX=-rc1`.
|
||||
- `src/config/version.h` is intentionally committed source for editor/release tooling compatibility, but CMake regenerates it from `src/config/version.h.in` during configure. Version bumps should update the CMake version/suffix and commit the regenerated header with the template if it changes.
|
||||
- SDL3 is found from the system first, then fetched by CMake if unavailable.
|
||||
- nlohmann/json and toml++ are fetched with CMake FetchContent.
|
||||
- libcurl is system-provided on Linux/macOS and fetched statically for Windows.
|
||||
- libsodium is system-provided on Linux or local under `libs/libsodium/`, `libs/libsodium-win/`, or `libs/libsodium-mac/`.
|
||||
- Fonts are embedded with INCBIN: Ubuntu, Material Icons, a one-glyph MDI pickaxe subset, and Noto CJK subset.
|
||||
- `res/themes/ui.toml` is embedded as a fallback and expanded into build themes with `scripts/expand_themes.py`.
|
||||
- `res/default_banlist.txt` is embedded into `build/generated/default_banlist_embedded.h`.
|
||||
|
||||
## RPC And Daemon Notes
|
||||
|
||||
- Default RPC port is `21769`.
|
||||
- `RPCClient` uses local HTTP with Basic auth. TLS is not assumed for localhost daemon RPC.
|
||||
- DragonX daemon config paths:
|
||||
- Linux: `~/.hush/DRAGONX/DRAGONX.conf`
|
||||
- Windows: `%APPDATA%/Hush/DRAGONX/DRAGONX.conf`
|
||||
- macOS: `~/Library/Application Support/Hush/DRAGONX/DRAGONX.conf`
|
||||
- `Connection::autoDetectConfig()` creates missing config files, appends `exportdir`, `experimentalfeatures=1`, and `developerencryptwallet=1`, and falls back to `.cookie` auth if no `rpcpassword` is configured.
|
||||
- The embedded daemon detects an external daemon on the RPC port and connects to it instead of taking ownership.
|
||||
- Chain args include TLS-only mode, adaptive `-dbcache`, DragonX asset parameters, node seeds `node.dragonx.is` through `node4.dragonx.is`, and optional `-maxconnections=<n>` from Settings.
|
||||
|
||||
## UI And Data Notes
|
||||
|
||||
- Sidebar navigation is driven by `NavPage`: Overview, Send, Receive, History, Mining, Market, Console, Network, Explorer, Settings.
|
||||
- Explorer lives in `src/ui/windows/explorer_tab.cpp`; Settings uses `src/ui/pages/settings_page.cpp`.
|
||||
- Address labels, icons, favorites, hidden state, and manual ordering are persisted in Settings, especially `address_meta`.
|
||||
- `AddressInfo::has_spending_key` tracks view-only shielded addresses; send flows filter or reject non-spendable z-addresses.
|
||||
- The pickaxe icon is not a normal `ICON_MD_*` glyph. Use `AddressLabelDialog::drawIconByName()` or `Typography::pickaxeFontForSize()` for that special case.
|
||||
- Remaining process-wide ImGui state and legacy compatibility wrappers are documented in `docs/ui-static-state.md`.
|
||||
- Warmup, connection-init, core, mining, peer, address, and transaction refreshes use typed `NetworkRefreshService` result contracts plus gateway-backed collectors. Price refresh keeps libcurl setup/execution and callback ownership in App, while `NetworkRefreshService` owns JSON parsing plus HTTP status/error result evaluation. App still owns warmup status handoffs, mining fast/slow cadence, and command-style RPC actions. Transaction application updates `WalletState`, `viewtx_cache_`, `send_txids_`, the confirmed transaction cache, and block-height markers as one cache-update operation.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
- Investigate `todo.md`: determine whether DragonX/Komodo wallet storage supports a safe compaction or consolidation workflow for wallets with too many addresses.
|
||||
- Phase 23 should continue sustained maintenance mode: keep refresh and price boundaries stable, and revisit command/lifecycle/decrypt/UI-state seams only when concrete feature work creates a focused tested boundary.
|
||||
@@ -0,0 +1,17 @@
|
||||
# UI Static State Policy
|
||||
|
||||
The wallet currently runs one `App` instance inside one ImGui context. Some UI state is still file-static because it represents process-wide tab or modal state that must persist across frames. Phase 9 reviewed these remaining cases and treats them as intentional until the owning view is next refactored.
|
||||
|
||||
## Intentional Process-Wide State
|
||||
|
||||
- Modal payload state in `src/ui/windows/*_dialog*` files owns one open dialog instance at a time. These statics are acceptable for singleton dialogs, but new async workflow state should live in a service or App-owned controller.
|
||||
- Tab-local form, filter, and selection state in views such as Send, Receive, Market, Mining, Peers, Transactions, and Explorer persists user input across frames. When those views are touched for behavior changes, prefer an explicit `*TabState` struct over adding more independent statics.
|
||||
- Material/theme/effect singletons hold process-wide rendering caches or current theme state. They should remain resettable only through their owning theme/effect APIs.
|
||||
- Compatibility glue remains where external call sites still rely on legacy names: `App::setCurrentTab()`/`getCurrentTab()`, `WalletState` balance aliases, layout `k*` accessors, and icon helper wrappers. Remove these only after all call sites have moved to the newer names.
|
||||
|
||||
## Rules For New UI Code
|
||||
|
||||
- Add new mutable UI state to an existing explicit state struct when one exists.
|
||||
- Use file-static state only for singleton UI surfaces that cannot have multiple instances in the current app model.
|
||||
- Keep long-running workflow progress outside render functions and expose it through services or App-owned controllers.
|
||||
- Document any new compatibility wrapper with the call sites it protects and delete it when the migration is complete.
|
||||
Binary file not shown.
@@ -346,6 +346,7 @@
|
||||
"market_now": "Jetzt",
|
||||
"market_pct_shielded": "%.0f%% Abgeschirmt",
|
||||
"market_portfolio": "PORTFOLIO",
|
||||
"market_price_loading": "Preisdaten werden geladen...",
|
||||
"market_price_unavailable": "Preisdaten nicht verfügbar",
|
||||
"market_refresh_price": "Preisdaten aktualisieren",
|
||||
"market_trade_on": "Handeln auf %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Ahora",
|
||||
"market_pct_shielded": "%.0f%% Protegido",
|
||||
"market_portfolio": "PORTAFOLIO",
|
||||
"market_price_loading": "Cargando datos de precio...",
|
||||
"market_price_unavailable": "Datos de precio no disponibles",
|
||||
"market_refresh_price": "Actualizar datos de precio",
|
||||
"market_trade_on": "Operar en %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Maintenant",
|
||||
"market_pct_shielded": "%.0f%% Blindé",
|
||||
"market_portfolio": "PORTEFEUILLE",
|
||||
"market_price_loading": "Chargement des données de prix...",
|
||||
"market_price_unavailable": "Données de prix indisponibles",
|
||||
"market_refresh_price": "Actualiser les données de prix",
|
||||
"market_trade_on": "Échanger sur %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "現在",
|
||||
"market_pct_shielded": "%.0f%% シールド済み",
|
||||
"market_portfolio": "ポートフォリオ",
|
||||
"market_price_loading": "価格データを読み込み中...",
|
||||
"market_price_unavailable": "価格データが利用できません",
|
||||
"market_refresh_price": "価格データを更新",
|
||||
"market_trade_on": "%s で取引",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "현재",
|
||||
"market_pct_shielded": "%.0f%% 차폐됨",
|
||||
"market_portfolio": "포트폴리오",
|
||||
"market_price_loading": "가격 데이터를 불러오는 중...",
|
||||
"market_price_unavailable": "가격 데이터를 사용할 수 없습니다",
|
||||
"market_refresh_price": "가격 데이터 새로고침",
|
||||
"market_trade_on": "%s에서 거래",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Agora",
|
||||
"market_pct_shielded": "%.0f%% Blindado",
|
||||
"market_portfolio": "PORTFÓLIO",
|
||||
"market_price_loading": "Carregando dados de preço...",
|
||||
"market_price_unavailable": "Dados de preço indisponíveis",
|
||||
"market_refresh_price": "Atualizar dados de preço",
|
||||
"market_trade_on": "Negociar no %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Сейчас",
|
||||
"market_pct_shielded": "%.0f%% Экранировано",
|
||||
"market_portfolio": "ПОРТФЕЛЬ",
|
||||
"market_price_loading": "Загрузка данных о ценах...",
|
||||
"market_price_unavailable": "Данные о ценах недоступны",
|
||||
"market_refresh_price": "Обновить данные о ценах",
|
||||
"market_trade_on": "Торговать на %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "现在",
|
||||
"market_pct_shielded": "%.0f%% 屏蔽",
|
||||
"market_portfolio": "投资组合",
|
||||
"market_price_loading": "正在加载价格数据...",
|
||||
"market_price_unavailable": "价格数据不可用",
|
||||
"market_refresh_price": "刷新价格数据",
|
||||
"market_trade_on": "在 %s 交易",
|
||||
|
||||
+22
-11
@@ -485,12 +485,12 @@ suggestion-trunc-len = { size = 50 }
|
||||
fee-rounding = { size = 10.0 }
|
||||
amount-bar-max-btn-width = { size = 80.0 }
|
||||
amount-bar-height = { size = 22.0 }
|
||||
confirm-popup-max-width = { size = 420.0 }
|
||||
confirm-popup-max-width = { size = 560.0 }
|
||||
confirm-addr-card-height = { size = 28.0 }
|
||||
confirm-amount-card-height = { size = 70.0 }
|
||||
confirm-row-step = { size = 16.0 }
|
||||
confirm-val-col-x = { size = 90.0 }
|
||||
confirm-usd-col-x = { size = 80.0 }
|
||||
confirm-amount-card-height = { size = 96.0 }
|
||||
confirm-row-step = { size = 24.0 }
|
||||
confirm-val-col-x = { size = 150.0 }
|
||||
confirm-usd-col-x = { size = 116.0 }
|
||||
progress-card-height = { size = 36.0 }
|
||||
progress-card-height-txid = { size = 52.0 }
|
||||
progress-card-pad-x = { size = 12.0 }
|
||||
@@ -516,10 +516,10 @@ error-icon-inset = { size = 20.0 }
|
||||
error-btn-rounding = { size = 4.0 }
|
||||
progress-glass-rounding-ratio = { size = 0.75 }
|
||||
confirm-addr-card-min-height = { size = 24.0 }
|
||||
confirm-val-col-min-x = { size = 70.0 }
|
||||
confirm-usd-col-min-x = { size = 60.0 }
|
||||
confirm-amount-card-min-height = { size = 54.0 }
|
||||
confirm-row-step-min = { size = 12.0 }
|
||||
confirm-val-col-min-x = { size = 112.0 }
|
||||
confirm-usd-col-min-x = { size = 92.0 }
|
||||
confirm-amount-card-min-height = { size = 82.0 }
|
||||
confirm-row-step-min = { size = 20.0 }
|
||||
action-btn-min-height = { size = 26.0 }
|
||||
recent-icon-min-size = { size = 3.5 }
|
||||
recent-icon-size = { size = 5.0 }
|
||||
@@ -566,7 +566,7 @@ txid-trunc-len = { size = 14 }
|
||||
txid-label-x-offset = { size = 20.0 }
|
||||
txid-copy-btn-right-offset = { size = 50.0 }
|
||||
txid-copy-btn-y-offset = { size = 2.0 }
|
||||
confirm-popup-width-ratio = { size = 0.85 }
|
||||
confirm-popup-width-ratio = { size = 0.92 }
|
||||
confirm-glass-rounding-ratio = { size = 0.75 }
|
||||
confirm-addr-trunc-len = { size = 48 }
|
||||
confirm-divider-thickness = { size = 1.0 }
|
||||
@@ -1279,7 +1279,7 @@ page-fade-speed = { size = 8.0 }
|
||||
collapse-hysteresis = { size = 60.0 }
|
||||
header-icon = { icon-dark = "logos/logo_ObsidianDragon_dark.png", icon-light = "logos/logo_ObsidianDragon_light.png" }
|
||||
coin-icon = { icon = "logos/logo_dragonx_128.png" }
|
||||
header-title = { font = "subtitle1", size = 14.0, pad-x = 22.0, pad-y = 6.0, logo-gap = 4.0, opacity = 0.7, offset-y = 4.0 }
|
||||
header-title = { font = "subtitle1", size = 12.0, pad-x = 8.0, pad-y = 10.0, logo-gap = 4.0, opacity = 0.7, offset-y = -2.0 }
|
||||
|
||||
[components.main-window.window]
|
||||
padding = [12, 36]
|
||||
@@ -1378,6 +1378,17 @@ summary = { min-width = 280.0, max-width = 400.0, width-ratio = 0.32, min-height
|
||||
side-panel = { min-width = 280.0, max-width = 450.0, width-ratio = 0.4, min-height = 200.0, max-height = 350.0, height-ratio = 0.8 }
|
||||
table = { min-height = 150.0, height-ratio = 0.45, min-remaining = 100.0, default-reserve = 30.0 }
|
||||
|
||||
[dialog]
|
||||
width-default = 480.0
|
||||
width-lg = 600.0
|
||||
width-xl = 660.0
|
||||
min-width = 280.0
|
||||
form-width = 400.0
|
||||
action-width = 100.0
|
||||
action-gap = 8.0
|
||||
max-height-ratio = 0.94
|
||||
compact-bottom-ratio = 0.64
|
||||
|
||||
[button]
|
||||
min-width = 180.0
|
||||
width = 140.0
|
||||
|
||||
@@ -120,20 +120,20 @@ pkgs_core_debian="build-essential cmake git pkg-config
|
||||
libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev
|
||||
libxinerama-dev libxi-dev libxkbcommon-dev libwayland-dev
|
||||
libsodium-dev libcurl4-openssl-dev
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python3 xxd"
|
||||
|
||||
pkgs_core_fedora="gcc gcc-c++ cmake git pkg-config
|
||||
mesa-libGL-devel libX11-devel libXcursor-devel libXrandr-devel
|
||||
libXinerama-devel libXi-devel libxkbcommon-devel wayland-devel
|
||||
libsodium-devel libcurl-devel
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python3 vim-common"
|
||||
|
||||
pkgs_core_arch="base-devel cmake git pkg-config
|
||||
mesa libx11 libxcursor libxrandr libxinerama libxi
|
||||
libxkbcommon wayland libsodium curl
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python xxd"
|
||||
|
||||
pkgs_core_macos="cmake"
|
||||
pkgs_core_macos="cmake python xxd"
|
||||
|
||||
# Windows cross-compile (from Linux)
|
||||
pkgs_win_debian="mingw-w64 zip"
|
||||
@@ -245,7 +245,7 @@ if [[ -z "$core_pkgs" ]]; then
|
||||
else
|
||||
# Check if key tools are already present
|
||||
NEED_CORE=false
|
||||
has_cmd cmake && has_cmd g++ && has_cmd pkg-config || NEED_CORE=true
|
||||
has_cmd cmake && has_cmd g++ && has_cmd pkg-config && has_cmd python3 && has_cmd xxd || NEED_CORE=true
|
||||
|
||||
if $NEED_CORE; then
|
||||
install_pkgs "$core_pkgs" "core build"
|
||||
@@ -258,6 +258,8 @@ check_tool cmake "cmake"
|
||||
check_tool g++ "g++ (C++ compiler)"
|
||||
check_tool git "git"
|
||||
check_tool make "make"
|
||||
check_tool python3 "python3 (theme expansion)"
|
||||
check_tool xxd "xxd (embedded language headers)"
|
||||
|
||||
# ── 2. libsodium ────────────────────────────────────────────────────────────
|
||||
header "libsodium"
|
||||
|
||||
+284
-241
@@ -11,7 +11,9 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
#include "ui/windows/main_window.h"
|
||||
#include "ui/windows/balance_tab.h"
|
||||
@@ -78,10 +80,12 @@
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <unordered_set>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -96,6 +100,48 @@ using json = nlohmann::json;
|
||||
App::App() = default;
|
||||
App::~App() = default;
|
||||
|
||||
bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
|
||||
{
|
||||
const char* label = context ? context : "App";
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Daemon lifecycle / Stop command");
|
||||
client.call("stop");
|
||||
DEBUG_LOGF("[%s] Stop command sent\n", label);
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[%s] Stop RPC failed: %s\n", label, e.what());
|
||||
} catch (...) {
|
||||
DEBUG_LOGF("[%s] Stop RPC failed with unknown exception\n", label);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class AppDaemonLifecycleRuntime final : public daemon::DaemonController::LifecycleRuntime {
|
||||
public:
|
||||
explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {}
|
||||
|
||||
void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); }
|
||||
bool startDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
void resetOutputOffset() override { app_.daemon_output_offset_ = 0; }
|
||||
|
||||
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
||||
{
|
||||
if (app_.rpc_ && app_.rpc_->isConnected()) {
|
||||
app_.sendStopCommandSafely(*app_.rpc_, context);
|
||||
app_.rpc_->disconnect();
|
||||
}
|
||||
app_.onDisconnected(reason ? reason : "Daemon lifecycle");
|
||||
}
|
||||
|
||||
int deleteBlockchainData() override
|
||||
{
|
||||
return daemon::BlockchainDataCleaner::removeBlockchainData(util::Platform::getDragonXDataDir());
|
||||
}
|
||||
|
||||
private:
|
||||
App& app_;
|
||||
};
|
||||
|
||||
bool App::init()
|
||||
{
|
||||
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
||||
@@ -219,6 +265,7 @@ bool App::init()
|
||||
// Initialize background RPC worker thread
|
||||
worker_ = std::make_unique<rpc::RPCWorker>();
|
||||
worker_->start();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);
|
||||
|
||||
// Forward error/warning notifications to the console tab
|
||||
// Use ConsoleTab colors so the Errors filter works correctly
|
||||
@@ -331,6 +378,7 @@ void App::update()
|
||||
if (fast_worker_) {
|
||||
fast_worker_->drainResults();
|
||||
}
|
||||
async_tasks_.reapCompleted();
|
||||
|
||||
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||
if (state_.connected && state_.isUnlocked()) {
|
||||
@@ -346,31 +394,25 @@ void App::update()
|
||||
state_.rebuildAddressList();
|
||||
}
|
||||
|
||||
// Update timers
|
||||
core_timer_ += io.DeltaTime;
|
||||
address_timer_ += io.DeltaTime;
|
||||
transaction_timer_ += io.DeltaTime;
|
||||
peer_timer_ += io.DeltaTime;
|
||||
price_timer_ += io.DeltaTime;
|
||||
fast_refresh_timer_ += io.DeltaTime;
|
||||
tx_age_timer_ += io.DeltaTime;
|
||||
opid_poll_timer_ += io.DeltaTime;
|
||||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||||
network_refresh_.tick(io.DeltaTime);
|
||||
|
||||
// Fast refresh (mining stats + daemon memory) every second
|
||||
// Skip when wallet is locked — no need to poll, and queued tasks
|
||||
// would delay the PIN unlock worker task.
|
||||
if (fast_refresh_timer_ >= FAST_REFRESH_INTERVAL) {
|
||||
fast_refresh_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
|
||||
if (state_.connected && !state_.isLocked()) {
|
||||
refreshMiningInfo();
|
||||
|
||||
// Poll getrescaninfo for rescan progress (if rescan flag is set)
|
||||
// Use fast_rpc_ when available to avoid blocking on rpc_'s
|
||||
// curl_mutex (which may be held by a long-running import).
|
||||
if (state_.sync.rescanning && fast_worker_) {
|
||||
if (state_.sync.rescanning && fast_worker_ && !rescan_status_poll_in_progress_) {
|
||||
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
rescan_status_poll_in_progress_ = true;
|
||||
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Startup / Rescan monitor");
|
||||
auto info = rescanRpc->call("getrescaninfo");
|
||||
bool rescanning = info.value("rescanning", false);
|
||||
float progress = 0.0f;
|
||||
@@ -379,6 +421,7 @@ void App::update()
|
||||
try { progress = std::stof(progStr) * 100.0f; } catch (...) {}
|
||||
}
|
||||
return [this, rescanning, progress]() {
|
||||
rescan_status_poll_in_progress_ = false;
|
||||
if (rescanning) {
|
||||
state_.sync.rescanning = true;
|
||||
if (progress > 0.0f) {
|
||||
@@ -394,7 +437,7 @@ void App::update()
|
||||
};
|
||||
} catch (...) {
|
||||
// RPC not available yet or failed
|
||||
return [](){};
|
||||
return [this](){ rescan_status_poll_in_progress_ = false; };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -435,13 +478,13 @@ void App::update()
|
||||
}
|
||||
|
||||
// Populate solo mining log lines from daemon output
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
state_.mining.log_lines = embedded_daemon_->getRecentLines(50);
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
state_.mining.log_lines = daemon_controller_->recentLines(50);
|
||||
}
|
||||
|
||||
// Check daemon output for rescan progress (offloaded to worker)
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
std::string newOutput = embedded_daemon_->getOutputSince(daemon_output_offset_);
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_);
|
||||
if (!newOutput.empty() && fast_worker_) {
|
||||
fast_worker_->post([this, output = std::move(newOutput)]() -> rpc::RPCWorker::MainCb {
|
||||
// Parse on worker thread — pure string work, no shared state access
|
||||
@@ -524,7 +567,7 @@ void App::update()
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (!embedded_daemon_ || !embedded_daemon_->isRunning()) {
|
||||
} else if (!daemon_controller_ || !daemon_controller_->isRunning()) {
|
||||
// Clear rescan state if daemon is not running (but preserve during restart)
|
||||
if (state_.sync.rescanning && state_.sync.rescan_progress >= 0.99f) {
|
||||
state_.sync.rescanning = false;
|
||||
@@ -534,73 +577,60 @@ void App::update()
|
||||
}
|
||||
|
||||
// Poll pending z_sendmany operations for completion
|
||||
if (opid_poll_timer_ >= OPID_POLL_INTERVAL && !pending_opids_.empty()
|
||||
&& state_.connected && fast_worker_) {
|
||||
opid_poll_timer_ = 0.0f;
|
||||
if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
|
||||
&& state_.connected && fast_worker_ && !opid_poll_in_progress_) {
|
||||
network_refresh_.reset(RefreshTimer::Opid);
|
||||
auto opids = pending_opids_; // copy for worker thread
|
||||
opid_poll_in_progress_ = true;
|
||||
fast_worker_->post([this, opids]() -> rpc::RPCWorker::MainCb {
|
||||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
if (!rpc) return [](){};
|
||||
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
|
||||
json ids = json::array();
|
||||
for (const auto& id : opids) ids.push_back(id);
|
||||
json result;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
|
||||
result = rpc->call("z_getoperationstatus", {ids});
|
||||
} catch (...) {
|
||||
return [](){};
|
||||
return [this](){ opid_poll_in_progress_ = false; };
|
||||
}
|
||||
// Collect completed/failed opids
|
||||
std::vector<std::string> done;
|
||||
bool anySuccess = false;
|
||||
for (const auto& op : result) {
|
||||
std::string status = op.value("status", "");
|
||||
std::string opid = op.value("id", "");
|
||||
if (status == "success") {
|
||||
done.push_back(opid);
|
||||
anySuccess = true;
|
||||
} else if (status == "failed") {
|
||||
done.push_back(opid);
|
||||
std::string msg = "Transaction failed";
|
||||
if (op.contains("error") && op["error"].contains("message"))
|
||||
msg = op["error"]["message"].get<std::string>();
|
||||
// Capture for main thread
|
||||
return [this, done, msg]() {
|
||||
ui::Notifications::instance().error(msg);
|
||||
for (const auto& id : done) {
|
||||
pending_opids_.erase(
|
||||
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
||||
pending_opids_.end());
|
||||
}
|
||||
};
|
||||
auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
|
||||
return [this, parsed = std::move(parsed)]() mutable {
|
||||
opid_poll_in_progress_ = false;
|
||||
for (const auto& msg : parsed.failureMessages) {
|
||||
ui::Notifications::instance().error(msg);
|
||||
}
|
||||
}
|
||||
// Extract txids from successful operations so shielded
|
||||
// sends are discoverable by z_viewtransaction.
|
||||
std::vector<std::string> successTxids;
|
||||
for (const auto& op : result) {
|
||||
if (op.value("status", "") == "success"
|
||||
&& op.contains("result") && op["result"].contains("txid")) {
|
||||
successTxids.push_back(op["result"]["txid"].get<std::string>());
|
||||
}
|
||||
}
|
||||
return [this, done, anySuccess,
|
||||
successTxids = std::move(successTxids)]() {
|
||||
for (const auto& id : done) {
|
||||
std::vector<std::string> terminalOpids = std::move(parsed.doneOpids);
|
||||
terminalOpids.insert(terminalOpids.end(),
|
||||
parsed.staleOpids.begin(), parsed.staleOpids.end());
|
||||
for (const auto& id : terminalOpids) {
|
||||
pending_opids_.erase(
|
||||
std::remove(pending_opids_.begin(), pending_opids_.end(), id),
|
||||
pending_opids_.end());
|
||||
}
|
||||
if (anySuccess) {
|
||||
for (const auto& txid : successTxids) {
|
||||
if (parsed.anySuccess) {
|
||||
std::unordered_set<std::string> successfulOpids;
|
||||
for (const auto& [opid, txid] : parsed.successTxidsByOpid) {
|
||||
successfulOpids.insert(opid);
|
||||
markPendingSendTransactionSucceeded(opid, txid);
|
||||
send_txids_.insert(txid);
|
||||
}
|
||||
std::vector<std::string> successOpids;
|
||||
std::vector<std::string> failedOrStaleOpids;
|
||||
for (const auto& opid : terminalOpids) {
|
||||
if (successfulOpids.find(opid) != successfulOpids.end()) successOpids.push_back(opid);
|
||||
else failedOrStaleOpids.push_back(opid);
|
||||
}
|
||||
removePendingSendTransactions(successOpids, false);
|
||||
removePendingSendTransactions(failedOrStaleOpids, true);
|
||||
// Transaction confirmed by daemon — force immediate data refresh
|
||||
transactions_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
core_timer_ = active_core_interval_;
|
||||
transaction_timer_ = active_tx_interval_;
|
||||
address_timer_ = active_addr_interval_;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
} else {
|
||||
removePendingSendTransactions(terminalOpids, true);
|
||||
maybeFinishTransactionSendProgress();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -609,27 +639,29 @@ void App::update()
|
||||
// Per-category refresh with tab-aware intervals
|
||||
// Skip when wallet is locked — same reason as above.
|
||||
if (state_.connected && !state_.isLocked()) {
|
||||
if (core_timer_ >= active_core_interval_) {
|
||||
core_timer_ = 0.0f;
|
||||
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
||||
refreshCoreData();
|
||||
}
|
||||
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
|
||||
if (!state_.warming_up) {
|
||||
if (transaction_timer_ >= active_tx_interval_) {
|
||||
transaction_timer_ = 0.0f;
|
||||
refreshTransactionData();
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
|
||||
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
|
||||
refreshTransactionData();
|
||||
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
|
||||
refreshRecentTransactionData();
|
||||
}
|
||||
}
|
||||
if (address_timer_ >= active_addr_interval_) {
|
||||
address_timer_ = 0.0f;
|
||||
refreshAddressData();
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
|
||||
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
|
||||
refreshAddressData();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (peer_timer_ >= active_peer_interval_) {
|
||||
peer_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
||||
refreshPeerInfo();
|
||||
}
|
||||
} else if (core_timer_ >= active_core_interval_) {
|
||||
core_timer_ = 0.0f;
|
||||
} else if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
||||
if (!connection_in_progress_ &&
|
||||
wizard_phase_ == WizardPhase::None &&
|
||||
!bootstrap_downloading_) {
|
||||
@@ -638,8 +670,7 @@ void App::update()
|
||||
}
|
||||
|
||||
// Price refresh every 60 seconds
|
||||
if (price_timer_ >= PRICE_INTERVAL) {
|
||||
price_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Price)) {
|
||||
if (settings_->getFetchPrices()) {
|
||||
refreshPrice();
|
||||
}
|
||||
@@ -1032,8 +1063,12 @@ void App::render()
|
||||
bool prevCollapsed = sidebar_collapsed_;
|
||||
{
|
||||
PERF_SCOPE("Render.Sidebar");
|
||||
ui::RenderSidebar(current_page_, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
|
||||
ui::NavPage requestedPage = current_page_;
|
||||
ui::RenderSidebar(requestedPage, sidebarW, sidebarH, sbStatus, sidebar_collapsed_,
|
||||
state_.isLocked());
|
||||
if (requestedPage != current_page_) {
|
||||
setCurrentPage(requestedPage);
|
||||
}
|
||||
}
|
||||
if (sbStatus.exitClicked) {
|
||||
requestQuit();
|
||||
@@ -1159,7 +1194,7 @@ void App::render()
|
||||
// Use fast-lane worker for console commands to avoid head-of-line
|
||||
// blocking behind the consolidated refreshData() batch.
|
||||
// Fall back to main rpc/worker if fast-lane hasn't connected yet.
|
||||
console_tab_.render(embedded_daemon_.get(),
|
||||
console_tab_.render(daemon_controller_ ? daemon_controller_->daemon() : nullptr,
|
||||
(fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(),
|
||||
(fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get(),
|
||||
xmrig_manager_.get());
|
||||
@@ -1571,8 +1606,24 @@ void App::renderStatusBar()
|
||||
displayHashrate);
|
||||
}
|
||||
|
||||
// Transaction submission/operation progress
|
||||
if (hasTransactionSendProgress()) {
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine(0, sbSeparatorGap);
|
||||
ImGui::PushFont(ui::material::Type().iconSmall());
|
||||
float pulse = 0.6f + 0.4f * sinf((float)ImGui::GetTime() * 3.0f);
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_SEND);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, sbIconTextGap);
|
||||
int dots = (int)(ImGui::GetTime() * 2.0f) % 4;
|
||||
const char* dotStr = (dots == 0) ? "." : (dots == 1) ? ".." : (dots == 2) ? "..." : "";
|
||||
std::string status = transactionSendProgressText();
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", status.c_str(), dotStr);
|
||||
}
|
||||
|
||||
// Decrypt-import background task indicator
|
||||
if (decrypt_import_active_) {
|
||||
if (wallet_security_workflow_.importActive()) {
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine(0, sbSeparatorGap);
|
||||
@@ -1992,13 +2043,11 @@ void App::renderAntivirusHelpDialog()
|
||||
void App::refreshNow()
|
||||
{
|
||||
// Trigger immediate refresh on all categories
|
||||
core_timer_ = active_core_interval_;
|
||||
transaction_timer_ = active_tx_interval_;
|
||||
address_timer_ = active_addr_interval_;
|
||||
peer_timer_ = active_peer_interval_;
|
||||
network_refresh_.markImmediateRefresh();
|
||||
transactions_dirty_ = true; // Force transaction list update
|
||||
addresses_dirty_ = true; // Force address/balance update
|
||||
last_tx_block_height_ = -1; // Reset tx cache
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
}
|
||||
|
||||
void App::handlePaymentURI(const std::string& uri)
|
||||
@@ -2155,12 +2204,12 @@ bool App::startEmbeddedDaemon()
|
||||
}
|
||||
}
|
||||
|
||||
// Create daemon manager if needed
|
||||
if (!embedded_daemon_) {
|
||||
embedded_daemon_ = std::make_unique<daemon::EmbeddedDaemon>();
|
||||
// Create daemon controller if needed
|
||||
if (!daemon_controller_) {
|
||||
daemon_controller_ = std::make_unique<daemon::DaemonController>();
|
||||
|
||||
// Set up state callback
|
||||
embedded_daemon_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||
switch (state) {
|
||||
case daemon::EmbeddedDaemon::State::Starting:
|
||||
daemon_status_ = TR("sb_starting_daemon");
|
||||
@@ -2181,27 +2230,17 @@ bool App::startEmbeddedDaemon()
|
||||
});
|
||||
}
|
||||
|
||||
// Sync debug logging categories from user settings
|
||||
if (settings_) {
|
||||
embedded_daemon_->setDebugCategories(settings_->getDebugCategories());
|
||||
embedded_daemon_->setMaxConnections(settings_->getMaxConnections());
|
||||
}
|
||||
|
||||
return embedded_daemon_->start();
|
||||
return daemon_controller_->start(settings_.get());
|
||||
}
|
||||
|
||||
void App::stopEmbeddedDaemon()
|
||||
{
|
||||
if (!embedded_daemon_) return;
|
||||
if (!daemon_controller_) return;
|
||||
|
||||
// Never stop an external daemon unless the user explicitly opted in
|
||||
// via the "Stop external daemon" checkbox in Settings. This is a
|
||||
// defence-in-depth guard — callers should also check, but this
|
||||
// ensures no code path accidentally shuts down a daemon we don't own.
|
||||
if (embedded_daemon_->externalDaemonDetected() &&
|
||||
!(settings_ && settings_->getStopExternalDaemon())) {
|
||||
DEBUG_LOGF("stopEmbeddedDaemon: external daemon detected — "
|
||||
"skipping (stop_external_daemon setting is off)\n");
|
||||
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
||||
false, settings_ && settings_->getStopExternalDaemon());
|
||||
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
||||
DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2231,15 +2270,12 @@ void App::stopEmbeddedDaemon()
|
||||
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
||||
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
||||
// Use a short timeout — if daemon isn't listening yet, don't block
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword)) {
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword,
|
||||
config.use_tls)) {
|
||||
DEBUG_LOGF("Temporary RPC connected, sending stop...\n");
|
||||
try {
|
||||
tmp_rpc->call("stop");
|
||||
if (sendStopCommandSafely(*tmp_rpc, "Temporary daemon stop")) {
|
||||
stop_sent = true;
|
||||
DEBUG_LOGF("Stop command sent via temporary connection\n");
|
||||
} catch (...) {
|
||||
DEBUG_LOGF("Stop RPC failed via temporary connection\n");
|
||||
}
|
||||
tmp_rpc->disconnect();
|
||||
} else {
|
||||
@@ -2263,19 +2299,21 @@ void App::stopEmbeddedDaemon()
|
||||
shutdown_status_ = "Waiting for dragonxd process to exit...";
|
||||
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
|
||||
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
|
||||
embedded_daemon_->stop(20000);
|
||||
daemon_controller_->stop(20000);
|
||||
}
|
||||
|
||||
bool App::isEmbeddedDaemonRunning() const
|
||||
{
|
||||
return embedded_daemon_ && embedded_daemon_->isRunning();
|
||||
return daemon_controller_ && daemon_controller_->isRunning();
|
||||
}
|
||||
|
||||
void App::rescanBlockchain()
|
||||
{
|
||||
if (!isUsingEmbeddedDaemon() || !embedded_daemon_) {
|
||||
ui::Notifications::instance().warning(
|
||||
"Rescan requires embedded daemon. Restart your daemon with -rescan manually.");
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::Rescan,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (!decision.allowed) {
|
||||
ui::Notifications::instance().warning(decision.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2285,103 +2323,68 @@ void App::rescanBlockchain()
|
||||
// Initialize rescan state for status bar display
|
||||
state_.sync.rescanning = true;
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
state_.sync.rescan_status = "Starting rescan...";
|
||||
state_.sync.rescan_status = decision.status;
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
|
||||
// Set rescan flag BEFORE stopping so it's ready when we restart
|
||||
embedded_daemon_->setRescanOnNextStart(true);
|
||||
DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", embedded_daemon_->rescanOnNextStart() ? 1 : 0);
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", daemon_controller_->rescanOnNextStart() ? 1 : 0);
|
||||
|
||||
// Stop daemon, then restart
|
||||
std::thread([this]() {
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
DEBUG_LOGF("[App] Stopping daemon for rescan...\n");
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
DEBUG_LOGF("[App] Waiting for daemon to fully stop...\n");
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
// Reset output offset so we parse fresh output for rescan progress
|
||||
daemon_output_offset_ = 0;
|
||||
|
||||
DEBUG_LOGF("[App] Starting daemon with rescan flag=%d\n", embedded_daemon_->rescanOnNextStart() ? 1 : 0);
|
||||
startEmbeddedDaemon();
|
||||
}).detach();
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
});
|
||||
}
|
||||
|
||||
void App::deleteBlockchainData()
|
||||
{
|
||||
if (!isUsingEmbeddedDaemon() || !embedded_daemon_) {
|
||||
ui::Notifications::instance().warning(
|
||||
"Delete blockchain requires embedded daemon. Stop your daemon manually and delete the data directory.");
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::DeleteBlockchainData,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (!decision.allowed) {
|
||||
ui::Notifications::instance().warning(decision.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
|
||||
ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");
|
||||
|
||||
std::thread([this]() {
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
DEBUG_LOGF("[App] Stopping daemon for blockchain deletion...\n");
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
|
||||
// Directories to remove
|
||||
const char* dirs[] = { "blocks", "chainstate", "database", "notarizations" };
|
||||
// Files to remove
|
||||
const char* files[] = { "peers.dat", "fee_estimates.dat", "banlist.dat",
|
||||
"db.log", ".lock" };
|
||||
|
||||
int removed = 0;
|
||||
std::error_code ec;
|
||||
for (auto d : dirs) {
|
||||
fs::path p = fs::path(dataDir) / d;
|
||||
if (fs::exists(p, ec)) {
|
||||
auto n = fs::remove_all(p, ec);
|
||||
if (!ec) { removed += (int)n; DEBUG_LOGF("[App] Removed %s (%d entries)\n", d, (int)n); }
|
||||
else { DEBUG_LOGF("[App] Failed to remove %s: %s\n", d, ec.message().c_str()); }
|
||||
}
|
||||
}
|
||||
for (auto f : files) {
|
||||
fs::path p = fs::path(dataDir) / f;
|
||||
if (fs::remove(p, ec)) { removed++; DEBUG_LOGF("[App] Removed %s\n", f); }
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", removed);
|
||||
|
||||
daemon_output_offset_ = 0;
|
||||
startEmbeddedDaemon();
|
||||
}).detach();
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems);
|
||||
});
|
||||
}
|
||||
|
||||
bool App::stopDaemonForBootstrap()
|
||||
{
|
||||
bool wasRunning = isEmbeddedDaemonRunning();
|
||||
if (wasRunning) {
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::BootstrapStop,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (decision.wasRunning) {
|
||||
DEBUG_LOGF("[App] Stopping embedded daemon for bootstrap download...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::ImmediateLifecycleTaskContext context;
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
}
|
||||
return wasRunning;
|
||||
return decision.wasRunning;
|
||||
}
|
||||
|
||||
double App::getDaemonMemoryUsageMB() const
|
||||
{
|
||||
// If we have an embedded daemon with a tracked process handle, use it
|
||||
// directly — more reliable than a process scan since we own the handle.
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
double mb = embedded_daemon_->getMemoryUsageMB();
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
double mb = daemon_controller_->memoryUsageMB();
|
||||
daemon_mem_diag_ = "embedded";
|
||||
if (mb > 0.0) return mb;
|
||||
} else {
|
||||
@@ -2408,6 +2411,7 @@ void App::beginShutdown()
|
||||
quit_requested_ = true;
|
||||
shutdown_timer_ = 0.0f;
|
||||
shutdown_start_time_ = std::chrono::steady_clock::now();
|
||||
async_tasks_.cancelAll();
|
||||
|
||||
// Signal the RPC worker to stop accepting new tasks (non-blocking).
|
||||
// The actual thread join + rpc disconnect happen in shutdown() after
|
||||
@@ -2425,13 +2429,13 @@ void App::beginShutdown()
|
||||
xmrig_manager_->stop(3000);
|
||||
}
|
||||
|
||||
DEBUG_LOGF("beginShutdown: starting (embedded_daemon_=%s)\n",
|
||||
embedded_daemon_ ? "yes" : "no");
|
||||
DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n",
|
||||
daemon_controller_ ? "yes" : "no");
|
||||
|
||||
// If no embedded daemon, just mark done — don't stop
|
||||
// an externally-managed daemon that the user started themselves.
|
||||
// Worker join + RPC disconnect happen in shutdown().
|
||||
if (!embedded_daemon_) {
|
||||
if (!daemon_controller_) {
|
||||
DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
|
||||
shutdown_status_ = "Disconnecting...";
|
||||
if (settings_) {
|
||||
@@ -2446,21 +2450,17 @@ void App::beginShutdown()
|
||||
settings_->save();
|
||||
}
|
||||
|
||||
// If user opted to keep daemon running, just mark done.
|
||||
// Also never stop an external daemon the user started themselves,
|
||||
// unless they've explicitly enabled the "stop external daemon" setting.
|
||||
bool externalDaemon = embedded_daemon_ && embedded_daemon_->externalDaemonDetected();
|
||||
if ((settings_ && settings_->getKeepDaemonRunning()) ||
|
||||
(externalDaemon && !(settings_ && settings_->getStopExternalDaemon()))) {
|
||||
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n",
|
||||
externalDaemon ? "external daemon (not ours to stop)"
|
||||
: "keep_daemon_running enabled");
|
||||
shutdown_status_ = "Disconnecting (daemon stays running)...";
|
||||
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
||||
settings_ && settings_->getKeepDaemonRunning(),
|
||||
settings_ && settings_->getStopExternalDaemon());
|
||||
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
||||
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
|
||||
shutdown_status_ = shutdownDecision.status;
|
||||
shutdown_complete_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
shutdown_status_ = "Sending stop command to daemon...";
|
||||
shutdown_status_ = shutdownDecision.status;
|
||||
DEBUG_LOGF("beginShutdown: spawning shutdown thread for daemon stop\n");
|
||||
|
||||
// Run the daemon shutdown on a background thread so the UI
|
||||
@@ -2684,8 +2684,8 @@ void App::renderShutdownScreen()
|
||||
// -------------------------------------------------------------------
|
||||
// 6. Daemon output panel — terminal-style box
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_) {
|
||||
auto lines = embedded_daemon_->getRecentLines(8);
|
||||
if (daemon_controller_) {
|
||||
auto lines = daemon_controller_->recentLines(8);
|
||||
if (!lines.empty()) {
|
||||
float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
|
||||
float panelX = cx - panelW * 0.5f;
|
||||
@@ -2894,7 +2894,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 3b. Deferred encryption status
|
||||
// -------------------------------------------------------------------
|
||||
if (deferred_encrypt_pending_) {
|
||||
if (wallet_security_.hasDeferredEncryption()) {
|
||||
curY += gap;
|
||||
ImFont* capFont = Type().caption();
|
||||
if (!capFont) capFont = ImGui::GetFont();
|
||||
@@ -2926,8 +2926,8 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 3c. Daemon crash error message
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_ &&
|
||||
embedded_daemon_->getState() == daemon::EmbeddedDaemon::State::Error) {
|
||||
if (daemon_controller_ &&
|
||||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) {
|
||||
curY += gap;
|
||||
ImFont* capFont = Type().caption();
|
||||
if (!capFont) capFont = ImGui::GetFont();
|
||||
@@ -2943,7 +2943,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
curY += ts.y + gap * 0.5f;
|
||||
|
||||
// Error details (wrapped) — show full diagnostic info
|
||||
const std::string& errDetail = embedded_daemon_->getLastError();
|
||||
const std::string& errDetail = daemon_controller_->lastError();
|
||||
if (!errDetail.empty()) {
|
||||
float wrapW = ws.x * 0.8f;
|
||||
if (wrapW > 700.0f) wrapW = 700.0f;
|
||||
@@ -2955,7 +2955,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
}
|
||||
|
||||
// Crash count hint
|
||||
if (embedded_daemon_->getCrashCount() >= 3) {
|
||||
if (daemon_controller_->crashCount() >= 3) {
|
||||
const char* hint = "Use Settings > Restart Daemon to try again";
|
||||
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
@@ -2968,8 +2968,8 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 4. Daemon output snippet (last few lines, if embedded)
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_) {
|
||||
auto lines = embedded_daemon_->getRecentLines(8);
|
||||
if (daemon_controller_) {
|
||||
auto lines = daemon_controller_->recentLines(8);
|
||||
if (!lines.empty()) {
|
||||
curY += gap;
|
||||
|
||||
@@ -3026,6 +3026,8 @@ void App::shutdown()
|
||||
// do synchronous shutdown as fallback.
|
||||
if (!shutting_down_) {
|
||||
DEBUG_LOGF("Synchronous shutdown fallback...\n");
|
||||
async_tasks_.cancelAll();
|
||||
async_tasks_.joinAll();
|
||||
if (worker_) {
|
||||
worker_->stop();
|
||||
}
|
||||
@@ -3035,7 +3037,7 @@ void App::shutdown()
|
||||
if (settings_) {
|
||||
settings_->save();
|
||||
}
|
||||
if (embedded_daemon_) {
|
||||
if (daemon_controller_) {
|
||||
stopEmbeddedDaemon();
|
||||
}
|
||||
if (rpc_) {
|
||||
@@ -3051,14 +3053,7 @@ void App::shutdown()
|
||||
if (shutdown_thread_.joinable()) {
|
||||
shutdown_thread_.join();
|
||||
}
|
||||
// Wait for wizard's external daemon stop thread
|
||||
if (wizard_stop_thread_.joinable()) {
|
||||
wizard_stop_thread_.join();
|
||||
}
|
||||
// Wait for daemon restart thread
|
||||
if (daemon_restart_thread_.joinable()) {
|
||||
daemon_restart_thread_.join();
|
||||
}
|
||||
async_tasks_.joinAll();
|
||||
// Join the RPC worker thread (was signaled in beginShutdown via requestStop)
|
||||
if (worker_) {
|
||||
worker_->stop();
|
||||
@@ -3094,45 +3089,93 @@ bool App::hasPendingRPCResults() const {
|
||||
return (worker_ && worker_->hasPendingResults())
|
||||
|| (fast_worker_ && fast_worker_->hasPendingResults());
|
||||
}
|
||||
|
||||
std::string App::transactionSendProgressText() const
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (send_submissions_in_flight_ > 0) return TR("tx_progress_submitting");
|
||||
|
||||
if (!pending_opids_.empty()) {
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), TR("tx_progress_waiting_ops"), (int)pending_opids_.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) {
|
||||
return TR("tx_progress_balances");
|
||||
}
|
||||
|
||||
if (transactions_dirty_ || network_refresh_.jobInProgress(Job::Transactions)) {
|
||||
return TR("tx_progress_history");
|
||||
}
|
||||
|
||||
return TR("tx_progress_finalizing");
|
||||
}
|
||||
|
||||
std::string App::transactionRefreshProgressText() const
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
bool running = network_refresh_.jobInProgress(Job::Transactions);
|
||||
bool canRefresh = state_.connected && !state_.warming_up && !state_.isLocked();
|
||||
if (!running && !(canRefresh && transactions_dirty_)) return {};
|
||||
|
||||
if (!running && transactions_dirty_) return TR("tx_loading_queued");
|
||||
|
||||
char buf[128];
|
||||
if (!send_txids_.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_enriching_sends"), (int)send_txids_.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (!state_.transactions.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_refreshing_cached"), (int)state_.transactions.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
if (!state_.z_addresses.empty()) {
|
||||
snprintf(buf, sizeof(buf), TR("tx_loading_scanning_shielded"), (int)state_.z_addresses.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
return TR("tx_loading_fetching_transparent");
|
||||
}
|
||||
|
||||
void App::maybeFinishTransactionSendProgress()
|
||||
{
|
||||
using Job = services::NetworkRefreshService::Job;
|
||||
if (!send_progress_active_) return;
|
||||
if (send_submissions_in_flight_ > 0 || !pending_opids_.empty()) return;
|
||||
if (addresses_dirty_ || transactions_dirty_) return;
|
||||
if (network_refresh_.jobInProgress(Job::Addresses) ||
|
||||
network_refresh_.jobInProgress(Job::Transactions)) return;
|
||||
send_progress_active_ = false;
|
||||
}
|
||||
void App::restartDaemon()
|
||||
{
|
||||
if (!use_embedded_daemon_ || daemon_restarting_.load()) return;
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::ManualRestart,
|
||||
use_embedded_daemon_, daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
||||
if (!decision.allowed) return;
|
||||
daemon_restarting_ = true;
|
||||
|
||||
// Reset crash counter on manual restart
|
||||
if (embedded_daemon_) {
|
||||
embedded_daemon_->resetCrashCount();
|
||||
}
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
|
||||
DEBUG_LOGF("[App] Restarting embedded daemon...\n");
|
||||
connection_status_ = TR("sb_restarting_daemon");
|
||||
|
||||
// Disconnect RPC so the loading overlay appears
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
if (decision.disconnectRpc && rpc_ && rpc_->isConnected()) {
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Daemon restart");
|
||||
|
||||
// Sync debug categories from settings to daemon
|
||||
if (embedded_daemon_ && settings_) {
|
||||
embedded_daemon_->setDebugCategories(settings_->getDebugCategories());
|
||||
}
|
||||
|
||||
// Run stop + start on a background thread to avoid blocking the UI.
|
||||
// The 5-second auto-retry in render() will reconnect once the daemon
|
||||
// is back up.
|
||||
if (daemon_restart_thread_.joinable()) {
|
||||
daemon_restart_thread_.join();
|
||||
}
|
||||
daemon_restart_thread_ = std::thread([this]() {
|
||||
if (embedded_daemon_ && isEmbeddedDaemonRunning()) {
|
||||
stopEmbeddedDaemon();
|
||||
}
|
||||
if (shutting_down_) { daemon_restarting_ = false; return; }
|
||||
// Brief pause to let the port free up
|
||||
for (int i = 0; i < 5 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
startEmbeddedDaemon();
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
daemon_restarting_ = false;
|
||||
DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n");
|
||||
});
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.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 "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -25,7 +30,7 @@ namespace dragonx {
|
||||
class RPCWorker;
|
||||
}
|
||||
namespace config { class Settings; }
|
||||
namespace daemon { class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace daemon { class DaemonController; class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace util { class Bootstrap; class SecureVault; }
|
||||
}
|
||||
|
||||
@@ -183,7 +188,9 @@ public:
|
||||
// Peers
|
||||
const std::vector<PeerInfo>& getPeers() const { return state_.peers; }
|
||||
const std::vector<BannedPeer>& getBannedPeers() const { return state_.bannedPeers; }
|
||||
bool isPeerRefreshInProgress() const { return peer_refresh_in_progress_.load(std::memory_order_relaxed); }
|
||||
bool isPeerRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Peers);
|
||||
}
|
||||
void banPeer(const std::string& ip, int duration_seconds = 86400);
|
||||
void unbanPeer(const std::string& ip);
|
||||
void clearBans();
|
||||
@@ -211,6 +218,9 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order);
|
||||
int getNextSortOrder() const;
|
||||
void swapAddressOrder(const std::string& a, const std::string& b);
|
||||
bool isMiningAddress(const std::string& addr) const;
|
||||
void setMiningAddress(const std::string& addr, bool mining);
|
||||
void invalidateAddressValidationCache();
|
||||
|
||||
// Key export/import
|
||||
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
|
||||
@@ -232,12 +242,7 @@ public:
|
||||
void refreshMarketData();
|
||||
|
||||
/// @brief Per-category refresh intervals, adjusted by active tab
|
||||
struct RefreshIntervals {
|
||||
float core; // balance + sync status
|
||||
float transactions; // tx list + enrichment
|
||||
float addresses; // address lists + balances
|
||||
float peers; // peer info (0 = disabled)
|
||||
};
|
||||
using RefreshIntervals = services::NetworkRefreshService::Intervals;
|
||||
|
||||
/// @brief Get recommended refresh intervals for a given page
|
||||
static RefreshIntervals getIntervalsForPage(ui::NavPage page);
|
||||
@@ -340,10 +345,7 @@ public:
|
||||
void showChangePassphraseDialog() { show_change_passphrase_ = true; }
|
||||
void showDecryptDialog() {
|
||||
show_decrypt_dialog_ = true;
|
||||
decrypt_phase_ = 0; // passphrase entry
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.reset();
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
}
|
||||
|
||||
@@ -355,8 +357,39 @@ public:
|
||||
|
||||
/// @brief Check if RPC worker has queued results waiting to be processed
|
||||
bool hasPendingRPCResults() const;
|
||||
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
||||
std::string transactionSendProgressText() const;
|
||||
std::string transactionRefreshProgressText() const;
|
||||
bool isTransactionRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
||||
}
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
|
||||
void maybeFinishTransactionSendProgress();
|
||||
void upsertPendingSendTransaction(const std::string& opid,
|
||||
const std::string& from,
|
||||
const std::string& to,
|
||||
double amount,
|
||||
const std::string& memo);
|
||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||
const std::string& txid);
|
||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||
bool restoreBalances);
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
|
||||
std::string transactionHistoryCacheWalletIdentity() const;
|
||||
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
|
||||
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
|
||||
void loadTransactionHistoryCacheIfAvailable();
|
||||
void storeTransactionHistoryCacheIfAvailable();
|
||||
void wipePendingTransactionHistoryCachePassphrase();
|
||||
void resetTransactionHistoryCacheSession();
|
||||
void pruneShieldedHistoryScanProgress();
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache);
|
||||
|
||||
// Subsystems
|
||||
std::unique_ptr<rpc::RPCClient> rpc_;
|
||||
std::unique_ptr<rpc::RPCWorker> worker_;
|
||||
@@ -371,8 +404,9 @@ private:
|
||||
rpc::ConnectionConfig saved_config_;
|
||||
|
||||
std::unique_ptr<config::Settings> settings_;
|
||||
std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
// Wallet state
|
||||
@@ -390,7 +424,6 @@ private:
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
std::thread daemon_restart_thread_;
|
||||
|
||||
// Encryption state check timeout
|
||||
float encryption_check_timer_ = 0.0f;
|
||||
@@ -423,6 +456,7 @@ private:
|
||||
// Connection
|
||||
std::string connection_status_ = "Disconnected";
|
||||
bool connection_in_progress_ = false;
|
||||
bool remote_rpc_plaintext_warning_shown_ = false;
|
||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||
|
||||
// Current page (sidebar navigation)
|
||||
@@ -460,64 +494,27 @@ private:
|
||||
std::string pending_memo_;
|
||||
std::string pending_label_;
|
||||
|
||||
// Per-category timers (in seconds since last refresh)
|
||||
float core_timer_ = 0.0f; // balance + sync status
|
||||
float address_timer_ = 0.0f; // address lists
|
||||
float transaction_timer_ = 0.0f; // transaction list
|
||||
float peer_timer_ = 0.0f; // peer info
|
||||
float price_timer_ = 0.0f;
|
||||
float fast_refresh_timer_ = 0.0f; // For mining stats
|
||||
|
||||
// Default refresh intervals (seconds)
|
||||
static constexpr float CORE_INTERVAL_DEFAULT = 5.0f;
|
||||
static constexpr float ADDRESS_INTERVAL_DEFAULT = 15.0f;
|
||||
static constexpr float TX_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PEER_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PRICE_INTERVAL = 60.0f;
|
||||
static constexpr float FAST_REFRESH_INTERVAL = 1.0f;
|
||||
|
||||
// Active intervals — adjusted by tab priority via applyRefreshPolicy()
|
||||
float active_core_interval_ = CORE_INTERVAL_DEFAULT;
|
||||
float active_tx_interval_ = TX_INTERVAL_DEFAULT;
|
||||
float active_addr_interval_ = ADDRESS_INTERVAL_DEFAULT;
|
||||
float active_peer_interval_ = PEER_INTERVAL_DEFAULT;
|
||||
|
||||
// Per-category refresh guards (prevent worker queue pileup)
|
||||
std::atomic<bool> core_refresh_in_progress_{false};
|
||||
std::atomic<bool> address_refresh_in_progress_{false};
|
||||
std::atomic<bool> tx_refresh_in_progress_{false};
|
||||
|
||||
// Mining refresh guard (prevents worker queue pileup)
|
||||
std::atomic<bool> mining_refresh_in_progress_{false};
|
||||
// Per-category refresh timers, policy, and worker queue guards.
|
||||
services::NetworkRefreshService network_refresh_;
|
||||
int mining_slow_counter_ = 0; // counts fast ticks; fires slow refresh every N
|
||||
|
||||
// Mining toggle guard (prevents concurrent setgenerate calls)
|
||||
std::atomic<bool> mining_toggle_in_progress_{false};
|
||||
|
||||
// Peer refresh guard (visual feedback for refresh button)
|
||||
std::atomic<bool> peer_refresh_in_progress_{false};
|
||||
|
||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||
std::atomic<bool> auto_shield_pending_{false};
|
||||
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
float tx_age_timer_ = 0.0f; // seconds since last tx fetch
|
||||
static constexpr float TX_MAX_AGE = 15.0f; // force tx refresh every N seconds even without new blocks
|
||||
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;
|
||||
std::unordered_map<std::string, int> shielded_history_scan_heights_;
|
||||
|
||||
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
|
||||
// txids we've already enriched. Keyed by txid.
|
||||
struct ViewTxCacheEntry {
|
||||
std::string from_address; // first spend address
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
std::unordered_map<std::string, ViewTxCacheEntry> viewtx_cache_;
|
||||
using ViewTxCacheEntry = services::NetworkRefreshService::TransactionViewCacheEntry;
|
||||
services::NetworkRefreshService::TransactionViewCache viewtx_cache_;
|
||||
|
||||
// P4c: Confirmed transaction cache — deeply-confirmed txns (>= 10 confs)
|
||||
// are accumulated here and reused across refresh cycles. Only
|
||||
@@ -528,14 +525,24 @@ private:
|
||||
|
||||
// Dirty flags for demand-driven refresh
|
||||
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
|
||||
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
|
||||
bool rescan_status_poll_in_progress_ = false;
|
||||
bool opid_poll_in_progress_ = false;
|
||||
|
||||
// Pending z_sendmany operation tracking
|
||||
bool send_progress_active_ = false;
|
||||
int send_submissions_in_flight_ = 0;
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
float opid_poll_timer_ = 0.0f;
|
||||
static constexpr float OPID_POLL_INTERVAL = 2.0f;
|
||||
|
||||
struct PendingSendInfo {
|
||||
std::string from;
|
||||
std::string to;
|
||||
std::string memo;
|
||||
double amount = 0.0;
|
||||
std::int64_t timestamp = 0;
|
||||
};
|
||||
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
|
||||
// Txids from completed z_sendmany operations.
|
||||
// Ensures shielded sends are discoverable by z_viewtransaction
|
||||
// even when they don't appear in listtransactions or
|
||||
@@ -549,18 +556,19 @@ private:
|
||||
std::string wizard_pending_passphrase_; // held until daemon connects
|
||||
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
||||
|
||||
// Deferred encryption (wizard background task)
|
||||
std::string deferred_encrypt_passphrase_;
|
||||
std::string deferred_encrypt_pin_;
|
||||
bool deferred_encrypt_pending_ = false;
|
||||
// Wallet security flow state shared by wizard/settings encryption paths.
|
||||
services::WalletSecurityController wallet_security_;
|
||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||
|
||||
// Wizard: stopping an external daemon before bootstrap
|
||||
bool wizard_stopping_external_ = false;
|
||||
std::string wizard_stop_status_;
|
||||
std::thread wizard_stop_thread_;
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
data::TransactionHistoryCache transaction_history_cache_;
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
|
||||
// Lock screen state
|
||||
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
|
||||
@@ -602,14 +610,7 @@ private:
|
||||
|
||||
// Decrypt wallet dialog state
|
||||
bool show_decrypt_dialog_ = false;
|
||||
int decrypt_phase_ = 0; // 0=passphrase, 1=working, 2=done, 3=error
|
||||
int decrypt_step_ = 0; // 0=unlock, 1=export, 2=stop, 3=rename, 4=restart, 5=import
|
||||
char decrypt_pass_buf_[256] = {};
|
||||
std::string decrypt_status_;
|
||||
bool decrypt_in_progress_ = false;
|
||||
std::chrono::steady_clock::time_point decrypt_step_start_time_{};
|
||||
std::chrono::steady_clock::time_point decrypt_overall_start_time_{};
|
||||
std::atomic<bool> decrypt_import_active_{false}; // background z_importwallet running
|
||||
|
||||
// Wizard PIN setup state
|
||||
char wizard_pin_buf_[16] = {};
|
||||
@@ -648,13 +649,17 @@ private:
|
||||
void refreshCoreData(); // Balance + blockchain info (can use fast_worker_)
|
||||
void refreshAddressData(); // Address lists + balances
|
||||
void refreshTransactionData(); // Transaction list + z_viewtransaction enrichment
|
||||
void refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshRecentTransactionData(); // Lightweight recent/unconfirmed tx poll
|
||||
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();
|
||||
void applyRefreshPolicy(ui::NavPage page);
|
||||
bool currentPageNeedsWalletDataRefresh() const;
|
||||
bool shouldRunWalletTransactionRefresh() const;
|
||||
bool shouldRefreshTransactions() const;
|
||||
bool shouldRefreshRecentTransactions() const;
|
||||
void checkAutoLock();
|
||||
void checkIdleMining();
|
||||
};
|
||||
|
||||
+814
-710
File diff suppressed because it is too large
Load Diff
+377
-237
@@ -9,6 +9,7 @@
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/wallet_security_workflow_executor.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
@@ -27,13 +28,203 @@
|
||||
|
||||
#include "imgui.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
|
||||
public:
|
||||
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc, std::string source = "Security settings")
|
||||
: rpc_(rpc), source_(std::move(source)) {}
|
||||
|
||||
bool encryptWallet(const std::string& passphrase, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
|
||||
}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_importwallet", {filePath}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
|
||||
public:
|
||||
explicit WalletSecurityVaultAdapter(util::SecureVault* vault) : vault_(vault) {}
|
||||
|
||||
bool storePin(const std::string& pin, const std::string& passphrase) override {
|
||||
return vault_ && vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
private:
|
||||
util::SecureVault* vault_ = nullptr;
|
||||
};
|
||||
|
||||
class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowExecutor::RpcGateway {
|
||||
public:
|
||||
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
|
||||
|
||||
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn,
|
||||
std::string source = "Security / Decrypt wallet workflow")
|
||||
: rpc_(rpc), stopFn_(std::move(stopFn)), source_(std::move(source)) {}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool requestDaemonStop(std::string& error) override {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
bool ok = stopFn_ ? stopFn_(*rpc_, "Decrypt export daemon stop") : false;
|
||||
if (!ok) error = "Stop RPC failed";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool probeDaemon(std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("getinfo"); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
StopFn stopFn_;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
|
||||
public:
|
||||
WalletSecurityImportRpcAdapter(rpc::RPCClient* fallbackRpc, rpc::ConnectionConfig config)
|
||||
: fallbackRpc_(fallbackRpc), config_(std::move(config)) {}
|
||||
|
||||
bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) override {
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(config_.host, config_.port,
|
||||
config_.rpcuser, config_.rpcpassword,
|
||||
config_.use_tls);
|
||||
if (!importRpcOk) importRpc.reset();
|
||||
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : fallbackRpc_;
|
||||
if (!rpcForImport) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Import wallet workflow");
|
||||
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
|
||||
if (importRpc) importRpc->disconnect();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
if (importRpc) importRpc->disconnect();
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
rpc::RPCClient* fallbackRpc_ = nullptr;
|
||||
rpc::ConnectionConfig config_;
|
||||
};
|
||||
|
||||
class WalletSecurityFileAdapter : public services::WalletSecurityWorkflowExecutor::FileGateway {
|
||||
public:
|
||||
std::string dataDir() override { return util::Platform::getDragonXDataDir(); }
|
||||
|
||||
bool backupEncryptedWallet(const services::WalletSecurityWorkflowExecutor::WalletFilePlan& filePlan,
|
||||
std::string& error) override {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(filePlan.walletPath, ec)) return true;
|
||||
|
||||
std::filesystem::remove(filePlan.backupPath, ec);
|
||||
ec.clear();
|
||||
std::filesystem::rename(filePlan.walletPath, filePlan.backupPath, ec);
|
||||
if (ec) {
|
||||
error = ec.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
class WalletSecurityDaemonAdapter : public services::WalletSecurityWorkflowExecutor::DaemonGateway {
|
||||
public:
|
||||
WalletSecurityDaemonAdapter(App& app, const util::AsyncTaskManager::Token& token)
|
||||
: app_(app), token_(token) {}
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
|
||||
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
|
||||
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
bool cancelled() const override { return token_.cancelled(); }
|
||||
bool shuttingDown() const override { return app_.isShuttingDown(); }
|
||||
void sleepForMs(int milliseconds) override {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
private:
|
||||
App& app_;
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// Wallet encryption helpers
|
||||
@@ -45,9 +236,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
auto result = rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
{std::move(passphrase), {}}, rpcAdapter, nullptr);
|
||||
if (result.encrypted) {
|
||||
return [this]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
|
||||
@@ -78,22 +271,22 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
connection_status_ = TR("restarting_after_encryption");
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
@@ -118,15 +311,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
// Called every frame from render() until the task completes.
|
||||
// ---------------------------------------------------------------------------
|
||||
void App::processDeferredEncryption() {
|
||||
if (!deferred_encrypt_pending_) return;
|
||||
if (!wallet_security_.hasDeferredEncryption()) return;
|
||||
|
||||
// Phase 1: wait for daemon connection
|
||||
if (!state_.connected || !rpc_ || !rpc_->isConnected()) {
|
||||
// Throttle connection attempts to every 3 seconds
|
||||
static double s_lastAttempt = -10.0;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_lastAttempt >= 3.0) {
|
||||
s_lastAttempt = now;
|
||||
if (wallet_security_.shouldAttemptDeferredConnect(ImGui::GetTime())) {
|
||||
if (!connection_in_progress_) {
|
||||
// Just try to connect — tryConnect is now async
|
||||
tryConnect();
|
||||
@@ -140,31 +329,29 @@ void App::processDeferredEncryption() {
|
||||
|
||||
// Phase 2: connected — launch encryption
|
||||
if (!encrypt_in_progress_) {
|
||||
std::string passphrase = deferred_encrypt_passphrase_;
|
||||
std::string pin = deferred_encrypt_pin_;
|
||||
auto deferredEncryption = wallet_security_.deferredEncryption();
|
||||
std::string passphrase = std::move(deferredEncryption.passphrase);
|
||||
std::string pin = std::move(deferredEncryption.pin);
|
||||
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
WalletSecurityVaultAdapter vaultAdapter(vault_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
std::move(request), rpcAdapter, vault_ ? &vaultAdapter : nullptr);
|
||||
|
||||
// Store PIN vault on the worker thread (Argon2id is expensive)
|
||||
bool pinStored = false;
|
||||
if (!pin.empty() && vault_) {
|
||||
pinStored = vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
return [this, pinStored, pin]() {
|
||||
if (result.encrypted) {
|
||||
return [this, result]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_.clear();
|
||||
DEBUG_LOGF("[App] Wallet encrypted (deferred)\n");
|
||||
|
||||
// Finalize PIN settings on main thread
|
||||
if (!pin.empty()) {
|
||||
if (pinStored) {
|
||||
if (result.pinProvided) {
|
||||
if (result.pinStored) {
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
|
||||
@@ -176,59 +363,32 @@ void App::processDeferredEncryption() {
|
||||
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
|
||||
}
|
||||
|
||||
// Securely clear deferred state
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
deferred_encrypt_pending_ = false;
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
|
||||
// Restart daemon (it shuts itself down after encryptwallet)
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("deferred-encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
deferred_encrypt_pending_ = false;
|
||||
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
||||
ui::Notifications::instance().error("Encryption failed: " + err);
|
||||
|
||||
// Clean up sensitive data on failure
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -243,17 +403,14 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
|
||||
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, passphrase, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
try {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout]() {
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
if (ok) {
|
||||
lock_error_msg_.clear();
|
||||
@@ -265,6 +422,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
} else {
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = TR("incorrect_passphrase");
|
||||
@@ -278,6 +436,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
}
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d): %s\n", lock_attempts_, err_msg.c_str());
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -302,6 +461,7 @@ void App::lockWallet() {
|
||||
if (ok) {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
}
|
||||
};
|
||||
@@ -315,7 +475,10 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
|
||||
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, newPass]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this,
|
||||
r,
|
||||
oldPass = std::string(oldPass),
|
||||
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
@@ -324,8 +487,14 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
std::string cacheNewPass = newPass;
|
||||
util::SecureVault::secureZero(oldPass.data(), oldPass.size());
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
|
||||
return [this, ok, err_msg]() {
|
||||
return [this,
|
||||
ok,
|
||||
err_msg,
|
||||
newPass = std::move(cacheNewPass)]() mutable {
|
||||
encrypt_in_progress_ = false;
|
||||
if (ok) {
|
||||
encrypt_status_.clear();
|
||||
@@ -333,10 +502,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
|
||||
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("Passphrase changed successfully");
|
||||
} else {
|
||||
encrypt_status_ = "Failed: " + err_msg;
|
||||
}
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -352,6 +524,7 @@ void App::refreshWalletEncryptionState() {
|
||||
json result;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Wallet encryption state");
|
||||
result = rpc_->call("getwalletinfo");
|
||||
ok = true;
|
||||
} catch (...) {}
|
||||
@@ -365,10 +538,24 @@ void App::refreshWalletEncryptionState() {
|
||||
int64_t until = result["unlocked_until"].get<int64_t>();
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
} else {
|
||||
state_.encrypted = false;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
|
||||
// Wallet is no longer encrypted — if a PIN vault exists,
|
||||
// it's stale (passphrase it protects is gone). Reset PIN
|
||||
@@ -382,7 +569,6 @@ void App::refreshWalletEncryptionState() {
|
||||
settings_->save();
|
||||
}
|
||||
}
|
||||
state_.encryption_state_known = true;
|
||||
} catch (...) {}
|
||||
};
|
||||
});
|
||||
@@ -826,11 +1012,8 @@ void App::renderLockScreen() {
|
||||
rpcErr = e.what();
|
||||
}
|
||||
|
||||
// Securely wipe passphrase
|
||||
util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
|
||||
if (rpcOk) {
|
||||
return [this, timeout]() {
|
||||
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_.clear();
|
||||
lock_attempts_ = 0;
|
||||
@@ -841,13 +1024,16 @@ void App::renderLockScreen() {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
} else {
|
||||
return [this, rpcErr]() {
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = "Unlock failed: " + rpcErr;
|
||||
lock_error_timer_ = 3.0f;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1146,14 +1332,18 @@ void App::renderEncryptWalletDialog() {
|
||||
void App::renderDecryptWalletDialog() {
|
||||
if (!show_decrypt_dialog_) return;
|
||||
using namespace ui::material;
|
||||
using DecryptPhase = services::WalletSecurityWorkflow::DecryptPhase;
|
||||
using DecryptStep = services::WalletSecurityWorkflow::DecryptStep;
|
||||
|
||||
bool canClose = (decrypt_phase_ != 1); // don't close while working
|
||||
auto decryptState = wallet_security_workflow_.snapshot();
|
||||
|
||||
bool canClose = wallet_security_workflow_.canClose();
|
||||
bool* pOpen = canClose ? &show_decrypt_dialog_ : nullptr;
|
||||
|
||||
if (BeginOverlayDialog("Remove Wallet Encryption", pOpen, 480.0f, 0.94f)) {
|
||||
|
||||
// ---- Phase 0: Passphrase entry ----
|
||||
if (decrypt_phase_ == 0) {
|
||||
if (decryptState.phase == DecryptPhase::PassphraseEntry) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.7f, 0.3f, 1));
|
||||
ImGui::TextWrapped(ICON_MD_WARNING
|
||||
" This will remove encryption from your wallet. "
|
||||
@@ -1176,143 +1366,110 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
if (!decrypt_status_.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decrypt_status_.c_str());
|
||||
if (!decryptState.status.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
bool valid = strlen(decrypt_pass_buf_) >= 1;
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || decrypt_in_progress_);
|
||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||
if (ImGui::Button("Remove Encryption", ImVec2(btnW, 40)) || (enterPressed && valid)) {
|
||||
std::string passphrase(decrypt_pass_buf_);
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
decrypt_phase_ = 1;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_in_progress_ = true;
|
||||
decrypt_status_ = "Unlocking wallet...";
|
||||
decrypt_overall_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_step_start_time_ = decrypt_overall_start_time_;
|
||||
wallet_security_workflow_.start(std::chrono::steady_clock::now());
|
||||
|
||||
// Run entire decrypt flow on worker thread
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
// Step 1: Unlock wallet
|
||||
try {
|
||||
rpc_->call("walletpassphrase", {passphrase, 600});
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Incorrect passphrase";
|
||||
decrypt_phase_ = 0; // back to entry
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||
if (!unlock.ok) {
|
||||
return [this]() {
|
||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||
};
|
||||
}
|
||||
|
||||
// Update step on main thread
|
||||
return [this]() {
|
||||
decrypt_step_ = 1;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Exporting wallet keys...";
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::ExportKeys,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::ExportKeys),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 2
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string exportFile = "obsidiandecryptexport" +
|
||||
std::to_string(std::time(nullptr));
|
||||
std::string exportPath = dataDir + exportFile;
|
||||
|
||||
try {
|
||||
rpc_->call("z_exportwallet", {exportFile}, 300L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
WalletSecurityFileAdapter files;
|
||||
auto exportOutcome = services::WalletSecurityWorkflowExecutor::exportWallet(
|
||||
decryptRpc, files, static_cast<std::uint64_t>(std::time(nullptr)));
|
||||
if (!exportOutcome.ok) {
|
||||
std::string err = exportOutcome.error;
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Export failed: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 2;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Stopping daemon...";
|
||||
auto filePlan = exportOutcome.filePlan;
|
||||
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::StopDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::StopDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 3
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("stop");
|
||||
} catch (...) {
|
||||
// stop often throws because connection drops
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 3;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Backing up encrypted wallet...";
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 4 (rename)
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string walletPath = dataDir + "wallet.dat";
|
||||
std::string backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(walletPath, ec)) {
|
||||
std::filesystem::remove(backupPath, ec);
|
||||
std::filesystem::rename(walletPath, backupPath, ec);
|
||||
if (ec) {
|
||||
std::string err = ec.message();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Failed to rename wallet.dat: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
};
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityFileAdapter files;
|
||||
auto backup = services::WalletSecurityWorkflowExecutor::backupEncryptedWallet(files, filePlan);
|
||||
if (!backup.ok) {
|
||||
std::string err = backup.error;
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 4;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Restarting daemon...";
|
||||
return [this, exportPath = filePlan.exportPath]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::RestartDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::RestartDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
auto restartAndImport = [this, exportPath]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
for (int i = 0; i < 10 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
// Wait for daemon to become available
|
||||
int maxWait = 60;
|
||||
bool daemonUp = false;
|
||||
for (int i = 0; i < maxWait && !shutting_down_; i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
try {
|
||||
rpc_->call("getinfo");
|
||||
daemonUp = true;
|
||||
break;
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
if (!daemonUp) {
|
||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto restart = services::WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
daemonAdapter, decryptRpc);
|
||||
if (!restart.ok) {
|
||||
if (restart.error.empty()) return;
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Daemon failed to restart";
|
||||
decrypt_phase_ = 3;
|
||||
worker_->post([this, err = restart.error]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1324,9 +1481,8 @@ void App::renderDecryptWalletDialog() {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
// Close the decrypt dialog — user can use the wallet now
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.closeDialogForImport();
|
||||
show_decrypt_dialog_ = false;
|
||||
decrypt_import_active_ = true;
|
||||
|
||||
// Mark rescanning so status bar picks it up immediately
|
||||
state_.sync.rescanning = true;
|
||||
@@ -1334,13 +1490,15 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Clear encryption state early — vault/PIN removed now,
|
||||
// wallet file is already unencrypted
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
});
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
"Importing keys & rescanning blockchain — wallet is usable while this runs",
|
||||
@@ -1349,32 +1507,17 @@ void App::renderDecryptWalletDialog() {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Import wallet in background (use full path)
|
||||
// Use a SEPARATE RPC client so the main rpc_'s
|
||||
// curl_mutex isn't held for the entire import duration.
|
||||
// Blocking rpc_ prevents refreshData/refreshPeerInfo
|
||||
// from running, which leaves the UI with no peers.
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(
|
||||
saved_config_.host, saved_config_.port,
|
||||
saved_config_.rpcuser, saved_config_.rpcpassword);
|
||||
if (!importRpcOk) {
|
||||
// Fall back to main client if temp connect fails
|
||||
importRpc.reset();
|
||||
}
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : rpc_.get();
|
||||
|
||||
// Use 20-minute timeout — import + rescan can be very slow
|
||||
try {
|
||||
rpcForImport->call("z_importwallet", {exportPath}, 1200L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||
importAdapter, exportPath);
|
||||
if (!importResult.ok) {
|
||||
std::string err = importResult.error;
|
||||
if (worker_) {
|
||||
worker_->post([this, err]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
ui::Notifications::instance().error(
|
||||
"Key import failed: " + err +
|
||||
err +
|
||||
"\nEncrypted backup: wallet.dat.encrypted.bak",
|
||||
12.0f);
|
||||
};
|
||||
@@ -1383,20 +1526,15 @@ void App::renderDecryptWalletDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect the temporary RPC client
|
||||
if (importRpc) {
|
||||
importRpc->disconnect();
|
||||
importRpc.reset();
|
||||
}
|
||||
|
||||
// Success — force full state refresh so peers,
|
||||
// balances, and addresses are fetched immediately.
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
@@ -1414,7 +1552,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
std::thread(restartAndImport).detach();
|
||||
async_tasks_.submit("decrypt-restart-import", restartAndImport);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1434,7 +1572,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 1: Working ----
|
||||
} else if (decrypt_phase_ == 1) {
|
||||
} else if (decryptState.phase == DecryptPhase::Working) {
|
||||
// Step checklist
|
||||
const char* stepLabels[] = {
|
||||
"Unlocking wallet",
|
||||
@@ -1448,17 +1586,18 @@ void App::renderDecryptWalletDialog() {
|
||||
// Compute elapsed times
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto stepElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_step_start_time_).count();
|
||||
now - decryptState.stepStarted).count();
|
||||
auto totalElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_overall_start_time_).count();
|
||||
now - decryptState.overallStarted).count();
|
||||
|
||||
ImGui::Spacing();
|
||||
for (int i = 0; i < numSteps; i++) {
|
||||
ImGui::PushFont(Type().iconMed());
|
||||
if (i < decrypt_step_) {
|
||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
// Completed
|
||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
} else if (i == decrypt_step_) {
|
||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// In progress - animate
|
||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, alpha), ICON_MD_PENDING);
|
||||
@@ -1469,7 +1608,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine();
|
||||
|
||||
if (i == decrypt_step_) {
|
||||
if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// Show step label with elapsed time
|
||||
int mins = (int)(stepElapsed / 60);
|
||||
int secs = (int)(stepElapsed % 60);
|
||||
@@ -1480,7 +1619,8 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f),
|
||||
"%s... (%ds)", stepLabels[i], secs);
|
||||
}
|
||||
} else if (i < decrypt_step_) {
|
||||
} else if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "%s", stepLabels[i]);
|
||||
} else {
|
||||
ImGui::TextDisabled("%s", stepLabels[i]);
|
||||
@@ -1515,7 +1655,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::Spacing();
|
||||
|
||||
// Step-specific hints
|
||||
if (decrypt_step_ == 4) {
|
||||
if (decryptState.step == DecryptStep::RestartDaemon) {
|
||||
ImGui::TextWrapped("Waiting for the daemon to finish starting up...");
|
||||
} else {
|
||||
ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, "
|
||||
@@ -1531,7 +1671,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 2: Success ----
|
||||
} else if (decrypt_phase_ == 2) {
|
||||
} else if (decryptState.phase == DecryptPhase::Success) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
ImGui::PopFont();
|
||||
@@ -1549,7 +1689,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 3: Error ----
|
||||
} else if (decrypt_phase_ == 3) {
|
||||
} else if (decryptState.phase == DecryptPhase::Error) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), ICON_MD_ERROR);
|
||||
ImGui::PopFont();
|
||||
@@ -1557,14 +1697,12 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Decryption failed");
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", decrypt_status_.c_str());
|
||||
ImGui::TextWrapped("%s", decryptState.status.c_str());
|
||||
|
||||
ImGui::Spacing();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ImGui::Button("Try Again", ImVec2(btnW, 40))) {
|
||||
decrypt_phase_ = 0;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
wallet_security_workflow_.reset();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close", ImVec2(btnW, 40))) {
|
||||
@@ -1639,6 +1777,7 @@ void App::renderPinDialogs() {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
} catch (const std::exception& e) {
|
||||
return [this]() {
|
||||
@@ -1652,6 +1791,7 @@ void App::renderPinDialogs() {
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
|
||||
|
||||
+72
-55
@@ -10,6 +10,7 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/material/color_theme.h"
|
||||
@@ -39,13 +40,43 @@ namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
struct WizardLowSpecSnapshot {
|
||||
bool valid = false;
|
||||
float blur = 0.0f;
|
||||
float uiOp = 0.0f;
|
||||
bool fx = false;
|
||||
bool scanline = false;
|
||||
};
|
||||
|
||||
struct WizardUiState {
|
||||
float blur_amount = 1.5f;
|
||||
bool theme_effects = true;
|
||||
float ui_opacity = 1.0f;
|
||||
bool low_spec = false;
|
||||
bool scanline = true;
|
||||
std::string balance_layout = "classic";
|
||||
int language_index = 0;
|
||||
bool appearance_init = false;
|
||||
WizardLowSpecSnapshot low_spec_snapshot;
|
||||
float card0_max_h = 0.0f;
|
||||
float card1_max_h = 0.0f;
|
||||
double external_last_check = -10.0;
|
||||
bool daemon_prestarted = false;
|
||||
};
|
||||
|
||||
WizardUiState s_wizardUi;
|
||||
|
||||
} // namespace
|
||||
|
||||
void App::restartWizard()
|
||||
{
|
||||
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
|
||||
|
||||
// Reset crash counter for fresh wizard attempt
|
||||
if (embedded_daemon_) {
|
||||
embedded_daemon_->resetCrashCount();
|
||||
if (daemon_controller_) {
|
||||
daemon_controller_->resetCrashCount();
|
||||
}
|
||||
|
||||
// Disconnect RPC
|
||||
@@ -56,10 +87,11 @@ void App::restartWizard()
|
||||
|
||||
// Stop the embedded daemon in a background thread to avoid
|
||||
// blocking the UI for up to 32 seconds (RPC stop + process wait).
|
||||
if (embedded_daemon_ && isEmbeddedDaemonRunning()) {
|
||||
std::thread([this]() {
|
||||
if (daemon_controller_ && isEmbeddedDaemonRunning()) {
|
||||
async_tasks_.submit("wizard-restart-stop-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) return;
|
||||
stopEmbeddedDaemon();
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
|
||||
// Enter wizard — the wizard completion handler already calls
|
||||
@@ -73,6 +105,7 @@ void App::restartWizard()
|
||||
// ===========================================================================
|
||||
|
||||
void App::renderFirstRunWizard() {
|
||||
auto& wizardUi = s_wizardUi;
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->WorkPos);
|
||||
ImGui::SetNextWindowSize(viewport->WorkSize);
|
||||
@@ -243,15 +276,14 @@ void App::renderFirstRunWizard() {
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
|
||||
// Statics for appearance settings
|
||||
static float wiz_blur_amount = 1.5f;
|
||||
static bool wiz_theme_effects = true;
|
||||
static float wiz_ui_opacity = 1.0f;
|
||||
static bool wiz_low_spec = false;
|
||||
static bool wiz_scanline = true;
|
||||
static std::string wiz_balance_layout = "classic";
|
||||
static int wiz_language_index = 0;
|
||||
static bool wiz_appearance_init = false;
|
||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||
float& wiz_ui_opacity = wizardUi.ui_opacity;
|
||||
bool& wiz_low_spec = wizardUi.low_spec;
|
||||
bool& wiz_scanline = wizardUi.scanline;
|
||||
std::string& wiz_balance_layout = wizardUi.balance_layout;
|
||||
int& wiz_language_index = wizardUi.language_index;
|
||||
bool& wiz_appearance_init = wizardUi.appearance_init;
|
||||
if (!wiz_appearance_init) {
|
||||
wiz_blur_amount = settings_->getBlurMultiplier();
|
||||
wiz_theme_effects = settings_->getThemeEffectsEnabled();
|
||||
@@ -398,7 +430,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
// --- Low-spec mode checkbox ---
|
||||
// Snapshot for restoring settings when low-spec is turned off
|
||||
static struct { bool valid; float blur; float uiOp; bool fx; bool scanline; } wiz_lsSnap = {};
|
||||
WizardLowSpecSnapshot& wiz_lsSnap = wizardUi.low_spec_snapshot;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
|
||||
@@ -596,7 +628,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen
|
||||
static float card0MaxH = 0.0f;
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
|
||||
@@ -774,14 +806,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Retry##bs", ImVec2(btnW2, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap retry...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap retry");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
@@ -808,17 +833,23 @@ void App::renderFirstRunWizard() {
|
||||
if (isFocused) {
|
||||
static std::atomic<bool> s_extCached{false};
|
||||
static std::atomic<bool> s_checkInFlight{false};
|
||||
static double s_extLastCheck = -10.0;
|
||||
double& s_extLastCheck = wizardUi.external_last_check;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_extLastCheck >= 2.0 && !s_checkInFlight.load()) {
|
||||
s_extLastCheck = now;
|
||||
bool embeddedRunning = isEmbeddedDaemonRunning();
|
||||
s_checkInFlight.store(true);
|
||||
std::thread([embeddedRunning]() {
|
||||
async_tasks_.submit("wizard-external-daemon-check", [embeddedRunning](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) {
|
||||
s_checkInFlight.store(false);
|
||||
return;
|
||||
}
|
||||
bool inUse = daemon::EmbeddedDaemon::isRpcPortInUse();
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
if (!token.cancelled()) {
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
}
|
||||
s_checkInFlight.store(false);
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
externalRunning = s_extCached.load();
|
||||
}
|
||||
@@ -859,19 +890,19 @@ void App::renderFirstRunWizard() {
|
||||
if (ImGui::Button("Stop Daemon##wiz", ImVec2(stopW, btnH2))) {
|
||||
wizard_stopping_external_ = true;
|
||||
wizard_stop_status_ = "Sending stop command...";
|
||||
if (wizard_stop_thread_.joinable()) wizard_stop_thread_.join();
|
||||
wizard_stop_thread_ = std::thread([this]() {
|
||||
async_tasks_.submit("wizard-stop-external-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
auto config = rpc::Connection::autoDetectConfig();
|
||||
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
||||
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword)) {
|
||||
try { tmp_rpc->call("stop"); } catch (...) {}
|
||||
config.rpcuser, config.rpcpassword,
|
||||
config.use_tls)) {
|
||||
sendStopCommandSafely(*tmp_rpc, "Wizard external daemon stop");
|
||||
tmp_rpc->disconnect();
|
||||
}
|
||||
}
|
||||
wizard_stop_status_ = "Waiting for daemon to shut down...";
|
||||
for (int i = 0; i < 60; i++) {
|
||||
for (int i = 0; i < 60 && !token.cancelled(); i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
||||
wizard_stop_status_ = "Daemon stopped.";
|
||||
@@ -879,6 +910,7 @@ void App::renderFirstRunWizard() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (token.cancelled()) return;
|
||||
wizard_stop_status_ = "Daemon did not stop — try manually.";
|
||||
wizard_stopping_external_ = false;
|
||||
});
|
||||
@@ -955,14 +987,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Download##bs", ImVec2(dlBtnW, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
@@ -978,14 +1003,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Mirror##bs_mirror", ImVec2(mirrorW, btnH2))) {
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap (mirror)...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||
@@ -1012,7 +1030,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||
static float card1MaxH = 0.0f;
|
||||
float& card1MaxH = wizardUi.card1_max_h;
|
||||
if (isCollapsed) {
|
||||
card1Bot = card1Top + (cy - card1Top);
|
||||
} else {
|
||||
@@ -1037,7 +1055,7 @@ void App::renderFirstRunWizard() {
|
||||
// Pre-start daemon when encrypt card becomes focused so it's ready
|
||||
// by the time the user finishes typing their passphrase
|
||||
if (isFocused) {
|
||||
static bool wiz_daemon_prestarted = false;
|
||||
bool& wiz_daemon_prestarted = wizardUi.daemon_prestarted;
|
||||
if (!wiz_daemon_prestarted) {
|
||||
wiz_daemon_prestarted = true;
|
||||
if (!state_.connected && isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||
@@ -1281,10 +1299,9 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::BeginDisabled(!canEncrypt);
|
||||
if (ImGui::Button("Encrypt & Continue##wiz", ImVec2(encBtnW, btnH2))) {
|
||||
// Save passphrase + optional PIN for background processing
|
||||
deferred_encrypt_passphrase_ = std::string(encrypt_pass_buf_);
|
||||
if (pinEntered && pinOk)
|
||||
deferred_encrypt_pin_ = pinStr;
|
||||
deferred_encrypt_pending_ = true;
|
||||
wallet_security_.beginDeferredEncryption(
|
||||
std::string(encrypt_pass_buf_),
|
||||
(pinEntered && pinOk) ? pinStr : std::string());
|
||||
|
||||
// Clear sensitive buffers
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
|
||||
@@ -136,6 +136,8 @@ bool Settings::load(const std::string& path)
|
||||
m.icon = meta["icon"].get<std::string>();
|
||||
if (meta.contains("order") && meta["order"].is_number_integer())
|
||||
m.sortOrder = meta["order"].get<int>();
|
||||
if (meta.contains("mining") && meta["mining"].is_boolean())
|
||||
m.mining = meta["mining"].get<bool>();
|
||||
address_meta_[addr] = m;
|
||||
}
|
||||
}
|
||||
@@ -246,11 +248,12 @@ bool Settings::save(const std::string& path)
|
||||
{
|
||||
json meta_obj = json::object();
|
||||
for (const auto& [addr, m] : address_meta_) {
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0) continue;
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0 && !m.mining) continue;
|
||||
json entry = json::object();
|
||||
if (!m.label.empty()) entry["label"] = m.label;
|
||||
if (!m.icon.empty()) entry["icon"] = m.icon;
|
||||
if (m.sortOrder >= 0) entry["order"] = m.sortOrder;
|
||||
if (m.mining) entry["mining"] = true;
|
||||
meta_obj[addr] = entry;
|
||||
}
|
||||
j["address_meta"] = meta_obj;
|
||||
|
||||
@@ -147,6 +147,7 @@ public:
|
||||
std::string label;
|
||||
std::string icon; // material icon name, e.g. "savings"
|
||||
int sortOrder = -1; // -1 = auto (use default sort)
|
||||
bool mining = false;
|
||||
};
|
||||
const AddressMeta& getAddressMeta(const std::string& addr) const {
|
||||
static const AddressMeta empty{};
|
||||
@@ -162,6 +163,20 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order) {
|
||||
address_meta_[addr].sortOrder = order;
|
||||
}
|
||||
bool isMiningAddress(const std::string& addr) const {
|
||||
auto it = address_meta_.find(addr);
|
||||
return it != address_meta_.end() && it->second.mining;
|
||||
}
|
||||
void setMiningAddress(const std::string& addr, bool mining) {
|
||||
address_meta_[addr].mining = mining;
|
||||
}
|
||||
std::set<std::string> getMiningAddresses() const {
|
||||
std::set<std::string> addresses;
|
||||
for (const auto& [addr, meta] : address_meta_) {
|
||||
if (meta.mining) addresses.insert(addr);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
int getNextSortOrder() const {
|
||||
int mx = -1;
|
||||
for (const auto& [k, v] : address_meta_)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "daemon_controller.h"
|
||||
#include "../config/settings.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
DaemonController::DaemonController()
|
||||
: daemon_(std::make_unique<EmbeddedDaemon>())
|
||||
{
|
||||
}
|
||||
|
||||
DaemonController::~DaemonController() = default;
|
||||
|
||||
void DaemonController::setStateCallback(StateCallback callback)
|
||||
{
|
||||
daemon_->setStateCallback(std::move(callback));
|
||||
}
|
||||
|
||||
void DaemonController::syncSettings(const config::Settings* settings)
|
||||
{
|
||||
if (!settings) return;
|
||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||
}
|
||||
|
||||
bool DaemonController::start(const config::Settings* settings)
|
||||
{
|
||||
syncSettings(settings);
|
||||
return daemon_->start();
|
||||
}
|
||||
|
||||
void DaemonController::stop(int waitMs)
|
||||
{
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
}
|
||||
|
||||
bool DaemonController::externalDaemonDetected() const
|
||||
{
|
||||
return daemon_->externalDaemonDetected();
|
||||
}
|
||||
|
||||
DaemonController::State DaemonController::state() const
|
||||
{
|
||||
return daemon_->getState();
|
||||
}
|
||||
|
||||
const std::string& DaemonController::lastError() const
|
||||
{
|
||||
return daemon_->getLastError();
|
||||
}
|
||||
|
||||
int DaemonController::crashCount() const
|
||||
{
|
||||
return daemon_->getCrashCount();
|
||||
}
|
||||
|
||||
int DaemonController::lastBlockHeight() const
|
||||
{
|
||||
return daemon_ ? daemon_->getLastBlockHeight() : 0;
|
||||
}
|
||||
|
||||
double DaemonController::memoryUsageMB() const
|
||||
{
|
||||
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
|
||||
}
|
||||
|
||||
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
|
||||
{
|
||||
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
|
||||
}
|
||||
|
||||
std::string DaemonController::outputSince(std::size_t& offset) const
|
||||
{
|
||||
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
|
||||
}
|
||||
|
||||
void DaemonController::resetCrashCount()
|
||||
{
|
||||
daemon_->resetCrashCount();
|
||||
}
|
||||
|
||||
void DaemonController::setRescanOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setRescanOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::rescanOnNextStart() const
|
||||
{
|
||||
return daemon_->rescanOnNextStart();
|
||||
}
|
||||
|
||||
void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings)
|
||||
{
|
||||
if (settings) syncSettings(settings);
|
||||
if (decision.resetCrashCount) resetCrashCount();
|
||||
if (decision.setRescanOnNextStart) setRescanOnNextStart(true);
|
||||
}
|
||||
|
||||
DaemonController::ShutdownDecision DaemonController::shutdownDecision(
|
||||
bool keepDaemonRunning, bool stopExternalDaemon) const
|
||||
{
|
||||
return evaluateShutdownPolicy(static_cast<bool>(daemon_),
|
||||
daemon_ && daemon_->externalDaemonDetected(),
|
||||
keepDaemonRunning,
|
||||
stopExternalDaemon);
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,225 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace config { class Settings; }
|
||||
namespace daemon {
|
||||
|
||||
class DaemonController {
|
||||
public:
|
||||
using State = EmbeddedDaemon::State;
|
||||
using StateCallback = EmbeddedDaemon::StateCallback;
|
||||
|
||||
enum class ShutdownAction {
|
||||
DisconnectOnly,
|
||||
StopDaemon
|
||||
};
|
||||
|
||||
struct ShutdownDecision {
|
||||
ShutdownAction action = ShutdownAction::DisconnectOnly;
|
||||
const char* logReason = "no embedded daemon";
|
||||
const char* status = "Disconnecting...";
|
||||
};
|
||||
|
||||
enum class LifecycleOperation {
|
||||
ManualRestart,
|
||||
Rescan,
|
||||
DeleteBlockchainData,
|
||||
BootstrapStop
|
||||
};
|
||||
|
||||
struct LifecycleDecision {
|
||||
LifecycleOperation operation = LifecycleOperation::ManualRestart;
|
||||
bool allowed = false;
|
||||
bool wasRunning = false;
|
||||
const char* taskName = "";
|
||||
const char* status = "";
|
||||
const char* warning = "";
|
||||
bool resetCrashCount = false;
|
||||
bool setRescanOnNextStart = false;
|
||||
bool disconnectRpc = false;
|
||||
int restartDelayMs = 0;
|
||||
};
|
||||
|
||||
class LifecycleTaskContext {
|
||||
public:
|
||||
virtual ~LifecycleTaskContext() = default;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
class LifecycleRuntime {
|
||||
public:
|
||||
virtual ~LifecycleRuntime() = default;
|
||||
virtual void stopDaemonWithPolicy() = 0;
|
||||
virtual bool startDaemon() = 0;
|
||||
virtual int deleteBlockchainData() = 0;
|
||||
virtual void resetOutputOffset() = 0;
|
||||
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
|
||||
};
|
||||
|
||||
struct LifecycleExecutionResult {
|
||||
bool completed = false;
|
||||
bool cancelled = false;
|
||||
bool stopped = false;
|
||||
bool started = false;
|
||||
int deletedItems = 0;
|
||||
};
|
||||
|
||||
DaemonController();
|
||||
~DaemonController();
|
||||
|
||||
DaemonController(const DaemonController&) = delete;
|
||||
DaemonController& operator=(const DaemonController&) = delete;
|
||||
|
||||
EmbeddedDaemon* daemon() { return daemon_.get(); }
|
||||
const EmbeddedDaemon* daemon() const { return daemon_.get(); }
|
||||
|
||||
void setStateCallback(StateCallback callback);
|
||||
void syncSettings(const config::Settings* settings);
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
void stop(int waitMs);
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
std::vector<std::string> recentLines(std::size_t count) const;
|
||||
std::string outputSince(std::size_t& offset) const;
|
||||
|
||||
void resetCrashCount();
|
||||
void setRescanOnNextStart(bool enabled);
|
||||
bool rescanOnNextStart() const;
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) {
|
||||
if (!hasDaemon) {
|
||||
return {};
|
||||
}
|
||||
if (keepDaemonRunning) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"keep_daemon_running enabled",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
if (externalDaemonDetected && !stopExternalDaemon) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"external daemon (not ours to stop)",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
return {ShutdownAction::StopDaemon,
|
||||
"stopping managed daemon",
|
||||
"Sending stop command to daemon..."};
|
||||
}
|
||||
static LifecycleDecision evaluateLifecycleOperation(LifecycleOperation operation,
|
||||
bool usingEmbeddedDaemon,
|
||||
bool hasDaemon,
|
||||
bool daemonRunning,
|
||||
bool restartInProgress = false) {
|
||||
switch (operation) {
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (!usingEmbeddedDaemon || restartInProgress) return {};
|
||||
return {operation, true, daemonRunning, "daemon-restart", "Restarting daemon...", "",
|
||||
true, false, true, 500};
|
||||
case LifecycleOperation::Rescan:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Rescan requires embedded daemon. Restart your daemon with -rescan manually."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "",
|
||||
false, true, false, 3000};
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Delete blockchain requires embedded daemon. Stop your daemon manually and delete the data directory."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "delete-blockchain-data", "Deleting blockchain data...", "",
|
||||
false, false, false, 3000};
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
return {operation, true, daemonRunning, "bootstrap-stop-daemon", "Stopping daemon for bootstrap...", "",
|
||||
false, false, true, 0};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
void prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings = nullptr);
|
||||
static inline LifecycleExecutionResult executeLifecycleOperation(const LifecycleDecision& decision,
|
||||
LifecycleRuntime& runtime,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
LifecycleExecutionResult result;
|
||||
if (!decision.allowed) return result;
|
||||
|
||||
auto waitForDelay = [&]() {
|
||||
int waitTicks = std::max(0, decision.restartDelayMs / 100);
|
||||
for (int i = 0; i < waitTicks && !task.cancelled() && !task.shuttingDown(); ++i) {
|
||||
task.sleepForMs(100);
|
||||
}
|
||||
};
|
||||
|
||||
auto cancelled = [&]() {
|
||||
result.cancelled = task.cancelled() || task.shuttingDown();
|
||||
return result.cancelled;
|
||||
};
|
||||
|
||||
switch (decision.operation) {
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
if (decision.wasRunning) {
|
||||
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
|
||||
result.stopped = true;
|
||||
}
|
||||
result.completed = true;
|
||||
return result;
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (decision.wasRunning) {
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
}
|
||||
break;
|
||||
case LifecycleOperation::Rescan:
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (cancelled()) return result;
|
||||
waitForDelay();
|
||||
if (cancelled()) return result;
|
||||
|
||||
if (decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
result.deletedItems = runtime.deleteBlockchainData();
|
||||
if (cancelled()) return result;
|
||||
}
|
||||
|
||||
if (decision.operation == LifecycleOperation::Rescan ||
|
||||
decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
runtime.resetOutputOffset();
|
||||
}
|
||||
|
||||
result.started = runtime.startDaemon();
|
||||
result.completed = !cancelled();
|
||||
return result;
|
||||
}
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "lifecycle_adapters.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
AsyncLifecycleTaskContext::AsyncLifecycleTaskContext(
|
||||
const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown)
|
||||
: token_(token), shuttingDown_(shuttingDown)
|
||||
{
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return token_.cancelled();
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return shuttingDown_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void AsyncLifecycleTaskContext::sleepForMs(int milliseconds)
|
||||
{
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void ImmediateLifecycleTaskContext::sleepForMs(int)
|
||||
{
|
||||
}
|
||||
|
||||
int BlockchainDataCleaner::removeBlockchainData(const std::filesystem::path& dataDir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static constexpr std::array<const char*, 4> directories = {
|
||||
"blocks", "chainstate", "database", "notarizations"
|
||||
};
|
||||
static constexpr std::array<const char*, 5> files = {
|
||||
"peers.dat", "fee_estimates.dat", "banlist.dat", "db.log", ".lock"
|
||||
};
|
||||
|
||||
int removed = 0;
|
||||
for (const char* directoryName : directories) {
|
||||
fs::path path = dataDir / directoryName;
|
||||
std::error_code existsError;
|
||||
if (!fs::exists(path, existsError)) continue;
|
||||
|
||||
std::error_code removeError;
|
||||
auto count = fs::remove_all(path, removeError);
|
||||
if (!removeError) {
|
||||
removed += static_cast<int>(count);
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s (%d entries)\n",
|
||||
directoryName, static_cast<int>(count));
|
||||
} else {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
directoryName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (const char* fileName : files) {
|
||||
fs::path path = dataDir / fileName;
|
||||
std::error_code removeError;
|
||||
if (fs::remove(path, removeError)) {
|
||||
++removed;
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s\n", fileName);
|
||||
} else if (removeError) {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
fileName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include "daemon_controller.h"
|
||||
#include "../util/async_task_manager.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
class AsyncLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
AsyncLifecycleTaskContext(const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown);
|
||||
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
|
||||
private:
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
const std::atomic<bool>& shuttingDown_;
|
||||
};
|
||||
|
||||
class ImmediateLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
};
|
||||
|
||||
class BlockchainDataCleaner final {
|
||||
public:
|
||||
static int removeBlockchainData(const std::filesystem::path& dataDir);
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,510 @@
|
||||
#include "transaction_history_cache.h"
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <sodium.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kSchemaVersion = 1;
|
||||
constexpr std::size_t kKeyBytes = 32;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool bindBlob(sqlite3_stmt* statement, int index, const std::vector<unsigned char>& value)
|
||||
{
|
||||
return sqlite3_bind_blob(statement, index, value.data(), static_cast<int>(value.size()), SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> readBlob(sqlite3_stmt* statement, int index)
|
||||
{
|
||||
const void* data = sqlite3_column_blob(statement, index);
|
||||
int bytes = sqlite3_column_bytes(statement, index);
|
||||
if (!data || bytes <= 0) return {};
|
||||
const auto* begin = static_cast<const unsigned char*>(data);
|
||||
return std::vector<unsigned char>(begin, begin + bytes);
|
||||
}
|
||||
|
||||
std::string hexEncode(const unsigned char* bytes, std::size_t length)
|
||||
{
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
std::string output;
|
||||
output.resize(length * 2);
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
output[index * 2] = kHex[(bytes[index] >> 4) & 0x0F];
|
||||
output[index * 2 + 1] = kHex[bytes[index] & 0x0F];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
json transactionToJson(const TransactionInfo& transaction)
|
||||
{
|
||||
return json{
|
||||
{"txid", transaction.txid},
|
||||
{"type", transaction.type},
|
||||
{"amount", transaction.amount},
|
||||
{"timestamp", transaction.timestamp},
|
||||
{"confirmations", transaction.confirmations},
|
||||
{"address", transaction.address},
|
||||
{"from_address", transaction.from_address},
|
||||
{"memo", transaction.memo}
|
||||
};
|
||||
}
|
||||
|
||||
TransactionInfo transactionFromJson(const json& source)
|
||||
{
|
||||
TransactionInfo transaction;
|
||||
transaction.txid = source.value("txid", std::string());
|
||||
transaction.type = source.value("type", std::string());
|
||||
transaction.amount = source.value("amount", 0.0);
|
||||
transaction.timestamp = source.value("timestamp", static_cast<std::int64_t>(0));
|
||||
transaction.confirmations = source.value("confirmations", 0);
|
||||
transaction.address = source.value("address", std::string());
|
||||
transaction.from_address = source.value("from_address", std::string());
|
||||
transaction.memo = source.value("memo", std::string());
|
||||
return transaction;
|
||||
}
|
||||
|
||||
std::string associatedDataForWallet(const std::string& walletHash)
|
||||
{
|
||||
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache()
|
||||
: TransactionHistoryCache(defaultDatabasePath())
|
||||
{
|
||||
}
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache(std::string databasePath)
|
||||
: database_path_(std::move(databasePath))
|
||||
{
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for transaction history cache\n");
|
||||
}
|
||||
}
|
||||
|
||||
TransactionHistoryCache::~TransactionHistoryCache()
|
||||
{
|
||||
lockKey();
|
||||
close();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "transaction_history.sqlite").string();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses)
|
||||
{
|
||||
std::vector<std::string> addresses;
|
||||
addresses.reserve(shieldedAddresses.size() + transparentAddresses.size());
|
||||
for (const auto& address : shieldedAddresses) {
|
||||
if (!address.empty()) addresses.push_back("z:" + address);
|
||||
}
|
||||
for (const auto& address : transparentAddresses) {
|
||||
if (!address.empty()) addresses.push_back("t:" + address);
|
||||
}
|
||||
if (addresses.empty()) return {};
|
||||
|
||||
std::sort(addresses.begin(), addresses.end());
|
||||
std::string identity = "wallet-addresses-v1\n";
|
||||
for (const auto& address : addresses) {
|
||||
identity += address;
|
||||
identity += '\n';
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityHash(const std::string& walletIdentity)
|
||||
{
|
||||
unsigned char digest[crypto_generichash_BYTES];
|
||||
crypto_generichash(digest, sizeof(digest),
|
||||
reinterpret_cast<const unsigned char*>(walletIdentity.data()),
|
||||
walletIdentity.size(), nullptr, 0);
|
||||
return hexEncode(digest, sizeof(digest));
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& exception) {
|
||||
DEBUG_LOGF("Failed to create transaction history cache directory: %s\n", exception.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open transaction history cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
|
||||
const std::string& passphrase)
|
||||
{
|
||||
if (walletIdentity.empty() || passphrase.empty() || !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;
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::lockKey()
|
||||
{
|
||||
if (key_ready_) sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
unlocked_wallet_hash_.clear();
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
|
||||
{
|
||||
return key_ready_ && !walletIdentity.empty() &&
|
||||
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
|
||||
}
|
||||
|
||||
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash)
|
||||
{
|
||||
LoadResult result;
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
if (!readSnapshot(walletHash, tipHeight, tipHash, updatedAt, nonce, cipherText)) return result;
|
||||
|
||||
if ((currentTipHeight > 0 && tipHeight > currentTipHeight) ||
|
||||
(currentTipHeight > 0 && tipHeight == currentTipHeight &&
|
||||
!currentTipHash.empty() && !tipHash.empty() && tipHash != currentTipHash)) {
|
||||
clearWalletByHash(walletHash);
|
||||
result.invalidated = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string plainText;
|
||||
if (!decryptPayload(walletHash, nonce, cipherText, plainText)) return result;
|
||||
|
||||
try {
|
||||
json payload = json::parse(plainText);
|
||||
if (payload.value("schema_version", 0) != kSchemaVersion) return result;
|
||||
if (payload.value("wallet_hash", std::string()) != walletHash) return result;
|
||||
if (!payload.contains("transactions") || !payload["transactions"].is_array()) return result;
|
||||
|
||||
result.transactions.reserve(payload["transactions"].size());
|
||||
for (const auto& transactionJson : payload["transactions"]) {
|
||||
if (transactionJson.is_object()) {
|
||||
result.transactions.push_back(transactionFromJson(transactionJson));
|
||||
}
|
||||
}
|
||||
if (payload.contains("shielded_scan_heights") && payload["shielded_scan_heights"].is_object()) {
|
||||
for (auto it = payload["shielded_scan_heights"].begin();
|
||||
it != payload["shielded_scan_heights"].end(); ++it) {
|
||||
if (!it.key().empty() && it.value().is_number_integer()) {
|
||||
result.shieldedScanHeights[it.key()] = it.value().get<int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
result.tipHeight = tipHeight;
|
||||
result.tipHash = tipHash;
|
||||
result.updatedAt = updatedAt;
|
||||
result.loaded = true;
|
||||
} catch (...) {
|
||||
result.transactions.clear();
|
||||
}
|
||||
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights)
|
||||
{
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
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["transactions"] = json::array();
|
||||
for (const auto& transaction : transactions) {
|
||||
payload["transactions"].push_back(transactionToJson(transaction));
|
||||
}
|
||||
payload["shielded_scan_heights"] = json::object();
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
if (!address.empty() && height >= 0) {
|
||||
payload["shielded_scan_heights"][address] = height;
|
||||
}
|
||||
}
|
||||
|
||||
std::string plainText = payload.dump();
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_snapshots "
|
||||
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
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));
|
||||
if (!bindBlob(statement.handle, 6, nonce)) return false;
|
||||
if (!bindBlob(statement.handle, 7, cipherText)) return false;
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return;
|
||||
clearWalletByHash(walletIdentityHash(walletIdentity));
|
||||
}
|
||||
|
||||
int TransactionHistoryCache::snapshotCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Transaction history cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS transaction_history_keys ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"salt BLOB NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS transaction_history_snapshots ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"schema_version INTEGER NOT NULL,"
|
||||
"tip_height INTEGER NOT NULL,"
|
||||
"tip_hash TEXT NOT NULL,"
|
||||
"updated_at INTEGER NOT NULL,"
|
||||
"nonce BLOB NOT NULL,"
|
||||
"ciphertext BLOB NOT NULL)");
|
||||
}
|
||||
|
||||
std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
{
|
||||
Statement statement(db_, "SELECT salt FROM transaction_history_keys WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, walletHash)) return {};
|
||||
if (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
auto salt = readBlob(statement.handle, 0);
|
||||
if (salt.size() == crypto_pwhash_SALTBYTES) return salt;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<unsigned char> salt(crypto_pwhash_SALTBYTES);
|
||||
randombytes_buf(salt.data(), salt.size());
|
||||
|
||||
Statement insert(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_keys (wallet_hash, salt) VALUES (?, ?)");
|
||||
if (!insert.handle) return {};
|
||||
if (!bindText(insert.handle, 1, walletHash)) return {};
|
||||
if (!bindBlob(insert.handle, 2, salt)) return {};
|
||||
if (sqlite3_step(insert.handle) != SQLITE_DONE) return {};
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
unsigned char derived[kKeyBytes];
|
||||
int result = crypto_pwhash(derived, sizeof(derived),
|
||||
passphrase.c_str(), passphrase.size(),
|
||||
salt.data(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (result != 0) return false;
|
||||
std::copy(derived, derived + sizeof(derived), key_.begin());
|
||||
sodium_memzero(derived, sizeof(derived));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
cipherText.resize(plainText.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long cipherLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
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());
|
||||
if (result != 0) return false;
|
||||
cipherText.resize(static_cast<std::size_t>(cipherLength));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const
|
||||
{
|
||||
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long plainLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
plain.data(), &plainLength, nullptr,
|
||||
cipherText.data(), cipherText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nonce.data(), key_.data());
|
||||
if (result != 0) return false;
|
||||
|
||||
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
|
||||
sodium_memzero(plain.data(), plain.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText)
|
||||
{
|
||||
Statement statement(db_,
|
||||
"SELECT tip_height, tip_hash, updated_at, nonce, ciphertext "
|
||||
"FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return false;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return false;
|
||||
|
||||
tipHeight = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* tipHashText = sqlite3_column_text(statement.handle, 1);
|
||||
tipHash = tipHashText ? reinterpret_cast<const char*>(tipHashText) : std::string();
|
||||
updatedAt = static_cast<std::time_t>(sqlite3_column_int64(statement.handle, 2));
|
||||
nonce = readBlob(statement.handle, 3);
|
||||
cipherText = readBlob(statement.handle, 4);
|
||||
return !nonce.empty() && !cipherText.empty();
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
class TransactionHistoryCache {
|
||||
public:
|
||||
struct LoadResult {
|
||||
bool loaded = false;
|
||||
bool invalidated = false;
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
explicit TransactionHistoryCache(std::string databasePath);
|
||||
~TransactionHistoryCache();
|
||||
|
||||
TransactionHistoryCache(const TransactionHistoryCache&) = delete;
|
||||
TransactionHistoryCache& operator=(const TransactionHistoryCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string walletIdentityFromAddresses(const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses);
|
||||
static std::string walletIdentityHash(const std::string& walletIdentity);
|
||||
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash);
|
||||
bool replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
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;
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const;
|
||||
bool readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -3,12 +3,44 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_state.h"
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance)
|
||||
{
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(addresses.size());
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
{
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
std::string TransactionInfo::getTimeString() const
|
||||
{
|
||||
if (timestamp == 0) return "Unknown";
|
||||
|
||||
@@ -18,6 +18,7 @@ struct AddressInfo {
|
||||
std::string address;
|
||||
double balance = 0.0;
|
||||
std::string type; // "shielded" or "transparent"
|
||||
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
||||
|
||||
// For display
|
||||
std::string label;
|
||||
@@ -25,8 +26,13 @@ struct AddressInfo {
|
||||
// Derived
|
||||
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
||||
bool isShielded() const { return type == "shielded"; }
|
||||
bool isSpendable() const { return has_spending_key; }
|
||||
};
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance = true);
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
||||
|
||||
/**
|
||||
* @brief Represents a wallet transaction
|
||||
*/
|
||||
@@ -135,6 +141,8 @@ struct MarketInfo {
|
||||
double market_cap = 0.0;
|
||||
std::string last_updated;
|
||||
std::chrono::steady_clock::time_point last_fetch_time{};
|
||||
bool price_loading = false;
|
||||
std::string price_error;
|
||||
|
||||
// Price history for chart
|
||||
std::vector<double> price_history;
|
||||
|
||||
@@ -12,4 +12,5 @@ INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf");
|
||||
INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf");
|
||||
INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf");
|
||||
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
|
||||
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
|
||||
|
||||
@@ -27,6 +27,9 @@ extern "C" {
|
||||
extern const unsigned char g_material_icons_data[];
|
||||
extern const unsigned int g_material_icons_size;
|
||||
|
||||
extern const unsigned char g_mdi_pickaxe_subset_data[];
|
||||
extern const unsigned int g_mdi_pickaxe_subset_size;
|
||||
|
||||
extern const unsigned char g_noto_cjk_subset_data[];
|
||||
extern const unsigned int g_noto_cjk_subset_size;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1224,6 +1224,8 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Immediate triggers: async RPC results or visible notifications
|
||||
bool hasImmediateWork = app.hasPendingRPCResults()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Periodic maintenance: fire refresh timers in app.update()
|
||||
@@ -1801,6 +1803,8 @@ int main(int argc, char* argv[])
|
||||
&& !opaqueBackground;
|
||||
bool animating = app.isShuttingDown()
|
||||
|| backdropNeedsFrames
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
|| dragonx::ui::Notifications::instance().hasActive()
|
||||
|| dragonx::ui::material::SmoothScrollAnimating();
|
||||
|
||||
+110
-20
@@ -6,11 +6,14 @@
|
||||
#include "../config/version.h"
|
||||
#include "../resources/embedded_resources.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "../util/logger.h"
|
||||
|
||||
@@ -26,6 +29,56 @@ namespace fs = std::filesystem;
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string generateSecureRandomString(size_t length)
|
||||
{
|
||||
static constexpr char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
static constexpr uint32_t charsetSize = static_cast<uint32_t>(sizeof(charset) - 1);
|
||||
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for RPC credential generation\n");
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
result.push_back(charset[randombytes_uniform(charsetSize)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string lowercase(std::string value)
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
bool parseBoolValue(const std::string& value)
|
||||
{
|
||||
std::string lowered = lowercase(value);
|
||||
return lowered == "1" || lowered == "true" || lowered == "yes" || lowered == "on";
|
||||
}
|
||||
|
||||
bool applyCookieAuth(ConnectionConfig& config, const std::string& dataDir)
|
||||
{
|
||||
std::string cookieUser, cookiePass;
|
||||
if (!Connection::readAuthCookie(dataDir, cookieUser, cookiePass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
config.auth_source = AuthSource::Cookie;
|
||||
if (config.hush_dir.empty()) config.hush_dir = dataDir;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Connection::Connection() = default;
|
||||
Connection::~Connection() = default;
|
||||
|
||||
@@ -140,8 +193,14 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.host = value;
|
||||
} else if (key == "proxy") {
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.rpcuser.empty() || !config.rpcpassword.empty()) {
|
||||
config.auth_source = AuthSource::ConfigFile;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -177,10 +236,7 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
|
||||
// If rpcpassword is empty, the daemon may be using .cookie auth
|
||||
if (config.rpcpassword.empty()) {
|
||||
std::string cookieUser, cookiePass;
|
||||
if (readAuthCookie(data_dir, cookieUser, cookiePass)) {
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
if (applyCookieAuth(config, data_dir)) {
|
||||
DEBUG_LOGF("Using .cookie authentication (no rpcpassword in config)\n");
|
||||
}
|
||||
}
|
||||
@@ -196,23 +252,57 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
return config;
|
||||
}
|
||||
|
||||
bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig)
|
||||
{
|
||||
if (base.auth_source == AuthSource::Cookie) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string dataDir = base.hush_dir.empty() ? getDefaultDataDir() : base.hush_dir;
|
||||
ConnectionConfig fallback = base;
|
||||
if (!applyCookieAuth(fallback, dataDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cookieConfig = std::move(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
if (!lowered.empty() && lowered.front() == '[' && lowered.back() == ']') {
|
||||
lowered = lowered.substr(1, lowered.size() - 2);
|
||||
}
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case AuthSource::ConfigFile: return "config";
|
||||
case AuthSource::Cookie: return "cookie";
|
||||
case AuthSource::Missing: return "missing";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bool Connection::createDefaultConfig(const std::string& path)
|
||||
{
|
||||
// Generate random rpcuser/rpcpassword
|
||||
auto generateRandomString = [](int length) -> std::string {
|
||||
const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
|
||||
std::srand(static_cast<unsigned>(std::time(nullptr)));
|
||||
for (int i = 0; i < length; i++) {
|
||||
result += charset[std::rand() % (sizeof(charset) - 1)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
std::string rpcuser = generateRandomString(16);
|
||||
std::string rpcpassword = generateRandomString(32);
|
||||
std::string rpcuser = generateSecureRandomString(16);
|
||||
std::string rpcpassword = generateSecureRandomString(32);
|
||||
if (rpcuser.empty() || rpcpassword.empty()) {
|
||||
DEBUG_LOGF("Failed to generate secure RPC credentials for config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
|
||||
@@ -12,6 +12,12 @@ namespace rpc {
|
||||
/**
|
||||
* @brief Connection configuration
|
||||
*/
|
||||
enum class AuthSource {
|
||||
Missing,
|
||||
ConfigFile,
|
||||
Cookie
|
||||
};
|
||||
|
||||
struct ConnectionConfig {
|
||||
std::string host = "127.0.0.1";
|
||||
std::string port = "21769";
|
||||
@@ -20,6 +26,8 @@ struct ConnectionConfig {
|
||||
std::string hush_dir;
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -96,6 +104,23 @@ public:
|
||||
*/
|
||||
static bool readAuthCookie(const std::string& dataDir, std::string& user, std::string& password);
|
||||
|
||||
/**
|
||||
* @brief Build a cookie-auth retry config from a failed config-auth attempt
|
||||
*/
|
||||
static bool buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig);
|
||||
|
||||
/**
|
||||
* @brief Whether a host is local enough for plaintext HTTP RPC
|
||||
*/
|
||||
static bool isLocalHost(const std::string& host);
|
||||
|
||||
/**
|
||||
* @brief Whether this config would send RPC credentials over plaintext to a remote host
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
};
|
||||
|
||||
|
||||
+87
-2
@@ -10,13 +10,76 @@
|
||||
#include "../util/base64.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include "../util/logger.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_trace_mutex;
|
||||
RPCClient::TraceCallback g_trace_callback;
|
||||
std::atomic_bool g_trace_enabled{false};
|
||||
thread_local std::string g_trace_source;
|
||||
|
||||
void emitRpcTrace(const std::string& method)
|
||||
{
|
||||
if (!g_trace_enabled.load(std::memory_order_relaxed)) return;
|
||||
|
||||
RPCClient::TraceCallback callback;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
callback = g_trace_callback;
|
||||
}
|
||||
if (!callback) return;
|
||||
|
||||
std::string source = g_trace_source.empty() ? std::string("App") : g_trace_source;
|
||||
callback(source, method);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RPCClient::TraceScope::TraceScope(std::string source)
|
||||
: previous_(RPCClient::currentTraceSource())
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(source));
|
||||
}
|
||||
|
||||
RPCClient::TraceScope::~TraceScope()
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(previous_));
|
||||
}
|
||||
|
||||
void RPCClient::setTraceCallback(TraceCallback callback)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
g_trace_callback = std::move(callback);
|
||||
}
|
||||
|
||||
void RPCClient::setTraceEnabled(bool enabled)
|
||||
{
|
||||
g_trace_enabled.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool RPCClient::isTraceEnabled()
|
||||
{
|
||||
return g_trace_enabled.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::string RPCClient::currentTraceSource()
|
||||
{
|
||||
return g_trace_source;
|
||||
}
|
||||
|
||||
void RPCClient::setTraceSource(std::string source)
|
||||
{
|
||||
g_trace_source = std::move(source);
|
||||
}
|
||||
|
||||
// Callback for libcurl to write response data
|
||||
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t totalSize = size * nmemb;
|
||||
@@ -60,17 +123,24 @@ RPCClient::~RPCClient() = default;
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password)
|
||||
{
|
||||
return connect(host, port, user, password, false);
|
||||
}
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
|
||||
// Create Basic auth header with proper base64 encoding
|
||||
std::string credentials = user + ":" + password;
|
||||
auth_ = util::base64_encode(credentials);
|
||||
|
||||
// Build URL - use HTTP for localhost RPC (TLS not always enabled)
|
||||
impl_->url = "http://" + host + ":" + port + "/";
|
||||
impl_->url = std::string(useTls ? "https://" : "http://") + host + ":" + port + "/";
|
||||
VERBOSE_LOGF("Connecting to dragonxd at %s\n", impl_->url.c_str());
|
||||
|
||||
// Clean up previous curl handle/headers to avoid leaks on retries
|
||||
@@ -110,6 +180,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_info_ = result;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
@@ -140,15 +211,23 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
return false;
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
return last_connect_info_;
|
||||
}
|
||||
|
||||
void RPCClient::disconnect()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
if (impl_->curl) {
|
||||
curl_easy_cleanup(impl_->curl);
|
||||
impl_->curl = nullptr;
|
||||
@@ -176,6 +255,8 @@ json RPCClient::call(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
@@ -229,6 +310,8 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
// Temporarily override timeout
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
@@ -288,6 +371,8 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
|
||||
@@ -25,6 +25,20 @@ using ErrorCallback = std::function<void(const std::string&)>;
|
||||
*/
|
||||
class RPCClient {
|
||||
public:
|
||||
using TraceCallback = std::function<void(const std::string& source, const std::string& method)>;
|
||||
|
||||
class TraceScope {
|
||||
public:
|
||||
explicit TraceScope(std::string source);
|
||||
~TraceScope();
|
||||
|
||||
TraceScope(const TraceScope&) = delete;
|
||||
TraceScope& operator=(const TraceScope&) = delete;
|
||||
|
||||
private:
|
||||
std::string previous_;
|
||||
};
|
||||
|
||||
RPCClient();
|
||||
~RPCClient();
|
||||
|
||||
@@ -43,6 +57,10 @@ public:
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password);
|
||||
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
@@ -70,6 +88,13 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
static void setTraceEnabled(bool enabled);
|
||||
static bool isTraceEnabled();
|
||||
static std::string currentTraceSource();
|
||||
static void setTraceSource(std::string source);
|
||||
|
||||
/**
|
||||
* @brief Make a raw RPC call
|
||||
@@ -198,6 +223,7 @@ private:
|
||||
bool warming_up_ = false;
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
// HTTP client (implementation hidden)
|
||||
|
||||
@@ -97,6 +97,18 @@ bool RPCWorker::hasPendingResults() const
|
||||
return !results_.empty();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingTaskCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(taskMtx_);
|
||||
return tasks_.size();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingResultCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(resultMtx_);
|
||||
return results_.size();
|
||||
}
|
||||
|
||||
void RPCWorker::run()
|
||||
{
|
||||
while (true) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
@@ -69,6 +70,8 @@ public:
|
||||
|
||||
/// True when there are completed results waiting for the main thread.
|
||||
bool hasPendingResults() const;
|
||||
std::size_t pendingTaskCount() const;
|
||||
std::size_t pendingResultCount() const;
|
||||
|
||||
/// True when the worker thread is running.
|
||||
bool isRunning() const { return running_.load(std::memory_order_relaxed); }
|
||||
@@ -80,7 +83,7 @@ private:
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// ---- Task queue (produced by main thread, consumed by worker) ----
|
||||
std::mutex taskMtx_;
|
||||
mutable std::mutex taskMtx_;
|
||||
std::condition_variable taskCv_;
|
||||
std::deque<WorkFn> tasks_;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
#pragma once
|
||||
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class NetworkRefreshService {
|
||||
public:
|
||||
using Timer = RefreshScheduler::Timer;
|
||||
using Intervals = RefreshScheduler::Intervals;
|
||||
|
||||
class RefreshRpcGateway {
|
||||
public:
|
||||
virtual ~RefreshRpcGateway() = default;
|
||||
virtual nlohmann::json call(const std::string& method,
|
||||
const nlohmann::json& params) = 0;
|
||||
};
|
||||
|
||||
enum class Job {
|
||||
Core,
|
||||
Addresses,
|
||||
Transactions,
|
||||
Mining,
|
||||
Peers,
|
||||
Price,
|
||||
Encryption,
|
||||
ConnectionInit,
|
||||
Count
|
||||
};
|
||||
|
||||
struct DispatchTicket {
|
||||
Job job = Job::Core;
|
||||
std::uint64_t generation = 0;
|
||||
bool accepted = false;
|
||||
};
|
||||
|
||||
struct JobStats {
|
||||
std::uint64_t started = 0;
|
||||
std::uint64_t finished = 0;
|
||||
std::uint64_t skippedInFlight = 0;
|
||||
std::uint64_t skippedQueuePressure = 0;
|
||||
std::uint64_t staleCallbacks = 0;
|
||||
std::size_t lastQueueDepth = 0;
|
||||
};
|
||||
|
||||
struct EnqueueResult {
|
||||
DispatchTicket ticket;
|
||||
bool enqueued = false;
|
||||
std::size_t queueDepth = 0;
|
||||
};
|
||||
|
||||
struct ConnectionInfoResult {
|
||||
bool ok = false;
|
||||
std::optional<int> daemonVersion;
|
||||
std::optional<int> protocolVersion;
|
||||
std::optional<int> p2pPort;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
std::optional<int> blocks;
|
||||
};
|
||||
|
||||
struct WalletEncryptionResult {
|
||||
bool ok = false;
|
||||
bool encrypted = false;
|
||||
std::int64_t unlockedUntil = 0;
|
||||
};
|
||||
|
||||
struct WarmupPollResult {
|
||||
bool ready = false;
|
||||
ConnectionInfoResult info;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct ConnectionInitResult {
|
||||
ConnectionInfoResult info;
|
||||
WalletEncryptionResult encryption;
|
||||
};
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
std::optional<std::string> bestBlockHash;
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
std::optional<double> localHashrate;
|
||||
bool miningOk = false;
|
||||
std::optional<bool> generate;
|
||||
std::optional<int> genproclimit;
|
||||
std::optional<int> blocks;
|
||||
std::optional<double> difficulty;
|
||||
std::optional<double> networkHashrate;
|
||||
std::optional<std::string> chain;
|
||||
double daemonMemoryMb = 0.0;
|
||||
};
|
||||
|
||||
struct PeerRefreshResult {
|
||||
std::vector<PeerInfo> peers;
|
||||
std::vector<BannedPeer> bannedPeers;
|
||||
};
|
||||
|
||||
struct PriceRefreshResult {
|
||||
MarketInfo market;
|
||||
};
|
||||
|
||||
struct PriceHttpResponse {
|
||||
bool transportOk = false;
|
||||
long httpStatus = 0;
|
||||
std::string body;
|
||||
std::string transportError;
|
||||
};
|
||||
|
||||
struct PriceHttpResult {
|
||||
std::optional<PriceRefreshResult> price;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
std::vector<AddressInfo> shieldedAddresses;
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
std::unordered_map<std::string, bool> shieldedSpendingKeys;
|
||||
};
|
||||
|
||||
struct TransactionViewCacheEntry {
|
||||
std::string from_address;
|
||||
std::int64_t timestamp = 0;
|
||||
int confirmations = 0;
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
|
||||
using TransactionViewCache = std::unordered_map<std::string, TransactionViewCacheEntry>;
|
||||
|
||||
struct TransactionRefreshSnapshot {
|
||||
std::vector<std::string> shieldedAddresses;
|
||||
std::unordered_set<std::string> fullyEnrichedTxids;
|
||||
TransactionViewCache viewTxCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
std::unordered_set<std::string> pendingOpids;
|
||||
std::vector<TransactionInfo> previousTransactions;
|
||||
std::set<std::string> miningAddresses;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
std::size_t shieldedScanStartIndex = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
std::vector<TransactionInfo> transactions;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
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;
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
std::vector<std::string> doneOpids;
|
||||
std::vector<std::string> staleOpids;
|
||||
std::vector<std::string> successTxids;
|
||||
std::unordered_map<std::string, std::string> successTxidsByOpid;
|
||||
std::vector<std::string> failureMessages;
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
std::vector<TransactionInfo>& confirmedTxCache;
|
||||
std::unordered_set<std::string>& confirmedTxIds;
|
||||
int& confirmedCacheBlock;
|
||||
int& lastTxBlockHeight;
|
||||
};
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
|
||||
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc);
|
||||
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
|
||||
bool miningOk,
|
||||
const nlohmann::json& localHashrate,
|
||||
bool hashrateOk,
|
||||
double daemonMemoryMb);
|
||||
static MiningRefreshResult collectMiningRefreshResult(RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh,
|
||||
bool includeLocalHashrate = true);
|
||||
static PeerRefreshResult parsePeerRefreshResult(const nlohmann::json& peers,
|
||||
const nlohmann::json& bannedPeers);
|
||||
static PeerRefreshResult collectPeerRefreshResult(RefreshRpcGateway& rpc);
|
||||
static std::optional<PriceRefreshResult> parseCoinGeckoPriceResponse(const std::string& response,
|
||||
std::time_t fetchedAt);
|
||||
static PriceHttpResult parsePriceHttpResponse(const PriceHttpResponse& response,
|
||||
std::time_t fetchedAt);
|
||||
static AddressInfo buildShieldedAddressInfo(const std::string& address,
|
||||
const nlohmann::json& validation,
|
||||
bool validationSucceeded);
|
||||
static AddressInfo buildTransparentAddressInfo(const std::string& address);
|
||||
static std::vector<AddressInfo> parseTransparentAddressList(const nlohmann::json& addressList);
|
||||
static void applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
|
||||
static AddressRefreshResult collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot = {});
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
static void appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const nlohmann::json& result,
|
||||
const std::set<std::string>& miningAddresses = {});
|
||||
static void appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const nlohmann::json& received,
|
||||
const std::set<std::string>& miningAddresses = {});
|
||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int maxViewTransactionsPerCycle);
|
||||
static TransactionRefreshResult collectRecentTransactionRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int pageSize = 100);
|
||||
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
|
||||
const std::vector<std::string>& requestedOpids);
|
||||
|
||||
static void applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result);
|
||||
static void applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result);
|
||||
static void applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result);
|
||||
static void applyCoreRefreshResult(WalletState& state,
|
||||
const CoreRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyMiningRefreshResult(WalletState& state,
|
||||
const MiningRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyPeerRefreshResult(WalletState& state,
|
||||
PeerRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
static void markPriceRefreshStarted(WalletState& state);
|
||||
static void applyPriceRefreshResult(WalletState& state,
|
||||
const PriceRefreshResult& result,
|
||||
std::chrono::steady_clock::time_point fetchedAt);
|
||||
static void applyPriceRefreshFailure(WalletState& state,
|
||||
const std::string& errorMessage);
|
||||
static void applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result);
|
||||
static void applyTransactionRefreshResult(WalletState& state,
|
||||
TransactionCacheUpdate cacheUpdate,
|
||||
TransactionRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
|
||||
void applyPage(ui::NavPage page) { scheduler_.applyPage(page); }
|
||||
void setIntervals(Intervals intervals) { scheduler_.setIntervals(intervals); }
|
||||
const Intervals& intervals() const { return scheduler_.intervals(); }
|
||||
|
||||
void tick(float deltaSeconds) { scheduler_.tick(deltaSeconds); }
|
||||
bool isDue(Timer timer) const { return scheduler_.isDue(timer); }
|
||||
bool consumeDue(Timer timer) { return scheduler_.consumeDue(timer); }
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
void markWalletMutationRefresh() { scheduler_.markWalletMutationRefresh(); }
|
||||
void resetTxAge() { scheduler_.resetTxAge(); }
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
bool beginJob(Job job);
|
||||
bool beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork);
|
||||
void finishJob(Job job);
|
||||
bool jobInProgress(Job job) const;
|
||||
void resetJobs();
|
||||
DispatchTicket beginDispatch(Job job, std::size_t queuedWork = 0, std::size_t maxQueuedWork = 0);
|
||||
bool completeDispatch(const DispatchTicket& ticket);
|
||||
void cancelDispatch(const DispatchTicket& ticket);
|
||||
JobStats stats(Job job) const;
|
||||
|
||||
template <typename Worker, typename WorkFn>
|
||||
EnqueueResult enqueue(Job job, Worker& worker, WorkFn&& work, std::size_t maxQueuedWork = 0)
|
||||
{
|
||||
std::size_t queueDepth = worker.pendingTaskCount();
|
||||
auto ticket = beginDispatch(job, queueDepth, maxQueuedWork);
|
||||
if (!ticket.accepted) return {ticket, false, queueDepth};
|
||||
|
||||
worker.post([this, ticket, work = std::forward<WorkFn>(work)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
rpc::RPCWorker::MainCb mainCallback;
|
||||
try {
|
||||
mainCallback = work();
|
||||
} catch (...) {
|
||||
mainCallback = nullptr;
|
||||
}
|
||||
return [this, ticket, mainCallback = std::move(mainCallback)]() mutable {
|
||||
if (!completeDispatch(ticket)) return;
|
||||
if (mainCallback) mainCallback();
|
||||
};
|
||||
});
|
||||
|
||||
return {ticket, true, queueDepth};
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool>& jobFlag(Job job);
|
||||
const std::atomic<bool>& jobFlag(Job job) const;
|
||||
static std::size_t jobIndex(Job job);
|
||||
|
||||
RefreshScheduler scheduler_;
|
||||
std::atomic<bool> coreInProgress_{false};
|
||||
std::atomic<bool> addressesInProgress_{false};
|
||||
std::atomic<bool> transactionsInProgress_{false};
|
||||
std::atomic<bool> miningInProgress_{false};
|
||||
std::atomic<bool> peersInProgress_{false};
|
||||
std::atomic<bool> priceInProgress_{false};
|
||||
std::atomic<bool> encryptionInProgress_{false};
|
||||
std::atomic<bool> connectionInitInProgress_{false};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> generations_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> started_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> finished_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedInFlight_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedQueuePressure_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> staleCallbacks_{};
|
||||
std::array<std::atomic<std::size_t>, static_cast<std::size_t>(Job::Count)> lastQueueDepth_{};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,156 @@
|
||||
#include "refresh_scheduler.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview: return {2.0f, 10.0f, 15.0f, 0.0f};
|
||||
case NP::Send: return {3.0f, 10.0f, 5.0f, 0.0f};
|
||||
case NP::Receive: return {5.0f, 15.0f, 5.0f, 0.0f};
|
||||
case NP::History: return {5.0f, 3.0f, 15.0f, 0.0f};
|
||||
case NP::Mining: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
case NP::Peers: return {5.0f, 15.0f, 15.0f, 5.0f};
|
||||
case NP::Market: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
case NP::Console: return {10.0f, 30.0f, 30.0f, 0.0f};
|
||||
default: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
}
|
||||
}
|
||||
|
||||
void RefreshScheduler::applyPage(ui::NavPage page)
|
||||
{
|
||||
setIntervals(intervalsForPage(page));
|
||||
}
|
||||
|
||||
void RefreshScheduler::setIntervals(Intervals intervals)
|
||||
{
|
||||
intervals_ = intervals;
|
||||
}
|
||||
|
||||
void RefreshScheduler::tick(float deltaSeconds)
|
||||
{
|
||||
float delta = std::max(0.0f, deltaSeconds);
|
||||
timers_.core += delta;
|
||||
timers_.transactions += delta;
|
||||
timers_.addresses += delta;
|
||||
timers_.peers += delta;
|
||||
timers_.price += delta;
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::consumeDue(Timer timer)
|
||||
{
|
||||
if (!isDue(timer)) return false;
|
||||
reset(timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
}
|
||||
|
||||
float RefreshScheduler::interval(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return intervals_.core;
|
||||
case Timer::Transactions: return intervals_.transactions;
|
||||
case Timer::Addresses: return intervals_.addresses;
|
||||
case Timer::Peers: return intervals_.peers;
|
||||
case Timer::Price: return kPrice;
|
||||
case Timer::Fast: return kFast;
|
||||
case Timer::TxAge: return kTxMaxAge;
|
||||
case Timer::Opid: return kOpidPoll;
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markImmediateRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
markDue(Timer::Peers);
|
||||
}
|
||||
|
||||
void RefreshScheduler::markWalletMutationRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
}
|
||||
|
||||
void RefreshScheduler::resetTxAge()
|
||||
{
|
||||
reset(Timer::TxAge);
|
||||
}
|
||||
|
||||
bool RefreshScheduler::shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const
|
||||
{
|
||||
return lastTxBlockHeight < 0
|
||||
|| currentBlockHeight != lastTxBlockHeight
|
||||
|| transactionsDirty;
|
||||
}
|
||||
|
||||
float& RefreshScheduler::timerRef(Timer timer)
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
const float& RefreshScheduler::timerRef(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class RefreshScheduler {
|
||||
public:
|
||||
enum class Timer {
|
||||
Core,
|
||||
Transactions,
|
||||
Addresses,
|
||||
Peers,
|
||||
Price,
|
||||
Fast,
|
||||
TxAge,
|
||||
Opid
|
||||
};
|
||||
|
||||
struct Intervals {
|
||||
float core;
|
||||
float transactions;
|
||||
float addresses;
|
||||
float peers;
|
||||
};
|
||||
|
||||
static constexpr float kCoreDefault = 5.0f;
|
||||
static constexpr float kAddressDefault = 15.0f;
|
||||
static constexpr float kTransactionDefault = 10.0f;
|
||||
static constexpr float kPeerDefault = 10.0f;
|
||||
static constexpr float kPrice = 60.0f;
|
||||
static constexpr float kFast = 1.0f;
|
||||
static constexpr float kTxMaxAge = 15.0f;
|
||||
static constexpr float kOpidPoll = 2.0f;
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page);
|
||||
|
||||
void applyPage(ui::NavPage page);
|
||||
void setIntervals(Intervals intervals);
|
||||
const Intervals& intervals() const { return intervals_; }
|
||||
|
||||
void tick(float deltaSeconds);
|
||||
|
||||
bool isDue(Timer timer) const;
|
||||
bool consumeDue(Timer timer);
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
void markImmediateRefresh();
|
||||
void markWalletMutationRefresh();
|
||||
void resetTxAge();
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
private:
|
||||
struct Timers {
|
||||
float core = 0.0f;
|
||||
float transactions = 0.0f;
|
||||
float addresses = 0.0f;
|
||||
float peers = 0.0f;
|
||||
float price = 0.0f;
|
||||
float fast = 0.0f;
|
||||
float txAge = 0.0f;
|
||||
float opid = 0.0f;
|
||||
};
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,127 @@
|
||||
#include "wallet_security_controller.h"
|
||||
#include "../util/secure_vault.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityController::~WalletSecurityController()
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
}
|
||||
|
||||
void WalletSecurityController::beginDeferredEncryption(std::string passphrase, std::string pin)
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
deferred_.passphrase = std::move(passphrase);
|
||||
deferred_.pin = std::move(pin);
|
||||
deferred_.pending = true;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionSnapshot WalletSecurityController::deferredEncryption() const
|
||||
{
|
||||
return {deferred_.passphrase, deferred_.pin};
|
||||
}
|
||||
|
||||
bool WalletSecurityController::shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds)
|
||||
{
|
||||
if (!deferred_.pending) return false;
|
||||
if (nowSeconds - deferred_.lastConnectAttempt < minIntervalSeconds) return false;
|
||||
deferred_.lastConnectAttempt = nowSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WalletSecurityController::clearDeferredEncryption()
|
||||
{
|
||||
secureClear(deferred_.passphrase);
|
||||
secureClear(deferred_.pin);
|
||||
deferred_.pending = false;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionResult WalletSecurityController::runDeferredEncryption(
|
||||
DeferredEncryptionSnapshot request, RpcGateway& rpc, VaultGateway* vault)
|
||||
{
|
||||
DeferredEncryptionResult result;
|
||||
result.pinProvided = !request.pin.empty();
|
||||
|
||||
std::string error;
|
||||
if (!rpc.encryptWallet(request.passphrase, error)) {
|
||||
result.error = error.empty() ? "encryptwallet failed" : error;
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.encrypted = true;
|
||||
result.restartRequired = true;
|
||||
if (result.pinProvided && vault) {
|
||||
result.pinStored = vault->storePin(request.pin, request.passphrase);
|
||||
}
|
||||
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletSecurityController::PinValidationResult WalletSecurityController::validatePinSetup(
|
||||
const std::string& pin, const std::string& confirmation, bool allowEmpty, std::size_t minLength)
|
||||
{
|
||||
if (pin.empty() && confirmation.empty()) {
|
||||
return allowEmpty
|
||||
? PinValidationResult{true, PinValidationError::None, ""}
|
||||
: PinValidationResult{false, PinValidationError::Empty, "PIN is required"};
|
||||
}
|
||||
if (pin != confirmation) {
|
||||
return {false, PinValidationError::Mismatch, "PINs do not match"};
|
||||
}
|
||||
if (pin.size() < minLength) {
|
||||
return {false, PinValidationError::TooShort, "PIN is too short"};
|
||||
}
|
||||
for (unsigned char c : pin) {
|
||||
if (!std::isdigit(c)) {
|
||||
return {false, PinValidationError::NonDigit, "PIN must contain only digits"};
|
||||
}
|
||||
}
|
||||
return {true, PinValidationError::None, ""};
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyAddress(const std::string& address)
|
||||
{
|
||||
return !address.empty() && address[0] == 'z' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(const std::string& key)
|
||||
{
|
||||
return !key.empty() && key[0] == 's' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
const char* WalletSecurityController::importSuccessMessage(KeyKind kind)
|
||||
{
|
||||
return kind == KeyKind::Shielded
|
||||
? "Z-address key imported successfully. Wallet is rescanning."
|
||||
: "T-address key imported successfully. Wallet is rescanning.";
|
||||
}
|
||||
|
||||
std::string WalletSecurityController::decryptExportFileName(std::uint64_t timestampSeconds)
|
||||
{
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "obsidiandecryptexport%llu",
|
||||
static_cast<unsigned long long>(timestampSeconds));
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
void WalletSecurityController::secureClear(std::string& value)
|
||||
{
|
||||
if (!value.empty()) {
|
||||
util::SecureVault::secureZero(&value[0], value.size());
|
||||
value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityController {
|
||||
public:
|
||||
enum class PinValidationError {
|
||||
None,
|
||||
Empty,
|
||||
Mismatch,
|
||||
TooShort,
|
||||
NonDigit
|
||||
};
|
||||
|
||||
struct PinValidationResult {
|
||||
bool ok = false;
|
||||
PinValidationError error = PinValidationError::None;
|
||||
const char* message = "";
|
||||
};
|
||||
|
||||
struct DeferredEncryptionSnapshot {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool encryptWallet(const std::string& passphrase, std::string& error) = 0;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class VaultGateway {
|
||||
public:
|
||||
virtual ~VaultGateway() = default;
|
||||
virtual bool storePin(const std::string& pin, const std::string& passphrase) = 0;
|
||||
};
|
||||
|
||||
enum class KeyKind {
|
||||
Transparent,
|
||||
Shielded
|
||||
};
|
||||
|
||||
struct DeferredEncryptionResult {
|
||||
bool encrypted = false;
|
||||
bool pinProvided = false;
|
||||
bool pinStored = false;
|
||||
bool restartRequired = false;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
~WalletSecurityController();
|
||||
|
||||
void beginDeferredEncryption(std::string passphrase, std::string pin = {});
|
||||
bool hasDeferredEncryption() const { return deferred_.pending; }
|
||||
DeferredEncryptionSnapshot deferredEncryption() const;
|
||||
bool shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds = 3.0);
|
||||
void clearDeferredEncryption();
|
||||
|
||||
DeferredEncryptionResult runDeferredEncryption(DeferredEncryptionSnapshot request,
|
||||
RpcGateway& rpc,
|
||||
VaultGateway* vault);
|
||||
|
||||
static PinValidationResult validatePinSetup(const std::string& pin,
|
||||
const std::string& confirmation,
|
||||
bool allowEmpty = false,
|
||||
std::size_t minLength = 4);
|
||||
static KeyKind classifyAddress(const std::string& address);
|
||||
static KeyKind classifyPrivateKey(const std::string& key);
|
||||
static const char* importSuccessMessage(KeyKind kind);
|
||||
static std::string decryptExportFileName(std::uint64_t timestampSeconds);
|
||||
static void secureClear(std::string& value);
|
||||
|
||||
private:
|
||||
struct DeferredEncryptionState {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
bool pending = false;
|
||||
double lastConnectAttempt = -10.0;
|
||||
};
|
||||
|
||||
DeferredEncryptionState deferred_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
void WalletSecurityWorkflow::reset()
|
||||
{
|
||||
state_ = {};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::start(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = stepStatus(DecryptStep::Unlock);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
state_.overallStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = step;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::failEntry(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::PassphraseEntry;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::fail(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::Error;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::closeDialogForImport()
|
||||
{
|
||||
state_.inProgress = false;
|
||||
state_.importActive = true;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::finishImport()
|
||||
{
|
||||
state_.importActive = false;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::WalletFilePlan WalletSecurityWorkflow::planWalletFiles(
|
||||
const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds)
|
||||
{
|
||||
WalletFilePlan plan;
|
||||
plan.dataDir = dataDir;
|
||||
plan.exportFile = WalletSecurityController::decryptExportFileName(timestampSeconds);
|
||||
plan.exportPath = dataDir + plan.exportFile;
|
||||
plan.walletPath = dataDir + "wallet.dat";
|
||||
plan.backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
return plan;
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepStatus(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet...";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys...";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon...";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet...";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon...";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys...";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepLabel(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::DecryptStep WalletSecurityWorkflow::stepFromIndex(int step)
|
||||
{
|
||||
if (step <= 0) return DecryptStep::Unlock;
|
||||
if (step == 1) return DecryptStep::ExportKeys;
|
||||
if (step == 2) return DecryptStep::StopDaemon;
|
||||
if (step == 3) return DecryptStep::BackupWallet;
|
||||
if (step == 4) return DecryptStep::RestartDaemon;
|
||||
return DecryptStep::ImportKeys;
|
||||
}
|
||||
|
||||
bool WalletSecurityWorkflow::stepIsComplete(DecryptStep current, DecryptStep candidate)
|
||||
{
|
||||
return stepIndex(candidate) < stepIndex(current);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_controller.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflow {
|
||||
public:
|
||||
enum class DecryptPhase {
|
||||
PassphraseEntry = 0,
|
||||
Working = 1,
|
||||
Success = 2,
|
||||
Error = 3
|
||||
};
|
||||
|
||||
enum class DecryptStep {
|
||||
Unlock = 0,
|
||||
ExportKeys = 1,
|
||||
StopDaemon = 2,
|
||||
BackupWallet = 3,
|
||||
RestartDaemon = 4,
|
||||
ImportKeys = 5
|
||||
};
|
||||
|
||||
struct DecryptSnapshot {
|
||||
DecryptPhase phase = DecryptPhase::PassphraseEntry;
|
||||
DecryptStep step = DecryptStep::Unlock;
|
||||
std::string status;
|
||||
bool inProgress = false;
|
||||
bool importActive = false;
|
||||
std::chrono::steady_clock::time_point stepStarted{};
|
||||
std::chrono::steady_clock::time_point overallStarted{};
|
||||
};
|
||||
|
||||
struct WalletFilePlan {
|
||||
std::string dataDir;
|
||||
std::string exportFile;
|
||||
std::string exportPath;
|
||||
std::string walletPath;
|
||||
std::string backupPath;
|
||||
};
|
||||
|
||||
void reset();
|
||||
void start(std::chrono::steady_clock::time_point now);
|
||||
void advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
void failEntry(std::string status);
|
||||
void fail(std::string status);
|
||||
void closeDialogForImport();
|
||||
void finishImport();
|
||||
|
||||
DecryptSnapshot snapshot() const { return state_; }
|
||||
DecryptPhase phase() const { return state_.phase; }
|
||||
DecryptStep step() const { return state_.step; }
|
||||
const std::string& status() const { return state_.status; }
|
||||
bool inProgress() const { return state_.inProgress; }
|
||||
bool importActive() const { return state_.importActive; }
|
||||
bool canClose() const { return state_.phase != DecryptPhase::Working; }
|
||||
|
||||
static WalletFilePlan planWalletFiles(const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds);
|
||||
static const char* stepStatus(DecryptStep step);
|
||||
static const char* stepLabel(DecryptStep step);
|
||||
static int stepIndex(DecryptStep step) { return static_cast<int>(step); }
|
||||
static DecryptStep stepFromIndex(int step);
|
||||
static bool stepIsComplete(DecryptStep current, DecryptStep candidate);
|
||||
|
||||
private:
|
||||
DecryptSnapshot state_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,104 @@
|
||||
#include "wallet_security_workflow_executor.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::unlockWallet(
|
||||
const std::string& passphrase, RpcGateway& rpc, int timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!rpc.unlockWallet(passphrase, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Incorrect passphrase" : error, true};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::ExportOutcome WalletSecurityWorkflowExecutor::exportWallet(
|
||||
RpcGateway& rpc, FileGateway& files, std::uint64_t timestampSeconds, long timeoutSeconds)
|
||||
{
|
||||
ExportOutcome outcome;
|
||||
outcome.filePlan = WalletSecurityWorkflow::planWalletFiles(files.dataDir(), timestampSeconds);
|
||||
|
||||
std::string error;
|
||||
if (!rpc.exportWallet(outcome.filePlan.exportFile, timeoutSeconds, error)) {
|
||||
outcome.ok = false;
|
||||
outcome.error = error.empty() ? "Export failed" : "Export failed: " + error;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::stopDaemon(RpcGateway& rpc)
|
||||
{
|
||||
std::string error;
|
||||
(void)rpc.requestDaemonStop(error);
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::backupEncryptedWallet(
|
||||
FileGateway& files, const WalletFilePlan& filePlan)
|
||||
{
|
||||
std::string error;
|
||||
if (!files.backupEncryptedWallet(filePlan, error)) {
|
||||
return {false, error.empty() ? "Failed to rename wallet.dat" : "Failed to rename wallet.dat: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
DaemonGateway& daemon, RpcGateway& rpc, int preRestartDelayMs,
|
||||
int embeddedRestartSettleMs, int maxProbeSeconds)
|
||||
{
|
||||
auto waitForMs = [&](int milliseconds) -> bool {
|
||||
int remaining = milliseconds;
|
||||
while (remaining > 0 && !daemon.cancelled() && !daemon.shuttingDown()) {
|
||||
int slice = remaining >= 100 ? 100 : remaining;
|
||||
daemon.sleepForMs(slice);
|
||||
remaining -= slice;
|
||||
}
|
||||
return !daemon.cancelled() && !daemon.shuttingDown();
|
||||
};
|
||||
|
||||
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
|
||||
|
||||
if (daemon.isUsingEmbeddedDaemon()) {
|
||||
daemon.stopEmbeddedDaemon();
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
|
||||
daemon.startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
bool daemonUp = false;
|
||||
std::string lastError;
|
||||
for (int i = 0; i < maxProbeSeconds && !daemon.cancelled() && !daemon.shuttingDown(); ++i) {
|
||||
daemon.sleepForMs(1000);
|
||||
if (rpc.probeDaemon(lastError)) {
|
||||
daemonUp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!daemonUp) return {false, "Daemon failed to restart", false};
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::importWallet(
|
||||
ImportGateway& importer, const std::string& exportPath, long timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!importer.importWallet(exportPath, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Key import failed" : "Key import failed: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflowExecutor::cleanupVaultAndPin(const VaultCleanupGateway& cleanup)
|
||||
{
|
||||
if (cleanup) cleanup();
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflowExecutor {
|
||||
public:
|
||||
using WalletFilePlan = WalletSecurityWorkflow::WalletFilePlan;
|
||||
|
||||
struct Outcome {
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
bool passphraseRejected = false;
|
||||
};
|
||||
|
||||
struct ExportOutcome : Outcome {
|
||||
WalletFilePlan filePlan;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool requestDaemonStop(std::string& error) = 0;
|
||||
virtual bool probeDaemon(std::string& error) = 0;
|
||||
};
|
||||
|
||||
class ImportGateway {
|
||||
public:
|
||||
virtual ~ImportGateway() = default;
|
||||
virtual bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class FileGateway {
|
||||
public:
|
||||
virtual ~FileGateway() = default;
|
||||
virtual std::string dataDir() = 0;
|
||||
virtual bool backupEncryptedWallet(const WalletFilePlan& filePlan, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class DaemonGateway {
|
||||
public:
|
||||
virtual ~DaemonGateway() = default;
|
||||
virtual bool isUsingEmbeddedDaemon() const = 0;
|
||||
virtual void stopEmbeddedDaemon() = 0;
|
||||
virtual bool startEmbeddedDaemon() = 0;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
using VaultCleanupGateway = std::function<void()>;
|
||||
|
||||
static Outcome unlockWallet(const std::string& passphrase,
|
||||
RpcGateway& rpc,
|
||||
int timeoutSeconds = 600);
|
||||
static ExportOutcome exportWallet(RpcGateway& rpc,
|
||||
FileGateway& files,
|
||||
std::uint64_t timestampSeconds,
|
||||
long timeoutSeconds = 300L);
|
||||
static Outcome stopDaemon(RpcGateway& rpc);
|
||||
static Outcome backupEncryptedWallet(FileGateway& files,
|
||||
const WalletFilePlan& filePlan);
|
||||
static Outcome restartDaemonAndWait(DaemonGateway& daemon,
|
||||
RpcGateway& rpc,
|
||||
int preRestartDelayMs = 2000,
|
||||
int embeddedRestartSettleMs = 1000,
|
||||
int maxProbeSeconds = 60);
|
||||
static Outcome importWallet(ImportGateway& importer,
|
||||
const std::string& exportPath,
|
||||
long timeoutSeconds = 1200L);
|
||||
static void cleanupVaultAndPin(const VaultCleanupGateway& cleanup);
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,362 @@
|
||||
#include "explorer_block_cache.h"
|
||||
|
||||
#include "../../util/logger.h"
|
||||
#include "../../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kCacheSchemaVersion = 1;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block)
|
||||
{
|
||||
if (!source.is_object()) return false;
|
||||
try {
|
||||
block.height = source.value("height", 0);
|
||||
block.hash = source.value("hash", std::string());
|
||||
block.tx_count = source.value("tx_count", 0);
|
||||
block.size = source.value("size", 0);
|
||||
block.time = source.value("time", static_cast<std::int64_t>(0));
|
||||
block.difficulty = source.value("difficulty", 0.0);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return block.height > 0 && !block.hash.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache()
|
||||
: ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath())
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath)
|
||||
: database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath))
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::~ExplorerBlockCache()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultLegacyJsonPath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
migrateLegacyJsonIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
|
||||
{
|
||||
std::map<int, ExplorerBlockSummary> blocks;
|
||||
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
|
||||
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) 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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_blocks "
|
||||
"(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, block.height);
|
||||
if (!bindText(statement.handle, 2, block.hash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 3, block.tx_count);
|
||||
sqlite3_bind_int(statement.handle, 4, block.size);
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(block.time));
|
||||
sqlite3_bind_double(statement.handle, 6, block.difficulty);
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::cachedBlockCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
}
|
||||
|
||||
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
int currentHeight, const std::string& currentBestHash)
|
||||
{
|
||||
SavedTipValidation validation;
|
||||
if (currentHeight <= 0 || !ensureOpen()) return validation;
|
||||
|
||||
int savedHeight = getMetaInt("tip_height", 0);
|
||||
std::string savedHash = getMetaValue("tip_hash");
|
||||
|
||||
if (savedHeight <= 0 || savedHash.empty()) {
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight < savedHeight) {
|
||||
clearBlocks();
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
else updateTip(0, std::string());
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight == savedHeight) {
|
||||
if (currentBestHash.empty()) return validation;
|
||||
if (currentBestHash != savedHash) clearBlocks();
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentBestHash.empty()) return validation;
|
||||
|
||||
validation.needed = true;
|
||||
validation.height = savedHeight;
|
||||
validation.expectedHash = savedHash;
|
||||
return validation;
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash)
|
||||
{
|
||||
if (!validation.needed || !ensureOpen()) return;
|
||||
if (actualHash.empty()) return;
|
||||
|
||||
if (actualHash != validation.expectedHash) {
|
||||
clearBlocks();
|
||||
}
|
||||
|
||||
if (currentHeight > 0 && !currentBestHash.empty()) {
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
setMetaValue("tip_height", std::to_string(std::max(0, height)));
|
||||
setMetaValue("tip_hash", hash);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::getMetaValue(const std::string& key)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, key)) return {};
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return {};
|
||||
|
||||
const unsigned char* valueText = sqlite3_column_text(statement.handle, 0);
|
||||
return valueText ? reinterpret_cast<const char*>(valueText) : std::string();
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
|
||||
{
|
||||
std::string value = getMetaValue(key);
|
||||
if (value.empty()) return fallback;
|
||||
try {
|
||||
return std::stoi(value);
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS explorer_blocks ("
|
||||
"height INTEGER PRIMARY KEY,"
|
||||
"hash TEXT NOT NULL,"
|
||||
"tx_count INTEGER NOT NULL,"
|
||||
"size INTEGER NOT NULL,"
|
||||
"time INTEGER NOT NULL,"
|
||||
"difficulty REAL NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta ("
|
||||
"key TEXT PRIMARY KEY,"
|
||||
"value TEXT NOT NULL)") &&
|
||||
exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES "
|
||||
"('schema_version', '1')");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
|
||||
{
|
||||
if (!db_ || getMetaValue("json_migrated") == "1") return;
|
||||
|
||||
bool migrated = false;
|
||||
try {
|
||||
if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) {
|
||||
std::ifstream file(legacy_json_path_);
|
||||
json cache;
|
||||
file >> cache;
|
||||
|
||||
if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) {
|
||||
exec("BEGIN IMMEDIATE TRANSACTION");
|
||||
if (cache.contains("blocks") && cache["blocks"].is_array()) {
|
||||
for (const auto& entry : cache["blocks"]) {
|
||||
ExplorerBlockSummary block;
|
||||
if (blockSummaryFromJson(entry, block)) {
|
||||
storeBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
exec("COMMIT");
|
||||
|
||||
int tipHeight = cache.value("tip_height", 0);
|
||||
std::string tipHash = cache.value("tip_hash", std::string());
|
||||
if (tipHeight > 0 && !tipHash.empty()) {
|
||||
updateTip(tipHeight, tipHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = true;
|
||||
} catch (const std::exception& e) {
|
||||
exec("ROLLBACK");
|
||||
DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what());
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) setMetaValue("json_migrated", "1");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ExplorerBlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
std::int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
|
||||
class ExplorerBlockCache {
|
||||
public:
|
||||
struct SavedTipValidation {
|
||||
bool needed = false;
|
||||
int height = 0;
|
||||
std::string expectedHash;
|
||||
};
|
||||
|
||||
ExplorerBlockCache();
|
||||
ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath);
|
||||
~ExplorerBlockCache();
|
||||
|
||||
ExplorerBlockCache(const ExplorerBlockCache&) = delete;
|
||||
ExplorerBlockCache& operator=(const ExplorerBlockCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string defaultLegacyJsonPath();
|
||||
|
||||
bool ensureOpen();
|
||||
bool isOpen() const { return db_ != nullptr; }
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
|
||||
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
|
||||
void applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash);
|
||||
void updateTip(int height, const std::string& hash);
|
||||
|
||||
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 createSchema();
|
||||
void migrateLegacyJsonIfNeeded();
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::string legacy_json_path_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
+15
-1
@@ -432,6 +432,20 @@ inline float columnOffset(float ratio, float availW) {
|
||||
return availW * ratio;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs
|
||||
// ============================================================================
|
||||
|
||||
inline float kDialogDefaultWidth() { return schema::UI().drawElement("dialog", "width-default").sizeOr(480.0f) * dpiScale(); }
|
||||
inline float kDialogLargeWidth() { return schema::UI().drawElement("dialog", "width-lg").sizeOr(600.0f) * dpiScale(); }
|
||||
inline float kDialogExtraLargeWidth() { return schema::UI().drawElement("dialog", "width-xl").sizeOr(660.0f) * dpiScale(); }
|
||||
inline float kDialogMinWidth() { return schema::UI().drawElement("dialog", "min-width").sizeOr(280.0f) * dpiScale(); }
|
||||
inline float kDialogFormWidth() { return schema::UI().drawElement("dialog", "form-width").sizeOr(400.0f) * dpiScale(); }
|
||||
inline float kDialogActionWidth() { return schema::UI().drawElement("dialog", "action-width").sizeOr(100.0f) * dpiScale(); }
|
||||
inline float kDialogActionGap() { return schema::UI().drawElement("dialog", "action-gap").sizeOr(8.0f) * dpiScale(); }
|
||||
inline float kDialogMaxHeightRatio() { return schema::UI().drawElement("dialog", "max-height-ratio").sizeOr(0.94f); }
|
||||
inline float kDialogCompactBottomRatio() { return schema::UI().drawElement("dialog", "compact-bottom-ratio").sizeOr(0.64f); }
|
||||
|
||||
// ============================================================================
|
||||
// Buttons
|
||||
// ============================================================================
|
||||
@@ -562,7 +576,7 @@ inline float mainCardTargetH(float formW, float vs) {
|
||||
float innerW = formW - pad * 2;
|
||||
float qrColW = innerW * 0.35f;
|
||||
float qrPad = spacingMd();
|
||||
float maxQrSz = std::min(qrColW - qrPad * 2, 280.0f * dp);
|
||||
float maxQrSz = std::min(std::max(0.0f, qrColW - qrPad * 2), 280.0f * dp);
|
||||
float qrSize = std::max(100.0f * dp, maxQrSz);
|
||||
float totalQr = qrSize + qrPad * 2;
|
||||
float innerGap = spacingLg();
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -90,10 +91,40 @@ inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col,
|
||||
// and will return true even when a modal popup covers the rect, which
|
||||
// causes background elements to show hover highlights through dialogs.
|
||||
|
||||
inline int& OverlayDialogActiveFrame()
|
||||
{
|
||||
static int s_frame = -1;
|
||||
return s_frame;
|
||||
}
|
||||
|
||||
inline void MarkOverlayDialogActive()
|
||||
{
|
||||
OverlayDialogActiveFrame() = ImGui::GetFrameCount();
|
||||
}
|
||||
|
||||
inline bool IsCurrentWindowOverlayDialog()
|
||||
{
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
for (ImGuiWindow* node = window; node; node = node->ParentWindow) {
|
||||
if (node->Name && strcmp(node->Name, "##OverlayScrim") == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool IsOverlayDialogBlockingInput()
|
||||
{
|
||||
int activeFrame = OverlayDialogActiveFrame();
|
||||
int currentFrame = ImGui::GetFrameCount();
|
||||
return activeFrame == currentFrame || activeFrame == (currentFrame - 1);
|
||||
}
|
||||
|
||||
inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
|
||||
{
|
||||
if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
|
||||
return false;
|
||||
if (IsOverlayDialogBlockingInput() && !IsCurrentWindowOverlayDialog())
|
||||
return false;
|
||||
// If a modal popup is open and it is not the current window, treat
|
||||
// the content as non-hoverable (same logic ImGui uses internally
|
||||
// inside IsWindowContentHoverable for modal blocking).
|
||||
@@ -885,8 +916,11 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
// Creates a fullscreen semi-transparent overlay with a centered card dialog.
|
||||
// Similar to the shutdown screen pattern but for interactive dialogs.
|
||||
|
||||
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f)
|
||||
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f,
|
||||
float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr)
|
||||
{
|
||||
MarkOverlayDialogActive();
|
||||
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
ImVec2 vp_pos = vp->Pos;
|
||||
ImVec2 vp_size = vp->Size;
|
||||
@@ -900,7 +934,16 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
|
||||
bool opened = ImGui::Begin("##OverlayScrim", nullptr,
|
||||
std::string scrimId = "##OverlayScrim";
|
||||
std::string childId = "##OverlayDialogContent";
|
||||
if (idSuffix && idSuffix[0] != '\0') {
|
||||
scrimId += "_";
|
||||
scrimId += idSuffix;
|
||||
childId += "_";
|
||||
childId += idSuffix;
|
||||
}
|
||||
|
||||
bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav |
|
||||
@@ -914,6 +957,14 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
}
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
// Consume pointer input on the scrim so the overlay owns clicks and wheel
|
||||
// events even when the click lands outside the card content.
|
||||
ImGui::SetCursorScreenPos(vp_pos);
|
||||
ImGui::InvisibleButton("##OverlayInputBlocker", vp_size,
|
||||
ImGuiButtonFlags_MouseButtonLeft |
|
||||
ImGuiButtonFlags_MouseButtonRight |
|
||||
ImGuiButtonFlags_MouseButtonMiddle);
|
||||
|
||||
// Calculate card position (centered)
|
||||
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
||||
@@ -921,7 +972,7 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
|
||||
// Draw glass card background
|
||||
ImVec2 cardMin(cardX, cardY);
|
||||
ImVec2 cardMax(cardX + cardWidth, vp_pos.y + vp_size.y * 0.85f);
|
||||
ImVec2 cardMax(cardX + cardWidth, vp_pos.y + vp_size.y * cardBottomViewportRatio);
|
||||
|
||||
// Card background with glass effect
|
||||
GlassPanelSpec cardGlass;
|
||||
@@ -930,6 +981,11 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
cardGlass.borderAlpha = 50;
|
||||
cardGlass.borderWidth = 1.0f;
|
||||
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
||||
|
||||
if (p_open && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
!ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) {
|
||||
*p_open = false;
|
||||
}
|
||||
|
||||
// Set up child region for card content
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
||||
@@ -937,7 +993,7 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn
|
||||
|
||||
bool childVisible = ImGui::BeginChild("##OverlayDialogContent",
|
||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||
ImVec2(cardWidth, 0), // 0 height = auto-size
|
||||
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
@@ -961,6 +1017,23 @@ inline void EndOverlayDialog()
|
||||
ImGui::PopStyleColor(); // WindowBg scrim
|
||||
}
|
||||
|
||||
inline void PlaceOverlayDialogActions(float totalWidth)
|
||||
{
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalWidth) * 0.5f));
|
||||
}
|
||||
|
||||
inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator = true)
|
||||
{
|
||||
ImGui::Spacing();
|
||||
if (drawSeparator) {
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
PlaceOverlayDialogActions(totalActionWidth);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "typography.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
namespace project_icons {
|
||||
|
||||
struct ProjectIcon {
|
||||
const char* name;
|
||||
const char* glyph;
|
||||
};
|
||||
|
||||
inline constexpr const char* kPickaxeName = "pickaxe";
|
||||
inline constexpr const char* kPickaxeGlyph = "\xEE\x80\x81";
|
||||
|
||||
inline constexpr ProjectIcon kWalletIcons[] = {
|
||||
{"savings", ICON_MD_SAVINGS}, {"account_balance", ICON_MD_ACCOUNT_BALANCE}, {"account_balance_wallet", ICON_MD_ACCOUNT_BALANCE_WALLET}, {"wallet", ICON_MD_WALLET},
|
||||
{"payments", ICON_MD_PAYMENTS}, {"credit_card", ICON_MD_CREDIT_CARD}, {"local_atm", ICON_MD_LOCAL_ATM}, {"diamond", ICON_MD_DIAMOND},
|
||||
{"attach_money", ICON_MD_ATTACH_MONEY}, {"currency_bitcoin", ICON_MD_CURRENCY_BITCOIN}, {"currency_exchange", ICON_MD_CURRENCY_EXCHANGE}, {"balance", ICON_MD_BALANCE},
|
||||
{"calculate", ICON_MD_CALCULATE}, {"trending_up", ICON_MD_TRENDING_UP}, {"euro", ICON_MD_EURO}, {"leaderboard", ICON_MD_LEADERBOARD},
|
||||
{"paid", ICON_MD_PAID}, {"sell", ICON_MD_SELL}, {"receipt", ICON_MD_RECEIPT}, {"percent", ICON_MD_PERCENT},
|
||||
{"price_change", ICON_MD_PRICE_CHANGE}, {"price_check", ICON_MD_PRICE_CHECK}, {"toll", ICON_MD_TOLL}, {"money", ICON_MD_MONEY},
|
||||
{"show_chart", ICON_MD_SHOW_CHART}, {"candlestick_chart", ICON_MD_CANDLESTICK_CHART}, {"bar_chart", ICON_MD_BAR_CHART}, {"pie_chart", ICON_MD_PIE_CHART},
|
||||
{"area_chart", ICON_MD_AREA_CHART}, {"stacked_bar_chart", ICON_MD_STACKED_BAR_CHART}, {"waterfall_chart", ICON_MD_WATERFALL_CHART}, {"scatter_plot", ICON_MD_SCATTER_PLOT},
|
||||
{"query_stats", ICON_MD_QUERY_STATS}, {"speed", ICON_MD_SPEED}, {"donut_large", ICON_MD_DONUT_LARGE},
|
||||
{kPickaxeName, nullptr},
|
||||
{"hardware", ICON_MD_HARDWARE}, {"construction", ICON_MD_CONSTRUCTION}, {"handyman", ICON_MD_HANDYMAN}, {"build", ICON_MD_BUILD},
|
||||
{"carpenter", ICON_MD_CARPENTER}, {"plumbing", ICON_MD_PLUMBING}, {"home_repair_service", ICON_MD_HOME_REPAIR_SERVICE}, {"precision_manufacturing", ICON_MD_PRECISION_MANUFACTURING},
|
||||
{"factory", ICON_MD_FACTORY}, {"warehouse", ICON_MD_WAREHOUSE}, {"inventory", ICON_MD_INVENTORY}, {"recycling", ICON_MD_RECYCLING},
|
||||
{"oil_barrel", ICON_MD_OIL_BARREL}, {"offline_bolt", ICON_MD_OFFLINE_BOLT}, {"thunderstorm", ICON_MD_THUNDERSTORM}, {"terminal", ICON_MD_TERMINAL},
|
||||
{"storage", ICON_MD_STORAGE}, {"memory", ICON_MD_MEMORY}, {"developer_board", ICON_MD_DEVELOPER_BOARD},
|
||||
{"shield", ICON_MD_SHIELD}, {"security", ICON_MD_SECURITY}, {"lock", ICON_MD_LOCK}, {"swap_horiz", ICON_MD_SWAP_HORIZ},
|
||||
{"verified", ICON_MD_VERIFIED}, {"verified_user", ICON_MD_VERIFIED_USER}, {"key", ICON_MD_KEY}, {"badge", ICON_MD_BADGE},
|
||||
{"store", ICON_MD_STORE}, {"storefront", ICON_MD_STOREFRONT}, {"shopping_bag", ICON_MD_SHOPPING_BAG}, {"business", ICON_MD_BUSINESS},
|
||||
{"work", ICON_MD_WORK}, {"real_estate_agent", ICON_MD_REAL_ESTATE_AGENT}, {"gavel", ICON_MD_GAVEL}, {"local_shipping", ICON_MD_LOCAL_SHIPPING},
|
||||
{"home", ICON_MD_HOME}, {"apartment", ICON_MD_APARTMENT}, {"cottage", ICON_MD_COTTAGE}, {"landscape", ICON_MD_LANDSCAPE},
|
||||
{"account_circle", ICON_MD_ACCOUNT_CIRCLE}, {"face", ICON_MD_FACE}, {"manage_accounts", ICON_MD_MANAGE_ACCOUNTS}, {"groups", ICON_MD_GROUPS}, {"mood", ICON_MD_MOOD},
|
||||
{"rocket_launch", ICON_MD_ROCKET_LAUNCH}, {"flight", ICON_MD_FLIGHT}, {"directions_car", ICON_MD_DIRECTIONS_CAR}, {"travel_explore", ICON_MD_TRAVEL_EXPLORE},
|
||||
{"explore", ICON_MD_EXPLORE}, {"location_on", ICON_MD_LOCATION_ON}, {"map", ICON_MD_MAP}, {"luggage", ICON_MD_LUGGAGE}, {"anchor", ICON_MD_ANCHOR},
|
||||
{"public", ICON_MD_PUBLIC}, {"language", ICON_MD_LANGUAGE}, {"forest", ICON_MD_FOREST}, {"park", ICON_MD_PARK},
|
||||
{"water_drop", ICON_MD_WATER_DROP}, {"beach_access", ICON_MD_BEACH_ACCESS}, {"energy_savings_leaf", ICON_MD_ENERGY_SAVINGS_LEAF}, {"solar_power", ICON_MD_SOLAR_POWER},
|
||||
{"favorite", ICON_MD_FAVORITE}, {"star", ICON_MD_STAR}, {"celebration", ICON_MD_CELEBRATION}, {"casino", ICON_MD_CASINO},
|
||||
{"auto_awesome", ICON_MD_AUTO_AWESOME}, {"emoji_events", ICON_MD_EMOJI_EVENTS}, {"military_tech", ICON_MD_MILITARY_TECH}, {"flag", ICON_MD_FLAG},
|
||||
{"bolt", ICON_MD_BOLT}, {"tungsten", ICON_MD_TUNGSTEN}, {"lightbulb", ICON_MD_LIGHTBULB}, {"insights", ICON_MD_INSIGHTS},
|
||||
{"hub", ICON_MD_HUB}, {"token", ICON_MD_TOKEN}, {"electric_bolt", ICON_MD_ELECTRIC_BOLT}, {"science", ICON_MD_SCIENCE}, {"biotech", ICON_MD_BIOTECH},
|
||||
{"category", ICON_MD_CATEGORY}, {"label", ICON_MD_LABEL}, {"school", ICON_MD_SCHOOL}, {"local_hospital", ICON_MD_LOCAL_HOSPITAL}, {"local_florist", ICON_MD_LOCAL_FLORIST},
|
||||
{"coffee", ICON_MD_LOCAL_CAFE}, {"restaurant", ICON_MD_RESTAURANT}, {"wine_bar", ICON_MD_WINE_BAR}, {"liquor", ICON_MD_LIQUOR},
|
||||
{"outdoor_grill", ICON_MD_OUTDOOR_GRILL}, {"nightlife", ICON_MD_NIGHTLIFE}, {"sports_bar", ICON_MD_SPORTS_BAR},
|
||||
{"pets", ICON_MD_PETS}, {"fitness_center", ICON_MD_FITNESS_CENTER}, {"spa", ICON_MD_SPA}, {"self_improvement", ICON_MD_SELF_IMPROVEMENT},
|
||||
{"psychology", ICON_MD_PSYCHOLOGY}, {"sports_soccer", ICON_MD_SPORTS_SOCCER}, {"sports_esports", ICON_MD_SPORTS_ESPORTS},
|
||||
{"hiking", ICON_MD_HIKING}, {"palette", ICON_MD_PALETTE}, {"museum", ICON_MD_MUSEUM}, {"church", ICON_MD_CHURCH}, {"surfing", ICON_MD_SURFING},
|
||||
{"redeem", ICON_MD_REDEEM}, {"handshake", ICON_MD_HANDSHAKE}, {"healing", ICON_MD_HEALING}, {"volunteer", ICON_MD_VOLUNTEER_ACTIVISM},
|
||||
{"stadium", ICON_MD_STADIUM}, {"temple_buddhist", ICON_MD_TEMPLE_BUDDHIST}, {"theater_comedy", ICON_MD_THEATER_COMEDY}, {"watch", ICON_MD_WATCH},
|
||||
};
|
||||
|
||||
inline int walletIconCount()
|
||||
{
|
||||
return static_cast<int>(sizeof(kWalletIcons) / sizeof(kWalletIcons[0]));
|
||||
}
|
||||
|
||||
inline const char* walletIconName(int index)
|
||||
{
|
||||
return (index >= 0 && index < walletIconCount()) ? kWalletIcons[index].name : "";
|
||||
}
|
||||
|
||||
inline const char* glyphForName(const std::string& name)
|
||||
{
|
||||
for (const auto& icon : kWalletIcons) {
|
||||
if (name == icon.name) return icon.glyph;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline bool drawByName(ImDrawList* drawList,
|
||||
const std::string& name,
|
||||
ImVec2 center,
|
||||
ImU32 color,
|
||||
ImFont* iconFont,
|
||||
float iconFontSize)
|
||||
{
|
||||
if (!drawList) return false;
|
||||
|
||||
if (name == kPickaxeName) {
|
||||
ImFont* pickaxeFont = Typography::instance().pickaxeFontForSize(iconFontSize);
|
||||
if (!pickaxeFont) return false;
|
||||
|
||||
ImVec2 iconSize = pickaxeFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, kPickaxeGlyph);
|
||||
drawList->AddText(pickaxeFont, iconFontSize,
|
||||
ImVec2(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f), color, kPickaxeGlyph);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* glyph = glyphForName(name);
|
||||
if (!glyph || !iconFont) return false;
|
||||
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, glyph);
|
||||
drawList->AddText(iconFont, iconFontSize,
|
||||
ImVec2(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f), color, glyph);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace project_icons
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -101,6 +101,7 @@ bool Typography::reload(ImGuiIO& io, float dpiScale)
|
||||
loaded_ = false;
|
||||
for (int i = 0; i < kNumStyles; ++i) fonts_[i] = nullptr;
|
||||
for (int i = 0; i < kNumIconSizes; ++i) iconFonts_[i] = nullptr;
|
||||
for (int i = 0; i < kNumIconSizes; ++i) pickaxeFonts_[i] = nullptr;
|
||||
return load(io, dpiScale);
|
||||
}
|
||||
|
||||
@@ -190,6 +191,13 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
iconFonts_[2] = loadIconFont(io, 24.0f * scale, "IconLarge");
|
||||
iconFonts_[3] = loadIconFont(io, 40.0f * scale, "IconXL");
|
||||
|
||||
// Load a one-glyph MDI subset for pickaxe. The glyph is remapped onto a
|
||||
// BMP private-use codepoint so it remains renderable with 16-bit ImWchar.
|
||||
pickaxeFonts_[0] = loadPickaxeFont(io, 14.0f * scale, "PickaxeSmall");
|
||||
pickaxeFonts_[1] = loadPickaxeFont(io, 18.0f * scale, "PickaxeMed");
|
||||
pickaxeFonts_[2] = loadPickaxeFont(io, 24.0f * scale, "PickaxeLarge");
|
||||
pickaxeFonts_[3] = loadPickaxeFont(io, 40.0f * scale, "PickaxeXL");
|
||||
|
||||
// Verify all fonts loaded
|
||||
bool allLoaded = true;
|
||||
for (int i = 0; i < kNumStyles; ++i) {
|
||||
@@ -346,6 +354,51 @@ ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
|
||||
return font;
|
||||
}
|
||||
|
||||
ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name)
|
||||
{
|
||||
if (g_mdi_pickaxe_subset_size == 0) {
|
||||
DEBUG_LOGF("Typography: Pickaxe subset font is empty\n");
|
||||
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.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
cfg.MergeMode = false;
|
||||
cfg.GlyphMinAdvanceX = size;
|
||||
|
||||
static const ImWchar pickaxeRange[] = {
|
||||
Typography::kPickaxeCodepoint,
|
||||
Typography::kPickaxeCodepoint,
|
||||
0,
|
||||
};
|
||||
cfg.GlyphRanges = pickaxeRange;
|
||||
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "MDIPickaxe %.0fpx", size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, 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;
|
||||
}
|
||||
|
||||
ImFont* Typography::pickaxeFontForSize(float size) const
|
||||
{
|
||||
if (size <= 15.0f) return pickaxeSmall();
|
||||
if (size <= 20.0f) return pickaxeMed();
|
||||
if (size <= 32.0f) return pickaxeLarge();
|
||||
return pickaxeXL();
|
||||
}
|
||||
|
||||
ImFont* Typography::getFont(TypeStyle style) const
|
||||
{
|
||||
int index = static_cast<int>(style);
|
||||
|
||||
@@ -175,6 +175,14 @@ public:
|
||||
ImFont* iconMed() const { return iconFonts_[1] ? iconFonts_[1] : getFont(TypeStyle::Body1); }
|
||||
ImFont* iconLarge() const { return iconFonts_[2] ? iconFonts_[2] : getFont(TypeStyle::H5); }
|
||||
ImFont* iconXL() const { return iconFonts_[3] ? iconFonts_[3] : getFont(TypeStyle::H3); }
|
||||
ImFont* pickaxeSmall() const { return pickaxeFonts_[0] ? pickaxeFonts_[0] : iconSmall(); }
|
||||
ImFont* pickaxeMed() const { return pickaxeFonts_[1] ? pickaxeFonts_[1] : iconMed(); }
|
||||
ImFont* pickaxeLarge() const { return pickaxeFonts_[2] ? pickaxeFonts_[2] : iconLarge(); }
|
||||
ImFont* pickaxeXL() const { return pickaxeFonts_[3] ? pickaxeFonts_[3] : iconXL(); }
|
||||
|
||||
ImFont* pickaxeFontForSize(float size) const;
|
||||
|
||||
static constexpr ImWchar kPickaxeCodepoint = 0xE001;
|
||||
|
||||
/**
|
||||
* @brief Resolve a font name string to ImFont*
|
||||
@@ -254,10 +262,12 @@ private:
|
||||
|
||||
// Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40)
|
||||
ImFont* iconFonts_[4] = {};
|
||||
ImFont* pickaxeFonts_[4] = {};
|
||||
static constexpr int kNumIconSizes = 4;
|
||||
|
||||
// Load an icon-only font at a specific pixel size
|
||||
ImFont* loadIconFont(ImGuiIO& io, float size, const char* name);
|
||||
ImFont* loadPickaxeFont(ImGuiIO& io, float size, const char* name);
|
||||
|
||||
// Type specifications
|
||||
static const TypeSpec* getTypeSpecs();
|
||||
|
||||
+344
-357
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ bool UISchema::loadFromFile(const std::string& path) {
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
@@ -172,7 +172,7 @@ bool UISchema::loadFromString(const std::string& tomlStr, const std::string& lab
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
@@ -251,7 +251,7 @@ bool UISchema::mergeOverlayFromFile(const std::string& path) {
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -121,33 +122,15 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
|
||||
// Links
|
||||
if (material::StyledButton(TR("about_website"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://dragonx.is");
|
||||
#elif __APPLE__
|
||||
system("open https://dragonx.is");
|
||||
#else
|
||||
system("xdg-open https://dragonx.is &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://dragonx.is");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_github"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
#elif __APPLE__
|
||||
system("open https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
#else
|
||||
system("xdg-open https://git.dragonx.is/dragonx/ObsidianDragon &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://explorer.dragonx.is");
|
||||
#elif __APPLE__
|
||||
system("open https://explorer.dragonx.is");
|
||||
#else
|
||||
system("xdg-open https://explorer.dragonx.is &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://explorer.dragonx.is");
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -35,6 +36,12 @@ static data::AddressBook& getAddressBook() {
|
||||
return *s_address_book;
|
||||
}
|
||||
|
||||
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||
if (destSize == 0) return;
|
||||
std::strncpy(dest, source.c_str(), destSize - 1);
|
||||
dest[destSize - 1] = '\0';
|
||||
}
|
||||
|
||||
void AddressBookDialog::show()
|
||||
{
|
||||
s_open = true;
|
||||
@@ -66,15 +73,101 @@ void AddressBookDialog::render(App* app)
|
||||
auto notesInput = S.input("dialogs.address-book", "notes-input");
|
||||
auto actionBtn = S.button("dialogs.address-book", "action-button");
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
|
||||
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
|
||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||
};
|
||||
|
||||
auto renderEntryDialog = [&]() {
|
||||
bool isEdit = s_show_edit_dialog;
|
||||
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
||||
if (!*open) return;
|
||||
|
||||
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
||||
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
|
||||
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
|
||||
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
|
||||
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
|
||||
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
|
||||
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
|
||||
|
||||
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
|
||||
Layout::kDialogCompactBottomRatio(), id)) {
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputText(isEdit ? "##EditLabel" : "##AddLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputText(isEdit ? "##EditAddress" : "##AddAddress", s_edit_address, sizeof(s_edit_address));
|
||||
if (!isEdit) {
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputTextMultiline(isEdit ? "##EditNotes" : "##AddNotes",
|
||||
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
|
||||
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
float totalActionsW = actionW * 2.0f + actionGap;
|
||||
material::BeginOverlayDialogFooter(totalActionsW);
|
||||
|
||||
if (!canSubmit) ImGui::BeginDisabled();
|
||||
|
||||
const char* primaryLabel = isEdit ? TR("save") : TR("add");
|
||||
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (isEdit) {
|
||||
if (getAddressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
} else {
|
||||
if (getAddressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!canSubmit) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, actionGap);
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
*open = false;
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
};
|
||||
|
||||
if (material::BeginOverlayDialog(TR("address_book_title"), &s_open, win.width, 0.94f)) {
|
||||
auto& book = getAddressBook();
|
||||
|
||||
// Toolbar
|
||||
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = true;
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
clearEditFields();
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
@@ -86,9 +179,7 @@ void AddressBookDialog::render(App* app)
|
||||
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
if (has_selection) {
|
||||
const auto& entry = book.entries()[s_selected_index];
|
||||
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
|
||||
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
|
||||
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
}
|
||||
@@ -153,9 +244,7 @@ void AddressBookDialog::render(App* app)
|
||||
// Double-click to edit
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
s_selected_index = static_cast<int>(i);
|
||||
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
|
||||
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
|
||||
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
|
||||
@@ -186,123 +275,8 @@ void AddressBookDialog::render(App* app)
|
||||
ImGui::TextDisabled(TR("address_book_count"), book.size());
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
// Add dialog
|
||||
if (s_show_add_dialog) {
|
||||
ImGui::OpenPopup("Add Address");
|
||||
}
|
||||
|
||||
// Re-use center for sub-dialogs
|
||||
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
|
||||
if (ImGui::BeginPopupModal("Add Address", &s_show_add_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
material::Type().text(material::TypeStyle::H6, TR("address_book_add"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##AddLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##AddAddress", s_edit_address, sizeof(s_edit_address));
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) {
|
||||
strncpy(s_edit_address, clipboard, sizeof(s_edit_address) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputTextMultiline("##AddNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
bool can_add = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
|
||||
if (!can_add) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("add"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (getAddressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!can_add) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = false;
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// Edit dialog
|
||||
if (s_show_edit_dialog) {
|
||||
ImGui::OpenPopup("Edit Address");
|
||||
}
|
||||
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
|
||||
if (ImGui::BeginPopupModal("Edit Address", &s_show_edit_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
material::Type().text(material::TypeStyle::H6, TR("address_book_edit"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##EditLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##EditAddress", s_edit_address, sizeof(s_edit_address));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputTextMultiline("##EditNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
bool can_save = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
|
||||
if (!can_save) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("save"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (getAddressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!can_save) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_edit_dialog = false;
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
renderEntryDialog();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <cstddef>
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/project_icons.h"
|
||||
#include "../theme.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
@@ -18,12 +21,23 @@ namespace ui {
|
||||
|
||||
class AddressLabelDialog {
|
||||
public:
|
||||
static bool drawIconByName(ImDrawList* dl,
|
||||
const std::string& name,
|
||||
ImVec2 center,
|
||||
float /*emSize*/,
|
||||
ImU32 color,
|
||||
ImFont* iconFont,
|
||||
float iconFontSize) {
|
||||
return material::project_icons::drawByName(dl, name, center, color, iconFont, iconFontSize);
|
||||
}
|
||||
|
||||
static void show(App* app, const std::string& address, bool isZ) {
|
||||
s_open = true;
|
||||
s_app = app;
|
||||
s_address = address;
|
||||
s_isZ = isZ;
|
||||
s_selectedIcon = -1;
|
||||
s_iconSearch[0] = '\0';
|
||||
|
||||
// Pre-fill from existing metadata
|
||||
std::string existing = app->getAddressLabel(address);
|
||||
@@ -31,8 +45,8 @@ public:
|
||||
s_label[sizeof(s_label) - 1] = '\0';
|
||||
|
||||
std::string existingIcon = app->getAddressIcon(address);
|
||||
for (int i = 0; i < kIconCount; ++i) {
|
||||
if (kIconNames[i] == existingIcon) {
|
||||
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
|
||||
if (material::project_icons::walletIconName(i) == existingIcon) {
|
||||
s_selectedIcon = i;
|
||||
break;
|
||||
}
|
||||
@@ -44,7 +58,9 @@ public:
|
||||
|
||||
using namespace material;
|
||||
|
||||
if (BeginOverlayDialog(TR("set_label"), &s_open, 420.0f, 0.92f)) {
|
||||
constexpr float cardTopViewportRatio = 0.15f;
|
||||
constexpr float cardBottomViewportRatio = 0.80f;
|
||||
if (BeginOverlayDialog(TR("set_label"), &s_open, 660.0f, 0.92f, cardBottomViewportRatio)) {
|
||||
float dp = Layout::dpiScale();
|
||||
|
||||
// Address preview
|
||||
@@ -70,16 +86,66 @@ public:
|
||||
Type().text(TypeStyle::Subtitle2, TR("choose_icon"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// Search bar
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::InputTextWithHint("##IconSearch", TR("search_icons"), s_iconSearch, sizeof(s_iconSearch));
|
||||
ImGui::Spacing();
|
||||
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
float iconFsz = ScaledFontSize(iconFont);
|
||||
float cellSz = iconFsz + 16.0f * dp;
|
||||
float avail = ImGui::GetContentRegionAvail().x;
|
||||
int cols = std::max(1, (int)(avail / (cellSz + 4.0f * dp)));
|
||||
|
||||
const char* cancelLabel = TR("cancel");
|
||||
const char* saveLabel = TR("save");
|
||||
ImFont* buttonFont = Type().button();
|
||||
float buttonFontSize = ScaledFontSize(buttonFont);
|
||||
const ImGuiStyle& style = ImGui::GetStyle();
|
||||
const float cardHeight = ImGui::GetMainViewport()->Size.y * (cardBottomViewportRatio - cardTopViewportRatio);
|
||||
const float bottomPadding = style.WindowPadding.y;
|
||||
const bool showClearIcon = (s_selectedIcon >= 0);
|
||||
const float clearRowH = showClearIcon ? ImGui::GetTextLineHeight() : 0.0f;
|
||||
const float buttonRowH = ImGui::GetFrameHeight();
|
||||
const float separatorH = 1.0f;
|
||||
const float preButtonReserve =
|
||||
(showClearIcon ? (style.ItemSpacing.y + clearRowH) : 0.0f) +
|
||||
style.ItemSpacing.y * 3.0f + separatorH;
|
||||
const float buttonY = cardHeight - bottomPadding - buttonRowH;
|
||||
|
||||
// Build filtered index list
|
||||
std::vector<int> visible;
|
||||
visible.reserve(material::project_icons::walletIconCount());
|
||||
{
|
||||
// Simple case-insensitive substring match on icon name
|
||||
std::string needle(s_iconSearch);
|
||||
for (char& c : needle) c = (char)std::tolower((unsigned char)c);
|
||||
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
|
||||
const char* iconName = material::project_icons::walletIconName(i);
|
||||
if (needle.empty() || std::strstr(iconName, needle.c_str()) != nullptr)
|
||||
visible.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
// The grid owns all vertical space above the bottom action band.
|
||||
const float gridStartY = ImGui::GetCursorPosY();
|
||||
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
|
||||
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
|
||||
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
for (int i = 0; i < kIconCount; ++i) {
|
||||
if (i % cols != 0) ImGui::SameLine(0, 4.0f * dp);
|
||||
if (visible.empty()) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_icons_found"));
|
||||
}
|
||||
|
||||
int col = 0;
|
||||
for (int vi = 0; vi < (int)visible.size(); ++vi) {
|
||||
int i = visible[vi];
|
||||
const char* iconName = material::project_icons::walletIconName(i);
|
||||
if (col != 0) ImGui::SameLine(0, 4.0f * dp);
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImVec2 mn = pos;
|
||||
ImVec2 mx(pos.x + cellSz, pos.y + cellSz);
|
||||
@@ -95,22 +161,33 @@ public:
|
||||
}
|
||||
|
||||
// Icon centered in cell
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFsz, 1000.0f, 0.0f, kIconGlyphs[i]);
|
||||
dl->AddText(iconFont, iconFsz,
|
||||
ImVec2(mn.x + (cellSz - iSz.x) * 0.5f, mn.y + (cellSz - iSz.y) * 0.5f),
|
||||
sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()),
|
||||
kIconGlyphs[i]);
|
||||
drawIconByName(dl,
|
||||
iconName,
|
||||
ImVec2(mn.x + cellSz * 0.5f, mn.y + cellSz * 0.5f),
|
||||
iconFsz,
|
||||
sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()),
|
||||
iconFont,
|
||||
iconFsz);
|
||||
|
||||
ImGui::PushID(i);
|
||||
ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz));
|
||||
if (ImGui::IsItemClicked()) s_selectedIcon = i;
|
||||
if (hov) ImGui::SetTooltip("%s", kIconNames[i]);
|
||||
if (hov) ImGui::SetTooltip("%s", iconName);
|
||||
ImGui::PopID();
|
||||
|
||||
col = (col + 1) % cols;
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
if (ImGui::GetCursorPosY() < controlsTopY) {
|
||||
ImGui::SetCursorPosY(controlsTopY);
|
||||
}
|
||||
|
||||
// "No icon" option
|
||||
ImGui::Spacing();
|
||||
if (s_selectedIcon >= 0) {
|
||||
if (showClearIcon) {
|
||||
ImGui::Spacing();
|
||||
if (ImGui::SmallButton(TR("clear_icon"))) {
|
||||
s_selectedIcon = -1;
|
||||
}
|
||||
@@ -121,20 +198,31 @@ public:
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Buttons
|
||||
float btnW = 120.0f;
|
||||
float totalW = btnW * 2 + Layout::spacingMd();
|
||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f);
|
||||
if (ImGui::GetCursorPosY() < buttonY) {
|
||||
ImGui::SetCursorPosY(buttonY);
|
||||
}
|
||||
|
||||
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
|
||||
// Buttons
|
||||
float minBtnW = 120.0f * dp;
|
||||
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
||||
float cancelW = std::max(minBtnW,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
|
||||
float saveW = std::max(minBtnW,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, saveLabel).x + buttonPadW);
|
||||
float totalW = cancelW + saveW + Layout::spacingMd();
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
|
||||
|
||||
if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) {
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (TactileButton(TR("save"), ImVec2(btnW, 0))) {
|
||||
if (TactileButton(saveLabel, ImVec2(saveW, 0), buttonFont)) {
|
||||
// Apply changes
|
||||
s_app->setAddressLabel(s_address, s_label);
|
||||
if (s_selectedIcon >= 0)
|
||||
s_app->setAddressIcon(s_address, kIconNames[s_selectedIcon]);
|
||||
s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon));
|
||||
else
|
||||
s_app->setAddressIcon(s_address, "");
|
||||
s_open = false;
|
||||
@@ -153,30 +241,12 @@ private:
|
||||
static inline bool s_isZ = false;
|
||||
static inline char s_label[128] = {};
|
||||
static inline int s_selectedIcon = -1;
|
||||
|
||||
// Icon palette — wallet-relevant Material Design icons
|
||||
static constexpr int kIconCount = 20;
|
||||
static inline const char* kIconNames[kIconCount] = {
|
||||
"savings", "account_balance", "wallet", "payments",
|
||||
"diamond", "shield", "lock", "swap_horiz",
|
||||
"store", "home", "work", "rocket_launch",
|
||||
"favorite", "bolt", "token", "category",
|
||||
"label", "coffee", "volunteer", "star"
|
||||
};
|
||||
static inline const char* kIconGlyphs[kIconCount] = {
|
||||
ICON_MD_SAVINGS, ICON_MD_ACCOUNT_BALANCE, ICON_MD_WALLET, ICON_MD_PAYMENTS,
|
||||
ICON_MD_DIAMOND, ICON_MD_SHIELD, ICON_MD_LOCK, ICON_MD_SWAP_HORIZ,
|
||||
ICON_MD_STORE, ICON_MD_HOME, ICON_MD_WORK, ICON_MD_ROCKET_LAUNCH,
|
||||
ICON_MD_FAVORITE, ICON_MD_BOLT, ICON_MD_TOKEN, ICON_MD_CATEGORY,
|
||||
ICON_MD_LABEL, ICON_MD_LOCAL_CAFE, ICON_MD_VOLUNTEER_ACTIVISM, ICON_MD_STAR
|
||||
};
|
||||
static inline char s_iconSearch[64] = {};
|
||||
|
||||
public:
|
||||
// Expose for the address list to look up icon glyphs by name
|
||||
static const char* iconGlyphForName(const std::string& name) {
|
||||
for (int i = 0; i < kIconCount; ++i)
|
||||
if (kIconNames[i] == name) return kIconGlyphs[i];
|
||||
return nullptr;
|
||||
return material::project_icons::glyphForName(name);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../notifications.h"
|
||||
#include "../theme.h"
|
||||
#include "imgui.h"
|
||||
|
||||
@@ -45,11 +46,12 @@ public:
|
||||
s_resultMsg.clear();
|
||||
s_success = false;
|
||||
|
||||
// Pre-fill amount with full source balance
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f", info.fromBalance);
|
||||
|
||||
// Default fee
|
||||
s_fee = 0.0001;
|
||||
|
||||
// Pre-fill with the maximum sendable amount so the dialog is valid
|
||||
// immediately without requiring the user to press Max.
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f", maxSendableAmount(info.fromBalance, s_fee));
|
||||
}
|
||||
|
||||
static void render() {
|
||||
@@ -57,7 +59,7 @@ public:
|
||||
|
||||
using namespace material;
|
||||
|
||||
if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 480.0f, 0.94f)) {
|
||||
if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 620.0f, 0.94f)) {
|
||||
float dp = Layout::dpiScale();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
@@ -104,18 +106,22 @@ public:
|
||||
ImGui::Spacing();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Amount input
|
||||
// Amount input + Max button on same row without overflow
|
||||
Type().text(TypeStyle::Subtitle2, TR("amount"));
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
{
|
||||
float spacing = ImGui::GetStyle().ItemSpacing.x;
|
||||
float maxBtnW = ImGui::CalcTextSize(TR("max")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - maxBtnW - spacing);
|
||||
}
|
||||
ImGui::InputText("##TransferAmt", s_amount, sizeof(s_amount),
|
||||
ImGuiInputTextFlags_CharsDecimal);
|
||||
|
||||
// Max button
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("max"))) {
|
||||
double maxAmt = s_info.fromBalance - s_fee;
|
||||
if (maxAmt < 0) maxAmt = 0;
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f", maxAmt);
|
||||
snprintf(s_amount, sizeof(s_amount), "%.8f",
|
||||
maxSendableAmount(s_info.fromBalance, s_fee));
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
@@ -140,15 +146,15 @@ public:
|
||||
ImGui::Spacing();
|
||||
{
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), "%s: %.8f DRGX → %.8f DRGX",
|
||||
TR("sender_balance"), s_info.fromBalance, amountValid ? newFromBal : s_info.fromBalance);
|
||||
snprintf(buf, sizeof(buf), TR("sender_balance"),
|
||||
s_info.fromBalance, amountValid ? newFromBal : s_info.fromBalance);
|
||||
Type().textColored(TypeStyle::Caption,
|
||||
(amountValid && newFromBal < 1e-9) ? Warning() : OnSurfaceMedium(), buf);
|
||||
}
|
||||
{
|
||||
char buf[128];
|
||||
snprintf(buf, sizeof(buf), "%s: %.8f DRGX → %.8f DRGX",
|
||||
TR("recipient_balance"), s_info.toBalance, amountValid ? newToBal : s_info.toBalance);
|
||||
snprintf(buf, sizeof(buf), TR("recipient_balance"),
|
||||
s_info.toBalance, amountValid ? newToBal : s_info.toBalance);
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
|
||||
}
|
||||
|
||||
@@ -166,34 +172,61 @@ public:
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Spacing();
|
||||
// Buttons
|
||||
const char* cancelLabel = TR("cancel");
|
||||
const char* confirmLabel = TR("confirm_transfer");
|
||||
const char* sendingLabel = TR("sending");
|
||||
ImFont* buttonFont = Type().button();
|
||||
float buttonFontSize = ScaledFontSize(buttonFont);
|
||||
float minBtnW = 120.0f * dp;
|
||||
float confirmMinW = 160.0f * dp;
|
||||
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
||||
float cancelW = std::max(minBtnW,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
|
||||
float confirmTextW = std::max(
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
||||
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||
float totalW = cancelW + confirmW + Layout::spacingMd();
|
||||
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f + 1.0f;
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
||||
float footerTopY = cardBottomY - 24.0f * dp - footerH;
|
||||
float currentY = ImGui::GetCursorScreenPos().y;
|
||||
if (currentY < footerTopY) {
|
||||
ImGui::Dummy(ImVec2(0, footerTopY - currentY));
|
||||
} else {
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Buttons
|
||||
float btnW = 140.0f;
|
||||
float totalW = btnW * 2 + Layout::spacingMd();
|
||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f);
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
|
||||
|
||||
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
|
||||
if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) {
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
|
||||
ImGui::BeginDisabled(!amountValid || s_sending);
|
||||
if (TactileButton(s_sending ? TR("sending") : TR("confirm_transfer"), ImVec2(btnW, 0))) {
|
||||
if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) {
|
||||
s_sending = true;
|
||||
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
||||
amount, s_fee, "",
|
||||
[](bool ok, const std::string& result) {
|
||||
s_sending = false;
|
||||
s_success = ok;
|
||||
if (ok)
|
||||
s_resultMsg = result; // opid
|
||||
else
|
||||
s_resultMsg = result; // error message
|
||||
s_resultMsg = result;
|
||||
if (ok) {
|
||||
Notifications::instance().success(TR("transfer_sent_desc"));
|
||||
} else {
|
||||
Notifications::instance().error(result.empty() ? TR("transfer_failed") : result);
|
||||
}
|
||||
});
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
@@ -205,6 +238,11 @@ public:
|
||||
static void close() { s_open = false; }
|
||||
|
||||
private:
|
||||
static double maxSendableAmount(double balance, double fee) {
|
||||
double maxAmt = balance - fee;
|
||||
return maxAmt > 0.0 ? maxAmt : 0.0;
|
||||
}
|
||||
|
||||
static void renderAddressRow(const std::string& addr, double balance, bool isZ, float dp) {
|
||||
using namespace material;
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
@@ -228,22 +266,25 @@ private:
|
||||
dl->AddText(iconFont, iconFsz, ImVec2(mn.x + pad, mn.y + (h - iSz.y) * 0.5f), col, icon);
|
||||
|
||||
// Address (truncated)
|
||||
float textX = mn.x + pad + iSz.x + 8.0f * dp;
|
||||
std::string display = addr;
|
||||
if (display.size() > 42) display = display.substr(0, 18) + "..." + display.substr(display.size() - 14);
|
||||
ImFont* capFont = Type().caption();
|
||||
float capFsz = ScaledFontSize(capFont);
|
||||
dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), display.c_str());
|
||||
|
||||
// Balance (right-aligned)
|
||||
// Balance (right-aligned) — computed first so we know how much space address gets
|
||||
char balBuf[32];
|
||||
snprintf(balBuf, sizeof(balBuf), "%.8f DRGX", balance);
|
||||
ImFont* body = Type().body2();
|
||||
float bodyFsz = ScaledFontSize(body);
|
||||
ImVec2 balSz = body->CalcTextSizeA(bodyFsz, 1000.0f, 0.0f, balBuf);
|
||||
dl->AddText(body, bodyFsz, ImVec2(mx.x - pad - balSz.x, mn.y + (h - balSz.y) * 0.5f),
|
||||
float balX = mx.x - pad - balSz.x;
|
||||
dl->AddText(body, bodyFsz, ImVec2(balX, mn.y + (h - balSz.y) * 0.5f),
|
||||
balance > 0 ? OnSurface() : OnSurfaceDisabled(), balBuf);
|
||||
|
||||
float textX = mn.x + pad + iSz.x + 8.0f * dp;
|
||||
ImFont* capFont = Type().caption();
|
||||
float capFsz = ScaledFontSize(capFont);
|
||||
// Clip address text so it never overlaps the balance
|
||||
float addrMaxW = balX - textX - 8.0f * dp;
|
||||
dl->PushClipRect(ImVec2(textX, mn.y), ImVec2(textX + addrMaxW, mn.y + h), true);
|
||||
dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), addr.c_str());
|
||||
dl->PopClipRect();
|
||||
|
||||
ImGui::Dummy(ImVec2(w, h));
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ void BackupWalletDialog::render(App* app)
|
||||
bool success = false;
|
||||
std::string statusMsg;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Backup wallet");
|
||||
rpc->call("backupwallet", json::array({dest}));
|
||||
// Check if file was created
|
||||
if (fs::exists(dest)) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#include "balance_address_list.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace {
|
||||
|
||||
bool containsIgnoreCase(const std::string& haystack, const std::string& needle)
|
||||
{
|
||||
if (needle.empty()) return true;
|
||||
auto it = std::search(haystack.begin(), haystack.end(),
|
||||
needle.begin(), needle.end(),
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) ==
|
||||
std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != haystack.end();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter)
|
||||
{
|
||||
if (!input.info) return false;
|
||||
return containsIgnoreCase(input.info->address, filter) ||
|
||||
containsIgnoreCase(input.info->label, filter) ||
|
||||
containsIgnoreCase(input.label, filter);
|
||||
}
|
||||
|
||||
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
|
||||
const std::string& filter,
|
||||
bool hideZeroBalances,
|
||||
bool showHidden)
|
||||
{
|
||||
std::vector<AddressListRow> rows;
|
||||
rows.reserve(inputs.size());
|
||||
for (const auto& input : inputs) {
|
||||
if (!input.info) continue;
|
||||
if (!AddressListMatchesFilter(input, filter)) continue;
|
||||
if (input.hidden && !showHidden) continue;
|
||||
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
|
||||
rows.push_back({input.info, input.isZ, input.hidden, input.favorite, input.mining,
|
||||
input.label, input.icon, input.sortOrder});
|
||||
}
|
||||
|
||||
std::sort(rows.begin(), rows.end(),
|
||||
[](const AddressListRow& a, const AddressListRow& b) {
|
||||
bool aHasOrder = a.sortOrder >= 0;
|
||||
bool bHasOrder = b.sortOrder >= 0;
|
||||
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
|
||||
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
|
||||
if (a.favorite != b.favorite) return a.favorite > b.favorite;
|
||||
if (a.isZ != b.isZ) return a.isZ > b.isZ;
|
||||
return a.info->balance > b.info->balance;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
float rowY,
|
||||
float rowWidth,
|
||||
float rowHeight,
|
||||
float rowPadLeft,
|
||||
float rowIconSize,
|
||||
float spacingMd,
|
||||
float spacingSm,
|
||||
float spacingXs)
|
||||
{
|
||||
AddressRowLayout layout;
|
||||
layout.contentStartX = rowX + rowPadLeft;
|
||||
layout.contentStartY = rowY + spacingMd;
|
||||
layout.buttonSize = rowHeight - spacingMd * 2.0f;
|
||||
|
||||
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
|
||||
const float rightEdge = rowX + rowWidth;
|
||||
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
|
||||
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
|
||||
|
||||
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||
layout.visibilityButton = {visibilityX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||
layout.contentRight = visibilityX - spacingSm;
|
||||
layout.labelX = layout.contentStartX + rowIconSize * 2.0f + spacingMd;
|
||||
return layout;
|
||||
}
|
||||
|
||||
std::string FormatAddressUsdValue(double balance, double priceUsd)
|
||||
{
|
||||
if (priceUsd <= 0.0 || balance <= 0.0) return {};
|
||||
|
||||
char buffer[32];
|
||||
const double usdValue = balance * priceUsd;
|
||||
if (usdValue >= 1.0) {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.2f", usdValue);
|
||||
} else if (usdValue >= 0.01) {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.4f", usdValue);
|
||||
} else {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.6f", usdValue);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
#include "data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct AddressListInput {
|
||||
const AddressInfo* info = nullptr;
|
||||
bool isZ = false;
|
||||
bool hidden = false;
|
||||
bool favorite = false;
|
||||
bool mining = false;
|
||||
std::string label;
|
||||
std::string icon;
|
||||
int sortOrder = -1;
|
||||
};
|
||||
|
||||
struct AddressListRow {
|
||||
const AddressInfo* info = nullptr;
|
||||
bool isZ = false;
|
||||
bool hidden = false;
|
||||
bool favorite = false;
|
||||
bool mining = false;
|
||||
std::string label;
|
||||
std::string icon;
|
||||
int sortOrder = -1;
|
||||
};
|
||||
|
||||
struct AddressRowRect {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float width = 0.0f;
|
||||
float height = 0.0f;
|
||||
};
|
||||
|
||||
struct AddressRowLayout {
|
||||
float contentStartX = 0.0f;
|
||||
float contentStartY = 0.0f;
|
||||
float labelX = 0.0f;
|
||||
float contentRight = 0.0f;
|
||||
float buttonSize = 0.0f;
|
||||
AddressRowRect favoriteButton;
|
||||
AddressRowRect visibilityButton;
|
||||
};
|
||||
|
||||
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter);
|
||||
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
|
||||
const std::string& filter,
|
||||
bool hideZeroBalances,
|
||||
bool showHidden);
|
||||
AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
float rowY,
|
||||
float rowWidth,
|
||||
float rowHeight,
|
||||
float rowPadLeft,
|
||||
float rowIconSize,
|
||||
float spacingMd,
|
||||
float spacingSm,
|
||||
float spacingXs);
|
||||
std::string FormatAddressUsdValue(double balance, double priceUsd);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,52 @@
|
||||
#include "balance_recent_tx.h"
|
||||
#include "../../config/version.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
std::string truncateRecentAddress(const std::string& address, int maxLen)
|
||||
{
|
||||
if (address.length() <= static_cast<size_t>(maxLen)) return address;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return address.substr(0, half) + "..." + address.substr(address.length() - half);
|
||||
}
|
||||
|
||||
std::string formatRecentAmount(const std::string& type, double amount)
|
||||
{
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
|
||||
type == "send" ? "-" : "+",
|
||||
std::abs(amount), DRAGONX_TICKER);
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
std::string recentTimeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
}
|
||||
|
||||
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
|
||||
{
|
||||
return {
|
||||
tx.getTypeDisplay(),
|
||||
truncateRecentAddress(tx.address, addressMaxLen),
|
||||
formatRecentAmount(tx.type, tx.amount),
|
||||
recentTimeAgo(tx.timestamp)
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct RecentTxDisplay {
|
||||
std::string typeText;
|
||||
std::string addressText;
|
||||
std::string amountText;
|
||||
std::string timeText;
|
||||
};
|
||||
|
||||
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
+86
-191
@@ -3,6 +3,9 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "balance_tab.h"
|
||||
#include "balance_address_list.h"
|
||||
#include "balance_tab_helpers.h"
|
||||
#include "balance_recent_tx.h"
|
||||
#include "key_export_dialog.h"
|
||||
#include "qr_popup_dialog.h"
|
||||
#include "address_label_dialog.h"
|
||||
@@ -32,92 +35,12 @@
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
||||
static std::string TrId(const char* tr_key, const char* id) {
|
||||
std::string s = TR(tr_key);
|
||||
s += "##";
|
||||
s += id;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Case-insensitive substring search
|
||||
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
|
||||
if (search.empty()) return true;
|
||||
std::string s = str, q = search;
|
||||
std::transform(s.begin(), s.end(), s.begin(), ::tolower);
|
||||
std::transform(q.begin(), q.end(), q.begin(), ::tolower);
|
||||
return s.find(q) != std::string::npos;
|
||||
}
|
||||
|
||||
// Relative time string ("2m ago", "3h ago", etc.)
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
|
||||
// Draw a small transaction-type icon (send=up, receive=down, mined=construction)
|
||||
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
|
||||
float cx, float cy, float /*s*/, ImU32 col)
|
||||
{
|
||||
using namespace material;
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* icon;
|
||||
if (type == "send") {
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
} else {
|
||||
icon = ICON_MD_CONSTRUCTION;
|
||||
}
|
||||
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
|
||||
}
|
||||
|
||||
// Animated balance state — lerps smoothly toward target
|
||||
static double s_dispTotal = 0.0;
|
||||
static double s_dispShielded = 0.0;
|
||||
static double s_dispTransparent = 0.0;
|
||||
static double s_dispUnconfirmed = 0.0;
|
||||
|
||||
// Helper to truncate address for display
|
||||
static std::string truncateAddress(const std::string& addr, int maxLen = 32) {
|
||||
if (addr.length() <= static_cast<size_t>(maxLen)) return addr;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return addr.substr(0, half) + "..." + addr.substr(addr.length() - half);
|
||||
}
|
||||
|
||||
// Helper to draw a sparkline polyline within a bounding box
|
||||
static void DrawSparkline(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax,
|
||||
const std::vector<double>& data, ImU32 color,
|
||||
float thickness = 1.5f)
|
||||
{
|
||||
if (data.size() < 2) return;
|
||||
double lo = *std::min_element(data.begin(), data.end());
|
||||
double hi = *std::max_element(data.begin(), data.end());
|
||||
double range = hi - lo;
|
||||
if (range < 1e-12) range = 1.0;
|
||||
|
||||
float w = pMax.x - pMin.x;
|
||||
float h = pMax.y - pMin.y;
|
||||
int n = (int)data.size();
|
||||
|
||||
std::vector<ImVec2> pts;
|
||||
pts.reserve(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
float x = pMin.x + (float)i / (float)(n - 1) * w;
|
||||
float y = pMax.y - (float)((data[i] - lo) / range) * h;
|
||||
pts.push_back(ImVec2(x, y));
|
||||
}
|
||||
dl->AddPolyline(pts.data(), n, color, ImDrawFlags_None, thickness);
|
||||
}
|
||||
|
||||
// Forward declarations for all layout functions
|
||||
static void RenderBalanceClassic(App* app);
|
||||
static void RenderBalanceDonut(App* app);
|
||||
@@ -729,6 +652,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isZ;
|
||||
bool hidden;
|
||||
bool favorite;
|
||||
bool mining;
|
||||
};
|
||||
std::vector<AddrRow> rows;
|
||||
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
@@ -742,7 +666,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
|
||||
continue;
|
||||
rows.push_back({&a, true, isHidden, isFav});
|
||||
rows.push_back({&a, true, isHidden, isFav, app->isMiningAddress(a.address)});
|
||||
}
|
||||
for (const auto& a : state.t_addresses) {
|
||||
std::string filter(addr_search);
|
||||
@@ -754,7 +678,7 @@ static void RenderBalanceClassic(App* app)
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav)
|
||||
continue;
|
||||
rows.push_back({&a, false, isHidden, isFav});
|
||||
rows.push_back({&a, false, isHidden, isFav, app->isMiningAddress(a.address)});
|
||||
}
|
||||
|
||||
// Sort: favorites first, then Z addresses, then by balance descending
|
||||
@@ -1027,8 +951,10 @@ static void RenderBalanceClassic(App* app)
|
||||
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
|
||||
const char* typeLabel = row.isZ ? "Shielded" : "Transparent";
|
||||
const char* hiddenTag = row.hidden ? " (hidden)" : "";
|
||||
const char* viewOnlyTag = (!addr.has_spending_key) ? " (view-only)" : "";
|
||||
const char* miningTag = row.mining ? TR("mining_tag") : "";
|
||||
char typeBuf[64];
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag);
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s%s", typeLabel, hiddenTag, viewOnlyTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// Label (if present, next to type)
|
||||
@@ -1109,6 +1035,9 @@ static void RenderBalanceClassic(App* app)
|
||||
app->setCurrentPage(NavPage::Send);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
|
||||
app->setMiningAddress(addr.address, !row.mining);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("export_private_key"))) {
|
||||
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
|
||||
}
|
||||
@@ -1187,18 +1116,19 @@ static void RenderBalanceClassic(App* app)
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int maxTx = kRecentTxCount;
|
||||
int count = (int)txs.size();
|
||||
if (count > maxTx) count = maxTx;
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
ImGui::BeginChild("##BalanceClassicRecentRows", ImVec2(0, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = (int)txs.size();
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||
"No transactions yet");
|
||||
} else {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size, S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
@@ -1206,51 +1136,37 @@ static void RenderBalanceClassic(App* app)
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
|
||||
// Icon
|
||||
ImU32 iconCol;
|
||||
if (tx.type == "send")
|
||||
iconCol = Error();
|
||||
else if (tx.type == "receive")
|
||||
iconCol = Success();
|
||||
else
|
||||
iconCol = Warning();
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
|
||||
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
|
||||
|
||||
// Type label
|
||||
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp),
|
||||
OnSurfaceMedium(), tx.getTypeDisplay().c_str());
|
||||
OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
// Address (truncated)
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), trAddr.c_str());
|
||||
OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
// Amount (right-aligned area)
|
||||
char amtBuf[32];
|
||||
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
|
||||
tx.type == "send" ? "-" : "+",
|
||||
std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(
|
||||
capFont->LegacySize, 10000, 0, amtBuf);
|
||||
capFont->LegacySize, 10000, 0, display.amountText.c_str());
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size, S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2 * dp),
|
||||
tx.type == "send" ? Error()
|
||||
: Success(),
|
||||
amtBuf);
|
||||
recentTxAmountColor(tx.type),
|
||||
display.amountText.c_str());
|
||||
|
||||
// Time ago
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(
|
||||
capFont->LegacySize, 10000, 0, ago.c_str());
|
||||
capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
// Clickable row — hover highlight + navigate to History
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
@@ -1266,6 +1182,7 @@ static void RenderBalanceClassic(App* app)
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,45 +1296,23 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
static float s_copiedTime = 0.0f;
|
||||
|
||||
// ---- Build and filter address rows ----
|
||||
struct AddrRow {
|
||||
const AddressInfo* info;
|
||||
bool isZ, hidden, favorite;
|
||||
std::string label, icon;
|
||||
int sortOrder;
|
||||
};
|
||||
std::vector<AddrRow> rows;
|
||||
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
std::vector<AddressListInput> rowInputs;
|
||||
rowInputs.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
|
||||
auto addRows = [&](const auto& addrs, bool isZ) {
|
||||
for (const auto& a : addrs) {
|
||||
std::string filter(addr_search);
|
||||
std::string addrLabel = app->getAddressLabel(a.address);
|
||||
if (!containsIgnoreCase(a.address, filter) &&
|
||||
!containsIgnoreCase(a.label, filter) &&
|
||||
!containsIgnoreCase(addrLabel, filter)) continue;
|
||||
bool isHidden = app->isAddressHidden(a.address);
|
||||
if (isHidden && !s_showHidden) continue;
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue;
|
||||
rows.push_back({&a, isZ, isHidden, isFav,
|
||||
addrLabel, app->getAddressIcon(a.address),
|
||||
app->getAddressSortOrder(a.address)});
|
||||
bool isMining = app->isMiningAddress(a.address);
|
||||
rowInputs.push_back({&a, isZ, isHidden, isFav, isMining,
|
||||
addrLabel, app->getAddressIcon(a.address),
|
||||
app->getAddressSortOrder(a.address)});
|
||||
}
|
||||
};
|
||||
addRows(state.z_addresses, true);
|
||||
addRows(state.t_addresses, false);
|
||||
|
||||
// Sort: custom order (if any) → favorites → Z first → balance desc
|
||||
std::sort(rows.begin(), rows.end(),
|
||||
[](const AddrRow& a, const AddrRow& b) -> bool {
|
||||
bool aHasOrder = a.sortOrder >= 0;
|
||||
bool bHasOrder = b.sortOrder >= 0;
|
||||
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
|
||||
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
|
||||
if (a.favorite != b.favorite) return a.favorite > b.favorite;
|
||||
if (a.isZ != b.isZ) return a.isZ > b.isZ;
|
||||
return a.info->balance > b.info->balance;
|
||||
});
|
||||
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
|
||||
|
||||
// ---- Toolbar: search + checkboxes + create buttons ----
|
||||
float avail = ImGui::GetContentRegionAvail().x;
|
||||
@@ -1676,24 +1571,21 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
float cx = rowPos.x + rowPadLeft;
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
auto rowLayout = ComputeAddressRowLayout(
|
||||
rowPos.x, rowPos.y, innerW, rowH, rowPadLeft, rowIconSz,
|
||||
Layout::spacingMd(), Layout::spacingSm(), Layout::spacingXs());
|
||||
float cx = rowLayout.contentStartX;
|
||||
float cy = rowLayout.contentStartY;
|
||||
|
||||
// ---- Button zone (right edge): [eye] [star] ----
|
||||
float btnH = rowH - Layout::spacingMd() * 2.0f;
|
||||
float btnW = btnH;
|
||||
float btnGap = Layout::spacingSm();
|
||||
float btnY = rowPos.y + (rowH - btnH) * 0.5f;
|
||||
float rightEdge = rowPos.x + innerW;
|
||||
float starX = rightEdge - btnW - Layout::spacingXs();
|
||||
float eyeX = starX - btnGap - btnW;
|
||||
float btnRound = 6.0f * dp;
|
||||
bool btnClicked = false;
|
||||
|
||||
if (!isDragged) {
|
||||
// Star button
|
||||
{
|
||||
ImVec2 bMin(starX, btnY), bMax(starX + btnW, btnY + btnH);
|
||||
const auto& starRect = rowLayout.favoriteButton;
|
||||
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
|
||||
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
|
||||
@@ -1702,7 +1594,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon);
|
||||
ImU32 starCol = row.favorite ? favGoldIcon : (bHov ? OnSurface() : OnSurfaceDisabled());
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(starX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), starCol, starIcon);
|
||||
ImVec2(starRect.x + (starRect.width - iSz.x) * 0.5f,
|
||||
starRect.y + (starRect.height - iSz.y) * 0.5f), starCol, starIcon);
|
||||
if (bHov && mouseClicked) {
|
||||
if (row.favorite) app->unfavoriteAddress(addr.address);
|
||||
else app->favoriteAddress(addr.address);
|
||||
@@ -1714,10 +1607,11 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
// Eye button (zero balance or hidden)
|
||||
bool showEye = true;
|
||||
// Always reserve space for both buttons so content doesn't shift
|
||||
float contentRight = eyeX - Layout::spacingSm();
|
||||
float contentRight = rowLayout.contentRight;
|
||||
|
||||
if (showEye) {
|
||||
ImVec2 bMin(eyeX, btnY), bMax(eyeX + btnW, btnY + btnH);
|
||||
const auto& eyeRect = rowLayout.visibilityButton;
|
||||
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
||||
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
||||
@@ -1726,7 +1620,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon);
|
||||
ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled();
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(eyeX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), iconCol, hideIcon);
|
||||
ImVec2(eyeRect.x + (eyeRect.width - iSz.x) * 0.5f,
|
||||
eyeRect.y + (eyeRect.height - iSz.y) * 0.5f), iconCol, hideIcon);
|
||||
if (bHov && mouseClicked) {
|
||||
if (row.hidden) app->unhideAddress(addr.address);
|
||||
else app->hideAddress(addr.address);
|
||||
@@ -1739,22 +1634,29 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
float iconCx = cx + rowIconSz;
|
||||
float iconCy = cy + body2->LegacySize * 0.5f;
|
||||
{
|
||||
const char* customGlyph = row.icon.empty() ? nullptr : AddressLabelDialog::iconGlyphForName(row.icon);
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* glyph = customGlyph ? customGlyph : (row.isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE);
|
||||
ImU32 icCol = customGlyph ? OnSurfaceMedium() : typeCol;
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, glyph);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), icCol, glyph);
|
||||
bool drewCustom = false;
|
||||
if (!row.icon.empty()) {
|
||||
drewCustom = AddressLabelDialog::drawIconByName(
|
||||
dl, row.icon, ImVec2(iconCx, iconCy), iconFont->LegacySize,
|
||||
OnSurfaceMedium(), iconFont, iconFont->LegacySize);
|
||||
}
|
||||
if (!drewCustom) {
|
||||
const char* glyph = row.isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE;
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, glyph);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), typeCol, glyph);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Type label (first line) ----
|
||||
float labelX = cx + rowIconSz * 2.0f + Layout::spacingMd();
|
||||
float labelX = rowLayout.labelX;
|
||||
{
|
||||
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
|
||||
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
|
||||
const char* miningTag = row.mining ? TR("mining_tag") : "";
|
||||
char typeBuf[64];
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag);
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// User label next to type
|
||||
@@ -1798,18 +1700,13 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
|
||||
// ---- USD value (second line, right-aligned) ----
|
||||
{
|
||||
double priceUsd = state.market.price_usd;
|
||||
if (priceUsd > 0.0 && addr.balance > 0.0) {
|
||||
char usdBuf[32];
|
||||
double usdVal = addr.balance * priceUsd;
|
||||
if (usdVal >= 1.0) snprintf(usdBuf, sizeof(usdBuf), "$%.2f", usdVal);
|
||||
else if (usdVal >= 0.01) snprintf(usdBuf, sizeof(usdBuf), "$%.4f", usdVal);
|
||||
else snprintf(usdBuf, sizeof(usdBuf), "$%.6f", usdVal);
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdBuf);
|
||||
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
|
||||
if (!usdText.empty()) {
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(contentRight - usdSz.x,
|
||||
cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), usdBuf);
|
||||
OnSurfaceDisabled(), usdText.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1873,6 +1770,9 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (ImGui::MenuItem(TR("set_label"))) {
|
||||
AddressLabelDialog::show(app, addr.address, row.isZ);
|
||||
}
|
||||
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
|
||||
app->setMiningAddress(addr.address, !row.mining);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("export_private_key")))
|
||||
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
|
||||
if (row.isZ) {
|
||||
@@ -1981,10 +1881,13 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
ImGui::Spacing();
|
||||
|
||||
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
||||
int maxTx = std::clamp((int)(recentH / scaledRowH), 2, 5);
|
||||
float availableListH = ImGui::GetContentRegionAvail().y;
|
||||
float listH = std::max({scaledRowH, recentH, availableListH});
|
||||
ImGui::BeginChild("##BalanceSharedRecentRows", ImVec2(0, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = (int)txs.size();
|
||||
if (count > maxTx) count = maxTx;
|
||||
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "No transactions yet");
|
||||
@@ -1999,39 +1902,30 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
const auto& tx = txs[i];
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
ImU32 iconCol;
|
||||
if (tx.type == "send") iconCol = Error();
|
||||
else if (tx.type == "receive") iconCol = Success();
|
||||
else iconCol = Warning();
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
|
||||
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
|
||||
|
||||
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), tx.getTypeDisplay().c_str());
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), trAddr.c_str());
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
char amtBuf[32];
|
||||
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
|
||||
tx.type == "send" ? "-" : "+",
|
||||
std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, amtBuf);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.amountText.c_str());
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(
|
||||
S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2 * dp),
|
||||
tx.type == "send" ? Error() : Success(), amtBuf);
|
||||
recentTxAmountColor(tx.type), display.amountText.c_str());
|
||||
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, ago.c_str());
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
@@ -2044,6 +1938,7 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
ImGui::Dummy(ImVec2(0, rowH));
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
// Render sync progress bar (used by multiple layouts)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "balance_tab_helpers.h"
|
||||
|
||||
#include "../../config/version.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
std::string TrId(const char* trKey, const char* id)
|
||||
{
|
||||
std::string value = TR(trKey);
|
||||
value += "##";
|
||||
value += id;
|
||||
return value;
|
||||
}
|
||||
|
||||
bool containsIgnoreCase(const std::string& value, const std::string& search)
|
||||
{
|
||||
if (search.empty()) return true;
|
||||
std::string haystack = value;
|
||||
std::string needle = search;
|
||||
std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
|
||||
std::transform(needle.begin(), needle.end(), needle.begin(), ::tolower);
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
std::string timeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
|
||||
std::string truncateAddress(const std::string& address, int maxLen)
|
||||
{
|
||||
if (address.length() <= static_cast<size_t>(maxLen)) return address;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return address.substr(0, half) + "..." + address.substr(address.length() - half);
|
||||
}
|
||||
|
||||
ImU32 recentTxIconColor(const std::string& type)
|
||||
{
|
||||
using namespace material;
|
||||
if (type == "send") return Error();
|
||||
if (type == "receive") return Success();
|
||||
return Warning();
|
||||
}
|
||||
|
||||
ImU32 recentTxAmountColor(const std::string& type)
|
||||
{
|
||||
using namespace material;
|
||||
return type == "send" ? Error() : Success();
|
||||
}
|
||||
|
||||
std::string formatRecentTxAmount(const std::string& type, double amount)
|
||||
{
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
|
||||
type == "send" ? "-" : "+",
|
||||
std::abs(amount), DRAGONX_TICKER);
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
|
||||
float centerX, float centerY, float, ImU32 color)
|
||||
{
|
||||
using namespace material;
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* icon = ICON_MD_CONSTRUCTION;
|
||||
if (type == "send") {
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
}
|
||||
ImVec2 size = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(centerX - size.x * 0.5f, centerY - size.y * 0.5f), color, icon);
|
||||
}
|
||||
|
||||
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
|
||||
const std::vector<double>& data, ImU32 color, float thickness)
|
||||
{
|
||||
if (data.size() < 2) return;
|
||||
double low = *std::min_element(data.begin(), data.end());
|
||||
double high = *std::max_element(data.begin(), data.end());
|
||||
double range = high - low;
|
||||
if (range < 1e-12) range = 1.0;
|
||||
|
||||
float width = max.x - min.x;
|
||||
float height = max.y - min.y;
|
||||
int count = static_cast<int>(data.size());
|
||||
|
||||
std::vector<ImVec2> points;
|
||||
points.reserve(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
float x = min.x + static_cast<float>(i) / static_cast<float>(count - 1) * width;
|
||||
float y = max.y - static_cast<float>((data[i] - low) / range) * height;
|
||||
points.push_back(ImVec2(x, y));
|
||||
}
|
||||
drawList->AddPolyline(points.data(), count, color, ImDrawFlags_None, thickness);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
std::string TrId(const char* trKey, const char* id);
|
||||
bool containsIgnoreCase(const std::string& value, const std::string& search);
|
||||
std::string timeAgo(int64_t timestamp);
|
||||
std::string truncateAddress(const std::string& address, int maxLen = 32);
|
||||
ImU32 recentTxIconColor(const std::string& type);
|
||||
ImU32 recentTxAmountColor(const std::string& type);
|
||||
std::string formatRecentTxAmount(const std::string& type, double amount);
|
||||
|
||||
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
|
||||
float centerX, float centerY, float size, ImU32 color);
|
||||
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
|
||||
const std::vector<double>& data, ImU32 color,
|
||||
float thickness = 1.5f);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,136 @@
|
||||
#include "console_command_reference.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
template <size_t N>
|
||||
constexpr int CountOf(const ConsoleCommandEntry (&)[N])
|
||||
{
|
||||
return static_cast<int>(N);
|
||||
}
|
||||
|
||||
const ConsoleCommandEntry kControlCommands[] = {
|
||||
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
|
||||
{"getinfo", "Get general info about the node", ""},
|
||||
{"stop", "Stop the daemon", ""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kNetworkCommands[] = {
|
||||
{"getnetworkinfo", "Return P2P network state info", ""},
|
||||
{"getpeerinfo", "Get data about each connected peer", ""},
|
||||
{"getconnectioncount", "Get number of peer connections", ""},
|
||||
{"getnettotals", "Get network traffic statistics", ""},
|
||||
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
|
||||
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
|
||||
{"listbanned", "List all banned IPs/subnets", ""},
|
||||
{"clearbanned", "Clear all banned IPs", ""},
|
||||
{"ping", "Ping all peers to measure round-trip time", ""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kBlockchainCommands[] = {
|
||||
{"getblockchaininfo", "Get current blockchain state", ""},
|
||||
{"getblockcount", "Get number of blocks in longest chain", ""},
|
||||
{"getbestblockhash", "Get hash of the tip block", ""},
|
||||
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
|
||||
{"getblockhash", "Get block hash at a given height", "height"},
|
||||
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
|
||||
{"getdifficulty", "Get proof-of-work difficulty", ""},
|
||||
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
|
||||
{"getmempoolinfo", "Get mempool state info", ""},
|
||||
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
|
||||
{"coinsupply", "Get coin supply information", "[height]"},
|
||||
{"getchaintips", "Get all known chain tips", ""},
|
||||
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
|
||||
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
|
||||
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
|
||||
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kMiningCommands[] = {
|
||||
{"getmininginfo", "Get mining-related information", ""},
|
||||
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
|
||||
{"getgenerate", "Check if the node is mining", ""},
|
||||
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
|
||||
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
|
||||
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
|
||||
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kWalletCommands[] = {
|
||||
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
|
||||
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
|
||||
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
|
||||
{"getnewaddress", "Generate a new transparent address", ""},
|
||||
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
|
||||
{"listaddresses", "List all transparent addresses", ""},
|
||||
{"z_listaddresses", "List all z-addresses", ""},
|
||||
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
|
||||
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
|
||||
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
|
||||
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
|
||||
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
|
||||
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
|
||||
{"z_listoperationids", "List all async z operation IDs", ""},
|
||||
{"getwalletinfo", "Get wallet state info", ""},
|
||||
{"backupwallet", "Back up wallet to a file", "\"destination\""},
|
||||
{"dumpprivkey", "Dump private key for an address", "\"address\""},
|
||||
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
|
||||
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
|
||||
{"importwallet", "Import wallet from a dump file", "\"filename\""},
|
||||
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
|
||||
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
|
||||
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
|
||||
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
|
||||
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
|
||||
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
|
||||
{"settxfee", "Set the transaction fee per kB", "amount"},
|
||||
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
|
||||
{"walletlock", "Lock the wallet", ""},
|
||||
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kRawTransactionCommands[] = {
|
||||
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
|
||||
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
|
||||
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
|
||||
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
|
||||
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
|
||||
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
|
||||
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kUtilityCommands[] = {
|
||||
{"validateaddress", "Validate a transparent address", "\"address\""},
|
||||
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
|
||||
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
|
||||
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
|
||||
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
|
||||
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
|
||||
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
||||
{
|
||||
static const std::vector<ConsoleCommandCategory> categories = {
|
||||
{"Control", kControlCommands, CountOf(kControlCommands)},
|
||||
{"Network", kNetworkCommands, CountOf(kNetworkCommands)},
|
||||
{"Blockchain", kBlockchainCommands, CountOf(kBlockchainCommands)},
|
||||
{"Mining", kMiningCommands, CountOf(kMiningCommands)},
|
||||
{"Wallet", kWalletCommands, CountOf(kWalletCommands)},
|
||||
{"Raw Transactions", kRawTransactionCommands, CountOf(kRawTransactionCommands)},
|
||||
{"Utility", kUtilityCommands, CountOf(kUtilityCommands)},
|
||||
};
|
||||
return categories;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleCommandEntry {
|
||||
const char* name;
|
||||
const char* desc;
|
||||
const char* params;
|
||||
};
|
||||
|
||||
struct ConsoleCommandCategory {
|
||||
const char* name;
|
||||
const ConsoleCommandEntry* commands;
|
||||
int count;
|
||||
};
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,240 @@
|
||||
#include "console_input_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
const std::vector<std::string>& ConsoleRpcCommandNames()
|
||||
{
|
||||
static const std::vector<std::string> commands = {
|
||||
"help", "getinfo", "stop",
|
||||
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
|
||||
"addnode", "setban", "listbanned", "clearbanned", "ping",
|
||||
"getblockchaininfo", "getblockcount", "getbestblockhash",
|
||||
"getblock", "getblockhash", "getblockheader", "getdifficulty",
|
||||
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
|
||||
"getmininginfo", "setgenerate", "getgenerate",
|
||||
"getnetworkhashps", "getblocksubsidy",
|
||||
"getbalance", "z_gettotalbalance", "z_getbalances",
|
||||
"getnewaddress", "z_getnewaddress",
|
||||
"listaddresses", "z_listaddresses",
|
||||
"sendtoaddress", "z_sendmany",
|
||||
"listtransactions", "listunspent", "z_listunspent",
|
||||
"z_getoperationstatus", "z_getoperationresult",
|
||||
"getwalletinfo", "backupwallet",
|
||||
"dumpprivkey", "importprivkey",
|
||||
"z_exportkey", "z_importkey",
|
||||
"signmessage", "settxfee",
|
||||
"createrawtransaction", "decoderawtransaction",
|
||||
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
|
||||
"validateaddress", "z_validateaddress", "estimatefee",
|
||||
"clear"
|
||||
};
|
||||
return commands;
|
||||
}
|
||||
|
||||
void AppendConsoleHistory(std::vector<std::string>& history,
|
||||
const std::string& command,
|
||||
std::size_t maxEntries)
|
||||
{
|
||||
if (command.empty()) return;
|
||||
if (history.empty() || history.back() != command) {
|
||||
history.push_back(command);
|
||||
while (history.size() > maxEntries) {
|
||||
history.erase(history.begin());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int NavigateConsoleHistoryIndex(int currentIndex, std::size_t historySize, bool up)
|
||||
{
|
||||
if (historySize == 0) return -1;
|
||||
if (up) {
|
||||
if (currentIndex < 0) return static_cast<int>(historySize) - 1;
|
||||
if (currentIndex > 0) return currentIndex - 1;
|
||||
return currentIndex;
|
||||
}
|
||||
if (currentIndex >= 0) {
|
||||
int next = currentIndex + 1;
|
||||
if (next >= static_cast<int>(historySize)) return -1;
|
||||
return next;
|
||||
}
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
std::string ConsoleHistoryEntry(const std::vector<std::string>& history, int historyIndex)
|
||||
{
|
||||
if (historyIndex < 0 || historyIndex >= static_cast<int>(history.size())) return {};
|
||||
return history[static_cast<std::size_t>(historyIndex)];
|
||||
}
|
||||
|
||||
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input)
|
||||
{
|
||||
ConsoleCompletionResult result;
|
||||
if (input.empty()) return result;
|
||||
|
||||
for (const auto& command : ConsoleRpcCommandNames()) {
|
||||
if (command.compare(0, input.size(), input) == 0) {
|
||||
result.matches.push_back(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.matches.empty()) return result;
|
||||
result.commonPrefix = result.matches.front();
|
||||
for (std::size_t matchIndex = 1; matchIndex < result.matches.size(); ++matchIndex) {
|
||||
const auto& match = result.matches[matchIndex];
|
||||
std::size_t prefixLength = 0;
|
||||
while (prefixLength < result.commonPrefix.size() &&
|
||||
prefixLength < match.size() &&
|
||||
result.commonPrefix[prefixLength] == match[prefixLength]) {
|
||||
++prefixLength;
|
||||
}
|
||||
result.commonPrefix.resize(prefixLength);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
||||
std::size_t maxLineLength)
|
||||
{
|
||||
std::vector<std::string> lines;
|
||||
std::string line = " ";
|
||||
for (std::size_t matchIndex = 0; matchIndex < matches.size(); ++matchIndex) {
|
||||
if (matchIndex > 0) line += " ";
|
||||
line += matches[matchIndex];
|
||||
if (line.length() > maxLineLength) {
|
||||
lines.push_back(line);
|
||||
line = " ";
|
||||
}
|
||||
}
|
||||
if (line.length() > 2) {
|
||||
lines.push_back(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
||||
{
|
||||
std::vector<std::string> args;
|
||||
std::size_t index = 0;
|
||||
while (index < command.size()) {
|
||||
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
|
||||
++index;
|
||||
}
|
||||
if (index >= command.size()) break;
|
||||
|
||||
std::string token;
|
||||
if (command[index] == '"' || command[index] == '\'') {
|
||||
char quote = command[index++];
|
||||
while (index < command.size() && command[index] != quote) {
|
||||
token += command[index++];
|
||||
}
|
||||
if (index < command.size()) ++index;
|
||||
} else if (command[index] == '[' || command[index] == '{') {
|
||||
char open = command[index];
|
||||
char close = (open == '[') ? ']' : '}';
|
||||
int depth = 0;
|
||||
while (index < command.size()) {
|
||||
if (command[index] == open) ++depth;
|
||||
else if (command[index] == close) --depth;
|
||||
token += command[index++];
|
||||
if (depth == 0) break;
|
||||
}
|
||||
} else {
|
||||
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
|
||||
token += command[index++];
|
||||
}
|
||||
}
|
||||
if (!token.empty()) args.push_back(token);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
||||
{
|
||||
auto args = ParseConsoleCommandArgs(command);
|
||||
ConsoleRpcCall call;
|
||||
if (args.empty()) return call;
|
||||
|
||||
call.valid = true;
|
||||
call.method = args.front();
|
||||
|
||||
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
|
||||
const std::string& arg = args[argIndex];
|
||||
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
||||
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
||||
if (!parsed.is_discarded()) {
|
||||
call.params.push_back(parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg == "true") {
|
||||
call.params.push_back(true);
|
||||
} else if (arg == "false") {
|
||||
call.params.push_back(false);
|
||||
} else {
|
||||
try {
|
||||
if (arg.find('.') != std::string::npos) {
|
||||
call.params.push_back(std::stod(arg));
|
||||
} else {
|
||||
call.params.push_back(std::stoll(arg));
|
||||
}
|
||||
} catch (...) {
|
||||
call.params.push_back(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError)
|
||||
{
|
||||
if (isError) {
|
||||
return {{"Error: " + result, ConsoleResultLineRole::Error}};
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> lines;
|
||||
std::string normalized = (result == "null") ? "(no result)" : result;
|
||||
bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '[');
|
||||
std::istringstream stream(normalized);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
||||
if (isJson && !line.empty()) {
|
||||
std::string trimmed = line;
|
||||
std::size_t first = trimmed.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
||||
if (!trimmed.empty()) {
|
||||
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
|
||||
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
||||
trimmed[0] == '[' || trimmed[0] == ']') {
|
||||
role = ConsoleResultLineRole::JsonBrace;
|
||||
} else if (trimmed[0] == '"') {
|
||||
if (trimmed.find("\": ") != std::string::npos ||
|
||||
trimmed.find("\":") != std::string::npos) {
|
||||
role = ConsoleResultLineRole::JsonKey;
|
||||
} else {
|
||||
role = ConsoleResultLineRole::JsonString;
|
||||
}
|
||||
} else if (std::isdigit(firstChar) || trimmed[0] == '-') {
|
||||
role = ConsoleResultLineRole::JsonNumber;
|
||||
} else if (trimmed == "true," || trimmed == "false," ||
|
||||
trimmed == "true" || trimmed == "false" ||
|
||||
trimmed == "null," || trimmed == "null") {
|
||||
role = ConsoleResultLineRole::JsonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back({line, role});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleCompletionResult {
|
||||
std::vector<std::string> matches;
|
||||
std::string commonPrefix;
|
||||
};
|
||||
|
||||
struct ConsoleRpcCall {
|
||||
bool valid = false;
|
||||
std::string method;
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
};
|
||||
|
||||
enum class ConsoleResultLineRole {
|
||||
Result,
|
||||
Error,
|
||||
JsonKey,
|
||||
JsonString,
|
||||
JsonNumber,
|
||||
JsonBrace
|
||||
};
|
||||
|
||||
struct ConsoleResultLine {
|
||||
std::string text;
|
||||
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
||||
};
|
||||
|
||||
const std::vector<std::string>& ConsoleRpcCommandNames();
|
||||
void AppendConsoleHistory(std::vector<std::string>& history,
|
||||
const std::string& command,
|
||||
std::size_t maxEntries = 100);
|
||||
int NavigateConsoleHistoryIndex(int currentIndex,
|
||||
std::size_t historySize,
|
||||
bool up);
|
||||
std::string ConsoleHistoryEntry(const std::vector<std::string>& history,
|
||||
int historyIndex);
|
||||
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input);
|
||||
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
||||
std::size_t maxLineLength = 60);
|
||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
|
||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,34 @@
|
||||
#include "console_output_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
std::string lowerCopy(std::string value)
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
const ConsoleOutputFilter& filter)
|
||||
{
|
||||
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
|
||||
if (!filter.rpcTraceEnabled && lineColor == filter.rpcTraceColor) return false;
|
||||
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
|
||||
if (!filter.text.empty()) {
|
||||
std::string needle = lowerCopy(filter.text);
|
||||
std::string haystack = lowerCopy(lineText);
|
||||
if (haystack.find(needle) == std::string::npos) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleOutputFilter {
|
||||
std::string text;
|
||||
bool daemonMessagesEnabled = true;
|
||||
bool errorsOnly = false;
|
||||
bool rpcTraceEnabled = false;
|
||||
ImU32 daemonColor = 0;
|
||||
ImU32 errorColor = 0;
|
||||
ImU32 rpcTraceColor = 0;
|
||||
};
|
||||
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
const ConsoleOutputFilter& filter);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
+177
-412
@@ -6,6 +6,10 @@
|
||||
// tab completion, daemon log display, and color-coded output.
|
||||
|
||||
#include "console_tab.h"
|
||||
#include "console_command_reference.h"
|
||||
#include "console_input_model.h"
|
||||
#include "console_output_model.h"
|
||||
#include "console_tab_helpers.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -23,6 +27,8 @@
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <ctime>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -34,10 +40,36 @@ ImU32 ConsoleTab::COLOR_RESULT = IM_COL32(200, 200, 200, 255);
|
||||
ImU32 ConsoleTab::COLOR_ERROR = IM_COL32(246, 71, 64, 255);
|
||||
ImU32 ConsoleTab::COLOR_DAEMON = IM_COL32(160, 160, 160, 180);
|
||||
ImU32 ConsoleTab::COLOR_INFO = IM_COL32(191, 209, 229, 255);
|
||||
ImU32 ConsoleTab::COLOR_RPC = IM_COL32(120, 180, 255, 210);
|
||||
bool ConsoleTab::s_scanline_enabled = true;
|
||||
float ConsoleTab::s_console_zoom = 1.0f;
|
||||
bool ConsoleTab::s_daemon_messages_enabled = true;
|
||||
bool ConsoleTab::s_errors_only_enabled = false;
|
||||
bool ConsoleTab::s_rpc_trace_enabled = false;
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex s_rpc_trace_console_mutex;
|
||||
ConsoleTab* s_rpc_trace_console = nullptr;
|
||||
|
||||
std::string rpcTraceTimestamp()
|
||||
{
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm localTime{};
|
||||
static std::mutex timeMutex;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(timeMutex);
|
||||
if (const std::tm* current = std::localtime(&now)) {
|
||||
localTime = *current;
|
||||
}
|
||||
}
|
||||
|
||||
char buffer[16];
|
||||
std::strftime(buffer, sizeof(buffer), "%H:%M:%S", &localTime);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ConsoleTab::refreshColors()
|
||||
{
|
||||
@@ -51,18 +83,21 @@ void ConsoleTab::refreshColors()
|
||||
auto err = S.drawElement("console", "color-error");
|
||||
auto dmn = S.drawElement("console", "color-daemon");
|
||||
auto inf = S.drawElement("console", "color-info");
|
||||
auto rpc = S.drawElement("console", "color-rpc");
|
||||
|
||||
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
|
||||
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
||||
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
||||
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
||||
|
||||
COLOR_COMMAND = !cmd.color.empty() ? S.resolveColor(cmd.color, defCmd) : defCmd;
|
||||
COLOR_RESULT = !res.color.empty() ? S.resolveColor(res.color, defRes) : defRes;
|
||||
COLOR_ERROR = !err.color.empty() ? S.resolveColor(err.color, defErr) : defErr;
|
||||
COLOR_DAEMON = !dmn.color.empty() ? S.resolveColor(dmn.color, defDmn) : defDmn;
|
||||
COLOR_INFO = !inf.color.empty() ? S.resolveColor(inf.color, defInf) : defInf;
|
||||
COLOR_RPC = !rpc.color.empty() ? S.resolveColor(rpc.color, defRpc) : defRpc;
|
||||
} else {
|
||||
// No schema — use hardcoded defaults per theme
|
||||
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
@@ -70,11 +105,26 @@ void ConsoleTab::refreshColors()
|
||||
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
||||
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
||||
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
||||
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
||||
}
|
||||
}
|
||||
|
||||
ConsoleTab::ConsoleTab()
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
s_rpc_trace_console = this;
|
||||
}
|
||||
rpc::RPCClient::setTraceCallback([](const std::string& source, const std::string& method) {
|
||||
ConsoleTab* console = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
console = s_rpc_trace_console;
|
||||
}
|
||||
if (console) console->addRpcTraceLine(source, method);
|
||||
});
|
||||
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
|
||||
|
||||
// Load console colors from ui.toml schema (uses current theme)
|
||||
refreshColors();
|
||||
|
||||
@@ -84,6 +134,14 @@ ConsoleTab::ConsoleTab()
|
||||
addLine("", COLOR_RESULT);
|
||||
}
|
||||
|
||||
ConsoleTab::~ConsoleTab()
|
||||
{
|
||||
rpc::RPCClient::setTraceEnabled(false);
|
||||
rpc::RPCClient::setTraceCallback(nullptr);
|
||||
std::lock_guard<std::mutex> lock(s_rpc_trace_console_mutex);
|
||||
if (s_rpc_trace_console == this) s_rpc_trace_console = nullptr;
|
||||
}
|
||||
|
||||
void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig)
|
||||
{
|
||||
using namespace material;
|
||||
@@ -96,7 +154,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
// Save old colors to remap existing lines
|
||||
ImU32 oldCmd = COLOR_COMMAND, oldRes = COLOR_RESULT;
|
||||
ImU32 oldErr = COLOR_ERROR, oldDmn = COLOR_DAEMON;
|
||||
ImU32 oldInf = COLOR_INFO;
|
||||
ImU32 oldInf = COLOR_INFO, oldRpc = COLOR_RPC;
|
||||
refreshColors();
|
||||
// Remap stored line colors from old to new
|
||||
{
|
||||
@@ -107,6 +165,7 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
else if (line.color == oldErr) line.color = COLOR_ERROR;
|
||||
else if (line.color == oldDmn) line.color = COLOR_DAEMON;
|
||||
else if (line.color == oldInf) line.color = COLOR_INFO;
|
||||
else if (line.color == oldRpc) line.color = COLOR_RPC;
|
||||
}
|
||||
}
|
||||
s_lastDark = nowDark;
|
||||
@@ -213,13 +272,18 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Output area (scrollable) — glass panel background
|
||||
float frameH = ImGui::GetFrameHeightWithSpacing();
|
||||
float itemSp = ImGui::GetStyle().ItemSpacing.y;
|
||||
float input_height = (Layout::spacingSm() + itemSp) // Dummy(0,sm) + spacing
|
||||
+ frameH + Layout::spacingSm() + Layout::spacingXs() + schema::UI().drawElement("tabs.console", "input-cursor-offset").size; // input glass panel + cursor offset
|
||||
float outputH = ImGui::GetContentRegionAvail().y - input_height;
|
||||
float availHeight = ImGui::GetContentRegionAvail().y;
|
||||
if (outputH < std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size)) outputH = std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
|
||||
float input_height = ComputeConsoleInputHeight(
|
||||
ImGui::GetFrameHeightWithSpacing(),
|
||||
ImGui::GetStyle().ItemSpacing.y,
|
||||
Layout::spacingSm(),
|
||||
Layout::spacingXs(),
|
||||
schema::UI().drawElement("tabs.console", "input-cursor-offset").size);
|
||||
float outputH = ComputeConsoleOutputHeight(
|
||||
availHeight,
|
||||
input_height,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height").size,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
|
||||
|
||||
ImDrawList* dlOut = ImGui::GetWindowDrawList();
|
||||
ImVec2 outPanelMin = ImGui::GetCursorScreenPos();
|
||||
@@ -273,81 +337,46 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
|
||||
// CRT scanline effect over output area — aligned to text lines
|
||||
if (s_scanline_enabled) {
|
||||
float panelH = outPanelMax.y - outPanelMin.y;
|
||||
|
||||
// --- Text-aligned horizontal scanlines ---
|
||||
// Stride matches the actual text line height so each band sits between lines.
|
||||
float textLineH = output_line_height_;
|
||||
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f; // fallback
|
||||
float bandH = schema::UI().drawElement("tabs.console", "scanline-gap").sizeOr(2.0f);
|
||||
int lineAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(18.0f);
|
||||
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f;
|
||||
|
||||
// Glow fringe parameters (soft gradient above/below each band)
|
||||
float glowSpread = schema::UI().drawElement("tabs.console", "scanline-glow-spread").sizeOr(0.0f);
|
||||
float glowIntensity = schema::UI().drawElement("tabs.console", "scanline-glow-intensity").sizeOr(0.6f);
|
||||
int glowRGB = (int)schema::UI().drawElement("tabs.console", "scanline-glow-color").sizeOr(255.0f);
|
||||
bool drawGlow = glowSpread > 0.0f && glowIntensity > 0.0f && lineAlpha > 0;
|
||||
int glowAlpha = drawGlow ? std::min(255, (int)(lineAlpha * glowIntensity)) : 0;
|
||||
ImU32 glowPeak = IM_COL32(glowRGB, glowRGB, glowRGB, glowAlpha);
|
||||
ImU32 glowClear = IM_COL32(glowRGB, glowRGB, glowRGB, 0);
|
||||
|
||||
if (textLineH >= 1.0f && lineAlpha > 0) {
|
||||
ImU32 lineCol = IM_COL32(255, 255, 255, lineAlpha);
|
||||
float stride = textLineH; // one text line per scanline period
|
||||
// Align with text: account for inner padding and scroll position
|
||||
float padY = Layout::spacingSm();
|
||||
float scrollFrac = std::fmod(consoleScrollY, stride);
|
||||
float startY = outPanelMin.y + padY - scrollFrac;
|
||||
// Ensure first band starts above the visible area
|
||||
while (startY > outPanelMin.y) startY -= stride;
|
||||
for (float y = startY; y < outPanelMax.y; y += stride) {
|
||||
// Place the dark band at the bottom edge of each text line period
|
||||
float bandTop = y + stride - bandH;
|
||||
float bandBot = y + stride;
|
||||
float yTop = std::max(bandTop, outPanelMin.y);
|
||||
float yBot = std::min(bandBot, outPanelMax.y);
|
||||
int lightAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(10.0f), 0, 255);
|
||||
int darkAlpha = std::clamp(lightAlpha + 10, 0, 255);
|
||||
if (textLineH >= 1.0f && (lightAlpha > 0 || darkAlpha > 0)) {
|
||||
ImU32 lightCol = IM_COL32(255, 255, 255, lightAlpha);
|
||||
ImU32 darkCol = IM_COL32(0, 0, 0, darkAlpha);
|
||||
for (const auto& row : scanline_rows_) {
|
||||
float yTop = std::max(row.yTop, outPanelMin.y);
|
||||
float yBot = std::min(row.yBot, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
// Glow fringes (gradient tapers away from band)
|
||||
if (drawGlow) {
|
||||
// Above fringe: transparent at top, glowPeak at bottom
|
||||
float gTop = std::max(yTop - glowSpread, outPanelMin.y);
|
||||
if (gTop < yTop) {
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, gTop), ImVec2(outPanelMax.x, yTop),
|
||||
glowClear, glowClear, glowPeak, glowPeak);
|
||||
}
|
||||
// Below fringe: glowPeak at top, transparent at bottom
|
||||
float gBot = std::min(yBot + glowSpread, outPanelMax.y);
|
||||
if (yBot < gBot) {
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yBot), ImVec2(outPanelMax.x, gBot),
|
||||
glowPeak, glowPeak, glowClear, glowClear);
|
||||
}
|
||||
}
|
||||
// Opaque scanline band (drawn on top of glow)
|
||||
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot), lineCol);
|
||||
dlOut->AddRectFilled(ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, yBot),
|
||||
(row.rowIndex % 2 == 0) ? lightCol : darkCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Animated sweep band (brighter moving highlight) ---
|
||||
float panelH = outPanelMax.y - outPanelMin.y;
|
||||
float scanSpeed = schema::UI().drawElement("tabs.console", "scanline-speed").sizeOr(40.0f);
|
||||
float scanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(30.0f);
|
||||
int scanAlpha = (int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(12.0f);
|
||||
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
|
||||
float scanY = outPanelMin.y + t - scanH;
|
||||
float yTop = std::max(scanY, outPanelMin.y);
|
||||
float yBot = std::min(scanY + scanH, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
float mid = (yTop + yBot) * 0.5f;
|
||||
ImU32 clear = IM_COL32(255, 255, 255, 0);
|
||||
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
|
||||
clear, clear, peak, peak);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
|
||||
peak, peak, clear, clear);
|
||||
float rawScanH = schema::UI().drawElement("tabs.console", "scanline-height").sizeOr(textLineH * 2.0f);
|
||||
int scanAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-alpha").sizeOr(8.0f), 0, 255);
|
||||
if (panelH > 1.0f && textLineH >= 1.0f && scanSpeed > 0.0f && scanAlpha > 0) {
|
||||
float scanLines = std::max(1.0f, std::round(rawScanH / textLineH));
|
||||
float scanH = scanLines * textLineH;
|
||||
float t = (float)std::fmod(ImGui::GetTime() * scanSpeed, (double)(panelH + scanH));
|
||||
float scanY = outPanelMin.y + t - scanH;
|
||||
float yTop = std::max(scanY, outPanelMin.y);
|
||||
float yBot = std::min(scanY + scanH, outPanelMax.y);
|
||||
if (yTop < yBot) {
|
||||
float mid = (yTop + yBot) * 0.5f;
|
||||
ImU32 clear = IM_COL32(255, 255, 255, 0);
|
||||
ImU32 peak = IM_COL32(255, 255, 255, scanAlpha);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, yTop), ImVec2(outPanelMax.x, mid),
|
||||
clear, clear, peak, peak);
|
||||
dlOut->AddRectFilledMultiColor(
|
||||
ImVec2(outPanelMin.x, mid), ImVec2(outPanelMax.x, yBot),
|
||||
peak, peak, clear, clear);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +512,25 @@ void ConsoleTab::renderToolbar(daemon::EmbeddedDaemon* daemon)
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", TR("console_show_errors_only"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
|
||||
// App RPC trace toggle — captures method/source only, never results or params
|
||||
{
|
||||
static bool s_prev_rpc_trace_enabled = false;
|
||||
if (ImGui::Checkbox(TR("console_rpc_trace"), &s_rpc_trace_enabled)) {
|
||||
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
|
||||
}
|
||||
if (s_prev_rpc_trace_enabled != s_rpc_trace_enabled && auto_scroll_) {
|
||||
scroll_to_bottom_ = true;
|
||||
}
|
||||
s_prev_rpc_trace_enabled = s_rpc_trace_enabled;
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("%s", TR("console_show_rpc_trace"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
@@ -598,30 +646,19 @@ void ConsoleTab::renderOutput()
|
||||
output_line_height_ = line_height; // store for scanline alignment
|
||||
output_origin_ = ImGui::GetCursorScreenPos();
|
||||
output_scroll_y_ = ImGui::GetScrollY();
|
||||
scanline_rows_.clear();
|
||||
|
||||
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
|
||||
std::string filter_str(filter_text_);
|
||||
bool has_text_filter = !filter_str.empty();
|
||||
bool hide_daemon = !s_daemon_messages_enabled;
|
||||
bool errors_only = s_errors_only_enabled;
|
||||
bool has_filter = has_text_filter || hide_daemon || errors_only;
|
||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||
s_errors_only_enabled, s_rpc_trace_enabled,
|
||||
COLOR_DAEMON, COLOR_ERROR, COLOR_RPC};
|
||||
bool has_text_filter = !outputFilter.text.empty();
|
||||
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled ||
|
||||
!outputFilter.rpcTraceEnabled || outputFilter.errorsOnly;
|
||||
visible_indices_.clear();
|
||||
if (has_filter) {
|
||||
std::string filter_lower;
|
||||
if (has_text_filter) {
|
||||
filter_lower = filter_str;
|
||||
std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), ::tolower);
|
||||
}
|
||||
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
|
||||
// Skip daemon lines when daemon toggle is off
|
||||
if (hide_daemon && lines_[i].color == COLOR_DAEMON) continue;
|
||||
// When errors-only is enabled, skip non-error lines
|
||||
if (errors_only && lines_[i].color != COLOR_ERROR) continue;
|
||||
if (has_text_filter) {
|
||||
std::string lower = lines_[i].text;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
|
||||
if (lower.find(filter_lower) == std::string::npos) continue;
|
||||
}
|
||||
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].color, outputFilter)) continue;
|
||||
visible_indices_.push_back(i);
|
||||
}
|
||||
} else {
|
||||
@@ -636,8 +673,7 @@ void ConsoleTab::renderOutput()
|
||||
// Each segment records which bytes of the source text appear on that visual
|
||||
// row, so hit-testing and selection highlight can map screen positions to
|
||||
// exact character offsets.
|
||||
float wrap_width = ImGui::GetContentRegionAvail().x - padX * 2;
|
||||
if (wrap_width < 50.0f) wrap_width = 50.0f;
|
||||
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
||||
|
||||
ImFont* font = ImGui::GetFont();
|
||||
float fontSize = ImGui::GetFontSize();
|
||||
@@ -832,6 +868,11 @@ void ConsoleTab::renderOutput()
|
||||
float rowY = lineOrigin.y + seg.yOffset;
|
||||
const char* segStart = line.text.c_str() + seg.byteStart;
|
||||
const char* segEnd = line.text.c_str() + seg.byteEnd;
|
||||
|
||||
if (s_scanline_enabled && line_height > 0.0f) {
|
||||
int rowIndex = static_cast<int>(std::floor((cumulative_y_offsets_[vi] + seg.yOffset) / line_height + 0.5f));
|
||||
scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex});
|
||||
}
|
||||
|
||||
// Selection highlight for this sub-row
|
||||
if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
|
||||
@@ -1169,106 +1210,37 @@ void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker)
|
||||
if (console->command_history_.empty()) return 0;
|
||||
|
||||
int prev_index = console->history_index_;
|
||||
|
||||
if (data->EventKey == ImGuiKey_UpArrow) {
|
||||
if (console->history_index_ < 0) {
|
||||
console->history_index_ = static_cast<int>(console->command_history_.size()) - 1;
|
||||
} else if (console->history_index_ > 0) {
|
||||
console->history_index_--;
|
||||
}
|
||||
} else if (data->EventKey == ImGuiKey_DownArrow) {
|
||||
if (console->history_index_ >= 0) {
|
||||
console->history_index_++;
|
||||
if (console->history_index_ >= static_cast<int>(console->command_history_.size())) {
|
||||
console->history_index_ = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
console->history_index_ = NavigateConsoleHistoryIndex(
|
||||
console->history_index_,
|
||||
console->command_history_.size(),
|
||||
data->EventKey == ImGuiKey_UpArrow);
|
||||
|
||||
if (prev_index != console->history_index_) {
|
||||
const char* history_str = (console->history_index_ >= 0)
|
||||
? console->command_history_[console->history_index_].c_str()
|
||||
: "";
|
||||
std::string history = ConsoleHistoryEntry(console->command_history_, console->history_index_);
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, history_str);
|
||||
data->InsertChars(0, history.c_str());
|
||||
}
|
||||
}
|
||||
else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
|
||||
// Tab completion for common RPC commands
|
||||
static const char* commands[] = {
|
||||
// Control
|
||||
"help", "getinfo", "stop",
|
||||
// Network
|
||||
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
|
||||
"addnode", "setban", "listbanned", "clearbanned", "ping",
|
||||
// Blockchain
|
||||
"getblockchaininfo", "getblockcount", "getbestblockhash",
|
||||
"getblock", "getblockhash", "getblockheader", "getdifficulty",
|
||||
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
|
||||
// Mining
|
||||
"getmininginfo", "setgenerate", "getgenerate",
|
||||
"getnetworkhashps", "getblocksubsidy",
|
||||
// Wallet
|
||||
"getbalance", "z_gettotalbalance", "z_getbalances",
|
||||
"getnewaddress", "z_getnewaddress",
|
||||
"listaddresses", "z_listaddresses",
|
||||
"sendtoaddress", "z_sendmany",
|
||||
"listtransactions", "listunspent", "z_listunspent",
|
||||
"z_getoperationstatus", "z_getoperationresult",
|
||||
"getwalletinfo", "backupwallet",
|
||||
"dumpprivkey", "importprivkey",
|
||||
"z_exportkey", "z_importkey",
|
||||
"signmessage", "settxfee",
|
||||
// Raw Transactions
|
||||
"createrawtransaction", "decoderawtransaction",
|
||||
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
|
||||
// Utility
|
||||
"validateaddress", "z_validateaddress", "estimatefee",
|
||||
// Built-in
|
||||
"clear"
|
||||
};
|
||||
|
||||
std::string input(data->Buf);
|
||||
if (!input.empty()) {
|
||||
// Collect all matches
|
||||
std::vector<const char*> matches;
|
||||
for (const char* cmd : commands) {
|
||||
if (strncmp(cmd, input.c_str(), input.length()) == 0) {
|
||||
matches.push_back(cmd);
|
||||
}
|
||||
}
|
||||
auto completion = CompleteConsoleCommand(input);
|
||||
|
||||
if (matches.size() == 1) {
|
||||
if (completion.matches.size() == 1) {
|
||||
// Single match — complete it
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, matches[0]);
|
||||
} else if (matches.size() > 1) {
|
||||
data->InsertChars(0, completion.matches.front().c_str());
|
||||
} else if (completion.matches.size() > 1) {
|
||||
// Multiple matches — show list in console and complete common prefix
|
||||
console->addLine(TR("console_completions"), ConsoleTab::COLOR_INFO);
|
||||
std::string line = " ";
|
||||
for (size_t m = 0; m < matches.size(); m++) {
|
||||
if (m > 0) line += " ";
|
||||
line += matches[m];
|
||||
if (line.length() > 60) {
|
||||
console->addLine(line, ConsoleTab::COLOR_RESULT);
|
||||
line = " ";
|
||||
}
|
||||
}
|
||||
if (line.length() > 2) {
|
||||
for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
|
||||
console->addLine(line, ConsoleTab::COLOR_RESULT);
|
||||
}
|
||||
|
||||
// Complete to longest common prefix
|
||||
std::string prefix = matches[0];
|
||||
for (size_t m = 1; m < matches.size(); m++) {
|
||||
size_t len = 0;
|
||||
while (len < prefix.length() && len < strlen(matches[m]) &&
|
||||
prefix[len] == matches[m][len]) len++;
|
||||
prefix = prefix.substr(0, len);
|
||||
}
|
||||
if (prefix.length() > input.length()) {
|
||||
if (completion.commonPrefix.length() > input.length()) {
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, prefix.c_str());
|
||||
data->InsertChars(0, completion.commonPrefix.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1312,117 +1284,7 @@ void ConsoleTab::renderCommandsPopup()
|
||||
ImGui::InputTextWithHint("##CmdSearch", TR("console_search_commands"), cmdFilter, sizeof(cmdFilter));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Command entries
|
||||
struct CmdEntry { const char* name; const char* desc; const char* params; };
|
||||
|
||||
static const CmdEntry controlCmds[] = {
|
||||
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
|
||||
{"getinfo", "Get general info about the node", ""},
|
||||
{"stop", "Stop the daemon", ""},
|
||||
};
|
||||
static const CmdEntry networkCmds[] = {
|
||||
{"getnetworkinfo", "Return P2P network state info", ""},
|
||||
{"getpeerinfo", "Get data about each connected peer", ""},
|
||||
{"getconnectioncount", "Get number of peer connections", ""},
|
||||
{"getnettotals", "Get network traffic statistics", ""},
|
||||
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
|
||||
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
|
||||
{"listbanned", "List all banned IPs/subnets", ""},
|
||||
{"clearbanned", "Clear all banned IPs", ""},
|
||||
{"ping", "Ping all peers to measure round-trip time", ""},
|
||||
};
|
||||
static const CmdEntry blockchainCmds[] = {
|
||||
{"getblockchaininfo", "Get current blockchain state", ""},
|
||||
{"getblockcount", "Get number of blocks in longest chain", ""},
|
||||
{"getbestblockhash", "Get hash of the tip block", ""},
|
||||
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
|
||||
{"getblockhash", "Get block hash at a given height", "height"},
|
||||
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
|
||||
{"getdifficulty", "Get proof-of-work difficulty", ""},
|
||||
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
|
||||
{"getmempoolinfo", "Get mempool state info", ""},
|
||||
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
|
||||
{"coinsupply", "Get coin supply information", "[height]"},
|
||||
{"getchaintips", "Get all known chain tips", ""},
|
||||
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
|
||||
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
|
||||
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
|
||||
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
|
||||
};
|
||||
static const CmdEntry miningCmds[] = {
|
||||
{"getmininginfo", "Get mining-related information", ""},
|
||||
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
|
||||
{"getgenerate", "Check if the node is mining", ""},
|
||||
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
|
||||
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
|
||||
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
|
||||
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
|
||||
};
|
||||
static const CmdEntry walletCmds[] = {
|
||||
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
|
||||
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
|
||||
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
|
||||
{"getnewaddress", "Generate a new transparent address", ""},
|
||||
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
|
||||
{"listaddresses", "List all transparent addresses", ""},
|
||||
{"z_listaddresses", "List all z-addresses", ""},
|
||||
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
|
||||
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
|
||||
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
|
||||
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
|
||||
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
|
||||
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
|
||||
{"z_listoperationids", "List all async z operation IDs", ""},
|
||||
{"getwalletinfo", "Get wallet state info", ""},
|
||||
{"backupwallet", "Back up wallet to a file", "\"destination\""},
|
||||
{"dumpprivkey", "Dump private key for an address", "\"address\""},
|
||||
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
|
||||
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
|
||||
{"importwallet", "Import wallet from a dump file", "\"filename\""},
|
||||
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
|
||||
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
|
||||
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
|
||||
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
|
||||
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
|
||||
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
|
||||
{"settxfee", "Set the transaction fee per kB", "amount"},
|
||||
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
|
||||
{"walletlock", "Lock the wallet", ""},
|
||||
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
|
||||
};
|
||||
static const CmdEntry rawTxCmds[] = {
|
||||
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
|
||||
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
|
||||
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
|
||||
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
|
||||
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
|
||||
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
|
||||
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
|
||||
};
|
||||
static const CmdEntry utilCmds[] = {
|
||||
{"validateaddress", "Validate a transparent address", "\"address\""},
|
||||
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
|
||||
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
|
||||
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
|
||||
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
|
||||
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
|
||||
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
|
||||
};
|
||||
|
||||
struct CmdCategory { const char* name; const CmdEntry* commands; int count; };
|
||||
|
||||
static const CmdCategory categories[] = {
|
||||
{"Control", controlCmds, IM_ARRAYSIZE(controlCmds)},
|
||||
{"Network", networkCmds, IM_ARRAYSIZE(networkCmds)},
|
||||
{"Blockchain", blockchainCmds, IM_ARRAYSIZE(blockchainCmds)},
|
||||
{"Mining", miningCmds, IM_ARRAYSIZE(miningCmds)},
|
||||
{"Wallet", walletCmds, IM_ARRAYSIZE(walletCmds)},
|
||||
{"Raw Transactions", rawTxCmds, IM_ARRAYSIZE(rawTxCmds)},
|
||||
{"Utility", utilCmds, IM_ARRAYSIZE(utilCmds)},
|
||||
};
|
||||
const auto& categories = consoleCommandCategories();
|
||||
|
||||
std::string filter(cmdFilter);
|
||||
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
|
||||
@@ -1602,12 +1464,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
{
|
||||
using namespace material;
|
||||
// Add to history (avoid duplicates)
|
||||
if (command_history_.empty() || command_history_.back() != cmd) {
|
||||
command_history_.push_back(cmd);
|
||||
if (command_history_.size() > 100) {
|
||||
command_history_.erase(command_history_.begin());
|
||||
}
|
||||
}
|
||||
AppendConsoleHistory(command_history_, cmd, 100);
|
||||
history_index_ = -1;
|
||||
|
||||
// Echo command
|
||||
@@ -1645,77 +1502,11 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse command and arguments (shell-like: handles quotes and JSON brackets)
|
||||
std::vector<std::string> args;
|
||||
{
|
||||
size_t i = 0;
|
||||
size_t len = cmd.size();
|
||||
while (i < len) {
|
||||
// Skip whitespace
|
||||
while (i < len && (cmd[i] == ' ' || cmd[i] == '\t')) i++;
|
||||
if (i >= len) break;
|
||||
auto call = BuildConsoleRpcCall(cmd);
|
||||
if (!call.valid) return;
|
||||
|
||||
std::string tok;
|
||||
if (cmd[i] == '"' || cmd[i] == '\'') {
|
||||
// Quoted string — collect until matching close quote
|
||||
char quote = cmd[i++];
|
||||
while (i < len && cmd[i] != quote) tok += cmd[i++];
|
||||
if (i < len) i++; // skip closing quote
|
||||
} else if (cmd[i] == '[' || cmd[i] == '{') {
|
||||
// JSON array/object — collect until matching bracket
|
||||
char open = cmd[i];
|
||||
char close = (open == '[') ? ']' : '}';
|
||||
int depth = 0;
|
||||
while (i < len) {
|
||||
if (cmd[i] == open) depth++;
|
||||
else if (cmd[i] == close) depth--;
|
||||
tok += cmd[i++];
|
||||
if (depth == 0) break;
|
||||
}
|
||||
} else {
|
||||
// Unquoted token — collect until whitespace
|
||||
while (i < len && cmd[i] != ' ' && cmd[i] != '\t') tok += cmd[i++];
|
||||
}
|
||||
if (!tok.empty()) args.push_back(tok);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.empty()) return;
|
||||
|
||||
std::string method = args[0];
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
|
||||
// Convert remaining args to JSON params
|
||||
for (size_t i = 1; i < args.size(); i++) {
|
||||
const std::string& arg = args[i];
|
||||
|
||||
// Try to parse as JSON first (handles objects, arrays, etc.)
|
||||
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
||||
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
||||
if (!parsed.is_discarded()) {
|
||||
params.push_back(parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as number or bool
|
||||
if (arg == "true") {
|
||||
params.push_back(true);
|
||||
} else if (arg == "false") {
|
||||
params.push_back(false);
|
||||
} else {
|
||||
try {
|
||||
if (arg.find('.') != std::string::npos) {
|
||||
params.push_back(std::stod(arg));
|
||||
} else {
|
||||
params.push_back(std::stoll(arg));
|
||||
}
|
||||
} catch (...) {
|
||||
// Keep as string
|
||||
params.push_back(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string method = call.method;
|
||||
nlohmann::json params = call.params;
|
||||
|
||||
// Execute RPC call on worker thread to avoid blocking UI
|
||||
if (worker) {
|
||||
@@ -1725,10 +1516,8 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
std::string result_str;
|
||||
bool is_error = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
result_str = rpc->callRaw(method, params);
|
||||
if (result_str == "null") {
|
||||
result_str = "(no result)";
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
is_error = true;
|
||||
@@ -1736,66 +1525,37 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
return [result_str, is_error, self]() {
|
||||
// Process results on main thread where ImGui colors are available
|
||||
using namespace material;
|
||||
if (is_error) {
|
||||
self->addLine("Error: " + result_str, COLOR_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
bool is_json = false;
|
||||
if (!result_str.empty()) {
|
||||
char first = result_str[0];
|
||||
is_json = (first == '{' || first == '[');
|
||||
}
|
||||
ImU32 json_key_col = WithAlpha(Secondary(), 255);
|
||||
ImU32 json_str_col = WithAlpha(Success(), 255);
|
||||
ImU32 json_num_col = WithAlpha(Warning(), 255);
|
||||
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
|
||||
|
||||
std::istringstream stream(result_str);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
if (is_json && !line.empty()) {
|
||||
std::string trimmed = line;
|
||||
size_t first = trimmed.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
||||
|
||||
ImU32 lineCol = COLOR_RESULT;
|
||||
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
||||
trimmed[0] == '[' || trimmed[0] == ']') {
|
||||
lineCol = json_brace_col;
|
||||
} else if (trimmed[0] == '\"') {
|
||||
size_t colon = trimmed.find("\": ");
|
||||
if (colon != std::string::npos || trimmed.find("\":") != std::string::npos) {
|
||||
lineCol = json_key_col;
|
||||
} else {
|
||||
lineCol = json_str_col;
|
||||
}
|
||||
} else if (std::isdigit(trimmed[0]) || trimmed[0] == '-') {
|
||||
lineCol = json_num_col;
|
||||
} else if (trimmed == "true," || trimmed == "false," ||
|
||||
trimmed == "true" || trimmed == "false" ||
|
||||
trimmed == "null," || trimmed == "null") {
|
||||
lineCol = json_num_col;
|
||||
}
|
||||
self->addLine(line, lineCol);
|
||||
} else {
|
||||
self->addLine(line, COLOR_RESULT);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, is_error)) {
|
||||
ImU32 lineCol = COLOR_RESULT;
|
||||
switch (resultLine.role) {
|
||||
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
|
||||
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
|
||||
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
|
||||
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
|
||||
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
|
||||
case ConsoleResultLineRole::Result: break;
|
||||
}
|
||||
self->addLine(resultLine.text, lineCol);
|
||||
}
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Fallback: synchronous execution if no worker available
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
std::string result_str = rpc->callRaw(method, params);
|
||||
if (result_str == "null") result_str = "(no result)";
|
||||
std::istringstream stream(result_str);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
addLine(line, COLOR_RESULT);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
|
||||
addLine(resultLine.text, COLOR_RESULT);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
addLine("Error: " + std::string(e.what()), COLOR_ERROR);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(e.what(), true)) {
|
||||
addLine(resultLine.text, COLOR_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1834,6 +1594,11 @@ void ConsoleTab::addLine(const std::string& line, ImU32 color)
|
||||
scroll_to_bottom_ = auto_scroll_;
|
||||
}
|
||||
|
||||
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)
|
||||
{
|
||||
addLine("[rpc] [" + rpcTraceTimestamp() + "] [" + source + "] " + method, COLOR_RPC);
|
||||
}
|
||||
|
||||
void ConsoleTab::addCommandResult(const std::string& cmd, const std::string& result, bool is_error)
|
||||
{
|
||||
addLine("> " + cmd, COLOR_COMMAND);
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ui {
|
||||
class ConsoleTab {
|
||||
public:
|
||||
ConsoleTab();
|
||||
~ConsoleTab() = default;
|
||||
~ConsoleTab();
|
||||
|
||||
/**
|
||||
* @brief Render the console tab
|
||||
@@ -46,6 +46,7 @@ public:
|
||||
* @brief Add a line to the console output
|
||||
*/
|
||||
void addLine(const std::string& line, ImU32 color = IM_COL32(200, 200, 200, 255));
|
||||
void addRpcTraceLine(const std::string& source, const std::string& method);
|
||||
|
||||
/**
|
||||
* @brief Clear console output
|
||||
@@ -69,6 +70,9 @@ public:
|
||||
// Show only error messages (filter toggle)
|
||||
static bool s_errors_only_enabled;
|
||||
|
||||
// Show app RPC calls made through RPCClient (method/source only)
|
||||
static bool s_rpc_trace_enabled;
|
||||
|
||||
/// Refresh console text colors for current theme (call after theme switch)
|
||||
static void refreshColors();
|
||||
|
||||
@@ -78,6 +82,7 @@ public:
|
||||
static ImU32 COLOR_ERROR;
|
||||
static ImU32 COLOR_DAEMON;
|
||||
static ImU32 COLOR_INFO;
|
||||
static ImU32 COLOR_RPC;
|
||||
|
||||
private:
|
||||
struct ConsoleLine {
|
||||
@@ -129,6 +134,13 @@ private:
|
||||
float output_scroll_y_ = 0.0f; // Track scroll position for selection
|
||||
ImVec2 output_origin_ = {0, 0}; // Top-left of output area
|
||||
float output_line_height_ = 0.0f; // Text line height (for scanline alignment)
|
||||
|
||||
struct ScanlineRow {
|
||||
float yTop = 0.0f;
|
||||
float yBot = 0.0f;
|
||||
int rowIndex = 0;
|
||||
};
|
||||
std::vector<ScanlineRow> scanline_rows_;
|
||||
|
||||
std::mutex lines_mutex_;
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "console_tab_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
float spacingXs,
|
||||
float cursorOffset)
|
||||
{
|
||||
return spacingSm + itemSpacingY + frameHeightWithSpacing + spacingSm + spacingXs + cursorOffset;
|
||||
}
|
||||
|
||||
float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float inputHeight,
|
||||
float minHeight,
|
||||
float minHeightRatio)
|
||||
{
|
||||
return std::max(availableHeight - inputHeight, std::max(minHeight, availableHeight * minHeightRatio));
|
||||
}
|
||||
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX)
|
||||
{
|
||||
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
float spacingXs,
|
||||
float cursorOffset);
|
||||
float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float inputHeight,
|
||||
float minHeight,
|
||||
float minHeightRatio);
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
+375
-152
@@ -8,6 +8,7 @@
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../explorer/explorer_block_cache.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -25,6 +26,8 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <atomic>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -39,18 +42,13 @@ static char s_search_buf[128] = {};
|
||||
static bool s_search_loading = false;
|
||||
static std::string s_search_error;
|
||||
|
||||
// Recent blocks cache
|
||||
struct BlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
static std::vector<BlockSummary> s_recent_blocks;
|
||||
static int s_last_known_height = 0;
|
||||
using BlockSummary = ExplorerBlockSummary;
|
||||
static ExplorerBlockCache s_recent_block_cache;
|
||||
static std::set<int> s_pending_block_heights;
|
||||
static int s_recent_page = 0;
|
||||
static int s_recent_max_page = 0;
|
||||
static std::atomic<int> s_pending_block_fetches{0};
|
||||
static bool s_recent_disk_cache_validation_pending = false;
|
||||
|
||||
// Block detail modal
|
||||
static bool s_show_detail_modal = false;
|
||||
@@ -116,6 +114,34 @@ static std::string truncateHashToFit(const std::string& hash, ImFont* font, floa
|
||||
return truncateHash(hash, front, back);
|
||||
}
|
||||
|
||||
static void validateRecentBlockCache(App* app, const WalletState& state) {
|
||||
if (s_recent_disk_cache_validation_pending) return;
|
||||
|
||||
auto validation = s_recent_block_cache.prepareValidation(state.sync.blocks, state.sync.best_blockhash);
|
||||
if (!validation.needed) return;
|
||||
|
||||
auto* worker = app ? app->worker() : nullptr;
|
||||
auto* rpc = app ? app->rpc() : nullptr;
|
||||
if (!worker || !rpc || !rpc->isConnected()) return;
|
||||
|
||||
int currentHeight = state.sync.blocks;
|
||||
std::string currentHash = state.sync.best_blockhash;
|
||||
s_recent_disk_cache_validation_pending = true;
|
||||
worker->post([rpc, validation, currentHeight, currentHash = std::move(currentHash)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string actualHash;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Cache validation");
|
||||
actualHash = rpc->call("getblockhash", {validation.height}).get<std::string>();
|
||||
} catch (...) {}
|
||||
|
||||
return [validation, currentHeight, currentHash = std::move(currentHash),
|
||||
actualHash = std::move(actualHash)]() mutable {
|
||||
s_recent_disk_cache_validation_pending = false;
|
||||
s_recent_block_cache.applySavedTipValidation(validation, actualHash, currentHeight, currentHash);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static std::string formatSize(int bytes) {
|
||||
char b[32];
|
||||
if (bytes >= 1048576)
|
||||
@@ -127,6 +153,21 @@ static std::string formatSize(int bytes) {
|
||||
return b;
|
||||
}
|
||||
|
||||
static std::string formatHashrate(double hashrate) {
|
||||
char b[32];
|
||||
if (hashrate >= 1e12)
|
||||
snprintf(b, sizeof(b), "%.2f TH/s", hashrate / 1e12);
|
||||
else if (hashrate >= 1e9)
|
||||
snprintf(b, sizeof(b), "%.2f GH/s", hashrate / 1e9);
|
||||
else if (hashrate >= 1e6)
|
||||
snprintf(b, sizeof(b), "%.2f MH/s", hashrate / 1e6);
|
||||
else if (hashrate >= 1e3)
|
||||
snprintf(b, sizeof(b), "%.2f KH/s", hashrate / 1e3);
|
||||
else
|
||||
snprintf(b, sizeof(b), "%.0f H/s", hashrate);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Copy icon button — draws a small icon that copies text to clipboard on click
|
||||
static void copyButton(const char* id, const std::string& text, float x, float y) {
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -211,6 +252,7 @@ static void fetchBlockDetail(App* app, int height) {
|
||||
json result;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Block detail");
|
||||
auto hashResult = rpc->call("getblockhash", {height});
|
||||
std::string hash = hashResult.get<std::string>();
|
||||
result = rpc->call("getblock", {hash});
|
||||
@@ -236,12 +278,14 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
bool gotTx = false;
|
||||
// Try as block hash first
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
blockResult = rpc->call("getblock", {hash});
|
||||
gotBlock = true;
|
||||
} catch (...) {}
|
||||
// If not a block hash, try as txid
|
||||
if (!gotBlock) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||
gotTx = true;
|
||||
} catch (...) {}
|
||||
@@ -267,39 +311,42 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
});
|
||||
}
|
||||
|
||||
static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
static bool fetchRecentBlock(App* app, int height) {
|
||||
auto* worker = app->worker();
|
||||
auto* rpc = app->rpc();
|
||||
if (!worker || !rpc || s_pending_block_fetches > 0) return;
|
||||
if (height < 1 || !worker || !rpc) return false;
|
||||
if (s_pending_block_heights.find(height) != s_pending_block_heights.end()) return false;
|
||||
|
||||
s_recent_blocks.clear();
|
||||
s_recent_blocks.resize(count);
|
||||
s_pending_block_fetches = 1; // single batched fetch
|
||||
s_pending_block_heights.insert(height);
|
||||
s_pending_block_fetches.fetch_add(1);
|
||||
|
||||
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<BlockSummary> results(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
int h = currentHeight - i;
|
||||
if (h < 1) continue;
|
||||
try {
|
||||
auto hashResult = rpc->call("getblockhash", {h});
|
||||
auto hash = hashResult.get<std::string>();
|
||||
auto result = rpc->call("getblock", {hash});
|
||||
auto& bs = results[i];
|
||||
bs.height = result.value("height", 0);
|
||||
bs.hash = result.value("hash", "");
|
||||
bs.time = result.value("time", (int64_t)0);
|
||||
bs.size = result.value("size", 0);
|
||||
bs.difficulty = result.value("difficulty", 0.0);
|
||||
if (result.contains("tx") && result["tx"].is_array())
|
||||
bs.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
}
|
||||
return [results]() {
|
||||
s_recent_blocks = results;
|
||||
s_pending_block_fetches = 0;
|
||||
worker->post([rpc, height]() -> rpc::RPCWorker::MainCb {
|
||||
BlockSummary block;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Recent blocks");
|
||||
auto hashResult = rpc->call("getblockhash", {height});
|
||||
auto hash = hashResult.get<std::string>();
|
||||
auto result = rpc->call("getblock", {hash});
|
||||
block.height = result.value("height", 0);
|
||||
block.hash = result.value("hash", "");
|
||||
block.time = result.value("time", (int64_t)0);
|
||||
block.size = result.value("size", 0);
|
||||
block.difficulty = result.value("difficulty", 0.0);
|
||||
if (result.contains("tx") && result["tx"].is_array())
|
||||
block.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
|
||||
return [height, block = std::move(block)]() mutable {
|
||||
if (block.height > 0) {
|
||||
s_recent_block_cache.storeBlock(block);
|
||||
}
|
||||
s_pending_block_heights.erase(height);
|
||||
if (s_pending_block_fetches.load() > 0) {
|
||||
s_pending_block_fetches.fetch_sub(1);
|
||||
}
|
||||
};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
static void fetchMempoolInfo(App* app) {
|
||||
@@ -311,6 +358,7 @@ static void fetchMempoolInfo(App* app) {
|
||||
int txCount = 0;
|
||||
int64_t bytes = 0;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Mempool summary");
|
||||
auto result = rpc->call("getmempoolinfo", json::array());
|
||||
txCount = result.value("size", 0);
|
||||
bytes = result.value("bytes", (int64_t)0);
|
||||
@@ -331,7 +379,10 @@ static void fetchTxDetail(App* app, const std::string& txid) {
|
||||
s_tx_detail = json();
|
||||
worker->post([rpc, txid]() -> rpc::RPCWorker::MainCb {
|
||||
json result;
|
||||
try { result = rpc->call("getrawtransaction", {txid, 1}); }
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Transaction detail");
|
||||
result = rpc->call("getrawtransaction", {txid, 1});
|
||||
}
|
||||
catch (...) {}
|
||||
return [result]() {
|
||||
s_tx_loading = false;
|
||||
@@ -370,6 +421,16 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
auto& S = schema::UI();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
|
||||
ImFont* capFont = Type().caption();
|
||||
float navBtnSz = std::max(24.0f * Layout::dpiScale(), capFont->LegacySize + 8.0f * Layout::dpiScale());
|
||||
if (s_recent_page > s_recent_max_page) s_recent_page = s_recent_max_page;
|
||||
if (s_recent_page < 0) s_recent_page = 0;
|
||||
char pageBuf[32];
|
||||
snprintf(pageBuf, sizeof(pageBuf), "%d / %d", s_recent_page + 1, s_recent_max_page + 1);
|
||||
float pageW = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, pageBuf).x;
|
||||
float navGap = Layout::spacingXs();
|
||||
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
|
||||
|
||||
float inputW = std::min(
|
||||
S.drawElement("tabs.explorer", "search-input-width").size,
|
||||
availWidth * 0.65f);
|
||||
@@ -377,11 +438,12 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
|
||||
|
||||
// Clamp so search bar never overflows
|
||||
float maxInputW = availWidth - btnW - pad * 3 - Type().iconMed()->LegacySize;
|
||||
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
|
||||
if (inputW > maxInputW) inputW = maxInputW;
|
||||
if (inputW < 80.0f) inputW = 80.0f;
|
||||
|
||||
ImGui::Spacing();
|
||||
float rowStartX = ImGui::GetCursorScreenPos().x;
|
||||
|
||||
// Icon
|
||||
ImGui::PushFont(Type().iconMed());
|
||||
@@ -422,6 +484,32 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
ImGui::TextDisabled("%s", TR("loading"));
|
||||
}
|
||||
|
||||
float navX = rowStartX + availWidth - navW;
|
||||
float navY = cursorY + std::max(0.0f, (barH - navBtnSz) * 0.5f);
|
||||
ImGui::SetCursorScreenPos(ImVec2(navX, navY));
|
||||
ImGui::BeginDisabled(s_recent_page <= 0);
|
||||
ImGui::PushID("RecentBlocksSearchPrev");
|
||||
if (TactileButton(ICON_MD_CHEVRON_LEFT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
|
||||
--s_recent_page;
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, navGap);
|
||||
ImVec2 pagePos = ImGui::GetCursorScreenPos();
|
||||
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(pagePos.x, pagePos.y + (navBtnSz - capFont->LegacySize) * 0.5f), OnSurfaceMedium(), pageBuf);
|
||||
ImGui::Dummy(ImVec2(pageW, navBtnSz));
|
||||
ImGui::SameLine(0, navGap);
|
||||
ImGui::BeginDisabled(s_recent_page >= s_recent_max_page);
|
||||
ImGui::PushID("RecentBlocksSearchNext");
|
||||
if (TactileButton(ICON_MD_CHEVRON_RIGHT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) {
|
||||
++s_recent_page;
|
||||
}
|
||||
ImGui::PopID();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(rowStartX, cursorY + barH));
|
||||
|
||||
// Error
|
||||
if (!s_search_error.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f));
|
||||
@@ -436,6 +524,7 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
const auto& state = app->getWalletState();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
float gap = Layout::cardGap();
|
||||
float dp = Layout::dpiScale();
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
GlassPanelSpec glassSpec;
|
||||
@@ -444,109 +533,185 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImFont* ovFont = Type().overline();
|
||||
ImFont* capFont = Type().caption();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
ImFont* heroFont = Type().h2();
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
|
||||
float cardW = (availWidth - gap) * 0.5f;
|
||||
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm();
|
||||
float cardH = pad * 0.5f + headerH + rowH * 3 + Layout::spacingSm() * 2 + pad * 0.5f;
|
||||
float heroLineH = capFont->LegacySize + Layout::spacingXs() + heroFont->LegacySize;
|
||||
float hashLineH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float cardH = std::max(148.0f * dp,
|
||||
pad * 1.5f + headerH + heroLineH + hashLineH + Layout::spacingLg() * 2.0f);
|
||||
|
||||
bool stacked = availWidth < 760.0f * dp;
|
||||
float chainW = stacked ? availWidth : (availWidth - gap) * 0.60f;
|
||||
float metricsW = stacked ? availWidth : availWidth - chainW - gap;
|
||||
|
||||
ImVec2 basePos = ImGui::GetCursorScreenPos();
|
||||
char buf[128];
|
||||
|
||||
// ── Chain Card (left) ──
|
||||
{
|
||||
ImVec2 cardMin = basePos;
|
||||
auto drawStatusPill = [&](const ImVec2& cardMin, float cardW) {
|
||||
bool connected = app->rpc() && app->rpc()->isConnected();
|
||||
ImU32 pillCol = connected ? Success() : Error();
|
||||
std::string statusText;
|
||||
|
||||
if (!connected) {
|
||||
statusText = TR("not_connected");
|
||||
} else if (state.sync.syncing && state.sync.headers > 0) {
|
||||
snprintf(buf, sizeof(buf), "%s %.1f%%", TR("syncing"), state.sync.verification_progress * 100.0);
|
||||
statusText = buf;
|
||||
pillCol = Warning();
|
||||
} else {
|
||||
statusText = TR("connected");
|
||||
}
|
||||
|
||||
float maxTextW = cardW * 0.34f;
|
||||
statusText = truncateHashToFit(statusText, capFont, maxTextW);
|
||||
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, statusText.c_str());
|
||||
float pillPadX = Layout::spacingSm();
|
||||
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
|
||||
float pillW = textSz.x + pillPadX * 2.0f;
|
||||
ImVec2 pillMin(cardMin.x + cardW - pad - pillW, cardMin.y + pad * 0.5f);
|
||||
ImVec2 pillMax(pillMin.x + pillW, pillMin.y + pillH);
|
||||
|
||||
dl->AddRectFilled(pillMin, pillMax, WithAlpha(pillCol, 32), pillH * 0.5f);
|
||||
dl->AddRect(pillMin, pillMax, WithAlpha(pillCol, 110), pillH * 0.5f, 0, 1.0f * dp);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(pillMin.x + pillPadX, pillMin.y + Layout::spacingXs()), pillCol, statusText.c_str());
|
||||
};
|
||||
|
||||
auto drawChainTip = [&](const ImVec2& cardMin, float cardW) {
|
||||
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
|
||||
drawStatusPill(cardMin, cardW);
|
||||
|
||||
float colW = (cardW - pad * 2) / 2.0f;
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
|
||||
// Row 1: Height | Difficulty
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
|
||||
float valueY = labelY + capFont->LegacySize + Layout::spacingXs();
|
||||
DrawTextShadow(dl, heroFont, heroFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, valueY), OnSurface(), buf);
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("difficulty"));
|
||||
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float progress = std::clamp(static_cast<float>(state.sync.verification_progress), 0.0f, 1.0f);
|
||||
float barY = valueY + heroFont->LegacySize + Layout::spacingMd();
|
||||
float barH = std::max(3.0f * dp, 3.0f);
|
||||
float barW = cardW - pad * 2.0f;
|
||||
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
|
||||
ImVec2(cardMin.x + pad + barW, barY + barH), WithAlpha(OnSurface(), 18), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(cardMin.x + pad, barY),
|
||||
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
|
||||
// Row 2: Hashrate | Notarized
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_hashrate"));
|
||||
double hr = state.mining.networkHashrate;
|
||||
if (hr >= 1e9)
|
||||
snprintf(buf, sizeof(buf), "%.2f GH/s", hr / 1e9);
|
||||
else if (hr >= 1e6)
|
||||
snprintf(buf, sizeof(buf), "%.2f MH/s", hr / 1e6);
|
||||
else if (hr >= 1e3)
|
||||
snprintf(buf, sizeof(buf), "%.2f KH/s", hr / 1e3);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%.0f H/s", hr);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
float hashLabelY = cardMax.y - pad - hashLineH;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_notarized"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.notarized);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
float hashY = hashLabelY + capFont->LegacySize + Layout::spacingXs();
|
||||
float copyW = iconFont->LegacySize + Layout::spacingSm() * 2.0f;
|
||||
float hashMaxW = cardW - pad * 2.0f - (state.sync.best_blockhash.empty() ? 0.0f : copyW + Layout::spacingSm());
|
||||
std::string hashDisp = state.sync.best_blockhash.empty()
|
||||
? std::string("--")
|
||||
: truncateHashToFit(state.sync.best_blockhash, sub1, hashMaxW);
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashY), OnSurface(), hashDisp.c_str());
|
||||
|
||||
if (!state.sync.best_blockhash.empty()) {
|
||||
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
|
||||
copyButton("ChainTipBestHash", state.sync.best_blockhash,
|
||||
cardMax.x - pad - copyW, hashY);
|
||||
ImGui::SetCursorScreenPos(savedCursor);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 3: Best Block Hash — adaptive truncation to fit card
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
float maxHashW = cardW - pad * 2;
|
||||
std::string hashDisp = truncateHashToFit(state.sync.best_blockhash, sub1, maxHashW);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), hashDisp.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mempool Card (right) ──
|
||||
{
|
||||
ImVec2 cardMin(basePos.x + cardW + gap, basePos.y);
|
||||
auto drawMetricGrid = [&](const ImVec2& cardMin, float cardW) {
|
||||
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_mempool"));
|
||||
struct MetricSpec {
|
||||
const char* icon;
|
||||
const char* label;
|
||||
std::string value;
|
||||
std::string detail;
|
||||
ImU32 accent;
|
||||
};
|
||||
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
|
||||
std::string difficulty = buf;
|
||||
snprintf(buf, sizeof(buf), "%d", state.notarized);
|
||||
std::string notarized = buf;
|
||||
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
|
||||
std::string mempoolTxs = s_mempool_loading ? std::string("...") : std::string(buf);
|
||||
|
||||
// Row 1: Transactions
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_txs"));
|
||||
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
MetricSpec metrics[4] = {
|
||||
{ ICON_MD_SPEED, TR("difficulty"), difficulty, "", Warning() },
|
||||
{ ICON_MD_SHOW_CHART, TR("peers_hashrate"), formatHashrate(state.mining.networkHashrate), "", Success() },
|
||||
{ ICON_MD_CONFIRMATION_NUMBER, TR("peers_notarized"), notarized, "", Primary() },
|
||||
{ ICON_MD_STORAGE, TR("explorer_mempool"), mempoolTxs, s_mempool_loading ? std::string("") : formatSize(static_cast<int>(s_mempool_size)), Secondary() },
|
||||
};
|
||||
|
||||
float gridX = cardMin.x + pad;
|
||||
float gridY = cardMin.y + pad;
|
||||
float gridW = cardW - pad * 2.0f;
|
||||
float gridH = cardH - pad * 2.0f;
|
||||
float cellW = gridW * 0.5f;
|
||||
float cellH = gridH * 0.5f;
|
||||
ImU32 dividerCol = WithAlpha(OnSurface(), 22);
|
||||
|
||||
dl->AddLine(ImVec2(gridX + cellW, gridY), ImVec2(gridX + cellW, gridY + gridH), dividerCol, 1.0f * dp);
|
||||
dl->AddLine(ImVec2(gridX, gridY + cellH), ImVec2(gridX + gridW, gridY + cellH), dividerCol, 1.0f * dp);
|
||||
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
int col = i % 2;
|
||||
int row = i / 2;
|
||||
ImVec2 cellMin(gridX + col * cellW, gridY + row * cellH);
|
||||
ImVec2 cellMax(cellMin.x + cellW, cellMin.y + cellH);
|
||||
float inset = Layout::spacingSm();
|
||||
float textX = cellMin.x + inset;
|
||||
float textY = cellMin.y + inset;
|
||||
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(cellMax.x - inset - iconFont->LegacySize, textY),
|
||||
WithAlpha(metrics[i].accent, 180), metrics[i].icon);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(textX, textY), OnSurfaceMedium(), metrics[i].label);
|
||||
|
||||
float valueY = textY + capFont->LegacySize + Layout::spacingXs();
|
||||
float valueMaxW = cellW - inset * 2.0f;
|
||||
std::string value = truncateHashToFit(metrics[i].value, sub1, valueMaxW);
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(textX, valueY), OnSurface(), value.c_str());
|
||||
|
||||
if (!metrics[i].detail.empty()) {
|
||||
std::string detail = truncateHashToFit(metrics[i].detail, capFont, valueMaxW);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(textX, valueY + sub1->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), detail.c_str());
|
||||
}
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 2: Size
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_size"));
|
||||
std::string sizeStr = formatSize(static_cast<int>(s_mempool_size));
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), sizeStr.c_str());
|
||||
}
|
||||
drawChainTip(basePos, chainW);
|
||||
|
||||
if (stacked) {
|
||||
drawMetricGrid(ImVec2(basePos.x, basePos.y + cardH + gap), metricsW);
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH * 2.0f + gap + Layout::spacingMd()));
|
||||
} else {
|
||||
drawMetricGrid(ImVec2(basePos.x + chainW + gap, basePos.y), metricsW);
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd()));
|
||||
}
|
||||
|
||||
static void renderRecentBlocks(App* app, float availWidth) {
|
||||
auto& S = schema::UI();
|
||||
float pad = Layout::cardInnerPadding();
|
||||
float dp = Layout::dpiScale();
|
||||
const auto& state = app->getWalletState();
|
||||
validateRecentBlockCache(app, state);
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
GlassPanelSpec glassSpec;
|
||||
@@ -557,29 +722,99 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImFont* body2 = Type().body2();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
|
||||
float rowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
|
||||
|
||||
// Filter out empty entries
|
||||
std::vector<const BlockSummary*> blocks;
|
||||
for (const auto& bs : s_recent_blocks) {
|
||||
if (bs.height > 0) blocks.push_back(&bs);
|
||||
}
|
||||
|
||||
// Fixed card height — content scrolls inside
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
float maxRows = 10.0f;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
|
||||
float tableH = headerH + contentH + pad;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + baseRowH * maxRows;
|
||||
float minTableH = headerH + contentH + pad;
|
||||
float remainingH = ImGui::GetContentRegionAvail().y;
|
||||
float tableH = std::max(minTableH, remainingH - Layout::spacingSm());
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Header
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_recent_blocks"));
|
||||
|
||||
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
|
||||
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
|
||||
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
|
||||
int rowsPerPage = std::max(1, (int)std::ceil(rowAreaH / std::max(1.0f, baseRowH)));
|
||||
float rowH = baseRowH;
|
||||
|
||||
int maxPage = (state.sync.blocks > 0) ? (state.sync.blocks - 1) / rowsPerPage : 0;
|
||||
s_recent_max_page = maxPage;
|
||||
if (s_recent_page > maxPage) s_recent_page = maxPage;
|
||||
if (s_recent_page < 0) s_recent_page = 0;
|
||||
|
||||
int startHeight = state.sync.blocks - s_recent_page * rowsPerPage;
|
||||
std::vector<int> pageHeights;
|
||||
pageHeights.reserve(rowsPerPage);
|
||||
for (int i = 0; i < rowsPerPage; ++i) {
|
||||
int height = startHeight - i;
|
||||
if (height < 1) break;
|
||||
pageHeights.push_back(height);
|
||||
}
|
||||
|
||||
std::map<int, BlockSummary> cachedBlocks;
|
||||
if (!pageHeights.empty()) {
|
||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
||||
}
|
||||
|
||||
bool canFetchBlocks = state.sync.blocks > 0 &&
|
||||
!s_show_detail_modal && !s_detail_loading && !s_tx_loading;
|
||||
if (canFetchBlocks) {
|
||||
bool currentPageComplete = true;
|
||||
bool scheduledFetch = false;
|
||||
auto* rpc = app->rpc();
|
||||
if (rpc && rpc->isConnected()) {
|
||||
for (int height : pageHeights) {
|
||||
if (cachedBlocks.find(height) == cachedBlocks.end()) {
|
||||
currentPageComplete = false;
|
||||
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
|
||||
}
|
||||
}
|
||||
if (currentPageComplete && s_recent_page < maxPage) {
|
||||
std::vector<int> nextPageHeights;
|
||||
nextPageHeights.reserve(rowsPerPage);
|
||||
int nextStartHeight = state.sync.blocks - (s_recent_page + 1) * rowsPerPage;
|
||||
for (int i = 0; i < rowsPerPage; ++i) {
|
||||
int height = nextStartHeight - i;
|
||||
if (height < 1) break;
|
||||
nextPageHeights.push_back(height);
|
||||
}
|
||||
std::map<int, BlockSummary> nextCachedBlocks;
|
||||
if (!nextPageHeights.empty()) {
|
||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
||||
}
|
||||
for (int height : nextPageHeights) {
|
||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||
scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scheduledFetch) {
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentBlockRow {
|
||||
int height = 0;
|
||||
const BlockSummary* block = nullptr;
|
||||
};
|
||||
|
||||
std::vector<RecentBlockRow> blocks;
|
||||
blocks.reserve(pageHeights.size());
|
||||
for (int height : pageHeights) {
|
||||
auto it = cachedBlocks.find(height);
|
||||
blocks.push_back({height,
|
||||
it != cachedBlocks.end() ? &it->second : nullptr});
|
||||
}
|
||||
// Responsive column layout — give height more room, use remaining for data
|
||||
float innerW = availWidth - pad * 2;
|
||||
float colHeight = pad;
|
||||
@@ -589,7 +824,6 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float colHash = colHeight + innerW * 0.56f;
|
||||
float colTime = colHeight + innerW * 0.82f;
|
||||
|
||||
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colHeight, hdrY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTxs, hdrY), OnSurfaceMedium(), TR("explorer_block_txs"));
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colSize, hdrY), OnSurfaceMedium(), TR("explorer_block_size"));
|
||||
@@ -598,13 +832,11 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTime, hdrY), OnSurfaceMedium(), TR("explorer_block_time"));
|
||||
|
||||
// Scrollable child region for rows
|
||||
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
|
||||
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, rowAreaTop));
|
||||
|
||||
int parentVtx = dl->VtxBuffer.Size;
|
||||
ImGui::BeginChild("##BlockRows", ImVec2(availWidth, rowAreaH), false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
|
||||
ImDrawList* childDL = ImGui::GetWindowDrawList();
|
||||
@@ -615,12 +847,14 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
char buf[128];
|
||||
float rowInset = 2 * dp;
|
||||
|
||||
if (blocks.empty() && s_pending_block_fetches > 0) {
|
||||
if (blocks.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
ImGui::TextDisabled("%s", TR("loading"));
|
||||
} else {
|
||||
for (size_t i = 0; i < blocks.size(); i++) {
|
||||
const auto* bs = blocks[i];
|
||||
const auto& row = blocks[i];
|
||||
const auto* bs = row.block;
|
||||
int blockHeight = bs ? bs->height : row.height;
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowW = ImGui::GetContentRegionAvail().x - rowInset * 2;
|
||||
|
||||
@@ -642,19 +876,19 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
}
|
||||
|
||||
// Hover highlight — clear clickable feedback
|
||||
if (hovered) {
|
||||
if (hovered && bs) {
|
||||
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 20), rowRound);
|
||||
childDL->AddRect(rowMin, rowMax, WithAlpha(Primary(), 40), rowRound);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
// Selected highlight
|
||||
if (s_show_detail_modal && s_detail_height == bs->height) {
|
||||
if (bs && s_show_detail_modal && s_detail_height == bs->height) {
|
||||
childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 25), rowRound);
|
||||
}
|
||||
|
||||
// Click opens block detail modal
|
||||
if (clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
if (bs && clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
fetchBlockDetail(app, bs->height);
|
||||
}
|
||||
|
||||
@@ -662,30 +896,32 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float textY2 = rowPos.y + (rowH - body2->LegacySize) * 0.5f;
|
||||
|
||||
// Height — emphasized with larger font and primary color
|
||||
snprintf(buf, sizeof(buf), "#%d", bs->height);
|
||||
snprintf(buf, sizeof(buf), "#%d", blockHeight);
|
||||
childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY),
|
||||
hovered ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
(hovered && bs) ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
|
||||
// Tx count
|
||||
snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
|
||||
if (bs) snprintf(buf, sizeof(buf), "%d tx", bs->tx_count);
|
||||
else snprintf(buf, sizeof(buf), "...");
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTxs, textY2), OnSurface(), buf);
|
||||
|
||||
// Size
|
||||
std::string sizeStr = formatSize(bs->size);
|
||||
std::string sizeStr = bs ? formatSize(bs->size) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colSize, textY2), OnSurface(), sizeStr.c_str());
|
||||
|
||||
// Difficulty
|
||||
snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
|
||||
if (bs) snprintf(buf, sizeof(buf), "%.2f", bs->difficulty);
|
||||
else snprintf(buf, sizeof(buf), "...");
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colDiff, textY2), OnSurface(), buf);
|
||||
|
||||
// Hash — adaptive to available column width
|
||||
float hashMaxW = (colTime - colHash) - Layout::spacingSm();
|
||||
std::string hashDisp = truncateHashToFit(bs->hash, body2, hashMaxW);
|
||||
std::string hashDisp = bs ? truncateHashToFit(bs->hash, body2, hashMaxW) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colHash, textY2),
|
||||
OnSurfaceMedium(), hashDisp.c_str());
|
||||
|
||||
// Time
|
||||
const char* timeStr = relativeTime(bs->time);
|
||||
const char* timeStr = bs ? relativeTime(bs->time) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTime, textY2), OnSurfaceMedium(), timeStr);
|
||||
}
|
||||
}
|
||||
@@ -1014,27 +1250,14 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
void RenderExplorerTab(App* app)
|
||||
{
|
||||
const auto& state = app->getWalletState();
|
||||
auto* rpc = app->rpc();
|
||||
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##ExplorerScroll", avail, false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
ApplySmoothScroll();
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
|
||||
// Auto-refresh recent blocks when chain height changes
|
||||
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height) {
|
||||
s_last_known_height = state.sync.blocks;
|
||||
if (rpc && rpc->isConnected()) {
|
||||
fetchRecentBlocks(app, state.sync.blocks);
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
|
||||
renderSearchBar(app, availWidth);
|
||||
renderChainStats(app, availWidth);
|
||||
renderSearchBar(app, availWidth);
|
||||
renderRecentBlocks(app, availWidth);
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
@@ -170,6 +170,7 @@ void ExportAllKeysDialog::render(App* app)
|
||||
keys += "# === Z-Addresses (Shielded) ===\n\n";
|
||||
for (const auto& addr : z_addrs) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
|
||||
auto result = rpc->call("z_exportkey", {addr});
|
||||
if (result.is_string()) {
|
||||
keys += "# Address: " + addr + "\n";
|
||||
@@ -185,6 +186,7 @@ void ExportAllKeysDialog::render(App* app)
|
||||
keys += "# === T-Addresses (Transparent) ===\n\n";
|
||||
for (const auto& addr : t_addrs) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
|
||||
auto result = rpc->call("dumpprivkey", {addr});
|
||||
if (result.is_string()) {
|
||||
keys += "# Address: " + addr + "\n";
|
||||
|
||||
@@ -309,7 +309,7 @@ void ImportKeyDialog::render(App* app)
|
||||
bool rescan = s_rescan;
|
||||
int rescanHeight = s_rescan_height;
|
||||
if (app->worker()) {
|
||||
app->worker()->post([rpc = app->rpc(), keys, rescan, rescanHeight]() -> rpc::RPCWorker::MainCb {
|
||||
app->worker()->post([app, rpc = app->rpc(), keys, rescan, rescanHeight]() -> rpc::RPCWorker::MainCb {
|
||||
int imported = 0;
|
||||
int failed = 0;
|
||||
|
||||
@@ -317,6 +317,7 @@ void ImportKeyDialog::render(App* app)
|
||||
std::string keyType = detectKeyType(key);
|
||||
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Import private keys");
|
||||
if (keyType == "z-spending") {
|
||||
// z_importkey "key" "yes"|"no" startheight
|
||||
if (rescan && rescanHeight > 0) {
|
||||
@@ -348,13 +349,14 @@ void ImportKeyDialog::render(App* app)
|
||||
// single rescanblockchain from that height now.
|
||||
if (rescan && rescanHeight > 0 && imported > 0) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Import private keys");
|
||||
rpc->call("rescanblockchain", {rescanHeight});
|
||||
} catch (...) {
|
||||
// rescan failure is non-fatal; user can retry
|
||||
}
|
||||
}
|
||||
|
||||
return [imported, failed]() {
|
||||
return [app, imported, failed]() {
|
||||
s_imported_keys = imported;
|
||||
s_failed_keys = failed;
|
||||
s_importing = false;
|
||||
@@ -363,6 +365,8 @@ void ImportKeyDialog::render(App* app)
|
||||
imported, failed);
|
||||
s_status = buf;
|
||||
if (imported > 0) {
|
||||
app->invalidateAddressValidationCache();
|
||||
app->refreshNow();
|
||||
Notifications::instance().success(TR("import_key_success"), 5.0f);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@ void KeyExportDialog::render(App* app)
|
||||
std::string key;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export key");
|
||||
auto result = rpc->call(method, {addr});
|
||||
key = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
@@ -152,6 +153,7 @@ void KeyExportDialog::render(App* app)
|
||||
std::string key;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
|
||||
auto result = rpc->call("z_exportviewingkey", {addr});
|
||||
key = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
|
||||
@@ -162,15 +162,6 @@ void RenderMarketTab(App* app)
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Accent stripe — clipped to card rounded corners
|
||||
{
|
||||
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
|
||||
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
|
||||
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-market)", Success()), 200),
|
||||
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
float cx = cardMin.x + Layout::spacingLg();
|
||||
float cy = cardMin.y + Layout::spacingLg();
|
||||
|
||||
@@ -306,7 +297,20 @@ void RenderMarketTab(App* app)
|
||||
ImGui::SetCursorScreenPos(savedCur);
|
||||
}
|
||||
} else {
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), TR("market_price_unavailable"));
|
||||
const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), status);
|
||||
if (!market.price_loading && !market.price_error.empty()) {
|
||||
std::string errorText = market.price_error;
|
||||
float maxErrorW = cardMax.x - cx - Layout::spacingLg();
|
||||
while (errorText.size() > 4 &&
|
||||
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
|
||||
errorText.pop_back();
|
||||
}
|
||||
if (errorText.size() < market.price_error.size()) errorText += "...";
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx, cy + 10 + sub1->LegacySize + Layout::spacingXs()),
|
||||
Warning(), errorText.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
@@ -784,15 +788,6 @@ void RenderMarketTab(App* app)
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Accent stripe
|
||||
{
|
||||
float sw = S.drawElement("tabs.market", "accent-stripe-width").size;
|
||||
dl->PushClipRect(cardMin, ImVec2(cardMin.x + sw, cardMax.y), true);
|
||||
dl->AddRectFilled(cardMin, cardMax, WithAlpha(S.resolveColor("var(--accent-portfolio)", Secondary()), 200),
|
||||
glassSpec.rounding, ImDrawFlags_RoundCornersLeft);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
float cx = cardMin.x + Layout::spacingLg();
|
||||
float cy = cardMin.y + Layout::spacingLg();
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#include "mining_benchmark.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
void ThreadBenchmark::reset()
|
||||
{
|
||||
phase = Phase::Idle;
|
||||
candidates.clear();
|
||||
current_index = 0;
|
||||
results.clear();
|
||||
phase_timer = 0.0f;
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
measure_sum = 0.0;
|
||||
measure_samples = 0;
|
||||
optimal_threads = 0;
|
||||
optimal_hashrate = 0.0;
|
||||
was_pool_running = false;
|
||||
prev_threads = 0;
|
||||
total_warmup_secs = 0.0f;
|
||||
}
|
||||
|
||||
void ThreadBenchmark::buildCandidates(int maxThreads)
|
||||
{
|
||||
candidates.clear();
|
||||
int start = std::max(1, maxThreads / 2);
|
||||
for (int threads = start; threads <= maxThreads; ++threads) {
|
||||
candidates.push_back(threads);
|
||||
}
|
||||
}
|
||||
|
||||
float ThreadBenchmark::avgWarmupSecs() const
|
||||
{
|
||||
if (current_index > 0) {
|
||||
return total_warmup_secs / static_cast<float>(current_index);
|
||||
}
|
||||
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::perTestSecs() const
|
||||
{
|
||||
return avgWarmupSecs() + MEASURE_SECS;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::totalEstimatedSecs() const
|
||||
{
|
||||
int count = static_cast<int>(candidates.size());
|
||||
if (count <= 0) return 0.0f;
|
||||
float completedTime = total_warmup_secs
|
||||
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
int remaining = count - current_index;
|
||||
float remainingTime = static_cast<float>(remaining) * (avgWarmupSecs() + MEASURE_SECS)
|
||||
+ static_cast<float>(std::max(0, remaining - 1)) * COOLDOWN_SECS;
|
||||
return completedTime + remainingTime;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::elapsedSecs() const
|
||||
{
|
||||
float completed = total_warmup_secs
|
||||
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
return completed + phase_timer;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::progress() const
|
||||
{
|
||||
float total = totalEstimatedSecs();
|
||||
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
|
||||
}
|
||||
|
||||
void ThreadBenchmark::resetStabilityTracking()
|
||||
{
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
}
|
||||
|
||||
bool ThreadBenchmark::active() const
|
||||
{
|
||||
return phase != Phase::Idle && phase != Phase::Done;
|
||||
}
|
||||
|
||||
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
|
||||
float deltaSeconds,
|
||||
double poolHashrate10s)
|
||||
{
|
||||
ThreadBenchmarkUpdate update;
|
||||
if (!benchmark.active()) return update;
|
||||
|
||||
benchmark.phase_timer += deltaSeconds;
|
||||
|
||||
switch (benchmark.phase) {
|
||||
case ThreadBenchmark::Phase::Starting:
|
||||
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
|
||||
int threads = benchmark.candidates[benchmark.current_index];
|
||||
update.stopPoolMining = true;
|
||||
update.startPoolMining = true;
|
||||
update.startThreads = threads;
|
||||
benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
benchmark.resetStabilityTracking();
|
||||
benchmark.measure_sum = 0.0;
|
||||
benchmark.measure_samples = 0;
|
||||
} else {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::WarmingUp: {
|
||||
bool pastMin = benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
|
||||
bool pastMax = benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
|
||||
|
||||
if (poolHashrate10s > 0.0) {
|
||||
benchmark.window_sum += poolHashrate10s;
|
||||
benchmark.window_samples++;
|
||||
}
|
||||
benchmark.window_timer += deltaSeconds;
|
||||
|
||||
bool stable = false;
|
||||
if (pastMin && benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS &&
|
||||
benchmark.window_samples > 0) {
|
||||
double currentAverage = benchmark.window_sum / benchmark.window_samples;
|
||||
if (benchmark.prev_window_avg > 0.0) {
|
||||
double change = std::abs(currentAverage - benchmark.prev_window_avg)
|
||||
/ benchmark.prev_window_avg;
|
||||
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
|
||||
benchmark.consecutive_stable++;
|
||||
else
|
||||
benchmark.consecutive_stable = 0;
|
||||
if (benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
|
||||
stable = true;
|
||||
}
|
||||
benchmark.prev_window_avg = currentAverage;
|
||||
benchmark.window_sum = 0.0;
|
||||
benchmark.window_samples = 0;
|
||||
benchmark.window_timer = 0.0f;
|
||||
}
|
||||
|
||||
if (stable || pastMax) {
|
||||
benchmark.total_warmup_secs += benchmark.phase_timer;
|
||||
benchmark.phase = ThreadBenchmark::Phase::Measuring;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
benchmark.measure_sum = 0.0;
|
||||
benchmark.measure_samples = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ThreadBenchmark::Phase::Measuring:
|
||||
if (poolHashrate10s > 0.0) {
|
||||
benchmark.measure_sum += poolHashrate10s;
|
||||
benchmark.measure_samples++;
|
||||
}
|
||||
if (benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
|
||||
int threads = benchmark.candidates[benchmark.current_index];
|
||||
double average = (benchmark.measure_samples > 0)
|
||||
? benchmark.measure_sum / benchmark.measure_samples
|
||||
: 0.0;
|
||||
benchmark.results.push_back({threads, average});
|
||||
if (average > benchmark.optimal_hashrate) {
|
||||
benchmark.optimal_hashrate = average;
|
||||
benchmark.optimal_threads = threads;
|
||||
}
|
||||
benchmark.phase = ThreadBenchmark::Phase::Advancing;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::Advancing:
|
||||
update.stopPoolMining = true;
|
||||
benchmark.current_index++;
|
||||
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
|
||||
benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
} else {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
if (benchmark.optimal_threads > 0) {
|
||||
update.saveOptimalThreads = true;
|
||||
update.optimalThreads = benchmark.optimal_threads;
|
||||
if (benchmark.was_pool_running) {
|
||||
update.startPoolMining = true;
|
||||
update.startThreads = benchmark.optimal_threads;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::CoolingDown:
|
||||
if (benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Starting;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ThreadBenchmark {
|
||||
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
|
||||
|
||||
struct Result {
|
||||
int threads = 0;
|
||||
double hashrate = 0.0;
|
||||
};
|
||||
|
||||
static constexpr float MIN_WARMUP_SECS = 90.0f;
|
||||
static constexpr float MAX_WARMUP_SECS = 300.0f;
|
||||
static constexpr float MEASURE_SECS = 30.0f;
|
||||
static constexpr float COOLDOWN_SECS = 5.0f;
|
||||
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
|
||||
static constexpr float STABILITY_THRESHOLD = 0.05f;
|
||||
static constexpr int STABLE_WINDOWS_NEEDED = 3;
|
||||
|
||||
Phase phase = Phase::Idle;
|
||||
std::vector<int> candidates;
|
||||
int current_index = 0;
|
||||
std::vector<Result> results;
|
||||
float phase_timer = 0.0f;
|
||||
double prev_window_avg = 0.0;
|
||||
double window_sum = 0.0;
|
||||
int window_samples = 0;
|
||||
float window_timer = 0.0f;
|
||||
int consecutive_stable = 0;
|
||||
double measure_sum = 0.0;
|
||||
int measure_samples = 0;
|
||||
int optimal_threads = 0;
|
||||
double optimal_hashrate = 0.0;
|
||||
bool was_pool_running = false;
|
||||
int prev_threads = 0;
|
||||
float total_warmup_secs = 0.0f;
|
||||
|
||||
void reset();
|
||||
void buildCandidates(int maxThreads);
|
||||
float avgWarmupSecs() const;
|
||||
float perTestSecs() const;
|
||||
float totalEstimatedSecs() const;
|
||||
float elapsedSecs() const;
|
||||
float progress() const;
|
||||
void resetStabilityTracking();
|
||||
bool active() const;
|
||||
};
|
||||
|
||||
struct ThreadBenchmarkUpdate {
|
||||
bool stopPoolMining = false;
|
||||
bool startPoolMining = false;
|
||||
int startThreads = 0;
|
||||
bool saveOptimalThreads = false;
|
||||
int optimalThreads = 0;
|
||||
};
|
||||
|
||||
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
|
||||
float deltaSeconds,
|
||||
double poolHashrate10s);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,36 @@
|
||||
#include "mining_pool_panel.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted)
|
||||
{
|
||||
return !alreadyDefaulted && (currentWorker.empty() || currentWorker == "x");
|
||||
}
|
||||
|
||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
|
||||
{
|
||||
for (const auto& addr : addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value)
|
||||
{
|
||||
if (value.empty()) return false;
|
||||
return std::find(savedValues.begin(), savedValues.end(), value) != savedValues.end();
|
||||
}
|
||||
|
||||
const char* defaultPoolUrl()
|
||||
{
|
||||
return "pool.dragonx.is:3433";
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value);
|
||||
const char* defaultPoolUrl();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
+30
-328
@@ -3,6 +3,9 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "mining_tab.h"
|
||||
#include "mining_benchmark.h"
|
||||
#include "mining_tab_helpers.h"
|
||||
#include "mining_pool_panel.h"
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../config/version.h"
|
||||
@@ -43,130 +46,10 @@ static int s_drag_anchor_thread = 0; // thread# where drag started
|
||||
// Earnings filter: 0 = All, 1 = Solo, 2 = Pool
|
||||
static int s_earnings_filter = 0;
|
||||
|
||||
// Thread benchmark state
|
||||
struct ThreadBenchmark {
|
||||
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
|
||||
Phase phase = Phase::Idle;
|
||||
|
||||
std::vector<int> candidates;
|
||||
int current_index = 0;
|
||||
|
||||
struct Result {
|
||||
int threads;
|
||||
double hashrate;
|
||||
};
|
||||
std::vector<Result> results;
|
||||
|
||||
float phase_timer = 0.0f;
|
||||
|
||||
// Warmup: wait at least MIN then check for hashrate stability; cap at MAX.
|
||||
// Laptops need 90s+ for thermal throttling to fully manifest.
|
||||
static constexpr float MIN_WARMUP_SECS = 90.0f;
|
||||
static constexpr float MAX_WARMUP_SECS = 300.0f;
|
||||
static constexpr float MEASURE_SECS = 30.0f;
|
||||
static constexpr float COOLDOWN_SECS = 5.0f;
|
||||
|
||||
// Stability detection — compare rolling 10s hashrate windows.
|
||||
// Require STABLE_WINDOWS_NEEDED consecutive stable readings.
|
||||
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
|
||||
static constexpr float STABILITY_THRESHOLD = 0.05f; // 5% change → stable
|
||||
static constexpr int STABLE_WINDOWS_NEEDED = 3;
|
||||
double prev_window_avg = 0.0;
|
||||
double window_sum = 0.0;
|
||||
int window_samples = 0;
|
||||
float window_timer = 0.0f;
|
||||
int consecutive_stable = 0; // count of consecutive stable windows
|
||||
|
||||
// Measurement: average-based (sustained performance, not peak burst)
|
||||
double measure_sum = 0.0;
|
||||
int measure_samples = 0;
|
||||
|
||||
int optimal_threads = 0;
|
||||
double optimal_hashrate = 0.0;
|
||||
bool was_pool_running = false;
|
||||
int prev_threads = 0;
|
||||
|
||||
// Track actual warmup durations for better time estimates
|
||||
float total_warmup_secs = 0.0f;
|
||||
|
||||
void reset() {
|
||||
phase = Phase::Idle;
|
||||
candidates.clear();
|
||||
current_index = 0;
|
||||
results.clear();
|
||||
phase_timer = 0.0f;
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
measure_sum = 0.0;
|
||||
measure_samples = 0;
|
||||
optimal_threads = 0;
|
||||
optimal_hashrate = 0.0;
|
||||
was_pool_running = false;
|
||||
prev_threads = 0;
|
||||
total_warmup_secs = 0.0f;
|
||||
}
|
||||
|
||||
void buildCandidates(int max_threads) {
|
||||
candidates.clear();
|
||||
// Start at half the cores — lower counts are rarely optimal and
|
||||
// testing them first would waste time warming up the CPU before
|
||||
// reaching the thread counts that actually matter.
|
||||
int start = std::max(1, max_threads / 2);
|
||||
for (int t = start; t <= max_threads; t++)
|
||||
candidates.push_back(t);
|
||||
}
|
||||
|
||||
/// Average warmup duration based on tests completed so far
|
||||
float avgWarmupSecs() const {
|
||||
if (current_index > 0)
|
||||
return total_warmup_secs / (float)current_index;
|
||||
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f; // initial estimate
|
||||
}
|
||||
|
||||
/// Estimated seconds per test (uses observed warmup average)
|
||||
float perTestSecs() const {
|
||||
return avgWarmupSecs() + MEASURE_SECS;
|
||||
}
|
||||
|
||||
float totalEstimatedSecs() const {
|
||||
int n = (int)candidates.size();
|
||||
if (n <= 0) return 0.0f;
|
||||
// Completed tests use actual time; remaining use estimate
|
||||
float completed_time = total_warmup_secs
|
||||
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
int remaining = n - current_index;
|
||||
float remaining_time = (float)remaining * (avgWarmupSecs() + MEASURE_SECS)
|
||||
+ (float)std::max(0, remaining - 1) * COOLDOWN_SECS;
|
||||
return completed_time + remaining_time;
|
||||
}
|
||||
|
||||
float elapsedSecs() const {
|
||||
float completed = total_warmup_secs
|
||||
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
return completed + phase_timer;
|
||||
}
|
||||
|
||||
float progress() const {
|
||||
float total = totalEstimatedSecs();
|
||||
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
|
||||
}
|
||||
|
||||
void resetStabilityTracking() {
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
}
|
||||
};
|
||||
static ThreadBenchmark s_benchmark;
|
||||
|
||||
bool IsMiningBenchmarkActive() {
|
||||
return s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
|
||||
s_benchmark.phase != ThreadBenchmark::Phase::Done;
|
||||
return s_benchmark.active();
|
||||
}
|
||||
|
||||
// Pool mode state
|
||||
@@ -178,59 +61,6 @@ static bool s_pool_state_loaded = false;
|
||||
static bool s_show_pool_log = false; // Toggle: false=chart, true=log
|
||||
static bool s_show_solo_log = false; // Toggle: false=chart, true=log (solo mode)
|
||||
|
||||
// Get max threads based on hardware
|
||||
static int GetMaxMiningThreads()
|
||||
{
|
||||
int hw_threads = std::thread::hardware_concurrency();
|
||||
return std::max(1, hw_threads);
|
||||
}
|
||||
|
||||
// Format hashrate with appropriate units
|
||||
static std::string FormatHashrate(double hashrate)
|
||||
{
|
||||
char buf[64];
|
||||
if (hashrate >= 1e12) {
|
||||
snprintf(buf, sizeof(buf), "%.2f TH/s", hashrate / 1e12);
|
||||
} else if (hashrate >= 1e9) {
|
||||
snprintf(buf, sizeof(buf), "%.2f GH/s", hashrate / 1e9);
|
||||
} else if (hashrate >= 1e6) {
|
||||
snprintf(buf, sizeof(buf), "%.2f MH/s", hashrate / 1e6);
|
||||
} else if (hashrate >= 1e3) {
|
||||
snprintf(buf, sizeof(buf), "%.2f KH/s", hashrate / 1e3);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%.2f H/s", hashrate);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Calculate estimated hours to find a block
|
||||
static double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||
{
|
||||
if (localHashrate <= 0 || networkHashrate <= 0) return 0;
|
||||
double blocksPerHour = 3600.0 / 75.0;
|
||||
double yourShare = localHashrate / networkHashrate;
|
||||
if (yourShare <= 0) return 0;
|
||||
return 1.0 / (blocksPerHour * yourShare);
|
||||
}
|
||||
|
||||
// Format estimated time
|
||||
static std::string FormatEstTime(double est_hours)
|
||||
{
|
||||
char buf[64];
|
||||
if (est_hours <= 0) {
|
||||
return "N/A";
|
||||
} else if (est_hours < 1.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.0f min", est_hours * 60.0);
|
||||
} else if (est_hours < 24.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.1f hrs", est_hours);
|
||||
} else if (est_hours < 168.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.1f days", est_hours / 24.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "~%.1f weeks", est_hours / 168.0);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
static void RenderMiningTabContent(App* app);
|
||||
|
||||
void RenderMiningTab(App* app)
|
||||
@@ -279,9 +109,9 @@ static void RenderMiningTabContent(App* app)
|
||||
if (!s_threads_initialized) {
|
||||
int saved = app->settings()->getPoolThreads();
|
||||
if (mining.generate)
|
||||
s_selected_threads = std::max(1, mining.genproclimit);
|
||||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||||
else if (saved > 0)
|
||||
s_selected_threads = std::min(saved, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(saved, max_threads);
|
||||
else
|
||||
s_selected_threads = 1;
|
||||
s_threads_initialized = true;
|
||||
@@ -295,11 +125,11 @@ static void RenderMiningTabContent(App* app)
|
||||
// than threads_active from the xmrig API which lags during restarts.
|
||||
int reqThreads = app->getXmrigRequestedThreads();
|
||||
if (reqThreads > 0)
|
||||
s_selected_threads = std::min(reqThreads, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(reqThreads, max_threads);
|
||||
else if (state.pool_mining.threads_active > 0)
|
||||
s_selected_threads = std::min(state.pool_mining.threads_active, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(state.pool_mining.threads_active, max_threads);
|
||||
} else if (mining.generate && mining.genproclimit > 0) {
|
||||
s_selected_threads = std::min(mining.genproclimit, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,15 +158,8 @@ static void RenderMiningTabContent(App* app)
|
||||
{
|
||||
static bool s_pool_worker_defaulted = false;
|
||||
std::string workerStr(s_pool_worker);
|
||||
if (!s_pool_worker_defaulted && !state.addresses.empty() &&
|
||||
(workerStr.empty() || workerStr == "x")) {
|
||||
std::string defaultAddr;
|
||||
for (const auto& addr : state.addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
defaultAddr = addr.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldDefaultPoolWorker(workerStr, s_pool_worker_defaulted) && !state.addresses.empty()) {
|
||||
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
|
||||
if (!defaultAddr.empty()) {
|
||||
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
|
||||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||||
@@ -368,136 +191,27 @@ static void RenderMiningTabContent(App* app)
|
||||
// Determine active mining state for UI
|
||||
// Include pool mining running state even when user just switched to solo,
|
||||
// so the button shows STOP/STOPPING while xmrig shuts down.
|
||||
bool isMiningActive = s_pool_mode
|
||||
? state.pool_mining.xmrig_running
|
||||
: (mining.generate || state.pool_mining.xmrig_running);
|
||||
bool isMiningActive = IsPoolMiningActive(s_pool_mode,
|
||||
state.pool_mining.xmrig_running,
|
||||
mining.generate);
|
||||
|
||||
// ================================================================
|
||||
// Thread Benchmark state machine — runs pool mining at each candidate
|
||||
// thread count to find the optimal setting for this CPU.
|
||||
// ================================================================
|
||||
if (s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
|
||||
s_benchmark.phase != ThreadBenchmark::Phase::Done) {
|
||||
float dt = ImGui::GetIO().DeltaTime;
|
||||
s_benchmark.phase_timer += dt;
|
||||
|
||||
switch (s_benchmark.phase) {
|
||||
case ThreadBenchmark::Phase::Starting:
|
||||
// Start pool mining at current candidate
|
||||
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
|
||||
int t = s_benchmark.candidates[s_benchmark.current_index];
|
||||
app->stopPoolMining();
|
||||
app->startPoolMining(t);
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
s_benchmark.resetStabilityTracking();
|
||||
s_benchmark.measure_sum = 0.0;
|
||||
s_benchmark.measure_samples = 0;
|
||||
} else {
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::WarmingUp: {
|
||||
// Adaptive warmup: wait for hashrate to stabilize (thermal steady state).
|
||||
// After MIN_WARMUP (90s), compare rolling 10s hashrate windows.
|
||||
// Require 3 consecutive windows within 5% to confirm equilibrium.
|
||||
// Laptops can take 2-3+ minutes for thermal throttling to fully
|
||||
// manifest, so a single stable window isn't sufficient.
|
||||
bool past_min = s_benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
|
||||
bool past_max = s_benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
|
||||
|
||||
// Accumulate samples into current window
|
||||
if (state.pool_mining.hashrate_10s > 0.0) {
|
||||
s_benchmark.window_sum += state.pool_mining.hashrate_10s;
|
||||
s_benchmark.window_samples++;
|
||||
}
|
||||
s_benchmark.window_timer += dt;
|
||||
|
||||
bool stable = false;
|
||||
if (past_min && s_benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS
|
||||
&& s_benchmark.window_samples > 0) {
|
||||
double current_avg = s_benchmark.window_sum / s_benchmark.window_samples;
|
||||
if (s_benchmark.prev_window_avg > 0.0) {
|
||||
double change = std::abs(current_avg - s_benchmark.prev_window_avg)
|
||||
/ s_benchmark.prev_window_avg;
|
||||
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
|
||||
s_benchmark.consecutive_stable++;
|
||||
else
|
||||
s_benchmark.consecutive_stable = 0; // reset on instability
|
||||
if (s_benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
|
||||
stable = true;
|
||||
}
|
||||
// Shift window
|
||||
s_benchmark.prev_window_avg = current_avg;
|
||||
s_benchmark.window_sum = 0.0;
|
||||
s_benchmark.window_samples = 0;
|
||||
s_benchmark.window_timer = 0.0f;
|
||||
}
|
||||
|
||||
if (stable || past_max) {
|
||||
s_benchmark.total_warmup_secs += s_benchmark.phase_timer;
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Measuring;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
s_benchmark.measure_sum = 0.0;
|
||||
s_benchmark.measure_samples = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ThreadBenchmark::Phase::Measuring:
|
||||
// Sample average hashrate — reflects sustained (thermally throttled) performance
|
||||
if (state.pool_mining.hashrate_10s > 0.0) {
|
||||
s_benchmark.measure_sum += state.pool_mining.hashrate_10s;
|
||||
s_benchmark.measure_samples++;
|
||||
}
|
||||
if (s_benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
|
||||
int t = s_benchmark.candidates[s_benchmark.current_index];
|
||||
double avg = (s_benchmark.measure_samples > 0)
|
||||
? s_benchmark.measure_sum / s_benchmark.measure_samples
|
||||
: 0.0;
|
||||
s_benchmark.results.push_back({t, avg});
|
||||
if (avg > s_benchmark.optimal_hashrate) {
|
||||
s_benchmark.optimal_hashrate = avg;
|
||||
s_benchmark.optimal_threads = t;
|
||||
}
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Advancing;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::Advancing:
|
||||
if (s_benchmark.active()) {
|
||||
auto benchmarkUpdate = AdvanceThreadBenchmark(
|
||||
s_benchmark, ImGui::GetIO().DeltaTime, state.pool_mining.hashrate_10s);
|
||||
if (benchmarkUpdate.stopPoolMining) {
|
||||
app->stopPoolMining();
|
||||
s_benchmark.current_index++;
|
||||
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
|
||||
// Cool down before next test to reduce thermal throttling bias
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
} else {
|
||||
// Done — apply optimal thread count
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
if (s_benchmark.optimal_threads > 0) {
|
||||
s_selected_threads = s_benchmark.optimal_threads;
|
||||
app->settings()->setPoolThreads(s_selected_threads);
|
||||
app->settings()->save();
|
||||
}
|
||||
// Restart mining if it was running before, using optimal count
|
||||
if (s_benchmark.was_pool_running && s_benchmark.optimal_threads > 0) {
|
||||
app->startPoolMining(s_benchmark.optimal_threads);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::CoolingDown:
|
||||
// Idle pause — let CPU temps drop before starting next test
|
||||
if (s_benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (benchmarkUpdate.saveOptimalThreads) {
|
||||
s_selected_threads = benchmarkUpdate.optimalThreads;
|
||||
app->settings()->setPoolThreads(s_selected_threads);
|
||||
app->settings()->save();
|
||||
}
|
||||
if (benchmarkUpdate.startPoolMining) {
|
||||
app->startPoolMining(benchmarkUpdate.startThreads);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,10 +404,7 @@ static void RenderMiningTabContent(App* app)
|
||||
ImDrawList* dl2 = ImGui::GetWindowDrawList();
|
||||
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
|
||||
std::string currentUrl(s_pool_url);
|
||||
bool alreadySaved = false;
|
||||
for (const auto& u : app->settings()->getSavedPoolUrls()) {
|
||||
if (u == currentUrl) { alreadySaved = true; break; }
|
||||
}
|
||||
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolUrls(), currentUrl);
|
||||
if (btnHov) {
|
||||
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
|
||||
StateHover(), 4.0f * dp);
|
||||
@@ -875,10 +586,7 @@ static void RenderMiningTabContent(App* app)
|
||||
ImDrawList* dl2 = ImGui::GetWindowDrawList();
|
||||
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
|
||||
std::string currentWorker(s_pool_worker);
|
||||
bool alreadySaved = false;
|
||||
for (const auto& w : app->settings()->getSavedPoolWorkers()) {
|
||||
if (w == currentWorker) { alreadySaved = true; break; }
|
||||
}
|
||||
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
|
||||
if (btnHov) {
|
||||
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
|
||||
StateHover(), 4.0f * dp);
|
||||
@@ -1036,16 +744,10 @@ static void RenderMiningTabContent(App* app)
|
||||
OnSurfaceMedium(), resetIcon);
|
||||
|
||||
if (btnClk) {
|
||||
strncpy(s_pool_url, "pool.dragonx.is:3433", sizeof(s_pool_url) - 1);
|
||||
strncpy(s_pool_url, defaultPoolUrl(), sizeof(s_pool_url) - 1);
|
||||
// Default to user's first shielded (z) address for pool payouts.
|
||||
// Leave blank if no z-address exists yet.
|
||||
std::string defaultAddr;
|
||||
for (const auto& addr : state.addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
defaultAddr = addr.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
|
||||
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
|
||||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user